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:
parent
6fb18cb47e
commit
d44e708525
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue