diff --git a/libs/hooks/session-state.ts b/libs/hooks/session-state.ts index 763d86b..41d66b4 100644 --- a/libs/hooks/session-state.ts +++ b/libs/hooks/session-state.ts @@ -30,6 +30,7 @@ export class HookSessionStore { cwd: input.cwd ?? null, guidance_injected_at: shouldInjectGuidance ? now : null, stops_since_memory_current: incrementStop, + first_seen_at: now, last_seen_at: now, last_memory_current_at: null, }; @@ -93,10 +94,10 @@ export class HookSessionStore { .prepare( `INSERT INTO agent_sessions ( platform, session_id, repo_id, transcript_path, cwd, guidance_injected_at, - stops_since_memory_current, last_seen_at, last_memory_current_at + stops_since_memory_current, first_seen_at, last_seen_at, last_memory_current_at ) VALUES ( @platform, @session_id, @repo_id, @transcript_path, @cwd, @guidance_injected_at, - @stops_since_memory_current, @last_seen_at, @last_memory_current_at + @stops_since_memory_current, @first_seen_at, @last_seen_at, @last_memory_current_at )`, ) .run(session); @@ -130,15 +131,16 @@ export function shouldAttemptUpdate( } const lastCurrentAt = parseTime(session.last_memory_current_at); - const lastSeenAt = parseTime(session.last_seen_at); - if (lastSeenAt === undefined) return undefined; - if (lastCurrentAt === undefined) { - return now.getTime() - lastSeenAt.getTime() >= thresholds.timeAttemptIntervalMs + const firstSeenAt = parseTime(session.first_seen_at); + if (firstSeenAt === undefined) return undefined; + return now.getTime() - firstSeenAt.getTime() >= thresholds.timeAttemptIntervalMs ? "time_threshold" : undefined; } + const lastSeenAt = parseTime(session.last_seen_at); + if (lastSeenAt === undefined) return undefined; if (lastSeenAt.getTime() <= lastCurrentAt.getTime() + thresholds.memoryCurrentGraceMs) return undefined; return now.getTime() - lastCurrentAt.getTime() >= thresholds.timeAttemptIntervalMs ? "time_threshold" : undefined; } diff --git a/libs/hooks/types.ts b/libs/hooks/types.ts index f857f67..7d4ae72 100644 --- a/libs/hooks/types.ts +++ b/libs/hooks/types.ts @@ -8,6 +8,7 @@ export interface AgentSession { cwd: string | null; guidance_injected_at: string | null; stops_since_memory_current: number; + first_seen_at: string; last_seen_at: string; last_memory_current_at: string | null; } diff --git a/libs/storage/sqlite/migrate.ts b/libs/storage/sqlite/migrate.ts index cb3f1ad..3341807 100644 --- a/libs/storage/sqlite/migrate.ts +++ b/libs/storage/sqlite/migrate.ts @@ -7,6 +7,7 @@ export function migrate(db: Database.Database): void { migrateClaimsTable(db); migrateGraphObjectTables(db); migrateClaimAnchorFingerprints(db); + migrateAgentSessionsTable(db); } function migrateReposTable(db: Database.Database): void { @@ -200,3 +201,15 @@ function migrateClaimAnchorFingerprints(db: Database.Database): void { throw error; } } + +function migrateAgentSessionsTable(db: Database.Database): void { + const columns = db.prepare("PRAGMA table_info(agent_sessions)").all() as Array<{ name: string }>; + if (!columns.some((column) => column.name === "first_seen_at")) { + try { + db.exec("ALTER TABLE agent_sessions ADD COLUMN first_seen_at TEXT"); + } catch (error: unknown) { + if (!(error instanceof Error && /duplicate column name/i.test(error.message))) throw error; + } + } + db.exec("UPDATE agent_sessions SET first_seen_at = last_seen_at WHERE first_seen_at IS NULL"); +} diff --git a/libs/storage/sqlite/schema.ts b/libs/storage/sqlite/schema.ts index 490c556..aec9fc8 100644 --- a/libs/storage/sqlite/schema.ts +++ b/libs/storage/sqlite/schema.ts @@ -104,6 +104,7 @@ CREATE TABLE IF NOT EXISTS agent_sessions ( cwd TEXT, guidance_injected_at TEXT, stops_since_memory_current INTEGER NOT NULL DEFAULT 0, + first_seen_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, last_memory_current_at TEXT, PRIMARY KEY(platform, session_id) diff --git a/package.json b/package.json index cccc5eb..03e3e72 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "smoke:openhands": "npm run build && node scripts/smoke-openhands-install.mjs", "smoke:copilot": "npm run build && node scripts/smoke-copilot-install.mjs", "smoke:opencode": "npm run build && node scripts/smoke-opencode-install.mjs", - "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js", + "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-hook-session-state.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js", "test:transcript-bundle": "npm run build && node scripts/check-transcript-bundle.js", "test:repo-context": "npm run build && node scripts/check-repo-context.js", "eval:bootstrap-current": "npm run build && node dist/evals/cases/bootstrap-current-repo-at-8038fe8/run.js", diff --git a/scripts/check-hook-session-state.js b/scripts/check-hook-session-state.js new file mode 100644 index 0000000..dece32d --- /dev/null +++ b/scripts/check-hook-session-state.js @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import Database from "better-sqlite3"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = new URL("..", import.meta.url); +const { HookSessionStore } = await import(new URL("dist/libs/hooks/session-state.js", root)); +const { openDatabase } = await import(new URL("dist/libs/storage/sqlite/db.js", root)); +const { migrate } = await import(new URL("dist/libs/storage/sqlite/migrate.js", root)); + +const tmp = mkdtempSync(join(tmpdir(), "greplica-hook-session-state-test-")); +const db = openDatabase(join(tmp, "graph.db")); +const sessionConfig = { + stopThreshold: 7, + timeThresholdMinutes: 40, + currentGraceMinutes: 5, + autoMemoryUpdates: true, +}; +const startedAt = new Date("2026-07-11T00:00:00.000Z"); +const beforeThreshold = new Date("2026-07-11T00:39:00.000Z"); +const afterThreshold = new Date("2026-07-11T00:41:00.000Z"); + +try { + db.prepare( + `INSERT INTO repos (id, remote_url, root_path, repo_name, default_branch) + VALUES (?, ?, ?, ?, ?)`, + ).run("repo.session-state", null, join(tmp, "repo"), "session-state", "main"); + + const store = new HookSessionStore(db, sessionConfig); + const firstHook = store.recordHook({ + platform: "codex", + sessionId: "session-time-threshold", + repoId: "repo.session-state", + eventName: "UserPromptSubmit", + now: startedAt, + }); + assert.equal(firstHook.session.first_seen_at, startedAt.toISOString()); + assert.deepEqual(store.claimDueMemoryUpdateAttempts(startedAt), []); + + const secondHook = store.recordHook({ + platform: "codex", + sessionId: "session-time-threshold", + repoId: "repo.session-state", + eventName: "UserPromptSubmit", + now: beforeThreshold, + }); + assert.equal(secondHook.session.first_seen_at, startedAt.toISOString()); + assert.deepEqual(store.claimDueMemoryUpdateAttempts(beforeThreshold), []); + + const thresholdHook = store.recordHook({ + platform: "codex", + sessionId: "session-time-threshold", + repoId: "repo.session-state", + eventName: "UserPromptSubmit", + now: afterThreshold, + }); + assert.equal(thresholdHook.session.first_seen_at, startedAt.toISOString()); + assert.deepEqual( + store.claimDueMemoryUpdateAttempts(afterThreshold).map((attempt) => attempt.reason), + ["time_threshold"], + ); + + assert.equal( + store.markMemoryCurrent({ + platform: "codex", + sessionId: "session-time-threshold", + repoId: "repo.session-state", + now: afterThreshold, + }), + true, + ); + const afterCurrent = new Date("2026-07-11T00:42:00.000Z"); + store.recordHook({ + platform: "codex", + sessionId: "session-time-threshold", + repoId: "repo.session-state", + eventName: "UserPromptSubmit", + now: afterCurrent, + }); + assert.deepEqual(store.claimDueMemoryUpdateAttempts(afterCurrent), []); +} finally { + db.close(); +} + +const legacyDb = new Database(join(tmp, "legacy.db")); +try { + legacyDb.exec(` + CREATE TABLE repos ( + id TEXT PRIMARY KEY, + remote_url TEXT UNIQUE, + root_path TEXT UNIQUE, + repo_name TEXT NOT NULL, + default_branch TEXT NOT NULL + ); + CREATE TABLE agent_sessions ( + platform TEXT NOT NULL, + session_id TEXT NOT NULL, + repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, + transcript_path TEXT, + cwd TEXT, + guidance_injected_at TEXT, + stops_since_memory_current INTEGER NOT NULL DEFAULT 0, + last_seen_at TEXT NOT NULL, + last_memory_current_at TEXT, + PRIMARY KEY(platform, session_id) + ); + `); + legacyDb.prepare( + `INSERT INTO repos (id, remote_url, root_path, repo_name, default_branch) + VALUES (?, ?, ?, ?, ?)`, + ).run("repo.legacy", null, join(tmp, "legacy-repo"), "legacy", "main"); + legacyDb.prepare( + `INSERT INTO agent_sessions ( + platform, session_id, repo_id, transcript_path, cwd, guidance_injected_at, + stops_since_memory_current, last_seen_at, last_memory_current_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run("codex", "legacy-session", "repo.legacy", null, null, null, 0, startedAt.toISOString(), null); + + migrate(legacyDb); + + const migrated = legacyDb + .prepare("SELECT first_seen_at, last_seen_at FROM agent_sessions WHERE session_id = ?") + .get("legacy-session"); + assert.equal(migrated.first_seen_at, startedAt.toISOString()); + assert.equal(migrated.last_seen_at, startedAt.toISOString()); + + const migratedStore = new HookSessionStore(legacyDb, sessionConfig); + assert.deepEqual( + migratedStore.claimDueMemoryUpdateAttempts(afterThreshold).map((attempt) => attempt.reason), + ["time_threshold"], + ); +} finally { + legacyDb.close(); + rmSync(tmp, { recursive: true, force: true }); +} + +console.log("check-hook-session-state: ok");