feat(offload): workflow pane offload & resume (RFC §5) - #899
Conversation
Chrome-tab-style offload for non-headless workflow panes: kill idle agent CLIs at workflow completion and reconnect via the agent's native --resume flag (claude --resume <id>, opencode --session <id>, copilot --resume=<id>) when the user navigates back to a pane.
…ypes Implements RFC §5.1 + §5.9: defines the TypeScript interfaces for the metadata.json resume sub-object and the extended top-level metadata shape, plus a JSON round-trip test covering all fields, null/number offloadedAt, optional error, and multi-key spawnEnv. Also stages co-authored provider buildResume helpers and tmux killWindow changes from parallel agents to restore typecheck consistency on branch. Pre-existing typecheck failures in deep-research-codebase (codegraph missing dep + scout.ts/scratch.ts unknown types) existed before this commit and cannot be fixed here — they predate the first commit on this branch.
…mpotency primitive Implements task #12 from the workflow-pane offload & resume RFC (§5.2). - Exports OffloadManager interface, OffloadManagerDeps interface, and createOffloadManager factory - registerSession stores entry with state "alive"; getStatus returns "alive" for unknown names (defensive) - onWorkflowCompletion / requestResume are TODO stubs rejecting with exact sentinel messages for task-2 / task-13 - _testOnlyGetOrStartOp (module-level, accepts optional queue param) is the idempotency primitive; instance-level wrapper uses per-manager queue - persistResume (task #10) co-located in same file as specified - 6 state-machine unit tests in offload-manager.skeleton.test.ts covering all required behaviours; all pass Pre-existing typecheck failures in deep-research-codebase (missing @colbymchenry/codegraph) are unrelated to this change.
…nents Extend SessionData.status union with offloaded and resuming values and wire up distinct icons, colors, labels, and header CountBadges for both. Add status-helpers.test.ts covering all new and regression paths.
…l three providers Covers empty string, null, and omitted agentSessionId for buildClaudeResumeArgs, buildOpencodeResumeArgs, and buildCopilotResumeArgs. Also adds canary asserting no sentinel Enter token in valid-session return values.
…FC §5.5) Add startMs param (default Date.now()) and replace fsAccess with fsStat mtime check so stale pre-resume marker files are skipped. Add injectable markerBaseDir seam for unit testing. Export _waitForClaudeReadyForTest. Add executor.waitForClaudeReady.test.ts covering stale-rejected, fresh-accepted, and mid-wait-written scenarios.
- Add WORKFLOW_OFFLOAD_CLAUDE_MARKER_CLEANUP local constant. - Extend OffloadManagerDeps with optional claudeOffloadCleanup for testability; defaults to real impl from providers/claude.ts. - In killOnePane, after persistResume and before tmux.killWindow, call cleanup for claude sessions and emit WORKFLOW_OFFLOAD_CLAUDE_MARKER_CLEANUP. Errors from cleanup are swallowed so kill always proceeds. - Pass real claudeOffloadCleanup at executor.ts construction site. - Add offload-manager.claudeMarkerCleanup.test.ts (4 tests).
Toasts are rendered in a dedicated `ToastStack` component anchored top-right, replacing the bottom-right inline box. Each toast carries a `kind` (`info` | `warning` | `error`) drawn from a per-severity color + icon scheme (RFC §5.5), and the store auto-dismisses after a configurable TTL via an `unref()`'d setTimeout so the timer never blocks process exit. `dismissToast` clears the pending timer to prevent late-fire on already-dismissed entries. Assistant-model: Claude Code
…args Without `--port 0` (opencode) and `--ui-server --port 0` (copilot), the resumed CLI starts in interactive mode and never binds a TCP port, so `waitForServer` times out at 15s during offload→resume and the pane respawn fails with `RESUME_TIMEOUT`. Mirror the original spawn args from `executor.ts buildSpawnArgs` in `buildOpencodeResumeArgs` and `buildCopilotResumeArgs`. Update the build-resume test suites to assert the new prefix is present and ordered before `--session` / `--resume=<id>`, and that `chatFlags` are appended after the resume token (not before the server flags). Assistant-model: Claude Code
…ady fires on respawn The `SessionStart` hook with matcher `startup` only fires for fresh spawns; `claude --resume <id>` triggers a `resume` SessionStart that the original matcher silently ignored. As a result, the `claude-ready/<id>` marker was never written after offload→resume and `waitForClaudeReady` blocked until its timeout, surfacing as a stuck "resuming…" status in the panel. Widen the matcher to `startup|resume` so the hook writes the marker for both spawn paths, and switch the `claudeOffloadCleanup` `rm` import from a deferred dynamic import to a top-level static import for consistency with the rest of the provider. Assistant-model: Claude Code
…rigger Idle agent CLIs from earlier stages were accumulating memory until workflow completion. This adds an `OffloadManager.offloadSession(name)` method that the executor calls as soon as each stage callback resolves, and a focus-poll branch in `SessionGraphPanel` that fires `offloadSession` on the pane the user just navigated away from. Behavior matches Chrome-tab semantics: never offload the pane the user is currently reading. The eligibility check inside `isEligibleForOffload` uses `panelStore.activeAgentId`, which the focus poll updates *before* the offload call so the manager sees the already-updated focus. Single-stage workflows offload only after the user returns to the orchestrator window — by design, the user controls when their active view goes dormant. `offloadSession` is idempotent: it no-ops on unknown sessions, headless sessions, sessions not in `alive` state, and already-offloaded sessions. It coalesces with `onWorkflowCompletion` via the per-name op queue, which is filtered to the still-alive subset so the workflow-completion sweep doesn't double-kill panes that per-stage offload already reaped. Test coverage: - `offloadSession` skips/honors focus, no-ops on unknown/headless/ already-offloaded. - `onWorkflowCompletion` skips already-offloaded sessions. - Focus-leave reproducer in the panel test asserts the offload fires on stage→orchestrator and stage→stage transitions but not on the first poll tick or when the user lands on the same window. Assistant-model: Claude Code
`statusColor` / `statusLabel` / `statusIcon` each carried their own inline status map, drifting apart whenever a new status was added. Centralize the three lookups behind a single typed `STATUS_TABLE` keyed by `SessionStatus`, with each helper falling through the shared `lookup()` so unknown strings still return the existing default. Pure refactor — same external behavior, same fallback values, no status keys added or removed. Assistant-model: Claude Code
…ormat fires
The Claude Agent SDK silently disables `outputFormat: { type:
"json_schema", schema }` when an `agent: <name>` is also passed to
`query()` — the persona's system prompt takes the main thread and the
schema-enforcement turn never runs, so `result.structured_output` is
absent and `lastStructuredOutput` is `undefined`. The reviewer's
verdict therefore never reaches `hasActionableFindings`, the loop
falls through to the conservative non-empty-raw-text branch, and the
ralph loop iterates indefinitely without ever converging on a clean
review.
Move the reviewer persona body and tool whitelist into a dedicated
`claude-reviewer.ts` helper and pass them via `systemPrompt: { type:
"preset", preset: "claude_code", append: REVIEWER_PERSONA_BODY }` plus
an explicit `tools: [...REVIEWER_TOOLS]`. The persona stays read-only
(Edit/Write/NotebookEdit excluded) and `outputFormat` is now honored,
so the structured verdict reaches the loop and convergence works.
Assistant-model: Claude Code
`uvx --from git+...` invokes `git clone` under the hood; without an empty git config it inherits any host-level `insteadOf` rewrites, GPG signing requirements, or hooks, all of which can fail the install in CI sandboxes or hardened user environments. Set `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null` on the ast-grep MCP server entry across all five codebase-* subagent configs (Claude Code under `.claude/agents/`, Copilot CLI under `.github/agents/`) plus `.opencode/opencode.json`. On POSIX this points at the actual null device; on Windows the path is missing and git falls through to an empty config, which is exactly what we want. `.opencode/opencode.json` was also reformatted from compact-array to multi-line per the standard `opencode` schema layout. Assistant-model: Claude Code
Mirrors the structure used by other examples: a local `opencode.json` declaring the github-mcp-server (remote, auth via `GH_TOKEN`) and a disabled azure-devops MCP placeholder, plus a sibling `.gitignore` that excludes the runtime artifacts (`node_modules`, lockfiles, generated `package.json`). Running `opencode` from the example dir now picks up the same MCP wiring as the rest of the workspace. Assistant-model: Claude Code
`shell-quote.ts` was at 0% coverage because its only consumer is the
coverage-excluded `executor.ts` and every test that wires
`OffloadManagerDeps.shellQuote` mocks it with `argv.join(" ")`. The
real implementation never executed in CI, which tripped the per-file
0.85 coverage threshold on pre-push.
Cover the four observable behaviors of the helper directly: plain
single-quoting, embedded-single-quote escape via the classic `'\\''`
sequence, empty-string and empty-argv edge cases, and shell-metachar
literal preservation under single quotes.
Assistant-model: Claude Code
`OrchestratorPanel.createWithRenderer` calls `renderTree(null)` in its constructor before `attachOffloadManager` can fire, and `SessionGraphPanel` calls `useOffloadManager()` unconditionally — which threw "useOffloadManager must be used within OffloadManagerContext. Provider". The ErrorBoundary swallowed the throw at runtime, but in test runs Bun reports the underlying React error to stderr, so `bun test --coverage` exited non-zero on pre-push. Gate the `<SessionGraphPanel />` element on a non-null `offloadManager` inside the render tree. The initial render with `null` now produces an empty subtree (no React error, no fallback flash); the subsequent `attachOffloadManager` rerender mounts the panel for real with the real manager. Prod ordering is unchanged — attach has always followed construction immediately. Assistant-model: Claude Code
`getProductionTelemetrySink` was at 83.33% function coverage — specifically the `.catch()` lambda inside `emit` was untested, which tripped the per-file 0.85 coverage threshold on pre-push. Cover the four observable behaviors of the sink: a single emit appends a JSON line under `<baseDir>/<runId>/telemetry.jsonl`, three concurrent emits all land (order is not guaranteed by the fire-and-forget contract, so events are sorted before comparison), the run subdirectory is created lazily on first emit (sink construction is side-effect-free), and an `appendFile` rejection is caught and reported via `console.warn` without throwing. Assistant-model: Claude Code
PR Review — feat(offload): workflow pane offload & resumeThis is a substantial, well-architected feature with strong test coverage (28+ new test files, 2535 tests passing per the PR description). The state machine, schema-versioned metadata, telemetry, and lint guards are all thoughtful design choices. Below are findings, ordered by impact. Overall
Bugs / correctness
Style / maintainability
Tests
Security
Conformance with CLAUDE.md
Bottom lineThis is well-architected and the test coverage is thorough. The most actionable item is finding #1 (rollback wedge) — that has a real chance of stranding users. #2 (await-blocking parallelism) and #3 (focus race in eligibility) are worth a second look but may be acceptable given the RFC's scope. Everything else is polish. — Review by Claude Code (claude-opus-4-7) |
* docs(spec): add workflow pane offload & resume RFC Chrome-tab-style offload for non-headless workflow panes: kill idle agent CLIs at workflow completion and reconnect via the agent's native --resume flag (claude --resume <id>, opencode --session <id>, copilot --resume=<id>) when the user navigates back to a pane. * feat(offload): add OffloadResumeMetadata and MetadataJsonWithResume types Implements RFC §5.1 + §5.9: defines the TypeScript interfaces for the metadata.json resume sub-object and the extended top-level metadata shape, plus a JSON round-trip test covering all fields, null/number offloadedAt, optional error, and multi-key spawnEnv. Also stages co-authored provider buildResume helpers and tmux killWindow changes from parallel agents to restore typecheck consistency on branch. Pre-existing typecheck failures in deep-research-codebase (codegraph missing dep + scout.ts/scratch.ts unknown types) existed before this commit and cannot be fixed here — they predate the first commit on this branch. * feat(offload): add OffloadManager skeleton with state machine and idempotency primitive Implements task #12 from the workflow-pane offload & resume RFC (§5.2). - Exports OffloadManager interface, OffloadManagerDeps interface, and createOffloadManager factory - registerSession stores entry with state "alive"; getStatus returns "alive" for unknown names (defensive) - onWorkflowCompletion / requestResume are TODO stubs rejecting with exact sentinel messages for task-2 / task-13 - _testOnlyGetOrStartOp (module-level, accepts optional queue param) is the idempotency primitive; instance-level wrapper uses per-manager queue - persistResume (task #10) co-located in same file as specified - 6 state-machine unit tests in offload-manager.skeleton.test.ts covering all required behaviours; all pass Pre-existing typecheck failures in deep-research-codebase (missing @colbymchenry/codegraph) are unrelated to this change. * feat(offload): add offloaded/resuming status rendering in panel components Extend SessionData.status union with offloaded and resuming values and wire up distinct icons, colors, labels, and header CountBadges for both. Add status-helpers.test.ts covering all new and regression paths. * test(providers): add RFC §5.4 empty agentSessionId guard tests for all three providers Covers empty string, null, and omitted agentSessionId for buildClaudeResumeArgs, buildOpencodeResumeArgs, and buildCopilotResumeArgs. Also adds canary asserting no sentinel Enter token in valid-session return values. * feat(offload): add mtime belt-and-suspenders to waitForClaudeReady (RFC §5.5) Add startMs param (default Date.now()) and replace fsAccess with fsStat mtime check so stale pre-resume marker files are skipped. Add injectable markerBaseDir seam for unit testing. Export _waitForClaudeReadyForTest. Add executor.waitForClaudeReady.test.ts covering stale-rejected, fresh-accepted, and mid-wait-written scenarios. * feat(offload): wire claudeOffloadCleanup into OffloadManager.killOnePane - Add WORKFLOW_OFFLOAD_CLAUDE_MARKER_CLEANUP local constant. - Extend OffloadManagerDeps with optional claudeOffloadCleanup for testability; defaults to real impl from providers/claude.ts. - In killOnePane, after persistResume and before tmux.killWindow, call cleanup for claude sessions and emit WORKFLOW_OFFLOAD_CLAUDE_MARKER_CLEANUP. Errors from cleanup are swallowed so kill always proceeds. - Pass real claudeOffloadCleanup at executor.ts construction site. - Add offload-manager.claudeMarkerCleanup.test.ts (4 tests). * feat(ui): top-right toast stack with severity + auto-dismiss Toasts are rendered in a dedicated `ToastStack` component anchored top-right, replacing the bottom-right inline box. Each toast carries a `kind` (`info` | `warning` | `error`) drawn from a per-severity color + icon scheme (RFC §5.5), and the store auto-dismisses after a configurable TTL via an `unref()`'d setTimeout so the timer never blocks process exit. `dismissToast` clears the pending timer to prevent late-fire on already-dismissed entries. Assistant-model: Claude Code * fix(providers): include server-mode flags in opencode/copilot resume args Without `--port 0` (opencode) and `--ui-server --port 0` (copilot), the resumed CLI starts in interactive mode and never binds a TCP port, so `waitForServer` times out at 15s during offload→resume and the pane respawn fails with `RESUME_TIMEOUT`. Mirror the original spawn args from `executor.ts buildSpawnArgs` in `buildOpencodeResumeArgs` and `buildCopilotResumeArgs`. Update the build-resume test suites to assert the new prefix is present and ordered before `--session` / `--resume=<id>`, and that `chatFlags` are appended after the resume token (not before the server flags). Assistant-model: Claude Code * fix(providers/claude): match resume in SessionStart hook so claude-ready fires on respawn The `SessionStart` hook with matcher `startup` only fires for fresh spawns; `claude --resume <id>` triggers a `resume` SessionStart that the original matcher silently ignored. As a result, the `claude-ready/<id>` marker was never written after offload→resume and `waitForClaudeReady` blocked until its timeout, surfacing as a stuck "resuming…" status in the panel. Widen the matcher to `startup|resume` so the hook writes the marker for both spawn paths, and switch the `claudeOffloadCleanup` `rm` import from a deferred dynamic import to a top-level static import for consistency with the rest of the provider. Assistant-model: Claude Code * feat(offload): per-stage offloadSession with chrome-tab focus-leave trigger Idle agent CLIs from earlier stages were accumulating memory until workflow completion. This adds an `OffloadManager.offloadSession(name)` method that the executor calls as soon as each stage callback resolves, and a focus-poll branch in `SessionGraphPanel` that fires `offloadSession` on the pane the user just navigated away from. Behavior matches Chrome-tab semantics: never offload the pane the user is currently reading. The eligibility check inside `isEligibleForOffload` uses `panelStore.activeAgentId`, which the focus poll updates *before* the offload call so the manager sees the already-updated focus. Single-stage workflows offload only after the user returns to the orchestrator window — by design, the user controls when their active view goes dormant. `offloadSession` is idempotent: it no-ops on unknown sessions, headless sessions, sessions not in `alive` state, and already-offloaded sessions. It coalesces with `onWorkflowCompletion` via the per-name op queue, which is filtered to the still-alive subset so the workflow-completion sweep doesn't double-kill panes that per-stage offload already reaped. Test coverage: - `offloadSession` skips/honors focus, no-ops on unknown/headless/ already-offloaded. - `onWorkflowCompletion` skips already-offloaded sessions. - Focus-leave reproducer in the panel test asserts the offload fires on stage→orchestrator and stage→stage transitions but not on the first poll tick or when the user lands on the same window. Assistant-model: Claude Code * refactor(ui): extract STATUS_TABLE single-source for status helpers `statusColor` / `statusLabel` / `statusIcon` each carried their own inline status map, drifting apart whenever a new status was added. Centralize the three lookups behind a single typed `STATUS_TABLE` keyed by `SessionStatus`, with each helper falling through the shared `lookup()` so unknown strings still return the existing default. Pure refactor — same external behavior, same fallback values, no status keys added or removed. Assistant-model: Claude Code * fix(workflows/ralph): use systemPrompt.append for reviewer so outputFormat fires The Claude Agent SDK silently disables `outputFormat: { type: "json_schema", schema }` when an `agent: <name>` is also passed to `query()` — the persona's system prompt takes the main thread and the schema-enforcement turn never runs, so `result.structured_output` is absent and `lastStructuredOutput` is `undefined`. The reviewer's verdict therefore never reaches `hasActionableFindings`, the loop falls through to the conservative non-empty-raw-text branch, and the ralph loop iterates indefinitely without ever converging on a clean review. Move the reviewer persona body and tool whitelist into a dedicated `claude-reviewer.ts` helper and pass them via `systemPrompt: { type: "preset", preset: "claude_code", append: REVIEWER_PERSONA_BODY }` plus an explicit `tools: [...REVIEWER_TOOLS]`. The persona stays read-only (Edit/Write/NotebookEdit excluded) and `outputFormat` is now honored, so the structured verdict reaches the loop and convergence works. Assistant-model: Claude Code * chore(agents): neutralize host git config for ast-grep MCP server `uvx --from git+...` invokes `git clone` under the hood; without an empty git config it inherits any host-level `insteadOf` rewrites, GPG signing requirements, or hooks, all of which can fail the install in CI sandboxes or hardened user environments. Set `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null` on the ast-grep MCP server entry across all five codebase-* subagent configs (Claude Code under `.claude/agents/`, Copilot CLI under `.github/agents/`) plus `.opencode/opencode.json`. On POSIX this points at the actual null device; on Windows the path is missing and git falls through to an empty config, which is exactly what we want. `.opencode/opencode.json` was also reformatted from compact-array to multi-line per the standard `opencode` schema layout. Assistant-model: Claude Code * chore(examples): add .opencode config to hello-world example Mirrors the structure used by other examples: a local `opencode.json` declaring the github-mcp-server (remote, auth via `GH_TOKEN`) and a disabled azure-devops MCP placeholder, plus a sibling `.gitignore` that excludes the runtime artifacts (`node_modules`, lockfiles, generated `package.json`). Running `opencode` from the example dir now picks up the same MCP wiring as the rest of the workspace. Assistant-model: Claude Code * test(offload): add unit tests for shellQuote helper `shell-quote.ts` was at 0% coverage because its only consumer is the coverage-excluded `executor.ts` and every test that wires `OffloadManagerDeps.shellQuote` mocks it with `argv.join(" ")`. The real implementation never executed in CI, which tripped the per-file 0.85 coverage threshold on pre-push. Cover the four observable behaviors of the helper directly: plain single-quoting, embedded-single-quote escape via the classic `'\\''` sequence, empty-string and empty-argv edge cases, and shell-metachar literal preservation under single quotes. Assistant-model: Claude Code * fix(ui): defer SessionGraphPanel render until OffloadManager is attached `OrchestratorPanel.createWithRenderer` calls `renderTree(null)` in its constructor before `attachOffloadManager` can fire, and `SessionGraphPanel` calls `useOffloadManager()` unconditionally — which threw "useOffloadManager must be used within OffloadManagerContext. Provider". The ErrorBoundary swallowed the throw at runtime, but in test runs Bun reports the underlying React error to stderr, so `bun test --coverage` exited non-zero on pre-push. Gate the `<SessionGraphPanel />` element on a non-null `offloadManager` inside the render tree. The initial render with `null` now produces an empty subtree (no React error, no fallback flash); the subsequent `attachOffloadManager` rerender mounts the panel for real with the real manager. Prod ordering is unchanged — attach has always followed construction immediately. Assistant-model: Claude Code * test(offload): add unit tests for telemetry sink `getProductionTelemetrySink` was at 83.33% function coverage — specifically the `.catch()` lambda inside `emit` was untested, which tripped the per-file 0.85 coverage threshold on pre-push. Cover the four observable behaviors of the sink: a single emit appends a JSON line under `<baseDir>/<runId>/telemetry.jsonl`, three concurrent emits all land (order is not guaranteed by the fire-and-forget contract, so events are sorted before comparison), the run subdirectory is created lazily on first emit (sink construction is side-effect-free), and an `appendFile` rejection is caught and reported via `console.warn` without throwing. Assistant-model: Claude Code --------- Co-authored-by: Norin Lavaee <nlavaee@umich.edu>
Summary
Implements RFC §5: Workflow Pane Offload & Resume end-to-end — keeps long-running workflows from accumulating idle agent CLIs by killing each tmux pane + agent process as soon as its stage completes (or as soon as the user navigates away from it), and respawns the pane via
<provider> --resume <id>when the user attaches again.What's in this PR
OffloadManager: state machine (alive/offloaded/resuming), per-name op queue for idempotency, schema-versionedmetadata.json, telemetry sink, and tmux/provider seams. (af03f358,a25e029b,2a166b96,a78c8dba)offloadSession()fires from the executor as each stage callback resolves, and from a focus-poll branch inSessionGraphPanelwhen the user navigates away from a pane. The eligibility check refuses to offload the user's currently-focused pane. (29003593)--port 0/--ui-server --port 0mirrors the original spawn, otherwise the resumed CLI never binds a TCP port andwaitForServertimes out. (a19d7fb1)startuptostartup|resumeso theclaude-ready/<id>marker fires onclaude --resumeandwaitForClaudeReadydoesn't hang. (c673f62f)offloaded(◌) andresuming…(◐) statuses with theme-aware colors and header badges, behind a single-sourceSTATUS_TABLE. (0cd45f33,72c23679)ToastStackcomponent with severity (info/warning/error), auto-dismiss timer (unref()'d), and a kind-aware store API. (d8469d8e)agentSessionIdacross all three providers. (4c942544)OrchestratorPanel: skips the initial null-manager render souseOffloadManagerdoesn't throw beforeattachOffloadManagerfires. (a2cef007)shellQuoteandgetProductionTelemetrySinkso pre-push test:coverage hits the 0.85 threshold. (e56d4300,ca6a2546)agent: "reviewer"tosystemPrompt.append, since the SDK silently disablesoutputFormatwhen both are passed and the loop never converges. (705e5465)6c30f9c7).opencodeconfig so the example picks up the same MCP wiring as the workspace. (8146e013)Test plan
bun lintandbun typecheck— passbun test --coverage packages tests— 2535 tests, 0 fail, all coverage thresholds metoffloaded ◌)resuming…thenalivewith the same agent session idhasActionableFindings)~/.atomic/sessions/<runId>/telemetry.jsonl