Skip to content

feat(offload): workflow pane offload & resume (RFC §5) - #899

Merged
lavaman131 merged 18 commits into
mainfrom
specs/workflow-pane-offload-and-resume
May 9, 2026
Merged

feat(offload): workflow pane offload & resume (RFC §5)#899
lavaman131 merged 18 commits into
mainfrom
specs/workflow-pane-offload-and-resume

Conversation

@lavaman131

Copy link
Copy Markdown
Collaborator

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-versioned metadata.json, telemetry sink, and tmux/provider seams. (af03f358, a25e029b, 2a166b96, a78c8dba)
  • Per-stage offload + chrome-tab semantics: offloadSession() fires from the executor as each stage callback resolves, and from a focus-poll branch in SessionGraphPanel when the user navigates away from a pane. The eligibility check refuses to offload the user's currently-focused pane. (29003593)
  • Resume args fix for opencode/copilot: --port 0 / --ui-server --port 0 mirrors the original spawn, otherwise the resumed CLI never binds a TCP port and waitForServer times out. (a19d7fb1)
  • Claude SessionStart hook fix: matcher widened from startup to startup|resume so the claude-ready/<id> marker fires on claude --resume and waitForClaudeReady doesn't hang. (c673f62f)
  • Status rendering: offloaded (◌) and resuming… (◐) statuses with theme-aware colors and header badges, behind a single-source STATUS_TABLE. (0cd45f33, 72c23679)
  • Toast UI (RFC §5.5): top-right ToastStack component with severity (info/warning/error), auto-dismiss timer (unref()'d), and a kind-aware store API. (d8469d8e)
  • Schema-guard tests for empty agentSessionId across all three providers. (4c942544)
  • Defer-render fix for OrchestratorPanel: skips the initial null-manager render so useOffloadManager doesn't throw before attachOffloadManager fires. (a2cef007)
  • Test coverage gaps closed for shellQuote and getProductionTelemetrySink so pre-push test:coverage hits the 0.85 threshold. (e56d4300, ca6a2546)
  • Ralph reviewer fix: switches the Claude reviewer stage from agent: "reviewer" to systemPrompt.append, since the SDK silently disables outputFormat when both are passed and the loop never converges. (705e5465)
  • MCP env hardening: neutralize host git config on the ast-grep MCP server entry across all subagent configs. (6c30f9c7)
  • hello-world example: add .opencode config so the example picks up the same MCP wiring as the workspace. (8146e013)

Test plan

  • bun lint and bun typecheck — pass
  • bun test --coverage packages tests — 2535 tests, 0 fail, all coverage thresholds met
  • Manual: run a multi-stage workflow with claude/opencode/copilot, navigate away from each completed pane, observe the chrome-tab-style offload (status badge flips to offloaded ◌)
  • Manual: re-attach to an offloaded pane, observe resuming… then alive with the same agent session id
  • Manual: ralph workflow with claude — verify the reviewer loop converges (structured output reaches hasActionableFindings)
  • Manual: verify offload/resume telemetry events land in ~/.atomic/sessions/<runId>/telemetry.jsonl

flora131 and others added 18 commits May 8, 2026 18:05
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
@lavaman131
lavaman131 merged commit ef4ea7f into main May 9, 2026
24 of 25 checks passed
@lavaman131
lavaman131 deleted the specs/workflow-pane-offload-and-resume branch May 9, 2026 05:40
@claude

claude Bot commented May 9, 2026

Copy link
Copy Markdown

PR Review — feat(offload): workflow pane offload & resume

This 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

  • Strong: per-stageDir mutex on persistResume, schema-version pinning, telemetry constants centralized, idempotent op-queue, the dedicated lint-offload-await.ts rule, and the rationale-rich docstrings (especially around the Stop/SessionStart hook semantics and the --port 0 / --ui-server resume args).
  • Solid: test coverage targets the right seams — Bug 1 (cascade isolation via Error identity), Bug 2 (null-safe schema guard), claudeOffloadCleanup, doResume rollback, the buildResumeArgs trio.
  • One overall concern: the in-process OffloadManager state is not persisted across an atomic restart (in-memory sessions Map is lost). Resume only works within a live orchestrator process. That seems intentional given the RFC scope, but it deserves a clear ""out of scope"" note in offload-manager.ts so future readers don't assume durability.

Bugs / correctness

  1. doResume rollback can leave the tmux window alive while state says ""offloaded""packages/atomic-sdk/src/runtime/offload-manager.ts:489-499

    If windowCreated is true and the post-create rollback killWindow itself fails, telemetry emits RESUME_ROLLBACK_FAILED and sess.state is set to ""offloaded"". A subsequent requestResume will go through doResume again and call tmux.createWindow against a window name that already exists, which tmux new-window rejects. The user gets stuck in a permanent ""resume failed"" loop with no automated recovery. Consider either: (a) marking the session as terminally error after a failed rollback, (b) detecting the existing window in createWindow and reusing it, or (c) at minimum surfacing a toast guiding the user to manually kill the wedged window.

  2. offloadSession is awaited inside the stage's success pathpackages/atomic-sdk/src/runtime/executor.ts:2231

    await shared.offloadManager.offloadSession(name) runs before completedRegistry.set(name, result) and resolveDone(). A slow killOnePane (e.g. tmux contention, claude marker fs.rm I/O) delays dependent stages from observing this stage's done promise, serializing parallel branches. Not a correctness bug, but it eliminates parallelism gains during workflow execution. Consider firing offload via void ... .catch(...) so dependents resolve immediately, with offload completing in the background — the per-name op queue already coalesces with onWorkflowCompletion's sweep.

  3. Eligibility check reads panelStore.activeAgentId without ordering guaranteespackages/atomic-sdk/src/runtime/offload-manager.ts:420-426 and packages/atomic-sdk/src/components/session-graph-panel.tsx:419-450

    The focus-leave logic in session-graph-panel.tsx updates setViewMode first, then calls offloadSession(prevName). That works for the chrome-tab path. But the per-stage offload at executor.ts:2231 fires from a different thread of control entirely, and there's no guarantee that activeAgentId reflects the currently-displayed window — tmux focus changes are detected by a 500 ms poll, not by an immediate event. Window: a stage completes, offloadSession fires, but the user has not yet been polled into being the ""active"" focus → the eligibility check refuses to offload because some other pane is registered as focused. Stage 0 from a long-running poll cycle could silently keep the pane open. Worth adding a comment explaining this is best-effort, and verifying the onWorkflowCompletion sweep is the safety net.

  4. buildResumeCommand for Claude omits --session-id but original spawn includes itpackages/atomic-sdk/src/providers/claude.ts:451-1465

    Original spawn (line 442-450) is claude <chatFlags> --settings <path> --session-id <UUID> 'Read prompt'. Resume is claude --resume <UUID> <chatFlags> --settings <path>. That's correct if --resume <UUID> substitutes for --session-id <UUID> in Claude Code's CLI. Worth confirming the Claude CLI accepts both interchangeably and that --resume doesn't expect a fresh session-id; otherwise the resumed CLI may run in a slightly different mode than the original. (Tests cover the argv shape but not the CLI's interpretation.)

  5. Per-stage and offload cleanup overlap for Claudepackages/atomic-sdk/src/runtime/executor.ts:2208 then :2231

    cleanupProviderclearClaudeSession(paneId) already unlinks pid, ready, inflight markers. Then offloadSessionclaudeOffloadCleanup(agentSessionId) does it again (and adds claude-stop). The cleanup is idempotent (ENOENT-tolerant), so this is correct — but it's two cleanup paths doing overlapping work. Worth either consolidating into a single helper or adding a comment in killOnePane noting the redundancy is intentional and idempotent.

Style / maintainability

  1. _testOnlyGetOrStartOp is exported as a public symbolpackages/atomic-sdk/src/runtime/offload-manager.ts:324

    The _testOnly prefix doesn't actually prevent it being part of the package API surface — anyone can import it. Consider moving it to a separate internal/ module or the test file, or guard with if (process.env.NODE_ENV !== ""test"") throw.

  2. SPAWN_ENV_PREFIX_ALLOW deserves an inline rationale commentpackages/atomic-sdk/src/runtime/offload-manager.ts:33

    Allowing every OPENCODE_* and COPILOT_* env var to persist to disk is broad. Some upstream projects use those prefixes for tokens (e.g. COPILOT_API_KEY could land in disk if it ever exists). The suffix-deny _(API_KEY|...)$ catches the obvious cases, but a comment noting ""prefix allowlist is broader than denylist by design — relies on suffix-deny + audit"" would help future maintainers.

  3. session-graph-panel.tsx 500 ms tmux poll is forking subprocesses on every tickpackages/atomic-sdk/src/components/session-graph-panel.tsx:400-454

    tmux display-message spawns a subprocess synchronously every 500 ms for the entire orchestrator lifetime. On a large workflow this is hundreds of forks. Tmux supports wait-for and hooks (window-active, client-active) that could push this rather than polling. Not a regression vs. existing behavior — the same tick already runs unrelated work — but worth noting if you're tuning idle-orchestrator CPU later.

  4. Empty in-memory state on register vs. eligible-for-offload racepackages/atomic-sdk/src/runtime/offload-manager.ts:514-541

    registerSession uses getOrStartOp(\register:${name}`, ...)to queue the persist, butoffloadSessionkeys on plainname. They don't coalesce, and the per-stageDir persistResume mutex is what actually serializes disk writes. The in-memory state mutation (sessions.set(input.name, ...)) happens *synchronously* before the await, which is correct. Adding a brief comment to registerSession explaining the dual-key choice (""register:""` prefix to allow concurrent register+offload via separate op slots, mutex still serializing disk) would help — it took a few read passes to confirm the design is sound.

Tests

  1. Missing test: rollback failure leaves window-create succeeding for next attempt

    The doResume-rollback.test.ts covers rollback emission of RESUME_ROLLBACK_FAILED but I don't see a test for the follow-up resume attempt that would hit the ""window already exists"" path described in finding add agent instructions #1. Worth adding one.

  2. Missing integration test: parallel stages where one offloads while another registers in the same stageDir is covered indirectly by the cascade test, but an explicit ""register A, register B, offload A, register C"" sequencing test against a real mkdtemp would catch any future regressions in the dual-key op queue.

Security

  • Token denylist (SPAWN_ENV_SUFFIX_DENY = /_(API_KEY|AUTH_TOKEN|SECRET|TOKEN|PASSWORD)$/i) is solid. The 0o600 mode on the metadata .tmp file before rename is correct. Tokens stay in-memory at resume time (re-injected from sess.spawnEnv), as documented.
  • One small note: telemetry events include runId, name, agent, and error messages — make sure error message strings (e.g. from createWindow failing) cannot contain credentials before they land in ~/.atomic/sessions/<runId>/telemetry.jsonl. Stack traces from the providers are usually fine; but if a future provider error includes URL with embedded basic-auth, that would persist. Probably defer to a follow-up.

Conformance with CLAUDE.md

  • All Bun-only conventions followed (Bun.write, Bun.spawn, no node/npm).
  • No any/unknown types in the new public APIs (good).
  • New deps: none.
  • No new *.md files created beyond the spec (correct).

Bottom line

This 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)

lavaman131 added a commit that referenced this pull request Jun 29, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants