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:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed persisted vibe workers disappearing or being replaced across graceful restarts, session switches, failed mode exits, and late cancelled initialization: resumable conversations now restore safely, mode exit atomically commits worker tombstones with the mode change and rolls back cleanly on storage failure, explicit kills tear workers down monotonically while repairing uncertain append tails, killed transcripts remain readable but non-revivable, and stale initializers cannot overwrite newer same-ID workers ([#5303](https://github.com/can1357/oh-my-pi/issues/5303) by [@mastertyko](https://github.com/mastertyko)).
|
||||||
|
|
||||||
## [16.4.8] - 2026-07-12
|
## [16.4.8] - 2026-07-12
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -614,10 +614,11 @@ export class CollabHost {
|
|||||||
case "kill": {
|
case "kill": {
|
||||||
const kill = async () => {
|
const kill = async () => {
|
||||||
const ref = AgentRegistry.global().get(agentId);
|
const ref = AgentRegistry.global().get(agentId);
|
||||||
if (ref && ref.status === "running" && ref.session) {
|
if (!ref) return;
|
||||||
|
if (ref.status === "running" && ref.session) {
|
||||||
await ref.session.abort({ reason: USER_INTERRUPT_LABEL });
|
await ref.session.abort({ reason: USER_INTERRUPT_LABEL });
|
||||||
}
|
}
|
||||||
await AgentLifecycleManager.global().release(agentId);
|
await AgentLifecycleManager.global().release(agentId, ref);
|
||||||
};
|
};
|
||||||
kill().catch(fail);
|
kill().catch(fail);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import { IrcBus } from "../../irc/bus";
|
|||||||
import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
|
import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
|
||||||
import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry";
|
import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry";
|
||||||
import { USER_INTERRUPT_LABEL } from "../../session/messages";
|
import { USER_INTERRUPT_LABEL } from "../../session/messages";
|
||||||
|
import { SessionManager } from "../../session/session-manager";
|
||||||
import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
|
import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
|
||||||
|
import { persistedVibeChildIds } from "../../vibe/runtime";
|
||||||
import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
|
import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
|
||||||
import { theme } from "../theme/theme";
|
import { theme } from "../theme/theme";
|
||||||
import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
|
import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
|
||||||
@@ -69,19 +71,35 @@ function statusBadge(status: AgentStatus): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readPersistedVibeChildIds(sessionFile: string): Promise<Set<string>> {
|
||||||
|
let sessionManager: SessionManager;
|
||||||
|
try {
|
||||||
|
sessionManager = await SessionManager.open(sessionFile, undefined, undefined, { suppressBreadcrumb: true });
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return persistedVibeChildIds(sessionManager.getEntries());
|
||||||
|
} finally {
|
||||||
|
await sessionManager.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function registerPersistedSubagents(
|
async function registerPersistedSubagents(
|
||||||
registry: AgentRegistry,
|
registry: AgentRegistry,
|
||||||
sessionFile: string | null | undefined,
|
sessionFile: string | null | undefined,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!sessionFile?.endsWith(".jsonl")) return;
|
if (!sessionFile?.endsWith(".jsonl")) return;
|
||||||
|
const vibeOwnedIds = await readPersistedVibeChildIds(sessionFile);
|
||||||
const root = sessionFile.slice(0, -6);
|
const root = sessionFile.slice(0, -6);
|
||||||
await registerPersistedSubagentsFromDir(registry, root, undefined);
|
await registerPersistedSubagentsFromDir(registry, root, undefined, vibeOwnedIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function registerPersistedSubagentsFromDir(
|
async function registerPersistedSubagentsFromDir(
|
||||||
registry: AgentRegistry,
|
registry: AgentRegistry,
|
||||||
dir: string,
|
dir: string,
|
||||||
parentId: string | undefined,
|
parentId: string | undefined,
|
||||||
|
vibeOwnedIds: ReadonlySet<string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let entries: fs.Dirent[];
|
let entries: fs.Dirent[];
|
||||||
try {
|
try {
|
||||||
@@ -123,6 +141,7 @@ async function registerPersistedSubagentsFromDir(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const id = entry.name.slice(0, -6);
|
const id = entry.name.slice(0, -6);
|
||||||
|
if (vibeOwnedIds.has(id) && registry.get(id)?.sessionFile !== sessionFile) continue;
|
||||||
if (!registry.get(id)) {
|
if (!registry.get(id)) {
|
||||||
registry.register({
|
registry.register({
|
||||||
id,
|
id,
|
||||||
@@ -134,7 +153,7 @@ async function registerPersistedSubagentsFromDir(
|
|||||||
status: "parked",
|
status: "parked",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await registerPersistedSubagentsFromDir(registry, path.join(dir, id), id);
|
await registerPersistedSubagentsFromDir(registry, path.join(dir, id), id, vibeOwnedIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,7 +626,7 @@ export class AgentHubOverlayComponent extends Container {
|
|||||||
if (ref.status === "running" && ref.session) {
|
if (ref.status === "running" && ref.session) {
|
||||||
await ref.session.abort({ reason: USER_INTERRUPT_LABEL });
|
await ref.session.abort({ reason: USER_INTERRUPT_LABEL });
|
||||||
}
|
}
|
||||||
await this.#lifecycle().release(ref.id);
|
await this.#lifecycle().release(ref.id, ref);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn("Agent hub: kill failed", { id: ref.id, error: String(error) });
|
logger.warn("Agent hub: kill failed", { id: ref.id, error: String(error) });
|
||||||
this.#notice = error instanceof Error ? error.message : String(error);
|
this.#notice = error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
@@ -992,7 +992,7 @@ export class CommandController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.ctx.sessionManager.moveTo(resolvedPath);
|
await this.ctx.session.moveSession(resolvedPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.ctx.showError(`Move failed: ${err instanceof Error ? err.message : String(err)}`);
|
this.ctx.showError(`Move failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" wit
|
|||||||
import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compact-instructions.md" with {
|
import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compact-instructions.md" with {
|
||||||
type: "text",
|
type: "text",
|
||||||
};
|
};
|
||||||
import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
|
import type { AgentRegistry } from "../registry/agent-registry";
|
||||||
import {
|
import {
|
||||||
type AgentSession,
|
type AgentSession,
|
||||||
type AgentSessionEvent,
|
type AgentSessionEvent,
|
||||||
@@ -126,7 +126,7 @@ import { getEditorCommand, openInEditor } from "../utils/external-editor";
|
|||||||
import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
|
import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
|
||||||
import { messageHasDisplayableThinking } from "../utils/thinking-display";
|
import { messageHasDisplayableThinking } from "../utils/thinking-display";
|
||||||
import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator";
|
import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator";
|
||||||
import { VibeSessionRegistry } from "../vibe/runtime";
|
import { type VibeOwnerScope, type VibeParentSession, VibeSessionRegistry } from "../vibe/runtime";
|
||||||
import type { AssistantMessageComponent } from "./components/assistant-message";
|
import type { AssistantMessageComponent } from "./components/assistant-message";
|
||||||
import type { BashExecutionComponent } from "./components/bash-execution";
|
import type { BashExecutionComponent } from "./components/bash-execution";
|
||||||
import { ChatBlock, type ChatBlockHost } from "./components/chat-block";
|
import { ChatBlock, type ChatBlockHost } from "./components/chat-block";
|
||||||
@@ -532,6 +532,8 @@ export class InteractiveMode implements InteractiveModeContext {
|
|||||||
#planModePreviousTools: string[] | undefined;
|
#planModePreviousTools: string[] | undefined;
|
||||||
#goalModePreviousTools: string[] | undefined;
|
#goalModePreviousTools: string[] | undefined;
|
||||||
#vibeModePreviousTools: string[] | undefined;
|
#vibeModePreviousTools: string[] | undefined;
|
||||||
|
#vibeModeOwnerScope: VibeOwnerScope | undefined;
|
||||||
|
#vibeScopeSuspendedForSwitch = false;
|
||||||
#goalContinuationTimer: NodeJS.Timeout | undefined;
|
#goalContinuationTimer: NodeJS.Timeout | undefined;
|
||||||
#goalTurnHadToolCalls = false;
|
#goalTurnHadToolCalls = false;
|
||||||
#goalContinuationTurnInFlight = false;
|
#goalContinuationTurnInFlight = false;
|
||||||
@@ -975,6 +977,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|||||||
await this.initHooksAndCustomTools();
|
await this.initHooksAndCustomTools();
|
||||||
|
|
||||||
// Restore mode from session (e.g. plan mode on resume)
|
// Restore mode from session (e.g. plan mode on resume)
|
||||||
|
this.session.setSessionBeforeSwitchReconciler?.(() => this.#quiesceVibeForSessionSwitch());
|
||||||
this.session.setSessionSwitchReconciler?.(() => this.#reconcileModeFromSession({ preserveActiveGoal: true }));
|
this.session.setSessionSwitchReconciler?.(() => this.#reconcileModeFromSession({ preserveActiveGoal: true }));
|
||||||
await this.#reconcileModeFromSession();
|
await this.#reconcileModeFromSession();
|
||||||
|
|
||||||
@@ -1955,6 +1958,24 @@ export class InteractiveMode implements InteractiveModeContext {
|
|||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#vibeParentSession(): VibeParentSession {
|
||||||
|
return {
|
||||||
|
getAgentId: () => this.session.getAgentId() ?? null,
|
||||||
|
getSessionId: () => this.sessionManager.getSessionId(),
|
||||||
|
getSessionFile: () => this.sessionManager.getSessionFile() ?? null,
|
||||||
|
sessionManager: this.sessionManager,
|
||||||
|
asyncJobManager: this.session.asyncJobManager,
|
||||||
|
settings: this.session.settings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async #quiesceVibeForSessionSwitch(): Promise<void> {
|
||||||
|
const ownerScope = this.#vibeModeOwnerScope;
|
||||||
|
if (!this.vibeModeEnabled || !ownerScope) return;
|
||||||
|
await VibeSessionRegistry.global().suspendScope(ownerScope, this.session.asyncJobManager);
|
||||||
|
this.#vibeScopeSuspendedForSwitch = true;
|
||||||
|
}
|
||||||
|
|
||||||
#updateGoalModeStatus(): void {
|
#updateGoalModeStatus(): void {
|
||||||
const status =
|
const status =
|
||||||
this.goalModeEnabled || this.goalModePaused
|
this.goalModeEnabled || this.goalModePaused
|
||||||
@@ -2092,7 +2113,10 @@ export class InteractiveMode implements InteractiveModeContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async #clearTransientModeState(): Promise<void> {
|
async #clearTransientModeState(options?: {
|
||||||
|
preserveVibe?: boolean;
|
||||||
|
vibeScopeAlreadySuspended?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
if (this.planModeEnabled || this.planModePaused) {
|
if (this.planModeEnabled || this.planModePaused) {
|
||||||
if (this.#planModePreviousTools !== undefined) {
|
if (this.#planModePreviousTools !== undefined) {
|
||||||
await this.session.setActiveToolsByName(this.#planModePreviousTools);
|
await this.session.setActiveToolsByName(this.#planModePreviousTools);
|
||||||
@@ -2124,23 +2148,35 @@ export class InteractiveMode implements InteractiveModeContext {
|
|||||||
this.#updateGoalModeStatus();
|
this.#updateGoalModeStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.vibeModeEnabled) {
|
if (this.vibeModeEnabled && !options?.preserveVibe) {
|
||||||
|
const ownerScope = this.#vibeModeOwnerScope;
|
||||||
await this.session.deactivateVibeTools(this.#vibeModePreviousTools ?? []);
|
await this.session.deactivateVibeTools(this.#vibeModePreviousTools ?? []);
|
||||||
this.session.setVibeModeState(undefined);
|
this.session.setVibeModeState(undefined);
|
||||||
this.vibeModeEnabled = false;
|
this.vibeModeEnabled = false;
|
||||||
this.#vibeModePreviousTools = undefined;
|
this.#vibeModePreviousTools = undefined;
|
||||||
await VibeSessionRegistry.global().killAll(
|
this.#vibeModeOwnerScope = undefined;
|
||||||
this.session.getAgentId() ?? MAIN_AGENT_ID,
|
if (ownerScope && !options?.vibeScopeAlreadySuspended) {
|
||||||
this.session.asyncJobManager,
|
await VibeSessionRegistry.global().suspendScope(ownerScope, this.session.asyncJobManager);
|
||||||
);
|
}
|
||||||
this.#updateVibeModeStatus();
|
this.#updateVibeModeStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reconcile mode state from session entries on resume/switch. */
|
/** Reconcile mode state from session entries on resume/switch. */
|
||||||
async #reconcileModeFromSession(options?: { preserveActiveGoal?: boolean }): Promise<void> {
|
async #reconcileModeFromSession(options?: { preserveActiveGoal?: boolean }): Promise<void> {
|
||||||
await this.#clearTransientModeState();
|
const vibeScopeAlreadySuspended = this.#vibeScopeSuspendedForSwitch;
|
||||||
|
this.#vibeScopeSuspendedForSwitch = false;
|
||||||
const sessionContext = this.sessionManager.buildSessionContext();
|
const sessionContext = this.sessionManager.buildSessionContext();
|
||||||
|
const vibeSession = this.#vibeParentSession();
|
||||||
|
const targetVibeScope = VibeSessionRegistry.global().ownerScope(vibeSession);
|
||||||
|
const preserveVibe =
|
||||||
|
this.vibeModeEnabled &&
|
||||||
|
sessionContext.mode === "vibe" &&
|
||||||
|
this.#vibeModeOwnerScope?.ownerId === targetVibeScope.ownerId &&
|
||||||
|
this.#vibeModeOwnerScope.parentSessionId === targetVibeScope.parentSessionId &&
|
||||||
|
this.#vibeModeOwnerScope.parentSessionFile === targetVibeScope.parentSessionFile;
|
||||||
|
await this.#clearTransientModeState({ preserveVibe, vibeScopeAlreadySuspended });
|
||||||
|
await VibeSessionRegistry.global().rehydrate(vibeSession);
|
||||||
const goalEnabled = this.session.settings.get("goal.enabled");
|
const goalEnabled = this.session.settings.get("goal.enabled");
|
||||||
if (!goalEnabled && (sessionContext.mode === "goal" || sessionContext.mode === "goal_paused")) {
|
if (!goalEnabled && (sessionContext.mode === "goal" || sessionContext.mode === "goal_paused")) {
|
||||||
this.session.goalRuntime.clearAccounting();
|
this.session.goalRuntime.clearAccounting();
|
||||||
@@ -2175,7 +2211,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|||||||
}
|
}
|
||||||
this.session.goalRuntime.clearAccounting();
|
this.session.goalRuntime.clearAccounting();
|
||||||
if (sessionContext.mode === "vibe") {
|
if (sessionContext.mode === "vibe") {
|
||||||
await this.#enterVibeMode();
|
if (!preserveVibe) await this.#enterVibeMode({ persistModeChange: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.session.settings.get("plan.enabled")) {
|
if (!this.session.settings.get("plan.enabled")) {
|
||||||
@@ -2959,7 +2995,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async #enterVibeMode(): Promise<void> {
|
async #enterVibeMode(options?: { persistModeChange?: boolean }): Promise<void> {
|
||||||
if (this.vibeModeEnabled) {
|
if (this.vibeModeEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2972,9 +3008,13 @@ export class InteractiveMode implements InteractiveModeContext {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const vibeRegistry = VibeSessionRegistry.global();
|
||||||
|
const ownerScope = vibeRegistry.ownerScope(this.#vibeParentSession());
|
||||||
|
vibeRegistry.activateScope(ownerScope);
|
||||||
const previousTools = this.session.getActiveToolNames();
|
const previousTools = this.session.getActiveToolNames();
|
||||||
await this.session.activateVibeTools(["read"]);
|
await this.session.activateVibeTools(["read"]);
|
||||||
this.#vibeModePreviousTools = previousTools;
|
this.#vibeModePreviousTools = previousTools;
|
||||||
|
this.#vibeModeOwnerScope = ownerScope;
|
||||||
this.vibeModeEnabled = true;
|
this.vibeModeEnabled = true;
|
||||||
// Suppress cache-miss marker on the next turn: vibe mode changes the
|
// Suppress cache-miss marker on the next turn: vibe mode changes the
|
||||||
// injected context, which predictably invalidates the cache.
|
// injected context, which predictably invalidates the cache.
|
||||||
@@ -2984,7 +3024,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|||||||
await this.session.sendVibeModeContext({ deliverAs: "steer" });
|
await this.session.sendVibeModeContext({ deliverAs: "steer" });
|
||||||
}
|
}
|
||||||
this.#updateVibeModeStatus();
|
this.#updateVibeModeStatus();
|
||||||
this.sessionManager.appendModeChange("vibe");
|
if (options?.persistModeChange !== false) this.sessionManager.appendModeChange("vibe");
|
||||||
this.showStatus("Vibe mode enabled. You direct fast/good worker sessions; toolset is read + vibe tools.");
|
this.showStatus("Vibe mode enabled. You direct fast/good worker sessions; toolset is read + vibe tools.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2992,17 +3032,15 @@ export class InteractiveMode implements InteractiveModeContext {
|
|||||||
if (!this.vibeModeEnabled) {
|
if (!this.vibeModeEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const ownerScope = this.#vibeModeOwnerScope;
|
||||||
|
const killed = await VibeSessionRegistry.global().killAll(this.#vibeParentSession(), ownerScope);
|
||||||
await this.session.deactivateVibeTools(this.#vibeModePreviousTools ?? []);
|
await this.session.deactivateVibeTools(this.#vibeModePreviousTools ?? []);
|
||||||
this.session.setVibeModeState(undefined);
|
this.session.setVibeModeState(undefined);
|
||||||
this.vibeModeEnabled = false;
|
this.vibeModeEnabled = false;
|
||||||
this.#vibeModePreviousTools = undefined;
|
this.#vibeModePreviousTools = undefined;
|
||||||
|
this.#vibeModeOwnerScope = undefined;
|
||||||
this.lastAssistantUsage = undefined;
|
this.lastAssistantUsage = undefined;
|
||||||
const killed = await VibeSessionRegistry.global().killAll(
|
|
||||||
this.session.getAgentId() ?? MAIN_AGENT_ID,
|
|
||||||
this.session.asyncJobManager,
|
|
||||||
);
|
|
||||||
this.#updateVibeModeStatus();
|
this.#updateVibeModeStatus();
|
||||||
this.sessionManager.appendModeChange("none");
|
|
||||||
this.showStatus(
|
this.showStatus(
|
||||||
killed > 0
|
killed > 0
|
||||||
? `Vibe mode disabled. Killed ${killed} worker session${killed === 1 ? "" : "s"}.`
|
? `Vibe mode disabled. Killed ${killed} worker session${killed === 1 ? "" : "s"}.`
|
||||||
|
|||||||
@@ -12,9 +12,15 @@
|
|||||||
|
|
||||||
import { logger } from "@oh-my-pi/pi-utils";
|
import { logger } from "@oh-my-pi/pi-utils";
|
||||||
import type { AgentSession } from "../session/agent-session";
|
import type { AgentSession } from "../session/agent-session";
|
||||||
import { type AgentRef, AgentRegistry, MAIN_AGENT_ID, type RegistryEvent } from "./agent-registry";
|
import {
|
||||||
|
type AgentRef,
|
||||||
|
type AgentRefExpectation,
|
||||||
|
AgentRegistry,
|
||||||
|
MAIN_AGENT_ID,
|
||||||
|
type RegistryEvent,
|
||||||
|
} from "./agent-registry";
|
||||||
|
|
||||||
export type AgentReviver = () => Promise<AgentSession>;
|
export type AgentReviver = (expected: AgentRef) => Promise<AgentSession>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a reviver for a `parked` ref restored from disk (Agent Hub scan,
|
* Builds a reviver for a `parked` ref restored from disk (Agent Hub scan,
|
||||||
@@ -33,11 +39,17 @@ export interface AdoptOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AdoptedAgent {
|
interface AdoptedAgent {
|
||||||
|
ref: AgentRef;
|
||||||
idleTtlMs: number;
|
idleTtlMs: number;
|
||||||
revive?: AgentReviver;
|
revive?: AgentReviver;
|
||||||
timer?: NodeJS.Timeout;
|
timer?: NodeJS.Timeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RevivingAgent {
|
||||||
|
ref: AgentRef;
|
||||||
|
promise: Promise<AgentSession>;
|
||||||
|
}
|
||||||
|
|
||||||
export class AgentLifecycleManager {
|
export class AgentLifecycleManager {
|
||||||
static #global: AgentLifecycleManager | undefined;
|
static #global: AgentLifecycleManager | undefined;
|
||||||
|
|
||||||
@@ -67,10 +79,10 @@ export class AgentLifecycleManager {
|
|||||||
|
|
||||||
readonly #registry: AgentRegistry;
|
readonly #registry: AgentRegistry;
|
||||||
readonly #adopted = new Map<string, AdoptedAgent>();
|
readonly #adopted = new Map<string, AdoptedAgent>();
|
||||||
/** Ids whose session is being disposed by {@link park} right now. */
|
/** Exact refs whose session is being disposed by {@link park} right now. */
|
||||||
readonly #parking = new Set<string>();
|
readonly #parking = new Map<string, AgentRef>();
|
||||||
/** In-flight revives, so concurrent {@link ensureLive} calls coalesce. */
|
/** In-flight revives, bound to the parked ref that initiated them. */
|
||||||
readonly #revivals = new Map<string, Promise<AgentSession>>();
|
readonly #revivals = new Map<string, RevivingAgent>();
|
||||||
#unsubscribe: (() => void) | undefined;
|
#unsubscribe: (() => void) | undefined;
|
||||||
#persistedReviverFactory: PersistedSubagentReviverFactory | undefined;
|
#persistedReviverFactory: PersistedSubagentReviverFactory | undefined;
|
||||||
/** TTL applied when a cold-revived ref is adopted on demand. */
|
/** TTL applied when a cold-revived ref is adopted on demand. */
|
||||||
@@ -96,27 +108,32 @@ export class AgentLifecycleManager {
|
|||||||
* Take ownership of a finished subagent. Caller has already set registry
|
* Take ownership of a finished subagent. Caller has already set registry
|
||||||
* status to "idle". Arms the TTL timer (idleTtlMs <= 0 adopts without one).
|
* status to "idle". Arms the TTL timer (idleTtlMs <= 0 adopts without one).
|
||||||
*/
|
*/
|
||||||
adopt(id: string, opts: AdoptOptions): void {
|
adopt(id: string, opts: AdoptOptions, expected?: AgentRefExpectation): void {
|
||||||
if (id === MAIN_AGENT_ID) return;
|
if (id === MAIN_AGENT_ID) return;
|
||||||
if (!this.#registry.get(id)) {
|
const ref = this.#registry.get(id);
|
||||||
logger.warn("AgentLifecycleManager.adopt: unknown agent id", { id });
|
if (!ref || (expected !== undefined && ref !== expected && ref.session !== expected)) {
|
||||||
|
logger.warn("AgentLifecycleManager.adopt: unknown or replaced agent id", { id });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const existing = this.#adopted.get(id);
|
const existing = this.#adopted.get(id);
|
||||||
clearTimeout(existing?.timer);
|
clearTimeout(existing?.timer);
|
||||||
const adopted: AdoptedAgent = { idleTtlMs: opts.idleTtlMs, revive: opts.revive };
|
const adopted: AdoptedAgent = { ref, idleTtlMs: opts.idleTtlMs, revive: opts.revive };
|
||||||
this.#adopted.set(id, adopted);
|
this.#adopted.set(id, adopted);
|
||||||
this.#armTimer(id, adopted);
|
this.#armTimer(id, adopted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True if the id is adopted (parked or live). */
|
/** True if the id is adopted (parked or live). */
|
||||||
has(id: string): boolean {
|
has(id: string, expected?: AgentRefExpectation): boolean {
|
||||||
return this.#adopted.has(id);
|
const adopted = this.#adopted.get(id);
|
||||||
|
return Boolean(
|
||||||
|
adopted && (expected === undefined || adopted.ref === expected || adopted.ref.session === expected),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True while {@link park} is disposing this agent's session (lets dispose hooks distinguish park from teardown). */
|
/** True while {@link park} is disposing this agent's session (lets dispose hooks distinguish park from teardown). */
|
||||||
isParking(id: string): boolean {
|
isParking(id: string, expected?: AgentRefExpectation): boolean {
|
||||||
return this.#parking.has(id);
|
const ref = this.#parking.get(id);
|
||||||
|
return Boolean(ref && (expected === undefined || ref === expected || ref.session === expected));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,24 +142,24 @@ export class AgentLifecycleManager {
|
|||||||
*/
|
*/
|
||||||
async park(id: string): Promise<void> {
|
async park(id: string): Promise<void> {
|
||||||
const adopted = this.#adopted.get(id);
|
const adopted = this.#adopted.get(id);
|
||||||
if (!adopted) return;
|
|
||||||
const ref = this.#registry.get(id);
|
const ref = this.#registry.get(id);
|
||||||
if (!ref?.session) return;
|
if (!adopted || adopted.ref !== ref || !ref.session) return;
|
||||||
|
const liveSession = ref.session;
|
||||||
if (adopted.timer) {
|
if (adopted.timer) {
|
||||||
clearTimeout(adopted.timer);
|
clearTimeout(adopted.timer);
|
||||||
adopted.timer = undefined;
|
adopted.timer = undefined;
|
||||||
}
|
}
|
||||||
this.#parking.add(id);
|
this.#parking.set(id, ref);
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
await ref.session.dispose();
|
await liveSession.dispose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn("AgentLifecycleManager.park: session dispose failed", { id, error: String(error) });
|
logger.warn("AgentLifecycleManager.park: session dispose failed", { id, error: String(error) });
|
||||||
}
|
}
|
||||||
this.#registry.detachSession(id);
|
this.#registry.detachSession(id, ref);
|
||||||
this.#registry.setStatus(id, "parked");
|
this.#registry.setStatus(id, "parked", ref);
|
||||||
} finally {
|
} finally {
|
||||||
this.#parking.delete(id);
|
if (this.#parking.get(id) === ref) this.#parking.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,13 +177,14 @@ export class AgentLifecycleManager {
|
|||||||
}
|
}
|
||||||
if (ref.session) return ref.session;
|
if (ref.session) return ref.session;
|
||||||
const inflight = this.#revivals.get(id);
|
const inflight = this.#revivals.get(id);
|
||||||
if (inflight) return inflight;
|
if (inflight?.ref === ref) return inflight.promise;
|
||||||
const revival = this.#resolveAndRevive(id, ref);
|
const revival = this.#resolveAndRevive(id, ref);
|
||||||
this.#revivals.set(id, revival);
|
const pending: RevivingAgent = { ref, promise: revival };
|
||||||
|
this.#revivals.set(id, pending);
|
||||||
try {
|
try {
|
||||||
return await revival;
|
return await revival;
|
||||||
} finally {
|
} finally {
|
||||||
this.#revivals.delete(id);
|
if (this.#revivals.get(id) === pending) this.#revivals.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,63 +196,97 @@ export class AgentLifecycleManager {
|
|||||||
* when the agent is not revivable or no reviver can be produced.
|
* when the agent is not revivable or no reviver can be produced.
|
||||||
*/
|
*/
|
||||||
async #resolveAndRevive(id: string, ref: AgentRef): Promise<AgentSession> {
|
async #resolveAndRevive(id: string, ref: AgentRef): Promise<AgentSession> {
|
||||||
let revive = this.#adopted.get(id)?.revive;
|
let adoption = this.#adopted.get(id);
|
||||||
|
let revive = adoption?.ref === ref ? adoption.revive : undefined;
|
||||||
let coldAdopted = false;
|
let coldAdopted = false;
|
||||||
if (!revive && ref.status === "parked" && ref.sessionFile && this.#persistedReviverFactory) {
|
if (!revive && ref.status === "parked" && ref.sessionFile && this.#persistedReviverFactory) {
|
||||||
revive = await this.#persistedReviverFactory(ref);
|
revive = await this.#persistedReviverFactory(ref);
|
||||||
if (revive) {
|
if (revive) {
|
||||||
this.#adopted.set(id, { idleTtlMs: this.#persistedReviveTtlMs, revive });
|
adoption = { ref, idleTtlMs: this.#persistedReviveTtlMs, revive };
|
||||||
|
this.#adopted.set(id, adoption);
|
||||||
coldAdopted = true;
|
coldAdopted = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ref.status !== "parked" || !revive) {
|
if (this.#registry.get(id) !== ref) {
|
||||||
|
throw new Error(`Agent "${id}" changed while its persisted session was being prepared.`);
|
||||||
|
}
|
||||||
|
if (ref.status !== "parked" || !revive || !adoption) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Agent "${id}" is ${ref.status} and cannot be revived${revive ? "" : " (no reviver registered)"}. Its transcript remains readable at history://${id}.`,
|
`Agent "${id}" is ${ref.status} and cannot be revived${revive ? "" : " (no reviver registered)"}. Its transcript remains readable at history://${id}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await this.#revive(id, revive, ref.sessionFile);
|
return await this.#revive(id, revive, ref, adoption);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// A failed cold revive (stale ctx, missing cwd, bad MCP) must not leave a
|
// A failed cold revive (stale ctx, missing cwd, bad MCP) must not leave a
|
||||||
// poisoned reviver stuck in #adopted — drop it so a later ensureLive
|
// poisoned reviver stuck in #adopted — drop it so a later ensureLive
|
||||||
// rebuilds via the factory (which may have fresher context by then).
|
// rebuilds via the factory (which may have fresher context by then).
|
||||||
if (coldAdopted) this.#adopted.delete(id);
|
if (coldAdopted && this.#adopted.get(id) === adoption) this.#adopted.delete(id);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hard removal: dispose if live, unregister from registry, drop timers. */
|
/** Hard removal: dispose if live, unregister from registry, drop timers. */
|
||||||
async release(id: string): Promise<void> {
|
async release(id: string, expected?: AgentRefExpectation): Promise<boolean> {
|
||||||
const adopted = this.#adopted.get(id);
|
const adopted = this.#adopted.get(id);
|
||||||
clearTimeout(adopted?.timer);
|
const current = this.#registry.get(id);
|
||||||
this.#adopted.delete(id);
|
const currentMatches =
|
||||||
const ref = this.#registry.get(id);
|
current && (expected === undefined || current === expected || current.session === expected);
|
||||||
if (ref?.session) {
|
const adoptedMatches =
|
||||||
|
adopted && (expected === undefined || adopted.ref === expected || adopted.ref.session === expected);
|
||||||
|
const ref = currentMatches ? current : adoptedMatches ? adopted.ref : undefined;
|
||||||
|
if (!ref) return false;
|
||||||
|
if (adopted?.ref === ref) {
|
||||||
|
clearTimeout(adopted.timer);
|
||||||
|
this.#adopted.delete(id);
|
||||||
|
}
|
||||||
|
const liveSession = ref.session;
|
||||||
|
if (liveSession) {
|
||||||
try {
|
try {
|
||||||
await ref.session.dispose();
|
await liveSession.dispose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn("AgentLifecycleManager.release: session dispose failed", { id, error: String(error) });
|
logger.warn("AgentLifecycleManager.release: session dispose failed", { id, error: String(error) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.#registry.unregister(id);
|
this.#registry.unregister(id, ref);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Teardown everything (process exit / main session dispose). */
|
/** Teardown everything (process exit / main session dispose). */
|
||||||
async dispose(): Promise<void> {
|
async dispose(): Promise<void> {
|
||||||
this.#unsubscribe?.();
|
this.#unsubscribe?.();
|
||||||
this.#unsubscribe = undefined;
|
this.#unsubscribe = undefined;
|
||||||
const ids = [...this.#adopted.keys()];
|
const adopted = [...this.#adopted.values()];
|
||||||
await Promise.all(ids.map(id => this.release(id)));
|
await Promise.all(adopted.map(entry => this.release(entry.ref.id, entry.ref)));
|
||||||
this.#revivals.clear();
|
this.#revivals.clear();
|
||||||
this.#parking.clear();
|
this.#parking.clear();
|
||||||
this.#persistedReviverFactory = undefined;
|
this.#persistedReviverFactory = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #revive(id: string, revive: AgentReviver, sessionFile: string | null): Promise<AgentSession> {
|
async #revive(id: string, revive: AgentReviver, ref: AgentRef, adopted: AdoptedAgent): Promise<AgentSession> {
|
||||||
const session = await revive();
|
const session = await revive(ref);
|
||||||
this.#registry.attachSession(id, session, sessionFile);
|
let liveRef = this.#registry.get(id);
|
||||||
// Emits status_changed → "idle", which re-arms the TTL timer below.
|
if (liveRef === ref) {
|
||||||
this.#registry.setStatus(id, "idle");
|
if (!this.#registry.attachSession(id, session, ref.sessionFile, ref)) {
|
||||||
|
await session.dispose();
|
||||||
|
throw new Error(`Agent "${id}" changed before its persisted session could attach.`);
|
||||||
|
}
|
||||||
|
liveRef = ref;
|
||||||
|
} else if (
|
||||||
|
!liveRef ||
|
||||||
|
liveRef.session !== session ||
|
||||||
|
liveRef.kind !== ref.kind ||
|
||||||
|
liveRef.parentId !== ref.parentId ||
|
||||||
|
liveRef.sessionFile !== ref.sessionFile
|
||||||
|
) {
|
||||||
|
await session.dispose();
|
||||||
|
throw new Error(`Agent "${id}" was replaced while its persisted session was reviving.`);
|
||||||
|
}
|
||||||
|
adopted.ref = liveRef;
|
||||||
|
if (!this.#registry.setStatus(id, "idle", liveRef)) {
|
||||||
|
await session.dispose();
|
||||||
|
throw new Error(`Agent "${id}" changed before its persisted session became idle.`);
|
||||||
|
}
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +303,7 @@ export class AgentLifecycleManager {
|
|||||||
|
|
||||||
#onRegistryEvent(event: RegistryEvent): void {
|
#onRegistryEvent(event: RegistryEvent): void {
|
||||||
const adopted = this.#adopted.get(event.ref.id);
|
const adopted = this.#adopted.get(event.ref.id);
|
||||||
if (!adopted) return;
|
if (!adopted || adopted.ref !== event.ref) return;
|
||||||
if (event.type === "removed") {
|
if (event.type === "removed") {
|
||||||
clearTimeout(adopted.timer);
|
clearTimeout(adopted.timer);
|
||||||
this.#adopted.delete(event.ref.id);
|
this.#adopted.delete(event.ref.id);
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export interface AgentRef {
|
|||||||
activity?: string;
|
activity?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AgentRefExpectation = AgentRef | AgentSession;
|
||||||
|
|
||||||
export type RegistryEvent =
|
export type RegistryEvent =
|
||||||
| { type: "registered"; ref: AgentRef }
|
| { type: "registered"; ref: AgentRef }
|
||||||
| { type: "status_changed"; ref: AgentRef }
|
| { type: "status_changed"; ref: AgentRef }
|
||||||
@@ -80,6 +82,10 @@ export class AgentRegistry {
|
|||||||
readonly #refs = new Map<string, AgentRef>();
|
readonly #refs = new Map<string, AgentRef>();
|
||||||
readonly #listeners = new Set<RegistryListener>();
|
readonly #listeners = new Set<RegistryListener>();
|
||||||
|
|
||||||
|
#matchesExpected(ref: AgentRef, expected?: AgentRefExpectation): boolean {
|
||||||
|
return expected === undefined || ref === expected || ref.session === expected;
|
||||||
|
}
|
||||||
|
|
||||||
register(input: RegisterInput): AgentRef {
|
register(input: RegisterInput): AgentRef {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const ref: AgentRef = {
|
const ref: AgentRef = {
|
||||||
@@ -98,15 +104,28 @@ export class AgentRegistry {
|
|||||||
return ref;
|
return ref;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus(id: string, status: AgentStatus): void {
|
/**
|
||||||
|
* Register a new id only when it is absent, or reuse the exact ref a parked
|
||||||
|
* revival was authorized to revive. A missing expected ref is a failed CAS:
|
||||||
|
* callers must never claim an id after its prior generation disappeared.
|
||||||
|
*/
|
||||||
|
registerIfAvailable(input: RegisterInput, expected: AgentRef | null): AgentRef | undefined {
|
||||||
|
const current = this.#refs.get(input.id);
|
||||||
|
if (expected === null) return current ? undefined : this.register(input);
|
||||||
|
return current === expected ? current : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(id: string, status: AgentStatus, expected?: AgentRefExpectation): boolean {
|
||||||
const ref = this.#refs.get(id);
|
const ref = this.#refs.get(id);
|
||||||
if (!ref || ref.status === status) return;
|
if (!ref || !this.#matchesExpected(ref, expected)) return false;
|
||||||
|
if (ref.status === status) return true;
|
||||||
ref.status = status;
|
ref.status = status;
|
||||||
// Activity describes current work; it is meaningless once the agent
|
// Activity describes current work; it is meaningless once the agent
|
||||||
// leaves `running`, so drop it to avoid showing stale work in rosters.
|
// leaves `running`, so drop it to avoid showing stale work in rosters.
|
||||||
if (status !== "running") ref.activity = undefined;
|
if (status !== "running") ref.activity = undefined;
|
||||||
ref.lastActivity = Date.now();
|
ref.lastActivity = Date.now();
|
||||||
this.#emit({ type: "status_changed", ref });
|
this.#emit({ type: "status_changed", ref });
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,25 +152,33 @@ export class AgentRegistry {
|
|||||||
ref.activity = gist;
|
ref.activity = gist;
|
||||||
}
|
}
|
||||||
|
|
||||||
attachSession(id: string, session: AgentSession, sessionFile?: string | null): void {
|
attachSession(
|
||||||
|
id: string,
|
||||||
|
session: AgentSession,
|
||||||
|
sessionFile?: string | null,
|
||||||
|
expected?: AgentRefExpectation,
|
||||||
|
): boolean {
|
||||||
const ref = this.#refs.get(id);
|
const ref = this.#refs.get(id);
|
||||||
if (!ref) return;
|
if (!ref || !this.#matchesExpected(ref, expected)) return false;
|
||||||
ref.session = session;
|
ref.session = session;
|
||||||
if (sessionFile !== undefined) ref.sessionFile = sessionFile;
|
if (sessionFile !== undefined) ref.sessionFile = sessionFile;
|
||||||
ref.lastActivity = Date.now();
|
ref.lastActivity = Date.now();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
detachSession(id: string): void {
|
detachSession(id: string, expected?: AgentRefExpectation): boolean {
|
||||||
const ref = this.#refs.get(id);
|
const ref = this.#refs.get(id);
|
||||||
if (!ref) return;
|
if (!ref || !this.#matchesExpected(ref, expected)) return false;
|
||||||
ref.session = null;
|
ref.session = null;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
unregister(id: string): void {
|
unregister(id: string, expected?: AgentRefExpectation): boolean {
|
||||||
const ref = this.#refs.get(id);
|
const ref = this.#refs.get(id);
|
||||||
if (!ref) return;
|
if (!ref || !this.#matchesExpected(ref, expected)) return false;
|
||||||
this.#refs.delete(id);
|
this.#refs.delete(id);
|
||||||
this.#emit({ type: "removed", ref });
|
this.#emit({ type: "removed", ref });
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
get(id: string): AgentRef | undefined {
|
get(id: string): AgentRef | undefined {
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ import type { MnemopiSessionState } from "./mnemopi/state";
|
|||||||
import asyncResultTemplate from "./prompts/tools/async-result.md" with { type: "text" };
|
import asyncResultTemplate from "./prompts/tools/async-result.md" with { type: "text" };
|
||||||
import lateDiagnosticTemplate from "./prompts/tools/lsp-late-diagnostic.md" with { type: "text" };
|
import lateDiagnosticTemplate from "./prompts/tools/lsp-late-diagnostic.md" with { type: "text" };
|
||||||
import { AgentLifecycleManager } from "./registry/agent-lifecycle";
|
import { AgentLifecycleManager } from "./registry/agent-lifecycle";
|
||||||
import { AgentRegistry, MAIN_AGENT_ID } from "./registry/agent-registry";
|
import { type AgentRef, AgentRegistry, MAIN_AGENT_ID } from "./registry/agent-registry";
|
||||||
import {
|
import {
|
||||||
collectEnvSecrets,
|
collectEnvSecrets,
|
||||||
deobfuscateSessionContext,
|
deobfuscateSessionContext,
|
||||||
@@ -199,6 +199,7 @@ import { ttsTool } from "./tools/tts";
|
|||||||
import { resolveActiveRepoContext } from "./utils/active-repo-context";
|
import { resolveActiveRepoContext } from "./utils/active-repo-context";
|
||||||
import { EventBus } from "./utils/event-bus";
|
import { EventBus } from "./utils/event-bus";
|
||||||
import { buildNamedToolChoice } from "./utils/tool-choice";
|
import { buildNamedToolChoice } from "./utils/tool-choice";
|
||||||
|
import { VibeSessionRegistry } from "./vibe/runtime";
|
||||||
import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
|
import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
|
||||||
|
|
||||||
type AsyncResultEntry = {
|
type AsyncResultEntry = {
|
||||||
@@ -522,6 +523,13 @@ export interface CreateAgentSessionOptions {
|
|||||||
agentDisplayName?: string;
|
agentDisplayName?: string;
|
||||||
/** Optional shared agent registry for IRC routing. Default: AgentRegistry.global(). */
|
/** Optional shared agent registry for IRC routing. Default: AgentRegistry.global(). */
|
||||||
agentRegistry?: AgentRegistry;
|
agentRegistry?: AgentRegistry;
|
||||||
|
/**
|
||||||
|
* Registry generation authorized for this creation. `null` requires the id
|
||||||
|
* to be absent; an AgentRef allows a parked revival to reuse only that ref.
|
||||||
|
* Undefined preserves legacy unconditional registration for external SDK callers.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
expectedAgentRef?: AgentRef | null;
|
||||||
/** Parent task ID prefix for nested artifact naming (e.g., "Extensions") */
|
/** Parent task ID prefix for nested artifact naming (e.g., "Extensions") */
|
||||||
parentTaskPrefix?: string;
|
parentTaskPrefix?: string;
|
||||||
/**
|
/**
|
||||||
@@ -1525,15 +1533,18 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||||||
const resolvedAgentDisplayName =
|
const resolvedAgentDisplayName =
|
||||||
options.agentDisplayName ?? ((options.taskDepth ?? 0) > 0 || options.parentTaskPrefix ? "sub" : "main");
|
options.agentDisplayName ?? ((options.taskDepth ?? 0) > 0 || options.parentTaskPrefix ? "sub" : "main");
|
||||||
const agentKind = (options.taskDepth ?? 0) > 0 || options.parentTaskPrefix ? ("sub" as const) : ("main" as const);
|
const agentKind = (options.taskDepth ?? 0) > 0 || options.parentTaskPrefix ? ("sub" as const) : ("main" as const);
|
||||||
|
let registeredAgentRef: AgentRef | undefined;
|
||||||
/**
|
/**
|
||||||
* Forget the agent ref on teardown — unless the agent is being parked (or is
|
* Forget the agent ref on teardown — unless the agent is being parked (or is
|
||||||
* already parked). Parking disposes the session but keeps the ref addressable
|
* already parked). Parking disposes the session but keeps the ref addressable
|
||||||
* (history://, revive); only process teardown / explicit kill unregisters.
|
* (history://, revive); only process teardown / explicit kill unregisters.
|
||||||
*/
|
*/
|
||||||
const unregisterUnlessParked = (): void => {
|
const unregisterUnlessParked = (): void => {
|
||||||
if (agentRegistry.get(resolvedAgentId)?.status === "parked") return;
|
const ref = registeredAgentRef;
|
||||||
if (AgentLifecycleManager.global().isParking(resolvedAgentId)) return;
|
if (!ref || agentRegistry.get(resolvedAgentId) !== ref) return;
|
||||||
agentRegistry.unregister(resolvedAgentId);
|
if (ref.status === "parked") return;
|
||||||
|
if (AgentLifecycleManager.global().isParking(resolvedAgentId, ref)) return;
|
||||||
|
agentRegistry.unregister(resolvedAgentId, ref);
|
||||||
};
|
};
|
||||||
const evalKernelOwnerId = `agent-session:${Snowflake.next()}`;
|
const evalKernelOwnerId = `agent-session:${Snowflake.next()}`;
|
||||||
|
|
||||||
@@ -1577,6 +1588,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||||||
requireYieldTool: options.requireYieldTool,
|
requireYieldTool: options.requireYieldTool,
|
||||||
taskDepth: options.taskDepth ?? 0,
|
taskDepth: options.taskDepth ?? 0,
|
||||||
getSessionFile: () => sessionManager.getSessionFile() ?? null,
|
getSessionFile: () => sessionManager.getSessionFile() ?? null,
|
||||||
|
sessionManager,
|
||||||
getEvalKernelOwnerId: () => evalKernelOwnerId,
|
getEvalKernelOwnerId: () => evalKernelOwnerId,
|
||||||
getEvalSessionId: () =>
|
getEvalSessionId: () =>
|
||||||
session?.getEvalSessionId() ?? options.parentEvalSessionId ?? defaultEvalSessionId(toolSession),
|
session?.getEvalSessionId() ?? options.parentEvalSessionId ?? defaultEvalSessionId(toolSession),
|
||||||
@@ -2590,16 +2602,25 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||||||
// so that subagents launched in the same parallel batch can see each other in
|
// so that subagents launched in the same parallel batch can see each other in
|
||||||
// their initial `# IRC Peers` block (rendered inside `rebuildSystemPrompt`).
|
// their initial `# IRC Peers` block (rendered inside `rebuildSystemPrompt`).
|
||||||
// The session reference is attached after construction below.
|
// The session reference is attached after construction below.
|
||||||
agentRegistry.register({
|
const registrationInput = {
|
||||||
id: resolvedAgentId,
|
id: resolvedAgentId,
|
||||||
displayName: resolvedAgentDisplayName,
|
displayName: resolvedAgentDisplayName,
|
||||||
kind: agentKind,
|
kind: agentKind,
|
||||||
parentId: options.parentAgentId,
|
parentId: options.parentAgentId,
|
||||||
session: null,
|
session: null,
|
||||||
sessionFile: sessionManager.getSessionFile() ?? null,
|
sessionFile: sessionManager.getSessionFile() ?? null,
|
||||||
status: "running",
|
status: "running" as const,
|
||||||
});
|
};
|
||||||
hasRegistered = true;
|
registeredAgentRef =
|
||||||
|
options.expectedAgentRef === undefined
|
||||||
|
? agentRegistry.register(registrationInput)
|
||||||
|
: agentRegistry.registerIfAvailable(registrationInput, options.expectedAgentRef);
|
||||||
|
if (!registeredAgentRef) {
|
||||||
|
throw new Error(`Agent "${resolvedAgentId}" is already owned by another session generation.`);
|
||||||
|
}
|
||||||
|
// A reused parked ref remains parked until the new AgentSession is fully
|
||||||
|
// constructed and attached. Startup failure therefore leaves it revivable.
|
||||||
|
hasRegistered = options.expectedAgentRef === undefined || options.expectedAgentRef === null;
|
||||||
|
|
||||||
setActiveToolNames(initialToolNames);
|
setActiveToolNames(initialToolNames);
|
||||||
const { systemPrompt } = await logger.time(
|
const { systemPrompt } = await logger.time(
|
||||||
@@ -2949,7 +2970,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||||||
// Attach the live session to the pre-registered ref so peers can route IRC
|
// Attach the live session to the pre-registered ref so peers can route IRC
|
||||||
// messages here. Refresh sessionFile in case it was unavailable at pre-register
|
// messages here. Refresh sessionFile in case it was unavailable at pre-register
|
||||||
// time. The dispose wrapper below unregisters on teardown (unless parked).
|
// time. The dispose wrapper below unregisters on teardown (unless parked).
|
||||||
agentRegistry.attachSession(resolvedAgentId, session, sessionManager.getSessionFile() ?? null);
|
if (
|
||||||
|
!registeredAgentRef ||
|
||||||
|
!agentRegistry.attachSession(
|
||||||
|
resolvedAgentId,
|
||||||
|
session,
|
||||||
|
sessionManager.getSessionFile() ?? null,
|
||||||
|
registeredAgentRef,
|
||||||
|
) ||
|
||||||
|
!agentRegistry.setStatus(resolvedAgentId, "running", registeredAgentRef)
|
||||||
|
) {
|
||||||
|
throw new Error(`Agent "${resolvedAgentId}" was replaced during session initialization.`);
|
||||||
|
}
|
||||||
|
hasRegistered = true;
|
||||||
{
|
{
|
||||||
const originalDispose = session.dispose.bind(session);
|
const originalDispose = session.dispose.bind(session);
|
||||||
session.dispose = async () => {
|
session.dispose = async () => {
|
||||||
@@ -2963,6 +2996,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||||||
// adopted subagent sessions, revivers. Tear it down while shared
|
// adopted subagent sessions, revivers. Tear it down while shared
|
||||||
// resources (kernels, MCP, LSP) are still live. Subagent disposal
|
// resources (kernels, MCP, LSP) are still live. Subagent disposal
|
||||||
// must NOT touch the global lifecycle.
|
// must NOT touch the global lifecycle.
|
||||||
|
const vibeRegistry = VibeSessionRegistry.global();
|
||||||
|
const vibeParentSession = {
|
||||||
|
getAgentId: () => resolvedAgentId,
|
||||||
|
getSessionId: () => sessionManager.getSessionId(),
|
||||||
|
getSessionFile: () => sessionManager.getSessionFile() ?? null,
|
||||||
|
sessionManager,
|
||||||
|
asyncJobManager: scopedAsyncJobManager,
|
||||||
|
settings,
|
||||||
|
getActiveModelString,
|
||||||
|
};
|
||||||
|
await vibeRegistry.suspendScope(vibeRegistry.ownerScope(vibeParentSession), scopedAsyncJobManager);
|
||||||
await AgentLifecycleManager.global().dispose();
|
await AgentLifecycleManager.global().dispose();
|
||||||
}
|
}
|
||||||
await originalDispose();
|
await originalDispose();
|
||||||
@@ -3158,6 +3202,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||||||
try {
|
try {
|
||||||
if (hasSession) {
|
if (hasSession) {
|
||||||
await session.dispose();
|
await session.dispose();
|
||||||
|
if (hasRegistered) unregisterUnlessParked();
|
||||||
} else {
|
} else {
|
||||||
if (hasRegistered) unregisterUnlessParked();
|
if (hasRegistered) unregisterUnlessParked();
|
||||||
if (asyncJobManager) {
|
if (asyncJobManager) {
|
||||||
|
|||||||
@@ -3092,6 +3092,12 @@ export class AgentSession {
|
|||||||
this.#standingResolveHandler = handler ?? undefined;
|
this.#standingResolveHandler = handler ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#sessionBeforeSwitchReconciler: (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
|
setSessionBeforeSwitchReconciler(reconciler: (() => Promise<void>) | null): void {
|
||||||
|
this.#sessionBeforeSwitchReconciler = reconciler ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
#sessionSwitchReconciler: (() => Promise<void>) | undefined;
|
#sessionSwitchReconciler: (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
setSessionSwitchReconciler(reconciler: (() => Promise<void>) | null): void {
|
setSessionSwitchReconciler(reconciler: (() => Promise<void>) | null): void {
|
||||||
@@ -7161,6 +7167,12 @@ export class AgentSession {
|
|||||||
this.#vibeModeState = state;
|
this.#vibeModeState = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#assertVibeSessionTransitionAllowed(action: string): void {
|
||||||
|
if (this.#vibeModeState?.enabled) {
|
||||||
|
throw new Error(`Cannot ${action} while vibe mode is active. Exit vibe mode first.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get goalRuntime(): GoalRuntime {
|
get goalRuntime(): GoalRuntime {
|
||||||
return this.#goalRuntime;
|
return this.#goalRuntime;
|
||||||
}
|
}
|
||||||
@@ -8858,6 +8870,7 @@ export class AgentSession {
|
|||||||
* @returns true if completed, false if cancelled by hook
|
* @returns true if completed, false if cancelled by hook
|
||||||
*/
|
*/
|
||||||
async newSession(options?: NewSessionOptions): Promise<boolean> {
|
async newSession(options?: NewSessionOptions): Promise<boolean> {
|
||||||
|
this.#assertVibeSessionTransitionAllowed("start a new session");
|
||||||
const previousSessionFile = this.sessionFile;
|
const previousSessionFile = this.sessionFile;
|
||||||
const nextDiscoverySessionToolNames = this.#mcpDiscoveryEnabled
|
const nextDiscoverySessionToolNames = this.#mcpDiscoveryEnabled
|
||||||
? [
|
? [
|
||||||
@@ -8964,6 +8977,7 @@ export class AgentSession {
|
|||||||
* @returns true if completed, false if cancelled by hook or not persisting
|
* @returns true if completed, false if cancelled by hook or not persisting
|
||||||
*/
|
*/
|
||||||
async fork(): Promise<boolean> {
|
async fork(): Promise<boolean> {
|
||||||
|
this.#assertVibeSessionTransitionAllowed("fork the session");
|
||||||
const previousSessionFile = this.sessionFile;
|
const previousSessionFile = this.sessionFile;
|
||||||
|
|
||||||
// Emit session_before_switch event with reason "fork" (can be cancelled)
|
// Emit session_before_switch event with reason "fork" (can be cancelled)
|
||||||
@@ -9026,6 +9040,12 @@ export class AgentSession {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Move the active session and artifacts after enforcing mode transition invariants. */
|
||||||
|
async moveSession(newCwd: string, targetSessionDir?: string): Promise<void> {
|
||||||
|
this.#assertVibeSessionTransitionAllowed("move the session");
|
||||||
|
await this.sessionManager.moveTo(newCwd, targetSessionDir);
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Model Management
|
// Model Management
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -10221,6 +10241,7 @@ export class AgentSession {
|
|||||||
* @returns The handoff document text, or undefined if cancelled/failed
|
* @returns The handoff document text, or undefined if cancelled/failed
|
||||||
*/
|
*/
|
||||||
async handoff(customInstructions?: string, options?: SessionHandoffOptions): Promise<HandoffResult | undefined> {
|
async handoff(customInstructions?: string, options?: SessionHandoffOptions): Promise<HandoffResult | undefined> {
|
||||||
|
this.#assertVibeSessionTransitionAllowed("handoff to a new session");
|
||||||
const entries = this.sessionManager.getBranch();
|
const entries = this.sessionManager.getBranch();
|
||||||
const messageCount = entries.filter(e => e.type === "message").length;
|
const messageCount = entries.filter(e => e.type === "message").length;
|
||||||
|
|
||||||
@@ -15019,6 +15040,7 @@ export class AgentSession {
|
|||||||
|
|
||||||
this.#disconnectFromAgent();
|
this.#disconnectFromAgent();
|
||||||
await this.abort({ goalReason: "internal" });
|
await this.abort({ goalReason: "internal" });
|
||||||
|
await this.#sessionBeforeSwitchReconciler?.();
|
||||||
|
|
||||||
// Flush pending writes before switching so restore snapshots reflect committed state.
|
// Flush pending writes before switching so restore snapshots reflect committed state.
|
||||||
await this.sessionManager.flush();
|
await this.sessionManager.flush();
|
||||||
@@ -15244,6 +15266,14 @@ export class AgentSession {
|
|||||||
this.#syncTodoPhasesFromBranch();
|
this.#syncTodoPhasesFromBranch();
|
||||||
this.#resetAllAdvisorRuntimes();
|
this.#resetAllAdvisorRuntimes();
|
||||||
this.#reconnectToAgent();
|
this.#reconnectToAgent();
|
||||||
|
try {
|
||||||
|
await this.#sessionSwitchReconciler?.();
|
||||||
|
} catch (reconcileError) {
|
||||||
|
logger.warn("Failed to reconcile session mode after switch rollback", {
|
||||||
|
targetSessionFile: sessionPath,
|
||||||
|
error: String(reconcileError),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (restoreMcpError) {
|
if (restoreMcpError) {
|
||||||
throw restoreMcpError;
|
throw restoreMcpError;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -270,8 +270,15 @@ export class IndexedSessionStorage implements SessionStorage {
|
|||||||
{ trackDrain: false },
|
{ trackDrain: false },
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.#restoreIndex(path, previous);
|
const error = toError(err);
|
||||||
throw toError(err);
|
try {
|
||||||
|
if ((await this.#backend.readFull(path)) === content) return;
|
||||||
|
} catch {
|
||||||
|
// Preserve the original write failure; verification was unavailable.
|
||||||
|
}
|
||||||
|
const current = this.#index.get(path);
|
||||||
|
if (current?.mtimeMs === mtimeMs) this.#restoreIndex(path, previous);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { SessionTitleUpdate } from "./session-title-slot";
|
|||||||
* without dragging the entire Bun typings into this module.
|
* without dragging the entire Bun typings into this module.
|
||||||
*/
|
*/
|
||||||
export interface RedisSessionStorageClient {
|
export interface RedisSessionStorageClient {
|
||||||
|
send(command: string, args: string[]): Promise<unknown>;
|
||||||
get(key: string): Promise<string | null>;
|
get(key: string): Promise<string | null>;
|
||||||
getrange(key: string, start: number, end: number): Promise<string>;
|
getrange(key: string, start: number, end: number): Promise<string>;
|
||||||
strlen(key: string): Promise<number>;
|
strlen(key: string): Promise<number>;
|
||||||
@@ -45,6 +46,26 @@ export interface RedisSessionStorageOptions {
|
|||||||
const DEFAULT_PREFIX = "omp:sessions:";
|
const DEFAULT_PREFIX = "omp:sessions:";
|
||||||
const DEFAULT_SCAN_COUNT = 500;
|
const DEFAULT_SCAN_COUNT = 500;
|
||||||
|
|
||||||
|
const WRITE_FULL_SCRIPT = `-- OMP_WRITE_FULL
|
||||||
|
redis.call("SET", KEYS[1], ARGV[1])
|
||||||
|
redis.call("HSET", KEYS[2], ARGV[2], ARGV[3])
|
||||||
|
if ARGV[4] == "1" then
|
||||||
|
redis.call("HSET", KEYS[3], ARGV[2], ARGV[5])
|
||||||
|
else
|
||||||
|
redis.call("HDEL", KEYS[3], ARGV[2])
|
||||||
|
end
|
||||||
|
return 1`;
|
||||||
|
|
||||||
|
const APPEND_SCRIPT = `-- OMP_APPEND
|
||||||
|
local size = redis.call("APPEND", KEYS[1], ARGV[1])
|
||||||
|
redis.call("HSET", KEYS[2], ARGV[2], ARGV[3])
|
||||||
|
return size`;
|
||||||
|
|
||||||
|
const UPDATE_TITLE_SCRIPT = `-- OMP_UPDATE_TITLE
|
||||||
|
redis.call("HSET", KEYS[1], ARGV[1], ARGV[2])
|
||||||
|
redis.call("HSET", KEYS[2], ARGV[1], ARGV[3])
|
||||||
|
return 1`;
|
||||||
|
|
||||||
function encodeTitleMeta(title: SessionTitleUpdate): string {
|
function encodeTitleMeta(title: SessionTitleUpdate): string {
|
||||||
return JSON.stringify(title);
|
return JSON.stringify(title);
|
||||||
}
|
}
|
||||||
@@ -155,23 +176,42 @@ class RedisSessionStorageBackend implements SessionStorageBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async writeFull(path: string, content: string, mtimeMs: number, title?: SessionTitleUpdate): Promise<void> {
|
async writeFull(path: string, content: string, mtimeMs: number, title?: SessionTitleUpdate): Promise<void> {
|
||||||
await this.#client.set(this.#fileKey(path), content);
|
await this.#client.send("EVAL", [
|
||||||
await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
|
WRITE_FULL_SCRIPT,
|
||||||
if (title) {
|
"3",
|
||||||
await this.#client.hset(this.#titleMetaKey(), path, encodeTitleMeta(title));
|
this.#fileKey(path),
|
||||||
} else {
|
this.#metaKey(),
|
||||||
await this.#client.hdel(this.#titleMetaKey(), path);
|
this.#titleMetaKey(),
|
||||||
}
|
content,
|
||||||
|
path,
|
||||||
|
String(mtimeMs),
|
||||||
|
title ? "1" : "0",
|
||||||
|
title ? encodeTitleMeta(title) : "",
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async append(path: string, line: string, mtimeMs: number): Promise<void> {
|
async append(path: string, line: string, mtimeMs: number): Promise<void> {
|
||||||
await this.#client.append(this.#fileKey(path), line);
|
await this.#client.send("EVAL", [
|
||||||
await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
|
APPEND_SCRIPT,
|
||||||
|
"2",
|
||||||
|
this.#fileKey(path),
|
||||||
|
this.#metaKey(),
|
||||||
|
line,
|
||||||
|
path,
|
||||||
|
String(mtimeMs),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSessionTitle(path: string, title: SessionTitleUpdate, mtimeMs: number): Promise<void> {
|
async updateSessionTitle(path: string, title: SessionTitleUpdate, mtimeMs: number): Promise<void> {
|
||||||
await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
|
await this.#client.send("EVAL", [
|
||||||
await this.#client.hset(this.#titleMetaKey(), path, encodeTitleMeta(title));
|
UPDATE_TITLE_SCRIPT,
|
||||||
|
"2",
|
||||||
|
this.#metaKey(),
|
||||||
|
this.#titleMetaKey(),
|
||||||
|
path,
|
||||||
|
String(mtimeMs),
|
||||||
|
encodeTitleMeta(title),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async truncate(path: string, mtimeMs: number): Promise<void> {
|
async truncate(path: string, mtimeMs: number): Promise<void> {
|
||||||
|
|||||||
@@ -365,6 +365,34 @@ interface DiskQueueOptions {
|
|||||||
epoch?: number;
|
epoch?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AtomicEntryBatch {
|
||||||
|
collecting: boolean;
|
||||||
|
entryIds: Set<string>;
|
||||||
|
deferredNotifications: SessionEntry[];
|
||||||
|
preBatchLeafId: string | null;
|
||||||
|
externalLeafChanged: boolean;
|
||||||
|
externalLeafId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The storage may have published a write that rejected, and an authoritative
|
||||||
|
* repair could not be proven durable. Callers must fail closed until recovery.
|
||||||
|
*/
|
||||||
|
export class SessionPersistenceIndeterminateError extends AggregateError {
|
||||||
|
readonly operationError: Error;
|
||||||
|
readonly recoveryErrors: readonly Error[];
|
||||||
|
|
||||||
|
constructor(operationError: Error, recoveryErrors: readonly Error[]) {
|
||||||
|
super(
|
||||||
|
[operationError, ...recoveryErrors],
|
||||||
|
`Session persistence is indeterminate after "${operationError.message}" and authoritative repair failed.`,
|
||||||
|
);
|
||||||
|
this.name = "SessionPersistenceIndeterminateError";
|
||||||
|
this.operationError = operationError;
|
||||||
|
this.recoveryErrors = [...recoveryErrors];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores and navigates an append-only conversation journal.
|
* Stores and navigates an append-only conversation journal.
|
||||||
*
|
*
|
||||||
@@ -425,6 +453,10 @@ export class SessionManager {
|
|||||||
#diskTail: Promise<void> = Promise.resolve();
|
#diskTail: Promise<void> = Promise.resolve();
|
||||||
#diskFailure: Error | undefined;
|
#diskFailure: Error | undefined;
|
||||||
#diskFailureLogged = false;
|
#diskFailureLogged = false;
|
||||||
|
/** FIFO reservation for atomic batches and authoritative recovery. */
|
||||||
|
#atomicPersistenceTail: Promise<void> = Promise.resolve();
|
||||||
|
/** Observer notifications withheld until their entries are proven durable. */
|
||||||
|
#pendingDurabilityNotifications: SessionEntry[] = [];
|
||||||
/** Bumped on every sync rewrite / chain reset so stale queued tasks become no-ops. */
|
/** Bumped on every sync rewrite / chain reset so stale queued tasks become no-ops. */
|
||||||
#diskEpoch = 0;
|
#diskEpoch = 0;
|
||||||
/**
|
/**
|
||||||
@@ -438,6 +470,8 @@ export class SessionManager {
|
|||||||
#atomicRewriteFenceEpoch: number | null = null;
|
#atomicRewriteFenceEpoch: number | null = null;
|
||||||
/** Set by synchronous appends that land while an atomic replacement is active. */
|
/** Set by synchronous appends that land while an atomic replacement is active. */
|
||||||
#atomicRewriteDirty = false;
|
#atomicRewriteDirty = false;
|
||||||
|
/** Atomic entry batch currently staged for a full-file commit. */
|
||||||
|
#atomicEntryBatch: AtomicEntryBatch | undefined;
|
||||||
|
|
||||||
#artifactManager: ArtifactManager | null = null;
|
#artifactManager: ArtifactManager | null = null;
|
||||||
#artifactManagerSessionFile: string | null = null;
|
#artifactManagerSessionFile: string | null = null;
|
||||||
@@ -500,6 +534,18 @@ export class SessionManager {
|
|||||||
return reported;
|
return reported;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #withAtomicPersistenceLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
const predecessor = this.#atomicPersistenceTail;
|
||||||
|
const turn = Promise.withResolvers<void>();
|
||||||
|
this.#atomicPersistenceTail = predecessor.catch(() => undefined).then(() => turn.promise);
|
||||||
|
await predecessor.catch(() => undefined);
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
turn.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async #drainAndCloseWriter(): Promise<void> {
|
async #drainAndCloseWriter(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.#scheduleDiskWork(
|
await this.#scheduleDiskWork(
|
||||||
@@ -527,6 +573,117 @@ export class SessionManager {
|
|||||||
await writer.close();
|
await writer.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#latchIndeterminate(operationError: Error, recoveryErrors: readonly Error[]): SessionPersistenceIndeterminateError {
|
||||||
|
const error = new SessionPersistenceIndeterminateError(operationError, recoveryErrors);
|
||||||
|
this.#diskFailure = error;
|
||||||
|
if (!this.#diskFailureLogged) {
|
||||||
|
this.#diskFailureLogged = true;
|
||||||
|
logger.error("Session persistence became indeterminate.", {
|
||||||
|
sessionFile: this.#sessionFile,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notifyDurableEntries(entries: readonly SessionEntry[] = []): void {
|
||||||
|
const notifications = [...this.#pendingDurabilityNotifications, ...entries];
|
||||||
|
this.#pendingDurabilityNotifications = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const entry of notifications) {
|
||||||
|
if (seen.has(entry.id)) continue;
|
||||||
|
seen.add(entry.id);
|
||||||
|
this.#notifyEntryAppended(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #authoritativelyRewriteCurrentStateLocked(operationError: Error): Promise<void> {
|
||||||
|
if (!this.#persist || !this.#sessionFile) return;
|
||||||
|
const previousDiskTail = this.#diskTail;
|
||||||
|
const writer = this.#writer;
|
||||||
|
this.#diskEpoch++;
|
||||||
|
const epoch = this.#diskEpoch;
|
||||||
|
this.#writer = undefined;
|
||||||
|
this.#diskTail = Promise.resolve();
|
||||||
|
this.#forceFileCreation = true;
|
||||||
|
this.#fileIsCurrent = false;
|
||||||
|
this.#rewriteRequired = true;
|
||||||
|
this.#atomicRewriteFenceEpoch = epoch;
|
||||||
|
if (!this.#diskFailure) this.#diskFailure = operationError;
|
||||||
|
try {
|
||||||
|
await previousDiskTail.catch(() => undefined);
|
||||||
|
let closeError: Error | undefined;
|
||||||
|
if (writer) {
|
||||||
|
try {
|
||||||
|
await writer.close();
|
||||||
|
} catch (error) {
|
||||||
|
closeError = toError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let drainError: Error | undefined;
|
||||||
|
try {
|
||||||
|
await this.#storage.drain();
|
||||||
|
} catch (error) {
|
||||||
|
drainError = toError(error);
|
||||||
|
}
|
||||||
|
if (writer?.isOpen()) {
|
||||||
|
throw this.#latchIndeterminate(operationError, [
|
||||||
|
closeError ?? new Error("Failed to close session writer before authoritative repair."),
|
||||||
|
...(drainError ? [drainError] : []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
this.#atomicRewriteDirty = false;
|
||||||
|
const sessionFile = this.#sessionFile;
|
||||||
|
if (!sessionFile) {
|
||||||
|
throw this.#latchIndeterminate(operationError, [
|
||||||
|
new Error("Session file disappeared during authoritative repair."),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const body = this.#fileBody();
|
||||||
|
try {
|
||||||
|
await this.#storage.writeTextAtomic(sessionFile, body, {
|
||||||
|
commitGuard: () => this.#diskEpoch === epoch,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const recoveryErrors = [toError(error)];
|
||||||
|
try {
|
||||||
|
await this.#storage.drain();
|
||||||
|
} catch (drainFailure) {
|
||||||
|
recoveryErrors.push(toError(drainFailure));
|
||||||
|
}
|
||||||
|
let actual: string;
|
||||||
|
try {
|
||||||
|
actual = await this.#storage.readText(sessionFile);
|
||||||
|
} catch (readFailure) {
|
||||||
|
recoveryErrors.push(toError(readFailure));
|
||||||
|
throw this.#latchIndeterminate(operationError, recoveryErrors);
|
||||||
|
}
|
||||||
|
if (actual !== body) {
|
||||||
|
recoveryErrors.push(new Error("Authoritative session repair did not match durable storage."));
|
||||||
|
throw this.#latchIndeterminate(operationError, recoveryErrors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.#diskEpoch !== epoch) {
|
||||||
|
throw this.#latchIndeterminate(operationError, [
|
||||||
|
new Error("Authoritative session repair was superseded before verification."),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} while (this.#atomicRewriteDirty);
|
||||||
|
|
||||||
|
this.#fileIsCurrent = true;
|
||||||
|
this.#rewriteRequired = false;
|
||||||
|
this.#hasTitleSlot = true;
|
||||||
|
this.#clearDiskError();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SessionPersistenceIndeterminateError) throw error;
|
||||||
|
throw this.#latchIndeterminate(operationError, [toError(error)]);
|
||||||
|
} finally {
|
||||||
|
if (this.#atomicRewriteFenceEpoch === epoch) this.#atomicRewriteFenceEpoch = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#appendWriter(): SessionStorageWriter {
|
#appendWriter(): SessionStorageWriter {
|
||||||
if (!this.#sessionFile) throw new Error("Cannot open a session writer before a session file exists");
|
if (!this.#sessionFile) throw new Error("Cannot open a session writer before a session file exists");
|
||||||
|
|
||||||
@@ -671,6 +828,12 @@ export class SessionManager {
|
|||||||
|
|
||||||
#appendToSessionFile(entry: SessionEntry): void {
|
#appendToSessionFile(entry: SessionEntry): void {
|
||||||
if (!this.#persist || !this.#sessionFile) return;
|
if (!this.#persist || !this.#sessionFile) return;
|
||||||
|
if (this.#atomicEntryBatch) {
|
||||||
|
this.#fileIsCurrent = false;
|
||||||
|
this.#rewriteRequired = true;
|
||||||
|
this.#atomicRewriteDirty = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.#diskFailure) throw this.#diskFailure;
|
if (this.#diskFailure) throw this.#diskFailure;
|
||||||
|
|
||||||
// Lazy gate: a brand-new session is not written until it has an assistant
|
// Lazy gate: a brand-new session is not written until it has an assistant
|
||||||
@@ -834,11 +997,44 @@ export class SessionManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#setLeaf(id: string | null): void {
|
||||||
|
this.#index.setLeaf(id);
|
||||||
|
const batch = this.#atomicEntryBatch;
|
||||||
|
if (batch && !batch.collecting) {
|
||||||
|
batch.externalLeafChanged = true;
|
||||||
|
batch.externalLeafId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#recordEntry(entry: SessionEntry): void {
|
#recordEntry(entry: SessionEntry): void {
|
||||||
this.#entries.push(entry);
|
this.#entries.push(entry);
|
||||||
this.#index.insert(entry);
|
this.#index.insert(entry);
|
||||||
|
const batch = this.#atomicEntryBatch;
|
||||||
|
if (batch?.collecting) batch.entryIds.add(entry.id);
|
||||||
|
if (batch && !batch.collecting) {
|
||||||
|
batch.externalLeafChanged = true;
|
||||||
|
batch.externalLeafId = entry.id;
|
||||||
|
}
|
||||||
this.#appendToSessionFile(entry);
|
this.#appendToSessionFile(entry);
|
||||||
this.#notifyEntryAppended(entry);
|
if (batch) batch.deferredNotifications.push(entry);
|
||||||
|
else this.#notifyEntryAppended(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rollbackAtomicEntryBatch(batch: AtomicEntryBatch): void {
|
||||||
|
const retainedAncestor = (id: string | null): string | null => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (id && batch.entryIds.has(id) && !seen.has(id)) {
|
||||||
|
seen.add(id);
|
||||||
|
id = this.#index.get(id)?.parentId ?? null;
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
const retained = this.#entries.filter(entry => !batch.entryIds.has(entry.id));
|
||||||
|
for (const entry of retained) entry.parentId = retainedAncestor(entry.parentId);
|
||||||
|
const restoredLeaf = retainedAncestor(batch.externalLeafChanged ? batch.externalLeafId : batch.preBatchLeafId);
|
||||||
|
this.#entries = retained;
|
||||||
|
this.#index.rebuild(retained);
|
||||||
|
this.#index.setLeaf(restoredLeaf && this.#index.has(restoredLeaf) ? restoredLeaf : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
#draftPath(): string | null {
|
#draftPath(): string | null {
|
||||||
@@ -1181,6 +1377,92 @@ export class SessionManager {
|
|||||||
await this.#rewriteAtomically();
|
await this.#rewriteAtomically();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage a synchronous group of entry appends and publish the resulting full
|
||||||
|
* journal with one atomic replace. A failed publish removes only the staged
|
||||||
|
* entries, preserves/reparents entries appended concurrently, restores the
|
||||||
|
* prior durable file view, and clears the failed writer latch for retry.
|
||||||
|
*
|
||||||
|
* The callback MUST be synchronous.
|
||||||
|
*/
|
||||||
|
appendEntriesAtomically<T>(append: () => T): Promise<T> {
|
||||||
|
return this.#withAtomicPersistenceLock(() => this.#appendEntriesAtomicallyLocked(append));
|
||||||
|
}
|
||||||
|
|
||||||
|
async #appendEntriesAtomicallyLocked<T>(append: () => T): Promise<T> {
|
||||||
|
if (!this.#persist || !this.#sessionFile) return append();
|
||||||
|
if (this.#atomicEntryBatch) throw new Error("Atomic persistence lock ownership was violated.");
|
||||||
|
try {
|
||||||
|
await this.ensureOnDisk();
|
||||||
|
await this.flush();
|
||||||
|
} catch (error) {
|
||||||
|
const operationError = toError(error);
|
||||||
|
await this.#authoritativelyRewriteCurrentStateLocked(operationError);
|
||||||
|
this.#notifyDurableEntries();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch: AtomicEntryBatch = {
|
||||||
|
collecting: true,
|
||||||
|
entryIds: new Set(),
|
||||||
|
deferredNotifications: [],
|
||||||
|
preBatchLeafId: this.#index.leafId(),
|
||||||
|
externalLeafChanged: false,
|
||||||
|
externalLeafId: null,
|
||||||
|
};
|
||||||
|
this.#atomicEntryBatch = batch;
|
||||||
|
let result!: T;
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
result = append();
|
||||||
|
} finally {
|
||||||
|
batch.collecting = false;
|
||||||
|
}
|
||||||
|
await this.#rewriteAtomically();
|
||||||
|
if (!this.#fileIsCurrent || this.#rewriteRequired) {
|
||||||
|
throw new Error("Atomic session batch was superseded before commit.");
|
||||||
|
}
|
||||||
|
this.#atomicEntryBatch = undefined;
|
||||||
|
this.#notifyDurableEntries(batch.deferredNotifications);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
batch.collecting = false;
|
||||||
|
const operationError = toError(error);
|
||||||
|
this.#rollbackAtomicEntryBatch(batch);
|
||||||
|
try {
|
||||||
|
await this.#authoritativelyRewriteCurrentStateLocked(operationError);
|
||||||
|
} catch (repairError) {
|
||||||
|
const retainedNotifications = batch.deferredNotifications.filter(entry => !batch.entryIds.has(entry.id));
|
||||||
|
this.#pendingDurabilityNotifications.push(...retainedNotifications);
|
||||||
|
this.#atomicEntryBatch = undefined;
|
||||||
|
this.#fileIsCurrent = false;
|
||||||
|
this.#rewriteRequired = true;
|
||||||
|
if (repairError instanceof SessionPersistenceIndeterminateError) throw repairError;
|
||||||
|
throw this.#latchIndeterminate(operationError, [toError(repairError)]);
|
||||||
|
}
|
||||||
|
const retainedNotifications = batch.deferredNotifications.filter(entry => !batch.entryIds.has(entry.id));
|
||||||
|
this.#atomicEntryBatch = undefined;
|
||||||
|
this.#notifyDurableEntries(retainedNotifications);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace an uncertain append tail with the authoritative in-memory journal.
|
||||||
|
* Callers must only use this for monotonic recovery where every retained
|
||||||
|
* entry remains intended (for example, an explicit terminal tombstone).
|
||||||
|
*/
|
||||||
|
recoverPersistenceFromCurrentState(): Promise<void> {
|
||||||
|
return this.#withAtomicPersistenceLock(async () => {
|
||||||
|
if (!this.#persist || !this.#sessionFile) return;
|
||||||
|
if (this.#atomicEntryBatch) throw new Error("Atomic persistence lock ownership was violated.");
|
||||||
|
const operationError =
|
||||||
|
this.#diskFailure ?? new Error("Authoritative session persistence recovery was requested.");
|
||||||
|
await this.#authoritativelyRewriteCurrentStateLocked(operationError);
|
||||||
|
this.#notifyDurableEntries();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Flush pending writes. Call before switching sessions or on shutdown. */
|
/** Flush pending writes. Call before switching sessions or on shutdown. */
|
||||||
async flush(): Promise<void> {
|
async flush(): Promise<void> {
|
||||||
if (!this.#persist || !this.#sessionFile) return;
|
if (!this.#persist || !this.#sessionFile) return;
|
||||||
@@ -1201,6 +1483,7 @@ export class SessionManager {
|
|||||||
*/
|
*/
|
||||||
flushSync(): void {
|
flushSync(): void {
|
||||||
if (!this.#persist || !this.#sessionFile) return;
|
if (!this.#persist || !this.#sessionFile) return;
|
||||||
|
if (this.#atomicEntryBatch) throw new Error("Cannot synchronously flush during an atomic session batch.");
|
||||||
if (this.#diskFailure) throw this.#diskFailure;
|
if (this.#diskFailure) throw this.#diskFailure;
|
||||||
if (this.#fileIsCurrent && !this.#rewriteRequired) {
|
if (this.#fileIsCurrent && !this.#rewriteRequired) {
|
||||||
const writerError = this.#writer?.getError();
|
const writerError = this.#writer?.getError();
|
||||||
@@ -1726,19 +2009,19 @@ export class SessionManager {
|
|||||||
*/
|
*/
|
||||||
branch(branchFromId: string): void {
|
branch(branchFromId: string): void {
|
||||||
if (!this.#index.has(branchFromId)) throw new Error(`Entry ${branchFromId} not found`);
|
if (!this.#index.has(branchFromId)) throw new Error(`Entry ${branchFromId} not found`);
|
||||||
this.#index.setLeaf(branchFromId);
|
this.#setLeaf(branchFromId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset the leaf to null so the next append creates a new root entry. */
|
/** Reset the leaf to null so the next append creates a new root entry. */
|
||||||
resetLeaf(): void {
|
resetLeaf(): void {
|
||||||
this.#index.setLeaf(null);
|
this.#setLeaf(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Like branch(), but also records a branch_summary of the abandoned path. */
|
/** Like branch(), but also records a branch_summary of the abandoned path. */
|
||||||
branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string {
|
branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string {
|
||||||
if (branchFromId !== null && !this.#index.has(branchFromId)) throw new Error(`Entry ${branchFromId} not found`);
|
if (branchFromId !== null && !this.#index.has(branchFromId)) throw new Error(`Entry ${branchFromId} not found`);
|
||||||
|
|
||||||
this.#index.setLeaf(branchFromId);
|
this.#setLeaf(branchFromId);
|
||||||
const entry: BranchSummaryEntry = {
|
const entry: BranchSummaryEntry = {
|
||||||
type: "branch_summary",
|
type: "branch_summary",
|
||||||
id: generateId(this.#index),
|
id: generateId(this.#index),
|
||||||
|
|||||||
@@ -1675,7 +1675,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|||||||
return usage(`Directory does not exist: ${resolvedPath}`, runtime);
|
return usage(`Directory does not exist: ${resolvedPath}`, runtime);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await runtime.sessionManager.moveTo(resolvedPath);
|
await runtime.session.moveSession(resolvedPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return usage(`Move failed: ${errorMessage(err)}`, runtime);
|
return usage(`Move failed: ${errorMessage(err)}`, runtime);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import type { MCPManager } from "../mcp/manager";
|
|||||||
import type { MnemopiSessionState } from "../mnemopi/state";
|
import type { MnemopiSessionState } from "../mnemopi/state";
|
||||||
import subagentSystemPromptTemplate from "../prompts/system/subagent-system-prompt.md" with { type: "text" };
|
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 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 { AgentRegistry } from "../registry/agent-registry";
|
||||||
import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage } from "../sdk";
|
import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage } from "../sdk";
|
||||||
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
|
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
|
||||||
@@ -1945,9 +1945,11 @@ export async function finalizeSubagentLifecycle(args: {
|
|||||||
keepAlive: boolean;
|
keepAlive: boolean;
|
||||||
isolated: boolean;
|
isolated: boolean;
|
||||||
agentIdleTtlMs: number;
|
agentIdleTtlMs: number;
|
||||||
reviveSession: (() => Promise<AgentSession>) | null;
|
reviveSession: AgentReviver | null;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const registry = AgentRegistry.global();
|
const registry = AgentRegistry.global();
|
||||||
|
const ref = registry.get(args.id);
|
||||||
|
const ownsRef = Boolean(ref && ref.session === args.session);
|
||||||
const disposeSession = async (): Promise<void> => {
|
const disposeSession = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await untilAborted(AbortSignal.timeout(5000), () => args.session.dispose());
|
await untilAborted(AbortSignal.timeout(5000), () => args.session.dispose());
|
||||||
@@ -1962,7 +1964,7 @@ export async function finalizeSubagentLifecycle(args: {
|
|||||||
const resumableAbort =
|
const resumableAbort =
|
||||||
args.abortKind === "budget" && args.keepAlive && !args.isolated && args.reviveSession !== null;
|
args.abortKind === "budget" && args.keepAlive && !args.isolated && args.reviveSession !== null;
|
||||||
if (args.aborted && !resumableAbort) {
|
if (args.aborted && !resumableAbort) {
|
||||||
registry.setStatus(args.id, "aborted");
|
if (ref && ownsRef) registry.setStatus(args.id, "aborted", ref);
|
||||||
await disposeSession();
|
await disposeSession();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1970,7 +1972,7 @@ export async function finalizeSubagentLifecycle(args: {
|
|||||||
if (!args.keepAlive) {
|
if (!args.keepAlive) {
|
||||||
// One-shot helper: dispose and unregister. No IRC, no revival.
|
// One-shot helper: dispose and unregister. No IRC, no revival.
|
||||||
await disposeSession();
|
await disposeSession();
|
||||||
registry.unregister(args.id);
|
if (ref && ownsRef) registry.unregister(args.id, ref);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1980,19 +1982,26 @@ export async function finalizeSubagentLifecycle(args: {
|
|||||||
// transcript stays reachable (history://), but ensureLive will throw.
|
// transcript stays reachable (history://), but ensureLive will throw.
|
||||||
// Status must flip to "parked" before dispose so the sdk dispose
|
// Status must flip to "parked" before dispose so the sdk dispose
|
||||||
// wrapper skips unregister.
|
// wrapper skips unregister.
|
||||||
registry.setStatus(args.id, "parked");
|
if (ref && ownsRef) registry.setStatus(args.id, "parked", ref);
|
||||||
await disposeSession();
|
await disposeSession();
|
||||||
registry.detachSession(args.id);
|
if (ref && ownsRef) registry.detachSession(args.id, ref);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep-alive: finished and failed subagents both stay interrogable.
|
// Keep-alive: finished and failed subagents both stay interrogable.
|
||||||
// The lifecycle manager owns idle-TTL parking + revival from here on.
|
// The lifecycle manager owns idle-TTL parking + revival from here on.
|
||||||
registry.setStatus(args.id, "idle");
|
if (!ref || !ownsRef || !registry.setStatus(args.id, "idle", ref)) {
|
||||||
AgentLifecycleManager.global().adopt(args.id, {
|
await disposeSession();
|
||||||
idleTtlMs: args.agentIdleTtlMs,
|
return;
|
||||||
revive: args.reviveSession ?? undefined,
|
}
|
||||||
});
|
AgentLifecycleManager.global().adopt(
|
||||||
|
args.id,
|
||||||
|
{
|
||||||
|
idleTtlMs: args.agentIdleTtlMs,
|
||||||
|
revive: args.reviveSession ?? undefined,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Options for {@link runSubagentFollowUpTurn}. */
|
/** Options for {@link runSubagentFollowUpTurn}. */
|
||||||
@@ -2240,7 +2249,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|||||||
});
|
});
|
||||||
const progress = monitor.progress;
|
const progress = monitor.progress;
|
||||||
let unsubscribe: (() => void) | null = null;
|
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
|
// Adopted (kept-alive) subagents flip registry status from session events on
|
||||||
// later turns: revive/wake → running, turn drained → idle. The subscription
|
// later turns: revive/wake → running, turn drained → idle. The subscription
|
||||||
// intentionally survives this run; a disposed session emits nothing, so it
|
// 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 => {
|
const installRegistryStatusSync = (target: AgentSession): void => {
|
||||||
target.subscribe(event => {
|
target.subscribe(event => {
|
||||||
if (event.type === "agent_start") {
|
if (event.type === "agent_start") {
|
||||||
AgentRegistry.global().setStatus(id, "running");
|
AgentRegistry.global().setStatus(id, "running", target);
|
||||||
} else if (event.type === "agent_end") {
|
} 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
|
// the same JSONL file re-invokes createAgentSession with the exact options
|
||||||
// of the original run (same agent id, tools, model, system prompt,
|
// of the original run (same agent id, tools, model, system prompt,
|
||||||
// artifacts dir) — only the SessionManager differs.
|
// artifacts dir) — only the SessionManager differs.
|
||||||
const buildSubagentSessionOptions = (sessionManagerForRun: SessionManager): CreateAgentSessionOptions => ({
|
const buildSubagentSessionOptions = (
|
||||||
|
sessionManagerForRun: SessionManager,
|
||||||
|
expectedAgentRef: CreateAgentSessionOptions["expectedAgentRef"],
|
||||||
|
): CreateAgentSessionOptions => ({
|
||||||
cwd: worktree ?? cwd,
|
cwd: worktree ?? cwd,
|
||||||
authStorage,
|
authStorage,
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
@@ -2474,6 +2486,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|||||||
parentAgentId: options.parentAgentId,
|
parentAgentId: options.parentAgentId,
|
||||||
agentId: id,
|
agentId: id,
|
||||||
agentDisplayName: agent.name,
|
agentDisplayName: agent.name,
|
||||||
|
expectedAgentRef,
|
||||||
enableLsp: lspEnabled,
|
enableLsp: lspEnabled,
|
||||||
skipPythonPreflight,
|
skipPythonPreflight,
|
||||||
enableMCP,
|
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;
|
let session: AgentSession;
|
||||||
try {
|
try {
|
||||||
({ session } = await awaitAbortable(sessionPromise));
|
({ 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
|
// the single-writer lock cleanly and restores the full message history
|
||||||
// (createAgentSession → agent.replaceMessages). Isolated runs are not
|
// (createAgentSession → agent.replaceMessages). Isolated runs are not
|
||||||
// resumable (worktree is merged + cleaned) and never get a reviver.
|
// resumable (worktree is merged + cleaned) and never get a reviver.
|
||||||
reviveSession = async () => {
|
reviveSession = async expectedAgentRef => {
|
||||||
const reopened = await SessionManager.open(sessionFile, undefined, undefined, {
|
const reopened = await SessionManager.open(sessionFile, undefined, undefined, {
|
||||||
suppressBreadcrumb: true,
|
suppressBreadcrumb: true,
|
||||||
});
|
});
|
||||||
if (options.parentArtifactManager) {
|
if (options.parentArtifactManager) {
|
||||||
reopened.adoptArtifactManager(options.parentArtifactManager);
|
reopened.adoptArtifactManager(options.parentArtifactManager);
|
||||||
}
|
}
|
||||||
const { session: revived } = await createAgentSession(buildSubagentSessionOptions(reopened));
|
const { session: revived } = await createAgentSession(
|
||||||
|
buildSubagentSessionOptions(reopened, expectedAgentRef),
|
||||||
|
);
|
||||||
installRegistryStatusSync(revived);
|
installRegistryStatusSync(revived);
|
||||||
return revived;
|
return revived;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ import { ADVISOR_TRANSCRIPT_STEM } from "../advisor/transcript-recorder";
|
|||||||
*
|
*
|
||||||
* The first allocation of a given name keeps the name as-is; subsequent
|
* The first allocation of a given name keeps the name as-is; subsequent
|
||||||
* allocations of the same name get a `-2`, `-3`, … suffix. On resume, scans
|
* allocations of the same name get a `-2`, `-3`, … suffix. On resume, scans
|
||||||
* existing output files so previously written outputs are never overwritten.
|
* existing output and child-session files so prior state is never overwritten.
|
||||||
*/
|
*/
|
||||||
export class AgentOutputManager {
|
export class AgentOutputManager {
|
||||||
#initialized = false;
|
#initialized = false;
|
||||||
|
#initializing: Promise<void> | undefined;
|
||||||
/** Final ids already handed out, relative to this manager's scope. */
|
/** Final ids already handed out, relative to this manager's scope. */
|
||||||
readonly #taken = new Set<string>();
|
readonly #taken = new Set<string>();
|
||||||
readonly #getArtifactsDir: () => string | null;
|
readonly #getArtifactsDir: () => string | null;
|
||||||
@@ -42,8 +43,12 @@ export class AgentOutputManager {
|
|||||||
*/
|
*/
|
||||||
async #ensureInitialized(): Promise<void> {
|
async #ensureInitialized(): Promise<void> {
|
||||||
if (this.#initialized) return;
|
if (this.#initialized) return;
|
||||||
|
this.#initializing ??= this.#seedFromDisk();
|
||||||
|
await this.#initializing;
|
||||||
this.#initialized = true;
|
this.#initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #seedFromDisk(): Promise<void> {
|
||||||
const dir = this.#getArtifactsDir();
|
const dir = this.#getArtifactsDir();
|
||||||
if (!dir) return;
|
if (!dir) return;
|
||||||
|
|
||||||
@@ -56,8 +61,9 @@ export class AgentOutputManager {
|
|||||||
|
|
||||||
const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : "";
|
const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : "";
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (!file.endsWith(".md")) continue;
|
const extension = file.endsWith(".jsonl") ? ".jsonl" : file.endsWith(".md") ? ".md" : undefined;
|
||||||
let rest = file.slice(0, -3); // drop ".md"
|
if (!extension) continue;
|
||||||
|
let rest = file.slice(0, -extension.length);
|
||||||
if (prefix) {
|
if (prefix) {
|
||||||
if (!rest.startsWith(prefix)) continue;
|
if (!rest.startsWith(prefix)) continue;
|
||||||
rest = rest.slice(prefix.length);
|
rest = rest.slice(prefix.length);
|
||||||
@@ -80,6 +86,22 @@ export class AgentOutputManager {
|
|||||||
return this.#parentPrefix ? `${this.#parentPrefix}.${candidate}` : candidate;
|
return this.#parentPrefix ? `${this.#parentPrefix}.${candidate}` : candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reserve final IDs discovered outside the output directory scan. */
|
||||||
|
async reserve(ids: Iterable<string>): Promise<void> {
|
||||||
|
await this.#ensureInitialized();
|
||||||
|
const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : "";
|
||||||
|
for (const id of ids) {
|
||||||
|
let rest = id;
|
||||||
|
if (prefix) {
|
||||||
|
if (!rest.startsWith(prefix)) continue;
|
||||||
|
rest = rest.slice(prefix.length);
|
||||||
|
}
|
||||||
|
const dot = rest.indexOf(".");
|
||||||
|
const segment = dot === -1 ? rest : rest.slice(0, dot);
|
||||||
|
if (segment) this.#taken.add(segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allocate a unique ID.
|
* Allocate a unique ID.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function createPersistedSubagentReviverFactory(
|
|||||||
taskDepth++;
|
taskDepth++;
|
||||||
parentId = registry.get(parentId)?.parentId;
|
parentId = registry.get(parentId)?.parentId;
|
||||||
}
|
}
|
||||||
return async () => {
|
return async expectedRef => {
|
||||||
// Re-open fresh on every revive: park closes the writer, so this takes
|
// Re-open fresh on every revive: park closes the writer, so this takes
|
||||||
// the single-writer lock cleanly and restores the full message history.
|
// the single-writer lock cleanly and restores the full message history.
|
||||||
const reopened = await SessionManager.open(sessionFile, undefined, undefined, {
|
const reopened = await SessionManager.open(sessionFile, undefined, undefined, {
|
||||||
@@ -96,6 +96,7 @@ export function createPersistedSubagentReviverFactory(
|
|||||||
agentDisplayName: ref.displayName,
|
agentDisplayName: ref.displayName,
|
||||||
parentTaskPrefix: ref.id,
|
parentTaskPrefix: ref.id,
|
||||||
parentAgentId: ref.parentId,
|
parentAgentId: ref.parentId,
|
||||||
|
expectedAgentRef: expectedRef,
|
||||||
taskDepth,
|
taskDepth,
|
||||||
toolNames: init.tools,
|
toolNames: init.tools,
|
||||||
outputSchema: init.outputSchema,
|
outputSchema: init.outputSchema,
|
||||||
@@ -119,8 +120,8 @@ export function createPersistedSubagentReviverFactory(
|
|||||||
// Without it the idle-TTL timer never clears on a turn and the lifecycle
|
// Without it the idle-TTL timer never clears on a turn and the lifecycle
|
||||||
// could park the agent mid-run.
|
// could park the agent mid-run.
|
||||||
session.subscribe(event => {
|
session.subscribe(event => {
|
||||||
if (event.type === "agent_start") registry.setStatus(ref.id, "running");
|
if (event.type === "agent_start") registry.setStatus(ref.id, "running", session);
|
||||||
else if (event.type === "agent_end") registry.setStatus(ref.id, "idle");
|
else if (event.type === "agent_end") registry.setStatus(ref.id, "idle", session);
|
||||||
});
|
});
|
||||||
return session;
|
return session;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type { ArtifactManager } from "../session/artifacts";
|
|||||||
import type { ClientBridge } from "../session/client-bridge";
|
import type { ClientBridge } from "../session/client-bridge";
|
||||||
import type { CustomMessage } from "../session/messages";
|
import type { CustomMessage } from "../session/messages";
|
||||||
import type { UsageStatistics } from "../session/session-entries";
|
import type { UsageStatistics } from "../session/session-entries";
|
||||||
|
import type { SessionManager } from "../session/session-manager";
|
||||||
import type { ToolChoiceQueue } from "../session/tool-choice-queue";
|
import type { ToolChoiceQueue } from "../session/tool-choice-queue";
|
||||||
import { TaskTool } from "../task";
|
import { TaskTool } from "../task";
|
||||||
import type { AgentOutputManager } from "../task/output-manager";
|
import type { AgentOutputManager } from "../task/output-manager";
|
||||||
@@ -209,6 +210,8 @@ export interface ToolSession {
|
|||||||
getEvalSessionId?: () => string | null;
|
getEvalSessionId?: () => string | null;
|
||||||
/** Get session file */
|
/** Get session file */
|
||||||
getSessionFile: () => string | null;
|
getSessionFile: () => string | null;
|
||||||
|
/** Parent session journal used by tools that persist runtime lifecycle state. */
|
||||||
|
sessionManager?: Pick<SessionManager, "appendCustomEntry" | "ensureOnDisk" | "flush" | "getBranch" | "getEntries">;
|
||||||
/** Get eval kernel owner ID for session-scoped retained-kernel cleanup. */
|
/** Get eval kernel owner ID for session-scoped retained-kernel cleanup. */
|
||||||
getEvalKernelOwnerId?: () => string | null;
|
getEvalKernelOwnerId?: () => string | null;
|
||||||
/** Reject new eval work once session disposal has started. */
|
/** Reject new eval work once session disposal has started. */
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import vibeListDescription from "../prompts/tools/vibe-list.md" with { type: "te
|
|||||||
import vibeSendDescription from "../prompts/tools/vibe-send.md" with { type: "text" };
|
import vibeSendDescription from "../prompts/tools/vibe-send.md" with { type: "text" };
|
||||||
import vibeSpawnDescription from "../prompts/tools/vibe-spawn.md" with { type: "text" };
|
import vibeSpawnDescription from "../prompts/tools/vibe-spawn.md" with { type: "text" };
|
||||||
import vibeWaitDescription from "../prompts/tools/vibe-wait.md" with { type: "text" };
|
import vibeWaitDescription from "../prompts/tools/vibe-wait.md" with { type: "text" };
|
||||||
import { MAIN_AGENT_ID } from "../registry/agent-registry";
|
|
||||||
import { oneLineLabel } from "../task/types";
|
import { oneLineLabel } from "../task/types";
|
||||||
import { renderStatusLine } from "../tui";
|
import { renderStatusLine } from "../tui";
|
||||||
import {
|
import {
|
||||||
@@ -93,7 +92,7 @@ export interface VibeToolDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function screensOf(session: ToolSession, ids?: string[]): VibeScreenSnapshot[] {
|
function screensOf(session: ToolSession, ids?: string[]): VibeScreenSnapshot[] {
|
||||||
return VibeSessionRegistry.global().screens(session.getAgentId?.() ?? MAIN_AGENT_ID, ids);
|
return VibeSessionRegistry.global().screens(session, ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
function textResult(text: string, details: VibeToolDetails): AgentToolResult<VibeToolDetails> {
|
function textResult(text: string, details: VibeToolDetails): AgentToolResult<VibeToolDetails> {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@
|
|||||||
* focus failure keeps the hub open and surfaces the error as a notice.
|
* focus failure keeps the hub open and surfaces the error as a notice.
|
||||||
*/
|
*/
|
||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||||
|
import * as fs from "node:fs/promises";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||||
import { IrcBus } from "@oh-my-pi/pi-coding-agent/irc/bus";
|
import { IrcBus } from "@oh-my-pi/pi-coding-agent/irc/bus";
|
||||||
@@ -14,6 +15,7 @@ import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|||||||
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
|
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
|
||||||
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
|
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
|
||||||
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||||
|
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||||
|
|
||||||
const AGENT_ID = "Worker";
|
const AGENT_ID = "Worker";
|
||||||
@@ -115,6 +117,66 @@ describe("Agent hub Enter activation", () => {
|
|||||||
hub.dispose();
|
hub.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not generically revive active or tombstoned Vibe children copied by a post-exit fork", async () => {
|
||||||
|
using tempDir = TempDir.createSync("@omp-agent-hub-vibe-fork-");
|
||||||
|
const manager = SessionManager.create(tempDir.path(), tempDir.path());
|
||||||
|
manager.appendModeChange("vibe");
|
||||||
|
const parentSessionId = manager.getSessionId();
|
||||||
|
for (const id of ["ActiveVibe", "KilledVibe"]) {
|
||||||
|
manager.appendCustomEntry("vibe-session-lifecycle", {
|
||||||
|
version: 1,
|
||||||
|
action: "spawn",
|
||||||
|
id,
|
||||||
|
ownerId: "Main",
|
||||||
|
parentSessionId,
|
||||||
|
cli: "fast",
|
||||||
|
agent: "sonic",
|
||||||
|
childSessionFile: `${id}.jsonl`,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
manager.appendCustomEntry("vibe-session-lifecycle", {
|
||||||
|
version: 1,
|
||||||
|
action: "tombstone",
|
||||||
|
id: "KilledVibe",
|
||||||
|
ownerId: "Main",
|
||||||
|
parentSessionId,
|
||||||
|
reason: "mode-exit",
|
||||||
|
});
|
||||||
|
manager.appendModeChange("none");
|
||||||
|
await manager.ensureOnDisk();
|
||||||
|
await manager.flush();
|
||||||
|
const sourceSessionFile = manager.getSessionFile();
|
||||||
|
if (!sourceSessionFile) throw new Error("Expected source session file");
|
||||||
|
const sourceArtifacts = sourceSessionFile.slice(0, -6);
|
||||||
|
await fs.mkdir(sourceArtifacts, { recursive: true });
|
||||||
|
for (const id of ["ActiveVibe", "KilledVibe", "OrdinaryTask"]) {
|
||||||
|
await fs.writeFile(path.join(sourceArtifacts, `${id}.jsonl`), "persisted child");
|
||||||
|
}
|
||||||
|
const fork = await manager.fork();
|
||||||
|
if (!fork) throw new Error("Expected persisted fork");
|
||||||
|
await fs.cp(sourceArtifacts, fork.newSessionFile.slice(0, -6), { recursive: true });
|
||||||
|
await manager.close();
|
||||||
|
|
||||||
|
const agents = new AgentRegistry();
|
||||||
|
const hub = new AgentHubOverlayComponent({
|
||||||
|
observers: new SessionObserverRegistry(),
|
||||||
|
hubKeys: [],
|
||||||
|
onDone: () => {},
|
||||||
|
requestRender: () => {},
|
||||||
|
registry: agents,
|
||||||
|
irc: new IrcBus(agents),
|
||||||
|
focusAgent: async () => {},
|
||||||
|
sessionFile: fork.newSessionFile,
|
||||||
|
});
|
||||||
|
await hub.persistedSubagentsReady;
|
||||||
|
|
||||||
|
expect(agents.get("ActiveVibe")).toBeUndefined();
|
||||||
|
expect(agents.get("KilledVibe")).toBeUndefined();
|
||||||
|
expect(agents.get("OrdinaryTask")?.status).toBe("parked");
|
||||||
|
hub.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("selector controller restores focus to the editor after Enter focuses an agent", async () => {
|
it("selector controller restores focus to the editor after Enter focuses an agent", async () => {
|
||||||
const agents = new AgentRegistry();
|
const agents = new AgentRegistry();
|
||||||
agents.register({
|
agents.register({
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|||||||
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||||
|
import { FileSessionStorage, type WriteTextAtomicOptions } from "@oh-my-pi/pi-coding-agent/session/session-storage";
|
||||||
import { VIBE_TOOL_NAMES } from "@oh-my-pi/pi-coding-agent/tools/vibe";
|
import { VIBE_TOOL_NAMES } from "@oh-my-pi/pi-coding-agent/tools/vibe";
|
||||||
import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus";
|
import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus";
|
||||||
|
import { VibeSessionRegistry } from "@oh-my-pi/pi-coding-agent/vibe/runtime";
|
||||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
|
|
||||||
@@ -34,11 +36,56 @@ function stubTool(name: string): AgentTool {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function vibeModeEntryCount(manager: SessionManager): number {
|
||||||
|
return manager.getEntries().filter(entry => entry.type === "mode_change" && entry.mode === "vibe").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExitFaultStorage extends FileSessionStorage {
|
||||||
|
failNextAtomicWrite = false;
|
||||||
|
#readGate:
|
||||||
|
| {
|
||||||
|
filePath: string;
|
||||||
|
started: ReturnType<typeof Promise.withResolvers<void>>;
|
||||||
|
release: ReturnType<typeof Promise.withResolvers<void>>;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
gateNextRead(filePath: string): { started: Promise<void>; release: () => void } {
|
||||||
|
const started = Promise.withResolvers<void>();
|
||||||
|
const release = Promise.withResolvers<void>();
|
||||||
|
this.#readGate = { filePath, started, release };
|
||||||
|
return { started: started.promise, release: release.resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
override async readTextSlices(
|
||||||
|
filePath: string,
|
||||||
|
prefixBytes: number,
|
||||||
|
suffixBytes: number,
|
||||||
|
): Promise<[string, string]> {
|
||||||
|
const gate = this.#readGate;
|
||||||
|
if (gate?.filePath === filePath) {
|
||||||
|
this.#readGate = undefined;
|
||||||
|
gate.started.resolve();
|
||||||
|
await gate.release.promise;
|
||||||
|
}
|
||||||
|
return super.readTextSlices(filePath, prefixBytes, suffixBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async writeTextAtomic(filePath: string, content: string, options?: WriteTextAtomicOptions): Promise<void> {
|
||||||
|
if (this.failNextAtomicWrite) {
|
||||||
|
this.failNextAtomicWrite = false;
|
||||||
|
throw Object.assign(new Error("journal atomic publish failed"), { code: "ENOSPC" });
|
||||||
|
}
|
||||||
|
await super.writeTextAtomic(filePath, content, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("InteractiveMode vibe mode toggle", () => {
|
describe("InteractiveMode vibe mode toggle", () => {
|
||||||
let tempDir: TempDir;
|
let tempDir: TempDir;
|
||||||
let authStorage: AuthStorage;
|
let authStorage: AuthStorage;
|
||||||
let session: AgentSession;
|
let session: AgentSession;
|
||||||
let mode: InteractiveMode;
|
let mode: InteractiveMode;
|
||||||
|
let storage: ExitFaultStorage;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await initTheme();
|
await initTheme();
|
||||||
@@ -46,6 +93,7 @@ describe("InteractiveMode vibe mode toggle", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
resetSettingsForTest();
|
resetSettingsForTest();
|
||||||
|
VibeSessionRegistry.resetGlobalForTests();
|
||||||
tempDir = TempDir.createSync("@pi-vibe-toggle-");
|
tempDir = TempDir.createSync("@pi-vibe-toggle-");
|
||||||
await Settings.init({ inMemory: true, cwd: tempDir.path() });
|
await Settings.init({ inMemory: true, cwd: tempDir.path() });
|
||||||
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
|
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
|
||||||
@@ -55,6 +103,7 @@ describe("InteractiveMode vibe mode toggle", () => {
|
|||||||
|
|
||||||
const registryTools = [stubTool("read")];
|
const registryTools = [stubTool("read")];
|
||||||
|
|
||||||
|
storage = new ExitFaultStorage();
|
||||||
session = new AgentSession({
|
session = new AgentSession({
|
||||||
agent: new Agent({
|
agent: new Agent({
|
||||||
initialState: {
|
initialState: {
|
||||||
@@ -64,7 +113,7 @@ describe("InteractiveMode vibe mode toggle", () => {
|
|||||||
messages: [],
|
messages: [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
sessionManager: SessionManager.create(tempDir.path(), tempDir.path()),
|
sessionManager: SessionManager.create(tempDir.path(), tempDir.path(), storage),
|
||||||
settings: Settings.isolated({}),
|
settings: Settings.isolated({}),
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
toolRegistry: new Map(registryTools.map(tool => [tool.name, tool])),
|
toolRegistry: new Map(registryTools.map(tool => [tool.name, tool])),
|
||||||
@@ -76,6 +125,7 @@ describe("InteractiveMode vibe mode toggle", () => {
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
mode?.stop();
|
mode?.stop();
|
||||||
await session?.dispose();
|
await session?.dispose();
|
||||||
|
VibeSessionRegistry.resetGlobalForTests();
|
||||||
authStorage?.close();
|
authStorage?.close();
|
||||||
tempDir?.removeSync();
|
tempDir?.removeSync();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -103,4 +153,89 @@ describe("InteractiveMode vibe mode toggle", () => {
|
|||||||
expect(session.getActiveToolNames()).toEqual([]);
|
expect(session.getActiveToolNames()).toEqual([]);
|
||||||
expect(session.getAllToolNames()).toEqual(["read"]);
|
expect(session.getAllToolNames()).toEqual(["read"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves workers and mode metadata on a same-session reload", async () => {
|
||||||
|
await mode.init({ suppressWelcomeIntro: true });
|
||||||
|
await mode.handleVibeModeCommand();
|
||||||
|
await session.sessionManager.ensureOnDisk();
|
||||||
|
const sessionFile = session.sessionFile;
|
||||||
|
if (!sessionFile) throw new Error("Expected persisted session file");
|
||||||
|
const registry = VibeSessionRegistry.global();
|
||||||
|
const suspend = vi.spyOn(registry, "suspendScope");
|
||||||
|
const terminate = vi.spyOn(registry, "killAll");
|
||||||
|
|
||||||
|
const readGate = storage.gateNextRead(sessionFile);
|
||||||
|
const switching = session.switchSession(sessionFile);
|
||||||
|
await readGate.started;
|
||||||
|
const suspendCallsBeforeRead = suspend.mock.calls.length;
|
||||||
|
readGate.release();
|
||||||
|
expect(suspendCallsBeforeRead).toBe(1);
|
||||||
|
expect(await switching).toBe(true);
|
||||||
|
|
||||||
|
expect(mode.vibeModeEnabled).toBe(true);
|
||||||
|
expect(suspend).toHaveBeenCalledTimes(1);
|
||||||
|
expect(terminate).not.toHaveBeenCalled();
|
||||||
|
expect(vibeModeEntryCount(session.sessionManager)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suspends the old scope without tombstones when switching to another vibe parent", async () => {
|
||||||
|
await mode.init({ suppressWelcomeIntro: true });
|
||||||
|
await mode.handleVibeModeCommand();
|
||||||
|
await session.sessionManager.ensureOnDisk();
|
||||||
|
const originalSessionId = session.sessionManager.getSessionId();
|
||||||
|
const targetManager = SessionManager.create(tempDir.path(), tempDir.path());
|
||||||
|
targetManager.appendModeChange("vibe");
|
||||||
|
await targetManager.ensureOnDisk();
|
||||||
|
const targetFile = targetManager.getSessionFile();
|
||||||
|
if (!targetFile) throw new Error("Expected target session file");
|
||||||
|
await targetManager.close();
|
||||||
|
const registry = VibeSessionRegistry.global();
|
||||||
|
const suspend = vi.spyOn(registry, "suspendScope");
|
||||||
|
const terminate = vi.spyOn(registry, "killAll");
|
||||||
|
|
||||||
|
expect(await session.switchSession(targetFile)).toBe(true);
|
||||||
|
|
||||||
|
expect(mode.vibeModeEnabled).toBe(true);
|
||||||
|
expect(suspend).toHaveBeenCalledTimes(1);
|
||||||
|
expect(suspend.mock.calls[0]?.[0]).toMatchObject({ parentSessionId: originalSessionId });
|
||||||
|
expect(terminate).not.toHaveBeenCalled();
|
||||||
|
expect(vibeModeEntryCount(session.sessionManager)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects new, drop, fork, and move transitions at the AgentSession boundary while vibe is active", async () => {
|
||||||
|
await mode.init({ suppressWelcomeIntro: true });
|
||||||
|
await mode.handleVibeModeCommand();
|
||||||
|
await session.sessionManager.ensureOnDisk();
|
||||||
|
const sessionFile = session.sessionFile;
|
||||||
|
if (!sessionFile) throw new Error("Expected persisted session file");
|
||||||
|
|
||||||
|
await expect(session.newSession()).rejects.toThrow("Exit vibe mode first");
|
||||||
|
await expect(session.newSession({ drop: true })).rejects.toThrow("Exit vibe mode first");
|
||||||
|
await expect(session.fork()).rejects.toThrow("Exit vibe mode first");
|
||||||
|
await expect(session.moveSession(path.join(tempDir.path(), "other-project"))).rejects.toThrow(
|
||||||
|
"Exit vibe mode first",
|
||||||
|
);
|
||||||
|
expect(session.sessionFile).toBe(sessionFile);
|
||||||
|
expect(session.sessionManager.getCwd()).toBe(tempDir.path());
|
||||||
|
expect(mode.vibeModeEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps vibe mode and tools active after a real storage failure, then allows a retry", async () => {
|
||||||
|
await mode.init({ suppressWelcomeIntro: true });
|
||||||
|
await mode.handleVibeModeCommand();
|
||||||
|
const activeTools = session.getActiveToolNames();
|
||||||
|
storage.failNextAtomicWrite = true;
|
||||||
|
|
||||||
|
const exitError = await mode.handleVibeModeCommand().catch(error => error);
|
||||||
|
expect(exitError).toBeInstanceOf(Error);
|
||||||
|
expect(String(exitError)).toContain("journal atomic publish failed");
|
||||||
|
|
||||||
|
expect(mode.vibeModeEnabled).toBe(true);
|
||||||
|
expect(session.getVibeModeState()).toEqual({ enabled: true });
|
||||||
|
expect(session.getActiveToolNames()).toEqual(activeTools);
|
||||||
|
expect(vibeModeEntryCount(session.sessionManager)).toBe(1);
|
||||||
|
|
||||||
|
await mode.handleVibeModeCommand();
|
||||||
|
expect(mode.vibeModeEnabled).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,6 +56,27 @@ describe("AgentLifecycleManager", () => {
|
|||||||
return registry.register({ id, displayName: "task", kind: "sub", session, sessionFile, status: "idle" });
|
return registry.register({ id, displayName: "task", kind: "sub", session, sessionFile, status: "idle" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it("registerIfAvailable never replaces a collision and reuses only the exact expected ref", () => {
|
||||||
|
const parked = registerIdleSub("generation-Sub", null);
|
||||||
|
registry.setStatus("generation-Sub", "parked", parked);
|
||||||
|
const next = {
|
||||||
|
id: "generation-Sub",
|
||||||
|
displayName: "replacement",
|
||||||
|
kind: "sub" as const,
|
||||||
|
session: null,
|
||||||
|
status: "running" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(registry.registerIfAvailable(next, null)).toBeUndefined();
|
||||||
|
expect(registry.get("generation-Sub")).toBe(parked);
|
||||||
|
expect(registry.registerIfAvailable(next, parked)).toBe(parked);
|
||||||
|
expect(registry.get("generation-Sub")).toBe(parked);
|
||||||
|
|
||||||
|
registry.unregister("generation-Sub", parked);
|
||||||
|
expect(registry.registerIfAvailable(next, parked)).toBeUndefined();
|
||||||
|
expect(registry.get("generation-Sub")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("adopt arms the TTL: an idle agent is parked — session disposed, ref + sessionFile retained", async () => {
|
it("adopt arms the TTL: an idle agent is parked — session disposed, ref + sessionFile retained", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const stub = makeSessionStub();
|
const stub = makeSessionStub();
|
||||||
@@ -250,6 +271,32 @@ describe("AgentLifecycleManager", () => {
|
|||||||
expect(registry.get("6-Sub")).toBeUndefined();
|
expect(registry.get("6-Sub")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a delayed release cannot remove or mutate a replacement ref with the same id", async () => {
|
||||||
|
const gate = deferred();
|
||||||
|
const oldSession = makeSessionStub(() => gate.promise);
|
||||||
|
const oldRef = registerIdleSub("cas-Sub", oldSession.session);
|
||||||
|
lifecycle.adopt("cas-Sub", { idleTtlMs: 0 }, oldRef);
|
||||||
|
const releasing = lifecycle.release("cas-Sub", oldRef);
|
||||||
|
await flushAsync();
|
||||||
|
expect(oldSession.disposeCalls()).toBe(1);
|
||||||
|
|
||||||
|
const replacementSession = makeSessionStub();
|
||||||
|
const replacement = registerIdleSub("cas-Sub", replacementSession.session, "/tmp/replacement.jsonl");
|
||||||
|
lifecycle.adopt("cas-Sub", { idleTtlMs: 0 }, replacement);
|
||||||
|
expect(registry.setStatus("cas-Sub", "aborted", oldRef)).toBe(false);
|
||||||
|
expect(registry.detachSession("cas-Sub", oldRef)).toBe(false);
|
||||||
|
expect(registry.unregister("cas-Sub", oldRef)).toBe(false);
|
||||||
|
|
||||||
|
gate.resolve();
|
||||||
|
await releasing;
|
||||||
|
|
||||||
|
expect(registry.get("cas-Sub")).toBe(replacement);
|
||||||
|
expect(replacement.status).toBe("idle");
|
||||||
|
expect(replacement.session).toBe(replacementSession.session);
|
||||||
|
expect(replacementSession.disposeCalls()).toBe(0);
|
||||||
|
expect(lifecycle.has("cas-Sub", replacement)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("adopt(Main) is a no-op: Main is never adopted or parked", async () => {
|
it("adopt(Main) is a no-op: Main is never adopted or parked", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const stub = makeSessionStub();
|
const stub = makeSessionStub();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
@@ -7,10 +7,13 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
|||||||
import type { Rule } from "@oh-my-pi/pi-coding-agent/capability/rule";
|
import type { Rule } from "@oh-my-pi/pi-coding-agent/capability/rule";
|
||||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||||
|
import { AgentLifecycleManager } from "@oh-my-pi/pi-coding-agent/registry/agent-lifecycle";
|
||||||
|
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
|
||||||
import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk";
|
import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk";
|
||||||
import { SecretObfuscator } from "@oh-my-pi/pi-coding-agent/secrets";
|
import { SecretObfuscator } from "@oh-my-pi/pi-coding-agent/secrets";
|
||||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||||
|
import { VibeSessionRegistry } from "@oh-my-pi/pi-coding-agent/vibe/runtime";
|
||||||
import { getSessionsDir, removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils";
|
import { getSessionsDir, removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils";
|
||||||
|
|
||||||
function createTtsrRule(name: string): Rule {
|
function createTtsrRule(name: string): Rule {
|
||||||
@@ -77,6 +80,7 @@ describe("createAgentSession session storage isolation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
for (const tempDir of tempDirs.splice(0)) {
|
for (const tempDir of tempDirs.splice(0)) {
|
||||||
removeSyncWithRetries(tempDir);
|
removeSyncWithRetries(tempDir);
|
||||||
}
|
}
|
||||||
@@ -115,6 +119,131 @@ describe("createAgentSession session storage isolation", () => {
|
|||||||
await session.dispose();
|
await session.dispose();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
it("does not replace a newer registry generation when creation expected the id to be absent", async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `pi-sdk-generation-cas-${Snowflake.next()}-`));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
const cwd = path.join(tempDir, "project");
|
||||||
|
fs.mkdirSync(cwd, { recursive: true });
|
||||||
|
const registry = new AgentRegistry();
|
||||||
|
const replacement = registry.register({
|
||||||
|
id: "shared-worker",
|
||||||
|
displayName: "replacement B",
|
||||||
|
kind: "sub",
|
||||||
|
parentId: "Main",
|
||||||
|
session: null,
|
||||||
|
status: "idle",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createAgentSession({
|
||||||
|
cwd,
|
||||||
|
agentDir: path.join(tempDir, "agent"),
|
||||||
|
modelRegistry: sharedModelRegistry,
|
||||||
|
settings: Settings.isolated(),
|
||||||
|
disableExtensionDiscovery: true,
|
||||||
|
skills: [],
|
||||||
|
contextFiles: [],
|
||||||
|
promptTemplates: [],
|
||||||
|
slashCommands: [],
|
||||||
|
enableMCP: false,
|
||||||
|
enableLsp: false,
|
||||||
|
agentRegistry: registry,
|
||||||
|
agentId: "shared-worker",
|
||||||
|
agentDisplayName: "late A",
|
||||||
|
parentTaskPrefix: "shared-worker",
|
||||||
|
parentAgentId: "Main",
|
||||||
|
taskDepth: 1,
|
||||||
|
expectedAgentRef: null,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("already owned by another session generation");
|
||||||
|
expect(registry.get("shared-worker")).toBe(replacement);
|
||||||
|
expect(replacement).toMatchObject({ status: "idle", session: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses the exact parked ref authorized for revival", async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `pi-sdk-generation-revive-${Snowflake.next()}-`));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
const cwd = path.join(tempDir, "project");
|
||||||
|
fs.mkdirSync(cwd, { recursive: true });
|
||||||
|
const sessionManager = SessionManager.create(cwd, tempDir);
|
||||||
|
await sessionManager.ensureOnDisk();
|
||||||
|
const sessionFile = sessionManager.getSessionFile();
|
||||||
|
if (!sessionFile) throw new Error("Expected persisted worker session file");
|
||||||
|
const registry = new AgentRegistry();
|
||||||
|
const parked = registry.register({
|
||||||
|
id: "revived-worker",
|
||||||
|
displayName: "revived worker",
|
||||||
|
kind: "sub",
|
||||||
|
parentId: "Main",
|
||||||
|
session: null,
|
||||||
|
sessionFile,
|
||||||
|
status: "parked",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { session } = await createAgentSession({
|
||||||
|
cwd,
|
||||||
|
modelRegistry: sharedModelRegistry,
|
||||||
|
settings: Settings.isolated(),
|
||||||
|
disableExtensionDiscovery: true,
|
||||||
|
skills: [],
|
||||||
|
contextFiles: [],
|
||||||
|
promptTemplates: [],
|
||||||
|
slashCommands: [],
|
||||||
|
enableMCP: false,
|
||||||
|
enableLsp: false,
|
||||||
|
sessionManager,
|
||||||
|
agentRegistry: registry,
|
||||||
|
agentId: "revived-worker",
|
||||||
|
agentDisplayName: "revived worker",
|
||||||
|
parentTaskPrefix: "revived-worker",
|
||||||
|
parentAgentId: "Main",
|
||||||
|
taskDepth: 1,
|
||||||
|
expectedAgentRef: parked,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(registry.get("revived-worker")).toBe(parked);
|
||||||
|
expect(parked).toMatchObject({ status: "running", session, sessionFile });
|
||||||
|
} finally {
|
||||||
|
await session.dispose();
|
||||||
|
}
|
||||||
|
expect(registry.get("revived-worker")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suspends the exact Vibe owner scope before global lifecycle teardown", async () => {
|
||||||
|
VibeSessionRegistry.resetGlobalForTests();
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `pi-sdk-vibe-dispose-${Snowflake.next()}-`));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
const cwd = path.join(tempDir, "project");
|
||||||
|
fs.mkdirSync(cwd, { recursive: true });
|
||||||
|
const { session } = await createAgentSession({
|
||||||
|
cwd,
|
||||||
|
agentDir: path.join(tempDir, "agent"),
|
||||||
|
modelRegistry: sharedModelRegistry,
|
||||||
|
settings: Settings.isolated(),
|
||||||
|
disableExtensionDiscovery: true,
|
||||||
|
skills: [],
|
||||||
|
contextFiles: [],
|
||||||
|
promptTemplates: [],
|
||||||
|
slashCommands: [],
|
||||||
|
enableMCP: false,
|
||||||
|
enableLsp: false,
|
||||||
|
});
|
||||||
|
const vibeRegistry = VibeSessionRegistry.global();
|
||||||
|
const suspend = vi.spyOn(vibeRegistry, "suspendScope");
|
||||||
|
const lifecycleDispose = vi.spyOn(AgentLifecycleManager.global(), "dispose");
|
||||||
|
const parentSessionId = session.sessionManager.getSessionId();
|
||||||
|
const parentSessionFile = session.sessionManager.getSessionFile();
|
||||||
|
if (!parentSessionFile) throw new Error("Expected persisted parent session file");
|
||||||
|
|
||||||
|
await session.dispose();
|
||||||
|
|
||||||
|
expect(suspend).toHaveBeenCalledWith(
|
||||||
|
{ ownerId: "Main", parentSessionId, parentSessionFile },
|
||||||
|
session.asyncJobManager,
|
||||||
|
);
|
||||||
|
expect(suspend.mock.invocationCallOrder[0]).toBeLessThan(lifecycleDispose.mock.invocationCallOrder[0]);
|
||||||
|
});
|
||||||
|
|
||||||
it("wires the discovered TTSR manager into the created session", async () => {
|
it("wires the discovered TTSR manager into the created session", async () => {
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `pi-sdk-ttsr-${Snowflake.next()}-`));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `pi-sdk-ttsr-${Snowflake.next()}-`));
|
||||||
tempDirs.push(tempDir);
|
tempDirs.push(tempDir);
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
||||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
import {
|
||||||
|
IndexedSessionStorage,
|
||||||
|
type SessionStorageBackend,
|
||||||
|
} from "@oh-my-pi/pi-coding-agent/session/indexed-session-storage";
|
||||||
|
import {
|
||||||
|
SessionManager,
|
||||||
|
SessionPersistenceIndeterminateError,
|
||||||
|
} from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||||
import {
|
import {
|
||||||
MemorySessionStorage,
|
MemorySessionStorage,
|
||||||
type SessionStorageWriter,
|
type SessionStorageWriter,
|
||||||
@@ -679,3 +686,270 @@ describe("SessionManager fence handoff across superseded rewrites", () => {
|
|||||||
expect(storage.detachedLines).toEqual([]);
|
expect(storage.detachedLines).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface AtomicFailureHandle {
|
||||||
|
started: Promise<void>;
|
||||||
|
release: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class GatedAtomicFailureStorage extends MemorySessionStorage {
|
||||||
|
#nextFailure:
|
||||||
|
| {
|
||||||
|
error: Error;
|
||||||
|
started: ReturnType<typeof Promise.withResolvers<void>>;
|
||||||
|
release: ReturnType<typeof Promise.withResolvers<void>>;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
failNextAtomicWrite(error: Error): AtomicFailureHandle {
|
||||||
|
if (this.#nextFailure) throw new Error("Atomic failure already armed");
|
||||||
|
const started = Promise.withResolvers<void>();
|
||||||
|
const release = Promise.withResolvers<void>();
|
||||||
|
this.#nextFailure = { error, started, release };
|
||||||
|
return { started: started.promise, release: release.resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
override async writeTextAtomic(path: string, content: string, options?: WriteTextAtomicOptions): Promise<void> {
|
||||||
|
const failure = this.#nextFailure;
|
||||||
|
if (!failure) {
|
||||||
|
await super.writeTextAtomic(path, content, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#nextFailure = undefined;
|
||||||
|
failure.started.resolve();
|
||||||
|
await failure.release.promise;
|
||||||
|
throw failure.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScriptedAtomicFailureStorage extends MemorySessionStorage {
|
||||||
|
readonly behaviors: Array<{ commit: boolean; error: Error }> = [];
|
||||||
|
|
||||||
|
override async writeTextAtomic(path: string, content: string, options?: WriteTextAtomicOptions): Promise<void> {
|
||||||
|
const behavior = this.behaviors.shift();
|
||||||
|
if (!behavior) {
|
||||||
|
await super.writeTextAtomic(path, content, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (behavior.commit) await super.writeTextAtomic(path, content, options);
|
||||||
|
throw behavior.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SessionManager atomic entry batches", () => {
|
||||||
|
it("restores the exact active branch when an atomic batch publish fails", async () => {
|
||||||
|
const storage = new GatedAtomicFailureStorage();
|
||||||
|
const manager = SessionManager.create("/cwd", "/sessions", storage);
|
||||||
|
const rootId = manager.appendCustomEntry("root");
|
||||||
|
manager.appendCustomEntry("abandoned-tail");
|
||||||
|
await manager.ensureOnDisk();
|
||||||
|
const sessionFile = manager.getSessionFile();
|
||||||
|
if (!sessionFile) throw new Error("Expected session file");
|
||||||
|
manager.branch(rootId);
|
||||||
|
const before = await storage.readText(sessionFile);
|
||||||
|
const failure = storage.failNextAtomicWrite(new Error("batch publish failed"));
|
||||||
|
const commit = manager.appendEntriesAtomically(() => manager.appendCustomEntry("staged-terminal"));
|
||||||
|
await failure.started;
|
||||||
|
failure.release();
|
||||||
|
|
||||||
|
await expect(commit).rejects.toThrow("batch publish failed");
|
||||||
|
expect(manager.getBranch().map(entry => entry.id)).toEqual([rootId]);
|
||||||
|
expect(
|
||||||
|
manager.getEntries().some(entry => entry.type === "custom" && entry.customType === "staged-terminal"),
|
||||||
|
).toBe(false);
|
||||||
|
expect(await storage.readText(sessionFile)).toBe(before);
|
||||||
|
|
||||||
|
await manager.appendEntriesAtomically(() => manager.appendCustomEntry("committed-terminal"));
|
||||||
|
expect(manager.getBranch().at(-1)).toMatchObject({ type: "custom", customType: "committed-terminal" });
|
||||||
|
await manager.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reparents and durably preserves a concurrent append when the staged batch rolls back", async () => {
|
||||||
|
const storage = new GatedAtomicFailureStorage();
|
||||||
|
const manager = SessionManager.create("/cwd", "/sessions", storage);
|
||||||
|
const rootId = manager.appendCustomEntry("root");
|
||||||
|
await manager.ensureOnDisk();
|
||||||
|
const sessionFile = manager.getSessionFile();
|
||||||
|
if (!sessionFile) throw new Error("Expected session file");
|
||||||
|
const notifiedIds: string[] = [];
|
||||||
|
manager.onEntryAppended = entry => notifiedIds.push(entry.id);
|
||||||
|
const failure = storage.failNextAtomicWrite(new Error("batch publish failed"));
|
||||||
|
let stagedId = "";
|
||||||
|
const commit = manager.appendEntriesAtomically(() => {
|
||||||
|
stagedId = manager.appendCustomEntry("staged-terminal");
|
||||||
|
});
|
||||||
|
await failure.started;
|
||||||
|
const concurrentId = manager.appendCustomEntry("concurrent-survivor");
|
||||||
|
failure.release();
|
||||||
|
|
||||||
|
await expect(commit).rejects.toThrow("batch publish failed");
|
||||||
|
expect(manager.getEntries().some(entry => entry.id === stagedId)).toBe(false);
|
||||||
|
expect(manager.getEntries().find(entry => entry.id === concurrentId)?.parentId).toBe(rootId);
|
||||||
|
expect(manager.getBranch().at(-1)?.id).toBe(concurrentId);
|
||||||
|
expect(notifiedIds).toEqual([concurrentId]);
|
||||||
|
|
||||||
|
const content = await storage.readText(sessionFile);
|
||||||
|
expect(content).not.toContain('"customType":"staged-terminal"');
|
||||||
|
expect(content).toContain('"customType":"concurrent-survivor"');
|
||||||
|
await manager.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repairs authoritative rollback after commit-then-throw, including a rejecting repair acknowledgement", async () => {
|
||||||
|
const storage = new ScriptedAtomicFailureStorage();
|
||||||
|
const manager = SessionManager.create("/cwd", "/sessions", storage);
|
||||||
|
manager.appendCustomEntry("root");
|
||||||
|
await manager.ensureOnDisk();
|
||||||
|
const sessionFile = manager.getSessionFile();
|
||||||
|
if (!sessionFile) throw new Error("Expected session file");
|
||||||
|
const before = await storage.readText(sessionFile);
|
||||||
|
storage.behaviors.push(
|
||||||
|
{ commit: true, error: new Error("batch committed but acknowledgement failed") },
|
||||||
|
{ commit: true, error: new Error("repair committed but acknowledgement failed") },
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(manager.appendEntriesAtomically(() => manager.appendCustomEntry("staged-terminal"))).rejects.toThrow(
|
||||||
|
"batch committed but acknowledgement failed",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await storage.readText(sessionFile)).toBe(before);
|
||||||
|
expect(
|
||||||
|
manager.getEntries().some(entry => entry.type === "custom" && entry.customType === "staged-terminal"),
|
||||||
|
).toBe(false);
|
||||||
|
await manager.appendEntriesAtomically(() => manager.appendCustomEntry("retry-terminal"));
|
||||||
|
expect(await storage.readText(sessionFile)).toContain('"customType":"retry-terminal"');
|
||||||
|
await manager.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reserves concurrent atomic batches FIFO before their first await", async () => {
|
||||||
|
const storage = new GatedAtomicFailureStorage();
|
||||||
|
const manager = SessionManager.create("/cwd", "/sessions", storage);
|
||||||
|
manager.appendCustomEntry("root");
|
||||||
|
await manager.ensureOnDisk();
|
||||||
|
const sessionFile = manager.getSessionFile();
|
||||||
|
if (!sessionFile) throw new Error("Expected session file");
|
||||||
|
const failure = storage.failNextAtomicWrite(new Error("first batch failed"));
|
||||||
|
const first = manager.appendEntriesAtomically(() => manager.appendCustomEntry("batch-a"));
|
||||||
|
await failure.started;
|
||||||
|
let secondCallbackRan = false;
|
||||||
|
const second = manager.appendEntriesAtomically(() => {
|
||||||
|
secondCallbackRan = true;
|
||||||
|
return manager.appendCustomEntry("batch-b");
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(secondCallbackRan).toBe(false);
|
||||||
|
failure.release();
|
||||||
|
|
||||||
|
await expect(first).rejects.toThrow("first batch failed");
|
||||||
|
await second;
|
||||||
|
expect(secondCallbackRan).toBe(true);
|
||||||
|
const content = await storage.readText(sessionFile);
|
||||||
|
expect(content).not.toContain('"customType":"batch-a"');
|
||||||
|
expect(content).toContain('"customType":"batch-b"');
|
||||||
|
await manager.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("latches a typed indeterminate error when rollback repair cannot be verified", async () => {
|
||||||
|
const storage = new ScriptedAtomicFailureStorage();
|
||||||
|
const manager = SessionManager.create("/cwd", "/sessions", storage);
|
||||||
|
manager.appendCustomEntry("root");
|
||||||
|
await manager.ensureOnDisk();
|
||||||
|
const sessionFile = manager.getSessionFile();
|
||||||
|
if (!sessionFile) throw new Error("Expected session file");
|
||||||
|
storage.behaviors.push(
|
||||||
|
{ commit: true, error: new Error("batch committed but acknowledgement failed") },
|
||||||
|
{ commit: false, error: new Error("authoritative repair failed before commit") },
|
||||||
|
);
|
||||||
|
|
||||||
|
const failure = await manager
|
||||||
|
.appendEntriesAtomically(() => manager.appendCustomEntry("possibly-durable-terminal"))
|
||||||
|
.catch(error => error);
|
||||||
|
|
||||||
|
expect(failure).toBeInstanceOf(SessionPersistenceIndeterminateError);
|
||||||
|
expect((failure as SessionPersistenceIndeterminateError).errors).toHaveLength(3);
|
||||||
|
expect(await storage.readText(sessionFile)).toContain('"customType":"possibly-durable-terminal"');
|
||||||
|
expect(
|
||||||
|
manager
|
||||||
|
.getEntries()
|
||||||
|
.some(entry => entry.type === "custom" && entry.customType === "possibly-durable-terminal"),
|
||||||
|
).toBe(false);
|
||||||
|
await expect(manager.flush()).rejects.toBeInstanceOf(SessionPersistenceIndeterminateError);
|
||||||
|
|
||||||
|
await manager.recoverPersistenceFromCurrentState();
|
||||||
|
expect(await storage.readText(sessionFile)).not.toContain('"customType":"possibly-durable-terminal"');
|
||||||
|
await manager.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
class CommitThenThrowIndexedBackend implements SessionStorageBackend {
|
||||||
|
content: string | null = null;
|
||||||
|
readonly atomicWriteStarted = Promise.withResolvers<void>();
|
||||||
|
readonly releaseAtomicWrite = Promise.withResolvers<void>();
|
||||||
|
readonly newerWriteFinished = Promise.withResolvers<void>();
|
||||||
|
#writeCount = 0;
|
||||||
|
#atomicWriteFailed = false;
|
||||||
|
|
||||||
|
async init(): Promise<void> {}
|
||||||
|
|
||||||
|
async loadIndex(): Promise<[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFull(): Promise<string | null> {
|
||||||
|
if (this.#atomicWriteFailed) await this.newerWriteFinished.promise;
|
||||||
|
return this.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
async readSlices(): Promise<[string, string]> {
|
||||||
|
return [this.content ?? "", this.content ?? ""];
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFull(_path: string, content: string): Promise<void> {
|
||||||
|
this.#writeCount++;
|
||||||
|
if (this.#writeCount === 2) {
|
||||||
|
this.atomicWriteStarted.resolve();
|
||||||
|
await this.releaseAtomicWrite.promise;
|
||||||
|
this.content = content;
|
||||||
|
this.#atomicWriteFailed = true;
|
||||||
|
throw new Error("atomic write committed but acknowledgement failed");
|
||||||
|
}
|
||||||
|
this.content = content;
|
||||||
|
if (this.#writeCount === 3) this.newerWriteFinished.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async append(_path: string, line: string): Promise<void> {
|
||||||
|
this.content = (this.content ?? "") + line;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSessionTitle(): Promise<void> {}
|
||||||
|
|
||||||
|
async truncate(): Promise<void> {
|
||||||
|
this.content = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(): Promise<void> {
|
||||||
|
this.content = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async move(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("IndexedSessionStorage atomic readback", () => {
|
||||||
|
it("preserves a newer synchronous takeover when failed-write readback sees its body", async () => {
|
||||||
|
const backend = new CommitThenThrowIndexedBackend();
|
||||||
|
const storage = new IndexedSessionStorage(backend);
|
||||||
|
const sessionPath = "/sessions/current.jsonl";
|
||||||
|
const newerBody = "newer-body-that-is-long";
|
||||||
|
await storage.initialize();
|
||||||
|
await storage.writeText(sessionPath, "old");
|
||||||
|
|
||||||
|
const failure = storage.writeTextAtomic(sessionPath, "atomic-body").catch(error => error);
|
||||||
|
await backend.atomicWriteStarted.promise;
|
||||||
|
storage.writeTextSync(sessionPath, newerBody);
|
||||||
|
backend.releaseAtomicWrite.resolve();
|
||||||
|
|
||||||
|
expect(await failure).toMatchObject({ message: "atomic write committed but acknowledgement failed" });
|
||||||
|
await storage.drain();
|
||||||
|
expect(await storage.readText(sessionPath)).toBe(newerBody);
|
||||||
|
expect(storage.statSync(sessionPath).size).toBe(Buffer.byteLength(newerBody));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -37,6 +37,38 @@ function createFakeRedis(): FakeRedis {
|
|||||||
return {
|
return {
|
||||||
strings,
|
strings,
|
||||||
hashes,
|
hashes,
|
||||||
|
async send(command, args) {
|
||||||
|
if (command !== "EVAL") throw new Error(`Unsupported Redis command: ${command}`);
|
||||||
|
const script = args[0] ?? "";
|
||||||
|
const keyCount = Number(args[1] ?? "0");
|
||||||
|
const keys = args.slice(2, 2 + keyCount);
|
||||||
|
const argv = args.slice(2 + keyCount);
|
||||||
|
if (script.includes("OMP_WRITE_FULL")) {
|
||||||
|
const [fileKey, metaKey, titleKey] = keys;
|
||||||
|
const [content, filePath, mtimeMs, hasTitle, title] = argv;
|
||||||
|
strings.set(fileKey, content);
|
||||||
|
getHash(metaKey).set(filePath, mtimeMs);
|
||||||
|
if (hasTitle === "1") getHash(titleKey).set(filePath, title);
|
||||||
|
else getHash(titleKey).delete(filePath);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (script.includes("OMP_APPEND")) {
|
||||||
|
const [fileKey, metaKey] = keys;
|
||||||
|
const [line, filePath, mtimeMs] = argv;
|
||||||
|
const next = (strings.get(fileKey) ?? "") + line;
|
||||||
|
strings.set(fileKey, next);
|
||||||
|
getHash(metaKey).set(filePath, mtimeMs);
|
||||||
|
return Buffer.byteLength(next, "utf-8");
|
||||||
|
}
|
||||||
|
if (script.includes("OMP_UPDATE_TITLE")) {
|
||||||
|
const [metaKey, titleKey] = keys;
|
||||||
|
const [filePath, mtimeMs, title] = argv;
|
||||||
|
getHash(metaKey).set(filePath, mtimeMs);
|
||||||
|
getHash(titleKey).set(filePath, title);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported Redis script");
|
||||||
|
},
|
||||||
async get(key) {
|
async get(key) {
|
||||||
return strings.has(key) ? (strings.get(key) as string) : null;
|
return strings.has(key) ? (strings.get(key) as string) : null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -68,6 +68,45 @@ function createFakeRedis(): FakeRedis {
|
|||||||
queue.push(error);
|
queue.push(error);
|
||||||
failures.set(method, queue);
|
failures.set(method, queue);
|
||||||
},
|
},
|
||||||
|
async send(command, args) {
|
||||||
|
record("send", [command, args]);
|
||||||
|
checkFailure("send");
|
||||||
|
if (command !== "EVAL") throw new Error(`Unsupported Redis command: ${command}`);
|
||||||
|
const script = args[0] ?? "";
|
||||||
|
const keyCount = Number(args[1] ?? "0");
|
||||||
|
const keys = args.slice(2, 2 + keyCount);
|
||||||
|
const argv = args.slice(2 + keyCount);
|
||||||
|
if (script.includes("OMP_WRITE_FULL")) {
|
||||||
|
checkFailure("set");
|
||||||
|
checkFailure("hset");
|
||||||
|
const [fileKey, metaKey, titleKey] = keys;
|
||||||
|
const [content, filePath, mtimeMs, hasTitle, title] = argv;
|
||||||
|
strings.set(fileKey, content);
|
||||||
|
getHash(metaKey).set(filePath, mtimeMs);
|
||||||
|
if (hasTitle === "1") getHash(titleKey).set(filePath, title);
|
||||||
|
else getHash(titleKey).delete(filePath);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (script.includes("OMP_APPEND")) {
|
||||||
|
checkFailure("append");
|
||||||
|
checkFailure("hset");
|
||||||
|
const [fileKey, metaKey] = keys;
|
||||||
|
const [line, filePath, mtimeMs] = argv;
|
||||||
|
const next = (strings.get(fileKey) ?? "") + line;
|
||||||
|
strings.set(fileKey, next);
|
||||||
|
getHash(metaKey).set(filePath, mtimeMs);
|
||||||
|
return Buffer.byteLength(next, "utf-8");
|
||||||
|
}
|
||||||
|
if (script.includes("OMP_UPDATE_TITLE")) {
|
||||||
|
checkFailure("hset");
|
||||||
|
const [metaKey, titleKey] = keys;
|
||||||
|
const [filePath, mtimeMs, title] = argv;
|
||||||
|
getHash(metaKey).set(filePath, mtimeMs);
|
||||||
|
getHash(titleKey).set(filePath, title);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported Redis script");
|
||||||
|
},
|
||||||
async get(key) {
|
async get(key) {
|
||||||
record("get", [key]);
|
record("get", [key]);
|
||||||
checkFailure("get");
|
checkFailure("get");
|
||||||
@@ -185,6 +224,21 @@ describe("RedisSessionStorage", () => {
|
|||||||
expect(typeof stat.mtimeMs).toBe("number");
|
expect(typeof stat.mtimeMs).toBe("number");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("commits file content and metadata through one atomic Redis script", async () => {
|
||||||
|
const storage = await RedisSessionStorage.create({ client: redis });
|
||||||
|
const sessionPath = "/sessions/p/atomic.jsonl";
|
||||||
|
await storage.writeText(sessionPath, "old\n");
|
||||||
|
const oldMtime = redis.hashes.get("omp:sessions:meta")?.get(sessionPath);
|
||||||
|
redis.failNext("send", new Error("EVAL transport failed"));
|
||||||
|
|
||||||
|
await expect(storage.writeTextAtomic(sessionPath, "new\n")).rejects.toThrow("EVAL transport failed");
|
||||||
|
|
||||||
|
expect(redis.strings.get(`omp:sessions:file:${sessionPath}`)).toBe("old\n");
|
||||||
|
expect(redis.hashes.get("omp:sessions:meta")?.get(sessionPath)).toBe(oldMtime);
|
||||||
|
expect(storage.statSync(sessionPath).size).toBe(4);
|
||||||
|
expect(redis.calls.some(call => call.method === "send" && call.args[0] === "EVAL")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("create() warms the metadata index with STRLEN and never GETs full content", async () => {
|
it("create() warms the metadata index with STRLEN and never GETs full content", async () => {
|
||||||
redis.strings.set("omp:sessions:file:/sessions/p/huge.jsonl", "0123456789");
|
redis.strings.set("omp:sessions:file:/sessions/p/huge.jsonl", "0123456789");
|
||||||
redis.hashes.set("omp:sessions:meta", new Map([["/sessions/p/huge.jsonl", String(Date.now())]]));
|
redis.hashes.set("omp:sessions:meta", new Map([["/sessions/p/huge.jsonl", String(Date.now())]]));
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { afterEach, describe, expect, it, vi } from "bun:test";
|
|||||||
import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||||
import type { LoadExtensionsResult } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types";
|
import type { LoadExtensionsResult } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types";
|
||||||
import type { CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk";
|
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
|
||||||
|
import type { CreateAgentSessionOptions, CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk";
|
||||||
import * as sdkModule from "@oh-my-pi/pi-coding-agent/sdk";
|
import * as sdkModule from "@oh-my-pi/pi-coding-agent/sdk";
|
||||||
import type { AgentSession, AgentSessionEvent, PromptOptions } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
import type { AgentSession, AgentSessionEvent, PromptOptions } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||||
import { runSubprocess } from "@oh-my-pi/pi-coding-agent/task/executor";
|
import { runSubprocess } from "@oh-my-pi/pi-coding-agent/task/executor";
|
||||||
@@ -71,6 +72,7 @@ function mockCreateAgentSession(session: AgentSession) {
|
|||||||
describe("runSubprocess wall clock (task.maxRuntimeMs)", () => {
|
describe("runSubprocess wall clock (task.maxRuntimeMs)", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
AgentRegistry.resetGlobalForTests();
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseAgent: AgentDefinition = {
|
const baseAgent: AgentDefinition = {
|
||||||
@@ -197,6 +199,67 @@ describe("runSubprocess wall clock (task.maxRuntimeMs)", () => {
|
|||||||
expect(promptCalls).toBe(0);
|
expect(promptCalls).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a cancelled late initializer cannot replace a newer same-id worker", async () => {
|
||||||
|
AgentRegistry.resetGlobalForTests();
|
||||||
|
const registry = AgentRegistry.global();
|
||||||
|
const creationGate = Promise.withResolvers<void>();
|
||||||
|
const creationStarted = Promise.withResolvers<CreateAgentSessionOptions>();
|
||||||
|
const lateDisposed = Promise.withResolvers<void>();
|
||||||
|
const lateSession = {
|
||||||
|
dispose: async () => lateDisposed.resolve(),
|
||||||
|
} as unknown as AgentSession;
|
||||||
|
let lateInstall = registry.get("late-generation");
|
||||||
|
vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async (options = {}) => {
|
||||||
|
creationStarted.resolve(options);
|
||||||
|
await creationGate.promise;
|
||||||
|
lateInstall = registry.registerIfAvailable(
|
||||||
|
{
|
||||||
|
id: "late-generation",
|
||||||
|
displayName: "late A",
|
||||||
|
kind: "sub",
|
||||||
|
parentId: "Main",
|
||||||
|
session: null,
|
||||||
|
status: "running",
|
||||||
|
},
|
||||||
|
options.expectedAgentRef ?? null,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
session: lateSession,
|
||||||
|
extensionsResult: {} as unknown as LoadExtensionsResult,
|
||||||
|
setToolUIContext: () => {},
|
||||||
|
eventBus: new EventBus(),
|
||||||
|
} satisfies CreateAgentSessionResult;
|
||||||
|
});
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const run = runSubprocess({
|
||||||
|
...baseOptions,
|
||||||
|
id: "late-generation",
|
||||||
|
settings: Settings.isolated({ "task.maxRuntimeMs": 0 }),
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
const creationOptions = await creationStarted.promise;
|
||||||
|
expect(creationOptions.expectedAgentRef).toBeNull();
|
||||||
|
abortController.abort();
|
||||||
|
const cancelled = await run;
|
||||||
|
expect(cancelled.aborted).toBe(true);
|
||||||
|
|
||||||
|
const replacementSession = { dispose: async () => {} } as unknown as AgentSession;
|
||||||
|
const replacement = registry.register({
|
||||||
|
id: "late-generation",
|
||||||
|
displayName: "replacement B",
|
||||||
|
kind: "sub",
|
||||||
|
parentId: "Main",
|
||||||
|
session: replacementSession,
|
||||||
|
status: "idle",
|
||||||
|
});
|
||||||
|
creationGate.resolve();
|
||||||
|
await lateDisposed.promise;
|
||||||
|
|
||||||
|
expect(lateInstall).toBeUndefined();
|
||||||
|
expect(registry.get("late-generation")).toBe(replacement);
|
||||||
|
expect(replacement).toMatchObject({ status: "idle", session: replacementSession });
|
||||||
|
});
|
||||||
|
|
||||||
it("a late successful yield does not flip a timed-out run to success", async () => {
|
it("a late successful yield does not flip a timed-out run to success", async () => {
|
||||||
// A hung subagent emits a successful `yield` event during teardown (after
|
// A hung subagent emits a successful `yield` event during teardown (after
|
||||||
// the timer has already aborted). Without the fix, `hasYield=true` would
|
// the timer has already aborted). Without the fix, `hasYield=true` would
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { TempDir } from "@oh-my-pi/pi-utils";
|
|||||||
|
|
||||||
// Contract: subagent output ids are the requested name, used verbatim the first
|
// Contract: subagent output ids are the requested name, used verbatim the first
|
||||||
// time and suffixed (`-2`, `-3`, …) only when the same name recurs. A parent
|
// time and suffixed (`-2`, `-3`, …) only when the same name recurs. A parent
|
||||||
// prefix nests ids under it. On resume the manager scans existing `.md` outputs
|
// prefix nests ids under it. On resume the manager scans existing output and
|
||||||
// so it never reuses a name that would clobber a previously written output.
|
// child-session files so it never reuses a name that would clobber prior state.
|
||||||
|
|
||||||
describe("AgentOutputManager", () => {
|
describe("AgentOutputManager", () => {
|
||||||
it("uses the requested name verbatim and suffixes only on repeat", async () => {
|
it("uses the requested name verbatim and suffixes only on repeat", async () => {
|
||||||
@@ -42,14 +42,24 @@ describe("AgentOutputManager", () => {
|
|||||||
const dir = tmp.path();
|
const dir = tmp.path();
|
||||||
await Bun.write(path.join(dir, "Anna.md"), "prior");
|
await Bun.write(path.join(dir, "Anna.md"), "prior");
|
||||||
await Bun.write(path.join(dir, "Anna-2.md"), "prior");
|
await Bun.write(path.join(dir, "Anna-2.md"), "prior");
|
||||||
|
await Bun.write(path.join(dir, "Bob.jsonl"), "persisted child session");
|
||||||
// Unrelated tool artifacts (numeric `.log` ids) must not be mistaken for names.
|
// Unrelated tool artifacts (numeric `.log` ids) must not be mistaken for names.
|
||||||
await Bun.write(path.join(dir, "7.bash.log"), "noise");
|
await Bun.write(path.join(dir, "7.bash.log"), "noise");
|
||||||
|
|
||||||
const mgr = new AgentOutputManager(() => dir);
|
const mgr = new AgentOutputManager(() => dir);
|
||||||
|
|
||||||
expect(await mgr.allocate("Anna")).toBe("Anna-3");
|
expect(await mgr.allocate("Anna")).toBe("Anna-3");
|
||||||
// A name with no file on disk is still pristine.
|
// A child JSONL without a result markdown still reserves its worker id.
|
||||||
expect(await mgr.allocate("Bob")).toBe("Bob");
|
expect(await mgr.allocate("Bob")).toBe("Bob-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("awaits one disk scan before concurrent allocations", async () => {
|
||||||
|
using tmp = TempDir.createSync("@omp-output-manager-");
|
||||||
|
const dir = tmp.path();
|
||||||
|
await Bun.write(path.join(dir, "Anna.jsonl"), "persisted child session");
|
||||||
|
const mgr = new AgentOutputManager(() => dir);
|
||||||
|
|
||||||
|
expect(await Promise.all([mgr.allocate("Anna"), mgr.allocate("Anna")])).toEqual(["Anna-2", "Anna-3"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only counts files within its own prefix scope on resume", async () => {
|
it("only counts files within its own prefix scope on resume", async () => {
|
||||||
@@ -66,6 +76,14 @@ describe("AgentOutputManager", () => {
|
|||||||
expect(await mgr.allocate("Dave")).toBe("Anna.Dave");
|
expect(await mgr.allocate("Dave")).toBe("Anna.Dave");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reserves lifecycle-known ids that no longer have files on disk", async () => {
|
||||||
|
const mgr = new AgentOutputManager(() => null);
|
||||||
|
await mgr.reserve(["Gone", "Other"]);
|
||||||
|
|
||||||
|
expect(await mgr.allocate("Gone")).toBe("Gone-2");
|
||||||
|
expect(await mgr.allocate("Fresh")).toBe("Fresh");
|
||||||
|
});
|
||||||
|
|
||||||
it("reserves the advisor transcript stem so a task can't clobber __advisor.jsonl", async () => {
|
it("reserves the advisor transcript stem so a task can't clobber __advisor.jsonl", async () => {
|
||||||
const mgr = new AgentOutputManager(() => null);
|
const mgr = new AgentOutputManager(() => null);
|
||||||
// A subagent allocated `__advisor` would write `__advisor.jsonl`, colliding with
|
// A subagent allocated `__advisor` would write `__advisor.jsonl`, colliding with
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user