feat(FN-1147): rehydrate AI sessions across server restarts

- Add recoverable-session querying in AiSessionStore and cover it with targeted store tests
- Persist resume context (ip, initial plan, mission metadata) and rebuild planning/subtask/mission sessions from SQLite rows
- Rehydrate recoverable sessions at server startup and resume planning/mission interviews by recreating agents with replayed conversation context
- Update API flows to pass project root context and clean in-memory sessions when persisted sessions are deleted, with comprehensive regression tests
This commit is contained in:
gsxdsm
2026-04-08 08:26:57 -07:00
parent 1afa5938f9
commit 5d540f7c7e
11 changed files with 1287 additions and 129 deletions

View File

@@ -70,6 +70,99 @@ export function setAiSessionStore(store: AiSessionStore): void {
type SubtaskInternalSession = SubtaskSession & { updatedAt: Date; agent?: any; thinkingOutput: string };
function safeParseJson<T>(
text: string | null,
fallback: T,
options?: { throwOnError?: boolean; fieldName?: string },
): T {
if (!text) {
return fallback;
}
try {
return JSON.parse(text) as T;
} catch (error) {
if (options?.throwOnError) {
const fieldSuffix = options.fieldName ? ` in ${options.fieldName}` : "";
throw new Error(`Invalid JSON${fieldSuffix}: ${(error as Error).message}`);
}
return fallback;
}
}
function buildSubtaskSessionFromRow(row: AiSessionRow): SubtaskInternalSession {
const payload = safeParseJson<{ initialDescription?: string }>(
row.inputPayload,
{},
{ throwOnError: true, fieldName: "inputPayload" },
);
const createdAt = new Date(row.createdAt);
const updatedAt = new Date(row.updatedAt);
if (Number.isNaN(createdAt.getTime()) || Number.isNaN(updatedAt.getTime())) {
throw new Error("Invalid session timestamps");
}
const rawStatus = row.status === "awaiting_input" ? "generating" : row.status;
const status: SubtaskSession["status"] =
rawStatus === "generating" || rawStatus === "complete" || rawStatus === "error"
? rawStatus
: "error";
return {
sessionId: row.id,
initialDescription: payload.initialDescription ?? row.title,
subtasks: row.result
? safeParseJson<SubtaskItem[]>(row.result, [], {
throwOnError: true,
fieldName: "result",
})
: [],
status,
error: row.error ?? undefined,
thinkingOutput: row.thinkingOutput,
createdAt,
updatedAt,
agent: undefined,
};
}
function toPublicSubtaskSession(session: SubtaskInternalSession): SubtaskSession {
return {
sessionId: session.sessionId,
initialDescription: session.initialDescription,
subtasks: session.subtasks,
status: session.status,
error: session.error,
createdAt: session.createdAt,
};
}
export function rehydrateFromStore(store: AiSessionStore): number {
let rows: AiSessionRow[] = [];
try {
rows = store.listRecoverable().filter((row) => row.type === "subtask");
} catch (error) {
console.error("[subtask-breakdown] Failed to list recoverable sessions:", error);
return 0;
}
let rehydrated = 0;
for (const row of rows) {
try {
const session = buildSubtaskSessionFromRow(row);
sessions.set(session.sessionId, session);
rehydrated += 1;
} catch (error) {
console.error(`[subtask-breakdown] Failed to rehydrate session ${row.id}:`, error);
}
}
return rehydrated;
}
function cleanupInMemorySubtaskSession(sessionId: string): boolean {
const session = sessions.get(sessionId);
if (!session) {
@@ -374,16 +467,28 @@ function completeSession(sessionId: string, subtasks: SubtaskItem[]): void {
}
export function getSubtaskSession(sessionId: string): SubtaskSession | undefined {
const session = sessions.get(sessionId);
if (!session) return undefined;
return {
sessionId: session.sessionId,
initialDescription: session.initialDescription,
subtasks: session.subtasks,
status: session.status,
error: session.error,
createdAt: session.createdAt,
};
const inMemory = sessions.get(sessionId);
if (inMemory) {
return toPublicSubtaskSession(inMemory);
}
if (!_aiSessionStore) {
return undefined;
}
const row = _aiSessionStore.get(sessionId);
if (!row || row.type !== "subtask") {
return undefined;
}
try {
const restored = buildSubtaskSessionFromRow(row);
sessions.set(restored.sessionId, restored);
return toPublicSubtaskSession(restored);
} catch (error) {
console.error(`[subtask-breakdown] Failed to restore session ${sessionId} from SQLite:`, error);
return undefined;
}
}
export async function cancelSubtaskSession(sessionId: string): Promise<void> {