From d44e708525f484ceb154e7dd90a82c5858099db7 Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Fri, 15 May 2026 04:46:35 +0530 Subject: [PATCH] fix(core): clear terminatedAt/completedAt when restoring a session out of terminal state (closes #1831) (#1834) * fix(core): clear terminal markers on non-terminal lifecycle transitions Clear stale completedAt/terminatedAt whenever lifecycle helpers settle on a non-terminal session state, including restore-to-working paths. Preserved invariants: terminal-state idempotence remains intact because done/terminated states keep their terminal markers; session state transitions still flow through existing code that updates lastTransitionAt before marker hygiene runs. * fix(core): share terminal marker hygiene Move non-terminal marker clearing into lifecycle-state so restore and lifecycle-transition paths use one terminal-state source of truth. Preserved invariants: terminal-state idempotence still keeps done/terminated markers; existing transition code continues to update lastTransitionAt before marker hygiene runs. --------- Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com> --- .../__tests__/lifecycle-transition.test.ts | 62 +++++++++++++++++++ .../__tests__/session-manager/restore.test.ts | 25 +++++++- packages/core/src/lifecycle-state.ts | 13 ++++ packages/core/src/lifecycle-transition.ts | 9 ++- packages/core/src/session-manager.ts | 3 + 5 files changed, 110 insertions(+), 2 deletions(-) diff --git a/packages/core/src/__tests__/lifecycle-transition.test.ts b/packages/core/src/__tests__/lifecycle-transition.test.ts index 80ac87fbc..53fdcf358 100644 --- a/packages/core/src/__tests__/lifecycle-transition.test.ts +++ b/packages/core/src/__tests__/lifecycle-transition.test.ts @@ -129,6 +129,29 @@ describe("applyDecisionToLifecycle", () => { expect(lifecycle.session.terminatedAt).toBe("2026-04-16T12:00:00.000Z"); }); + it("clears terminal markers when transitioning into a non-terminal state", () => { + lifecycle.session.state = "terminated"; + lifecycle.session.reason = "runtime_lost"; + lifecycle.session.completedAt = "2026-04-15T12:00:00.000Z"; + lifecycle.session.terminatedAt = "2026-04-16T12:00:00.000Z"; + lifecycle.session.lastTransitionAt = "2026-04-16T12:00:00.000Z"; + + const decision: LifecycleDecision = { + status: "working", + evidence: "restore", + detecting: { attempts: 0 }, + sessionState: "working", + sessionReason: "task_in_progress", + }; + + applyDecisionToLifecycle(lifecycle, decision, nowIso); + + expect(lifecycle.session.state).toBe("working"); + expect(lifecycle.session.completedAt).toBeNull(); + expect(lifecycle.session.terminatedAt).toBeNull(); + expect(lifecycle.session.lastTransitionAt).toBe(nowIso); + }); + it("applies PR state and reason", () => { const decision: LifecycleDecision = { status: "pr_open", @@ -372,6 +395,45 @@ describe("applyLifecycleDecision (integration)", () => { expect(result.success).toBe(true); expect(result.previousStatus).toBe("spawning"); }); + + it("persists cleared terminal markers when a terminal snapshot transitions to working", () => { + const lifecycle = createInitialCanonicalLifecycle("worker"); + lifecycle.session.state = "terminated"; + lifecycle.session.reason = "runtime_lost"; + lifecycle.session.completedAt = "2026-04-16T11:00:00.000Z"; + lifecycle.session.terminatedAt = "2026-04-16T12:00:00.000Z"; + lifecycle.session.lastTransitionAt = "2026-04-16T12:00:00.000Z"; + + writeTestSession("test-5", { + status: "killed", + lifecycle: JSON.stringify(lifecycle), + worktree: "/tmp/test", + branch: "main", + }); + + const result = applyLifecycleDecision({ + dataDir, + sessionId: "test-5", + decision: { + status: "working", + evidence: "restore", + detecting: { attempts: 0 }, + sessionState: "working", + sessionReason: "task_in_progress", + }, + source: "restore", + now: new Date("2026-04-17T12:00:00.000Z"), + }); + + expect(result.success).toBe(true); + + const meta = readMetadataRaw(dataDir, "test-5"); + const persisted = JSON.parse(meta!["lifecycle"]); + expect(persisted.session.state).toBe("working"); + expect(persisted.session.completedAt).toBeNull(); + expect(persisted.session.terminatedAt).toBeNull(); + expect(persisted.session.lastTransitionAt).toBe("2026-04-17T12:00:00.000Z"); + }); }); describe("createStateTransitionDecision", () => { diff --git a/packages/core/src/__tests__/session-manager/restore.test.ts b/packages/core/src/__tests__/session-manager/restore.test.ts index aa287bcee..9445d16f3 100644 --- a/packages/core/src/__tests__/session-manager/restore.test.ts +++ b/packages/core/src/__tests__/session-manager/restore.test.ts @@ -7,6 +7,7 @@ import { } from "node:fs"; import { join } from "node:path"; import { createSessionManager } from "../../session-manager.js"; +import { createInitialCanonicalLifecycle } from "../../lifecycle-state.js"; import { getWorkspaceAgentsMdPath } from "../../opencode-agents-md.js"; import { getProjectDir } from "../../paths.js"; import { @@ -283,8 +284,22 @@ describe("restore", () => { createdAt: "2025-01-01T00:00:00.000Z", runtimeHandle: makeHandle("rt-old"), }); + const staleLifecycle = createInitialCanonicalLifecycle( + "worker", + new Date("2025-01-01T00:00:00.000Z"), + ); + staleLifecycle.session.state = "terminated"; + staleLifecycle.session.reason = "runtime_lost"; + staleLifecycle.session.completedAt = "2025-01-01T00:01:00.000Z"; + staleLifecycle.session.terminatedAt = "2025-01-01T00:02:00.000Z"; + staleLifecycle.session.lastTransitionAt = "2025-01-01T00:02:00.000Z"; + staleLifecycle.pr.state = "open"; + staleLifecycle.pr.reason = "in_progress"; + staleLifecycle.pr.number = 10; + staleLifecycle.pr.url = "https://github.com/org/my-app/pull/10"; + staleLifecycle.pr.lastObservedAt = "2025-01-01T00:00:00.000Z"; updateMetadata(sessionsDir, "app-1", { - lifecycle: JSON.stringify({ session: { state: "terminated", terminatedAt: new Date().toISOString() } }), + lifecycle: JSON.stringify(staleLifecycle), }); const sm = createSessionManager({ config, registry: mockRegistry }); @@ -300,6 +315,14 @@ describe("restore", () => { expect(meta).not.toBeNull(); expect(meta!["issue"]).toBe("TEST-1"); expect(meta!["pr"]).toBe("https://github.com/org/my-app/pull/10"); + + const lifecycle = JSON.parse(meta!["lifecycle"]); + expect(lifecycle.session.state).toBe("working"); + expect(lifecycle.session.completedAt).toBeNull(); + expect(lifecycle.session.terminatedAt).toBeNull(); + expect(new Date(lifecycle.session.lastTransitionAt).getTime()).toBeGreaterThan( + new Date("2025-01-01T00:02:00.000Z").getTime(), + ); }); it("preserves displayName when restoring terminated session", async () => { diff --git a/packages/core/src/lifecycle-state.ts b/packages/core/src/lifecycle-state.ts index 0daf6f609..6192c23a9 100644 --- a/packages/core/src/lifecycle-state.ts +++ b/packages/core/src/lifecycle-state.ts @@ -128,6 +128,19 @@ const CanonicalSessionLifecycleSchema = z.object({ type ParsedCanonicalSessionLifecycle = z.infer; +const TERMINAL_CANONICAL_SESSION_STATES: ReadonlySet = new Set([ + "done", + "terminated", +]); + +export function clearTerminalMarkersForNonTerminalState( + lifecycle: CanonicalSessionLifecycle, +): void { + if (TERMINAL_CANONICAL_SESSION_STATES.has(lifecycle.session.state)) return; + lifecycle.session.completedAt = null; + lifecycle.session.terminatedAt = null; +} + function normalizeTimestamp(value: unknown, fallback: string | null = null): string | null { if (typeof value !== "string") return fallback; const parsed = Date.parse(value); diff --git a/packages/core/src/lifecycle-transition.ts b/packages/core/src/lifecycle-transition.ts index f54aebb34..b35b46d40 100644 --- a/packages/core/src/lifecycle-transition.ts +++ b/packages/core/src/lifecycle-transition.ts @@ -19,7 +19,11 @@ import type { SessionId, SessionStatus, } from "./types.js"; -import { cloneLifecycle, deriveLegacyStatus } from "./lifecycle-state.js"; +import { + clearTerminalMarkersForNonTerminalState, + cloneLifecycle, + deriveLegacyStatus, +} from "./lifecycle-state.js"; import { updateMetadata, readMetadataRaw, readCanonicalLifecycle } from "./metadata.js"; import type { LifecycleDecision } from "./lifecycle-status-decisions.js"; @@ -35,6 +39,7 @@ export type TransitionSource = | "cleanup" // Session cleanup | "claim_pr"; // PR claim + /** * Result of a lifecycle transition attempt. */ @@ -106,6 +111,8 @@ export function applyDecisionToLifecycle( lifecycle.session.terminatedAt = nowIso; } } + + clearTerminalMarkersForNonTerminalState(lifecycle); } /** diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index d276b1b09..be8238e10 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -62,6 +62,7 @@ import { } from "./metadata.js"; import { buildLifecycleMetadataPatch, + clearTerminalMarkersForNonTerminalState, cloneLifecycle, createInitialCanonicalLifecycle, deriveLegacyStatus, @@ -108,6 +109,7 @@ const OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS = 10_000; const EXEC_SHELL_OPTION = process.platform === "win32" ? ({ shell: true, windowsHide: true } as const) : ({} as const); + function errorIncludesSessionNotFound(err: unknown): boolean { if (!(err instanceof Error)) return false; const e = err as Error & { stderr?: string; stdout?: string }; @@ -493,6 +495,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM }), ); updater(lifecycle); + clearTerminalMarkersForNonTerminalState(lifecycle); return lifecycle; }