fix(hierarchy): polish + critical fixes for #165 unification - #166
Conversation
nox-0x
left a comment
There was a problem hiding this comment.
The hierarchy/migration polish is generally sound. The idempotency sentinel fix, throw-on-cache-divergence, and tree-builder dedup are good improvements. Two concerns worth flagging: the new stale onExit guard may skip per-spawn resource cleanup (output buffers, listeners) for the killed PTY, and the partial-reparent abort path leaves agents reparented to the new parent without rollback (acknowledged in the error but worth confirming intent). Left 3 inline comments — 2 warnings, 1 suggestion.
Polish review surfaced three critical bugs in PR #165 that were merged before review completed. This PR addresses them plus simplifier wins. ## Critical fixes (data loss / state corruption) **1. Migration sentinel — no more silent partial-migration data loss** `agents/store.ts` + `agents/migrate.ts`. The original idempotency check used `agentsDirExists()`, but the dir is created by the FIRST per-agent write — so a mid-loop crash left some files written, sessions.json still present, and the next startup `agentsDirExists() === true` skipped the remaining records permanently. New: write a `.migration-complete` sentinel file ONLY after all per-agent writes + source rename succeed; re-runs retry cleanly because insertAgent overwrites by id. **2. writeAgentFile throws on lastReadFailed — no more cache desync** `agents/store.ts`. Previously the function logged + silently returned when the disk-load failure flag was set, but callers unconditionally updated the in-memory cache on the next line — disk and cache diverged, the dashboard showed updated state, restart wiped the edits. Now throws so callers get a real error and the cache stays consistent. **3. PTY-identity guard in onExit — no more restart-race state corruption** `agents/runtime.ts`. node-pty's onExit is async, so during restartAllAttachments (kill old → spawn new for the same agent.id), the killed PTY's onExit can fire AFTER the new attachment is registered. Without a guard the stale handler called markExited(id) on the freshly-spawned record. Now we capture the `pty` reference in the closure and no-op when `live.get(id)?.pty !== capturedPty`. ## Other reviewer findings addressed - `index.ts`: migration call now wrapped in try/catch with `process.exit(2)` on failure — pm2/systemd see the unhealthy boot rather than serving with half-migrated state - `agents/migrate.ts`: source-rename failure now throws (was warn) so the migration-complete sentinel isn't written if the source can't be moved - `routes/agents.ts` DELETE-with-reassignTo: pre-aborts on first failed setManager with 409 + `reparented` list + `failedAt` detail rather than silently committing partial reparenting - `routes/agents.ts` DELETE: filesystem-error path now 500s instead of silent 200 after deleteAgentRaw fails - `agents/runtime.ts` onExit: missing-store-record case now logs warning instead of silently dropping the agent.exited event - `ws/agents.ts` safeSend: dead-client now removed from set inside catch to prevent slow leak when ws.send throws without firing onClose ## Simplifier-applied refactors (kept from polish run) - `agents/store.ts`: extracted `buildAgentTree<N>({includeExited, mapNode})` shared helper used by both `routes/agents.ts /tree` and `mcp.ts get_org_chart` — dedup of the same tree-building logic - `agents/runtime.ts`: collapsed duplicate fresh-spawn vs fork-spawn branches; extracted `respawnAgent(a)` helper used by both `resumeActiveAgents` and `restartAllAttachments` - `mcp.ts`: dead-branch removal in set_manager error check; `routes/agents.ts` GET /:id no longer double-resolves with `getAgent ?? resolveAgent` ## Verification `make check` clean: 294/294 tests pass, biome lint clean, full TypeScript build clean. ## Follow-ups deferred The polish reports also flagged these — leaving them for a follow-up PR to keep this scope focused: - Round-trip migration unit test + cycle-detection test + restart-race repro test (no test coverage on the new modules — was deleted in #165) - titleCache lookup in `runtime.ts:resolveAgentId` includes non-CC agents whose providerSessionId won't be in any JSONL (small wasted work) - `markExited` dead-code branch in store.ts (logic-correct but confusing) - Typed error classes from `runtime.ts` to replace string-matching error classification in `routes/agents.ts` POST handler - UUID-shape validation in `getAgentFile()` (mode 0700 on parent dir is the actual security boundary, but defense-in-depth) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
c7ae066 to
05bf160
Compare
nox-0x
left a comment
There was a problem hiding this comment.
Automated review timed out — see PR for existing inline comments from manual review. Please check for any additional issues not yet surfaced.
OrgChart cards were rendering empty status badges for freshly-spawned agents because the status lookup map was keyed by providerSessionId while OrgNode.claudeSessionId is set to agent.id (per /api/agents/tree backward-compat alias). For migrated agents these UUIDs are equal (Option A migration), so the bug was invisible on existing data — only new spawns missed. This also fixes the parallel issue in resumeSession's URL builder, where /api/agents/:id/attach was being called with providerSessionId instead of agent.id — would 404 because the route's resolveAgent does cache lookups keyed by agent.id. The dashboard's SessionInfo.claudeSessionId is purely a stable lookup key (not actually a CC session id in the new model). Aligning it with agent.id everywhere — same as /api/agents/tree, useAgentStatusById, and the route URL params — makes the org chart, sidebar, and resume flow consistent. Verified: 306/306 tests pass, biome clean, full TS build clean. Found via live dashboard QA against the rebased branch — qa-* agents spawned via /api/agents POST showed correct hierarchy structure but empty status badges in the OrgChart cards (sidebar was correct because it keys by session.id directly). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
578ade4 to
1ef325e
Compare
nox-0x
left a comment
There was a problem hiding this comment.
Automated review timed out after 10 min — the diff is too large for single-pass analysis with extended thinking enabled. 3 existing inline comments are already on the PR from the manual polish review (stale onExit guard, partial-reparent abort, markMigrationComplete throw). A maintainer should do a hand-review before merging if not already satisfied with the existing review. Size: 256 additions / 141 deletions across 8 files.
nox-0x
left a comment
There was a problem hiding this comment.
The three critical fixes (migration sentinel, throw-on-cache-divergence, PTY-identity guard) are solid. One new issue found: the rewritten DELETE /:id fallback path converts a benign race (agent already gone) into a 500. See inline comment on packages/server/src/routes/agents.ts line 374 for detail and suggested fix. Otherwise the PR is good to merge.
1. routes/agents.ts:374 — DELETE no longer escalates benign race to 500. When both runtimeDeleteAgent and deleteAgentRaw return false, re-check getAgent(id): if absent (race resolved), return 200; only 500 if the record genuinely still exists. 2. routes/agents.ts:348 — DELETE-with-reassignTo now best-effort rolls back partial reparenting on failure. Captures originalManagerId of each child up-front, walks reparented children in reverse on failure and restores their original managerId. Surfaces rollbackFailures in the 409 response when a rollback step also fails. 3. agents/migrate.ts:204 — markMigrationComplete() in a try/catch. If marker write fails post-rename (disk full / EPERM), warn instead of throw — migration succeeded, the marker can be reconciled on next startup via the no-source path. Avoids fatal exit for transient marker failures. 4. agents/runtime.ts:289 — added resource-disposal note to the stale onExit guard. Confirmed nothing in the spawn closure needs explicit cleanup today (outputBuffer GC's with the dropped attachment, node-pty owns the fds), and documented the requirement for future captures. Verified: 306/306 tests pass, biome clean, full TS build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addressed all 4 inline review comments in
|
| File:Line | Comment | Resolution |
|---|---|---|
runtime.ts:289 |
Stale-handler early-return resource cleanup | Added doc-comment confirming no captured resources need disposal today (outputBuffer GC's with the dropped ManagedAttachment, node-pty owns the fds). Documents the contract for future spawn-closure changes. |
routes/agents.ts:348 |
Partial-state rollback on reparent failure | Implemented option (a) — capture each child's originalManagerId up-front; on failure walk reparented children in reverse and restore. Surfaces rollbackFailures in the 409 payload as defense-in-depth when a rollback step itself fails. |
migrate.ts:204 |
markMigrationComplete() throw masks success |
Wrapped in try/catch with warn. Migration success no longer turns into fatal startup exit when the marker write hits ENOSPC/EPERM — next startup reconciles via the no-source path. |
routes/agents.ts:374 |
DELETE escalates benign race to 500 | Implemented option (b) — when both removes return false, re-check getAgent(id): if absent (race resolved), fall through to 200; only 500 if the record genuinely still exists. Distinguishes desired-state-achieved from real fs error. |
Verified: 306/306 tests pass, biome clean, full TS build clean. CI re-running on the push.
nox-0x
left a comment
There was a problem hiding this comment.
Automated Review — Comment
Verdict: COMMENT — 5 issues found (2 warnings, 3 suggestions)
The PR addresses real gaps from #165 with thoughtful comments and good rollback ergonomics. Main concerns are around the reparent-failure HTTP semantics (409 misrepresents an inconsistent-tree state), event ordering during reparent rollback that leaves WS clients with confusing transient state, and the removal of the GET /:id fast-path lookup which makes UUID hits go through name resolution.
Issues posted inline:
- 🟡 409 misused for inconsistent-tree state —
rollbackFailures.length > 0should return 500, not 409 - 🟡 WS delta ordering during rollback — emit forward events before failure is known, then emit reverse events; UI flicker
- 🟢 GET /:id lost O(1) fast-path —
getAgent(id) ?? resolveAgent(id)simplified to justresolveAgent(id), potential regression - 🟢 writeAgentFile throw inside rollback — new throwing behavior not caught in rollback loop, would crash handler
- 🟢 claudeSessionId field renamed semantically — repurposed from provider session id to agent.id alias; name is misleading
Reviewed by Nox 🤖 using Claude Code (print mode, single-turn analysis of diff only).
Round 2 of automated reviewer findings — addresses behavioral, status-code, and ergonomic issues surfaced after the first round of fixes. 1. migrate.ts:74 — wrap markMigrationComplete() in fresh-install branch symmetrically with the post-rename branch. A transient marker-write failure on first boot no longer crashloops with a misleading "investigate sessions.json" error when there was never a sessions.json. 2. routes/agents.ts:388 — buffer agent.reparented deltas during the reparent loop, only flush on success. Previously the loop emitted forward deltas as it went; on rollback, N forward + N reverse events produced visible flicker on the dashboard (or worse, missed events on coalescing clients). 3. routes/agents.ts:396 — split status code: 409 only when rollback is clean (caller can safely retry); 500 when rollbackFailures > 0 (tree left mutated, manual reconciliation required, must NOT retry). Previously 409 in both cases — clients with retry-on-conflict logic would compound inconsistency. 4. agents/store.ts:184 — document that resolveAgent() always tries the O(1) cache.get() before the O(N) name scan, so /api/agents/:id stays constant-time on the hot UUID path. 5. routes/agents.ts (rollback loop) — wrap each setManager call in try/catch. writeAgentFile throws on lastReadFailed; without the wrap, a transient FS error mid-rollback would short-circuit the structured response and crash to a generic 500. Throws now feed into the same rollbackFailures array. 6. dashboard/store.ts SessionInfo — added providerSessionId field + docs clarifying that claudeSessionId is now an agent.id alias (not the actual CC provider session id). Callers needing to invoke `claude --resume` should read providerSessionId. Verified: 306/306 tests pass, biome clean, full TS build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Round 2 — addressed 6 follow-up review comments in
|
| File:Line | Severity | Resolution |
|---|---|---|
migrate.ts:74 |
🟡 Warning | Wrapped markMigrationComplete() in the fresh-install branch in try/catch + warn (symmetric with post-rename branch). No more misleading fatal exit on first boot when marker write hits ENOSPC/EPERM. |
routes/agents.ts:388 |
🟡 Warning | Reparent deltas buffered into pendingDeltas[] and flushed only on full-loop success. On rollback, no events emitted (clients see no transition rather than flicker through forward+reverse). |
routes/agents.ts:396 |
🟡 Warning | Status code split: 409 for clean rollback (safe retry), 500 for rollbackFailures > 0 (tree mutated, must not retry). Body wording updated to match. |
agents/store.ts:184 |
🟢 Suggestion | Doc-comment on resolveAgent confirms the O(1) cache.get fast-path runs before the O(N) name scan — /api/agents/:id stays constant-time on UUID hits. |
routes/agents.ts (rollback loop) |
🟢 Suggestion | Each setManager in the rollback loop wrapped in try/catch. writeAgentFile throws on lastReadFailed; throws now feed into rollbackFailures with reason "throw" rather than crashing the handler. |
dashboard/store.ts:805 |
🟢 Suggestion | Added providerSessionId? field to SessionInfo so callers needing the actual CC provider session id (e.g. for claude --resume) have access. claudeSessionId documented as a stable lookup alias for agent.id. |
Verified: 306/306 tests pass, biome clean, full TS build clean. CI re-running on the push.
nox-0x
left a comment
There was a problem hiding this comment.
Automated Review — Comment
1 new issue found beyond the 10 already on the PR from a previous pass.
The three critical fixes (migration sentinel, throw-on-cache-divergence, PTY-identity guard) are solid, and the buffered-reparent-with-structured-rollback pattern is well-designed. The one remaining gap: the forward setManager() call in the reparent loop is unwrapped, so a throw from writeAgentFile (per this PR's own store.ts change) mid-loop would skip the structured rollback entirely and produce a generic 500 with mutated disk state.
👉 Inline comment posted on packages/server/src/routes/agents.ts:355.
Reviewed by Nox 🤖 using Claude Code (print mode, single-turn analysis of diff only).
Forward setManager call in the DELETE-with-reassignTo loop is throw-capable (writeAgentFile throws on lastReadFailed). Without the wrap, a mid-loop throw skipped the structured rollback path entirely — disk left mutated, pendingDeltas never flushed, generic 500 returned. Wrapped symmetrically with the rollback loop. A throw is treated as `updated === undefined` and falls into the existing rollback branch, which still produces the structured 409/500 response with rolledBack and rollbackFailures. Verified: 306/306 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Round 3 — addressed in
|
Round 10: CachePoisonedError contract fidelity 🔴Critical contract bug — fixed.
|
| Throw site | Code | Status |
|---|---|---|
| runtimeDeleteAgent | 503 + CACHE_POISONED + reparented? | ✓ |
| deleteAgentRaw fallback | 503 + CACHE_POISONED + reparented? | ✓ |
| Forward setManager (children loop) | 503 + CACHE_POISONED + reparented? | ✓ NEW |
| Rollback setManager (children loop) | 503 + CACHE_POISONED + rolledBack/rollbackFailures | ✓ NEW |
| POST/PATCH/PUT (router onError) | 503 + CACHE_POISONED | ✓ |
Every surface now produces the same envelope. No client-visible divergence between paths.
Status
- 306 tests pass, biome+TS clean
- Diff: +66 / -0 in
routes/agents.ts
Latest commit: 31f02d7. Continuing the loop.
nox-0x
left a comment
There was a problem hiding this comment.
Automated Review — Changes Requested ❌
Verdict: REQUEST_CHANGES — 4 new issues found (1 critical, 3 warnings)
The CachePoisonedError plumbing is broadly sound — the global onError, throw-conversion in writeAgentFile, and the local DELETE catches all cohere. However, the rollback-poisoned escape path misattributes the forward failure (hard-codes reason="throw"), the forward-poisoned fast-path is missing the failedAt field, the buildAgentTree filter silently widens from "running" to "non-exited" (API contract change), and the MCP set_manager tool has no CachePoisonedError catch despite now calling a throw-capable writeAgentFile.
Issues posted inline:
- 🔴 Critical — routes/agents.ts:487 — Rollback-poisoned escape path hard-codes
failedAt.reason="throw"even when forward returned "cycle"/"not-found" - 🟡 Warning — routes/agents.ts:420 — Forward CachePoisonedError fast-path missing
failedAtfield (contract drift from all other DELETE error responses) - 🟡 Warning — store.ts:392 —
buildAgentTreefilter widened fromstatus==="running"tostatus!=="exited"— user-visible API change for MCP + REST consumers - 🟡 Warning — mcp.ts:330 — MCP set_manager calls
setManager()unwrapped, but writeAgentFile now throws CachePoisonedError
Reviewed by Nox 🤖 via Claude Code (print mode, single-turn diff analysis, all tools disabled).
…ison handler Four review fixes from PR #166 round 11: 1. **routes/agents.ts:487 (CRITICAL)** — Rollback-poisoned escape path hardcoded `failedAt.reason="throw"` even when the forward setManager returned "cycle"/"not-found" cleanly. Hoisted forwardReason above both CachePoisonedError branches so it's computed once and reused correctly. Forward "cycle"/"not-found" failures that happen to hit rollback-CachePoisoned no longer get misattributed as transient FS throws. 2. **routes/agents.ts:420** — Forward CachePoisonedError fast-path was missing the `failedAt` field that every other DELETE error response includes. Clients had to parse a different shape depending on failure mode. Added `failedAt: { id, name, reason: forwardReason, message? }` so all DELETE failures share one envelope. 3. **store.ts:392** — Round 9 widened the `buildAgentTree` filter from `status === "running"` to `status !== "exited"` to match the docstring's wording. The reviewer (correctly) flagged this as a user-visible contract change for existing MCP/REST consumers — `starting` agents would suddenly appear in the org chart. Reverted the filter and updated the docstring to honestly describe the strict-running behavior plus rationale (preserves pre-refactor `buildOrgChartFromAgents` contract; transient states deliberately omitted so the chart stays stable while sessions warm up). 4. **mcp.ts:330** — MCP `set_manager` tool called `setManager()` unwrapped, despite writeAgentFile now throwing CachePoisonedError. The MCP framework would catch and produce a generic error, losing the CACHE_POISONED signal that the REST surface emits via 503. Wrapped in try/catch: returns isError MCP response with explicit CACHE_POISONED prefix and a "retry pointless until restart" hint so MCP clients (agents) see the same stable signal HTTP clients do. All 306 tests pass.
Round 11: failedAt fidelity, contract revert, MCP poison handlerAll four issues addressed. 1.
|
nox-0x
left a comment
There was a problem hiding this comment.
Automated Review — 2 new issues found
Reviewed this PR after a prior commit (SHA 9340c7a4). The diff addresses the same concerns from #165: migration sentinel, throw-on-cache-divergence, PTY-identity guard, structured DELETE rollback, and tree-builder dedup.
30+ existing comments from previous passes cover the major concerns (race-to-500 regression, 409/503 semantics, WS delta ordering, partial-reparent rollback gaps, CachePoisonedError handling, type safety, etc.).
2 new issues found:
- 🟡
agents.ts:59— onError re-throws instead of returning Response.throw err;from anonErrorhandler relies on Hono undocumented behavior; in some versions this surfaces as an unhandled rejection or connection drop instead of a 500-with-stack. Shouldreturn c.json(...)explicitly. - 🟢
store.ts:294— No-op short-circuit skips manager existence validation. Theif (existing.managerId === managerId) return existing;check runs beforecache.has(managerId), allowing set_manager to silently "succeed" against a tombstoned/dangling manager pointer.
Reviewed by Nox 🤖 (Claude Code, print mode, --tools "" single-turn diff analysis).
…atch Two review fixes from PR #166 round 12: 1. **routes/agents.ts:59** — `onError` was `throw err`-ing for non-CachePoisonedError cases, relying on undocumented Hono behavior. Depending on Hono version, a re-throw from an error handler can surface as an unhandled rejection or connection drop instead of the structured 500 the operator expects. Replaced with an explicit `return c.json({ error: message }, 500)` plus logging the stack server-side so the canonical record is preserved. 2. **store.ts:294** — The `setManager` no-op short-circuit ran BEFORE the `cache.has(managerId)` existence validation. If `existing.managerId` pointed to a manager whose record had been deleted on disk but whose eviction hadn't yet propagated to the in-memory cache (idempotent retries, stale UI, migration window), passing the same dangling id would silently re-affirm the broken pointer. Reordered: existence check now runs first, so dangling refs return undefined regardless of whether the caller's argument matches the existing field. Self-loop check also hoisted above the short-circuit for symmetry. All 306 tests pass.
Round 12: explicit onError 500 + dangling-managerId catchBoth issues addressed. 1.
|
nox-0x
left a comment
There was a problem hiding this comment.
Requesting changes — the dashboard's ConversationView reads activeSession.claudeSessionId, but this PR repurposes claudeSessionId to mean agent.id, which silently breaks /api/conversation/<id> lookups for any freshly-spawned (non-migrated) agent.
The migration sentinel, cache-poisoned 503 contract, deferred WS flush, and PTY-identity guard all look solid. Reviewer threads from rounds 4–12 are addressed. The blocker is a single new field-mapping regression introduced by this PR.
The bug
`SessionInfo.claudeSessionId` previously equaled the CC provider session id; this PR remaps it to `agent.id` (store.ts:614, store.ts:822) and adds a separate `providerSessionId` field with a comment noting that "callers that … read CC's JSONL" should use the new field. `ConversationView.tsx:412` is exactly such a caller and was not updated:
```tsx
const claudeSessionId = activeSession?.claudeSessionId; // now agent.id, not CC's session id
// ...
fetch(`/api/conversation/${claudeSessionId}`)
```
`/api/conversation/:sessionId` (routes/conversation.ts:14) scans `~/.claude/projects//.jsonl`. CC writes that JSONL keyed on its own session UUID. For Option-A migrated agents the bug is invisible (id ≡ providerSessionId by construction). For any agent spawned post-merge, agent.id is a fresh `crypto.randomUUID()` (runtime.ts:155), distinct from the CC session id (runtime.ts:154) — so the file does not exist and the conversation panel surfaces "Session not found" 404.
The fix
One line in `packages/dashboard/src/components/conversation/ConversationView.tsx:412`:
```diff
- const claudeSessionId = activeSession?.claudeSessionId;
- const claudeSessionId = activeSession?.providerSessionId;
```
(Variable rename optional but worth doing; the new `providerSessionId` field on `SessionInfo` was added precisely for this.) Worth grepping for any other `claudeSessionId` consumers that hand the value to provider-side APIs (telemetry, log scrubbers, copy-for-support) — most other call sites I traced just use it as a stable lookup key and are correct.
Once that's in, this is good to merge.
Real ship-blocking regression introduced by the agent-unification PR. **The bug** `SessionInfo.claudeSessionId` previously equaled the CC provider session id; this PR remapped it to `agent.id` (a dashboard-internal stable lookup key) and added a separate `providerSessionId` field for callers that read CC's JSONL files. `ConversationView.tsx:412` is exactly such a caller and was not updated. For Option-A migrated agents the bug is invisible (id === providerSessionId by construction). For any agent spawned post-merge, agent.id is a fresh crypto.randomUUID() distinct from the CC session id — `/api/conversation/:id` scans `~/.claude/projects/<encoded-cwd>/<id>.jsonl` which CC writes keyed on its own session UUID, so the file doesn't exist and the conversation panel surfaces "Session not found" 404. **The fix** One line in ConversationView.tsx — read `activeSession?.providerSessionId` instead of `claudeSessionId`. Renamed the local variable for clarity and added a comment explaining why the distinction matters. Verified ConversationView is the only `/api/conversation/:id` consumer in the dashboard (single grep result). All other claudeSessionId consumers (store.ts resumeSession, mergeOrgWithSessions, HierarchyPanel) use it as a stable lookup key — correct semantics post-refactor. All 306 tests pass.
Round 13: ConversationView — providerSessionId regression fixExcellent catch — real ship-blocking bug. The agent-unification PR remapped Why it escaped reviewType-checking can't catch a semantic remapping when the field name stays the same — the compiler sees The fix- const claudeSessionId = activeSession?.claudeSessionId;
+ // /api/conversation/:id reads ~/.claude/projects/<cwd>/<id>.jsonl, which
+ // CC writes keyed on its own session UUID. After the agent-unification
+ // PR, SessionInfo.claudeSessionId is repurposed to mean `agent.id` ...
+ const providerSessionId = activeSession?.providerSessionId;Plus the dependent variable rename in the Audit of other consumersVerified that For migrated vs spawned agents
Status
Latest commit: |
| @@ -319,6 +320,10 @@ export function spawnAgent(params: SpawnParams): SpawnResult { | |||
| exitReason: reason, | |||
| version: updated.version, | |||
| }); | |||
There was a problem hiding this comment.
🟡 Warning — markExited is now throw-capable. writeAgentFile now throws CachePoisonedError, so the markExited call on line 314 can throw too — and the call is unwrapped. Under cache-poisoned state, node-pty receives an unhandled throw from the onExit callback, AND live.delete + the agent.exited delta (lines 315–322) never run, leaving a phantom-running entry in the live map and an inconsistent dashboard view. Same shape applies to killAttachment line 349 and spawnAgents markRunning / insertAgent on lines 226–232 — in the spawn case the PTY child process is already alive when the throw fires, so each retry under poisoned state spawns another orphan claude process. Only manifests after a prior loadAll failure, so not blocking. Worth a follow-up to wrap these store-mutating calls and at minimum still run live.delete + emit a delta when the disk write fails.
| agentsRouter.get("/:id", (c) => { | ||
| const id = c.req.param("id"); | ||
| const agent = getAgent(id) ?? resolveAgent(id); | ||
| const agent = resolveAgent(id); |
There was a problem hiding this comment.
🟢 Suggestion — no-op setManager still emits agent.reparented. The new no-op short-circuit in setManager (store.ts line 306) returns the unchanged existing record when managerId === existing.managerId. Both this REST handler (line 295) and the MCP set_manager tool (mcp.ts line 361) unconditionally emit agent.reparented afterward, so an idempotent re-issue (drag-and-drop UI re-firing the same parent, idempotent MCP retry) broadcasts a redundant delta with the un-bumped version. Not a correctness bug — clients converging on (id, managerId, version) see no change — just noise on the WS bus. Easy fix: have setManager return a distinct sentinel for the no-op case, or detect it at the callsite via pre-call version capture.
nox-0x
left a comment
There was a problem hiding this comment.
Approving — clean polish PR; the DELETE state machine, migration sentinel, PTY-identity guard, and CachePoisonedError surfacing all hold up under tracing, no critical issues in the current code.
The 33 prior review threads are all resolved and the response shapes / rollback logic are mutually consistent. Two minor follow-ups worth a separate ticket, neither blocking:
- The new writeAgentFile throw widens the blast radius for CachePoisonedError. pty.onExit → markExited, killAttachment → markExited, and spawnAgent → insertAgent / markRunning still call those throw-capable functions unwrapped, so a poisoned cache could leak phantom live entries (and in spawnAgent the throw fires after spawn(), so the PTY child becomes an orphan on each retry). Manifests only post-loadAll-failure.
- The new no-op short-circuit in setManager (store.ts:306) still triggers agent.reparented broadcasts at the REST and MCP callsites. Harmless noise — version does not bump, clients converge — but worth a sentinel-return to skip the emit.
Dashboard claudeSessionId → providerSessionId split for /api/conversation/:id is correct and the field comment in store.ts documents the legacy alias clearly.
| // change adds captured streams/timers/listeners to the spawn closure, they | ||
| // must be disposed here BEFORE the early return. | ||
| if (live.get(persisted.id)?.pty !== pty) { | ||
| return; |
There was a problem hiding this comment.
🔴 Critical — PTY leak when markRunning / insertAgent throws CachePoisonedError
This guard correctly defangs stale onExit handlers, but the mirror race on the spawn side is uncovered. The PR makes writeAgentFile throw on lastReadFailed, so both insertAgent (line 232) and markRunning (lines 227-231) are now throw-capable. They run after spawn(binary, args, …) at line 212, with no try/catch around them.
If the throw fires, the spawned PTY child process is alive but never registered in live and never killed. The router-level onError maps the exception to a 503, but the orphan claude process keeps running with no record. For the resume path, the existing record stays in exited state on disk → next resumeActiveAgents may try to spawn another claude for the same agent id; for the fresh-spawn path there is no record at all so the orphan is invisible until manual ps cleanup.
This is a new gap the PR introduces (silent on the happy path, compounds with each restart cycle once the cache is poisoned).
Fix: wrap the markRunning / insertAgent call in try/catch, pty.kill() (best-effort) on throw, then re-throw so the router still surfaces 503.
| // the inner branch produces. | ||
| let removed: boolean; | ||
| try { | ||
| removed = runtimeDeleteAgent(id); |
There was a problem hiding this comment.
🟡 Warning — runtimeDeleteAgent returns true on rmSync failure when the agent was live, masking the failure as a 200 success
runtime.deleteAgent (runtime.ts:364-380) ends with return removed || wasLive. If wasLive=true and deleteAgentRaw returns false (rmSync threw and was caught at store.ts:364-367), the function still returns true. The route handler then sees removed=true, skips the if (!removed) recovery branch (with its rawRemoved try/catch and the disk-state-check at line 731), and falls through to return c.json({ ok: true, id }) at line 759.
Net result on rmSync EBUSY/EPERM with a live agent:
- PTY killed and removed from
live✓ - Agent file still on disk, cache entry still present ✗
agent.deletedevent NOT emitted (only fired whenremoved=trueinsideruntime.deleteAgent) ✗- Forward reparent deltas DO emit ✓
- Client gets
200 ok:true✗ — but the next/api/agentsrefresh shows the agent reappear
Fix: in runtime.deleteAgent, distinguish "PTY-killed but raw-removal-failed" from "fully gone" — either propagate the rmSync error (rather than deleteAgentRaw swallowing it), or have it return a richer status the route can branch on. The route-level if (!rawRemoved && getAgent(id) !== undefined) recovery exists; runtime.deleteAgent just needs to surface the failure so it runs.
| // pointer; the read-time scrub would then fix it on next loadAll, but the | ||
| // write returned "success" in the meantime. Returning undefined surfaces | ||
| // the missing manager to the caller so they can refetch and re-target. | ||
| if (managerId !== null && !cache.has(managerId)) return undefined; |
There was a problem hiding this comment.
🟢 Suggestion — setManager returns undefined for two distinct failures: "agent id not found" AND "managerId not found"
This new cache.has(managerId) check correctly closes the dangling-ref window, but it overloads the undefined return that already meant "id (the agent) not found" (line 289). MCP set_manager (mcp.ts:355-359) only branches on !result || typeof result === "string" and returns a generic Failed to set manager. — the human/agent client cannot tell which arg was bad and may keep retrying with the same wrong managerId.
The REST handler at routes/agents.ts:277-279 says "Agent or managerId not found" which is at least informative, but still ambiguous.
Fix: add a "missing-manager" discriminant to the union return so callers can produce a specific 404. Cheap; setManager already returns string discriminants ("cycle" / "stale") for other distinguishable failure modes.
nox-0x
left a comment
There was a problem hiding this comment.
Approving — the three headline fixes (migration sentinel, throw-on-poisoned-cache, PTY-identity guard in onExit) are sound and the DELETE-with-reassignTo rollback path is well-thought-through. None of the findings below are merge-blockers; they're all reachable only after the cache is already poisoned (a degraded state) and have safe surrounding behavior.
Findings (see inline comments for details):
- 🔴 Critical follow-up:
spawnAgentPTY leak onmarkRunning/insertAgentthrow — symmetric mirror of the onExit fix. Wrap andpty.kill()on throw before re-raising. (runtime.ts:226-232) - 🟡 Warning:
runtime.deleteAgentreturn removed || wasLivereturns true onrmSyncfailure when wasLive, so the route returns 200 while the agent file remains on disk andagent.deletedis never emitted. (routes/agents.ts:658) - 🟢 Suggestion:
setManageroverloadsundefinedfor both "agent not found" and "managerId not found" — MCP surface returns generic "Failed to set manager." Add a"missing-manager"discriminant. (store.ts:302)
Two prior threads (markExited throw-propagation in onExit, no-op setManager still emitting agent.reparented) remain unresolved — both already accepted as deferred follow-ups in the PR description / by the reviewer themselves. Leaving them open as tracked follow-ups; not blocking this merge.
…partial-success (#167) fix(post-166): close-tab UX, dead RestartAll, stale endpoints, partial-success surfacing Cleanup PR for regressions discovered during post-merge live QA of #166. ## Bugs fixed ### 1. X button on the only tab silently no-op'd — close-tab UX `closeTab` → `closeLeaf` → `removeLeaf` returns `null` on the only leaf (layout invariant: ≥1 leaf must exist), and `closeLeaf` then silently returned. Fix: when the only leaf would be removed, fall through to `removeTab` so the leaf becomes empty and the existing "Create or select an agent to start" placeholder renders. Adds 4 unit tests covering: - multi-tab close (leaf intact, sibling tab takes over) - last tab in one of multiple leaves (sibling leaf collapses up) - last tab in the only leaf (regression — empty-leaf placeholder) - stale tabId (defensive — the fall-through must not no-op silently) ### 2. RestartAll button was wired to a route that no longer exists PR #166 renamed `/api/sessions` → `/api/agents` but the SettingsStatusBarItem RestartAll button was still posting to `/api/sessions/restart-all`. Added `POST /api/agents/restart-all` server route (calls existing `restartAllAttachments()` from runtime.ts) and updated the dashboard caller. ### 3. App.tsx auth probe still pointed at the gone endpoint Mount-time and Retry probes were calling `/api/sessions` (404). The ternary `res.status === 401 ? "unauthenticated" : "authenticated"` was also misclassifying the 404 (and any future 5xx / 404 / 403) as authenticated, silently landing the user on a broken main UI. Changed to a 3-state classification: 401 → unauthenticated; 2xx → authenticated; anything else → error (with logging) so the existing "Cannot connect to server" screen surfaces. ### 4. RestartAll silently dropped per-agent failures `restartAllAttachments()` returned only the success `idMap`; respawn errors were `console.error`'d and discarded. The route returned 200 with a partial idMap and the UI showed a green "done" state — N failed agents would simply not come back. Changed signature to return `{ idMap, failures: Array<{id, name, error}> }`. The route forwards `failures` in the response body. RestartAllButton surfaces non-empty failures via the existing error UI (e.g. "2 agent(s) failed to restart: foo, bar"). Also logs the previously-empty `pty.kill()` catch and the "agent record vanished mid-restart" branch. ### 5. Stale endpoint references in comments / docstrings Carried-over `/api/sessions` and `/api/org` references replaced with the current `/api/agents` and `/api/agents/tree`: - mergeOrgWithSessions.ts (docstring) - Sidebar.tsx (3 comments — fingerprint refresh, fallback notice, error state) - HierarchyPanel.tsx (DELETE endpoint reference) - Sidebar.mergeOrgWithSessions.test.ts (header + race-condition comment) ## Verification - All 306 server tests pass - 4 new closeTab tests pass (regression + stale-tabId guard + 2 happy paths) - Live dev verification: 0 console errors, X button closes tab to placeholder, POST /api/agents/restart-all returns 200 `{ idMap, failures }`, RestartAll UI renders confirmation → restart → success/partial-failure flow correctly. ## Reviewed by - code-reviewer: clean (no high-confidence findings) - code-simplifier: clean (no simplification warranted) - silent-failure-hunter: 5 findings — all addressed in this PR
Why this PR exists
PR #165 (the Agent + Session unification) auto-merged before the post-ship
/polishreview completed. The reviewers surfaced 3 critical bugs plus a handful of issues that warrant a fast follow-up. None block normal usage today, but two of the three create silent data-loss paths that would bite during the next migration or restart.Critical fixes
1. Migration sentinel — no more silent partial-migration data loss
Files:
packages/server/src/agents/store.ts,packages/server/src/agents/migrate.tsThe original idempotency check used
agentsDirExists(). But the dir is created by the first per-agent write inside the loop, so:```
```
Fix: write a
.migration-completesentinel file ONLY after all writes + the source rename succeed. Idempotency now checks for the sentinel, not the dir. Mid-crash retries cleanly becauseinsertAgentoverwrites by id.2.
writeAgentFilethrows onlastReadFailed— no more cache desyncFile:
packages/server/src/agents/store.tsThe function logged a warning and silently returned when the disk-load failure flag was set. But every caller (
saveAgent,insertAgent,setManager,markExited,markRunning,patchAgent) updated the in-memory cache on the line afterwriteAgentFile. Net effect: dashboard saw the change, restart wiped it. Now throws so the error propagates and the cache stays consistent with disk.3. PTY-identity guard in
onExit— no more restart-race state corruptionFile:
packages/server/src/agents/runtime.tsnode-pty.onExitis async. DuringrestartAllAttachmentsthe kill→spawn cycle for the sameagent.idcan leave the killed PTY'sonExitto fire AFTER the new attachment is registered. The stale handler then calledmarkExited(persisted.id, reason)andlive.delete(persisted.id)on the freshly-spawned record. Now the closure captures theptyreference and no-ops whenlive.get(id)?.pty !== capturedPty.Other reviewer findings addressed
index.tsprocess.exit(2)on failure so pm2/systemd flag the unhealthy bootagents/migrate.tsroutes/agents.tsDELETE-with-reassignToreparentedlist +failedAtdetail rather than silent partial-stateroutes/agents.tsDELETEagents/runtime.tsonExitagent.exitedeventws/agents.tssafeSendSimplifier-applied refactors (kept from the polish run)
agents/store.ts: extractedbuildAgentTree<N>({includeExited, mapNode})shared helper used by both/api/agents/treeand theget_org_chartMCP tool — dedup of identical tree-building logicagents/runtime.ts: collapsed duplicate fresh-spawn vs fork-spawn branches inspawnAgent; extractedrespawnAgent(a)helper used by bothresumeActiveAgentsandrestartAllAttachmentsmcp.ts: dead-branch removal inset_managererror checkroutes/agents.tsGET /:id: no longer double-resolves withgetAgent ?? resolveAgentTest plan
make checkclean: 294/294 tests pass, biome lint clean, full TypeScript build cleanmake dev, spawn 2 agents, restart dev, verify both views show the same set~/.autonomos/sessions.jsonand~/.autonomos/agents/before deploying — the new sentinel logic is forward-compatible (adds the marker if missing on next startup)Follow-ups deliberately deferred
runtime.ts:resolveAgentIdincludes non-CC agents whoseproviderSessionIdwon't be in any JSONL (wasted work, not incorrect)markExiteddead-code branch (logic-correct but confusing)runtime.tsto replace string-matching error classificationgetAgentFile()(defense in depth — mode 0700 on parent dir is the actual security boundary)🤖 Generated with Claude Code