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:
parent
e6ad078d7a
commit
667d1dedfc
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue