feat(FN-1146): add configurable AI session cleanup lifecycle
- Add aiSessionTtlMs and aiSessionCleanupIntervalMs project settings with defaults and bounds for cleanup scheduling - Extend AiSessionStore cleanup to expire stale in-progress sessions, emit deletion events, and support start/stop scheduled cleanup loops - Wire server startup/shutdown to load cleanup settings and manage the scheduled ai_sessions sweep lifecycle - Align planning, subtask breakdown, and mission interview in-memory session retention to a 7-day TTL with shared deletion-driven cleanup - Update schema/tests/docs for migration v15 ai_sessions indexing and end-to-end cleanup/TTL behavior stability
This commit is contained in:
49
AGENTS.md
49
AGENTS.md
@@ -1007,6 +1007,55 @@ Timeout in milliseconds for detecting stuck tasks. When a task's agent session s
|
|||||||
- The timeout is read from settings on every poll cycle, so changes take effect immediately
|
- The timeout is read from settings on every poll cycle, so changes take effect immediately
|
||||||
- When the timeout value is changed (e.g., reduced from 30 to 10 minutes), the system immediately checks for stuck tasks under the new timer rather than waiting for the next poll cycle
|
- When the timeout value is changed (e.g., reduced from 30 to 10 minutes), the system immediately checks for stuck tasks under the new timer rather than waiting for the next poll cycle
|
||||||
|
|
||||||
|
### `aiSessionTtlMs` (default: `604800000` / 7 days)
|
||||||
|
|
||||||
|
TTL in milliseconds for persisted AI planning, subtask breakdown, and mission interview sessions stored in `ai_sessions`.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- `AiSessionStore.cleanupOld(ttlMs)` treats sessions older than this value as expired
|
||||||
|
- Expired `generating`/`awaiting_input` sessions are first marked as `error` with "Session expired", then removed
|
||||||
|
- Expired `complete`/`error` sessions are removed directly
|
||||||
|
- In-memory session maps in planning/subtask/mission modules use the same 7-day TTL to avoid memory/SQLite mismatch
|
||||||
|
|
||||||
|
**Valid range:** `600000` (10 minutes) to `2592000000` (30 days)
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"aiSessionTtlMs": 604800000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Lower values clean up stale sessions faster but reduce how long users can resume old planning flows
|
||||||
|
- Higher values preserve session recovery longer at the cost of more rows in `ai_sessions`
|
||||||
|
|
||||||
|
### `aiSessionCleanupIntervalMs` (default: `3600000` / 1 hour)
|
||||||
|
|
||||||
|
Interval in milliseconds for scheduled SQLite-backed cleanup sweeps of `ai_sessions`.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- On server startup, `createServer()` reads this setting and starts `AiSessionStore.startScheduledCleanup(interval, ttl)`
|
||||||
|
- Each sweep runs `cleanupOld(aiSessionTtlMs)` using the configured TTL
|
||||||
|
- Cleanup is stopped on server shutdown via `AiSessionStore.stopScheduledCleanup()`
|
||||||
|
|
||||||
|
**Valid range:** `60000` (1 minute) to `86400000` (24 hours)
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"aiSessionCleanupIntervalMs": 3600000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Shorter intervals reduce stale-row buildup but increase cleanup query frequency
|
||||||
|
- Longer intervals reduce background work but allow expired rows to linger until the next sweep
|
||||||
|
|
||||||
### `runStepsInNewSessions` (default: `false`)
|
### `runStepsInNewSessions` (default: `false`)
|
||||||
|
|
||||||
When enabled, each task step runs in its own fresh agent session via `StepSessionExecutor` instead of a single monolithic session. This enables per-step error recovery with retry semantics and optional parallel execution for non-conflicting steps.
|
When enabled, each task step runs in its own fresh agent session via `StepSessionExecutor` instead of a single monolithic session. This enables per-step error recovery with retry semantics and optional parallel execution for non-conflicting steps.
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ describe("Database", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("seeds schema version", () => {
|
it("seeds schema version", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(14);
|
expect(db.getSchemaVersion()).toBe(15);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("seeds lastModified", () => {
|
it("seeds lastModified", () => {
|
||||||
@@ -119,7 +119,7 @@ describe("Database", () => {
|
|||||||
|
|
||||||
it("is idempotent - calling init() twice does not fail", () => {
|
it("is idempotent - calling init() twice does not fail", () => {
|
||||||
expect(() => db.init()).not.toThrow();
|
expect(() => db.init()).not.toThrow();
|
||||||
expect(db.getSchemaVersion()).toBe(14);
|
expect(db.getSchemaVersion()).toBe(15);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not overwrite existing config on re-init", () => {
|
it("does not overwrite existing config on re-init", () => {
|
||||||
@@ -726,7 +726,7 @@ describe("schema migrations", () => {
|
|||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
|
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
|
||||||
expect(db.getSchemaVersion()).toBe(14);
|
expect(db.getSchemaVersion()).toBe(15);
|
||||||
|
|
||||||
// Verify new columns exist and existing data is intact
|
// Verify new columns exist and existing data is intact
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
@@ -751,16 +751,16 @@ describe("schema migrations", () => {
|
|||||||
const db = new Database(kbDir);
|
const db = new Database(kbDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(14);
|
expect(db.getSchemaVersion()).toBe(15);
|
||||||
|
|
||||||
// Re-init should not fail
|
// Re-init should not fail
|
||||||
db.init();
|
db.init();
|
||||||
expect(db.getSchemaVersion()).toBe(14);
|
expect(db.getSchemaVersion()).toBe(15);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies migration 14 by creating agentRatings table and indexes", () => {
|
it("applies migration 14+15 by creating agentRatings and ai_sessions indexes", () => {
|
||||||
tmpDir = makeTmpDir();
|
tmpDir = makeTmpDir();
|
||||||
const kbDir = join(tmpDir, ".fusion");
|
const kbDir = join(tmpDir, ".fusion");
|
||||||
|
|
||||||
@@ -771,7 +771,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(14);
|
expect(db.getSchemaVersion()).toBe(15);
|
||||||
|
|
||||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||||
@@ -874,7 +874,7 @@ describe("schema migrations", () => {
|
|||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 5
|
// Verify version bumped to 5
|
||||||
expect(db.getSchemaVersion()).toBe(14);
|
expect(db.getSchemaVersion()).toBe(15);
|
||||||
|
|
||||||
// Verify new columns exist and existing data is intact
|
// Verify new columns exist and existing data is intact
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
@@ -1084,7 +1084,7 @@ describe("createDatabase factory", () => {
|
|||||||
const db = createDatabase(kbDir);
|
const db = createDatabase(kbDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(14);
|
expect(db.getSchemaVersion()).toBe(15);
|
||||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
|||||||
|
|
||||||
// ── Schema Definition ────────────────────────────────────────────────
|
// ── Schema Definition ────────────────────────────────────────────────
|
||||||
|
|
||||||
const SCHEMA_VERSION = 14;
|
const SCHEMA_VERSION = 15;
|
||||||
|
|
||||||
function normalizeTaskComments(
|
function normalizeTaskComments(
|
||||||
steeringComments: SteeringComment[] | undefined,
|
steeringComments: SteeringComment[] | undefined,
|
||||||
@@ -516,6 +516,14 @@ export class Database {
|
|||||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxAgentRatingsCreatedAt ON agentRatings(createdAt)`);
|
this.db.exec(`CREATE INDEX IF NOT EXISTS idxAgentRatingsCreatedAt ON agentRatings(createdAt)`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (version < 15) {
|
||||||
|
this.applyMigration(15, () => {
|
||||||
|
if (this.hasTable("ai_sessions")) {
|
||||||
|
this.db.exec(`CREATE INDEX IF NOT EXISTS idxAiSessionsUpdatedAt ON ai_sessions(updatedAt)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -530,6 +538,16 @@ export class Database {
|
|||||||
.run(String(targetVersion));
|
.run(String(targetVersion));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a table exists.
|
||||||
|
*/
|
||||||
|
private hasTable(table: string): boolean {
|
||||||
|
const row = this.db
|
||||||
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||||
|
.get(table) as { name: string } | undefined;
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether a table has a given column.
|
* Check whether a table has a given column.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -863,6 +863,15 @@ export interface ProjectSettings {
|
|||||||
* than this duration, the task is considered stuck and will be terminated and retried.
|
* than this duration, the task is considered stuck and will be terminated and retried.
|
||||||
* Default: undefined (disabled). Suggested value: 600000 (10 minutes). */
|
* Default: undefined (disabled). Suggested value: 600000 (10 minutes). */
|
||||||
taskStuckTimeoutMs?: number;
|
taskStuckTimeoutMs?: number;
|
||||||
|
/** TTL in milliseconds for persisted AI planning/subtask/mission interview sessions.
|
||||||
|
* Sessions older than this cutoff are expired by the dashboard session cleanup loop.
|
||||||
|
* Valid range: 600000 (10 minutes) to 2592000000 (30 days).
|
||||||
|
* Default: 604800000 (7 days). */
|
||||||
|
aiSessionTtlMs?: number;
|
||||||
|
/** Interval in milliseconds for scheduled AI session cleanup sweeps.
|
||||||
|
* Valid range: 60000 (1 minute) to 86400000 (24 hours).
|
||||||
|
* Default: 3600000 (1 hour). */
|
||||||
|
aiSessionCleanupIntervalMs?: number;
|
||||||
/** When true, automatically unpause after rate-limit-triggered globalPause using
|
/** When true, automatically unpause after rate-limit-triggered globalPause using
|
||||||
* escalating backoff. Allows unattended recovery from transient API rate limits.
|
* escalating backoff. Allows unattended recovery from transient API rate limits.
|
||||||
* Default: true. */
|
* Default: true. */
|
||||||
@@ -1035,6 +1044,8 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
|
|||||||
buildTimeoutMs: 300_000,
|
buildTimeoutMs: 300_000,
|
||||||
requirePlanApproval: false,
|
requirePlanApproval: false,
|
||||||
taskStuckTimeoutMs: undefined,
|
taskStuckTimeoutMs: undefined,
|
||||||
|
aiSessionTtlMs: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
aiSessionCleanupIntervalMs: 60 * 60 * 1000,
|
||||||
autoUnpauseEnabled: true,
|
autoUnpauseEnabled: true,
|
||||||
autoUnpauseBaseDelayMs: 300_000,
|
autoUnpauseBaseDelayMs: 300_000,
|
||||||
autoUnpauseMaxDelayMs: 3_600_000,
|
autoUnpauseMaxDelayMs: 3_600_000,
|
||||||
@@ -1126,6 +1137,8 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
|
|||||||
"smartConflictResolution",
|
"smartConflictResolution",
|
||||||
"requirePlanApproval",
|
"requirePlanApproval",
|
||||||
"taskStuckTimeoutMs",
|
"taskStuckTimeoutMs",
|
||||||
|
"aiSessionTtlMs",
|
||||||
|
"aiSessionCleanupIntervalMs",
|
||||||
"maxStuckKills",
|
"maxStuckKills",
|
||||||
"autoUpdatePrStatus",
|
"autoUpdatePrStatus",
|
||||||
"autoCreatePr",
|
"autoCreatePr",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { mkdtempSync } from "node:fs";
|
import { mkdtempSync } from "node:fs";
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -6,7 +6,7 @@ import { join } from "node:path";
|
|||||||
import { Database } from "@fusion/core";
|
import { Database } from "@fusion/core";
|
||||||
import { AiSessionStore, type AiSessionRow, type AiSessionStatus } from "./ai-session-store.js";
|
import { AiSessionStore, type AiSessionRow, type AiSessionStatus } from "./ai-session-store.js";
|
||||||
|
|
||||||
describe("AiSessionStore.listActive", () => {
|
describe("AiSessionStore", () => {
|
||||||
let tmpRoot: string;
|
let tmpRoot: string;
|
||||||
let db: Database;
|
let db: Database;
|
||||||
let store: AiSessionStore;
|
let store: AiSessionStore;
|
||||||
@@ -19,6 +19,8 @@ describe("AiSessionStore.listActive", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
store.stopScheduledCleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
try {
|
try {
|
||||||
db.close();
|
db.close();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -27,7 +29,7 @@ describe("AiSessionStore.listActive", () => {
|
|||||||
await rm(tmpRoot, { recursive: true, force: true });
|
await rm(tmpRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
function createSession(id: string, status: AiSessionStatus, projectId: string | null = null): AiSessionRow {
|
function makeRow(id: string, status: AiSessionStatus, projectId: string | null = null): AiSessionRow {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -46,32 +48,155 @@ describe("AiSessionStore.listActive", () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it("returns generating, awaiting_input, and complete sessions", () => {
|
function seedSession(params: {
|
||||||
store.upsert(createSession("S-1", "generating"));
|
id: string;
|
||||||
store.upsert(createSession("S-2", "awaiting_input"));
|
status: AiSessionStatus;
|
||||||
store.upsert(createSession("S-3", "complete"));
|
ageMs?: number;
|
||||||
store.upsert(createSession("S-4", "error"));
|
projectId?: string | null;
|
||||||
|
currentQuestion?: object | null;
|
||||||
|
error?: string | null;
|
||||||
|
}): void {
|
||||||
|
const { id, status, ageMs = 0, projectId = null, currentQuestion = null, error } = params;
|
||||||
|
const row = makeRow(id, status, projectId);
|
||||||
|
row.currentQuestion = currentQuestion ? JSON.stringify(currentQuestion) : null;
|
||||||
|
row.error = error ?? row.error;
|
||||||
|
store.upsert(row);
|
||||||
|
|
||||||
const active = store.listActive();
|
if (ageMs > 0) {
|
||||||
const statuses = active.map((session) => session.status).sort();
|
const staleTs = new Date(Date.now() - ageMs).toISOString();
|
||||||
|
db.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?").run(staleTs, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
expect(statuses).toEqual(["awaiting_input", "complete", "generating"]);
|
it("cleanupOld removes stale sessions across all statuses and emits deleted events", () => {
|
||||||
expect(active.map((session) => session.id)).toEqual(expect.arrayContaining(["S-1", "S-2", "S-3"]));
|
const deletedIds: string[] = [];
|
||||||
|
store.on("ai_session:deleted", (id) => deletedIds.push(id));
|
||||||
|
|
||||||
|
seedSession({ id: "S-complete", status: "complete", ageMs: 2 * 60 * 60 * 1000 });
|
||||||
|
seedSession({ id: "S-error", status: "error", ageMs: 2 * 60 * 60 * 1000 });
|
||||||
|
seedSession({ id: "S-generating", status: "generating", ageMs: 2 * 60 * 60 * 1000 });
|
||||||
|
seedSession({ id: "S-awaiting", status: "awaiting_input", ageMs: 2 * 60 * 60 * 1000 });
|
||||||
|
seedSession({ id: "S-fresh", status: "generating", ageMs: 5 * 60 * 1000 });
|
||||||
|
|
||||||
|
const removed = store.cleanupOld(60 * 60 * 1000);
|
||||||
|
|
||||||
|
expect(removed).toBe(4);
|
||||||
|
expect(store.get("S-complete")).toBeNull();
|
||||||
|
expect(store.get("S-error")).toBeNull();
|
||||||
|
expect(store.get("S-generating")).toBeNull();
|
||||||
|
expect(store.get("S-awaiting")).toBeNull();
|
||||||
|
expect(store.get("S-fresh")).not.toBeNull();
|
||||||
|
expect(deletedIds.sort()).toEqual(["S-awaiting", "S-complete", "S-error", "S-generating"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes sessions with error status", () => {
|
it("cleanupOld marks stale generating/awaiting_input sessions as error before delete", () => {
|
||||||
store.upsert(createSession("S-err", "error"));
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_session_status_audit (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
oldStatus TEXT NOT NULL,
|
||||||
|
newStatus TEXT NOT NULL,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_ai_sessions_mark_expired
|
||||||
|
AFTER UPDATE OF status ON ai_sessions
|
||||||
|
WHEN NEW.status = 'error' AND OLD.status IN ('generating', 'awaiting_input')
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO ai_session_status_audit (id, oldStatus, newStatus, error)
|
||||||
|
VALUES (NEW.id, OLD.status, NEW.status, NEW.error);
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
|
||||||
|
seedSession({ id: "S-generating", status: "generating", ageMs: 2 * 60 * 60 * 1000, error: null });
|
||||||
|
seedSession({ id: "S-awaiting", status: "awaiting_input", ageMs: 2 * 60 * 60 * 1000, error: null });
|
||||||
|
|
||||||
|
store.cleanupOld(60 * 60 * 1000);
|
||||||
|
|
||||||
|
const auditRows = db
|
||||||
|
.prepare("SELECT id, oldStatus, newStatus, error FROM ai_session_status_audit ORDER BY id")
|
||||||
|
.all() as Array<{ id: string; oldStatus: string; newStatus: string; error: string }>;
|
||||||
|
|
||||||
|
expect(auditRows).toEqual([
|
||||||
|
{
|
||||||
|
id: "S-awaiting",
|
||||||
|
oldStatus: "awaiting_input",
|
||||||
|
newStatus: "error",
|
||||||
|
error: "Session expired",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "S-generating",
|
||||||
|
oldStatus: "generating",
|
||||||
|
newStatus: "error",
|
||||||
|
error: "Session expired",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("startScheduledCleanup and stopScheduledCleanup control cleanup interval", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
seedSession({ id: "S-old", status: "complete", ageMs: 2 * 60 * 1000 });
|
||||||
|
|
||||||
|
store.startScheduledCleanup(1_000, 60_000);
|
||||||
|
vi.advanceTimersByTime(1_000);
|
||||||
|
|
||||||
|
expect(store.get("S-old")).toBeNull();
|
||||||
|
|
||||||
|
seedSession({ id: "S-old-2", status: "complete", ageMs: 2 * 60 * 1000 });
|
||||||
|
store.stopScheduledCleanup();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5_000);
|
||||||
|
expect(store.get("S-old-2")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports configurable TTL values", () => {
|
||||||
|
seedSession({ id: "S-older", status: "complete", ageMs: 2 * 60 * 60 * 1000 });
|
||||||
|
seedSession({ id: "S-recent", status: "complete", ageMs: 30 * 60 * 1000 });
|
||||||
|
|
||||||
|
const removedWithShortTtl = store.cleanupOld(60 * 60 * 1000);
|
||||||
|
|
||||||
|
expect(removedWithShortTtl).toBe(1);
|
||||||
|
expect(store.get("S-older")).toBeNull();
|
||||||
|
expect(store.get("S-recent")).not.toBeNull();
|
||||||
|
|
||||||
|
const removedWithLongTtl = store.cleanupOld(3 * 60 * 60 * 1000);
|
||||||
|
expect(removedWithLongTtl).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recoverStaleSessions keeps recoverable sessions and marks unrecoverable ones as error", () => {
|
||||||
|
seedSession({
|
||||||
|
id: "S-recoverable",
|
||||||
|
status: "generating",
|
||||||
|
currentQuestion: { id: "q-1", type: "text", question: "Continue?" },
|
||||||
|
});
|
||||||
|
seedSession({ id: "S-broken", status: "generating", currentQuestion: null });
|
||||||
|
|
||||||
|
const recovered = store.recoverStaleSessions();
|
||||||
|
|
||||||
|
expect(recovered).toBe(2);
|
||||||
|
expect(store.get("S-recoverable")?.status).toBe("awaiting_input");
|
||||||
|
expect(store.get("S-broken")?.status).toBe("error");
|
||||||
|
expect(store.get("S-broken")?.error).toBe("Session interrupted — please restart");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listActive only returns generating/awaiting_input sessions", () => {
|
||||||
|
seedSession({ id: "S-generating", status: "generating" });
|
||||||
|
seedSession({ id: "S-awaiting", status: "awaiting_input" });
|
||||||
|
seedSession({ id: "S-complete", status: "complete" });
|
||||||
|
seedSession({ id: "S-error", status: "error" });
|
||||||
|
|
||||||
const active = store.listActive();
|
const active = store.listActive();
|
||||||
|
|
||||||
expect(active).toEqual([]);
|
expect(active.map((session) => session.status).sort()).toEqual(["awaiting_input", "generating"]);
|
||||||
|
expect(active.map((session) => session.id).sort()).toEqual(["S-awaiting", "S-generating"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters active sessions by projectId", () => {
|
it("listActive filters by projectId", () => {
|
||||||
store.upsert(createSession("S-a1", "generating", "project-a"));
|
seedSession({ id: "S-a1", status: "generating", projectId: "project-a" });
|
||||||
store.upsert(createSession("S-a2", "complete", "project-a"));
|
seedSession({ id: "S-a2", status: "awaiting_input", projectId: "project-a" });
|
||||||
store.upsert(createSession("S-b1", "awaiting_input", "project-b"));
|
seedSession({ id: "S-b1", status: "awaiting_input", projectId: "project-b" });
|
||||||
store.upsert(createSession("S-none", "complete", null));
|
seedSession({ id: "S-a-done", status: "complete", projectId: "project-a" });
|
||||||
|
|
||||||
const projectA = store.listActive("project-a");
|
const projectA = store.listActive("project-a");
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ const THINKING_DEBOUNCE_MS = 2000;
|
|||||||
export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||||
/** Pending debounce timers for thinking-only writes, keyed by session id. */
|
/** Pending debounce timers for thinking-only writes, keyed by session id. */
|
||||||
private thinkingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
private thinkingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
/** Interval used for periodic stale-session cleanup. */
|
||||||
|
private cleanupTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
constructor(private db: Database) {
|
constructor(private db: Database) {
|
||||||
super();
|
super();
|
||||||
@@ -144,7 +146,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List active sessions (generating, awaiting_input, or complete).
|
* List active sessions (generating or awaiting_input).
|
||||||
* Optionally filtered by projectId.
|
* Optionally filtered by projectId.
|
||||||
*/
|
*/
|
||||||
listActive(projectId?: string): AiSessionSummary[] {
|
listActive(projectId?: string): AiSessionSummary[] {
|
||||||
@@ -152,7 +154,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
|||||||
return this.db
|
return this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, type, status, title, projectId, updatedAt FROM ai_sessions
|
`SELECT id, type, status, title, projectId, updatedAt FROM ai_sessions
|
||||||
WHERE status IN ('generating', 'awaiting_input', 'complete') AND projectId = ?
|
WHERE status IN ('generating', 'awaiting_input') AND projectId = ?
|
||||||
ORDER BY updatedAt DESC`,
|
ORDER BY updatedAt DESC`,
|
||||||
)
|
)
|
||||||
.all(projectId) as unknown as AiSessionSummary[];
|
.all(projectId) as unknown as AiSessionSummary[];
|
||||||
@@ -160,7 +162,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
|||||||
return this.db
|
return this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, type, status, title, projectId, updatedAt FROM ai_sessions
|
`SELECT id, type, status, title, projectId, updatedAt FROM ai_sessions
|
||||||
WHERE status IN ('generating', 'awaiting_input', 'complete')
|
WHERE status IN ('generating', 'awaiting_input')
|
||||||
ORDER BY updatedAt DESC`,
|
ORDER BY updatedAt DESC`,
|
||||||
)
|
)
|
||||||
.all() as unknown as AiSessionSummary[];
|
.all() as unknown as AiSessionSummary[];
|
||||||
@@ -209,16 +211,88 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up completed/error sessions older than the given age (ms).
|
* Clean up stale sessions older than the given age (ms).
|
||||||
|
*
|
||||||
|
* For stale in-progress sessions (`generating`, `awaiting_input`), status is first
|
||||||
|
* transitioned to `error` with a "Session expired" marker before deletion.
|
||||||
|
* Returns the number of deleted sessions.
|
||||||
*/
|
*/
|
||||||
cleanupOld(maxAgeMs: number): number {
|
cleanupOld(maxAgeMs: number): number {
|
||||||
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
|
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
|
||||||
const result = this.db
|
|
||||||
|
const stale = this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`DELETE FROM ai_sessions WHERE status IN ('complete', 'error') AND updatedAt < ?`,
|
`SELECT id FROM ai_sessions
|
||||||
|
WHERE updatedAt < ?
|
||||||
|
AND status IN ('complete', 'error', 'generating', 'awaiting_input')`,
|
||||||
)
|
)
|
||||||
.run(cutoff);
|
.all(cutoff) as Array<{ id: string }>;
|
||||||
return Number((result as any).changes ?? 0);
|
|
||||||
|
if (stale.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.db.transaction(() => {
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE ai_sessions
|
||||||
|
SET status = 'error',
|
||||||
|
error = CASE
|
||||||
|
WHEN error IS NULL OR error = '' THEN 'Session expired'
|
||||||
|
ELSE error
|
||||||
|
END
|
||||||
|
WHERE updatedAt < ?
|
||||||
|
AND status IN ('generating', 'awaiting_input')`,
|
||||||
|
)
|
||||||
|
.run(cutoff);
|
||||||
|
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`DELETE FROM ai_sessions
|
||||||
|
WHERE updatedAt < ?
|
||||||
|
AND status IN ('complete', 'error', 'generating', 'awaiting_input')`,
|
||||||
|
)
|
||||||
|
.run(cutoff);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { id } of stale) {
|
||||||
|
this.clearThinkingTimer(id);
|
||||||
|
this.emit("ai_session:deleted", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stale.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start periodic stale-session cleanup using the provided schedule and TTL.
|
||||||
|
*/
|
||||||
|
startScheduledCleanup(cleanupIntervalMs: number, ttlMs: number): void {
|
||||||
|
this.stopScheduledCleanup();
|
||||||
|
|
||||||
|
const runCleanup = () => {
|
||||||
|
try {
|
||||||
|
const deleted = this.cleanupOld(ttlMs);
|
||||||
|
if (deleted > 0) {
|
||||||
|
console.log(`[ai-session-store] Cleaned up ${deleted} stale sessions`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ai-session-store] Scheduled cleanup failed:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cleanupTimer = setInterval(runCleanup, cleanupIntervalMs);
|
||||||
|
this.cleanupTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop periodic stale-session cleanup if currently running.
|
||||||
|
*/
|
||||||
|
stopScheduledCleanup(): void {
|
||||||
|
if (!this.cleanupTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearInterval(this.cleanupTimer);
|
||||||
|
this.cleanupTimer = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Internal ────────────────────────────────────────────────────────
|
// ── Internal ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ const engineReady = initEngine();
|
|||||||
|
|
||||||
// ── Constants ───────────────────────────────────────────────────────────────
|
// ── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Session TTL in milliseconds (30 minutes) */
|
/** Session TTL in milliseconds (7 days) */
|
||||||
const SESSION_TTL_MS = 30 * 60 * 1000;
|
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
/** Cleanup interval in milliseconds (5 minutes) */
|
/** Cleanup interval in milliseconds (5 minutes) */
|
||||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
@@ -182,9 +182,34 @@ const rateLimits = new Map<string, RateLimitEntry>();
|
|||||||
// ── AI Session Persistence ────────────────────────────────────────────────
|
// ── AI Session Persistence ────────────────────────────────────────────────
|
||||||
|
|
||||||
let _aiSessionStore: AiSessionStore | undefined;
|
let _aiSessionStore: AiSessionStore | undefined;
|
||||||
|
let _aiSessionDeletedListener: ((sessionId: string) => void) | undefined;
|
||||||
|
|
||||||
export function setAiSessionStore(store: AiSessionStore): void {
|
export function setAiSessionStore(store: AiSessionStore): void {
|
||||||
|
if (_aiSessionStore && _aiSessionDeletedListener) {
|
||||||
|
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);
|
||||||
|
}
|
||||||
|
|
||||||
_aiSessionStore = store;
|
_aiSessionStore = store;
|
||||||
|
_aiSessionDeletedListener = (sessionId: string) => {
|
||||||
|
cleanupInMemoryMissionSession(sessionId);
|
||||||
|
};
|
||||||
|
_aiSessionStore.on("ai_session:deleted", _aiSessionDeletedListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupInMemoryMissionSession(sessionId: string): boolean {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.agent) {
|
||||||
|
try { session.agent.session.dispose?.(); } catch { /* ignore */ }
|
||||||
|
session.agent = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
missionInterviewStreamManager.cleanupSession(sessionId);
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistMissionSession(session: MissionInterviewSession, status: "generating" | "awaiting_input" | "complete" | "error", error?: string): void {
|
function persistMissionSession(session: MissionInterviewSession, status: "generating" | "awaiting_input" | "complete" | "error", error?: string): void {
|
||||||
@@ -223,11 +248,7 @@ function cleanupExpiredSessions(): void {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [id, session] of sessions) {
|
for (const [id, session] of sessions) {
|
||||||
if (now - session.updatedAt.getTime() > SESSION_TTL_MS) {
|
if (now - session.updatedAt.getTime() > SESSION_TTL_MS) {
|
||||||
if (session.agent) {
|
cleanupInMemoryMissionSession(id);
|
||||||
try { session.agent.session.dispose?.(); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
missionInterviewStreamManager.cleanupSession(id);
|
|
||||||
sessions.delete(id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [ip, entry] of rateLimits) {
|
for (const [ip, entry] of rateLimits) {
|
||||||
@@ -798,18 +819,11 @@ export async function submitMissionInterviewResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelMissionInterviewSession(sessionId: string): Promise<void> {
|
export async function cancelMissionInterviewSession(sessionId: string): Promise<void> {
|
||||||
const session = sessions.get(sessionId);
|
const removed = cleanupInMemoryMissionSession(sessionId);
|
||||||
if (!session) {
|
if (!removed) {
|
||||||
throw new SessionNotFoundError(`Mission interview session ${sessionId} not found or expired`);
|
throw new SessionNotFoundError(`Mission interview session ${sessionId} not found or expired`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.agent) {
|
|
||||||
try { session.agent.session.dispose?.(); } catch { /* ignore */ }
|
|
||||||
session.agent = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
missionInterviewStreamManager.cleanupSession(sessionId);
|
|
||||||
sessions.delete(sessionId);
|
|
||||||
unpersistMissionSession(sessionId);
|
unpersistMissionSession(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,12 +836,7 @@ export function getMissionInterviewSummary(sessionId: string): MissionPlanSummar
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function cleanupMissionInterviewSession(sessionId: string): void {
|
export function cleanupMissionInterviewSession(sessionId: string): void {
|
||||||
const session = sessions.get(sessionId);
|
cleanupInMemoryMissionSession(sessionId);
|
||||||
if (session?.agent) {
|
|
||||||
try { session.agent.session.dispose?.(); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
missionInterviewStreamManager.cleanupSession(sessionId);
|
|
||||||
sessions.delete(sessionId);
|
|
||||||
unpersistMissionSession(sessionId);
|
unpersistMissionSession(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,14 +844,18 @@ export function cleanupMissionInterviewSession(sessionId: string): void {
|
|||||||
* Reset all mission interview state. Used for testing only.
|
* Reset all mission interview state. Used for testing only.
|
||||||
*/
|
*/
|
||||||
export function __resetMissionInterviewState(): void {
|
export function __resetMissionInterviewState(): void {
|
||||||
for (const [, session] of sessions) {
|
for (const [id] of sessions) {
|
||||||
if (session.agent) {
|
cleanupInMemoryMissionSession(id);
|
||||||
try { session.agent.session.dispose?.(); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sessions.clear();
|
sessions.clear();
|
||||||
rateLimits.clear();
|
rateLimits.clear();
|
||||||
missionInterviewStreamManager.reset();
|
missionInterviewStreamManager.reset();
|
||||||
|
|
||||||
|
if (_aiSessionStore && _aiSessionDeletedListener) {
|
||||||
|
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);
|
||||||
|
}
|
||||||
|
_aiSessionDeletedListener = undefined;
|
||||||
|
_aiSessionStore = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Custom Errors ───────────────────────────────────────────────────────────
|
// ── Custom Errors ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
parseAgentResponse,
|
parseAgentResponse,
|
||||||
generateSubtasksFromPlanning,
|
generateSubtasksFromPlanning,
|
||||||
formatInterviewQA,
|
formatInterviewQA,
|
||||||
|
SESSION_TTL_MS,
|
||||||
} from "./planning.js";
|
} from "./planning.js";
|
||||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||||
|
|
||||||
@@ -483,23 +484,20 @@ describe("planning module", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("session TTL", () => {
|
describe("session TTL", () => {
|
||||||
it("sessions expire after TTL", async () => {
|
it("uses a 7-day TTL constant", () => {
|
||||||
|
expect(SESSION_TTL_MS).toBe(7 * 24 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expire sessions within the old 30-minute window", async () => {
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
try {
|
try {
|
||||||
const mockIp = getUniqueIp();
|
const mockIp = getUniqueIp();
|
||||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||||
|
|
||||||
// Verify session exists
|
// Advance beyond the old 30-minute TTL used prior to FN-1146.
|
||||||
expect(getSession(sessionId)).toBeDefined();
|
|
||||||
|
|
||||||
// Advance time by 31 minutes
|
|
||||||
vi.advanceTimersByTime(31 * 60 * 1000);
|
vi.advanceTimersByTime(31 * 60 * 1000);
|
||||||
|
|
||||||
// Trigger cleanup by creating a new session
|
expect(getSession(sessionId)).toBeDefined();
|
||||||
await createSession(getUniqueIp(), "Another plan", undefined, TEST_ROOT_DIR);
|
|
||||||
|
|
||||||
// Note: Session should be expired after cleanup runs
|
|
||||||
// We can't directly verify as cleanup is async
|
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ For questions:
|
|||||||
For completion:
|
For completion:
|
||||||
{\n "type": "complete",\n "data": {\n "title": "Task title",\n "description": "Detailed description",\n "suggestedSize": "S|M|L",\n "suggestedDependencies": [],\n "keyDeliverables": ["Item 1", "Item 2"]\n }\n}`;
|
{\n "type": "complete",\n "data": {\n "title": "Task title",\n "description": "Detailed description",\n "suggestedSize": "S|M|L",\n "suggestedDependencies": [],\n "keyDeliverables": ["Item 1", "Item 2"]\n }\n}`;
|
||||||
|
|
||||||
/** Session TTL in milliseconds (30 minutes) */
|
/** Session TTL in milliseconds (7 days) */
|
||||||
const SESSION_TTL_MS = 30 * 60 * 1000;
|
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
/** Cleanup interval in milliseconds (5 minutes) */
|
/** Cleanup interval in milliseconds (5 minutes) */
|
||||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
@@ -154,10 +154,39 @@ const rateLimits = new Map<string, RateLimitEntry>();
|
|||||||
|
|
||||||
/** Optional store for persisting session state across reloads/browsers. */
|
/** Optional store for persisting session state across reloads/browsers. */
|
||||||
let _aiSessionStore: AiSessionStore | undefined;
|
let _aiSessionStore: AiSessionStore | undefined;
|
||||||
|
let _aiSessionDeletedListener: ((sessionId: string) => void) | undefined;
|
||||||
|
|
||||||
/** Wire up the AI session persistence store. Called once from server.ts. */
|
/** Wire up the AI session persistence store. Called once from server.ts. */
|
||||||
export function setAiSessionStore(store: AiSessionStore): void {
|
export function setAiSessionStore(store: AiSessionStore): void {
|
||||||
|
if (_aiSessionStore && _aiSessionDeletedListener) {
|
||||||
|
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);
|
||||||
|
}
|
||||||
|
|
||||||
_aiSessionStore = store;
|
_aiSessionStore = store;
|
||||||
|
_aiSessionDeletedListener = (sessionId: string) => {
|
||||||
|
cleanupInMemorySession(sessionId);
|
||||||
|
};
|
||||||
|
_aiSessionStore.on("ai_session:deleted", _aiSessionDeletedListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupInMemorySession(sessionId: string): boolean {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.agent) {
|
||||||
|
try {
|
||||||
|
session.agent.session.dispose?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[planning] Error disposing agent for session ${sessionId}:`, err);
|
||||||
|
}
|
||||||
|
session.agent = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
planningStreamManager.cleanupSession(sessionId);
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist the current session state to SQLite (no-op if store not wired). */
|
/** Persist the current session state to SQLite (no-op if store not wired). */
|
||||||
@@ -207,8 +236,9 @@ function cleanupExpiredSessions(): void {
|
|||||||
// Clean up expired sessions
|
// Clean up expired sessions
|
||||||
for (const [id, session] of sessions) {
|
for (const [id, session] of sessions) {
|
||||||
if (now - session.updatedAt.getTime() > SESSION_TTL_MS) {
|
if (now - session.updatedAt.getTime() > SESSION_TTL_MS) {
|
||||||
sessions.delete(id);
|
if (cleanupInMemorySession(id)) {
|
||||||
cleanedSessions++;
|
cleanedSessions++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1144,25 +1174,11 @@ export function formatInterviewQA(
|
|||||||
* Cancel and cleanup a planning session.
|
* Cancel and cleanup a planning session.
|
||||||
*/
|
*/
|
||||||
export async function cancelSession(sessionId: string): Promise<void> {
|
export async function cancelSession(sessionId: string): Promise<void> {
|
||||||
const session = sessions.get(sessionId);
|
const removed = cleanupInMemorySession(sessionId);
|
||||||
if (!session) {
|
if (!removed) {
|
||||||
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
|
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup AI agent if present
|
|
||||||
if (session.agent) {
|
|
||||||
try {
|
|
||||||
session.agent.session.dispose?.();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[planning] Error disposing agent for session ${sessionId}:`, err);
|
|
||||||
}
|
|
||||||
session.agent = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup SSE subscriptions
|
|
||||||
planningStreamManager.cleanupSession(sessionId);
|
|
||||||
|
|
||||||
sessions.delete(sessionId);
|
|
||||||
unpersistSession(sessionId);
|
unpersistSession(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1250,16 +1266,7 @@ export function generateSubtasksFromPlanning(sessionId: string): SubtaskItem[] {
|
|||||||
* Cleanup a session (used after task creation).
|
* Cleanup a session (used after task creation).
|
||||||
*/
|
*/
|
||||||
export function cleanupSession(sessionId: string): void {
|
export function cleanupSession(sessionId: string): void {
|
||||||
const session = sessions.get(sessionId);
|
cleanupInMemorySession(sessionId);
|
||||||
if (session?.agent) {
|
|
||||||
try {
|
|
||||||
session.agent.session.dispose?.();
|
|
||||||
} catch {
|
|
||||||
// Ignore errors during cleanup
|
|
||||||
}
|
|
||||||
}
|
|
||||||
planningStreamManager.cleanupSession(sessionId);
|
|
||||||
sessions.delete(sessionId);
|
|
||||||
unpersistSession(sessionId);
|
unpersistSession(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1268,18 +1275,18 @@ export function cleanupSession(sessionId: string): void {
|
|||||||
*/
|
*/
|
||||||
export function __resetPlanningState(): void {
|
export function __resetPlanningState(): void {
|
||||||
// Cleanup all agent sessions
|
// Cleanup all agent sessions
|
||||||
for (const [id, session] of sessions) {
|
for (const [id] of sessions) {
|
||||||
if (session.agent) {
|
cleanupInMemorySession(id);
|
||||||
try {
|
|
||||||
session.agent.session.dispose?.();
|
|
||||||
} catch {
|
|
||||||
// Ignore errors during cleanup
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sessions.clear();
|
sessions.clear();
|
||||||
rateLimits.clear();
|
rateLimits.clear();
|
||||||
planningStreamManager.reset();
|
planningStreamManager.reset();
|
||||||
|
|
||||||
|
if (_aiSessionStore && _aiSessionDeletedListener) {
|
||||||
|
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);
|
||||||
|
}
|
||||||
|
_aiSessionDeletedListener = undefined;
|
||||||
|
_aiSessionStore = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2111,7 +2111,7 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
|
|||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
expect(res.body.error).toBe("Agent not found");
|
expect(res.body.error).toBe("Agent not found");
|
||||||
expect(store.listTasks).not.toHaveBeenCalled();
|
expect(store.listTasks).not.toHaveBeenCalled();
|
||||||
});
|
}, 30_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Attachment routes", () => {
|
describe("Attachment routes", () => {
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ import { setAiSessionStore as setMissionAiSessionStore } from "./mission-intervi
|
|||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const DEFAULT_AI_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const MIN_AI_SESSION_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const MAX_AI_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const DEFAULT_AI_SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
||||||
|
const MIN_AI_SESSION_CLEANUP_INTERVAL_MS = 60 * 1000;
|
||||||
|
const MAX_AI_SESSION_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
export interface ServerOptions {
|
export interface ServerOptions {
|
||||||
/** Custom merge handler — when provided, used instead of store.mergeTask */
|
/** Custom merge handler — when provided, used instead of store.mergeTask */
|
||||||
onMerge?: (taskId: string) => Promise<MergeResult>;
|
onMerge?: (taskId: string) => Promise<MergeResult>;
|
||||||
@@ -97,6 +105,18 @@ function normalizeListenArgsForTests(args: unknown[]): unknown[] {
|
|||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveBoundedMs(
|
||||||
|
value: number | undefined,
|
||||||
|
fallback: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): number {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
export function createServer(store: TaskStore, options?: ServerOptions): ReturnType<typeof express> {
|
export function createServer(store: TaskStore, options?: ServerOptions): ReturnType<typeof express> {
|
||||||
const app = express();
|
const app = express();
|
||||||
const mutationRateLimit = rateLimit(RATE_LIMITS.mutation);
|
const mutationRateLimit = rateLimit(RATE_LIMITS.mutation);
|
||||||
@@ -302,6 +322,39 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
setSubtaskAiSessionStore(aiSessionStore);
|
setSubtaskAiSessionStore(aiSessionStore);
|
||||||
setMissionAiSessionStore(aiSessionStore);
|
setMissionAiSessionStore(aiSessionStore);
|
||||||
|
|
||||||
|
const loadSettings = (store as { getSettings?: () => Promise<{ aiSessionTtlMs?: number; aiSessionCleanupIntervalMs?: number }> }).getSettings;
|
||||||
|
if (typeof loadSettings === "function") {
|
||||||
|
void loadSettings
|
||||||
|
.call(store)
|
||||||
|
.then((settings) => {
|
||||||
|
const ttlMs = resolveBoundedMs(
|
||||||
|
settings.aiSessionTtlMs,
|
||||||
|
DEFAULT_AI_SESSION_TTL_MS,
|
||||||
|
MIN_AI_SESSION_TTL_MS,
|
||||||
|
MAX_AI_SESSION_TTL_MS,
|
||||||
|
);
|
||||||
|
const cleanupIntervalMs = resolveBoundedMs(
|
||||||
|
settings.aiSessionCleanupIntervalMs,
|
||||||
|
DEFAULT_AI_SESSION_CLEANUP_INTERVAL_MS,
|
||||||
|
MIN_AI_SESSION_CLEANUP_INTERVAL_MS,
|
||||||
|
MAX_AI_SESSION_CLEANUP_INTERVAL_MS,
|
||||||
|
);
|
||||||
|
aiSessionStore.startScheduledCleanup(cleanupIntervalMs, ttlMs);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn("[server] Failed to load settings for AI session cleanup; using defaults", err);
|
||||||
|
aiSessionStore.startScheduledCleanup(
|
||||||
|
DEFAULT_AI_SESSION_CLEANUP_INTERVAL_MS,
|
||||||
|
DEFAULT_AI_SESSION_TTL_MS,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
aiSessionStore.startScheduledCleanup(
|
||||||
|
DEFAULT_AI_SESSION_CLEANUP_INTERVAL_MS,
|
||||||
|
DEFAULT_AI_SESSION_TTL_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// REST API
|
// REST API
|
||||||
app.use("/api", createApiRoutes(store, { ...options, aiSessionStore }));
|
app.use("/api", createApiRoutes(store, { ...options, aiSessionStore }));
|
||||||
|
|
||||||
@@ -340,6 +393,10 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
const normalizedArgs = normalizeListenArgsForTests(args) as Parameters<typeof originalListen>;
|
const normalizedArgs = normalizeListenArgsForTests(args) as Parameters<typeof originalListen>;
|
||||||
const server = originalListen(...normalizedArgs);
|
const server = originalListen(...normalizedArgs);
|
||||||
|
|
||||||
|
server.once("close", () => {
|
||||||
|
aiSessionStore.stopScheduledCleanup();
|
||||||
|
});
|
||||||
|
|
||||||
if (!dashboardApp.__kbWebSocketsAttached) {
|
if (!dashboardApp.__kbWebSocketsAttached) {
|
||||||
dashboardApp.__kbWebSocketsAttached = true;
|
dashboardApp.__kbWebSocketsAttached = true;
|
||||||
setupTerminalWebSocket(dashboardApp, server);
|
setupTerminalWebSocket(dashboardApp, server);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export type SubtaskStreamEvent =
|
|||||||
|
|
||||||
export type SubtaskStreamCallback = (event: SubtaskStreamEvent, eventId?: number) => void;
|
export type SubtaskStreamCallback = (event: SubtaskStreamEvent, eventId?: number) => void;
|
||||||
|
|
||||||
const SESSION_TTL_MS = 30 * 60 * 1000;
|
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
const sessions = new Map<string, SubtaskSession & { updatedAt: Date; agent?: any; thinkingOutput: string }>();
|
const sessions = new Map<string, SubtaskSession & { updatedAt: Date; agent?: any; thinkingOutput: string }>();
|
||||||
@@ -54,13 +54,39 @@ const sessions = new Map<string, SubtaskSession & { updatedAt: Date; agent?: any
|
|||||||
// ── AI Session Persistence ────────────────────────────────────────────────
|
// ── AI Session Persistence ────────────────────────────────────────────────
|
||||||
|
|
||||||
let _aiSessionStore: AiSessionStore | undefined;
|
let _aiSessionStore: AiSessionStore | undefined;
|
||||||
|
let _aiSessionDeletedListener: ((sessionId: string) => void) | undefined;
|
||||||
|
|
||||||
export function setAiSessionStore(store: AiSessionStore): void {
|
export function setAiSessionStore(store: AiSessionStore): void {
|
||||||
|
if (_aiSessionStore && _aiSessionDeletedListener) {
|
||||||
|
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);
|
||||||
|
}
|
||||||
|
|
||||||
_aiSessionStore = store;
|
_aiSessionStore = store;
|
||||||
|
_aiSessionDeletedListener = (sessionId: string) => {
|
||||||
|
cleanupInMemorySubtaskSession(sessionId);
|
||||||
|
};
|
||||||
|
_aiSessionStore.on("ai_session:deleted", _aiSessionDeletedListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubtaskInternalSession = SubtaskSession & { updatedAt: Date; agent?: any; thinkingOutput: string };
|
type SubtaskInternalSession = SubtaskSession & { updatedAt: Date; agent?: any; thinkingOutput: string };
|
||||||
|
|
||||||
|
function cleanupInMemorySubtaskSession(sessionId: string): boolean {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
session.agent?.session?.dispose?.();
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
|
||||||
|
subtaskStreamManager.cleanupSession(sessionId);
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function persistSubtaskSession(session: SubtaskInternalSession, status: "generating" | "complete" | "error", error?: string): void {
|
function persistSubtaskSession(session: SubtaskInternalSession, status: "generating" | "complete" | "error", error?: string): void {
|
||||||
if (!_aiSessionStore) return;
|
if (!_aiSessionStore) return;
|
||||||
const row: AiSessionRow = {
|
const row: AiSessionRow = {
|
||||||
@@ -125,13 +151,7 @@ function cleanupExpiredSessions(): void {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [id, session] of sessions) {
|
for (const [id, session] of sessions) {
|
||||||
if (now - session.updatedAt.getTime() > SESSION_TTL_MS) {
|
if (now - session.updatedAt.getTime() > SESSION_TTL_MS) {
|
||||||
try {
|
cleanupInMemorySubtaskSession(id);
|
||||||
session.agent?.session?.dispose?.();
|
|
||||||
} catch {
|
|
||||||
// ignore cleanup failures
|
|
||||||
}
|
|
||||||
sessions.delete(id);
|
|
||||||
subtaskStreamManager.cleanupSession(id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,42 +387,30 @@ export function getSubtaskSession(sessionId: string): SubtaskSession | undefined
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelSubtaskSession(sessionId: string): Promise<void> {
|
export async function cancelSubtaskSession(sessionId: string): Promise<void> {
|
||||||
const session = sessions.get(sessionId);
|
const removed = cleanupInMemorySubtaskSession(sessionId);
|
||||||
if (!session) {
|
if (!removed) {
|
||||||
throw new SessionNotFoundError(`Subtask session ${sessionId} not found or expired`);
|
throw new SessionNotFoundError(`Subtask session ${sessionId} not found or expired`);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
session.agent?.session?.dispose?.();
|
|
||||||
} catch {
|
|
||||||
// ignore dispose errors
|
|
||||||
}
|
|
||||||
subtaskStreamManager.cleanupSession(sessionId);
|
|
||||||
sessions.delete(sessionId);
|
|
||||||
unpersistSubtaskSession(sessionId);
|
unpersistSubtaskSession(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanupSubtaskSession(sessionId: string): void {
|
export function cleanupSubtaskSession(sessionId: string): void {
|
||||||
const session = sessions.get(sessionId);
|
cleanupInMemorySubtaskSession(sessionId);
|
||||||
try {
|
|
||||||
session?.agent?.session?.dispose?.();
|
|
||||||
} catch {
|
|
||||||
// ignore cleanup errors
|
|
||||||
}
|
|
||||||
subtaskStreamManager.cleanupSession(sessionId);
|
|
||||||
sessions.delete(sessionId);
|
|
||||||
unpersistSubtaskSession(sessionId);
|
unpersistSubtaskSession(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function __resetSubtaskBreakdownState(): void {
|
export function __resetSubtaskBreakdownState(): void {
|
||||||
for (const [, session] of sessions) {
|
for (const [id] of sessions) {
|
||||||
try {
|
cleanupInMemorySubtaskSession(id);
|
||||||
session.agent?.session?.dispose?.();
|
|
||||||
} catch {
|
|
||||||
// ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sessions.clear();
|
sessions.clear();
|
||||||
subtaskStreamManager.reset();
|
subtaskStreamManager.reset();
|
||||||
|
|
||||||
|
if (_aiSessionStore && _aiSessionDeletedListener) {
|
||||||
|
_aiSessionStore.off("ai_session:deleted", _aiSessionDeletedListener);
|
||||||
|
}
|
||||||
|
_aiSessionDeletedListener = undefined;
|
||||||
|
_aiSessionStore = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SessionNotFoundError extends Error {
|
export class SessionNotFoundError extends Error {
|
||||||
|
|||||||
Reference in New Issue
Block a user