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:
parent
406b26e837
commit
eac581768d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue