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>
This commit is contained in:
i-trytoohard 2026-05-15 04:46:35 +05:30 committed by GitHub
parent 6fb18cb47e
commit d44e708525
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 110 additions and 2 deletions

View File

@ -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", () => {

View File

@ -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 () => {

View File

@ -128,6 +128,19 @@ const CanonicalSessionLifecycleSchema = z.object({
type ParsedCanonicalSessionLifecycle = z.infer<typeof CanonicalSessionLifecycleSchema>;
const TERMINAL_CANONICAL_SESSION_STATES: ReadonlySet<CanonicalSessionState> = 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);

View File

@ -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);
}
/**

View File

@ -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;
}