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:
@@ -1,10 +1,69 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import {
|
||||
__resetSubtaskBreakdownState,
|
||||
getSubtaskSession,
|
||||
rehydrateFromStore,
|
||||
setAiSessionStore,
|
||||
subtaskStreamManager,
|
||||
} from "./subtask-breakdown.js";
|
||||
import type { AiSessionRow } from "./ai-session-store.js";
|
||||
|
||||
class MockAiSessionStore extends EventEmitter {
|
||||
rows = new Map<string, AiSessionRow>();
|
||||
|
||||
get(id: string): AiSessionRow | null {
|
||||
return this.rows.get(id) ?? null;
|
||||
}
|
||||
|
||||
listRecoverable(): AiSessionRow[] {
|
||||
return [...this.rows.values()].filter(
|
||||
(row) => row.status === "awaiting_input" || row.status === "generating",
|
||||
);
|
||||
}
|
||||
|
||||
on(event: "ai_session:deleted", listener: (sessionId: string) => void): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
off(event: "ai_session:deleted", listener: (sessionId: string) => void): this {
|
||||
return super.off(event, listener);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSubtaskRow(
|
||||
overrides: Partial<AiSessionRow> & Pick<AiSessionRow, "id" | "status">,
|
||||
): AiSessionRow {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: overrides.id,
|
||||
type: overrides.type ?? "subtask",
|
||||
status: overrides.status,
|
||||
title: overrides.title ?? "Subtask breakdown",
|
||||
inputPayload:
|
||||
overrides.inputPayload ?? JSON.stringify({ initialDescription: "Break this task down" }),
|
||||
conversationHistory: overrides.conversationHistory ?? "[]",
|
||||
currentQuestion: overrides.currentQuestion ?? null,
|
||||
result:
|
||||
overrides.result ??
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "subtask-1",
|
||||
title: "Define scope",
|
||||
description: "Plan the work",
|
||||
suggestedSize: "S",
|
||||
dependsOn: [],
|
||||
},
|
||||
]),
|
||||
thinkingOutput: overrides.thinkingOutput ?? "thinking",
|
||||
error: overrides.error ?? null,
|
||||
projectId: overrides.projectId ?? null,
|
||||
createdAt: overrides.createdAt ?? now,
|
||||
updatedAt: overrides.updatedAt ?? now,
|
||||
};
|
||||
}
|
||||
|
||||
describe("subtask-breakdown stream buffering", () => {
|
||||
beforeEach(() => {
|
||||
@@ -71,3 +130,100 @@ describe("subtask-breakdown stream buffering", () => {
|
||||
expect(subtaskStreamManager.getBufferedEvents(sessionId, 0)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subtask-breakdown rehydration", () => {
|
||||
beforeEach(() => {
|
||||
__resetSubtaskBreakdownState();
|
||||
});
|
||||
|
||||
it("rehydrates recoverable subtask sessions from SQLite rows", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const subtaskRow = buildSubtaskRow({ id: "subtask-rehydrate-1", status: "generating" });
|
||||
const planningRow = buildSubtaskRow({ id: "planning-rehydrate-1", status: "awaiting_input", type: "planning" });
|
||||
|
||||
store.rows.set(subtaskRow.id, subtaskRow);
|
||||
store.rows.set(planningRow.id, planningRow);
|
||||
|
||||
const rehydrated = rehydrateFromStore(store as any);
|
||||
|
||||
expect(rehydrated).toBe(1);
|
||||
const session = getSubtaskSession(subtaskRow.id);
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.sessionId).toBe(subtaskRow.id);
|
||||
expect(session?.initialDescription).toBe("Break this task down");
|
||||
expect(session?.status).toBe("generating");
|
||||
expect(session?.subtasks).toHaveLength(1);
|
||||
expect(getSubtaskSession(planningRow.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips corrupted rows and continues with valid rows", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const goodRow = buildSubtaskRow({ id: "subtask-good", status: "generating" });
|
||||
const badRow = buildSubtaskRow({
|
||||
id: "subtask-bad",
|
||||
status: "generating",
|
||||
inputPayload: "{bad-json",
|
||||
});
|
||||
|
||||
store.rows.set(goodRow.id, goodRow);
|
||||
store.rows.set(badRow.id, badRow);
|
||||
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
const rehydrated = rehydrateFromStore(store as any);
|
||||
|
||||
expect(rehydrated).toBe(1);
|
||||
expect(getSubtaskSession(goodRow.id)).toBeDefined();
|
||||
expect(getSubtaskSession(badRow.id)).toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
`[subtask-breakdown] Failed to rehydrate session ${badRow.id}:`,
|
||||
expect.any(Error),
|
||||
);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("falls through to SQLite when session is missing in memory", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildSubtaskRow({ id: "subtask-fallthrough", status: "generating" });
|
||||
store.rows.set(row.id, row);
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
const session = getSubtaskSession(row.id);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.sessionId).toBe(row.id);
|
||||
expect(session?.initialDescription).toBe("Break this task down");
|
||||
expect(session?.status).toBe("generating");
|
||||
});
|
||||
|
||||
it("returns in-memory session before SQLite fallback", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildSubtaskRow({ id: "subtask-memory-first", status: "generating" });
|
||||
store.rows.set(row.id, row);
|
||||
|
||||
setAiSessionStore(store as any);
|
||||
rehydrateFromStore(store as any);
|
||||
|
||||
store.rows.set(
|
||||
row.id,
|
||||
buildSubtaskRow({
|
||||
id: row.id,
|
||||
status: "generating",
|
||||
inputPayload: JSON.stringify({ initialDescription: "SQLite version" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const getSpy = vi.spyOn(store, "get");
|
||||
const session = getSubtaskSession(row.id);
|
||||
|
||||
expect(session?.initialDescription).toBe("Break this task down");
|
||||
expect(getSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined when session exists nowhere", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
expect(getSubtaskSession("missing-subtask-session")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user