fix(server): resume after self_exit fails with 'resumeAgentId not found' - #254
Merged
Conversation
`self_exit` issued a hard `DELETE /api/agents/:id`, which rmSync'd the agent record off disk and dropped it from the in-memory cache. The resume path (`spawnAgent` with `resumeAgentId`) looks the agent up via `getAgent(id)`, which then returned undefined → `throw resumeAgentId "<id>" not found`. This was asymmetric with `kill_agent`, which soft-exits via `POST /:id/kill` → `killAttachment` → `markExited` and KEEPS the record as `status:"exited"` (resumable). The contradiction has existed since the #165 Agent+Session unification — self_exit has hard-DELETEd since #124, while resume began requiring a persisted exited record. Not a regression from #249 (UI-only) or #237 (Codex providerThreadId only). Fix: route `self_exit` through the same soft-exit path as kill/crash so the record survives and stays resumable, with an honest exit reason: - `killAttachment(id, reason: ExitReason = "user_killed")` — parametrize reason - `POST /:id/kill` accepts an optional `{reason}` body, validated against the ExitReason union (defaults to user_killed; logs unrecognized values instead of silently coercing them) - `self_exit` POSTs `/:id/kill {reason:"self_exited"}` instead of DELETE - `ExitReason` now derives from a single `EXIT_REASONS` tuple + `isExitReason` typeguard (drift-proof: a new reason can't desync a hand-maintained allowlist) Verified end-to-end against a live dev server: spawn → self_exit → record kept as exited/self_exited (HTTP 200, was 404) → resume succeeds (HTTP 201, was "not found"); DELETE still hard-deletes (404 after). Regression test added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0158xdYsvWMjXfEXP4n9fVCd
aterrylu
enabled auto-merge (squash)
June 26, 2026 08:33
nox-0x
approved these changes
Jun 26, 2026
nox-0x
left a comment
Collaborator
There was a problem hiding this comment.
Approving — clean fix for the self_exit / resume asymmetry, and the implementation is tight.
Reviewed the incremental diff. Verified:
- Diagnosis is correct.
runtime.ts:243-245doesgetAgent(params.resumeAgentId)and throwsresumeAgentId "<id>" not foundon undefined — exactly the symptom. Routing self_exit throughPOST /:id/kill→killAttachment→markExited("self_exited")keeps the record on disk so the resume lookup resolves. killAttachmentsignature change is backward-compatible. The newreason: ExitReason = "user_killed"parameter is defaulted; the lone other caller (mcp.ts:249, single-arg) is unaffected./:id/killbody parsing is safe.c.req.json().catch(() => null)handles the no-body case (dashboardkillSessionatstore.ts:1192andkill_agentMCP atchannel-server/index.ts:435both send none) → silentuser_killeddefault. Unknown reasons get logged, not silently coerced — good call.isExitReason+EXIT_REASONSsingle-source-of-truth removes the desync risk that motivated the type refactor.serverFetcherror surfacing inchannel-server/index.ts:520-524is correct. ConfirmedserverFetchresolves (not rejects) on non-2xx with{ isError: true }(seechannel-server/index.ts:235-241), so the prior.catch-only path really was blind to HTTP-level failures.markExitedis idempotent ("first reason wins",store.ts:341), so the inevitable onExit handler firing afterpty.kill()won't overwrite"self_exited"with"crashed"on disk. (There's a pre-existing stale-WS-delta race inruntime.ts:605-623where the onExit handler emits anagent.exiteddelta with its locally-computed reason regardless of whatmarkExitedactually persisted — but that's not introduced by this PR and applies equally to operator kill.)- Bundle (
dist.mjs) regenerated in sync withchannel-server/index.ts. - Behavior-change trade-offs are honest. Self-exiting manager's reports no longer reparenting is intentional and symmetric with operator kill; unbounded exited-record growth from self_exit now matches existing kill/crash paths (already unpruned) — a separate retention prune is a reasonable follow-up, not a blocker.
Tests cover the store-level invariant the fix hinges on. Ship it.
aterrylu
added a commit
that referenced
this pull request
Jun 26, 2026
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UYB9dBo9Ap9ViBYKERneu7
aterrylu
added a commit
that referenced
this pull request
Jun 26, 2026
…e) (#257) * feat(settings): per-provider permission modes (replaces autonomousMode) Replace the coarse `autonomousMode: boolean` with a provider-agnostic `permissionMode` enum (default | auto | plan | bypass) that each provider maps to its native permission surface: - Claude Code: --permission-mode default|acceptEdits|plan, with the legacy --dangerously-skip-permissions for bypass - Gemini CLI: --approval-mode default|auto_edit|plan|yolo - Codex: approval_policy=on-request|on-failure|never (sandbox stays danger-full-access — autonomOS is the trust boundary). No plan mode, so plan is disabled for Codex in the UI and clamps to default with a warning. Settings holds a global default; Create Agent overrides per spawn. A shared PermissionModeSelect renders a "?" current-selection explainer sourced from core's PERMISSION_MODE_INFO (single source of truth). Templates carry their own default mode. Migration is accept-and-discard at every persisted layer (agent records, sessions.json, user templates, dashboard localStorage): autonomousMode:true -> bypass, false -> default; the old field is scrubbed with a warning. An unspecified mode resolves to bypass to preserve prior behavior. See ADR-045. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UYB9dBo9Ap9ViBYKERneu7 * docs(decisions): record PR number in ADR-045 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UYB9dBo9Ap9ViBYKERneu7 * test: migrate #254 resume test fixture to permissionMode Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UYB9dBo9Ap9ViBYKERneu7 * test: prove upgrade backward-compat for legacy autonomousMode records Add store-migration.test.ts covering the main upgrade path: an old server's on-disk per-agent records (autonomousMode boolean, no permissionMode) load under the new store without crashing on the now-required field, derive the correct mode (true->bypass, false->default, missing->default), coerce a malformed value, and scrub the legacy field on disk on next write. Also harden templates-migration.test.ts to do all fs/env setup inside before() rather than at module top-level (avoids import-time side effects that can crash the Linux e2e collector). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UYB9dBo9Ap9ViBYKERneu7 * test: pin prompt-delivery integration spawn to permissionMode default The raw POST /api/agents path's pre-ADR-045 default resolved to supervised ((body.autonomousMode ?? tmpl?.autonomousMode) === true is false when absent). Unifying to DEFAULT_PERMISSION_MODE=bypass flipped it to bypass, which emits --dangerously-skip-permissions — refused by Claude Code when the CI runner is root, so the agent died code=1 and the prompt never delivered. Prompt delivery is permission-mode-agnostic, so pin this raw-REST test to default mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UYB9dBo9Ap9ViBYKERneu7 * test(integration): set IS_SANDBOX so root CI can spawn bypass-mode agents The spawn default is now 'bypass' (--dangerously-skip-permissions), which Claude Code refuses as root — the CI runner. The real-spawn integration suites (agent-spawn-prompt, usage-queue, embedded-mode) spawn via the default and so died code=1 as root. IS_SANDBOX=1 is Claude Code's documented escape hatch for ephemeral CI sandboxes; bootEmbedded spreads it into every spawned agent, so the suites now exercise the real production-default spawn instead of dying. Reverts the earlier per-test permissionMode pin in favor of this systemic fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UYB9dBo9Ap9ViBYKERneu7 * fix(claude): 'default' mode emits no flag; pin integration spawns to default Two coupled fixes for the bypass-default-in-CI breakage: 1. claude-code 'default' mode now emits NO permission flag instead of --permission-mode default. It IS claude's built-in behavior, so the flag was redundant — and it perturbed the interactive TUI startup. This matches the pre-ADR-045 flag-less supervised spawn exactly. 2. The real-spawn integration suites (agent-spawn-prompt, usage-queue, usage-queue-sim, embedded-mode) POST with no mode, which now defaults to bypass (--dangerously-skip-permissions) — which the real claude binary exits 1 on in CI. Pin them to permissionMode 'default' (supervised): they test spawn mechanics, not permission semantics. Reverts the ineffective IS_SANDBOX attempt. Product default stays bypass (Terry's call, pending). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UYB9dBo9Ap9ViBYKERneu7 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Problem
Resuming an agent that called
self_exitfails withresumeAgentId "<id>" not found.Root cause: two "terminate" paths that look equivalent are asymmetric:
kill_agentPOST /api/agents/:id/kill→killAttachment→markExitedstatus:"exited"self_exitDELETE /api/agents/:id→deleteAgentRaw→rmSync+cache.deleteThe resume path (
spawnAgentwithresumeAgentId) looks the agent up viagetAgent(id)— a pure cache/disk read. Afterself_exitthe record wasrmSync'd off disk, sogetAgentreturnsundefinedandruntime.tsthrowsresumeAgentId "<id>" not found.The reporting agent's theory ("autonomOS purges the session record on self_exit") was correct — the precise mechanism is the DELETE route's
rmSync, notmarkExited.sequenceDiagram participant A as Agent participant CS as channel-server participant API as server API participant Store as agents store rect rgb(255,235,235) note over A,Store: BEFORE — self_exit hard-deletes, resume fails A->>CS: self_exit CS->>API: DELETE /api/agents/:id API->>Store: deleteAgentRaw → rmSync + cache.delete Note over Store: record GONE A-->>API: create_agent({resumeSessionId}) API->>Store: getAgent(id) → undefined API-->>A: ❌ resumeAgentId "id" not found end rect rgb(235,255,235) note over A,Store: AFTER — self_exit soft-exits, resume works A->>CS: self_exit CS->>API: POST /:id/kill {reason:"self_exited"} API->>Store: killAttachment → markExited("self_exited") Note over Store: record KEPT (status: exited) A-->>API: create_agent({resumeSessionId}) API->>Store: getAgent(id) → record ✓ API-->>A: ✅ resumed (status: running) endNot a recent regression
self_exithas hard-DELETEd since it was introduced (#124); #165 retargeted it/api/sessions→/api/agents(per-file hard delete) at the same time the resume feature began requiring a persisted exited record — a latent design conflict since the #165 unification. Verified not from #249 (UI-only, zero server files) or #237 (CodexproviderThreadIdpath only).Solution
Route
self_exitthrough the same soft-exit path as kill/crash, keeping the record resumable with an honest exit reason:agents/runtime.ts—killAttachment(id, reason: ExitReason = "user_killed")parametrized; threadsreasonintomarkExited+ theagent.exitedWS delta.routes/agents.ts—POST /:id/killaccepts an optional{reason}body, validated viaisExitReason(defaults touser_killed; logs unrecognized values rather than silently coercing). Guards anullJSON body.channel-server/index.ts—self_exitPOSTs/:id/kill {reason:"self_exited"}instead ofDELETE. Also surfaces HTTP-level kill failures to stderr (serverFetchresolves non-2xx asisError, so the prior.catchcouldn't see them). Bundle (dist.mjs) regenerated.core/types/agent.ts—ExitReasonnow derives from a singleEXIT_REASONStuple +isExitReasontypeguard, so adding a reason can't desync a hand-maintained allowlist.Testing
Unit (
resume-after-self-exit.test.ts, 5 tests): self-exited record stays findable across in-memory + on-disk reload; olddeleteAgentRawpath documented as the breakage;isExitReasonaccepts the union and rejects 8 malformed inputs.Real-spawn e2e (live dev server, isolated config, real
claudePTY):runningPOST /:id/kill {reason:"self_exited"}→GET /:idreturns 200exited/self_exited(was 404)POST /api/agents {resumeAgentId}→ 201, re-attached (was "not found")DELETE /:id→ 200 then 404 (hard-delete path unchanged)/killno-body →user_killed; bogus reason →user_killed+ server logsunrecognized reasonFull
make checkgreen: 558 server + 318 dashboard tests.Risks / trade-offs
/killdoesn't). Intentional — now symmetric withkill_agent, and keeping the exited manager in the chart is the point of the fix.self_exitwas the only auto-cleanup path. Self-exited agents now accumulate like killed/crashed ones already do (there is no exited-record pruning anywhere today). This makes growth consistent rather than introducing a new unbounded class — but a bounded-retention prune is a sensible follow-up (filed separately, out of scope here).🤖 Generated with Claude Code