fix: recover native session restore fallback (#1797)

* fix: recover native session restore fallback

Persist native agent restore metadata from dead runtimes and fall back to a fresh launch when native restore context is missing, so stuck sessions do not permanently block startup.

* fix: avoid redundant dead-session metadata discovery

* test: harden Linear list issue polling
This commit is contained in:
Priyanshu Choudhary 2026-05-18 01:37:39 +05:30 committed by GitHub
parent 406b26e837
commit eac581768d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 261 additions and 61 deletions

View File

@ -8,6 +8,7 @@ import { createSessionManager } from "../../session-manager.js";
import {
writeMetadata,
readMetadataRaw,
updateMetadata,
} from "../../metadata.js";
import { createInitialCanonicalLifecycle } from "../../lifecycle-state.js";
import type {
@ -63,6 +64,47 @@ describe("list", () => {
expect(sessions.map((s) => s.id).sort()).toEqual(["app-1", "app-2"]);
});
it("skips dead-runtime agent metadata discovery when native restore metadata is already persisted", async () => {
writeMetadata(sessionsDir, "app-1", {
worktree: config.projects["my-app"]!.path,
branch: "feat/a",
status: "killed",
project: "my-app",
runtimeHandle: makeHandle("rt-old"),
});
updateMetadata(sessionsDir, "app-1", { codexThreadId: "thread-1" });
const deadRuntime: Runtime = {
...mockRuntime,
isAlive: vi.fn().mockResolvedValue(false),
};
const agentWithSessionInfo: Agent = {
...mockAgent,
name: "codex",
getSessionInfo: vi.fn().mockResolvedValue({
summary: null,
agentSessionId: "rollout-1",
metadata: { codexThreadId: "thread-1" },
}),
};
const registryWithDeadRuntime: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return deadRuntime;
if (slot === "agent") return agentWithSessionInfo;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
const sm = createSessionManager({ config, registry: registryWithDeadRuntime });
await sm.list("my-app");
await sm.list("my-app");
expect(agentWithSessionInfo.getSessionInfo).not.toHaveBeenCalled();
expect(readMetadataRaw(sessionsDir, "app-1")!["codexThreadId"]).toBe("thread-1");
});
it("does not backfill role onto foreign bare-id orchestrator records (issue #1048)", async () => {
// Regression guard for PR #1075 review comment: a legacy record whose id
// is `{projectId}-orchestrator` (pre-numbered scheme, wrong prefix) must

View File

@ -575,7 +575,7 @@ describe("restore", () => {
expect(meta!["restoreFallbackReason"]).toBe("mock-agent.getRestoreCommand returned null");
});
it("does not launch a fresh chat when a native-restore agent cannot build restore command", async () => {
it("falls back to a fresh launch when a native-restore agent cannot build restore command", async () => {
const wsPath = join(tmpDir, "ws-app-native-restore-missing");
mkdirSync(wsPath, { recursive: true });
@ -605,12 +605,119 @@ describe("restore", () => {
const sm = createSessionManager({ config, registry: registryWithNativeRestoreAgent });
await expect(sm.restore("app-1")).rejects.toThrow(SessionNotRestorableError);
expect(mockRuntime.create).not.toHaveBeenCalled();
await sm.restore("app-1");
expect(mockRuntime.create).toHaveBeenCalled();
const createCall = (mockRuntime.create as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(createCall.launchCommand).toBe("mock-agent --start");
const meta = readMetadataRaw(sessionsDir, "app-1");
expect(meta!["restoreFallbackReason"]).toBe("codex.getRestoreCommand returned null");
});
it("persists native restore metadata even when runtime is already dead", async () => {
const wsPath = join(tmpDir, "ws-app-dead-runtime-metadata");
mkdirSync(wsPath, { recursive: true });
const deadRuntime: Runtime = {
...mockRuntime,
isAlive: vi.fn().mockResolvedValue(false),
};
const agentWithDiscoverableThread: Agent = {
...mockAgent,
name: "codex",
getSessionInfo: vi.fn().mockResolvedValue({
summary: null,
agentSessionId: "rollout-1",
metadata: { codexThreadId: "thread-1" },
}),
getRestoreCommand: vi
.fn()
.mockImplementation(async (session) =>
session.metadata?.codexThreadId ? `codex resume ${session.metadata.codexThreadId}` : null,
),
};
const registryWithDeadRuntime: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return deadRuntime;
if (slot === "agent") return agentWithDiscoverableThread;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
writeMetadata(sessionsDir, "app-1", {
worktree: wsPath,
branch: "feat/TEST-1",
status: "killed",
project: "my-app",
runtimeHandle: makeHandle("rt-old"),
});
const sm = createSessionManager({ config, registry: registryWithDeadRuntime });
const restored = await sm.restore("app-1");
expect(agentWithDiscoverableThread.getSessionInfo).toHaveBeenCalled();
expect(agentWithDiscoverableThread.getRestoreCommand).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({ codexThreadId: "thread-1" }),
}),
expect.any(Object),
);
expect(restored.metadata["codexThreadId"]).toBe("thread-1");
const createCall = (deadRuntime.create as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(createCall.launchCommand).toBe("codex resume thread-1");
const meta = readMetadataRaw(sessionsDir, "app-1");
expect(meta!["codexThreadId"]).toBe("thread-1");
});
it("uses project path as restore workspace when worktree metadata is missing", async () => {
mkdirSync(config.projects["my-app"]!.path, { recursive: true });
const agentWithWorkspaceAssertion: Agent = {
...mockAgent,
name: "codex",
getRestoreCommand: vi
.fn()
.mockImplementation(async (session) =>
session.workspacePath === config.projects["my-app"]!.path
? "codex resume thread-1"
: null,
),
};
const registryWithWorkspaceAssertion: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return agentWithWorkspaceAssertion;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
writeMetadata(sessionsDir, "app-1", {
worktree: "",
branch: "feat/TEST-1",
status: "killed",
project: "my-app",
runtimeHandle: makeHandle("rt-old"),
});
const sm = createSessionManager({ config, registry: registryWithWorkspaceAssertion });
const restored = await sm.restore("app-1");
expect(restored.workspacePath).toBe(config.projects["my-app"]!.path);
expect(agentWithWorkspaceAssertion.getRestoreCommand).toHaveBeenCalledWith(
expect.objectContaining({ workspacePath: config.projects["my-app"]!.path }),
expect.any(Object),
);
const createCall = (mockRuntime.create as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(createCall.workspacePath).toBe(config.projects["my-app"]!.path);
expect(createCall.launchCommand).toBe("codex resume thread-1");
});
it("clears restore fallback reason when getRestoreCommand succeeds", async () => {
const wsPath = join(tmpDir, "ws-app-restore-clears-fallback");
mkdirSync(wsPath, { recursive: true });

View File

@ -141,6 +141,7 @@ function metadataToSession(sessionId: string, project: PortfolioProject, metadat
return sessionFromMetadata(sessionId, metadataToRecord(metadata), {
projectId: project.id,
workspacePathFallback: project.repoPath,
status: (metadata.status as Session["status"]) || "spawning",
activity: null,
runtimeHandle: metadata.runtimeHandle ?? null,

View File

@ -132,6 +132,7 @@ export async function recoverSession(
const session = sessionFromMetadata(sessionId, updatedMetadata, {
projectId: assessment.projectId,
workspacePathFallback: assessment.workspacePath ?? undefined,
status: preservedStatus,
runtimeHandle: assessment.runtimeHandle,
lastActivityAt: new Date(),

View File

@ -338,27 +338,33 @@ function parseLifecycleFromRaw(
}
}
interface MetadataToSessionOptions {
projectId: string;
sessionPrefix?: string;
createdAt?: Date;
modifiedAt?: Date;
workspacePathFallback?: string;
}
/** Reconstruct a Session object from raw metadata key=value pairs. */
function metadataToSession(
sessionId: SessionId,
meta: Record<string, string>,
projectId: string,
sessionPrefix?: string,
createdAt?: Date,
modifiedAt?: Date,
options: MetadataToSessionOptions,
): Session {
const sessionKind =
meta["role"] === "orchestrator" ||
(sessionPrefix
? new RegExp(`^${escapeRegex(sessionPrefix)}-orchestrator-\\d+$`).test(sessionId)
(options.sessionPrefix
? new RegExp(`^${escapeRegex(options.sessionPrefix)}-orchestrator-\\d+$`).test(sessionId)
: false)
? "orchestrator"
: "worker";
return sessionFromMetadata(sessionId, meta, {
projectId,
projectId: options.projectId,
workspacePathFallback: options.workspacePathFallback,
sessionKind,
createdAt,
lastActivityAt: modifiedAt ?? new Date(),
createdAt: options.createdAt,
lastActivityAt: options.modifiedAt ?? new Date(),
});
}
@ -453,18 +459,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
return isOrchestratorSessionRecord(sessionId, metadata ?? {}, project.sessionPrefix);
}
function requiresNativeRestore(agentName: string): boolean {
// kimicode is intentionally excluded: kimi's session dir only exists if the
// previous launch ran far enough to write to ~/.kimi/sessions. A failed
// launch (e.g. the --agent-file YAML crash) leaves no session to resume,
// and falling back to a fresh getLaunchCommand is the only sensible choice.
return (
agentName === "claude-code" ||
agentName === "codex" ||
agentName === "opencode"
);
}
function applyMetadataUpdatesToRaw(
raw: Record<string, string>,
updates: Partial<Record<string, string>>,
@ -1020,12 +1014,68 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
*/
const TERMINAL_SESSION_STATUSES = new Set(["killed", "done", "merged", "terminated", "cleanup"]);
function hasPersistedNativeRestoreMetadata(session: Session, agent: Agent): boolean {
const metadata = session.metadata ?? {};
switch (agent.name) {
case "claude-code":
return typeof metadata["claudeSessionUuid"] === "string" && metadata["claudeSessionUuid"].trim().length > 0;
case "codex":
return typeof metadata["codexThreadId"] === "string" && metadata["codexThreadId"].trim().length > 0;
case "opencode":
return asValidOpenCodeSessionId(metadata["opencodeSessionId"]) !== null;
default:
return false;
}
}
function canDiscoverSessionInfoAfterRuntimeExit(agent: Agent): boolean {
return agent.name === "claude-code" || agent.name === "codex";
}
async function enrichSessionWithRuntimeState(
session: Session,
plugins: ReturnType<typeof resolvePlugins>,
handleFromMetadata: boolean,
sessionsDir: string,
): Promise<void> {
async function persistAgentSessionInfo(options?: { skipIfNativeRestoreMetadataPresent?: boolean }): Promise<void> {
if (!plugins.agent) return;
if (
options?.skipIfNativeRestoreMetadataPresent &&
hasPersistedNativeRestoreMetadata(session, plugins.agent)
) {
return;
}
let info: Awaited<ReturnType<Agent["getSessionInfo"]>>;
try {
info = await plugins.agent.getSessionInfo(session);
} catch {
// Can't get session info — keep existing values
info = null;
}
if (!info) return;
session.agentInfo = info;
const metadataUpdates = info.metadata ?? {};
const allAlreadyPersisted = Object.keys(metadataUpdates).every(
(key) => session.metadata?.[key] === metadataUpdates[key],
);
if (allAlreadyPersisted) return;
if (Object.keys(metadataUpdates).length > 0) {
try {
updateMetadata(sessionsDir, session.id, metadataUpdates);
session.metadata = applyMetadataUpdates(session.metadata, metadataUpdates);
invalidateCache();
} catch {
// Persisting agent metadata is best-effort; keep live agent info.
}
}
}
// Check runtime liveness first — for all statuses except "spawning".
// Skip spawning sessions because tmux may not be fully initialized yet,
// and a false-negative from isAlive() would permanently mark the session
@ -1066,6 +1116,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
activity: "exited",
source: "runtime",
});
// Dead-runtime session info discovery is intentionally limited to
// agents that recover restore metadata from persisted local files.
if (plugins.agent && canDiscoverSessionInfoAfterRuntimeExit(plugins.agent)) {
await persistAgentSessionInfo({ skipIfNativeRestoreMetadataPresent: true });
}
return;
}
} catch {
@ -1102,28 +1157,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
session.activitySignal = createActivitySignal("probe_failure", { source: "native" });
}
// Enrich with live agent session info (summary, cost).
let info: Awaited<ReturnType<Agent["getSessionInfo"]>>;
try {
info = await plugins.agent.getSessionInfo(session);
} catch {
// Can't get session info — keep existing values
info = null;
}
if (info) {
session.agentInfo = info;
const metadataUpdates = info.metadata ?? {};
if (Object.keys(metadataUpdates).length > 0) {
try {
updateMetadata(sessionsDir, session.id, metadataUpdates);
session.metadata = applyMetadataUpdates(session.metadata, metadataUpdates);
invalidateCache();
} catch {
// Persisting agent metadata is best-effort; keep live agent info.
}
}
}
// Enrich with agent session info (summary, cost, native restore metadata).
await persistAgentSessionInfo();
}
}
@ -1977,10 +2012,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
const session = metadataToSession(
sessionName,
raw,
sessionProjectId,
project.sessionPrefix,
createdAt,
modifiedAt,
{
projectId: sessionProjectId,
sessionPrefix: project.sessionPrefix,
createdAt,
modifiedAt,
workspacePathFallback: project.path,
},
);
const selection = resolveSelectionForSession(project, sessionName, raw);
const effectiveAgentName = selection.agentName;
@ -2098,10 +2136,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
const session = metadataToSession(
sessionId,
repaired.raw,
projectId,
project.sessionPrefix,
createdAt,
modifiedAt,
{
projectId,
sessionPrefix: project.sessionPrefix,
createdAt,
modifiedAt,
workspacePathFallback: project.path,
},
);
const selection = resolveSelectionForSession(project, sessionId, repaired.raw);
@ -2872,7 +2913,15 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
// metadataToSession sets activity: null, so without enrichment a crashed
// session (status "working", agent exited) would not be detected as terminal
// and isRestorable would reject it.
const session = metadataToSession(sessionId, raw, projectId, project.sessionPrefix);
const session = metadataToSession(
sessionId,
raw,
{
projectId,
sessionPrefix: project.sessionPrefix,
workspacePathFallback: project.path,
},
);
const plugins = resolvePlugins(project, selection.agentName);
await enrichSessionWithRuntimeState(session, plugins, true, sessionsDir);
@ -3008,13 +3057,12 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
launchCommand = restoreCmd;
updateMetadata(sessionsDir, sessionId, { restoreFallbackReason: "" });
} else {
// Agents with native restore can still launch fresh when no resumable
// session metadata exists; this keeps restore from becoming a hard stop.
const reason = `${plugins.agent.name}.getRestoreCommand returned null`;
updateMetadata(sessionsDir, sessionId, {
restoreFallbackReason: reason,
});
if (requiresNativeRestore(plugins.agent.name)) {
throw new SessionNotRestorableError(sessionId, reason);
}
launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig);
}
} else {

View File

@ -14,6 +14,7 @@ import { safeJsonParse, validateStatus } from "./validation.js";
interface SessionFromMetadataOptions {
projectId?: string;
workspacePathFallback?: string;
status?: SessionStatus;
sessionKind?: SessionKind;
activity?: Session["activity"];
@ -86,7 +87,7 @@ export function sessionFromMetadata(
};
})()
: null,
workspacePath: meta["worktree"] || null,
workspacePath: meta["worktree"] || options.workspacePathFallback || null,
runtimeHandle: lifecycle.runtime.handle ?? runtimeHandle,
agentInfo: meta["summary"] ? { summary: meta["summary"], agentSessionId: null } : null,
createdAt: meta["createdAt"] ? new Date(meta["createdAt"]) : (options.createdAt ?? new Date()),

View File

@ -252,10 +252,10 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => {
// Linear API has eventual consistency — poll until the issue appears in list results
const found = await pollUntil(
async () => {
const issues = await tracker.listIssues!({ state: "open", limit: 50 }, project);
const issues = await tracker.listIssues!({ state: "open", limit: 100 }, project);
return issues.find((i: { id: string }) => i.id === issueIdentifier);
},
{ timeoutMs: 5_000, intervalMs: 500 },
{ timeoutMs: 15_000, intervalMs: 1_000 },
);
expect(found).toBeDefined();