limn-review 0.1.141 → 0.1.142

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -168,6 +168,8 @@ export async function eraseAccountData(accountId) {
168
168
  // hold third-party analysis of the account's code. No FK on the snapshots, so this is the whole
169
169
  // guarantee; workspace_sonar cascades from workspaces too, and is explicit anyway.
170
170
  await tx.delete(schema.sonarSnapshots).where(eq(schema.sonarSnapshots.accountId, accountId)).execute();
171
+ // The team-claim ledger (migration 0138): GitHub logins and heads. No FK, so this is the whole guarantee.
172
+ await tx.delete(schema.teamClaimLines).where(eq(schema.teamClaimLines.accountId, accountId)).execute();
171
173
  await tx.delete(schema.workspaceSonar).where(eq(schema.workspaceSonar.accountId, accountId)).execute();
172
174
  // Sequence diagrams (migration 0115 / pg 0102): no FKs, so this is the whole guarantee (story rows
173
175
  // carry no pr_id and are reached ONLY here).
@@ -330,6 +332,8 @@ export function accountScopedTables() {
330
332
  { name: 'workspaceTrackers', col: schema.workspaceTrackers.accountId, table: schema.workspaceTrackers },
331
333
  // SonarCloud (migration 0114 / pg 0101), erased explicitly above; `workspaceSonar` holds a credential.
332
334
  { name: 'sonarSnapshots', col: schema.sonarSnapshots.accountId, table: schema.sonarSnapshots },
335
+ // The team-claim ledger (migration 0138), erased explicitly above.
336
+ { name: 'teamClaimLines', col: schema.teamClaimLines.accountId, table: schema.teamClaimLines },
333
337
  { name: 'workspaceSonar', col: schema.workspaceSonar.accountId, table: schema.workspaceSonar },
334
338
  // Sequence diagrams (migration 0115 / pg 0102), erased explicitly above.
335
339
  { name: 'sequenceDiagrams', col: schema.sequenceDiagrams.accountId, table: schema.sequenceDiagrams },
@@ -0,0 +1,55 @@
1
+ -- THE TEAM-CLAIM LEDGER (docs/CLAUDE-REVIEW.md § Team claims → Releasing stale lines). One row per
2
+ -- GitHub status line (`limn/review-claude` | `limn/review-codex`) Limn holds or released on a PR
3
+ -- head, written BEFORE the pending line is posted, so a restart, a dropped run or a failed final write
4
+ -- never leaves this machine's line pending: the reconciler releases every open row whose run is not
5
+ -- running. `whose = 'teammate'` records a teammate's expired line released once.
6
+ CREATE TABLE `team_claim_lines` (
7
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
8
+ `account_id` integer NOT NULL,
9
+ `pr_id` integer NOT NULL,
10
+ `repo_id` integer,
11
+ `owner` text,
12
+ `name` text,
13
+ `head_sha` text NOT NULL,
14
+ `provider` text NOT NULL,
15
+ `review_id` integer,
16
+ `whose` text DEFAULT 'mine' NOT NULL,
17
+ `state` text NOT NULL,
18
+ `login` text,
19
+ `final_description` text,
20
+ `final_target_url` text,
21
+ `release_attempts` integer DEFAULT 0 NOT NULL,
22
+ `last_error` text,
23
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL,
24
+ `updated_at` integer DEFAULT (unixepoch()) NOT NULL
25
+ );
26
+ --> statement-breakpoint
27
+ CREATE INDEX `team_claim_lines_state` ON `team_claim_lines` (`state`);
28
+ --> statement-breakpoint
29
+ CREATE INDEX `team_claim_lines_account_pr_head` ON `team_claim_lines` (`account_id`,`pr_id`,`head_sha`);
30
+ --> statement-breakpoint
31
+ -- Lines older Limn versions left PENDING (a failed run's "didn't finish · free to claim", or a run that
32
+ -- died mid-way): the newest auto run's record per (PR, head, context), when it is still pending, from
33
+ -- the last 14 days. The reconciler replaces each with the success release line at boot — only where the
34
+ -- head's newest line is still this account's pending one (it reads GitHub first).
35
+ INSERT INTO `team_claim_lines` (`account_id`, `pr_id`, `repo_id`, `owner`, `name`, `head_sha`, `provider`, `review_id`, `whose`, `state`)
36
+ SELECT cr.`account_id`, cr.`pr_id`, pr.`repo_id`, r.`owner`, r.`name`, cr.`head_sha`,
37
+ CASE json_extract(cr.`team_claim`, '$.context') WHEN 'limn/review-codex' THEN 'codex' ELSE 'claude' END,
38
+ cr.`id`, 'mine', 'pending'
39
+ FROM `claude_reviews` cr
40
+ JOIN `pull_requests` pr ON pr.`id` = cr.`pr_id`
41
+ JOIN `repos` r ON r.`id` = pr.`repo_id`
42
+ WHERE cr.`team_claim` IS NOT NULL
43
+ AND json_valid(cr.`team_claim`)
44
+ AND json_extract(cr.`team_claim`, '$.state') = 'marked'
45
+ AND json_extract(cr.`team_claim`, '$.lastLine') IN ('reviewing', 'unfinished')
46
+ AND cr.`created_at` >= unixepoch() - 14 * 86400
47
+ AND cr.`id` = (
48
+ SELECT MAX(c2.`id`) FROM `claude_reviews` c2
49
+ WHERE c2.`account_id` = cr.`account_id`
50
+ AND c2.`pr_id` = cr.`pr_id`
51
+ AND c2.`head_sha` = cr.`head_sha`
52
+ AND c2.`team_claim` IS NOT NULL
53
+ AND json_valid(c2.`team_claim`)
54
+ AND json_extract(c2.`team_claim`, '$.context') = json_extract(cr.`team_claim`, '$.context')
55
+ );
@@ -967,6 +967,13 @@
967
967
  "when": 1792137624016,
968
968
  "tag": "0137_review_pair_consolidation",
969
969
  "breakpoints": true
970
+ },
971
+ {
972
+ "idx": 138,
973
+ "version": "6",
974
+ "when": 1792137625016,
975
+ "tag": "0138_team_claim_lines",
976
+ "breakpoints": true
970
977
  }
971
978
  ]
972
979
  }
@@ -18,6 +18,7 @@ const REASON_PRIORITY = [
18
18
  ];
19
19
  import { db, schema } from './client.js';
20
20
  import { dropRepoRelations } from './related-repos.js';
21
+ import { deleteSettledClaimLinesForPrs } from './team-claim-lines-delete.js';
21
22
  import { runTransaction } from './client.js';
22
23
  import { enrichMyTurn } from '../feed/my-turn.js';
23
24
  import { resolvePrTickets } from '../pr/detail-enricher.js';
@@ -7873,6 +7874,9 @@ export async function deleteRepo(id, accountId) {
7873
7874
  await tx.delete(schema.trackerTickets).where(inArray(schema.trackerTickets.prId, prIds)).execute();
7874
7875
  // SonarCloud snapshots (migration 0114 / pg 0101). No FKs, so nothing would cascade.
7875
7876
  await tx.delete(schema.sonarSnapshots).where(inArray(schema.sonarSnapshots.prId, prIds)).execute();
7877
+ // The team-claim ledger (migration 0138). No FKs, so nothing would cascade. This machine's OPEN
7878
+ // rows stay: their lines still need releasing on GitHub (db/team-claim-lines-delete.ts).
7879
+ await deleteSettledClaimLinesForPrs(tx, prIds);
7876
7880
  // Sequence diagrams of these PRs, and the story diagrams naming any of them (migration 0115 /
7877
7881
  // pg 0102). No FKs, so nothing would cascade. Before the PRs go: it reads their accounts.
7878
7882
  await pruneSequenceDiagramsForPrs(tx, prIds);
@@ -4,6 +4,7 @@ import { config } from '../config.js';
4
4
  import { pruneTicketReviewsForPrs } from './ticket-review-prune.js';
5
5
  import { pruneCiReviewsForPrs } from './ci-review-prune.js';
6
6
  import { pruneSequenceDiagramsForPrs } from './sequence-diagram-prune.js';
7
+ import { deleteSettledClaimLinesForPrs } from './team-claim-lines-delete.js';
7
8
  // Time-based retention sweep. Per-account server data (events, PRs + their whole subtree)
8
9
  // accumulates forever otherwise; this prunes anything past the retention window so the DB
9
10
  // stays bounded (the pre-launch, tight-budget scalability lever). Runs periodically from
@@ -100,6 +101,9 @@ async function deletePrSubtree(tx, prIds) {
100
101
  await tx.delete(schema.trackerTickets).where(inArray(schema.trackerTickets.prId, prIds)).execute();
101
102
  // SonarCloud snapshots (migration 0114 / pg 0101). No FKs — explicit, pr-keyed.
102
103
  await tx.delete(schema.sonarSnapshots).where(inArray(schema.sonarSnapshots.prId, prIds)).execute();
104
+ // The team-claim ledger (migration 0138). No FKs — explicit, pr-keyed. This machine's OPEN rows
105
+ // stay: their lines still need releasing on GitHub (db/team-claim-lines-delete.ts).
106
+ await deleteSettledClaimLinesForPrs(tx, prIds);
103
107
  // Sequence diagrams of these PRs, and the story diagrams naming any of them (migration 0115 / pg
104
108
  // 0102). No FKs. Before the PRs go: it reads their accounts.
105
109
  await pruneSequenceDiagramsForPrs(tx, prIds);
@@ -3390,4 +3390,51 @@ export const storyDocsAlignments = sqliteTable('story_docs_alignments', {
3390
3390
  foreignColumns: [workspaces.id, workspaces.accountId],
3391
3391
  }).onDelete('cascade'),
3392
3392
  }));
3393
+ // team_claim_lines — THE TEAM-CLAIM LEDGER (migration 0138; docs/CLAUDE-REVIEW.md § Team claims →
3394
+ // Releasing stale lines). ONE row per GitHub status line Limn holds or has released on a PR head, so a
3395
+ // line this machine posted is never left pending by a restart, a dropped run or a failed final write:
3396
+ // whose 'mine' a claim this machine made. Written as 'claiming' BEFORE the pending line is posted,
3397
+ // 'pending' once it is up, 'finished' when the success line ("reviewed") is up,
3398
+ // 'released' when the release line ("didn't finish · free to claim"), a Clear, a
3399
+ // newer claim at the same head, or GitHub's refusal ended it.
3400
+ // whose 'teammate' a teammate's expired line this machine released ("Released: bob's claim
3401
+ // expired"), recorded so it is posted ONCE. Always 'released'.
3402
+ // The reconciler (review/claude-review/team-claim-release.ts) releases every 'claiming' / 'pending'
3403
+ // row whose run is not queued or running in this process. No FK on pr_id (the sonar_snapshots
3404
+ // precedent): BOTH delete paths (deleteRepo, retention's deletePrSubtree) delete its rows EXCEPT this
3405
+ // machine's OPEN ones (a pending line on GitHub still needs its release; the row carries owner/name);
3406
+ // the erasure deletes everything.
3407
+ export const teamClaimLines = sqliteTable('team_claim_lines', {
3408
+ id: integer('id').primaryKey({ autoIncrement: true }),
3409
+ accountId: integer('account_id').notNull(),
3410
+ prId: integer('pr_id').notNull(),
3411
+ repoId: integer('repo_id'),
3412
+ // The repo's owner/name when the row was written: an open line is still released after its PR or
3413
+ // repo left Limn (the delete paths keep OPEN 'mine' rows for exactly that).
3414
+ owner: text('owner'),
3415
+ name: text('name'),
3416
+ headSha: text('head_sha').notNull(),
3417
+ // 'claude' | 'codex' — the status context (`limn/review-<provider>`).
3418
+ provider: text('provider').notNull(),
3419
+ // The run the claim was for (NULL until its row is written, and on a teammate's line).
3420
+ reviewId: integer('review_id'),
3421
+ whose: text('whose', { enum: ['mine', 'teammate'] }).notNull().default('mine'),
3422
+ state: text('state', { enum: ['claiming', 'pending', 'finished', 'released'] }).notNull(),
3423
+ // The GitHub login the line names (NULL on a row backfilled from before the ledger: the
3424
+ // account's login is used).
3425
+ login: text('login'),
3426
+ // A success line that could not be written when the run finished: the reconciler posts IT
3427
+ // instead of the release line.
3428
+ finalDescription: text('final_description'),
3429
+ finalTargetUrl: text('final_target_url'),
3430
+ releaseAttempts: integer('release_attempts').notNull().default(0),
3431
+ lastError: text('last_error'),
3432
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql `(unixepoch())`),
3433
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql `(unixepoch())`),
3434
+ }, (t) => ({
3435
+ // The reconciler's read (open rows).
3436
+ stateIdx: index('team_claim_lines_state').on(t.state),
3437
+ // One head's rows (supersede, Clear, the tab's "Clear" offer).
3438
+ prHeadIdx: index('team_claim_lines_account_pr_head').on(t.accountId, t.prId, t.headSha),
3439
+ }));
3393
3440
  //# sourceMappingURL=schema.sqlite.js.map
@@ -0,0 +1,18 @@
1
+ // The team-claim ledger's DELETE-PATH rule (migration 0138; docs/CLAUDE-REVIEW.md § Team claims →
2
+ // Releasing stale lines). When PRs leave Limn (deleteRepo, retention's deletePrSubtree), their ledger
3
+ // rows go — EXCEPT this machine's OPEN ones ('mine', 'claiming' / 'pending'): each stands for a line
4
+ // that may still be pending on GitHub, and the reconciler releases it from the row's own owner/name
5
+ // (it closes a row that has neither). The account erasure deletes every row regardless.
6
+ import { and, eq, inArray, not } from 'drizzle-orm';
7
+ import { schema } from './client.js';
8
+ /* eslint-disable @typescript-eslint/no-explicit-any */
9
+ export async function deleteSettledClaimLinesForPrs(tx, prIds) {
10
+ if (prIds.length === 0)
11
+ return;
12
+ const tl = schema.teamClaimLines;
13
+ await tx
14
+ .delete(tl)
15
+ .where(and(inArray(tl.prId, prIds), not(and(eq(tl.whose, 'mine'), inArray(tl.state, ['claiming', 'pending'])))))
16
+ .execute();
17
+ }
18
+ //# sourceMappingURL=team-claim-lines-delete.js.map
@@ -101,6 +101,11 @@ export async function startAgenticBackground(app) {
101
101
  catch (err) {
102
102
  app.log.warn({ err }, 'claude review: startup reconcile failed');
103
103
  }
104
+ // TEAM CLAIMS: a run this process lost (a restart mid-review) left its limn/review-* line pending on
105
+ // GitHub; release every such line now, in the BACKGROUND (GitHub calls; never fails or delays the boot).
106
+ void import('./claude-review/team-claim-release.js')
107
+ .then((m) => m.releaseStaleTeamClaims(ctx))
108
+ .catch((err) => app.log.warn({ err }, 'team claim release at boot failed'));
104
109
  try {
105
110
  await reconcileThreadChecksOnStartup(ctx);
106
111
  }
@@ -572,6 +572,11 @@ export function registerAutoReview(ctx) {
572
572
  return;
573
573
  ctx.registerScheduledJob(AUTO_REVIEW_CRON, async () => {
574
574
  await runAutoReviewSweep(ctx);
575
+ // TEAM CLAIMS: release every limn/review-* line of ours no run holds any more, and teammates'
576
+ // expired ones (team-claim-release.ts). Never throws; never blocks the sweep.
577
+ await import('./team-claim-release.js')
578
+ .then((m) => m.releaseStaleTeamClaims(ctx))
579
+ .catch((err) => ctx.log.warn(`team claim release: ${err instanceof Error ? err.message : String(err)}`));
575
580
  }, 'claude-auto-review');
576
581
  }
577
582
  //# sourceMappingURL=auto.js.map
@@ -1,5 +1,5 @@
1
1
  import { and, asc, desc, eq, inArray, isNull } from 'drizzle-orm';
2
- import { CLAUDE_REVIEW_CHAT_EXPLAIN_DEFAULT_QUESTION, CLAUDE_REVIEW_CHAT_MAX_QUESTION_CHARS, CLAUDE_REVIEW_MODELS, aiRunProvider, DEFAULT_CLAUDE_REVIEW_MODEL, } from '../../shared/index.js';
2
+ import { CLAUDE_REVIEW_CHAT_EXPLAIN_DEFAULT_QUESTION, CLAUDE_REVIEW_CHAT_MAX_QUESTION_CHARS, CLAUDE_REVIEW_MODELS, aiRunProvider, findingSourceLabel, pairChatProvider, DEFAULT_CLAUDE_REVIEW_MODEL, } from '../../shared/index.js';
3
3
  import { requestModelOf } from '../../ai/provider.js';
4
4
  import { AGENTIC_AI_ENABLED, chatMayApplyAuthEnv } from './manager.js';
5
5
  import { getReviewPrContext, getReviewView } from './persist.js';
@@ -7,6 +7,7 @@ import { pickReviewNonce } from './prompts.js';
7
7
  import { EXPLAIN_INSTRUCTION, EXPLAIN_SYSTEM_ADDENDUM, explainSectionLines, explanationsMarkdown, findingPinLabel, findingPinText, parsePinRefs, questionWithPins, storyPinLabel, storyPinText, validateExplanations, } from './chat-explain.js';
8
8
  import { providerName } from '../../ai/prompt-tools.js';
9
9
  import { followUpProvider, preflightFor } from '../../ai/run-choice.js';
10
+ import { activeProviderId } from '../../ai/provider-setting.js';
10
11
  // CLAUDE REVIEW CHAT (CORE, free, local-only like the rest of Claude Review). Questions about ONE
11
12
  // succeeded review: a general thread (findingId null) and one thread per finding. The answer comes
12
13
  // from `ctx.review.chat` — a core agent run that MIRRORS the review's mode (worktree → Read/Glob/
@@ -77,16 +78,49 @@ const messageCols = (msg) => ({
77
78
  provider: msg.provider,
78
79
  });
79
80
  /** One thread's stored messages, oldest first. Callers have already proved the review is theirs. */
80
- export async function listChatMessages(ctx, accountId, reviewId, findingId) {
81
+ export async function listChatMessages(ctx, accountId,
82
+ // One run's id, or a pair's thread ids (`chatThreadIds`).
83
+ reviewId, findingId) {
81
84
  const { msg } = tables(ctx);
85
+ const ids = typeof reviewId === 'number' ? [reviewId] : [...reviewId];
82
86
  const rows = (await ctx.db
83
87
  .select(messageCols(msg))
84
88
  .from(msg)
85
- .where(and(eq(msg.accountId, accountId), eq(msg.reviewId, reviewId), findingId == null ? isNull(msg.findingId) : eq(msg.findingId, findingId)))
89
+ .where(and(eq(msg.accountId, accountId), ids.length === 1 ? eq(msg.reviewId, ids[0]) : inArray(msg.reviewId, ids), findingId == null ? isNull(msg.findingId) : eq(msg.findingId, findingId)))
86
90
  .orderBy(asc(msg.id))
87
91
  .execute());
88
92
  return rows.map(toWire);
89
93
  }
94
+ // ---- a pair's ONE chat ---------------------------------------------------------
95
+ /**
96
+ * Where a review's chat is STORED: a Claude + Codex pair's chat (general and per finding) is keyed to
97
+ * the pair's FIRST row id (`pair.pairId`, which always exists), whichever half is latest; an ordinary
98
+ * run's is its own id.
99
+ */
100
+ export function chatStoreId(review) {
101
+ return review.pair?.pairId ?? review.id;
102
+ }
103
+ /**
104
+ * Every id a review's chat may be READ from: a pair's first row id, then each half's (a pair's chat was
105
+ * stored under its latest half's id before 2026-10-28, so both are read; nothing is moved).
106
+ */
107
+ export function chatThreadIds(review) {
108
+ const ids = new Set([chatStoreId(review), review.id]);
109
+ for (const h of review.pair?.halves ?? [])
110
+ ids.add(h.reviewId);
111
+ return [...ids];
112
+ }
113
+ /**
114
+ * WHO ANSWERS: an ordinary run's own provider; a pair's, the DEFAULT provider when its half succeeded,
115
+ * else the half that did (`pairChatProvider`). Either way only when it can answer now; else the
116
+ * default (`followUpProvider`).
117
+ */
118
+ export function chatPreferredProvider(review, defaultProvider = activeProviderId) {
119
+ if (review.pair == null)
120
+ return aiRunProvider(review.provider, review.model);
121
+ const ok = (review.pair.halves ?? []).filter((h) => h.status === 'succeeded').map((h) => h.provider);
122
+ return pairChatProvider(defaultProvider(), ok);
123
+ }
90
124
  /** Pair stored messages into question/answer turns (an unanswered question is skipped). */
91
125
  export function pairTurns(messages) {
92
126
  const out = [];
@@ -160,6 +194,10 @@ const findingRef = (findings, id) => `F${findings.findIndex((f) => f.id === id)
160
194
  function findingText(f, ref) {
161
195
  const where = f.line != null ? `${f.path}:${f.line}` : f.path;
162
196
  const parts = [`${ref} [${f.severity}] ${where}`, `Title: ${f.title}`, clip(f.body, FINDING_BODY_CHARS)];
197
+ // A Claude + Codex pair's finding names who raised it (absent on an ordinary run: its prompt is
198
+ // unchanged).
199
+ if (f.source != null)
200
+ parts.splice(2, 0, `Raised by: ${findingSourceLabel(f.source)}`);
163
201
  if (f.editedBody && f.editedBody.trim() && f.editedBody !== f.body) {
164
202
  parts.push(`The developer reworded it as:\n${clip(f.editedBody, FINDING_BODY_CHARS)}`);
165
203
  }
@@ -211,6 +249,10 @@ export function buildChatPrompt(input) {
211
249
  fence(lines, 'PULL REQUEST', nonce, `Title: ${pr.title}\n\n${pr.body && pr.body.trim() ? clip(pr.body, PR_BODY_CHARS) : '(no description)'}`);
212
250
  lines.push('');
213
251
  lines.push('## Your review');
252
+ // A Claude + Codex pair is asked about as its ONE consolidated review.
253
+ if (review.pair != null) {
254
+ lines.push('This is one review by Claude and Codex together: the summary and verdict are the merged ones, and each finding says who raised it.');
255
+ }
214
256
  fence(lines, 'REVIEW', nonce, `Verdict: ${review.verdict ?? 'COMMENT'}\n\n${clip(review.summary ?? '(no summary)', SUMMARY_CHARS)}`);
215
257
  lines.push('');
216
258
  lines.push(`## Findings (${review.findings.length})`);
@@ -421,15 +463,18 @@ export async function answerReviewChat(ctx, accountId, reviewId, body) {
421
463
  // the head has not moved. Once it has, the findings' own excerpts (and, in worktree mode, the
422
464
  // files at the reviewed commit) are what the answer reads.
423
465
  const prepared = headMoved ? null : await preparedFor(ctx, reviewId, pr);
424
- const stored = await listChatMessages(ctx, accountId, reviewId, findingId);
466
+ // A pair's ONE chat: stored under its first row id, read from every id it may sit under.
467
+ const storeId = chatStoreId(review);
468
+ const stored = await listChatMessages(ctx, accountId, chatThreadIds(review), findingId);
425
469
  const fit = fitTranscript(pairTurns(stored));
426
470
  const base = { review, pr, mode, headMoved, prepared, findingId, turns: fit.turns, question, pins };
427
471
  const nonce = pickReviewNonce(chatUntrustedTexts(base));
428
472
  const prompt = buildChatPrompt({ ...base, nonce });
429
473
  const model = chatModelFor(review.model);
430
474
  // THE CHAT RUNS ON ITS REVIEW'S PROVIDER when that one is signed in and can answer; else on the
431
- // default provider (Settings → AI). A Codex review is asked about on Codex, a Claude one on Claude.
432
- const provider = followUpProvider(aiRunProvider(review.provider, review.model), 'review_chat', preflightFor(ctx));
475
+ // default provider (Settings → AI). A Codex review is asked about on Codex, a Claude one on Claude;
476
+ // a Claude + Codex pair on the default provider when its half succeeded, else on the half that did.
477
+ const provider = followUpProvider(chatPreferredProvider(review), 'review_chat', preflightFor(ctx));
433
478
  const abortController = new AbortController();
434
479
  const timer = setTimeout(() => abortController.abort(), CHAT_TIMEOUT_MS);
435
480
  let result;
@@ -495,7 +540,7 @@ export async function answerReviewChat(ctx, accountId, reviewId, body) {
495
540
  .values([
496
541
  {
497
542
  accountId,
498
- reviewId,
543
+ reviewId: storeId,
499
544
  findingId,
500
545
  role: 'user',
501
546
  content: question,
@@ -504,7 +549,7 @@ export async function answerReviewChat(ctx, accountId, reviewId, body) {
504
549
  },
505
550
  {
506
551
  accountId,
507
- reviewId,
552
+ reviewId: storeId,
508
553
  findingId,
509
554
  role: 'assistant',
510
555
  content: clip(answerText, STORED_ANSWER_CHARS),
@@ -548,6 +593,7 @@ export async function listChatHistoryForPr(ctx, accountId, prId) {
548
593
  reviewMode: cr.reviewMode,
549
594
  finishedAt: cr.finishedAt,
550
595
  createdAt: cr.createdAt,
596
+ pairId: cr.pairId,
551
597
  })
552
598
  .from(cr)
553
599
  .where(and(eq(cr.accountId, accountId), eq(cr.prId, prId)))
@@ -555,6 +601,19 @@ export async function listChatHistoryForPr(ctx, accountId, prId) {
555
601
  .execute());
556
602
  if (runs.length === 0)
557
603
  return { prId, runs: [] };
604
+ // A Claude + Codex pair is ONE review with ONE chat: every half's messages are listed under the
605
+ // pair's view id (its LATEST half, the id the tab shows), so the current pair's chat never reappears
606
+ // as an "earlier review".
607
+ const viewIdOf = new Map();
608
+ const latestOfPair = new Map();
609
+ for (const r of runs) {
610
+ const key = r.pairId ?? null;
611
+ if (key == null)
612
+ continue;
613
+ latestOfPair.set(key, Math.max(latestOfPair.get(key) ?? 0, r.id));
614
+ }
615
+ for (const r of runs)
616
+ viewIdOf.set(r.id, r.pairId != null ? (latestOfPair.get(r.pairId) ?? r.id) : r.id);
558
617
  const rows = (await ctx.db
559
618
  .select({ ...messageCols(msg), reviewId: msg.reviewId })
560
619
  .from(msg)
@@ -563,9 +622,10 @@ export async function listChatHistoryForPr(ctx, accountId, prId) {
563
622
  .execute());
564
623
  const byRun = new Map();
565
624
  for (const r of rows) {
566
- const list = byRun.get(r.reviewId) ?? [];
625
+ const key = viewIdOf.get(r.reviewId) ?? r.reviewId;
626
+ const list = byRun.get(key) ?? [];
567
627
  list.push(toWire(r));
568
- byRun.set(r.reviewId, list);
628
+ byRun.set(key, list);
569
629
  }
570
630
  return {
571
631
  prId,
@@ -623,7 +683,7 @@ export function registerClaudeReviewChatRoutes(app, ctx) {
623
683
  return {
624
684
  reviewId,
625
685
  findingId,
626
- messages: await listChatMessages(ctx, accountId, reviewId, findingId),
686
+ messages: await listChatMessages(ctx, accountId, chatThreadIds(review), findingId),
627
687
  headMoved: pr?.headSha != null && pr.headSha !== review.headSha,
628
688
  answering: inFlight.get(accountId) === reviewId,
629
689
  };
@@ -227,6 +227,23 @@ opts = {}) {
227
227
  export function reviewHeldForPr(prId) {
228
228
  return claimed.has(prId) || startingAuto.has(prId) || autoPending.some((a) => a.prId === prId);
229
229
  }
230
+ /**
231
+ * TEAM CLAIMS — may a GitHub line for `provider` at `headSha` on this PR still be held by a run of
232
+ * THIS process? (team-claim-release.ts asks before releasing a ledger row.) True while an auto item is
233
+ * mid-start (its provider is not known yet), while a pair is between its halves at that head, and while
234
+ * a run at that head holds that provider's line — its own, or (a pair's second half) its first half's.
235
+ */
236
+ export function reviewClaimBusy(accountId, prId, provider, headSha) {
237
+ if (startingAuto.get(prId)?.accountId === accountId)
238
+ return true;
239
+ const between = startingPair.get(prId);
240
+ if (between && between.accountId === accountId && between.headSha === headSha)
241
+ return true;
242
+ const run = runningItems.get(prId);
243
+ if (!run || run.accountId !== accountId || run.headSha !== headSha)
244
+ return false;
245
+ return run.provider === provider || run.pair?.first?.provider === provider;
246
+ }
230
247
  /**
231
248
  * Put ONE PR on the auto lane (the sweeper's only door into the queue). Synchronous and
232
249
  * storage-free: the row is written when a slot opens (`startAutoItem`). Same model and budget as a
@@ -353,7 +370,7 @@ async function startAutoItem(a) {
353
370
  if (pair)
354
371
  pair.pairId = reviewId;
355
372
  if (teamClaim)
356
- await saveTeamClaimRecord(ctx, reviewId, teamClaim.record).catch(() => { });
373
+ await saveTeamClaimRecord(ctx, reviewId, teamClaim.record, teamClaim).catch(() => { });
357
374
  reviewIdByPr.set(prId, reviewId);
358
375
  startingAuto.delete(prId);
359
376
  launched = true;
@@ -642,7 +659,7 @@ async function startPairSecond(first) {
642
659
  const firstFindings = firstReview?.findings ?? [];
643
660
  const reviewId = await insertQueuedReview(ctx, prId, first.headSha, first.model, accountId, [], first.trigger, provider, first.fresh === true, { role: 'second', pairId: pair.pairId });
644
661
  if (teamClaim)
645
- await saveTeamClaimRecord(ctx, reviewId, teamClaim.record).catch(() => { });
662
+ await saveTeamClaimRecord(ctx, reviewId, teamClaim.record, teamClaim).catch(() => { });
646
663
  startingPair.delete(prId);
647
664
  launched = true;
648
665
  // The second half's row is running now; the end (consolidateAtPairEnd) marks the merge itself.
@@ -0,0 +1,135 @@
1
+ import { and, asc, eq, inArray, lt } from 'drizzle-orm';
2
+ export const OPEN_CLAIM_STATES = ['claiming', 'pending'];
3
+ /** After this many failed releases a row is left (logged) — and out of the reconciler's read; the tab's
4
+ * Clear still finds it (`openClaimLinesAtHead`). */
5
+ export const RELEASE_MAX_ATTEMPTS = 5;
6
+ const t = (ctx) => ctx.schema.teamClaimLines;
7
+ const now = () => new Date();
8
+ const clipErr = (s) => (s == null ? null : s.slice(0, 300));
9
+ /**
10
+ * The INTENT, written before the pending line is posted ('claiming'). Our new line replaces any older
11
+ * line of ours at the same (PR, head, provider), so those rows are closed ('released', "superseded")
12
+ * in the same transaction. Returns the new row's id.
13
+ */
14
+ export async function recordClaimIntent(ctx, row) {
15
+ const tl = t(ctx);
16
+ const prs = ctx.schema.pullRequests;
17
+ const repo = (await ctx.db
18
+ .select({ repoId: prs.repoId })
19
+ .from(prs)
20
+ .where(and(eq(prs.id, row.prId), eq(prs.accountId, row.accountId)))
21
+ .limit(1)
22
+ .execute());
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ const write = async (tx) => {
25
+ await tx
26
+ .update(tl)
27
+ .set({ state: 'released', lastError: 'superseded', updatedAt: now() })
28
+ .where(and(eq(tl.accountId, row.accountId), eq(tl.prId, row.prId), eq(tl.headSha, row.headSha), eq(tl.provider, row.provider), eq(tl.whose, 'mine'), inArray(tl.state, [...OPEN_CLAIM_STATES])))
29
+ .execute();
30
+ const [ins] = (await tx
31
+ .insert(tl)
32
+ .values({
33
+ accountId: row.accountId,
34
+ prId: row.prId,
35
+ repoId: repo[0]?.repoId ?? null,
36
+ owner: row.owner ?? null,
37
+ name: row.name ?? null,
38
+ headSha: row.headSha,
39
+ provider: row.provider,
40
+ whose: 'mine',
41
+ state: 'claiming',
42
+ login: row.login,
43
+ createdAt: now(),
44
+ updatedAt: now(),
45
+ })
46
+ .returning({ id: tl.id })
47
+ .execute());
48
+ return ins.id;
49
+ };
50
+ // Two DB statements, no network: close the older rows, then write the intent.
51
+ return write(ctx.db);
52
+ }
53
+ /** Move a row on (and stamp it). `patch` may set the run, the error, the counters. */
54
+ export async function setClaimLine(ctx, id, patch) {
55
+ const tl = t(ctx);
56
+ const set = { updatedAt: now() };
57
+ for (const [k, v] of Object.entries(patch))
58
+ if (v !== undefined)
59
+ set[k] = k === 'lastError' ? clipErr(v) : v;
60
+ await ctx.db.update(tl).set(set).where(eq(tl.id, id)).execute();
61
+ }
62
+ /**
63
+ * The open rows (ours: 'claiming' / 'pending') the reconciler may still try, oldest first, capped.
64
+ * ⚠ A row that used up its RELEASE_MAX_ATTEMPTS is left OUT here, so given-up rows can never fill the
65
+ * window and starve newer ones.
66
+ */
67
+ export async function openClaimLines(ctx, limit = 50) {
68
+ const tl = t(ctx);
69
+ return (await ctx.db
70
+ .select()
71
+ .from(tl)
72
+ .where(and(eq(tl.whose, 'mine'), inArray(tl.state, [...OPEN_CLAIM_STATES]), lt(tl.releaseAttempts, RELEASE_MAX_ATTEMPTS)))
73
+ .orderBy(asc(tl.id))
74
+ .limit(limit)
75
+ .execute());
76
+ }
77
+ /** This machine's open rows at one PR head (the tab's "Clear" offer). */
78
+ export async function openClaimLinesAtHead(ctx, accountId, prId, headSha) {
79
+ const tl = t(ctx);
80
+ return (await ctx.db
81
+ .select()
82
+ .from(tl)
83
+ .where(and(eq(tl.accountId, accountId), eq(tl.prId, prId), eq(tl.headSha, headSha), eq(tl.whose, 'mine'), inArray(tl.state, [...OPEN_CLAIM_STATES])))
84
+ .orderBy(asc(tl.id))
85
+ .execute());
86
+ }
87
+ /** A Clear (or anything else that settled the head for one provider): close every open row there. */
88
+ export async function releaseClaimLinesAtHead(ctx, accountId, prId, headSha, provider, why) {
89
+ const tl = t(ctx);
90
+ const rows = (await ctx.db
91
+ .update(tl)
92
+ .set({ state: 'released', lastError: clipErr(why), updatedAt: now() })
93
+ .where(and(eq(tl.accountId, accountId), eq(tl.prId, prId), eq(tl.headSha, headSha), eq(tl.provider, provider), inArray(tl.state, [...OPEN_CLAIM_STATES])))
94
+ .returning({ id: tl.id })
95
+ .execute());
96
+ return rows.length;
97
+ }
98
+ /** Has this machine already released `login`'s line at this head (posted once, never again)? */
99
+ export async function teammateLineReleased(ctx, accountId, prId, headSha, provider, login) {
100
+ const tl = t(ctx);
101
+ const rows = (await ctx.db
102
+ .select({ id: tl.id, login: tl.login })
103
+ .from(tl)
104
+ .where(and(eq(tl.accountId, accountId), eq(tl.prId, prId), eq(tl.headSha, headSha), eq(tl.provider, provider), eq(tl.whose, 'teammate')))
105
+ .execute());
106
+ return rows.some((r) => (r.login ?? '').toLowerCase() === login.toLowerCase());
107
+ }
108
+ /** Record a teammate's line this machine released (or tried to: `lastError`). */
109
+ export async function recordTeammateRelease(ctx, row) {
110
+ const tl = t(ctx);
111
+ const prs = ctx.schema.pullRequests;
112
+ const repo = (await ctx.db
113
+ .select({ repoId: prs.repoId })
114
+ .from(prs)
115
+ .where(and(eq(prs.id, row.prId), eq(prs.accountId, row.accountId)))
116
+ .limit(1)
117
+ .execute());
118
+ await ctx.db
119
+ .insert(tl)
120
+ .values({
121
+ accountId: row.accountId,
122
+ prId: row.prId,
123
+ repoId: repo[0]?.repoId ?? null,
124
+ headSha: row.headSha,
125
+ provider: row.provider,
126
+ whose: 'teammate',
127
+ state: 'released',
128
+ login: row.login,
129
+ lastError: clipErr(row.lastError),
130
+ createdAt: now(),
131
+ updatedAt: now(),
132
+ })
133
+ .execute();
134
+ }
135
+ //# sourceMappingURL=team-claim-ledger.js.map