fix(coding-agent): persisted vibe sessions across restarts
Vibe worker roster lived only in a process-local Map, so a resumed parent session started with an empty registry and vibe_send failed with "Unknown vibe session". Persist a versioned, parent-scoped lifecycle journal (spawn/turn/tombstone events), rehydrate validated idle workers through the persisted-subagent reviver on resume, and gate the flow with generation/CAS protection so stale finalizers cannot clobber a replacement worker. Killed transcripts stay readable but non-revivable; mode-exit commits tombstones atomically with the mode change and rolls back cleanly on storage failure. Ported from @mastertyko's fork branch fix/vibe-session-persistence. Fixes #5303
This commit is contained in:
@@ -33,7 +33,7 @@ import type { MCPManager } from "../mcp/manager";
|
||||
import type { MnemopiSessionState } from "../mnemopi/state";
|
||||
import subagentSystemPromptTemplate from "../prompts/system/subagent-system-prompt.md" with { type: "text" };
|
||||
import submitReminderTemplate from "../prompts/system/subagent-yield-reminder.md" with { type: "text" };
|
||||
import { AgentLifecycleManager } from "../registry/agent-lifecycle";
|
||||
import { AgentLifecycleManager, type AgentReviver } from "../registry/agent-lifecycle";
|
||||
import { AgentRegistry } from "../registry/agent-registry";
|
||||
import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage } from "../sdk";
|
||||
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
|
||||
@@ -1945,9 +1945,11 @@ export async function finalizeSubagentLifecycle(args: {
|
||||
keepAlive: boolean;
|
||||
isolated: boolean;
|
||||
agentIdleTtlMs: number;
|
||||
reviveSession: (() => Promise<AgentSession>) | null;
|
||||
reviveSession: AgentReviver | null;
|
||||
}): Promise<void> {
|
||||
const registry = AgentRegistry.global();
|
||||
const ref = registry.get(args.id);
|
||||
const ownsRef = Boolean(ref && ref.session === args.session);
|
||||
const disposeSession = async (): Promise<void> => {
|
||||
try {
|
||||
await untilAborted(AbortSignal.timeout(5000), () => args.session.dispose());
|
||||
@@ -1962,7 +1964,7 @@ export async function finalizeSubagentLifecycle(args: {
|
||||
const resumableAbort =
|
||||
args.abortKind === "budget" && args.keepAlive && !args.isolated && args.reviveSession !== null;
|
||||
if (args.aborted && !resumableAbort) {
|
||||
registry.setStatus(args.id, "aborted");
|
||||
if (ref && ownsRef) registry.setStatus(args.id, "aborted", ref);
|
||||
await disposeSession();
|
||||
return;
|
||||
}
|
||||
@@ -1970,7 +1972,7 @@ export async function finalizeSubagentLifecycle(args: {
|
||||
if (!args.keepAlive) {
|
||||
// One-shot helper: dispose and unregister. No IRC, no revival.
|
||||
await disposeSession();
|
||||
registry.unregister(args.id);
|
||||
if (ref && ownsRef) registry.unregister(args.id, ref);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1980,19 +1982,26 @@ export async function finalizeSubagentLifecycle(args: {
|
||||
// transcript stays reachable (history://), but ensureLive will throw.
|
||||
// Status must flip to "parked" before dispose so the sdk dispose
|
||||
// wrapper skips unregister.
|
||||
registry.setStatus(args.id, "parked");
|
||||
if (ref && ownsRef) registry.setStatus(args.id, "parked", ref);
|
||||
await disposeSession();
|
||||
registry.detachSession(args.id);
|
||||
if (ref && ownsRef) registry.detachSession(args.id, ref);
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep-alive: finished and failed subagents both stay interrogable.
|
||||
// The lifecycle manager owns idle-TTL parking + revival from here on.
|
||||
registry.setStatus(args.id, "idle");
|
||||
AgentLifecycleManager.global().adopt(args.id, {
|
||||
idleTtlMs: args.agentIdleTtlMs,
|
||||
revive: args.reviveSession ?? undefined,
|
||||
});
|
||||
if (!ref || !ownsRef || !registry.setStatus(args.id, "idle", ref)) {
|
||||
await disposeSession();
|
||||
return;
|
||||
}
|
||||
AgentLifecycleManager.global().adopt(
|
||||
args.id,
|
||||
{
|
||||
idleTtlMs: args.agentIdleTtlMs,
|
||||
revive: args.reviveSession ?? undefined,
|
||||
},
|
||||
ref,
|
||||
);
|
||||
}
|
||||
|
||||
/** Options for {@link runSubagentFollowUpTurn}. */
|
||||
@@ -2240,7 +2249,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
});
|
||||
const progress = monitor.progress;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let reviveSession: (() => Promise<AgentSession>) | null = null;
|
||||
let reviveSession: AgentReviver | null = null;
|
||||
// Adopted (kept-alive) subagents flip registry status from session events on
|
||||
// later turns: revive/wake → running, turn drained → idle. The subscription
|
||||
// intentionally survives this run; a disposed session emits nothing, so it
|
||||
@@ -2248,9 +2257,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
const installRegistryStatusSync = (target: AgentSession): void => {
|
||||
target.subscribe(event => {
|
||||
if (event.type === "agent_start") {
|
||||
AgentRegistry.global().setStatus(id, "running");
|
||||
AgentRegistry.global().setStatus(id, "running", target);
|
||||
} else if (event.type === "agent_end") {
|
||||
AgentRegistry.global().setStatus(id, "idle");
|
||||
AgentRegistry.global().setStatus(id, "idle", target);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -2426,7 +2435,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
// the same JSONL file re-invokes createAgentSession with the exact options
|
||||
// of the original run (same agent id, tools, model, system prompt,
|
||||
// artifacts dir) — only the SessionManager differs.
|
||||
const buildSubagentSessionOptions = (sessionManagerForRun: SessionManager): CreateAgentSessionOptions => ({
|
||||
const buildSubagentSessionOptions = (
|
||||
sessionManagerForRun: SessionManager,
|
||||
expectedAgentRef: CreateAgentSessionOptions["expectedAgentRef"],
|
||||
): CreateAgentSessionOptions => ({
|
||||
cwd: worktree ?? cwd,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
@@ -2474,6 +2486,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
parentAgentId: options.parentAgentId,
|
||||
agentId: id,
|
||||
agentDisplayName: agent.name,
|
||||
expectedAgentRef,
|
||||
enableLsp: lspEnabled,
|
||||
skipPythonPreflight,
|
||||
enableMCP,
|
||||
@@ -2487,7 +2500,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
},
|
||||
});
|
||||
|
||||
const sessionPromise = createAgentSession(buildSubagentSessionOptions(sessionManager));
|
||||
const sessionPromise = createAgentSession(buildSubagentSessionOptions(sessionManager, null));
|
||||
let session: AgentSession;
|
||||
try {
|
||||
({ session } = await awaitAbortable(sessionPromise));
|
||||
@@ -2507,14 +2520,16 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
// the single-writer lock cleanly and restores the full message history
|
||||
// (createAgentSession → agent.replaceMessages). Isolated runs are not
|
||||
// resumable (worktree is merged + cleaned) and never get a reviver.
|
||||
reviveSession = async () => {
|
||||
reviveSession = async expectedAgentRef => {
|
||||
const reopened = await SessionManager.open(sessionFile, undefined, undefined, {
|
||||
suppressBreadcrumb: true,
|
||||
});
|
||||
if (options.parentArtifactManager) {
|
||||
reopened.adoptArtifactManager(options.parentArtifactManager);
|
||||
}
|
||||
const { session: revived } = await createAgentSession(buildSubagentSessionOptions(reopened));
|
||||
const { session: revived } = await createAgentSession(
|
||||
buildSubagentSessionOptions(reopened, expectedAgentRef),
|
||||
);
|
||||
installRegistryStatusSync(revived);
|
||||
return revived;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user