fix(core): sm.list() no longer writes terminated state to disk (#1737)

* fix(core): sm.list() no longer writes terminated state to disk (#1735)

sm.list() was bypassing the lifecycle manager's probe decision matrix by
persisting terminated state immediately on a single isAlive() failure.
The dashboard's 3s poll via /api/sessions/patches called sm.list() ~10x
more often than the lifecycle manager, so a transient runtime failure
would permanently kill the session before the lifecycle manager could
evaluate all three probes (runtime, process, activity).

Changes:
- sm.list() now persists "detecting" instead of "terminated" when it
  detects a dead runtime, so the lifecycle manager's resolveProbeDecision
  pipeline remains the single authority on terminal decisions.
- /api/sessions/patches now calls listCached() instead of list(),
  preventing the dashboard's 3s poll from probing runtimes directly.
  The cache TTL (35s) aligns with the lifecycle manager's 30s poll.
- Updated CLAUDE.md invariants to reflect the new behavior.

* fix(core): skip re-persisting detecting state on subsequent list() calls

Check the on-disk lifecycle state (raw metadata) instead of the
in-memory state when deciding whether to persist. Enrichment already
sets detecting in-memory, so the previous guard always skipped the
persist block. Using the on-disk state ensures:
- First detection: persists detecting + lastTransitionAt
- Subsequent calls: skips re-write, preserving the original timestamp
This commit is contained in:
Adil Shaikh 2026-05-16 19:05:49 +05:30 committed by GitHub
parent e6ad078d7a
commit 667d1dedfc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 24 additions and 14 deletions

View File

@ -111,7 +111,7 @@ spawning -> working -> pr_open -> ci_failed / review_pending
+-> mergeable -> merged -> cleanup -> done
```
**Stale runtime reconciliation:** `sm.list()` detects dead runtimes (tmux/process gone) during enrichment and persists `runtime_lost` reason to disk. This maps to legacy status `killed`. Without this, sessions with dead runtimes would show stale "active" status indefinitely.
**Stale runtime reconciliation:** `sm.list()` detects dead runtimes (tmux/process gone) during enrichment and persists `detecting` state with `runtime_lost` reason to disk. The lifecycle manager's `resolveProbeDecision` pipeline is the single authority on terminal decisions — `sm.list()` never writes `terminated` directly (#1735).
### Data Flow
@ -224,7 +224,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
- Kanban board filters client-side via `projectSessions` memo
### Key invariants
- `sm.list()` persists `runtime_lost` lifecycle to disk when enrichment detects dead runtimes — this is the only place stale runtime state gets reconciled
- `sm.list()` persists `detecting` state (not `terminated`) to disk when enrichment detects dead runtimes — terminal decisions are made only by the lifecycle manager's probe pipeline (#1735)
- `deriveLegacyStatus()` maps canonical lifecycle to legacy status — new terminal reasons must be added here
- Tab completions merge local config + global config to show all projects

View File

@ -220,7 +220,9 @@ describe("list", () => {
const sm = createSessionManager({ config, registry: registryWithDead });
const sessions = await sm.list();
expect(sessions[0].status).toBe("killed");
// sm.list() persists "detecting" (not "terminated") so the lifecycle
// manager's probe pipeline makes the final terminal decision (#1735).
expect(sessions[0].status).toBe("detecting");
expect(sessions[0].activity).toBe("exited");
});
@ -336,7 +338,8 @@ describe("list", () => {
expect(sessions).toHaveLength(1);
expect(sessions[0].runtimeHandle?.id).toBe(expectedTmuxName);
expect(sessions[0].status).toBe("killed");
// sm.list() persists "detecting" so the lifecycle manager decides (#1735).
expect(sessions[0].status).toBe("detecting");
expect(sessions[0].activity).toBe("exited");
expect(agentWithSpy.getActivityState).not.toHaveBeenCalled();
});

View File

@ -1941,23 +1941,30 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
}
}
// Persist lifecycle to disk when enrichment detected a dead runtime.
// enrichSessionWithRuntimeState updates the in-memory lifecycle but
// doesn't write to disk — without this, the stale "alive" state persists
// and the dashboard shows terminated sessions on the active sidebar.
// Persist runtime probe result to disk so the lifecycle manager sees it
// on next poll. We only persist the runtime signal and detecting state —
// the lifecycle manager's resolveProbeDecision pipeline is the single
// authority on terminal decisions (terminated/done). See #1735.
// Check the on-disk state (raw) to avoid re-writing when already
// detecting — enrichment sets detecting in-memory, but we only need
// to persist the transition once to avoid resetting lastTransitionAt.
const onDiskLifecycle = parseCanonicalLifecycle(raw, {
sessionId: sessionName,
status: validateStatus(raw["status"]),
});
if (
session.lifecycle &&
(session.lifecycle.runtime.state === "missing" ||
session.lifecycle.runtime.state === "exited") &&
session.lifecycle.session.state !== "terminated" &&
session.lifecycle.session.state !== "done"
onDiskLifecycle.session.state !== "terminated" &&
onDiskLifecycle.session.state !== "done" &&
onDiskLifecycle.session.state !== "detecting"
) {
try {
const persisted = buildUpdatedLifecycle(sessionName, raw, (next) => {
next.session.state = "terminated";
next.session.state = "detecting";
next.session.reason = "runtime_lost";
next.session.terminatedAt = new Date().toISOString();
next.session.lastTransitionAt = next.session.terminatedAt;
next.session.lastTransitionAt = new Date().toISOString();
next.runtime.state = session.lifecycle!.runtime.state;
next.runtime.reason = session.lifecycle!.runtime.reason;
next.runtime.lastObservedAt = new Date().toISOString();

View File

@ -16,7 +16,7 @@ export async function GET(request: Request) {
? projectFilter
: undefined;
const coreSessions = await sessionManager.list(requestedProjectId);
const coreSessions = await sessionManager.listCached(requestedProjectId);
const visibleSessions = filterWorkerSessions(coreSessions, projectFilter, config.projects);
// Convert to dashboard format