feat(FN-1148): add AI session keep-alive pings for SSE streams

- Add AiSessionStore.ping() and expose POST /api/ai-sessions/:id/ping for lightweight heartbeat updates without emitting high-frequency session events
- Add pingSession() client API and integrate 25s keep-alive timers into planning, subtask, and mission interview resilient SSE connections
- Ensure keep-alive timers stop on stream completion, fatal errors, and explicit close while treating ping failures as best-effort non-fatal behavior
- Expand API/store/routes coverage with targeted tests for ping endpoint behavior, ping store semantics, and SSE keep-alive lifecycle handling
- Stabilize MissionStore latest-error lookup ordering with a rowid tiebreaker when timestamps are equal
This commit is contained in:
gsxdsm
2026-04-08 09:15:25 -07:00
parent c07c9f4aba
commit ebb8e63274
7 changed files with 274 additions and 7 deletions

View File

@@ -205,6 +205,30 @@ describe("AiSessionStore", () => {
expect(projectA.every((session) => session.projectId === "project-a")).toBe(true);
});
it("ping updates updatedAt for existing sessions without emitting updates", () => {
seedSession({ id: "S-ping", status: "awaiting_input" });
const staleTs = new Date(Date.now() - 60_000).toISOString();
db.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?").run(staleTs, "S-ping");
const onUpdated = vi.fn();
store.on("ai_session:updated", onUpdated);
const updated = store.ping("S-ping");
expect(updated).toBe(true);
expect(store.get("S-ping")?.updatedAt).not.toBe(staleTs);
expect(onUpdated).not.toHaveBeenCalled();
});
it("ping returns false for nonexistent sessions", () => {
const onUpdated = vi.fn();
store.on("ai_session:updated", onUpdated);
expect(store.ping("missing-session")).toBe(false);
expect(onUpdated).not.toHaveBeenCalled();
});
it("listRecoverable returns awaiting_input and generating sessions", () => {
seedSession({ id: "S-generating", status: "generating", ageMs: 3_000 });
seedSession({ id: "S-awaiting", status: "awaiting_input", ageMs: 1_000 });

View File

@@ -145,6 +145,20 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
return row ?? null;
}
/**
* Lightweight heartbeat for active sessions.
* Updates only `updatedAt` and intentionally does NOT emit
* `ai_session:updated` to avoid high-frequency SSE broadcasts.
*/
ping(id: string): boolean {
const now = new Date().toISOString();
const result = this.db
.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?")
.run(now, id) as { changes?: number };
return Number(result.changes ?? 0) > 0;
}
/**
* List active sessions (generating or awaiting_input).
* Optionally filtered by projectId.

View File

@@ -6366,6 +6366,46 @@ describe("Git Management endpoints", () => {
});
});
describe("POST /api/ai-sessions/:id/ping", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
it("returns 200 when the session exists", async () => {
const mockAiSessionStore = {
ping: vi.fn().mockReturnValue(true),
};
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { aiSessionStore: mockAiSessionStore as any }));
const res = await REQUEST(app, "POST", "/api/ai-sessions/session-123/ping");
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(mockAiSessionStore.ping).toHaveBeenCalledWith("session-123");
});
it("returns 404 when the session does not exist", async () => {
const mockAiSessionStore = {
ping: vi.fn().mockReturnValue(false),
};
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { aiSessionStore: mockAiSessionStore as any }));
const res = await REQUEST(app, "POST", "/api/ai-sessions/missing-session/ping");
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: "Session not found" });
expect(mockAiSessionStore.ping).toHaveBeenCalledWith("missing-session");
});
});
describe("Terminal session routes", () => {
let store: TaskStore;

View File

@@ -8469,6 +8469,26 @@ Output ONLY the prompt text (no markdown, no explanations).`;
res.json(session);
});
/**
* POST /api/ai-sessions/:id/ping
* Lightweight keep-alive touch for active AI sessions.
*/
router.post("/ai-sessions/:id/ping", (req, res) => {
if (!aiSessionStore) {
res.status(404).json({ error: "AI sessions not available" });
return;
}
const { id } = req.params;
const updated = aiSessionStore.ping(id);
if (!updated) {
res.status(404).json({ error: "Session not found" });
return;
}
res.json({ ok: true });
});
/**
* DELETE /api/ai-sessions/:id
* Dismiss/cancel a background AI session.