Skip to content

Commit ef729c6

Browse files
authored
Merge pull request #1812 from quantified-uncertainty/claude/session-id-fk-consolidation
feat(sessions): add session_id FK to link agent_sessions to session logs (Phase 1 of #1668)
2 parents c076de5 + b501f76 commit ef729c6

File tree

10 files changed

+189
-22
lines changed

10 files changed

+189
-22
lines changed

.squawk.toml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@ pg_version = "16"
2222
# by ensuring migrations only run once.
2323
#
2424
# require-concurrent-index-creation, disallowed-unique-constraint, constraint-missing-not-valid,
25-
# adding-not-nullable-field:
25+
# adding-not-nullable-field, adding-foreign-key-constraint:
2626
# Drizzle's migrator runs ALL statements in a single transaction (see pg-core/dialect.js
27-
# line 60). CREATE INDEX CONCURRENTLY cannot run inside a transaction, so these online-safe
28-
# patterns are impossible with Drizzle. Tables are small (~700 rows) so brief locking is
29-
# acceptable. If large-table migrations are needed in the future, use a separate raw SQL
30-
# runner outside Drizzle.
27+
# line 60). CREATE INDEX CONCURRENTLY cannot run inside a transaction, and the NOT VALID
28+
# two-step pattern for FK constraints requires two separate transactions — both impossible
29+
# with Drizzle. Tables are small (hundreds of rows) so brief locking is acceptable.
30+
# If large-table migrations are needed in the future, use a separate raw SQL runner
31+
# outside Drizzle (see .claude/rules/database-migrations.md for the manual migration pattern).
3132
excluded_rules = [
3233
"prefer-bigint-over-int",
3334
"prefer-identity",
@@ -38,4 +39,5 @@ excluded_rules = [
3839
"disallowed-unique-constraint",
3940
"constraint-missing-not-valid",
4041
"adding-not-nullable-field",
42+
"adding-foreign-key-constraint",
4143
]

apps/web/src/app/internal/agent-sessions/agent-sessions-content.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,25 @@ async function loadFromApi(): Promise<FetchResult<AgentSessionRow[]>> {
4949
revalidate: 60,
5050
});
5151

52-
// Build a branch → session log map for enrichment
52+
// Build lookup maps for enrichment:
53+
// 1. session_id → session log (direct FK, preferred for newer records)
54+
// 2. branch → session log (fallback heuristic for older records without session_id)
55+
const logsById = new Map<number, SessionRow>();
5356
const logsByBranch = new Map<string, SessionRow>();
5457
if (logsResult.ok) {
5558
for (const log of logsResult.data.items) {
59+
logsById.set(log.id, log);
5660
if (log.branch) {
5761
logsByBranch.set(log.branch, log);
5862
}
5963
}
6064
}
6165

6266
const rows: AgentSessionRow[] = agentResult.data.sessions.map((s): AgentSessionRow => {
63-
const log = logsByBranch.get(s.branch);
67+
// Prefer FK-linked session log; only fall back to branch heuristic when sessionId is absent
68+
const log = s.sessionId != null
69+
? logsById.get(s.sessionId)
70+
: logsByBranch.get(s.branch);
6471
return {
6572
id: s.id,
6673
branch: s.branch,
@@ -72,7 +79,7 @@ async function loadFromApi(): Promise<FetchResult<AgentSessionRow[]>> {
7279
startedAt: s.startedAt,
7380
completedAt: s.completedAt,
7481
// Prefer prUrl from agent_sessions (set by `crux issues done --pr=URL`),
75-
// fall back to session log join on branch for older records.
82+
// fall back to session log for older records.
7683
prUrl: s.prUrl ?? log?.prUrl ?? null,
7784
prOutcome: s.prOutcome ?? null,
7885
fixesPrUrl: s.fixesPrUrl ?? null,
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-- Add session_id FK to agent_sessions, linking live tracking records to their
2+
-- corresponding historical session log.
3+
--
4+
-- This replaces the fragile branch-name join currently used by the Agent Sessions
5+
-- dashboard to enrich agent_sessions with cost/model data from the sessions table.
6+
-- With this FK, the dashboard can use a direct JOIN instead of a heuristic match.
7+
--
8+
-- The column is nullable because:
9+
-- 1. Older records predate this migration and won't have a session_id
10+
-- 2. Some agent sessions may never produce a session log (e.g., abandoned sessions)
11+
-- 3. Session logs can be created after agent sessions (at PR time)
12+
--
13+
-- Note: adding-foreign-key-constraint is excluded in .squawk.toml because Drizzle's
14+
-- migrator runs in a single transaction, making the two-step NOT VALID / VALIDATE
15+
-- pattern impossible. agent_sessions is a small table (hundreds of rows), so the
16+
-- brief SHARE ROW EXCLUSIVE lock is acceptable.
17+
18+
ALTER TABLE "agent_sessions" ADD COLUMN IF NOT EXISTS "session_id" bigint REFERENCES "sessions"("id") ON DELETE SET NULL;
19+
20+
CREATE INDEX IF NOT EXISTS "idx_as_session_id" ON "agent_sessions" ("session_id");

apps/wiki-server/drizzle/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,13 @@
442442
"when": 1773993600000,
443443
"tag": "0062_add_numeric_cost_duration_to_sessions",
444444
"breakpoints": true
445+
},
446+
{
447+
"idx": 63,
448+
"version": "7",
449+
"when": 1774080000000,
450+
"tag": "0063_add_session_id_to_agent_sessions",
451+
"breakpoints": true
445452
}
446453
]
447454
}

apps/wiki-server/src/__tests__/agent-sessions.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import {
66
postJson,
77
} from "./test-utils";
88

9+
// ---- In-memory sessions fixture (simulates FK target) ----
10+
// Pre-seeded IDs that are valid FK targets for session_id.
11+
// Tests that use sessionId: 42 must find it here; others must not.
12+
const VALID_SESSION_IDS = new Set<number>([42]);
13+
914
// ---- In-memory store simulating agent_sessions table ----
1015

1116
let nextId = 1;
@@ -16,6 +21,7 @@ let store: Array<{
1621
session_type: string;
1722
issue_number: number | null;
1823
checklist_md: string;
24+
session_id: number | null;
1925
status: string;
2026
started_at: Date;
2127
completed_at: Date | null;
@@ -48,6 +54,7 @@ const dispatch: SqlDispatcher = (query, params) => {
4854
session_type: params[2] as string,
4955
issue_number: params[3] as number | null,
5056
checklist_md: params[4] as string,
57+
session_id: null,
5158
status: "active",
5259
started_at: new Date(),
5360
completed_at: null,
@@ -88,6 +95,17 @@ const dispatch: SqlDispatcher = (query, params) => {
8895
case "checklist_md":
8996
store[idx].checklist_md = params[pIdx] as string;
9097
break;
98+
case "session_id": {
99+
const sid = params[pIdx] as number | null;
100+
if (sid !== null && !VALID_SESSION_IDS.has(sid)) {
101+
// Simulate FK constraint violation — the route translates this to 400.
102+
throw new Error(
103+
`insert or update on table "agent_sessions" violates foreign key constraint`
104+
);
105+
}
106+
store[idx].session_id = sid;
107+
break;
108+
}
91109
case "status":
92110
store[idx].status = params[pIdx] as string;
93111
break;
@@ -486,6 +504,61 @@ describe("Agent Sessions API", () => {
486504
});
487505
expect(res.status).toBe(400);
488506
});
507+
508+
it("sets sessionId FK link to session log", async () => {
509+
await postJson(app, "/api/agent-sessions", sampleSession);
510+
511+
const res = await patchJson(app, "/api/agent-sessions/1", {
512+
sessionId: 42,
513+
});
514+
expect(res.status).toBe(200);
515+
const body = await res.json();
516+
expect(body.sessionId).toBe(42);
517+
});
518+
519+
it("clears sessionId with null", async () => {
520+
await postJson(app, "/api/agent-sessions", sampleSession);
521+
522+
// Set it first
523+
await patchJson(app, "/api/agent-sessions/1", { sessionId: 42 });
524+
525+
// Now clear it
526+
const res = await patchJson(app, "/api/agent-sessions/1", {
527+
sessionId: null,
528+
});
529+
expect(res.status).toBe(200);
530+
const body = await res.json();
531+
expect(body.sessionId).toBeNull();
532+
});
533+
534+
it("rejects non-integer sessionId", async () => {
535+
await postJson(app, "/api/agent-sessions", sampleSession);
536+
537+
const res = await patchJson(app, "/api/agent-sessions/1", {
538+
sessionId: 1.5,
539+
});
540+
expect(res.status).toBe(400);
541+
});
542+
543+
it("rejects zero sessionId", async () => {
544+
await postJson(app, "/api/agent-sessions", sampleSession);
545+
546+
const res = await patchJson(app, "/api/agent-sessions/1", {
547+
sessionId: 0,
548+
});
549+
expect(res.status).toBe(400);
550+
});
551+
552+
it("returns 400 invalid_reference for non-existent sessionId", async () => {
553+
await postJson(app, "/api/agent-sessions", sampleSession);
554+
555+
const res = await patchJson(app, "/api/agent-sessions/1", {
556+
sessionId: 9999, // not in VALID_SESSION_IDS — FK violation
557+
});
558+
expect(res.status).toBe(400);
559+
const body = await res.json();
560+
expect(body.error).toBe("invalid_reference");
561+
});
489562
});
490563

491564
// ================================================================

apps/wiki-server/src/api-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,6 +1174,8 @@ export const UpdateAgentSessionSchema = z.object({
11741174
prUrl: z.string().url().max(1000).nullable().optional(),
11751175
prOutcome: z.enum(PR_OUTCOMES).nullable().optional(),
11761176
fixesPrUrl: z.string().url().max(1000).nullable().optional(),
1177+
/** FK to the sessions table — set when the session log is synced to the DB */
1178+
sessionId: z.number().int().positive().nullable().optional(),
11771179
});
11781180
export type UpdateAgentSession = z.infer<typeof UpdateAgentSessionSchema>;
11791181

apps/wiki-server/src/routes/agent-sessions.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,15 +115,16 @@ const agentSessionsApp = new Hono()
115115
const parsed = UpdateAgentSessionSchema.safeParse(body);
116116
if (!parsed.success) return validationError(c, parsed.error.message);
117117

118-
const { checklistMd, status, prUrl, prOutcome, fixesPrUrl } = parsed.data;
118+
const { checklistMd, status, prUrl, prOutcome, fixesPrUrl, sessionId } = parsed.data;
119119
if (
120120
checklistMd === undefined &&
121121
status === undefined &&
122122
prUrl === undefined &&
123123
prOutcome === undefined &&
124-
fixesPrUrl === undefined
124+
fixesPrUrl === undefined &&
125+
sessionId === undefined
125126
) {
126-
return validationError(c, "At least one of checklistMd, status, prUrl, prOutcome, or fixesPrUrl must be provided");
127+
return validationError(c, "At least one of checklistMd, status, prUrl, prOutcome, fixesPrUrl, or sessionId must be provided");
127128
}
128129

129130
const updates: Record<string, unknown> = { updatedAt: new Date() };
@@ -137,13 +138,34 @@ const agentSessionsApp = new Hono()
137138
if (prUrl !== undefined) updates.prUrl = prUrl;
138139
if (prOutcome !== undefined) updates.prOutcome = prOutcome;
139140
if (fixesPrUrl !== undefined) updates.fixesPrUrl = fixesPrUrl;
141+
if (sessionId !== undefined) updates.sessionId = sessionId;
140142

141143
const db = getDrizzleDb();
142-
const result = await db
143-
.update(agentSessions)
144-
.set(updates)
145-
.where(eq(agentSessions.id, id))
146-
.returning();
144+
let result;
145+
try {
146+
result = await db
147+
.update(agentSessions)
148+
.set(updates)
149+
.where(eq(agentSessions.id, id))
150+
.returning();
151+
} catch (e: unknown) {
152+
// Drizzle wraps DB errors in DrizzleQueryError; the FK message lives in e.cause.
153+
const msg = e instanceof Error ? e.message : String(e);
154+
const causeMsg =
155+
e instanceof Error && e.cause instanceof Error ? e.cause.message : "";
156+
const isFkViolation =
157+
msg.includes("foreign key") ||
158+
msg.includes("violates foreign key") ||
159+
causeMsg.includes("foreign key") ||
160+
causeMsg.includes("violates foreign key");
161+
if (isFkViolation) {
162+
return c.json(
163+
{ error: "invalid_reference", message: "sessionId references a non-existent session" },
164+
400,
165+
);
166+
}
167+
throw e;
168+
}
147169

148170
if (result.length === 0) {
149171
return c.json({ error: "not_found", message: `No session with id: ${id}` }, 404);

apps/wiki-server/src/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,7 @@ export const agentSessions = pgTable(
762762
prUrl: text("pr_url"), // PR URL recorded when crux issues done --pr=URL is called
763763
prOutcome: text("pr_outcome"), // Outcome: merged | merged_with_revisions | reverted | closed_without_merge
764764
fixesPrUrl: text("fixes_pr_url"), // URL of the PR this session is fixing (enables fix-chain tracking)
765+
sessionId: bigint("session_id", { mode: "number" }).references(() => sessions.id, { onDelete: "set null" }), // FK to session log — set when session completes and log is synced
765766
status: text("status").notNull().default("active"),
766767
startedAt: timestamp("started_at", { withTimezone: true })
767768
.notNull()
@@ -779,6 +780,7 @@ export const agentSessions = pgTable(
779780
index("idx_as_status").on(table.status),
780781
index("idx_as_issue").on(table.issueNumber),
781782
index("idx_as_started_at").on(table.startedAt),
783+
index("idx_as_session_id").on(table.sessionId),
782784
]
783785
);
784786

crux/pr-patrol/index.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export type PrIssueType =
2828
| 'bot-review-nitpick';
2929

3030
interface BotComment {
31+
threadId: string;
3132
path: string;
3233
line: number | null;
3334
startLine: number | null;
@@ -662,7 +663,7 @@ const PR_QUERY = `query($owner: String!, $name: String!) {
662663
}}
663664
}}}}
664665
reviewThreads(first: 50) { nodes {
665-
isResolved isOutdated path line startLine
666+
id isResolved isOutdated path line startLine
666667
comments(first: 3) { nodes {
667668
author { login }
668669
body
@@ -674,6 +675,7 @@ const PR_QUERY = `query($owner: String!, $name: String!) {
674675
}`;
675676

676677
interface GqlReviewThread {
678+
id: string;
677679
isResolved: boolean;
678680
isOutdated: boolean;
679681
path: string;
@@ -732,6 +734,7 @@ function extractBotComments(pr: GqlPrNode): BotComment[] {
732734
if (!KNOWN_BOT_LOGINS.has(firstComment.author.login)) continue;
733735

734736
comments.push({
737+
threadId: thread.id,
735738
path: thread.path,
736739
line: thread.line,
737740
startLine: thread.startLine,
@@ -805,7 +808,7 @@ const SINGLE_PR_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
805808
}}
806809
}}}}
807810
reviewThreads(first: 50) { nodes {
808-
isResolved isOutdated path line startLine
811+
id isResolved isOutdated path line startLine
809812
comments(first: 3) { nodes {
810813
author { login }
811814
body
@@ -1291,6 +1294,7 @@ function spawnClaude(
12911294
});
12921295
}
12931296

1297+
12941298
async function fixPr(pr: ScoredPr, config: PatrolConfig): Promise<void> {
12951299
log(`${cl.bold}${cl.reset} Fixing PR ${cl.cyan}#${pr.number}${cl.reset} (${pr.title})`);
12961300
log(` Issues: ${cl.yellow}${pr.issues.join(', ')}${cl.reset}`);
@@ -1383,6 +1387,12 @@ async function fixPr(pr: ScoredPr, config: PatrolConfig): Promise<void> {
13831387
},
13841388
}).catch(() => log(' Warning: could not post summary comment'));
13851389
}
1390+
1391+
// Note: we intentionally do NOT auto-resolve bot review threads here.
1392+
// A zero exit code only means Claude ran without crashing — it does not
1393+
// guarantee every open thread was addressed. Blanket resolution would
1394+
// dismiss valid feedback that wasn't actually fixed. Threads are resolved
1395+
// by CodeRabbit re-review after the fix is pushed, or manually.
13861396
} else if (result.hitMaxTurns) {
13871397
const failCount = recordFailure(pr.number);
13881398
outcome = 'max-turns';

0 commit comments

Comments
 (0)