Conversation
* feat(providers): add GitHub Copilot provider configuration types Define CopilotProviderDefaults with model, reasoning effort, and auth options Include system message injection and CLI path configuration support * feat(providers): add GitHub Copilot community provider integration Implement full provider with session management, streaming, and binary resolution Include comprehensive test coverage and lazy-load SDK pattern * feat(providers): add Copilot provider registration and exports Export CopilotProvider, config parser, and binary resolver utilities Register Copilot provider in community providers initialization * test(e2e): add GitHub Copilot provider smoke and abort tests Include streaming verification, token validation, and interrupt handling Verify connectivity, output plumbing, and session management * feat(copilot): add reasoning effort alias and session timeout improvements Map Archon `max` effort to SDK `xhigh` and extend sendAndWait timeout to 60min Handle fork-session requests with fresh session creation fallback * feat(copilot): add environment variable override support and auto model default Add COPILOT_MODEL env var with envOverrides tracking across config system Update provider to default model to 'auto' and enhance settings UI * docs(copilot): clarify session option handling comment * feat(copilot): add MCP, skills, agents, and structured output support Implement full Copilot SDK feature translation including tool restrictions, session config assembly, and best-effort JSON parsing for structured output * feat(copilot): respect useLoggedInUser to override env token test(copilot): cover env token precedence and override behavior * refactor(copilot): remove isCopilotModelCompatible and model-ref delete model-ref.ts and model-ref.test.ts update copilot index and registration to drop isCopilotModelCompatible export * fix(struct-out): enforce object requirement for structured output parsing return undefined if parsed JSON is not an object add tests covering non-object JSON in structured output parsing * feat(copilot): add isExecutableFile check for Copilot binary implement isExecutableFile using stat/access and use it in path resolution update errors to reference executable file and chmod guidance * feat(copilot): add PATH lookup for copilot binary resolution export resolveFromPath and prefer PATH result when executable * ci(workflows): migrate and add Copilot CI workflows - rename e2e-copilot-abort.yaml to test-workflows/e2e-copilot-abort.yaml - add e2e-copilot-all-features.yaml and relocate smoke workflow to test-workflows * refactor(shared): centralize structured-output parsing and skills update providers to re-export shared implementations expose shared utilities: tryParseStructuredOutput, augmentPromptForJsonSchema * feat(registry): register Copilot community provider update registry tests to cover copilot provider registration verify no collision with built-ins and copilot appears in lists * feat(copilot): defer session error warning and harden abort flow update event-bridge to emit no system chunk on session.error add provider-hardening tests for abort, trim model config and cleanup * ci(workflow): simplify output capture in e2e-copilot-smoke workflow * ci(workflows): restructure Copilot e2e workflows for clarity refactor multiple files into sections for fixtures, demos, and checks * ci(workflow): remove e2e-copilot-all-features workflow * feat(workflows): add e2e-copilot-all-nodes-smoke workflow delete old e2e-copilot-smoke workflow extend Copilot smoke tests to cover all node types and structured outputs * refactor(config): remove envOverrides support and COPILOT_MODEL usage use DEFAULT_AI_ASSISTANT env var to select default ai assistant update tests and docs to reflect new default and env var usage * docs: update Copilot docs and env sample * feat(copilot): implement token precedence for Copilot auth introduce COPILOT_GITHUB_TOKEN and generic GH tokens; track tokenSource reorder provider registration to register Pi before Copilot * feat(copilot): improve binary resolution and skill dir validation use isExecutableFile for vendor and autodetect checks validate skill names to reject absolute or traversal paths * fix: address review feedback on Copilot community provider - Add packages/providers/src/shared/structured-output.test.ts covering augmentPromptForJsonSchema, the happy-path clean parse, fence stripping (both ```json and bare ```), the forward-brace scan recovery for reasoning-model prose preamble, fence + preamble combo, whitespace trimming, invalid JSON, empty input, and the bare-primitive rejection contract (null/number/string/boolean). - Add packages/providers/src/shared/skills.test.ts covering empty/null inputs, non-string and empty-string skipping, missing skills, cwd vs home resolution order, cwd-shadows-home semantics, deduplication, and the name-only contract (rejection of absolute paths, nested paths, and parent traversal). Uses a staged temp HOME so reads are isolated. - Wire both new test files into packages/providers/package.json so they run in CI as separate bun test invocations. - Add `copilot` to the registered-providers list in the validation error example at guides/authoring-workflows.md, add a Copilot bullet to the Model strings section, and add an AI Providers -- Copilot env-var subsection plus DEFAULT_AI_ASSISTANT enumeration to reference/configuration.md. The two duplicate-import HIGH findings from the May 14 review were hallucinations — the imports don't exist in the current branch — so they need no fix. * chore(rebase): resolve semantic conflicts from dev - Update loadMcpConfig import to ../../mcp/config — #1459 (Codex MCP nodes) extracted it out of claude/provider.ts into its own module. - Regenerate bun.lock from current dev (configVersion: 1). Old commits on this branch carried configVersion: 0; rebased forward unchanged but produced different transitive resolution on install (telegram markdown tests fail locally despite identical telegramify-markdown pin). bun install re-adds @github/copilot-sdk on top of the fresh lockfile. * test(copilot): address CodeRabbit feedback on shared/skills tests - Stage the home copy of `delta` in `.agents` (not `.claude`) so the "prefers cwd over home" precedence test actually verifies precedence within `.agents`. Previously the home copy was in `.claude`, which could not have beaten the cwd `.agents` copy regardless of the resolver's behavior. - Add explicit return types on `makeFakeWorld` and the inner `stageSkill` to satisfy the project's strict TS annotation rule. * fix(providers): address remaining Wirasm review items - pi/event-bridge.ts: consolidate the `export-from` + `import-from` pair on shared/structured-output into the idiomatic `import { X }; export { X };` form. The preceding comment already promised "import once for local use and re-export" but the prior order said the opposite. - authoring-workflows.md: add `copilot` to the prose listing of registered providers (the example validation error string below it already includes copilot). * chore(copilot): drop stale "Claude's loadMcpConfig" attribution #1459 (Codex MCP nodes) extracted loadMcpConfig out of claude/provider.ts into a shared mcp/config.ts module. Update the applyMcpServers docblock to reflect that the helper is shared, not Claude-specific. --------- Co-authored-by: Daniel Scholl <daniel.scholl@microsoft.com> Co-authored-by: Rasmus Widing <rasmus.widing@gmail.com>
…1384) * feat(providers): add OpenCode community provider with correct capabilities - Add OpenCode provider using @opencode-ai/sdk - Support both embedded server and external server modes - Implement session resume, MCP, structured output, env injection - Correctly declare capabilities: hooks, skills, agents, toolRestrictions, effortControl, thinkingControl all supported - Add model/agent validation (one required) - Include E2E smoke workflow and registry tests - Update docs with auth guidance and feature table * feat(providers/opencode): remove agent field - use Archon's own agent impl Archon has its own agent implementation and should not delegate to OpenCode's agent profiles. Removed the agent field from: - OpencodeProviderDefaults interface - parseOpencodeConfig parsing - streamOpencodeSession function - Updated capabilities to agents: false Model is now required (no agent fallback). * feat(providers/opencode): enable agents support with adaptation layer - Flip agents capability from false to true - Add agent adaptation layer that maps nodeConfig.agents to OpenCode API: - Agent selection by sorted key order - Model override from agent config - Tools permissions map (deny wins) - Add 4 tests for agent adaptation behavior - Update smoke test to verify agent field works * fix(providers/opencode): address PR review feedback - Fix assert node to fail with exit 1 when pattern not found - Set effortControl/thinkingControl to false (not wired to SDK) - Replace generic 'terminated' with specific crash patterns - Add TODO for health endpoint (SDK limitation) - Fix race condition in releaseEmbeddedRuntime - Call iterator.return on abort in abortableStream - Tighten isOpencodeModelCompatible validation - Add agent field to OpencodeProviderDefaults type * fix(providers/opencode): address Oracle validation issues - Fix race condition: capture runtime instance at acquire time - Add agent field parsing in parseOpencodeConfig - Tighten isOpencodeModelCompatible to trim whitespace - Update registry test for effortControl/thinkingControl * fix(providers/opencode): address all CodeRabbit review feedback - Replace session.create() health check with global.health() (stateless) - Yield terminal result chunk when stream ends before session.idle - Move comment under agent: field in ai-assistants.md - Change 'Inline sub-agents' support to⚠️ Partial - Preserve insertion order in selectPrimaryAgent (remove .sort()) - Remove redundant nodeConfig argument from streamOpencodeSession - Preserve error structure in session.error handler (err.cause) - Consolidate model-ref validation (parseModelRef in registration.ts) - Update test mocks to include global.health() * fix(providers/opencode): address latest CodeRabbit review feedback - Add warning when multiple agents configured (first wins) - Add 2s timeout to global.health() probe - Add TODO for skipped abort test - Consolidate imports in registration.ts - Fix TypeScript error: use deferred pattern for creationPromise * fix(providers/opencode): address remaining PR review feedback - Fix deferred pattern hang: wire both resolve and reject in deferred promise so startup errors propagate to callers (3137799074) - Fix server close leak: decouple server.close() from cache identity check in releaseEmbeddedRuntime (3137799084) - Update TODO reference to follow-up issue #1400 for abort test (3136883117) * fix(providers/opencode): use direct HTTP fetch for health check The SDK's global.health() method only exists in v2, but we import from the root SDK which uses the old client. Switch to direct HTTP fetch to /global/health endpoint for checking existing servers. - Remove global.health from OpencodeClientLike interface - Use fetch() directly with 2s timeout for health check - Update tests to mock fetch for health check scenarios * fix(workflows): bash quoting for linux compatibility * refactor(providers/opencode): decompose provider into focused modules Extract runtime, session, multi-agent, agent-config, agent-fs, and error handling into separate files to reduce provider.ts complexity. Add inline multi-agent e2e workflow and expand test coverage. * Self AI Review suggestion. * chore: update opencode e2e smoke test with hooks coverage + refresh docs Add hook-node to e2e smoke workflow covering PreToolUse/PostToolUse hooks (10 node types total). Switch smoke model to cpamc/minimax. Remove deprecated baseUrl option and refresh feature support table in docs. * chore(providers/opencode): improve abort error logging and multi-agent e2e workflow * test(workflows): use default model for opencode e2e tests Switch from cpamc/minimax to opencode/big-pickle (provider default) for general e2e testing of OpenCode provider. * fix: match homebrew formula to upstream/dev * fix(providers/opencode): address code review findings - Add CHANGELOG.md entry for assistants.opencode provider (#1703) - Elevate silent debug catches to warn level with context (session, multi-agent, runtime) - Preserve error cause chain in retry loop (provider.ts) - Include retry count in final throw message - Fix doc typo: cofnig -> config - Update CLAUDE.md monorepo layout with community/opencode/ * chore: align SDK versions with origin/dev * version downgrade fix. * Add opencode-ai sdk * fix: enable abort test and remove redundant isModelCompatible - Enable skipped abort test with deterministic setTimeout timing - Remove unused isOpencodeModelCompatible function from registration - Remove isModelCompatible test from registry tests - Update bundled defaults with archon-four-role-loop workflow * chore: regenerate bun.lock to sync with package.json after rebase CI was failing on 'lockfile had changes, but lockfile is frozen' — the lockfile was missing the overrides entries (@hono/node-server, flatted, follow-redirects, path-to-regexp, qs) and had a stale @archon/providers version (0.3.9 → 0.3.12) after rebasing onto current dev. Net diff: +11/-8 in bun.lock, no source changes. * chore: regenerate bundled defaults to sync with current commands state CI failed on 'bundled-defaults.generated.ts is stale' after the lockfile fix unblocked the install step. The generated file was 1 line out of date relative to current dev's command set (drift from rebases). Functional diff is +1/-2 (a single trailing-newline difference in one embedded command); full diff is large only because the file inlines all commands as TypeScript strings. This is mechanical — produced by 'bun run generate:bundled' with no other changes. --------- Co-authored-by: cropse <cropse0219@gmail.com> Co-authored-by: Rasmus Widing <rasmus.widing@gmail.com>
* fix: coalesce transient chat status updates * feat(web): improve streaming thinking and tool readability
…es (#1523) When a workflow run is approved/rejected via the Web UI but `tryAutoResumeAfterGate` cannot auto-resume — because there is no `parent_conversation_id`, the parent conversation is gone, or the parent sits on a non-web platform (Slack/Telegram/GitHub/CLI) — the success message said only "Send a message to continue" / "On-reject prompt will run on resume". A web-UI user whose run originated from a terminal has no obvious next step from that text and the run sits in `failed` status. Both approve and reject (on_reject branch) now include the exact `archon workflow resume <runId>` command in the non-auto-resumed response, so the web-UI surface always carries an actionable next step. The auto-resume happy path and the no-on_reject cancellation path are unchanged. The Resume endpoint's CLI hints (covered by #1329) are not touched. Closes #1522. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* experiment(console): scaffold primitives-first web UI spike at /console
Greenfield spike of Archon's web UI built around four primitives — Project,
Run, Workflow, Worktree — to validate a simpler mental model before any
migration. Lives under packages/web/src/experiments/console/, mounted at
/console/* outside the shared Layout so it does not inherit the production
TopNav. ESLint no-restricted-imports scope forbids coupling to @/components,
@/contexts, @/hooks, @/routes, @/stores, @tanstack/react-query so the spike
stays extractable or disposable.
Surface:
- Project rail (Discord-style 44x44 tiles with deterministic hashed colors)
with ALL scope toggle, remove-project via right-click, Add Project dialog.
- Runs view split into Active (rich cards for running/paused, pulsing blue
LiveDot for running + amber for paused) and Recent (compact monospace rows
for completed/failed/cancelled). Attention model: running is attention,
completed is audit trail.
- DraftRunCard — inline "start a run" primitive that lives at the top of
the Active list. Collapsed = thin + Start a new run row; expanded = full
card with workflow picker + context textarea. Same shape as a paused
approval card; N keybind expands.
- ApprovalPanel with ApprovalContext preview — shows the actual last
agent message so users see the question being asked, not just the gate
label. Supports capture_response gates and traditional approve/reject.
- Run detail page — header with live-ticking elapsed, StreamToolbar with
Tool calls / System / Graph toggles persisted to localStorage, stream of
StreamCards (message / tool / artifact / node_transition), state-
sensitive ActionBar (cancel/resume/abandon/re-run). Relative timestamps
(+MM:SS from run start) via a small StreamContext provider.
- RunGraphPanel sidebar — dagre TB layout, parallel nodes side-by-side,
loop/approval/bash/command/script/prompt glyphs, status-derived from
node_transition events, click a node to scroll-into-view.
Skill API (packages/web/src/experiments/console/skills/) is the single
mutation surface: listProjects/getProject/addProjectBy{Url,Path}/
removeProject/listWorkflows/getWorkflowGraph/listWorktrees/listRuns/
getRun/startRun/cancelRun/approveRun/rejectRun/resumeRun/abandonRun/
listMessages. Every UI action calls exactly one verb; internal
orchestrators (CLI, Claude Code skill, future LLM driver) hit the same
contract. startRun hides the legacy conversation coupling as a two-call
createConversation -> runWorkflow sequence.
State layer (store/cache.ts) is a Map + subs + useEntity hook. No React
Query, no Zustand, ~100 LOC. Polling fallback every 3s until SSE lands.
Warm theme scoped to .console-root (theme.css) — espresso surfaces,
tangerine accent reserved for CTAs, ocean-blue running, teal-green
completed/approve, amber paused, warm rose-red failed. Production theme
untouched.
Preview route at /console/_preview renders every status, every origin,
swatches for each token.
Milestones done: M1 scaffold, M2 skill+store+populated feed, M3 run
detail + event stream, M5 DraftRunCard, M3 polish (sticky toolbar,
compact tool cards, relative timestamps, empty/system filter, compact
user chips, graph sidebar). Pending: M4 SSE live updates, M6 polish.
* experiment(console): widen project rail with editable title + locator, fix invalidate-without-reload
Rail goes from 44x44 abbreviation tiles to a 240px sidebar of two-line rows:
small color dot + title + monospace locator (owner/repo from a git URL, last
two path segments otherwise). Title is editable per-project — double-click to
rename, Enter saves, Esc cancels, blank reverts to the API name. Override
persists in localStorage (console:displayName:<id>) via a small useDisplayName
hook so the spike stays self-contained. Right-click still removes.
Also fixes a latent bug in store/cache.ts: invalidate() and refetch() cleared
the cache and notified subscribers but never re-ran the loader, so add/remove
project and the run-action / approval flows all required a page reload to
reflect new state. useEntity now registers its loader and ensureLoad() refires
it on any cleared key that still has an active subscriber.
ProjectTile is left in place — still used by /console/_preview.
* experiment(console): fix startRun — pass platform id to dispatch, recover run id by polling
Two bugs were preventing workflows from launching from the spike:
1. The dispatch call was sending conv.id (DB UUID) where the route looks the
conversation up via findConversationByPlatformId. The lookup silently
returned null, the orchestrator dispatched against an unknown reference,
and no workflow_run was ever created. Fix: pass conv.conversationId (the
web-<ts>-<rand> platform id) to /api/workflows/:name/run. Keep conv.id (the
DB UUID) for the parent-conversation match in the recovery step.
2. POST /api/workflows/:name/run returns { accepted, status } — never a run
id, since the workflow_run row is written asynchronously inside the
orchestrator after the HTTP response returns. The old extractRunId() always
threw. Replace with pollForRun(): fetches /api/dashboard/runs filtered by
codebaseId, matches on parent_conversation_id === conv.id, returns the
first hit. Bound at 30s / 400ms interval to absorb cold-start worktree and
isolation-env setup; timeout message points users to the active list since
the run is almost certainly already running by then.
* experiment(console): make startRun optimistic — dispatch and let the runs feed surface the new run
Submit-button no longer blocks for up to 30s while the orchestrator spins up
worktrees and isolation envs. startRun now does just the two dispatch calls
and returns; the workflow_run row appears in the active list as ambient runs
polling picks it up. DraftRunCard fires an immediate invalidate('runs') after
dispatch to nudge the next refetch instead of waiting up to 3s for the next
poll tick.
Drops pollForRun + the runId return value — callers were the navigate-to-run-
detail path only, which traded one bad UX (30s spinner) for another (forced
context switch away from the runs list right after starting). The active card
that appears within a few seconds is a better affordance.
* experiment(console): port to Archon brand foundation — duotone gradient + Geist
Replace the warm espresso/tangerine palette with the cool charcoal +
brand-magenta-to-teal duotone from the Archon brand standalone. All changes
remain scoped under `.console-root` so the production /app surface is
untouched.
theme.css
- Surfaces shift hue 40° → 265° (warm → cool charcoal)
- Accent tokens point at --brand-magenta; --success uses --brand-teal so
affirmative reads as brand
- --brand-gradient + .brand-text / .brand-bar / .brand-bar-soft utilities
added (gradient-soft is the translucent wash used for selected states)
- --accent-ring set to 30% alpha magenta, matching the brand spec
- Geist + Geist Mono loaded from Google Fonts on console route mount
only; .console-root font-family override + higher-specificity .font-mono
rule beat Tailwind v4's @theme inline literal
Components
- ConsoleApp wordmark: .brand-text on "Archon"
- DraftRunCard: 4px gradient strip as an absolute child (keeps the card's
overflow:visible so the workflow picker dropdown can escape); Start run
button background is the duotone bar
- FilterChips: active filter shows a 2px gradient underline pill
- ProjectRail: ALL projects pill now uses brand-bar-soft instead of the
chunky 2px ring with offset
* experiment(console): brand the run detail page
The first brand pass cascaded surfaces + accents into the detail view via
tokens but never threaded the gradient itself through, leaving the timeline
visually flat. This adds three brand moments:
RunDetailHeader
- 1px brand-gradient strip along the bottom edge (replaces the flat
border-border line) anchors the detail view in the same way the
DraftRunCard strip anchors the runs feed
- Run id renders with .brand-text so the focal piece of mono data carries
the duotone
StreamCard
- YOU pill: accent-soft background + brand-magenta text
- AGENT pill: success-soft background + brand-teal text
Role pills now read as the brand duotone across every exchange — magenta
for user (presence/authorship), teal for agent (execution/affirmative)
RunGraphPanel
- Same 1px gradient strip under the GRAPH label; bumps the label color
from tertiary to secondary so the panel header doesn't disappear
theme.css
- Adds --running-soft / --success-soft / --warning-soft / --error-soft
translucent companions for status colors (StreamCard now consumes
--success-soft; the others are there for symmetry)
* fix(experiment/console): surface tool calls from workflow_events
Two independent bugs caused tool calls to never render on the run detail
page despite the toggle being on.
primitives/event.ts
- Server emits `tool_called` / `tool_completed`; the normalizer matched
`tool_started` (a name that's never written). Result: 43 tool_called
events fell through to the text-fallback branch and rendered as
junk-string placeholders elsewhere
- Field names were also wrong: read `toolName` / `args` / `durationMs`
instead of the snake_case `tool_name` / `tool_input` / `duration_ms`
actually present in the JSONB payload, so the few tool_completed
events that did match the branch produced empty entries that
downstream filters dropped
components/RunStream.tsx
- Even with the normalizer fixed, RunStream explicitly skipped
`tool_call` events under the assumption that conversation metadata
is canonical. That's true for Claude (the SDK persists into
message.metadata.toolCalls) but false for Pi / Codex / bash nodes,
which only emit workflow events. Now: if no message carries inline
tool calls, the paired workflow tool events are surfaced instead.
Pairing matches each tool_called to the next unclaimed tool_completed
in the same step so the duration shows correctly.
routes/RunDetailPage.tsx
- Toolbar `toolCallCount` mirrors the same source-of-truth rule so the
"X tool calls" header counts the rendered events, not just the
(empty) inline metadata
* fix(experiment/console): tab bar for Log/Graph, wire System toggle, fix subfoldered workflow 404
Detail page now has a Log / Graph tab pair instead of a fixed-width log
with an optional right-rail graph. Both views get the full main content
area (next to the project rail); switching between them is a toggle, not
a side-by-side compromise.
StreamToolbar
- Hosts the tab pair (Log / Graph) on the left with the gradient
underline indicating the active tab
- "X messages · Y tool calls" + Tool calls / System checkboxes only
render when the Log tab is active — irrelevant in Graph view
RunGraphPanel
- Drops the fixed 420px aside chrome; renders as full-width content
- Bigger node dimensions (160×40, 56/20 sep) for the larger canvas
- Returns to centered overflow-auto when content exceeds viewport
RunDetailPage
- `view: 'log' | 'graph'` state persisted to localStorage
- Layout switches single-view; Log view drops the 820px max-width so
the stream uses the full main area
- Clicking a graph node switches to Log and scrolls to that node's
transition
System toggle (the second half of the fix)
- workflow_started / workflow_completed / workflow_failed were
falling through to the text-fallback branch, rendering as junk
`workflow_started — {payload}` strings
- Added SystemEvent kind + explicit branch in `toRunEvent`; surfaced
in RunStream as compact rows behind the System toggle
- Error events also flow into the same system bucket
Graph 404 fix
- The single-fetch `/api/workflows/:name` endpoint doesn't recurse
into `.archon/workflows/<subdir>/`; subfoldered workflows like
`maintainer/maintainer-review-pr.yaml` were unreachable
- `getWorkflowGraph` now goes through the list endpoint (which does
recurse) and filters by name. One extra row of JSON, but the graph
now resolves for every workflow Archon knows about
* feat(experiment/console): live updates via SSE, drop 3s polling
Replaces the per-page 3s setInterval polling loops in RunsPage and
RunDetailPage with subscriptions to the server's existing SSE streams.
Events flow through the existing cache: an SSE message invalidates the
relevant cache keys, useEntity refetches authoritative state, the UI
re-renders. No partial in-memory event-payload merging — keeps the wire
shape decoupled from React state.
lib/sse.ts
- useDashboardSSE subscribes to /api/stream/__dashboard__ and
invalidates runs:* (and run:<id> if the event carries a runId) on
workflow_status / dag_node events. Mounted from RunsPage.
- useRunStreamSSE subscribes to /api/stream/<conversationPlatformId>
and invalidates run:<id> + messages:<convId> on text / tool_call /
tool_result / workflow_* events. A 100ms coalesce timer dedupes
bursts from streamed text. No-ops while the conversation id is
still null (e.g. before the run detail loads).
RunsPage
- Drops the 3s setInterval that re-fetched listRuns; calls
useDashboardSSE instead.
RunDetailPage
- Drops the 3s setInterval that re-fetched getRun + listMessages;
calls useRunStreamSSE with the platform conversation id.
EventSource auto-reconnects on transient failures, so no explicit
recovery logic is needed; permanent close happens at unmount.
* feat(experiment/console): make System toggle reveal real diagnostic content
The toggle was technically working but only added two thin rows
(workflow_started / workflow_completed) for Pi-driven runs that lack
system-role messages. Functional but invisible. This pass turns it into
the framework-chatter view it should always have been.
What System now reveals
- Workflow lifecycle: workflow_started / workflow_completed /
workflow_failed (existing, now styled to stand out)
- Skipped-node reasons: when a node is skipped, an inline second line
on the NodeDivider shows `reason when_condition · expr ...` — catches
DAG-branching surprises without making the user open the YAML
- Workflow dispatch metadata: assistant messages with
`category: workflow_dispatch_status` (carrying a workflowDispatch
blob) now collapse into a compact 'Workflow dispatch' system row
displaying the workflow name, instead of being rendered as agent
prose. Same for any message whose metadata.category starts with
workflow_ or system_
- Empty / no-signal messages: previously dropped by isMeaningful();
now surface as 'Noise' rows so the timeline is gap-less and SDK
plumbing chatter is visible
Styling
- System rows now use brand-teal for the pill label + a translucent
teal top hairline (instead of a flat charcoal border on all sides).
Border colors land via inline style because the console's
.console-root * { border-color: var(--border) } rule outweighs
Tailwind utility-class color in the cascade; this finally makes
border-success/30 and friends paint the intended hue too
Cleanup
- StreamCard kind styles now own their full border (width + sides +
color) rather than splitting between the base class and a partial
override
- message.ts exports isSystemCategory + WorkflowDispatchMeta so
RunStream can keep the rendering decision local
- event.ts NodeTransitionEvent carries skipReason + skipExpr;
NodeDivider accepts them and renders only when showDetail is true
* feat(experiment/console): cost on cards + reject-with-reason expander
Cost on cards
- Read `metadata.total_cost_usd` into a typed `Run.costUsd: number | null`
- formatCost picks precision by magnitude: $24.35 / $0.023 / $0.0082
- Surfaces on RecentRunRow (between elapsed and origin badge), on
ActiveRunCard (between origin and elapsed), and on the run detail
header (between origin and elapsed). Hidden when null
- typeof === 'number' guard so demo runs without the field don't blow
up at .toFixed()
Reject-with-reason
- ApprovalPanel now has two distinct flows instead of one shared field
+ Approve / Continue: one click, single-line input above for an
optional comment captured as $<node-id>.output
+ Reject: two-step. First click reveals a 3-row textarea with a red
"REASON FOR REJECTING · REQUIRED" label; confirm only enables
with non-empty text
- Cmd+Enter confirms reject, Esc cancels back to idle
- Reduces accidental rejects (which previously fired on any click of
a single button when the input happened to be non-empty) and makes
the reviewer's reasoning explicit and unavoidable
* feat(experiment/console): per-project env vars dialog
A gear icon on each project row in the rail (visible on hover / always
on the selected row) opens an EnvVarsDialog modal that lists, adds, and
removes per-project environment variables. Wires straight into the
existing GET/PUT/DELETE /api/codebases/:id/env endpoints.
Design notes
- The server never returns values, only keys — the UI mirrors that
constraint (no "reveal" affordance, no edit-in-place). To rotate a
secret the user adds a new value at the same key; the server
overwrites
- Key input auto-uppercases for the conventional ENV_VAR_NAME look;
value input uses type=password so it doesn't shoulder-surf
- Cache invalidates on every dialog open so external edits (CLI, other
web sessions) show up — without it the in-memory cache pinned the
stale empty list across close/reopen
- skill.listEnvVarKeys / setEnvVar / deleteEnvVar live in a new
skills/envVars.ts module, exported through skills/index.ts to match
the existing skill-verb surface
* feat(experiment/console): artifact tab with sidebar + viewer
Adds a third tab on the run detail page that lets you browse and read the
files a run wrote to disk — the new go-to surface for plans, reports, PR
diffs, and synthesis docs that workflows produce as their actual output.
Server: GET /api/runs/:runId/artifacts
- Walks the run's artifact directory (recursively, dotfiles skipped)
- Returns { files: [{ path, size, modifiedAt }] }
- Needed because workflow_artifact events are empty for nearly every
run we have — bash/script nodes write straight to $ARTIFACTS_DIR
without emitting an event, so an event-driven file list shows nothing
- Reuses the same owner/repo derivation + path-escape guards the
existing /api/artifacts/:runId/* handler uses
Client: ArtifactPanel
- 260px sidebar lists every file with size + parent-dir hint; clicking
a row loads it into the main viewer
- Viewer renders .md / .mdx through react-markdown + GFM + rehype-
highlight (same stack the old UI used), everything else as
pre-formatted monospace text
- Auto-selects the first file on mount so the tab isn't empty
- "open raw ↗" link in the file header for downloads or PR pasting
- Empty-state copy points at $ARTIFACTS_DIR so users understand what
fills the panel
StreamToolbar
- Tabs now accept an optional count; Artifacts shows it ("ARTIFACTS 7")
so users can tell at a glance whether a run produced anything
RunDetailPage
- The artifact-list useEntity is hoisted above the early returns so
React's hook order stays stable (the obvious-in-retrospect bug that
hit the first attempt — early returns after running detail-related
hooks meant the artifacts hook didn't fire on the loading render)
- Cache key is K.artifacts(runId), shared between the tab badge and
the panel so navigating to the tab doesn't refetch
* feat(console + server): file upload on DraftRunCard
Server
- /api/workflows/:name/run now accepts multipart/form-data alongside the
existing application/json. conversationId + message + files[] (max 5,
≤10 MB each). Body schema dropped from the OpenAPI route config so
@hono/zod-openapi doesn't try to validate multipart against the JSON
shape — same pattern sendMessageRoute uses. Handler manually branches
on content-type
- persistUploadedFiles helper lifted out of sendMessageRoute so both
routes go through the same validate-write-rollback logic. Returns
either { ok: true, savedFiles, uploadDir } or a structured error the
caller forwards via apiError. sendMessageRoute is untouched for this
pass; could be refactored to use the helper later
- extraContext.attachedFiles + filesToCleanup are passed straight to
dispatchToOrchestrator so cleanup happens inside the lock handler,
after handleMessage completes — matches the freeform-message flow
Client
- skill.startRun gains an optional files: File[]. With files, posts
multipart (browser-set boundary); without, keeps the JSON path
- DraftRunCard handles three input paths the chat input has always
handled: drag-and-drop on the whole card, paste of clipboard images
inside the textarea, and a paperclip button that opens the file
picker. Same MAX_FILES=5 and MAX_FILE_BYTES=10 MB caps the server
enforces, surfaced as inline errors
- File chips render above the start row with name + size + remove (X).
Drag-over shows a brand-gradient-soft overlay with a "drop files to
attach" pill so the affordance is obvious without persistent chrome
- Collapse / submit both clear the file list so reopening the card
starts clean
* feat(experiment/console): open-in-IDE, rerun, SSE-drop safety net
Three tier-2 affordances that each accelerate the iteration loop without
adding chrome.
Open in IDE
- vscode://file/<workingPath> button on ActiveRunCard (hover), every
RecentRunRow (hover), and RunDetailHeader (always visible)
- Hidden when /api/health reports is_docker=true. The first request
defaults isDocker to true so a flash of broken links inside Docker
never happens — matches the old UI's safer default
- new lib/health.ts exposes useIsDocker() (cached via useEntity on the
'health' key so all callers share one fetch) and openInIde(path)
which normalises backslashes on Windows paths the same way the old
Header.tsx did
Rerun
- ↻ button on completed/failed/cancelled RecentRunRows. Navigates to
/console/p/<id>?rerun=1&workflow=<name>&message=<userMessage> with
URLSearchParams so spaces / unicode survive
- DraftRunCard watches searchParams: when rerun=1 arrives (whether by
fresh mount or by within-component navigation) it expands the card,
fills the workflow picker + textarea, then strips the params via
setSearchParams(..., { replace: true }) so a reload doesn't re-fire
- Deliberately depends on [searchParams] not [] — the rerun click
typically lands while DraftRunCard is already mounted (same project
route, search-param-only change). The empty-deps version was the
bug that made the first attempt look like nothing happened
SSE-drop safety net
- 30s setInterval on RunDetailPage that invalidates K.run(runId) +
K.messages(convId) while status is running or paused
- Stops automatically the moment status flips terminal, so it's not
polling proper — just a heartbeat refetch that catches dropped SSE
streams (network hiccup, mobile sleep/wake) without us noticing
- Replaces nothing — the existing useRunStreamSSE keeps streaming
when the connection is alive; this is purely a "if we missed the
terminal event, find it within 30s" insurance
* fix(experiment/console): project rail — selection visible, identity vs status, real path locator
Five compounding issues in the project rail, all addressed.
1. Routing param read (the load-bearing bug)
ProjectRail mounts outside the inner <Routes> (it's sibling to <main>
in ConsoleApp), so useParams() returns {} for it. `scope` was
always 'all'; the ALL PROJECTS button was always aria-pressed=true;
the selected ProjectRow never received selected:true and therefore
never showed the ring or background. Fix: useLocation() + a regex
pull on `/console/p/:id`.
2. Selection is now unmistakable
Each row paints a 4px brand-gradient left strip + bg-surface-elevated +
brighter title when selected. Replaces the magenta ring (which was
invisible against the dark inset background even when it did fire).
The gradient strip rounds at the corners via rounded-l-md so we
don't need overflow-hidden on the row — which had been clipping the
⋯ menu dropdown.
3. Identity vs status disambiguated
The hash-coloured dot was identity (project tile color) but read as
a status indicator. Replaced with a 20×20 rounded square showing the
project's first letter on the hash-coloured background — clearly a
"this is which project" affordance, can't be confused with status.
4. Activity status, when it exists
Right-side dot is now real: pulsing blue when the project has a
running run, pulsing amber when paused, solid red when only failed
runs are recent. Idle projects show nothing. Sources data from the
shared K.runs('all') cache (so the dashboard SSE invalidation we
already have keeps it live; no extra fetch). Priority: running >
paused > failed-only, so a project with one running and one failed
run reads as "running", not "broken".
5. Locator below the name = the actual local path
formatProjectLocator now returns `~/path/to/project` (homedir
shortened). The old `owner/repo` derivation was identical to the
project name for github projects, so the row read as duplicated
text. After rename, the path stays as a stable identity anchor —
which is what the user wanted: "rename a project but still show the
path below."
Bonus fixes
ALL PROJECTS button: same selection treatment as project rows
(strip + elevated bg), sentence case label ("All projects"), uses
an `∗` avatar in a small square — visually consistent with rows.
Remove project is now discoverable: ⋯ menu button on hover (always
visible on the selected row), opens a small dropdown with "Remove
project". Right-click still works for power users and now also
opens the same menu.
Add project hover treatment normalised to border-bright/surface-hover
to match the rest of the rail (used to be magenta).
* refactor(experiment/console): drop avatar + activity dot from project rail
Both added noise more than signal:
- The first-letter avatar carried no information for owner/repo names
(we were rendering the owner's first letter). Removed it entirely
rather than try to derive something cleverer
- The right-side activity dot lit up red for any project with a
failed run in recent history. That's a thing that happened, not
something the user needs to act on from the rail. Removed
The rail row is now: optional gradient strip when selected, title,
path subtitle, hover actions (gear + ⋯). Selection is still
unmistakable via the brand strip + elevated background + brighter
title color. Width is reclaimed for the path (Widinglabs/sasha-demo's
full ~/Projects/mine/sasha now fits where it was truncated before).
Also drops the matching ∗ avatar from the "All projects" row for
consistency, and the K.runs('all') fetch + deriveActivityByProject
helper that only existed to feed the now-gone status dots.
* feat(console + old ui): real logo, drop spike chrome, cross-UI switch buttons
Console header
- Replace text-only "Archon" with the actual shield mark from
packages/web/public/favicon.png (the existing brand mark) +
gradient wordmark
- Drop the "spike" badge — the experiment is real enough now; the
"console" tag stays as a "this is a separate surface" hint
- Drop the stray "m2 populated" telemetry text in the right slot;
replaced with a small "← Old UI" link so users always have an
escape hatch back to the classic chrome
Old UI TopNav
- Add a gradient "Try the new console →" CTA between the last tab
and the version readout. Inline-styled with the brand
magenta → violet → teal gradient because the old UI's token set
doesn't include the brand-gradient variables (those live in the
console-scoped theme.css)
- Sized to read as a primary CTA without dominating the nav. Arrow
nudges 2px on hover for an inviting affordance
* tweak(old ui): rename console CTA to 'Try the new console UI'
* fix(experiment/console + server): satisfy validate suite after rebase
Type-check
- Demo run factories in RunsPage and PreviewPage now include
costUsd: null so the test fixtures match the Run type that was
extended with the new cost field
- startRun's HttpError throw on multipart failure now passes the
URL path as the 2nd arg (HttpError takes status/path/body) so
the upload-error path constructs correctly
Server test
- /api/workflows/:name/run only forwards the message metadata 4th
arg to addMessage when files are present, so the JSON path keeps
the 3-arg signature the existing api.workflow-runs.test asserted
Format
- prettier --write on eslint.config.mjs and theme.css
Telegram-markdown blockquote tests are 3 pre-existing failures on dev
(verified by checking out dev's adapters/ before the run) — unrelated
to this PR's scope.
* fix(console): correct silent invalidate + recover errored entries (C1+C2)
The cache's invalidate(prefix) checked `key === prefix || key.startsWith(`${prefix}:`)`
so passing 'runs:' looked for 'runs::' — three callers (ApprovalPanel
approve/reject, RunActionBar cancel/resume/abandon) silently did nothing,
and the runs feed only refreshed on the next SSE event. Drop the trailing
colon at the three sites.
Separately, errored cache entries lived only in the `errors` Map, but
invalidate() walked `cache.keys()` only — so a failed fetch was stuck
until full page reload. Extend the walk to both maps so recovery works.
* fix(server): guard new artifacts route + register OpenAPI (C3+I1+I3+I4)
Convert GET /api/runs/:runId/artifacts from raw app.get() to
registerOpenApiRoute against a typed schema (ArtifactFile +
ListArtifactsResponse in workflow.schemas.ts). The route was the only
recently-added endpoint bypassing the project's OpenAPI rule
(CLAUDE.md L25) without a constraint that justifies it — the response
is plain JSON of a fixed shape. Generated types now include it, so
skills/runs.ts re-exports the schema type instead of maintaining a
parallel hand-written interface (I3).
Other guards on the same handler:
- I1: defense-in-depth path-containment check on the resolved
artifact directory. A maliciously crafted codebase name (`..` in
owner/repo) would have escaped ARCHON_HOME; now blocked with a
400 + artifacts.path_escape_blocked log
- I4: getCodebase() now wrapped in try/catch, mirroring the
getWorkflowRun() block above it. DB errors produce a logged 500
instead of an unlogged crash
- I3: stat() error swallow narrowed — ENOENT/EACCES are skipped
(file deleted mid-walk, permission flip) but unknown errors now
propagate to the outer artifacts.walk_failed log + 500 response,
so we never return a half-list silently
* fix(console): real defects from review (CR-1..CR-5, CR-7, CR-9, I2, I5)
- AddProjectDialog: import FormEvent from 'react' instead of relying on
the ambient React namespace which isn't actually imported here. Real
type bug in strict-mode setups (CR-1)
- lib/sse: route EventSource opens through SSE_BASE_URL so dev bypasses
the Vite proxy. The proxy buffers SSE; bare paths reintroduce the
buffering useSSE already worked around in the old UI (CR-2)
- DraftRunCard: guard Enter-submit during IME composition. Without the
e.nativeEvent.isComposing check, Japanese/Chinese/Korean candidate
selection dispatches the run prematurely (CR-3)
- display-name: wrap localStorage in try/catch. Private-browsing modes
throw SecurityError and crashed the rail row on mount (CR-4)
- ActiveRunCard: add role/tabIndex/onKeyDown so the card is operable
with Enter/Space, matching RecentRunRow which already had this (CR-5)
- eslint.config: harden import-restriction patterns. * → ** so nested
paths (@/components/layout/foo) can't slip past, and the @/lib/api
restriction now applies to all named imports rather than only the
default. Generated types from @/lib/api.generated are still allowed
via a different module path (CR-7)
- NodeDivider: only emit the scroll-anchor id on 'started' transitions
so multiple transitions for the same node don't produce duplicate
ids in the DOM. The graph 'jump to node' still works (it lands on
the entry point, which is the right target anyway) (CR-9)
- primitives/workflow: toWorkflow now preserves 'global' as a distinct
source. Previously `raw.source === 'project' ? 'project' : 'bundled'`
silently demoted home-scoped (~/.archon/workflows) workflows to the
bundled badge + sort rank (I2)
- lib/sse: SSE onerror logs at console.warn when readyState is CLOSED,
so dropped streams aren't completely silent (I5)
* fix(console): SPA nav + nullable project type + truncate multipart errors (CR-6, CR-8, S4)
- TopNav and ConsoleApp: swap <a href> for <Link to> on the cross-UI
switch buttons. Same React app, same DOM tree, no need to trigger a
full reload (CR-6)
- RunsPage and RunDetailPage: useEntity<Project | null> instead of
useEntity<Project> with a Promise.resolve(null as unknown as Project)
loader. Removes the type cast and keeps downstream readers honest
about nullability — added explicit `if (detail === null)` guard in
RunDetailPage where the type narrowed (CR-8)
- skills/startRun: multipart error path now truncates to 200 chars
matching requestJson, so an HTML 502 body doesn't land in the error
toast as raw markup (S4 from multi-agent review)
* test(server): 6 tests for GET /api/runs/:runId/artifacts (I6)
Cover the branches that can be tested without mocking fs/promises:
- 400 for invalid run ids that fail the [A-Za-z0-9_-] regex guard
- 404 when the workflow run does not exist
- 200 + empty files when run has no codebase_id (orphan)
- 200 + empty files when codebase name lacks owner/repo shape
- 500 when the codebase DB lookup throws
- 400 when the resolved artifact dir escapes ARCHON_HOME
(defense-in-depth path-containment guard)
Multipart-dispatch unit testing would require mocking c.req.parseBody —
deferring; the end-to-end multipart round-trip was verified during
development against a real workflow with server-side
`run_workflow.files_uploaded` log + upload dir written under
~/.archon/artifacts/uploads/. The existing JSON-path tests continue
to assert addMessage is called with 3 args (not 4) for the JSON branch.
Tweaks to the test harness:
- paths mock now exports getArchonHome and getRunArtifactsPath so the
new handler can resolve a deterministic test path
- getCodebase is now a top-level mockGetCodebase that supports
.mockImplementationOnce per-test
* docs: register new artifacts endpoint + clean stale references (I7+C4+S5)
CLAUDE.md
- Add GET /api/runs/:runId/artifacts to the API Endpoints section
- Extend the directory tree to mention packages/web/src/experiments/
(lint-guarded in-repo spike directory, currently hosting /console)
- Update the registerOpenApiRoute rule to enumerate the two narrow
exceptions: raw-content wildcard routes (e.g.
/api/artifacts/:runId/*) and multipart-or-JSON routes (drop
request.body from the route config; handler parses both)
docs-web/reference/api.md
- Add the artifacts row to the Runs table + a 'List Run Artifacts'
section with curl
- Expand the 'Run a Workflow' example to show the new multipart
branch alongside the existing JSON one
packages/web/src/experiments/console/README.md
- Replace the dead /Users/rasmus/.claude/plans/quiet-twirling-bentley.md
link with a Status section noting that milestone planning has been
superseded by PR-template-driven feedback
packages/web/src/experiments/console/lib/format.ts
- Drop the orphan JSDoc that described formatProjectLocator above the
formatCost function
packages/web/src/experiments/console/theme.css
- The 'maps --color-* to --base vars' line invented terminology that
doesn't exist in Tailwind. Replace with the accurate version:
@theme inline defines color tokens that reference plain CSS vars,
redefining those vars inside .console-root cascades through every
utility that reads them
packages/server/src/routes/api.ts
- persistUploadedFiles docstring no longer claims to be shared by
both message + workflow routes (only run uses it today;
sendMessageRoute still inlines the same logic and could migrate
in a separate pass)
store/cache.ts and routes/RunDetailPage.tsx
- Drop the (M4) milestone references — the SSE wiring landed weeks
ago; the comments now describe the actual lib/sse.ts coupling
* feat(console): neovim-style keymap for project / workflow / run selection
Adds a light-modal keymap so picking a project, picking a workflow, and
starting a run can all be driven from the keyboard:
- p anywhere: full-screen project palette (subsequence fuzzy match,
↑↓/Enter/Esc, listbox + combobox a11y)
- n in a project: opens the draft card and auto-summons the workflow
picker; closing the picker hands focus to the context textarea
- ? anywhere: keyboard shortcuts overlay (esc/? to dismiss)
- runs feed: j/k move, gg/G jump, Enter open, Esc clear, / focus search,
1-5 filter by status (with magenta selection ring + scroll-into-view)
- run detail: 1/2/3 tabs, t/s toggle tool / system rows, a/r approve /
reject (paused only), Esc/h back to runs
Shared infrastructure in lib/keymap.ts: chord buffer with 500ms window,
input + modal-dialog guards so route bindings don't leak through when a
palette is open. Help catalogue lives in lib/shortcuts.ts and is kept
in sync per-page.
…slash commands (#1757) * feat(slack): umbrella Slack UX upgrade — buttons, status, reactions, slash commands Single Slack adapter PR pulling together the in-thread interactivity primitives the team will need on a shared instance: - Interactive Block Kit Approve/Reject buttons on approval gates - Cancel button on a per-run status message edited in place as DAG nodes progress - Lifecycle reactions on the triggering message (🔄 → ✅ / ❌) - Native `/archon` and `/archon-workflow` slash commands (Socket Mode, no URL needed) - `_part i/n_` annotations on long replies split across multiple messages - Italic cost/token footer after direct-chat replies and on terminal workflow status Approve/Reject/Cancel buttons call existing platform-agnostic operations (approveWorkflow / rejectWorkflow / abandonWorkflow); no schema or workflow engine changes. Authorization re-uses the existing SLACK_ALLOWED_USER_IDS whitelist for button clicks and slash commands. Per-user attribution in thread context is intentionally deferred to a separate PR — it needs a user_id column on conversations/messages/workflow_runs and orchestrator plumbing. * fix(adapters): declare @archon/providers as workspace dep CI's stricter package-resolution caught that @archon/adapters imports @archon/providers/types (TokenUsage) without declaring the workspace dependency. Locally bun resolved it transitively via @archon/core; CI's clean install does not. * fix(slack): address coderabbit review - Drop ephemeral denial from slash command auth path so unauthorized users are silently rejected, matching the existing app_mention / message.im pattern. Posting a denial leaks that a bot is listening. - Surface failureReason on cancelled runs too, not just failed. The type already documents this for both terminal states. - Stop forwarding raw error messages to Slack when a cancel click fails. Backend / DB errors stay in server logs; user sees a generic "check the server logs or try again" line. Adds a test for the cancelled-with-reason rendering. * fix(slack): address 6-agent PR review Critical: - Declare @archon/workflows as an explicit workspace dep on @archon/adapters (same class of fix as the providers one). Resolves today via hoisting but breaks under stricter installs. - Split workflow-bridge.test.ts into its own bun test invocation so its irreversible mock.module() calls on @archon/core and @archon/workflows/event-emitter cannot leak into the slack/telegram batch. - Fix "trailing-edge" debounce comments — the implementation is leading-edge. Document the Slack chat.update rate limit as the 500ms rationale. Important: - Wire slackBridge.detach() into the server graceful shutdown path so the event subscription doesn't leak and a pending chat.update can't fire against a closed Bolt socket. - Drop dead `comment` plumbing through handleApprovalDecision / applyResolutionEdit / buildApprovalResolutionBlocks — Block Kit buttons have no UI to capture it. - Widen the action-handler try/catch to also cover applyResolutionEdit so block-builder or chat.update failures don't bubble as unhandled rejections. - Cancel-click with missing run state now logs and posts an ephemeral acknowledgement (using the button message's channel/ts) so the user isn't left wondering whether the click registered. - Use Bolt's BlockButtonAction / ButtonAction types directly on the app.action() registrations instead of the ad-hoc ActionBody / ActionElement aliases. Test coverage: - Slash command silent-rejection of unauthorized users. - triggeringMessages 1000-entry FIFO eviction at the cap boundary. - Slash command seed-post failure → ephemeral error + handler not called. - Single-chunk message path skips the _part i/n_ footer. - rejectWorkflow → { cancelled: true, maxAttemptsReached: true } branch. Docs: - architecture.md IPlatformAdapter listing includes sendResultFooter. - approval-nodes.md mentions the Slack in-thread Approve button. - CLAUDE.md test-isolation batch count for @archon/adapters updated to 6 (was 3 — pre-existing drift, now also accounts for workflow-bridge). Polish: - removeReactionSafe gets the same intentional-fallback comment as addReactionSafe (no_reaction is a normal terminal-state interleave). - IPlatformAdapter.sendResultFooter signature uses TokenUsage directly. - Drop "for v1" tag on the unhandled-event comment. - Remove what-comments from blocks.ts / blocks.test.ts / adapter.ts.
) * fix(orchestrator): resume interactive workflows on chat platforms (#1741) Interactive approval-gate and interactive-loop workflows started from Slack, Telegram, Discord, or GitHub never resumed after the user provided their answer — each approval response triggered a brand-new workflow run from node 0 in a fresh worktree, re-asking the same questions indefinitely. The cause was a `platform.getPlatformType() === 'web'` gate that wrapped the entire resume-detection block in `dispatchOrchestratorWorkflow`, leaving all chat platforms to unconditionally fall through to a fresh `executeWorkflow`. The chat-side `resumeRun` mechanism that previously handled this was removed in #915 (natural-language approval routing) without lifting the resume lookup out of the web branch. Changes: - Restructure dispatchOrchestratorWorkflow so resume detection (findResumableRunByParentConversation + hydrateResumableRun) runs for every platform; only the background-dispatch branch remains web-only - Add codebaseId parameter to findResumableRunByParentConversation so persistent chat conversation IDs (Telegram chat_id, Slack thread) cannot resume a stale run from a different project - Add tests for chat resume, codebase scoping, and fresh-run fallback Fixes #1741 * test(orchestrator): strengthen mock coverage and add web non-interactive resume test - Add hydrateResumableRun to executor mock in orchestrator.test.ts to mirror the real module exports and prevent opaque TypeErrors for future test contributors - Add test asserting that a web non-interactive workflow with a resumable run resumes foreground rather than dispatching a fresh background run, pinning the priority order of the if/else if dispatch block * simplify: inline single-use mock vars in orchestrator.test.ts
…1703) (#1746) createCodebase() hardcoded 'claude' as the fallback when ai_assistant_type was not provided. Now checks process.env.DEFAULT_AI_ASSISTANT first, consistent with how getOrCreateConversation() resolves the default. Falls back to 'claude' only when both the parameter and env var are unset.
…or and workflow runs (#1783) * feat(core): plumb user_id from chat/forge adapters through orchestrator and workflow runs Adds remote_agent_users + remote_agent_user_identities tables (Archon identity + per-platform mapping, UNIQUE(platform, platform_user_id)) and threads a resolved user_id through HandleMessageContext into the orchestrator, workflow executor, and isolation resolver. Every new conversation, message, workflow_run, and isolation_environment row created from Slack/Telegram/Discord/GitHub now carries attribution. Slack additionally enriches first-sight users with their real name via users.info (requires bot scope users:read — reinstall the app to grant). Telegram/Discord derive display name from the inbound event payload. GitHub resolves event.comment.user.login or event.sender.login on each webhook. Resolution failure warn-logs and continues — never drops a message. Schema is additive and nullable everywhere: existing rows remain valid with NULL, ON DELETE SET NULL on every new FK. Web POST /api/conversations and the CLI continue to write NULL user_id; those surfaces become attributed in a follow-up PR. Solo installs with GITHUB_TOKEN are unchanged. Race-safe create-on-first-sight: UNIQUE(platform, platform_user_id) trips on concurrent first-sight webhooks; the losing transaction rolls back and we re-SELECT the winner's identity. Orphaned identities (user row deleted out from under them) are auto-repaired. Foundation for the small-team Archon initiative. Follow-ups will swap the shared GITHUB_TOKEN for a GitHub App and wire per-user GitHub tokens via device flow. * fix(core): address PR review — FK semantics, narrowed error handling, identity type union Critical fix: SQLite migrateColumns ALTERs now include ON DELETE SET NULL on all four new user_id / created_by_user_id FK columns. Upgraded SQLite DBs previously inherited the default NO ACTION ≈ RESTRICT semantic, contradicting the PR's documented "no destructive cascade on user deletion" guarantee. Hardening on the new user-identity surface: - findOrCreateUserByPlatformIdentity narrows its race-recovery catch to true UNIQUE-constraint violations (PG sqlstate 23505 or SQLite "UNIQUE constraint failed" message). Any other error logs as user.create_failed and propagates — no more masking generic DB failures as recoverable races. - backfillDisplayName wraps its UPDATE in try/catch with a dedicated warn event. A failed opportunistic backfill must not silently fail the entire user resolution path; the caller already has the resolved user row. - repairOrphanedIdentity now logs user.identity_orphan_repair_failed on transaction failure (previously surfaced only as a generic resolve_failed upstream). - New IdentityPlatform literal union ('slack' | 'telegram' | 'discord' | 'github' | 'web' | 'cli') replaces the unconstrained `platform: string` on UserIdentity and the findOrCreate signature. Typos now fail at compile time rather than silently breaking the UNIQUE(platform, platform_user_id) invariant. - user.create_started/_completed/_failed are now properly paired per the project event-naming convention. Slack adapter: - users.info missing_scope WARN now gated by an instance flag so it fires once per adapter lifetime instead of once per unknown user. The misconfiguration is permanent — flooding logs after every restart in a 100-user workspace was the wrong shape. - users_info_failed log strips err (which can include err.data with workspace metadata) in favor of structured errMessage / slackErrorCode / slackUserId fields. No PII through the log pipeline. Server resolver: - resolveUserId exported for testability and now logs as a single static event server.user_resolve_failed (platform in structured fields) instead of the templated ${platform}.user_resolve_failed which collided with the GitHub adapter's own event name. - Dead `=== null` branch removed (TypeScript already narrows the type). GitHub adapter: - User-identity resolution moved up to immediately after self-filtering + @mention checks. Now runs before the codebase-ensure and comment- history Octokit calls so resolution can't be silently skipped by an upstream Octokit failure (which was masking a missing-mock bug in the existing test suite). Tests: - New packages/server/src/resolve-user-id.test.ts covers the never-throws contract that three adapter handlers depend on. 6 cases including the static-event-name regression. - GitHub adapter test now mocks @archon/core/db/users and covers the comment.user.login ?? sender.login attribution fallback in both directions, plus a never-throws case for resolution failure. - users.test.ts gains the asymmetric-backfill case, backfill-failure- does-not-block-resolution case, both PG and SQLite UNIQUE-error shapes for race recovery, and a non-UNIQUE-rethrows-without-recovery case that explicitly counts the query calls. - isolation-environments.test.ts adds a "ON CONFLICT does NOT update created_by_user_id" regression guard so a copy-paste in the SET clause can't silently transfer ownership across re-activations. Comment cleanup: stripped the (PR-A) / (until PR-C) / pre-PR-A history labels from production types, migrations, and source files. They were PR- state markers that would rot on merge; the substantive WHY content stays. Docs: - CLAUDE.md table count: 8 → 10; users and user_identities documented. - docs-web/reference/database.md: 8 → 10 with explicit ON DELETE semantics and a note that re-running 000_combined.sql is idempotent and picks up the new ALTERs. - docs-web/reference/architecture.md: 7-table diagram → 10-table; full schema block extended with the new tables and user_id columns. - docs-web/adapters/slack.md: users:read scope added to the Bot Token Scopes setup with a note about graceful degradation if omitted. Skipped (with reason): - Converting User/UserIdentity to z.infer<typeof schema>: all sibling row interfaces in types/index.ts are hand-crafted; doing this for just the two new types creates inconsistency. A separate consistency pass should convert the whole file, not selectively. - Threading userId into the four web/CLI addMessage callsites: those surfaces don't have an auth flow yet, so threading now means passing `undefined` from every caller. Added explicit TODOs at each callsite pointing at the upcoming web/CLI auth work instead.
…tion routing (#1788) Phase 2 of the team-foundation PRD. Replaces the bot's single shared GITHUB_TOKEN PAT with a registered GitHub App that supports multi-installation token routing from day one. New @archon/core/github-auth/ module wrapping @octokit/auth-app with a three-level cache: - lookupCache: owner/repo → installationId (1h TTL; evicted on 401) - tokenCache: installationId → access token (1h GitHub TTL, refreshed 5min before expiry) - octokitCache: installationId → Octokit (per-installation auth strategy; evicted on 401 so the SDK's hidden internal token state can't keep serving the dead token) GitHubAdapter takes a `GitHubAuth` discriminated union at construction. All 4 Octokit callsites (postComment / listComments / repos.get / pulls.get) plus the clone path route through resolveOctokit + a withTokenRefresh wrapper that calls invalidateRepo and retries once on 401. Webhook event.installation.id primes the lookup cache to skip a round-trip. Secondary self-filter compares against `<slug>[bot]` in App mode (via a botLogin getter, distinct from botMention) so PR-C's per-user tokens won't trip it. Server bootstrap detects App vs PAT mode via env and fails fast if both are configured. In App mode it registers the provider on a module singleton consumed by createWorkflowDeps(), so the workflow executor's bash/script subprocesses inherit a fresh GH_TOKEN/GITHUB_TOKEN. New POST /internal/git-credential endpoint (App mode only) backs a POSIX git credential helper installed at clone time, covering workflows that outlive the 1h installation-token expiry. The public-bind guard runs BEFORE Bun.serve so a rejected config never opens the listening socket — opt-out via ARCHON_ALLOW_INTERNAL_ON_PUBLIC_BIND=1 for deployments where the reverse proxy already drops /internal/*. Refactor + extracted helpers in server/src/github-auth-bootstrap.ts (selectGitHubAuthMode + parseGitCredentialPath) so the security-critical decisions are testable in isolation without spinning up Hono. Backwards compat: solo installs running GITHUB_TOKEN only see zero functional change. All 54 existing PAT-mode adapter tests pass unchanged. Tests added: 23 strictly-mocked auth-module tests (PRD Q7 — no live api.github.com in CI); 10 new App-mode adapter tests (multi-install routing, payload short-circuit, 401 retry + retry-on-retry propagation, AppNotInstalledError surfacing, clone-token resolution, post-clone credential helper install); 20 server-bootstrap unit tests (dual-mode fail-fast, /internal path validation incl. traversal + null bytes). Closes phase 2 of .claude/PRPs/prds/github-app-and-user-identity.prd.md. Depends on #1783 (PR-A user-identity foundation).
…#1792) createSchema() ran two CREATE INDEX statements referencing user_id on remote_agent_conversations and remote_agent_workflow_runs. On databases created before v0.4.0 those columns don't exist yet — they're added by migrateColumns(), which runs AFTER createSchema(). The index creation aborted the entire createSchema() exec block, the constructor threw, and every subsequent operation failed with "no such column: user_id". New installs were unaffected because the columns exist in the same schema. Moves both CREATE INDEX statements into migrateColumns() so they run after the matching ALTER TABLE. idx_user_identities_user_id stays in createSchema() because user_identities is a new table whose user_id column always exists. Adds a regression test that seeds a pre-0.4.0 schema (no user_id columns on conversations/workflow_runs/messages, no created_by_user_id on isolation_environments) and asserts SqliteAdapter construction completes, migrates the columns, and creates both indexes. Caught by /test-release brew 0.4.0.
pi-coding-agent 0.71+ dropped the `options.agentDir ?? getAgentDir()`
fallback in DefaultResourceLoader and PackageManager. Without an
explicit `agentDir`, `getBaseDirForScope("user")` returns undefined
and any downstream `join(agentDir, ...)` throws:
TypeError: paths[0] must be of type string, got undefined
at resolvePackageSources (pi-coding-agent/dist/core/package-manager.js)
at reload (pi-coding-agent/dist/core/resource-loader.js)
This was the symptom pinning us to ^0.67.5 and blocking model-catalog
updates (e.g. Gemini 3.5 Flash).
Fix: call Pi's own `getAgentDir()` in createNoopResourceLoader. This
honors `PI_CODING_AGENT_DIR` and matches the exact behavior of the
pre-0.71 implicit fallback. Update provider.test.ts mock to stub
`getAgentDir` for the value import.
Verified: Pi -> OpenRouter -> Gemini 3.5 Flash routes end-to-end
through an Archon workflow (test-pi-gemini, model
openrouter/google/gemini-3.5-flash registered via ~/.pi/agent/models.json).
All pi tests pass (model-ref, config, event-bridge, options-translator,
session-resolver, provider 67/67, provider-lazy-load).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
….0.1, Caddy /internal/* drop, opt-out flag) (#1795) * docs(github-app): document the canonical Docker deploy recipe The internal-endpoint-security section in the existing setup guide tells operators "Operators who use systemd / docker for upstream routing also fall into this category" for the recommended HOST=127.0.0.1 path. That's misleading for Docker — the app container has to bind 0.0.0.0 inside the container for docker-proxy to forward traffic, so option 1 isn't directly usable on the canonical compose stack. The setup we hit in production is explicitly option 2 plus three concrete defensive layers, and none of the three are written down anywhere a Docker operator would find them. Hit during the dynamous.ai dogfood deploy: server failed to start with github_app.internal_endpoint_public_bind_rejected; recovery required re-reading PR-B's source plus reasoning about the docker-compose / Caddy boundary. Documenting now so the next operator doesn't repeat that loop. Changes: - Clarify option 1 is bare-metal / systemd; option 2 is the right path for Docker (with a forward link to the new section) - Tag the existing Caddy snippet as bare-metal so it isn't confused with the Docker pattern - Add "Canonical Docker setup" subsection with the three required changes: (1) docker-compose.override.yml port -> 127.0.0.1 with the !override tag, (2) Caddyfile handle /internal/* drop block, (3) ARCHON_ALLOW_INTERNAL_ON_PUBLIC_BIND=1 in .env. Plus an apply+verify recipe (curl from outside must 404; ss -tlnp confirms loopback bind; startup log shows internal_endpoint_exposed_acknowledged for audit). - Cross-reference the new section from the two relevant troubleshooting entries. * docs(github-app): probe /internal/git-credential with POST, not GET CodeRabbit (#1795 review): a GET probe against the external-exposure check can false-pass — a reverse proxy can 404 the GET on an unmatched route while still happily forwarding POSTs upstream. The endpoint is POST-only and hands out installation tokens, so the probe must exercise the actual method an attacker would use. Also tighten the surrounding prose: 404 OR 403 from the proxy both indicate the request didn't reach the Archon process. Anything else means the proxy is still relaying.
* fix(providers/pi): migrate to @earendil-works/pi (0.76.0) Pi moved to a new owner — see https://pi.dev and https://github.com/earendil-works/pi. The `@mariozechner/pi-coding-agent` package is no longer maintained at that namespace; the live development continues under `@earendil-works/pi-coding-agent` (latest 0.76.0). The previous commit on dev (c839bcc) bumped pi-coding-agent to 0.73.1 under the old @mariozechner namespace. That version's type definitions broke build (CI test job red on every PR since): `codingTools` aggregate removed in favor of `createCodingTools` factory, `ExtensionUIContext` gained four new methods, and `CreateAgentSessionOptions.tools` switched from `Tool[]` to `string[]` (with the actual Tool objects moving to `customTools`). This commit: - Bumps both dependencies to @earendil-works/pi-{coding-agent,ai}@^0.76.0. - Updates every import (12 source files + 5 test/doc/script files). - Replaces the `codingTools` aggregate with `createCodingTools` and derives `PiTool` from `ReturnType<typeof createCodingTools>[number]`. - Adds the four new ExtensionUIContext methods (setWorkingVisible, setWorkingIndicator, addAutocompleteProvider, getEditorComponent) to the headless UI stub as no-ops, with comments explaining why. - Switches createAgentSession from `tools: PiTool[]` to `customTools: PiTool[]` + `noTools: 'builtin'`, preserving the old "filteredTools replaces the default set entirely" semantic. - Updates the four provider tests that asserted on the old `tools` key shape to assert on `customTools` + `noTools` instead. bun run validate passes locally; all 67 provider tests pass. * fix(pi-migration): address PR review findings Multi-agent review (code-reviewer + docs-impact + comment-analyzer + pr-test-analyzer + type-design-analyzer) on PR #1800 surfaced: - 7 of 8 "Added in pi 0.71+" comments cite the wrong version. The changelog (badlogic/pi-mono → earendil-works/pi-mono) places these changes much earlier: * codingTools → createCodingTools factory: 0.68 (not 0.71) * customTools / tools-as-string[] / noTools: 0.68 (not 0.71) * DefaultResourceLoader requires explicit agentDir: 0.68 (not 0.71) * setWorkingIndicator: 0.68 (not 0.71) * addAutocompleteProvider: 0.69 (not 0.71) * setWorkingVisible: 0.70.3 (not 0.71) * getEditorComponent: 0.71 (this one was correct) All seven incorrect annotations corrected to the version that actually introduced the change, matching the published changelog. - Two user-facing docs still pointed at the abandoned namespace: * CLAUDE.md (2 lines) — directory comment + PiProvider bullet * examples/workflows/README.md (2 install commands) Both now point at @earendil-works/pi-* so users following the docs install the live package. - Docs site "See also" section pointed at the old badlogic/pi-mono GitHub mirror. Updated to https://github.com/earendil-works/pi (the current source repo) and added https://pi.dev as a second link for upstream documentation. - Expanded the createAgentSession call-site comment with the load-bearing invariant the original write-up missed: customTools is the only path through which BashSpawnHook env injection can attach to Pi's bash tool. Without that hook there's no way to retrofit managed env vars onto Pi's pre-constructed default bash tool — explaining why we must always supply customTools when env injection is needed, not just for tool restrictions. Lower-priority findings deferred: - Inline-vs-derived parameter typing inconsistency in ui-context-stub.ts (type-design-analyzer, LOW). The current mix doesn't cause bugs and the no-op bodies are correctness-insensitive to parameter shape. - Add `expect(ui.getEditorComponent()).toBeUndefined()` regression test (pr-test-analyzer, LOW). The contract is already enforced by the ExtensionUIContext return-type check on createArchonUIContext. - Pre-existing failing theme test in ui-context-stub.test.ts (pr-test-analyzer noted it). Not introduced by this PR; out of scope. * test(pi): fix theme-getter test + re-enable ui-context-stub in CI The themeProxy returns identity-decorator passthroughs (lastStringArg for unknown style methods, factories returning identity functions for the border-color getters) so plannotator-style extensions can decorate text without crashing. The implementation's docstring says exactly this. The test, however, asserted the opposite — that `theme.fg('accent', 'text')` THROWS with /Archon's remote UI stub/. The test never matched the implementation: both lines were authored in the same commit (cb44b96, 2026-04-20) but inconsistently, and the test file was quietly excluded from the providers package's test script chain so the disagreement never surfaced in CI. Caught by pr-test-analyzer during the #1800 review. This commit: - Rewrites the test to verify the actual passthrough contract: `theme.fg(color, text)` returns `text`, `theme.bold(text)` returns `text`, `getThinkingBorderColor()` returns a passthrough function, `getColorMode()` returns 'truecolor', `getFgAnsi(...)` returns ''. - Adds `ui-context-stub.test.ts` to the providers package's test script so the file actually runs (along with the 16 other tests in it that were already passing but silent).
* Fix: move per-platform user-identity extraction into adapter callback signatures (#1784) Discord passed raw discord.js Message objects, forcing the server to reach into message.author.id/username. Gitea and GitLab webhooks never resolved senders to Archon user UUIDs at all. This refactor moves extraction into each adapter and normalizes the callback signatures. Changes: - Extend IdentityPlatform union with 'gitea' and 'gitlab' - Create DiscordMessageContext with platformUserId + displayName - Update Discord adapter to extract identity before calling handler - Update server Discord handler to consume normalized context - Add user resolution to Gitea handleWebhook (mirrors GitHub pattern) - Add user resolution to GitLab handleWebhook (mirrors GitHub pattern) - Add tests for Discord context normalization - Add tests for Gitea/GitLab user identity resolution Fixes #1784 * fix: address review findings for PR #1801 Fixed: - Remove dead Pi tool factory mocks and rewrite obsolete spawnHook test - Remove unused _cwd/_env params from resolvePiTools and update callsite - Add env-injection-unavailable warning in Pi provider - Fix GitLab step comment drift (7-13 → 8-14) - Add fallback behavior comments to Gitea and GitLab adapters - Fix incorrect module path in Gitea/GitLab test mock comments - Move Discord JSDoc above export interface - Add defensive null-check for message.author in Discord adapter - Add userId propagation tests to Gitea/GitLab adapters - Add undefined-sender edge-case tests to Gitea/GitLab adapters - Add Discord author-null edge-case test - Add Pi UI stub new methods test - Update architecture.md schema docs with gitea/gitlab platforms Tests added: - Gitea/GitLab userId propagation and undefined-sender tests - Discord missing-author test - Pi UI stub new no-op methods test Skipped: - Pi provider SDK migration scope creep (requires PR split, not a code fix) * simplify: reduce complexity in changed files --------- Co-authored-by: Archon Bot <archon@example.com>
…1802) * Fix: Close parallel-definition drift between hand-crafted interfaces and Zod schemas (#1787) Eliminate parallel schema drift between core/src/types/index.ts, core/src/db/*.ts, and server/src/routes/schemas/*.ts by converting hand-crafted TypeScript interfaces to Zod-derived types (z.infer<typeof schema>) in a new packages/core/src/schemas/ directory. Changes: - Create packages/core/src/schemas/ with row schemas for conversation, message, user, codebase, session, workflow-event, env-var, and workflow-run concepts - Update core/src/types/index.ts to re-export schema-derived types instead of declaring parallel interfaces - Update core/src/db/*.ts to import types from schemas instead of declaring interfaces inline - Update server route schemas to extend core row schemas with wire-shape overrides (Date -> ISO string) using .extend() and .openapi() - Add toApi* transform helpers in server/src/routes/api.ts for safe Date/string normalization at the API boundary - Replace hand-crafted response interfaces in web/src/lib/api.ts with generated OpenAPI component types (components['schemas']['X']) - Add ./schemas exports to packages/core/package.json - Regenerate packages/web/src/lib/api.generated.d.ts (additive only) Behavioral interfaces (IPlatformAdapter, IDatabase, etc.) remain hand-crafted. Fixes #1787 * fix: address review findings Fixed: - toISOString now guards against invalid Date objects with try/catch + logging - toApiMessage now guards JSON.stringify with try/catch + logging (falls back to '{}') - Added inline comment documenting toApiCodebase corrupted-commands fallback intent - Imported workflowRunStatusSchema to eliminate enum duplication in listDashboardRunsOptionsSchema - Removed dead UserIdentityPlatform type alias from core schemas index - Replaced verbose inline type imports in api.ts with top-level schema imports - Added TODO comment for z.custom<TransitionTrigger>() runtime validation gap - Updated CLAUDE.md: added schemas/ to monorepo layout, core schema conventions, @hono/zod-openapi dep - Fixed adapter-implementation-guide.md: replaced stale line numbers with symbol references Tests added: - packages/core/src/schemas/index.test.ts — safeParse tests for all 9 core schema files - api.conversations.test.ts — Date→string conversion and undefined nullable handling - api.codebases.test.ts — corrupted commands JSON fallback - api.messages.test.ts — circular metadata serialization fallback - api.workflow-runs.test.ts — Date→string conversion for workflow runs Skipped: - Server route schema extensions untested for OpenAPI validation (medium effort, deserves follow-up PR) - Web client type refactor untested (type-only change, build provides coverage) * simplify: reduce complexity in changed files --------- Co-authored-by: Archon Bot <archon@example.com>
…ted numeric/boolean RHS (#1777) * feat(workflows/condition-evaluator): support shorthand path and unquoted numeric/boolean RHS Accept `$nodeId.field` as shorthand for `$nodeId.output.field`, and allow bare numeric/boolean RHS (`$n.exit_code == 0`, `$n.passed == true`) so number and flag comparisons no longer have to quote their literal. The change is confined to the atom regex plus the path/RHS resolution in evaluateAtom; the AND/OR splitter, resolveOutputRef, and numeric coercion path are untouched, so every existing `when:` clause evaluates identically. Shorthand refs with a sub-field (`$n.field.sub`) are rejected fail-closed. Closes #1763 * test(workflows/condition-evaluator): drop task prefix, add shorthand absent-field case Address review feedback: remove the stale task-id prefix from the shorthand/unquoted RHS section comments, and add a test asserting an absent shorthand field resolves to '' (parsed: true) rather than a parse error, matching the canonical $node.output.field semantics. --------- Co-authored-by: Raphael Lechner <raphael.l@asix.pro>
…/fallbackModel/betas/sandbox in loader (#1799) * fix(workflows): stop silently dropping workflow-level effort/thinking/fallbackModel/betas/sandbox in loader `workflowBaseSchema` declares five workflow-level fallback fields (`effort`, `thinking`, `fallbackModel`, `betas`, `sandbox`) that the DAG executor reads from `workflow.{field}` at the top of `executeDagWorkflow` (dag-executor.ts: 2585-2591) and threads down as `workflowLevelOptions` so per-node options can inherit them when unset. But `parseWorkflow` in loader.ts builds the returned workflow object manually rather than round-tripping `raw` through Zod, and the five fields were never copied across. Result: YAML → loader → executor silently dropped them, so a workflow that set e.g. `effort: high` at the root would fall through to SDK defaults instead of being inherited by its nodes. Why this wasn't caught: the existing "forwards workflow-level effort to node when no per-node override" test in dag-executor.test.ts calls `executeDagWorkflow` directly with a hand-built workflow object literal — it bypasses the loader entirely, so the executor side works in tests even though the production path is broken. No bundled workflow uses any of these as a workflow-level field today (grep returned 0), which is the only reason there's no shipped user-visible regression. Fix: add five `safeParse` + warn-and-drop blocks mirroring the existing `modelReasoningEffort` / `webSearchMode` pattern, and spread each conditionally onto the returned workflow object alongside `mutates_checkout` and `tags`. `effort`, `thinking`, and `sandbox` use the existing Zod schemas (`effortLevelSchema`, `thinkingConfigSchema`, `sandboxSettingsSchema`). `fallbackModel` is inline (trim + non-empty check) so trailing whitespace is normalised rather than rejected. `betas` is inline trim+filter (same shape as `tags`); an empty result drops the field because the Claude SDK expects either a populated beta header or no header at all. Tests: - positive: a YAML with all 5 workflow-level fields set survives parse and round-trips to the workflow object with the correct shapes (incl. the `thinking` discriminated-union narrowing to `{ type: 'enabled', budgetTokens }`) - negative: invalid values (`effort: nuclear`, `fallbackModel: ''`, `betas: []`) warn-and-drop instead of failing the workflow - trim semantics: `betas: [' alpha ', '', 'beta']` normalises to `['alpha', 'beta']` - absent: a workflow that doesn't set any of the five fields leaves them undefined on the parsed object No schema or executor changes — the bug was the loader silently dropping values the executor was already prepared to consume. * refactor(workflows/loader): address review feedback on workflow-level field parsing Follow-up to the workflow-level effort/thinking/fallbackModel/betas/sandbox loader fix, addressing multi-agent PR review findings: - Extract the repeated safeParse + warn-and-drop pattern into a single parseOptionalField helper; apply it to effort/thinking/sandbox plus the pre-existing modelReasoningEffort/webSearchMode blocks. - Extract the duplicated betas schema (workflow.ts + dag-node.ts) into a shared betasSchema and validate the cleaned list through it. Removes the unsound `as [string, ...string[]]` cast — the schema's .nonempty() constraint now supplies the tuple type. - Replace hard-coded `dag-executor.ts:2585-2591` references (in loader.ts and loader.test.ts) with stable text anchors per the line-number anti-pattern. - Add an `expected: 'non-empty string'` hint to the fallbackModel warning. - Tests: assert the structured warn events fire, cover the thinking string shorthand and an invalid thinking/sandbox shape, and verify fallbackModel whitespace trimming. - Docs: note per-node-over-workflow-level precedence for the Claude SDK advanced options in the authoring-workflows Configuration Priority section.
…tatus/reset CLI) (#1780) * telemetry: umbrella trust fixes (CI auto-disable, first-run notice, status/reset CLI) Cleans up the PostHog telemetry path with a set of low-risk fixes that improve trust signals, raise data quality, and give users a way to inspect and control state without env-var spelunking. 1. Auto-disable when CI=true. Forks running fixtures in GitHub Actions, CircleCI, etc. no longer pollute the dataset. Matches the convention used by Next.js, Astro, Bun, Prisma. 2. One-time first-run stderr notice (stamped at ${ARCHON_HOME}/telemetry-notice-shown). Only printed when stderr is a TTY and telemetry is enabled. Most users learn about the data collection without having to read the README. 3. POSTHOG_API_KEY opt-out is now meaningful: empty / off / 0 / false / disabled all disable telemetry instead of silently falling through to the embedded key. 4. silentFetch logs the first network/HTTP failure at warn (so self-hosters notice a typo'd POSTHOG_HOST), subsequent failures at debug to stay quiet. 5. New `archon telemetry status` / `archon telemetry reset` CLI subcommands. Status surface also wired into `archon doctor`. 6. workflow_description removed from the event payload — it's user-authored YAML that may contain private context. 7. $ip: '' added to event properties so source IP isn't retained at PostHog ingest (disableGeoip only blocks geo enrichment). The `phc_*` embedded key remains write-only and shipped in source. Backwards compatible: `isTelemetryDisabled()` retained, opt-out env vars unchanged. * test(telemetry): isolate CI and POSTHOG_API_KEY env in disabled=0 test The 'ARCHON_TELEMETRY_DISABLED=0 does not disable' test left CI and POSTHOG_API_KEY untouched. GitHub Actions always sets CI=true, so the new CI auto-disable branch made isTelemetryDisabled() return true and the test failed in CI while passing locally. Clear both env vars to match the sibling opt-out tests. * fix(telemetry): address PR review findings (trust, types, tests, docs) Behavioral fixes: - logFetchFailure: gate the first-failure warn on POSTHOG_HOST being overridden, so default-host users who are merely offline stay silent (warn was only ever justified for self-hosters with a typo'd host). - getTelemetryStatus: read the install UUID without creating it when disabled, so `telemetry status` / `doctor` never materialize a telemetry-id file for an opted-out user (new peekTelemetryId helper). - maybeShowFirstRunNotice: add a self-contained isTelemetryDisabled() guard so the function is safe for any future caller, matching its docstring. - resolveDisabledReason: match CI case-insensitively (AppVeyor sets CI=True); CI=1 still does not disable. Type design: - TelemetryStatus is now a discriminated union on `enabled`, making {enabled:true,disabledReason:...} and {enabled:false,disabledReason:null} unrepresentable and removing the unreachable fallback in doctor.ts. Comments/docs: - resetTelemetryId: document its non-fire-and-forget throw contract (@throws). - silentFetch + module docstrings: correct "zero noise" wording and the notice-stamp-failure comment; add inline rationale on the capture catch. - doctor: broaden the POSTHOG_API_KEY disabled message ("opt-out value"). - Add telemetry to CLAUDE.md CLI list; add telemetry section + doctor check mention to docs-web cli.md; add Telemetry env-var table to configuration.md. Tests: - New packages/cli/src/commands/telemetry.test.ts (status output per reason, reset success + write-failure exit code). - First-run notice coverage (TTY guard, stamp idempotency, disabled, in-process guard); CI case-insensitive cases; status-while-disabled writes no file; clear POSTHOG_HOST for determinism; doctor POSTHOG_API_KEY/ARCHON_TELEMETRY_DISABLED reasons. Deferred: silentFetch 3xx handling (effectively dead — fetch follows redirects; reviewer rated low-confidence).
…e-runs (#1790) * feat(workflows): persist per-node provider sessions across workflow re-runs Opt-in via `persist_session: true` on a DAG node (or `persist_sessions: true` at workflow root). When set, the executor stores the provider's session ID after each completion keyed by (workflow_name, node_id, scope_key, provider) — scope = the conversation UUID — and passes it back as `resumeSessionId` on subsequent runs. Each AI role accumulates conversation across re-invocations of the same workflow in the same scope; planners remember their plan, reviewers remember what they already flagged. Capability-gated on `ProviderCapabilities.sessionResume` at both load time (loader.ts, when provider is explicit) and runtime (dag-executor.ts, for the implicit-default-provider case). `node.context: 'fresh'` overrides — the explicit "always fresh" intent wins. Reset surfaces: `archon workflow reset-sessions`, `/workflow reset-sessions` chat command, and `DELETE /api/workflows/{name}/node-sessions`. Conversations are soft-deleted in this codebase so no cascade is wired; `scope_key` stays structurally valid. Distinct from `AgentRequestOptions.persistSession` (Claude SDK on-disk transcript flag). YAML field is snake_case; the SDK option is camelCase TS; they sit at different layers and don't conflict — disambiguation comments at both call sites. Test coverage: - 9 cases for the new DB module (get/upsert/delete/deleteByScope, all filter combinations, null rowCount coalesce) - 7 cases for the DAG executor: fresh + upsert; resume + upsert with new ID; no sessionId returned → delete stale row; persist_session unset → no store calls; workflow-level on + node-level off → opt-out; context:'fresh' → bypass in both directions; non-resume-capable provider → clear error - 3 cases for the loader: schema parses field on node and at workflow root; load-time rejection on non-resume-capable provider (registers ephemeral provider in test to drive the gate) - 3 cases for the shared op: workflow-only delegation; full filter forwarding; DB error wrapped with descriptive message Web UI panel deferred to follow-up — REST + OpenAPI types ship now so the client side can land separately without blocking. Docs updated with usage, scope rules, capability requirement, reset surfaces, and the Codex/Pi unbounded-token-growth caveat. * fix(workflows): address CodeRabbit review on persist_session PR - CLI `workflow reset-sessions`: reject extra positional args. A call like `archon workflow reset-sessions wf planner --yes` silently ignored `planner` and wiped every scope; now fails closed and hints `--node`. - `upsertWorkflowNodeSession`: wrap pool.query in try/catch and log on failure before rethrowing. CLAUDE.md INSERT contract was missing here. - `node_session_resumed` workflow event: mask the resumed session ID to an 8-char prefix. Workflow events live longer and reach broader observability surfaces than the node-session table; storing the raw token there leaked a resumable artifact into the audit stream. Field renamed `provider_session_id` → `provider_session_id_preview`. - Stale-session cleanup on no-sessionId: include `provider` in the delete filter so switching providers between runs no longer clobbers the other side's saved row. Extended `IWorkflowStore.deleteWorkflowNodeSessions` filter shape and the underlying DB helper to accept an optional provider predicate; reset surfaces (CLI/chat/REST) still omit it so a manual reset wipes every provider. - Loader capability gate: skip non-persistable nodes. Workflow-level `persist_sessions: true` was triggering the capability check on bash / script / approval / cancel / loop nodes and on `context: 'fresh'` AI nodes, producing false validation failures. Now only command/prompt AI nodes outside `context: 'fresh'` are checked, matching what the executor actually persists. - Loop nodes: documented as out-of-scope for `persist_session` in this release. They have their own per-iteration session machinery; the schema already warn-drops the field on loops, and the loader now skips them in the capability gate. Use a `prompt:` node for cross-run memory. - Chat help: `/workflow reset-sessions` was undiscoverable. Added to both the fallback `/workflow` usage string and the `/help` workflows section. - REST: moved the request schemas (`resetWorkflowNodeSessionsParamsSchema`, `…QuerySchema`) into `workflow.schemas.ts` so the entire endpoint contract is co-located, per the project's "Route schemas live in packages/server/src/routes/schemas/" rule. Tests: - New: upsert rethrows DB errors after logging - New: delete filter narrows by provider - New: loader does NOT reject a workflow-level `persist_sessions: true` against a non-resume-capable provider when the only node is bash - Updated: dag-executor "no sessionId → delete" expects `provider` in the filter * fix(workflows): address multi-agent review on persist_session PR Critical — cascade comments were lying: - Remove dead deleteWorkflowNodeSessionsByScope (no caller) + its test. - Correct the "cascade-on-conversation-delete handled by app code" claims in the DB docstring, migration 022, and 000_combined.sql: conversation delete is a soft delete and the conversation UUID is never reused, so orphans are unreachable and harmless; a future hard-delete path should delete by scope_key. Important: - Surface persist_session lookup/upsert DB failures to the user (safeSendMessage), not just logs; add provider to the upsert failure log context. - Add --conversation-id to `workflow run` so persisted sessions resume across CLI invocations; document the scope-per-run caveat. - Guard the REST cross-scope reset: DELETE .../node-sessions without ?scope= now requires ?confirm=all-scopes (mirrors the CLI --yes guard). - Derive WorkflowNodeSession / WorkflowNodeSessionRow from a new workflowNodeSessionSchema (z.infer) instead of hand-written interfaces. - Remove the dead persist_sessions field from WorkflowLevelOptions. - Update CLAUDE.md (11 tables, CLI/REST/subcommand entries, node fields) and authoring-workflows.md (CLI scope caveat, cross-scope confirm). - Fix misleading comments (scope "always populated", loop warning, token citation). Cleanups: - last_run_id write type aligned to string | null (matches ON DELETE SET NULL). - getWorkflowNodeSession takes an object param (no transposable positional strings). - Drop the no-op conditional spreads at the three reset surfaces. - Extract shared isPersistableNode predicate; move upsert success log into the try. Tests: - Executor: lookup-failure -> fresh + warn, upsert-failure -> completes + warn, workflow-level persist_sessions inheritance. - Chat /workflow reset-sessions (auto-scope, missing-name, DB error). - CLI workflowResetSessionsCommand --yes guard + scope/json paths. - DB: object-param + null last_run_id. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix: auto-apply Postgres schema on startup (#1796) PostgresAdapter had no schema management — schema-adding upgrades silently broke Postgres deployments until someone SSH'd in and ran psql. The Docker initdb mount only fires on a fresh volume, and external managed Postgres (Supabase/Neon) had no convergence mechanism at all. PostgresAdapter now runs the idempotent migrations/000_combined.sql inside a pg_advisory_xact_lock(1796) transaction during construction, and every query()/withTransaction() awaits the resulting promise so the first DB op cannot race init. The SQL is embedded into the compiled binary via a bundled-schema generator that mirrors the bundled-defaults pattern. Changes: - Add PostgresAdapter.initSchema() with advisory-lock transaction - Add bundled-schema.ts facade + bundled-schema.generated.ts (embedded SQL) - Add scripts/generate-bundled-schema.ts (mirrors generate-bundled-defaults) - Wire generate:bundled-schema / check:bundled-schema into validate - Add tests covering one-shot init, advisory lock ordering, and DDL failure - Strip manual psql instructions from CLAUDE.md, database.md, docker.md, cloud.md Fixes #1796 * fix: address review findings from PR #1810 - Move getSchemaSQL() and pool.connect() inside initSchema() try block so connection failures (ECONNREFUSED, auth errors, pool timeout) emit a db.postgres_schema_init_failed log entry instead of propagating silently - Rename new log events from db.pg_* to db.postgres_* to match the established domain prefix used by other events in the same file - Add if (client) guard before ROLLBACK attempt in initSchema() error path - Use instanceof Error guard before cast in initSchema() catch blocks - Import PoolClient type from pg for proper type annotation - Add bundled-schema.test.ts covering the BUNDLED_IS_BINARY=true branch; wire it into @archon/core test script as its own isolated bun test invocation - Add client.release() assertion to DDL failure test to guard the finally block - Update CLAUDE.md: validate count six -> seven, add check:bundled-schema and generate:bundled-schema guidance to the Defaults section - Replace stale manual psql migration step in local.md with a Database Setup section matching the cloud.md pattern (advisory-lock auto-apply) - Remove issue number references from test section-divider comment and generator script JSDoc (violates CLAUDE.md "don't reference current task" rule) - Rephrase "implicitly" comment in postgres.test.ts to be precise from all perspectives
* chore(deps): standardize on zod v4 across the codebase Two zod versions coexisted in the tree (v3 from the project + @hono/zod-openapi@0.19, v4 from @earendil-works/pi-coding-agent), so a ZodError thrown by one copy was not instanceof the ZodError imported from the other — breaking cross-version instanceof checks (sessions.test.ts) and risking subtle validation bugs. Standardize the project's own packages on zod v4: - Bump zod ^3 -> ^4.4.3 and @hono/zod-openapi ^0.19.6 -> ^1.4.0 (the zod-v4 line) in @archon/core, @archon/workflows, @archon/server. All @archon/* packages now resolve a single zod@4.4.3 (remaining v3/older-v4 copies are nested in unrelated third-party deps and don't touch our code's instanceof). v4 breaking-change fixes: - z.record(value) -> z.record(z.string(), value): v4 requires an explicit key type (dag-node, hooks, workflow-run, core schemas, server routes). - parseOptionalField: v4 dropped the 3-arg z.ZodType<O, Def, I>; infer from the schema via <S extends z.ZodType> returning z.output<S>. - betasSchema .nonempty() no longer infers a [string, ...string[]] tuple in v4; loader's local type is now string[]. - agents record: v4 collapses a failing key-schema into a generic "Invalid key in record" and drops the custom message, so the kebab-case check moved into a superRefine that preserves the guidance (with the offending key in the path). Tests: - Update two loader assertions for v4 error wording (v3 "Required" -> v4 "Invalid input: expected number, received undefined"); behavior unchanged. - Add a GET /api/openapi.json regression test confirming the spec generates across all routes under @hono/zod-openapi v1 (the major-bump risk). bun run validate is green: type-check (10 packages), lint, format, tests; the previously failing sessions ZodError test now passes. * chore(review): address zod v4 PR review findings - loader.ts: fix stale betas comment that still claimed the [string, ...string[]] tuple type after the v4 change to string[]; move the parseOptionalField generic rationale into the JSDoc and make the ZodTypeDef note precise - dag-node.ts: reword betasSchema comment to a version-agnostic description - api.health.test.ts: harden the openapi.json test to assert components.schemas is populated (Conversation/Codebase/WorkflowEvent) and that the datetime-heavy conversations/codebases routes serialize — the actual zod-to-openapi v7->v8 risk - CLAUDE.md: document the z.record(z.string(), value) v4 requirement
The form-auth walkthrough showed an unescaped bcrypt hash. Docker Compose interpolates $ in env_file values, so a copied hash silently loses everything after the last $ — verified: '$2b$12$abcdef' arrives in the container as '$2b$12', while '$$2b$$12$$abcdef' arrives correctly as '$2b$12$abcdef'. Escapes every $ as $$ in the form-auth example and states the Compose requirement, matching the existing convention in .env.example and the basic-auth example. Also notes that the cloud guide targets the repository Compose deployment — edit .env directly rather than running 'archon setup'. No runtime auth behavior or Docker configuration changed. Closes #1168
…2205) The prompt-substitution catch in executeNodeInternal logged and returned a failed result without emitting anything, so the failure was invisible in the console run view and in 'workflow get --json'. Adds logNodeError, a persisted node_failed event, and the emitter call — byte-for-byte parallel to the sibling command-load failure path 40 lines above. Plus a regression test. Reachable in production, not theoretical: substituteWorkflowVariables throws when a prompt references $BASE_BRANCH and none resolves, which is the normal state for folder projects (non-git, no base branch). Event shape verified against both consumers — the console normalizer maps node_failed to a terminal 'failed' state, and buildNodeSummaries reads the data.error payload this writes.
…, not prompts (#2300) The rule 'YAML coordinates, code computes, agents judge' was readable as a partition of work between node types, which led a review to argue on constitutional grounds that a prompt-implemented guard should become a script node. The constitution had no opinion on that; the real argument was reliability. Scopes the rule to the YAML surface in all four places that stated it, adds direction.md §prompt-computation, and records the narrow reliability carve-out explicitly as not a constitutional argument.
…real database (#2303) Two proven root causes behind the CI timeout class, both the same shape: a unit test silently reaching a real external resource, where nothing fails loudly and the only bound is Bun's 5000 ms default timeout. GitHubAdapter's self-filtering suite never stubbed the private Octokit client, making 12 real HTTPS requests to api.github.com per run; GiteaAdapter had the same defect via bare fetch. Black-holing the endpoint reproduced #2186's reported failure list exactly. mock.module() merges over the real module, so an export omitted from the factory keeps its real implementation. findChildRuns was added to db/workflows by #2121 and never added to command-handler.test.ts's factory, so every /workflow abandon test ran the real function and created a schema-initialised SQLite database. It produced a byte-identical success message, so hidden disk I/O was the only trace. Adds a preloaded no-network guard for @archon/adapters that fails in 0.33 ms naming the URL instead of 5000 ms naming nothing, and documents both mechanisms in CLAUDE.md. Closes #2186. #2240 referenced, not auto-closed: its root cause is fixed but the share of the observed latency attributable to it is unproven. Test-only; no production code.
…2301) The docs build has been broken on dev since 2026-07-21: ea87ddd inserted a marketplace entry after an existing brace, consuming it and leaving the next entry with no opener. deploy-docs.yml only builds on push to main, so this was a landmine set for the next release rather than a visible failure. The guard mattered more than the fix. marketplace-lint.yml caught this exact error on #1687 and the PR merged with that check red, so the root cause is enforcement, not tooling. What was genuinely missing locally: @archon/docs-web had no type-check script, so bun run type-check skipped it entirely via the --filter glob. It was the only package of eleven without one. Adds that script (astro sync && tsc --noEmit, since .astro is build-generated and gitignored) and a path-filtered docs-build.yml. Deliberately does not add build:docs to bun run validate: it resolves externalised bare imports out of dist/, which escapes the package in nested worktrees and would fail for reasons unrelated to the change under test. Also sets fail-fast: false on the test matrix. Windows was being cancelled whenever ubuntu failed, so a windows-only break stayed invisible until the next round — and a cancelled leg cannot report success once it is a required check. Merged with Run marketplace auto-review red: it has failed on every run since 2026-07-20 with billing_error, unrelated to this change. Closes #2281.
…writing to ARCHON_HOME (#2307) GitHub adapter tests created a real archon.db, config.yaml and bin/git-credential-archon under ARCHON_HOME. None of it came from Octokit — it came from real @archon/core functions the test factories left live, three of them on subpaths a root @archon/core factory cannot cover. loadGlobalConfig() creates a default config when absent, so merely reading config wrote one. A complete Octokit stub would not have fixed the leak, and fixing only the leak would have left a trap armed: six tests terminated cleanly only because a stubbed 401 made step 7 return before steps 8-13, all of which were unmocked. Making repos.get resolve — the obvious move when exercising those steps — would have dropped all six into unmocked code at once. Both are closed here. Also fixes a spy that existed and looked correct but was dead by the time it mattered: spyOn(core, 'handleMessage') sat inside a nested describe whose afterAll restored it before the App mode block ran, so that test drove the real orchestrator. Spies are now file-scoped. A sweep of all 245 test files confirms this was the only instance, not a class. Proof: full adapters suite against a probe ARCHON_HOME goes from 7 entries to 0, with gitea/gitlab as controls at 0 both times and each stub separately shown load-bearing. Zero assertion changes across the six self-filtering tests. 14 mutants, 13 killed; the survivor is disclosed rather than papered over. Closes #2305.
* fix: harden macOS installer detection Fixed:\n- select the ARM64 release when a Darwin shell is translated by Rosetta\n- verify downloaded binaries before replacing an existing installation\n- keep the public installer mirror synchronized\n\nTests added:\n- deterministic installer platform, mirror, and failed-probe regression coverage\n\nSkipped:\n- PR artifact generation because this branch has no associated PR or registry entry * fix: address installer review findings Fixed: - cover Linux and missing Rosetta-marker platform fallbacks - cover successful candidate probe, replacement, and version output Tests added: - extended deterministic installer regression coverage Skipped: - none * simplify: reduce complexity in changed files
Finalize DAG and loop tool timing when a provider emits tool_result, while retaining the existing next-tool and result fallbacks. Add deterministic regressions that exclude delayed assistant output from tool duration.
…-only migration rule (#2317) * feat(core): record the database schema vintage and state the additive-only rule Archon carries no record of which build created a database or last applied schema to it. Schema convergence re-runs idempotent additive statements on every connection from every process that opens the database, so an install from six months ago and a fresh one can differ structurally on identical code and nothing can say so. The property that keeps this safe -- every shipped ADD COLUMN NOT NULL carries a DEFAULT -- was an accident of authorship rather than a stated rule. The rule is the important half. Both schemas are hand-maintained and have drifted three times already (#988, #2033, and a live allow_env_keys divergence found while investigating this), and the only automated guard compares table names, not columns. Changes: - CLAUDE.md: additive-only migrations are now a hard rule; ADD COLUMN NOT NULL requires a DEFAULT; NOT NULL added to an existing CREATE TABLE body binds only new databases - New remote_agent_schema_version table on both dialects, written from APP_VERSION by the existing idempotent apply-on-connect path, and only when the value changes. Postgres writes inside the existing advisory-locked transaction; SQLite's write is warn-and-continue so diagnostic metadata can never stop the database from opening - created_app_version is NULL, never guessed, for databases that predate the table -- the unknowability is the reportable fact - Surfaced in `archon doctor` and GET /api/health; nothing gates on it - @archon/paths gains a bundled-build subpath export so the version constant does not have to travel through the barrel that 80+ test files mock Deliberately not included: a migration framework, a versioned ledger, up/down migrations, or any two-writer refuse/warn policy. This makes the vintage legible; choosing a policy is a separate call. Closes #2316 * fix(core): isolate the Postgres vintage write behind a savepoint Self-review found a real asymmetry: the SQLite vintage write is wrapped in warn-and-continue so it can never stop the database from opening, but the Postgres write was fused into the schema transaction with no isolation. A throw there would reject schemaInitPromise -- which every query() and withTransaction() awaits -- bricking the adapter for the life of the process over a row nothing gates on. That silently broke the "diagnostic only" contract this change itself states, on the dialect the VPS runs. Review fixes: - Postgres: SAVEPOINT/RELEASE around the upsert, ROLLBACK TO SAVEPOINT and warn on failure, so the schema transaction still commits - Postgres: extract the block into recordSchemaVersion(), mirroring the existing installNotifyTrigger() precedent and the SQLite method name - api.ts: shape the health payload next to the read, so the field filtering (createdAt is deliberately not exposed) lives in one place - doctor.test.ts: hoist a makeDeps() helper, matching the convention the checkFolderProject block in the same file already uses - schema-version.ts: inline the table name; the exported constant had one reader and was not a single source of truth Declined from the simplifier: collapsing SQLite's read-then-branch into a single UPSERT. It is tidier, but an INSERT ... ON CONFLICT whose DO UPDATE WHERE fails still opens a write transaction, so every process that opens the database -- including every CLI invocation and every --detach child -- would take a write lock where a SELECT suffices today, and a read-only database file would warn on every open instead of only when a write is actually needed. * fix(core): do not stamp a vintage onto a partially-migrated SQLite database migrateColumns() suppresses each table's failure so one bad ALTER cannot abort startup, but recordSchemaVersion() then stamped APP_VERSION unconditionally. A database missing a failed migration would therefore report that this build last applied its schema -- a wrong answer that gets believed, which is the outcome #2316 itself ranks as the most damaging. migrateColumns() now returns whether every block succeeded, and the vintage write is skipped (with a warn naming the version that was withheld) when it did not. A stale or absent vintage is a state a reader can act on; a confidently wrong one is not. The next clean open records it. Test seeds a view named remote_agent_users so the users ALTER genuinely fails, then asserts no vintage row is written and readSchemaVersion() returns null. Reported by CodeRabbit on PR 2317. Its other two findings -- duplicate `issued` declarations in postgres.test.ts and duplicate type assertions in sqlite.test.ts, both flagged critical -- are false positives: each declaration is in its own test scope, there are no duplicated assertion lines, and both tsc and the suites run clean.
…l mirror (#2340) * fix(install): repair the curl|bash entry point and sync the PowerShell mirror #2330 added a `[[ "${BASH_SOURCE[0]}" == "$0" ]]` guard so test-install.sh could source the installer to unit-test detect_platform. Under `set -u` and with bash reading the script from stdin -- which is exactly what `curl -fsSL … | bash` does -- BASH_SOURCE[0] is unbound, so the guard aborted the script before main() ran. The documented install path failed for every user on every platform, turning #2295 from "installs but won't run on one Mac config" into "installer hard-fails for everyone". Not yet live: deploy-docs.yml only publishes from main. Uses `${BASH_SOURCE[0]:-$0}`. Verified reaching main when piped and when executed by path, and still correctly skipped when sourced -- so detect_with_mocks keeps working and does not regain its live-network-plus-sudo behaviour. Also fixed: - Post-install verification was removed rather than moved. The pre-install probe runs against the temp download and its output was cached; after `mv` the installer printed that cached string and declared success without ever executing the installed file. It now re-runs the version check against $INSTALL_DIR/$BINARY_NAME, which is the artifact the user actually invokes. A post-install check that never runs the installed binary cannot detect the failure class #2295 reported. - packages/docs-web/public/install.ps1 had already drifted from scripts/install.ps1: it was missing the @() array wrapper added by 53cabd4 ("PowerShell Add-ToUserPath corrupts PATH when single entry exists", #1000). `irm https://archon.diy/install.ps1 | iex` is the documented Windows install path, so the public installer has been shipping that PATH corruption since the repo copy was fixed. Line 191 was the only difference; the mirror is now byte-identical. Tests: - A piped-stdin regression test. Every existing case invokes the installer by path or sources it, so none could catch this. Verified failing against the installer at 0872317 with both mirrors reverted (so the parity check does not mask it) and passing after the fix. - A cmp parity assertion for the .ps1 pair, matching the one #2330 added for the bash pair. The .ps1 pair had no drift protection at all, which is why #1000 went unmirrored unnoticed. Closes #2338 Closes #2339 * test(install): assert the piped installer's exit status, not just its stderr The regression test captured stderr with `|| true` and asserted only that "unbound variable" was absent. That passes if the installer aborts at the same boundary for any other reason while still leaving a usable binary behind -- it tested for one known message rather than for success. Now captures the status and asserts 0, so the test covers the boundary rather than the symptom. Still verified failing against 0872317 with both mirrors reverted: "Piped installer exits successfully: expected 0, got 1". Raised by CodeRabbit on #2340. * fix(install): make the mirror parity check portable and stop a guard regression reaching the network Two review findings, neither introduced by this PR, both in files it already edits. .gitattributes: scripts/install.sh is pinned to LF by `*.sh text eol=lf`, but the published mirror has no extension, so it fell through to `* text=auto` and is converted to CRLF on a core.autocrlf=true checkout (the Windows default). `cmp` compares bytes, so the parity assertion failed on a clean tree -- a Windows contributor running `bun run validate` got "public installer mirror differs" with nothing actually wrong. Pinned the mirror to LF. test-install.sh: detect_with_mocks SOURCES the installer, so it relies on the source guard suppressing main(). make_platform_mocks stubbed only uname and sysctl, so a guard regression -- the exact failure this PR repairs the other half of -- would run main(), hit the real release URL via curl, and reach sudo mkdir/mv with a non-writable INSTALL_DIR, during `bun run validate`. Added hard-failing curl/sudo stubs. Verified by neutralising the guard to `if true`: the stub fires with "TEST BUG: sourced installer invoked curl" instead of the request going out. Raised in review of #2340. * test(install): report the cause of a piped-installer regression, not just the exit code fac6e4c put the exit-status assertion before the stderr case, so on a regression assert_equals fired first with "expected 0, got 1" and the case block never ran -- dead code, and no hint at the cause. Reordered: specific diagnosis first, generic assertion as the catch-all, so both are live. Verified against 0872317 with both mirrors reverted: "FAIL: installer aborts when piped to bash (BASH_SOURCE unbound under set -u) — see #2338" Raised as S1 in review of #2340.
* fix(providers): record resolved model metadata * fix(workflows,providers): record resolved model for loop nodes; disambiguate multi-model usage Two gaps in the #2314 resolved-model record. Loop nodes recorded neither half. executeLoopNode owns its own sendQuery and chunk loop, so it never saw msg.resolvedModel, and its node_started row omitted provider/model/tier. Thread the resolved provider/model/tier from the dispatcher into the loop's node_started (both persisted row and emitter event), capture msg.resolvedModel per iteration (last-seen wins, like loopFinalStopReason), and write model_usage on node_completed — mirroring the AI-node path exactly, and omitting the field entirely when the provider reports no model. loop_group needs no change: its bodies run through runLayers -> executeNodeInternal, which already records both halves (verified against the iterCtx dispatch). Claude's modelUsage can hold more than one key — a subagent pinned via agents:, or a fallbackModel takeover — so Object.keys()[0] silently picked whichever came first. Select the greatest-output-token entry instead and warn (claude.resolved_model_ambiguous) so a multi-model turn is visible rather than collapsed. Tie / missing counts keep the first key, i.e. the prior behavior. Tests: multi-key selection with a deliberate non-first winner, empty-record absence, and single-key warns-not-called; loop-node coverage for both halves plus absence when the provider reports no model. Also corrects the existing modelUsage mock to the SDK's camelCase field names. * fix(workflows): clear the resolved model when a later result omits it Both capture sites guarded the assignment (`if (msg.resolvedModel)`), which can set a value but never clear one. Pi and Copilot reask loops emit several result chunks and Pi omits resolvedModel when its later assistant message carries no responseModel -- so an earlier attempt's model was persisted as the final attempt's answer. That is fabricated attribution, which is the exact defect #2314 exists to prevent: absence must stay absence rather than being filled from a stale read. Applies to both the AI-node path and the loop path added in c215734, which mirrored the flaw along with the pattern. Assigns unconditionally at both sites. Test: two results in one loop iteration via the background-task wait (the #2083 shape), the first reporting a model and the final one not. Verified failing with the guard restored and passing without it, so it covers the behaviour rather than the code path. Full @archon/workflows suite green across all 18 batches; type-check clean. Raised by CodeRabbit on #2337.
* Run installer tests in CI * fix: preserve installer mirror line endings on Windows * fix(ci): skip the installer test suite on Windows The new "Run installer tests" step made test (windows-latest) permanently red. scripts/install.sh refuses MINGW/MSYS by design -- Windows users are directed to WSL2 -- and test-install.sh invokes the real installer with only curl mocked, so uname -s is not stubbed at that call site. On windows-latest, Git Bash reports MINGW64_NT, the installer exits with "Windows is not supported", and `set -euo pipefail` in both scripts fails the job. Guarded with `if: runner.os != 'Windows'`. The ubuntu leg still runs the suite, so mirror-drift coverage in CI is unaffected -- byte parity is platform-independent and only needs asserting once.
…pawning tar (#2310) * test(cli): build the web-dist tarball fixture in-process instead of spawning tar The windows-only `downloadWebDist` timeout is Bun's timeout-with-a-live-child path — the `tar` child had not exited after 5 s. It is not slowness, and not a stream that failed to EOF. Proof, in three independent pieces: 1. Reproduced the exact CI signature locally by hanging a `beforeAll` while a spawned child was still alive: killed 1 dangling process (fail) ... > (unnamed) [5000.97ms] ^ a beforeEach/afterEach hook timed out for this test. The same hook hung on an unrelated promise, with an already-exited child, produces the timeout WITHOUT the `dangling process` line. So that line means "a child was still running when the budget expired" — it is not noise. 2. A sweep of 599 test.yml runs (2026-06-26 → 07-29) agrees: `killed N dangling process` appears 0 times in 16 passing job logs, and on exactly the 3 windows timeouts that spawn a subprocess — never on the 4 that don't. 3. In the failing run (29801864393), `afterAll`'s `rmSync(tmpRoot)` failed with `EBUSY: resource busy or locked` — the only EBUSY in the whole corpus. The `tar` child still held a handle on the temp dir. Independent confirmation that it was alive rather than merely unreaped. The three `downloadWebDist` `it()` bodies appear in that log neither as pass nor fail, which is only consistent with `beforeAll` failing — Bun mislabels a `beforeAll` timeout as "a beforeEach/afterEach hook". So the fixture stops shelling out: it emits a ~1.1 KB ustar archive and gzips it in-process. The hook is now synchronous and has no child to hang on. The three `it()` bodies still feed that fixture to the real `tar xzf -`, which is what proves the hand-rolled archive is valid. Also: `overlay-scripts.test.ts` routes its shell through `resolveBashPath()` rather than a bare `execFileSync('bash', …)`. It was the only bash spawn in the repo bypassing that helper, and on Windows a bare `bash` resolves through System32 to the WSL launcher instead of Git-Bash (#1326). This is consistency hardening, not a flake fix — no isolation test timeout appears in the swept corpus. Ruled out, so the next attempt does not redo it: - No production code is in this class. A repo-root sweep over `Bun.spawn`, `Bun.$`, `child_process`, and every `new Response(proc.*)` site found the pattern only in tests. Bun eagerly drains piped stdio into an internal buffer (verified with 5 MB of undrained stderr behind `await proc.exited`), so `downloadWebDist`'s own extraction step — which awaits exit before reading a piped stderr — cannot deadlock. - Exit code 130 carries no signal. Under `--filter '*' --parallel` the failing package exits 1, siblings get SIGINT, and the aggregate reports 130 or 1 nondeterministically; 130 also shows up on plain assertion failures. Scope boundary: this removes one of the three subprocess-hang sites. The `@archon/workflows` `bash node … stdout` test (2 of the 3 occurrences, most recently 07-29) spawns a real shell because that is the thing under test, and cannot be fixed this way. Runner starvation under `--filter '*' --parallel` remains the open lever in #2306. Refs #2186, #2240, #2306 * test(cli): validate the tarball fixture, and correct the cluster tally Review found the fixture could rot silently — the exact failure mode a hand-rolled binary format invites, and worse than the hang it replaced. The three extraction tests asserted only `existsSync(index.html)`. A `size` field short by 3 bytes extracts `"<html>ok</ht"` with `tar` exit 0 and all 18 tests green. Mutation-tested each corruption, applied then reverted: corruption before now size field short by 3 bytes 18 pass 2 fail (content) header checksum zeroed — 2 fail (extraction) end-of-archive terminator dropped 18 pass 1 fail (structural) block padding dropped 18 pass 1 fail (structural) So: the extraction tests now compare the extracted file's CONTENT to the declared fixture string, and two new tests check the archive against the POSIX ustar envelope (whole 512-byte blocks, trailing 1024 zero bytes). The structural pair is not redundant — local `tar` is bsdtar 3.5.3, which happily extracts an archive with no terminator and no padding, so those two corruptions passed on macOS and would have surfaced only as a platform-specific CI failure. That is the class of bug this file is being changed to remove. Still uncaught: a bad `ustar` magic string, which is benign — `tar` extracts byte-correct content regardless. Also retracting a false claim from the previous revision. It stated that `@archon/isolation`'s `.wh.` test "has never timed out" and that #2306's table mis-attributed it. Both wrong. Job 90566157638 (run 30448971374) contains: @archon/isolation:test | killed 1 dangling process @archon/isolation:test | ::error title=error: Test "`.wh.` (empty decoded name) does NOT wipe the parent dir" timed out after 5000ms:: @archon/isolation:test | (fail) apply script — C1 whiteout-name traversal … [5015.00ms] #2306's row is correct, the mechanism matches (synchronous `execFileSync` blocks the loop; child still alive at 5 s; killed as dangling), and the cluster has FOUR subprocess sites, not three. This PR removes one of four — which makes `Related` over `Closes` more correct, not less. The claim came from a sweep that missed run 30448971374, compounded by `gh run view --job <id> --log` silently returning a LATER run's logs. The API path `gh api repos/.../actions/jobs/<id>/logs` returns the right bytes. A clean grep from the wrong source is indistinguishable from evidence. The WSL hypothesis stays rejected (zero markers for `wsl`, `/mnt/c/`, `System32\bash`), so `resolveBashPath()` remains labelled hardening rather than a proven fix — but it is hardening on a real cluster site, not on a non-problem. One nuance the corrected corpus adds: the same job shows a `downloadWebDist` timeout WITHOUT the dangling marker. Presence of the marker proves a live child; absence proves nothing. Only the positive direction is load-bearing, and that is the direction the local repro establishes. Refs #2186, #2240, #2306 --------- Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
* Fix: clear stale workflow errors on resume (#2329) Resumed workflow runs retained the prior attempt's metadata error, causing completed runs to display stale SIGTERM failures. Changes: - Clear metadata.error atomically in the resume CAS - Cover PostgreSQL SQL binding and real SQLite JSON cleanup - Preserve failed-run output while suppressing cleared completed errors Fixes #2329 * fix(core): preserve the error the resume CAS clears as an audit event Clearing metadata.error on resume (#2329) assumed the failure history survives in workflow_events. For the motivating run it did not: the CLI's SIGTERM handler calls failWorkflowRun and writes neither an event nor a log line, so metadata.error was the ONLY record that the run ever failed — and the clear deleted it. resumeWorkflowRun now reads that error, runs the CAS, and inserts a workflow_resumed event carrying it, all in ONE transaction (the shape resolveApprovalGate established in #2146): - The row is pinned for the read (FOR UPDATE on Postgres; SQLite needs none — its adapter serializes transactions and a cross-process writer makes the deferred BEGIN's read→write upgrade fail busy rather than admit a stale snapshot), so the value read is the value cleared. - The event is gated on the CAS rowCount, so a losing concurrent resumer writes nothing at all. - No event when there was no prior error — a paused gate resuming clean does not gain a spurious "this failed once" record. - A failed event INSERT rolls the clear back, leaving the run resumable with its error intact. Read-then-UPDATE rather than UPDATE…RETURNING because the SQLite adapter rejects RETURNING on UPDATE and points at exactly this pattern. workflow_resumed joins WORKFLOW_EVENT_TYPES and the console normalizer's system branch, where data.error (the same key workflow_failed uses) renders as the event detail instead of falling through to raw JSON. Also drops the CLI workflowGetCommand test added alongside the original fix: it passes unchanged against dev, because the renderer already guarded on `typeof … === 'string'` and so never printed a null error. It proved nothing and must not be counted as regression coverage. The real coverage is in workflows.resume-cas.integration.test.ts, whose three new cases fail against both dev and the pre-fix commit. Verified on real SQLite; the Postgres row lock is covered by the mock suite's SQL assertion.
* Fix: detect SQLite and Postgres column drift (#2319) The schema parity suite compared table names only, allowing missing SQLite columns to ship undetected. Changes: - Parse Postgres CREATE TABLE and ALTER TABLE column declarations - Compare every shared Postgres column against fresh SQLite schema columns - Keep the #2318 allow_env_keys exception exact and retain parent_run_id index coverage Fixes #2319 * fix: address review findings Fixed:\n- cover ALTER-only PostgreSQL migration columns in the schema parity test\n- document the table and column parity exceptions\n\nTests added:\n- strengthened existing SQLite schema parity coverage\n\nSkipped:\n- none * fix(test): make schema-parity checks blind-spot proof Follow-up to the column-level parity check. Three ways it could pass while seeing nothing: 1. Table discovery had become a by-product of column-body parsing (the list came from postgresArchonColumns().keys()), so a table only registered if its full `(...);` body matched. A `)\n;` terminator made the *following* table vanish from the map entirely — re-opening the PR #2033 drift class (whole table in the migration, missing from sqlite.ts) that the original table-parity test exists to catch. Table discovery is back on a body-independent `CREATE TABLE <name>` regex. 2. The non-greedy body match ended at the first `);`, including one inside a comment. `migrations/000_combined.sql` already writes `);` in prose (lines 447, 467, 469), so this is house style, not a contrived input: an ordinary `-- see remote_agent_workflow_runs(id);` cut remote_agent_workflow_events from 7 parsed columns to 3 with the suite still green. Comments are now stripped first, and bodies are read with paren-depth tracking and split on top-level commas, so nested parens in REFERENCES / CHECK / DEFAULT clauses cannot end a body early. 3. The anti-vacuity pair only proved two named columns survived parsing — both stayed true while four others silently disappeared. Added a floor on the number of columns actually compared (136 today, verified by execution; the truncating parser scores 132). Also adds the reverse pass. isolation_environments.updated_at exists in sqlite.ts and not in the migration, and is the only reverse-direction difference in the schema with no SQLite-only tables, so the check costs one allowlist entry. Reverse drift is the works-locally / breaks-on-the-Postgres-VPS direction, which is worth a guard. Both allowlists are now self-expiring: each entry must still describe real drift, so fixing #2318 fails the suite until the entry is deleted rather than quietly leaving a live column unprotected. Comparison stays names-only — SQLite type affinity means a declared TEXT against a Postgres UUID constrains nothing, so a type map would add cost without catching a bug. * test(db): report schema drift before the vacuity floor, not after The MIN_NON_AUTH_COLUMNS floor was asserted before the missing-column check in the same test, so it could mask the drift it exists to protect. Demonstrated: two legitimate column removals plus one genuine drift made the floor fire first and the drift list never printed. Drift is now asserted first. The floor follows as an explicit throw rather than an expect(), because a bare "Expected: >= 136 / Received: 135" under a test named "every non-auth Postgres column exists in a fresh SQLite schema" reads as drift when it is either a parser regression or a legitimate removal -- the message now says which to check and how to respond. 136 kept with no headroom, deliberately: the collapse this guards cost only 4 columns (workflow_events 7 -> 3), so a lower floor would miss it. Verified: 20/20 on the clean tree; with 2 removals + 1 injected drift the run now names remote_agent_workflow_events.zz_real_drift, which the previous ordering swallowed. Raised in second-pass review of #2346.
* Fix: persist per-node workflow token usage (#2333) Workflow node token usage was only written to local JSONL logs, leaving remote event consumers without provider-neutral per-node usage data. Changes: - Persist direct AI-node token usage in node_completed events - Persist aggregated loop and loop-group token usage - Add regression coverage for all successful AI-derived completion paths Fixes #2333 * fix: address review findings Fixed:\n- cover omission of unreported token usage in completion events\n\nTests added:\n- direct AI and loop_group no-usage completion coverage\n- loop no-usage assertion\n\nSkipped:\n- none * fix(workflows): close the remaining per-node token gaps (#2333) Review findings on the initial persistence change. B1 — loop_group double-counted the persisted event stream. A loop_group's body nodes write their own namespaced node_completed rows, so persisting the group total under the same `tokens` field name meant a consumer summing node_completed rows got 600/60 for 300/30 of real usage, with nothing in the row marking it as an aggregate. Chose omission over a discriminator: the leaves are authoritative (per-provider, so usable for the cross-provider comparison the issue exists to enable) and already summable by step-name prefix, and omission means a naive consumer gets the right total without having to know about a discriminator field. The return value still carries the total — that is the run-level roll-up, which counts each group once. `cost_usd` has the same double-count shape and predates this; left as-is and noted rather than silently changed under a token fix. B2 — the `workflow:` sub-run node persisted the child's rolled-up cost but not its tokens, dropping the one axis every provider reports. Added. B3 — finalizeLoopFromSignal (bare approve at a signalled gate) wrote a node_completed with no usage for iterations that really ran. The gate now carries the pausing invocation's usage across the pause in the approval context, matching what the normal re-run completion path reports. Cost and resolved model are lost across the same gate; those belong to the single "preserve terminal provider stats across a gate" change tracked by #2345. Also normalizes the direct-node capture to `{input, output}` with a finite-value guard, mirroring the loop-node capture: `total` is provider-defined and is not input + output (Pi folds cache tokens into it), `cost` duplicates cost_usd, and a NaN would persist as a null a consumer would believe. * fix(workflows): drop the loop_group finalize token double-count (#2333) finalizeLoopFromSignal is shared by executeLoopNode and executeLoopGroupNode. Passing it the gate's signaledTokens is right for a plain loop -- its per-iteration rows carry no tokens, so the finalize row is the only record of the usage -- but wrong for a loop_group, whose body nodes persisted their own `<groupId>.<nodeId>` rows (with tokens) BEFORE the pause. Those rows survive the pause, so the finalize row repeated usage already in the same event stream: a consumer summing `data.tokens` read 200/20 for 100/10 of real usage. This is the same double-count the natural-completion group row already avoids by omitting `tokens`; the gate path had simply not been covered. - loop_group finalize call site no longer passes finalizeTokens - loop_group gate pause no longer writes signaledTokens (it had no consumer left) - the schema field and the pauseWorkflowRun explicit-null reset stay: the plain loop still writes the field, and the reset still clears a stale value from a prior gate in the same run Tests: a loop_group gate -> bare approve -> resume across ONE shared event store, asserting the naive sum over persisted node_completed rows equals the true usage. Per-test isolation is what hid this: the pre-pause body rows have to survive into the resume for the double-count to appear at all. Plus a guard that the loop_group gate context carries no signaledTokens. Also corrects a measurably false claim in the signaledTokens doc comment: earlier invocations' usage is NOT visible in the run-level totals. A pausing invocation never reaches completeWorkflowRun (status is `paused`, so the pre-complete check bails) and `total_tokens_*` are written only there -- measured on a twice-gated loop, 40/4 then 60/6, the node row and the run row both report only 60/6. That under-report is pre-existing and belongs to #2345; only the comment changed.
…2297) * ci: attach provenance and SBOM attestations to the published image * ci: restore trailing newline at end of file
Records the position taken when declining #2131 (Nix flake + Devbox): the maintained install channels are the installer, Homebrew, Docker, and the GitHub release binaries. Additional package-manager channels belong in docs as community recipes, or upstream in the package manager's own registry — not as in-repo manifests Archon version-bumps every release. Reasoning mirrors §deployment-recipes: each maintained hash-pinned channel doubles a release-critical surface, and rots silently between releases when nothing exercises it. Stating it here so the next AUR/Scoop/winget proposal gets an answer before the work is written, rather than a per-PR judgment call. Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
…#2292) findResumableRunByParentConversation ordered candidates by started_at alone, but the two statuses it can return are not interchangeable for its caller: a 'paused' run is an open approval gate that gets hydrated and resumed in the foreground, while a 'failed' one is deliberately gated behind an explicit user prompt first (#1549). Bare recency therefore lets a newer, unrelated failure shadow an older waiting gate — approving that gate resumes nothing and the user instead gets a resume prompt for the wrong run. #2075 made this more likely to surface: approve/reject no longer stage a fake 'failed', so gated runs stay 'paused' while genuine failures accumulate alongside them. Order status-first, then recency within status. Contrast documented against getActiveWorkflowRunByPath, which sorts older-wins because it answers a different question (who holds the path lock). Adds the first test coverage for this function. Closes #2289
* Fix: accumulate tokens across resumed workflow runs (#2352) Resumed DAG runs discarded token usage from earlier failed executions because resume hydration restored only node outputs. Changes: - Restore cumulative token usage from completed-node events - Seed resumed DAG token counters with prior usage - Add reconciliation and malformed-event regression coverage Fixes #2352 * fix: address review findings Fixed:\n- Log malformed persisted gate token metadata with run and node context\n- Avoid warnings for absent optional historical token usage\n- Cover resume snapshot hydration into DAG execution\n\nTests added:\n- Gate-token fallback, missing token usage, and resume hydration coverage\n\nSkipped:\n- None * simplify: reduce complexity in changed files
…ected (#2358) The command's decision tree already routes a worktree run to "use the branch you are on", but the clean-working-tree requirement sat in the sibling ON $BASE_BRANCH case with nothing scoping it there. Three archon-fix-github-issue runs stopped at the implement step over .archon/ files that Archon itself had copied into the worktree before the agent started. Hoist the disambiguation above the decision tree rather than adding another rule inside it: a dirty tree is expected in a worktree, .archon/ edits are never the implementer's to commit or remove, the clean-tree requirement applies only to the ON $BASE_BRANCH case, and this overrides a stricter git precondition imposed by a loaded skill — which is where the stricter precondition was coming from. Classify the checkout with git-dir vs git-common-dir, not git worktree list: the latter lists every worktree including the primary checkout, so it reads identically from both and would let an agent claim the worktree exemption on the primary checkout. Dirty paths outside .archon/ are likewise not a reason to stop and not the agent's to commit — stage by name, and check git diff --cached --name-only before every commit. Closes #2328
Contributor
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This was referenced Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release 0.7.0
Runtime sub-runs (
workflow:), the connected Studio builder, usage accounting you can trust, a repairedcurl | bashinstall path, and a security batch across cloning, transport, and path resolution.Added
workflow:runtime sub-run node — run another workflow as a governed child run with its ownworkflow_runsrow, artifacts, approval gates, cost line, and audit trail. The child's terminal output threads back as$<nodeId>.output, and a child gate pauses the whole tree (approve the child by run id; the parent auto-resumes on completion). Slice 1 is sequential composition in a shared checkout — dynamic fan-out, per-child worktrees,first_successracing, andwith:parameter mapping are reserved in the schema and rejected fail-fast. (feat(workflows): sub-workflow composition — include: (load-time inlining) and workflow: (runtime sub-run) primitives #2121, feat(workflows): workflow: runtime sub-run node (#2121 Phase 2 slice 1) #2169)/console/builder[/:name]loads, saves, creates, renames, and deletes real workflows through the existing CRUD endpoints, with a project picker, explicit Save behind a dirty + navigation guard, server-tier validation surfaced in the issue panel, and bundled → Save-as. (feat(web): Studio Builder PR-3 — connected mode #2051)evidence_policy: { required: true }refuses terminalcompletedunless$ARTIFACTS_DIR/evidence.jsonexists; the run is markedfailedwith a structured note, anevidence_validation_failedevent, and the expected path named. The engine gates on file presence only — what counts as valid evidence is produced by the workflow's own bash/script nodes. (feat(workflows): thin terminal-success gate — refuse completed when evidence_policy.required and evidence.json absent #2230, feat(workflows): evidence gate — refuse terminal completed when evidence_policy.required and evidence.json absent #2235)worktree.remotein.archon/config.yamlplus auto-detection (originif present → sole remote → actionable error on ambiguity), threaded through worktrees, workspace sync, PR-state lookup, forge detection, and cleanup. A repo whose only remote isn't namedoriginpreviously could not use isolation at all. (feat(git): support configurable git remote name #2234)detectForge()in@archon/gitresolves a remote to GitHub / GitLab / Gitea, including self-hosted instances viaGITHUB_URL/GITEA_URL/GITLAB_URL. Lands as the reviewed foundation for forge-agnostic adapters; no consumers wired yet, by design. (feat(git): forge detection (github/gitea/gitlab) salvaged from #1104 #2210)settingSourcesoverride for Claude nodes. (feat(workflows): per-node settingSources override for Claude nodes #2216)DISCORD_REQUIRE_MENTIONlets the Discord adapter respond without an @mention. (feat(discord): DISCORD_REQUIRE_MENTION to allow responding without @mention (#2208) #2209)ARCHON_ALLOW_ROOT_FALLBACK) for macOS bind mounts. (feat(docker): opt-in root fallback (ARCHON_ALLOW_ROOT_FALLBACK) for macOS bind mounts #2228)archon-resolve-mr-conflicts. (feat(marketplace): add archon-resolve-mr-conflicts (GitLab counterpart) #1687)Security
GIT_TERMINAL_PROMPT=0, so a clone with missing or invalid credentials fails fast instead of hanging indefinitely on an interactive prompt. The credential sanitizer gainsGITLAB_TOKEN/GITEA_TOKEN, and URL redaction is generalized from@github.com-only to the userinfo of anyscheme://user[:pass]@hostform — closing a path where a failed GitLab/Gitea clone could surface an embedded token to chat platforms and logs. (fix(core): harden clone against credential prompts and token leaks #2221)defaults/, so an uncommitted local file cannot silently ship inside a binary. (fix(bundled-defaults): refuse to embed untracked files in defaults/ #2237)Changed
tool_resultpayloads are bounded at 16 KiB at the SSE emit and message-hydration boundaries, so a multi-megabyte tool output no longer costs every viewer a full parse and full cache residency. Database writes keep the full output — the DB and logs remain the authoritative record. (fix(server): bound tool_result payloads at SSE-emit and message-hydration boundaries #2244)LIMITwindows are deterministic. (fix: add id tie-breaker to message queries so LIMIT windows are deterministic #2220)@archon/paths. (refactor(git,paths): unify owner/repo identity resolution on @archon/paths #2231)Fixed
curl -fsSL https://archon.diy/install | bashwas broken for every user and is repaired, along with the PowerShell mirror, which had drifted from it. Installer tests now run in CI to keep the two in sync, and Rosetta architecture detection on macOS no longer selects the wrong binary. (fix(install): repair the curl|bash entry point and sync the PowerShell mirror #2340, Run installer tests in CI to prevent mirror drift #2335, Fix Rosetta architecture detection in macOS installer #2330)node_failedinstead of failing quietly. (fix(workflows): emit node_failed when AI prompt substitution fails #2205)argv[1]. (fix(cli): drop Bun SFE virtual argv[1] from detached re-invoke (#2248) #2273)gh pr createto the origin repo. (fix(workflows): pin gh pr create to the origin repo in bundled defaults #2229)archon-fix-issueno longer stops on the dirty run worktree it is expected to be working in: the clean-tree requirement is scoped to the base-branch case, and the checkout is classified withgit-dirvsgit-common-dirrather thangit worktree list, which cannot distinguish them. (fix(commands): tell archon-fix-issue that a dirty run worktree is expected #2358)llms.txtcoverage is improved, and the workflow constitution is clarified as governing the YAML surface rather than prompt content. (fix(docs): repair marketplace syntax error and close the docs-build CI gap #2301, Fix: clarify cloud Docker auth setup (#1168) #2259, docs: add llms.txt link directive to HTML pages #2066, docs: improve llms.txt coverage to 95%+ #2067, docs: clarify that the workflow constitution governs the YAML surface, not prompts #2300)ARCHON_HOME. (test: stop unit tests silently reaching real network and database #2303, test(adapters): stop github adapter tests writing to a real ARCHON_HOME #2307, test(cli): build the web-dist tarball fixture in-process instead of spawning tar #2310)Merging this PR releases 0.7.0 to main.