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:
@@ -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 });
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user