Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/__tests__/session-manager/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,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");
});

Expand Down Expand Up @@ -335,7 +337,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();
});
Expand Down
25 changes: 16 additions & 9 deletions packages/core/src/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1901,23 +1901,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"
) {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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();
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/app/api/sessions/patches/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading