Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions libs/hooks/session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions libs/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
13 changes: 13 additions & 0 deletions libs/storage/sqlite/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
}
1 change: 1 addition & 0 deletions libs/storage/sqlite/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
138 changes: 138 additions & 0 deletions scripts/check-hook-session-state.js
Original file line number Diff line number Diff line change
@@ -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");