limn-review 0.1.183 → 0.1.184
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.
- package/dist/db/erase-account.js +4 -0
- package/dist/db/migrations/0166_team_claim_skip_lines.sql +20 -0
- package/dist/db/migrations/meta/_journal.json +7 -0
- package/dist/db/schema.sqlite.js +22 -0
- package/dist/db/team-claim-lines-delete.js +3 -0
- package/dist/review/ci-review/auto-post.js +1 -1
- package/dist/review/claude-review/approval-withdraw.js +1 -1
- package/dist/review/claude-review/auto-post.js +1 -1
- package/dist/review/claude-review/auto-settings.js +12 -7
- package/dist/review/claude-review/auto.js +118 -2
- package/dist/review/claude-review/manager.js +33 -3
- package/dist/review/claude-review/team-claim-skip.js +209 -0
- package/dist/review/claude-review/team-claim.js +38 -2
- package/dist/review/ticket-review/auto-post.js +3 -1
- package/dist/shared/types.js +1 -1
- package/package.json +1 -1
- package/public/assets/{AiFixTab-DwjVZ3Od.js → AiFixTab-1Th64RMo.js} +1 -1
- package/public/assets/{ClaudeReviewTab-CMCKluXj.js → ClaudeReviewTab-BRGdbkXT.js} +1 -1
- package/public/assets/{PrBotBehaviourTab-3mieFdQL.js → PrBotBehaviourTab-DCXj-QHC.js} +1 -1
- package/public/assets/{index-DnvzZnsD.js → index-CXSOCXMH.js} +4 -4
- package/public/index.html +1 -1
package/dist/db/erase-account.js
CHANGED
|
@@ -177,6 +177,8 @@ export async function eraseAccountData(accountId) {
|
|
|
177
177
|
await tx.delete(schema.teamClaimLines).where(eq(schema.teamClaimLines.accountId, accountId)).execute();
|
|
178
178
|
// The teammate lines the AI Review tab names (migration 0159): GitHub logins. No FK.
|
|
179
179
|
await tx.delete(schema.teamClaimSeen).where(eq(schema.teamClaimSeen.accountId, accountId)).execute();
|
|
180
|
+
// The skipped lines (migration 0166): heads and providers. No FK.
|
|
181
|
+
await tx.delete(schema.teamClaimSkipLines).where(eq(schema.teamClaimSkipLines.accountId, accountId)).execute();
|
|
180
182
|
// The worktree ledger (migration 0146): which PR / ticket a checkout on disk was for. No FK. The
|
|
181
183
|
// directories themselves become unledgered orphans the worktree sweep collects by age.
|
|
182
184
|
await tx.delete(schema.worktreeLedger).where(eq(schema.worktreeLedger.accountId, accountId)).execute();
|
|
@@ -370,6 +372,8 @@ export function accountScopedTables() {
|
|
|
370
372
|
{ name: 'teamClaimLines', col: schema.teamClaimLines.accountId, table: schema.teamClaimLines },
|
|
371
373
|
// The teammate lines (migration 0159), erased explicitly above.
|
|
372
374
|
{ name: 'teamClaimSeen', col: schema.teamClaimSeen.accountId, table: schema.teamClaimSeen },
|
|
375
|
+
// The skipped lines (migration 0166), erased explicitly above.
|
|
376
|
+
{ name: 'teamClaimSkipLines', col: schema.teamClaimSkipLines.accountId, table: schema.teamClaimSkipLines },
|
|
373
377
|
// The worktree ledger (migration 0146), erased explicitly above.
|
|
374
378
|
{ name: 'worktreeLedger', col: schema.worktreeLedger.accountId, table: schema.worktreeLedger },
|
|
375
379
|
{ name: 'workspaceSonar', col: schema.workspaceSonar.accountId, table: schema.workspaceSonar },
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- AN AUTO REVIEW THIS LIMN SKIPPED, SAID ON THE PR (docs/CLAUDE-REVIEW.md § Team claims → The skipped
|
|
2
|
+
-- line). When the reader's Limn does not start an auto review it would otherwise run (the AI budget is
|
|
3
|
+
-- used, the workspace's daily limit is reached, the provider is over its usage limit, AI is not signed
|
|
4
|
+
-- in), it posts ONE green `limn/review-<provider>` line on the head. One row per (account, PR, head,
|
|
5
|
+
-- provider, reason), claimed BEFORE the GitHub write: at most once, never retried. Additive.
|
|
6
|
+
CREATE TABLE `team_claim_skip_lines` (
|
|
7
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
8
|
+
`account_id` integer NOT NULL,
|
|
9
|
+
`pr_id` integer NOT NULL,
|
|
10
|
+
`head_sha` text NOT NULL,
|
|
11
|
+
`provider` text NOT NULL,
|
|
12
|
+
`reason` text NOT NULL,
|
|
13
|
+
`state` text NOT NULL,
|
|
14
|
+
`description` text,
|
|
15
|
+
`last_error` text,
|
|
16
|
+
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
|
17
|
+
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
|
18
|
+
);
|
|
19
|
+
--> statement-breakpoint
|
|
20
|
+
CREATE UNIQUE INDEX `team_claim_skip_lines_key` ON `team_claim_skip_lines` (`account_id`,`pr_id`,`head_sha`,`provider`,`reason`);
|
|
@@ -1163,6 +1163,13 @@
|
|
|
1163
1163
|
"when": 1792224073016,
|
|
1164
1164
|
"tag": "0165_withdraw_approval_on_push",
|
|
1165
1165
|
"breakpoints": true
|
|
1166
|
+
},
|
|
1167
|
+
{
|
|
1168
|
+
"idx": 166,
|
|
1169
|
+
"version": "6",
|
|
1170
|
+
"when": 1792224075016,
|
|
1171
|
+
"tag": "0166_team_claim_skip_lines",
|
|
1172
|
+
"breakpoints": true
|
|
1166
1173
|
}
|
|
1167
1174
|
]
|
|
1168
1175
|
}
|
package/dist/db/schema.sqlite.js
CHANGED
|
@@ -3980,6 +3980,28 @@ export const teamClaimSeen = sqliteTable('team_claim_seen', {
|
|
|
3980
3980
|
}, (t) => ({
|
|
3981
3981
|
prHeadProviderUx: uniqueIndex('team_claim_seen_account_pr_head_provider').on(t.accountId, t.prId, t.headSha, t.provider),
|
|
3982
3982
|
}));
|
|
3983
|
+
// 0166. AN AUTO REVIEW THIS LIMN SKIPPED, SAID ON THE PR (docs/CLAUDE-REVIEW.md § Team claims → The
|
|
3984
|
+
// skipped line): one row per (account, PR, head, provider, reason) — the claim, written BEFORE the
|
|
3985
|
+
// GitHub write, so the line "Cleared by alex: auto review skipped · AI budget used" goes out at most
|
|
3986
|
+
// ONCE and a failed write is never retried. `reason` 'budget' | 'daily_cap' | 'usage_limit' |
|
|
3987
|
+
// 'not_ready'; `state` 'posting' (claimed) → 'posted' | 'failed' (GitHub refused; `last_error`) |
|
|
3988
|
+
// 'not_needed' (another line already sits on the head). No FK on pr_id: BOTH delete paths delete its
|
|
3989
|
+
// rows (db/team-claim-lines-delete.ts), the erasure deletes everything.
|
|
3990
|
+
export const teamClaimSkipLines = sqliteTable('team_claim_skip_lines', {
|
|
3991
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
3992
|
+
accountId: integer('account_id').notNull(),
|
|
3993
|
+
prId: integer('pr_id').notNull(),
|
|
3994
|
+
headSha: text('head_sha').notNull(),
|
|
3995
|
+
provider: text('provider').notNull(),
|
|
3996
|
+
reason: text('reason').notNull(),
|
|
3997
|
+
state: text('state', { enum: ['posting', 'posted', 'failed', 'not_needed'] }).notNull(),
|
|
3998
|
+
description: text('description'),
|
|
3999
|
+
lastError: text('last_error'),
|
|
4000
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql `(unixepoch())`),
|
|
4001
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql `(unixepoch())`),
|
|
4002
|
+
}, (t) => ({
|
|
4003
|
+
keyUx: uniqueIndex('team_claim_skip_lines_key').on(t.accountId, t.prId, t.headSha, t.provider, t.reason),
|
|
4004
|
+
}));
|
|
3983
4005
|
// 0139. THIS DATABASE'S OWN FACTS (key → value) — never an account's, so not account-scoped and not
|
|
3984
4006
|
// erased with one. `instance_id`: a random id written once (db/instance-id.ts) that names this
|
|
3985
4007
|
// database file wherever it is moved; the reference-docs janitor's ownership marker.
|
|
@@ -17,5 +17,8 @@ export async function deleteSettledClaimLinesForPrs(tx, prIds) {
|
|
|
17
17
|
// The teammate lines the AI Review tab names (migration 0159): nothing on GitHub to release, so
|
|
18
18
|
// every row of these PRs goes.
|
|
19
19
|
await tx.delete(schema.teamClaimSeen).where(inArray(schema.teamClaimSeen.prId, prIds)).execute();
|
|
20
|
+
// The skipped lines this machine posted (migration 0166): a settled success on GitHub, nothing to
|
|
21
|
+
// release, so every row of these PRs goes.
|
|
22
|
+
await tx.delete(schema.teamClaimSkipLines).where(inArray(schema.teamClaimSkipLines.prId, prIds)).execute();
|
|
20
23
|
}
|
|
21
24
|
//# sourceMappingURL=team-claim-lines-delete.js.map
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// THE RULE, in order (the first that fails decides):
|
|
7
7
|
// 0. the workspace holding the PR's repo has auto-posting ON and its `ciFailures` kind on
|
|
8
|
-
// (default on, inside auto-posting,
|
|
8
|
+
// (default on, inside auto-posting, itself ON by default since 2026-11-04). Off ⇒ nothing is
|
|
9
9
|
// recorded at all;
|
|
10
10
|
// 1. the run is an AUTO run that SUCCEEDED (a CI check you started yourself is never posted) and
|
|
11
11
|
// has never been claimed;
|
|
@@ -201,7 +201,7 @@ async function withdrawOnPush(ctx, { accountId, prId, headSha }, deps) {
|
|
|
201
201
|
.execute());
|
|
202
202
|
const pr = rows[0];
|
|
203
203
|
// Auto review AND auto-posting on (the approval could only exist there), and the switch not off.
|
|
204
|
-
if (!pr || pr.reviewOn !== true || pr.postOn
|
|
204
|
+
if (!pr || pr.reviewOn !== true || pr.postOn === false || pr.withdrawOn === false)
|
|
205
205
|
return { kind: 'off' };
|
|
206
206
|
if (pr.state !== 'open')
|
|
207
207
|
return { kind: 'none' };
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// THE RULE, in order (the first that fails decides):
|
|
5
5
|
// 0. the workspace holding the PR's repo has auto-posting ON (`workspaces.auto_post_enabled`,
|
|
6
|
-
// migration 0087
|
|
6
|
+
// migration 0087 — ON by default since 2026-11-04: only a stored false is off). Off ⇒ nothing is
|
|
7
7
|
// recorded at all;
|
|
8
8
|
// 1. WHICH PRs (`autoPostEligibility`, pure): open (never merged or closed), not a draft, not
|
|
9
9
|
// bot-authored (the workspace's bot union), and under scope 'mine' the PR is the reader's own,
|
|
@@ -35,12 +35,14 @@ import { onOff, writeSettingEvent } from '../../db/setting-event-write.js';
|
|
|
35
35
|
// by the wire shape and the sweeper's roster alike. Bounded 1..500 at the route.
|
|
36
36
|
//
|
|
37
37
|
// AUTO-POSTING rides the same route as two more columns (migration 0087 / pg 0074):
|
|
38
|
-
// `auto_post_enabled` (
|
|
39
|
-
//
|
|
38
|
+
// `auto_post_enabled` (⚠ ON BY DEFAULT since 2026-11-04 — NULL reads ON, only a stored false is
|
|
39
|
+
// off, in every reader and in the writer; it writes to GitHub AS THE READER, but only for AUTO runs,
|
|
40
|
+
// so it does nothing until auto review is switched on) and `auto_post_settings`, OVERRIDES ONLY
|
|
40
41
|
// ({ scope?, kinds? }; NULL = the product defaults), resolved through `resolveAutoPostSettings`.
|
|
41
42
|
// Stored independently of `auto_review_enabled`: it only ever acts on AUTO runs, so it does nothing
|
|
42
|
-
// while auto review is off, but switching auto review off and on keeps it. The `autoVerdict`
|
|
43
|
-
//
|
|
43
|
+
// while auto review is off, but switching auto review off and on keeps it. The `autoVerdict` (ON by
|
|
44
|
+
// default since 2026-11-04: absent = on, a stored false = off) and `autoResolve` (OFF by default)
|
|
45
|
+
// switches ride the same overrides JSON (no column), and so
|
|
44
46
|
// does `approveImpact` (which blast-radius levels an auto verdict may APPROVE; all by default).
|
|
45
47
|
// So do the two TEAMMATE-LIFT opt-ins, `teamLiftConsent` and `liftTeammateBlocks` (both OFF by
|
|
46
48
|
// default; docs/CLAUDE-REVIEW.md § Lifting a teammate's block).
|
|
@@ -103,7 +105,8 @@ approveLeftovers) {
|
|
|
103
105
|
approveImpact[k] = si[k];
|
|
104
106
|
}
|
|
105
107
|
return {
|
|
106
|
-
|
|
108
|
+
// ON unless explicitly switched off (2026-11-04).
|
|
109
|
+
enabled: enabled !== false,
|
|
107
110
|
scope: isScope(stored?.scope) ? stored.scope : AUTO_POST_DEFAULT_SCOPE,
|
|
108
111
|
kinds,
|
|
109
112
|
autoVerdict: typeof stored?.autoVerdict === 'boolean' ? stored.autoVerdict : AUTO_POST_DEFAULT_AUTO_VERDICT,
|
|
@@ -218,6 +221,7 @@ export async function readWorkspaceAutoPostForPr(ctx, accountId, prId) {
|
|
|
218
221
|
const rows = (await ctx.db
|
|
219
222
|
.select({
|
|
220
223
|
workspaceId: w.id,
|
|
224
|
+
autoReviewOn: w.autoReviewEnabled,
|
|
221
225
|
enabled: w.autoPostEnabled,
|
|
222
226
|
stored: w.autoPostSettings,
|
|
223
227
|
...verdictRuleCols(w),
|
|
@@ -234,6 +238,7 @@ export async function readWorkspaceAutoPostForPr(ctx, accountId, prId) {
|
|
|
234
238
|
workspaceId: r.workspaceId,
|
|
235
239
|
settings: resolveAutoPostSettings(r.enabled, r.stored, r.approveLeftovers),
|
|
236
240
|
pairStrictness: resolvePairStrictness(r.pairStrictness),
|
|
241
|
+
autoReviewOn: r.autoReviewOn === true,
|
|
237
242
|
}
|
|
238
243
|
: null;
|
|
239
244
|
}
|
|
@@ -450,7 +455,7 @@ export async function setWorkspaceAutoReview(ctx, accountId, workspaceId, change
|
|
|
450
455
|
set.approveLeftovers = leftoversStored;
|
|
451
456
|
}
|
|
452
457
|
if (autoPost !== undefined) {
|
|
453
|
-
if (postAfter.enabled !== (existing.autoPostEnabled
|
|
458
|
+
if (postAfter.enabled !== (existing.autoPostEnabled !== false))
|
|
454
459
|
set.autoPostEnabled = postAfter.enabled;
|
|
455
460
|
if (JSON.stringify(postOverrides) !== JSON.stringify(existing.autoPostSettings ?? null)) {
|
|
456
461
|
set.autoPostSettings = postOverrides;
|
|
@@ -524,7 +529,7 @@ function settingChanges(set, existing, next) {
|
|
|
524
529
|
]);
|
|
525
530
|
}
|
|
526
531
|
if ('autoPostEnabled' in set)
|
|
527
|
-
out.push(['auto_post', `Auto-posting ${onOff(next.autoPostEnabled
|
|
532
|
+
out.push(['auto_post', `Auto-posting ${onOff(next.autoPostEnabled !== false)}`]);
|
|
528
533
|
else if ('autoPostSettings' in set)
|
|
529
534
|
out.push(['auto_post', 'Auto-posting settings changed']);
|
|
530
535
|
return out;
|
|
@@ -188,9 +188,26 @@ export async function runAutoReviewSweep(ctx, nowMs = Date.now(), press = null)
|
|
|
188
188
|
// row that would use up its one automatic review. The PRs still qualify on a later tick.
|
|
189
189
|
if (!agenticRunReady(ctx, 'review')) {
|
|
190
190
|
result.stopped = 'ai_not_ready';
|
|
191
|
+
// THE SKIPPED LINE (team-claim-skip.ts): the tick says so on each due head, once. A press does not
|
|
192
|
+
// (the SPA shows its own line, and the next tick posts).
|
|
193
|
+
if (!press) {
|
|
194
|
+
sweeping = true;
|
|
195
|
+
try {
|
|
196
|
+
await noteSkippedWhileHeld(ctx, nowMs, candidatesOf, 'not_ready');
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
ctx.log.warn(`auto review skipped lines: ${err instanceof Error ? err.message : String(err)}`);
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
sweeping = false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
191
205
|
return result;
|
|
192
206
|
}
|
|
193
207
|
sweeping = true;
|
|
208
|
+
// Due heads this pass did not start, and why (the daily cap; the spend budget) — said on the PR once
|
|
209
|
+
// each, at the end of the pass (team-claim-skip.ts).
|
|
210
|
+
const skipped = [];
|
|
194
211
|
try {
|
|
195
212
|
const dayStartMs = utcDayStartMs(nowMs);
|
|
196
213
|
const liveHeads = new Set();
|
|
@@ -224,8 +241,11 @@ export async function runAutoReviewSweep(ctx, nowMs = Date.now(), press = null)
|
|
|
224
241
|
result.overBudget = true;
|
|
225
242
|
}
|
|
226
243
|
for (const ws of roster) {
|
|
227
|
-
if (budgetOk.get(ws.accountId) === false)
|
|
244
|
+
if (budgetOk.get(ws.accountId) === false) {
|
|
245
|
+
// Over the spend budget: nothing is queued; the due heads are said on the PR (read-only pass).
|
|
246
|
+
skipped.push(...(await dueHeadsHeld(ctx, nowMs, candidatesOf, ws, 'budget')));
|
|
228
247
|
continue;
|
|
248
|
+
}
|
|
229
249
|
if (autoLaneRoom() === 0) {
|
|
230
250
|
result.stopped = 'lane_full';
|
|
231
251
|
break;
|
|
@@ -273,7 +293,9 @@ export async function runAutoReviewSweep(ctx, nowMs = Date.now(), press = null)
|
|
|
273
293
|
continue;
|
|
274
294
|
if (budget <= 0) {
|
|
275
295
|
result.capped = true;
|
|
276
|
-
|
|
296
|
+
// Said on the PR (team-claim-skip.ts); the rest of the list is noted the same way.
|
|
297
|
+
skipped.push({ accountId: ws.accountId, prId, headSha: null, reason: 'daily_cap' });
|
|
298
|
+
continue;
|
|
277
299
|
}
|
|
278
300
|
const k = `${ws.accountId}:${prId}`;
|
|
279
301
|
livePrs.add(k);
|
|
@@ -523,6 +545,9 @@ export async function runAutoReviewSweep(ctx, nowMs = Date.now(), press = null)
|
|
|
523
545
|
continue;
|
|
524
546
|
if (budget <= 0) {
|
|
525
547
|
result.capped = true;
|
|
548
|
+
// A MOVED HEAD is a head never reviewed: said on the PR. A same-head comment burst is not.
|
|
549
|
+
if (commentsAtMs == null)
|
|
550
|
+
skipped.push({ accountId: ws.accountId, prId, headSha, reason: 'daily_cap' });
|
|
526
551
|
continue;
|
|
527
552
|
}
|
|
528
553
|
const r = enqueueAutoReview(ctx, ws.accountId, prId, ws.workspaceId, commentsAtMs ?? null, { pressed });
|
|
@@ -563,6 +588,7 @@ export async function runAutoReviewSweep(ctx, nowMs = Date.now(), press = null)
|
|
|
563
588
|
}
|
|
564
589
|
if (result.queued > 0)
|
|
565
590
|
ctx.log.info(`auto claude review: queued ${result.queued} PR(s)`);
|
|
591
|
+
await postSkippedHeads(ctx, skipped);
|
|
566
592
|
return result;
|
|
567
593
|
}
|
|
568
594
|
catch (err) {
|
|
@@ -573,6 +599,96 @@ export async function runAutoReviewSweep(ctx, nowMs = Date.now(), press = null)
|
|
|
573
599
|
sweeping = false;
|
|
574
600
|
}
|
|
575
601
|
}
|
|
602
|
+
/** At most this many PRs are said on GitHub per tick (each once per head and reason, recorded). */
|
|
603
|
+
export const AUTO_SKIP_LINES_PER_TICK = 25;
|
|
604
|
+
/**
|
|
605
|
+
* The heads this workspace's tick WOULD have started — first reviews, and moved heads whose start rule
|
|
606
|
+
* says go — without starting anything or touching a clock. Read-only: the spend budget and AI
|
|
607
|
+
* readiness hold the whole workspace, so these are said on the PR instead. Not a held PR (a teammate's
|
|
608
|
+
* claim, already visible), not one waiting in the lane, not a retry or a same-head comment burst.
|
|
609
|
+
*/
|
|
610
|
+
async function dueHeadsHeld(ctx, nowMs, candidatesOf, ws, reason) {
|
|
611
|
+
const waiting = autoPendingPrIds();
|
|
612
|
+
const res = await candidatesOf(ws.accountId, ws.workspaceId, {
|
|
613
|
+
openedSinceMs: ws.enabledAtMs,
|
|
614
|
+
dayStartMs: utcDayStartMs(nowMs),
|
|
615
|
+
limit: ws.dailyCap + waiting.size,
|
|
616
|
+
});
|
|
617
|
+
if (!res)
|
|
618
|
+
return [];
|
|
619
|
+
const retrying = new Set((res.retry ?? []).map((r) => r.prId));
|
|
620
|
+
const held = async (prId, headSha) => {
|
|
621
|
+
const h = teamClaimHeldHead(ws.accountId, prId, nowMs);
|
|
622
|
+
if (h == null)
|
|
623
|
+
return false;
|
|
624
|
+
return (headSha ?? (await syncedHeadOf(ctx, ws.accountId, prId))) === h;
|
|
625
|
+
};
|
|
626
|
+
const out = [];
|
|
627
|
+
for (const prId of res.prIds) {
|
|
628
|
+
if (waiting.has(prId) || (await held(prId, null)))
|
|
629
|
+
continue;
|
|
630
|
+
out.push({ accountId: ws.accountId, prId, headSha: null, reason });
|
|
631
|
+
}
|
|
632
|
+
for (const cand of res.reReview ?? []) {
|
|
633
|
+
const { prId, headSha, commentsAtMs, lastRunAtMs } = cand;
|
|
634
|
+
if (commentsAtMs != null || waiting.has(prId) || retrying.has(prId))
|
|
635
|
+
continue;
|
|
636
|
+
const seen = headSeen.get(`${ws.accountId}:${prId}`);
|
|
637
|
+
const due = autoReviewDue({
|
|
638
|
+
nowMs,
|
|
639
|
+
settle: {
|
|
640
|
+
quietSinceMs: seen && seen.key === `${headSha}|` ? seen.firstSeenMs : nowMs,
|
|
641
|
+
burstStartMs: seen ? seen.burstStartMs : nowMs,
|
|
642
|
+
reason: 'head',
|
|
643
|
+
},
|
|
644
|
+
lastRunAtMs: lastRunAtMs ?? null,
|
|
645
|
+
});
|
|
646
|
+
if (!due.due || (await held(prId, headSha)))
|
|
647
|
+
continue;
|
|
648
|
+
out.push({ accountId: ws.accountId, prId, headSha, reason });
|
|
649
|
+
}
|
|
650
|
+
return out;
|
|
651
|
+
}
|
|
652
|
+
/** AI not ready: every switched-on workspace's due heads, said on the PR. */
|
|
653
|
+
async function noteSkippedWhileHeld(ctx, nowMs, candidatesOf, reason) {
|
|
654
|
+
const heads = [];
|
|
655
|
+
for (const ws of await listAutoReviewWorkspaces(ctx)) {
|
|
656
|
+
heads.push(...(await dueHeadsHeld(ctx, nowMs, candidatesOf, ws, reason)));
|
|
657
|
+
if (heads.length >= AUTO_SKIP_LINES_PER_TICK)
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
await postSkippedHeads(ctx, heads);
|
|
661
|
+
}
|
|
662
|
+
/** Post the skipped lines (each provider's context, once per head and reason). Never throws. */
|
|
663
|
+
async function postSkippedHeads(ctx, heads) {
|
|
664
|
+
if (heads.length === 0)
|
|
665
|
+
return;
|
|
666
|
+
try {
|
|
667
|
+
const m = await import('./team-claim-skip.js');
|
|
668
|
+
let notReady = null;
|
|
669
|
+
for (const h of heads.slice(0, AUTO_SKIP_LINES_PER_TICK)) {
|
|
670
|
+
let skips;
|
|
671
|
+
if (h.reason === 'not_ready') {
|
|
672
|
+
if (!notReady) {
|
|
673
|
+
const [{ activeProviderId }, { activeRuntimeStatus }] = await Promise.all([
|
|
674
|
+
import('../../ai/provider-setting.js'),
|
|
675
|
+
import('../../ai/active-runtime.js'),
|
|
676
|
+
]);
|
|
677
|
+
notReady = { provider: activeProviderId(), notSetUp: activeRuntimeStatus().runtime !== 'ready' };
|
|
678
|
+
}
|
|
679
|
+
skips = [{ provider: notReady.provider, reason: 'not_ready', notSetUp: notReady.notSetUp }];
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
const providers = await m.autoProvidersForPr(ctx, h.accountId, h.prId);
|
|
683
|
+
skips = providers.map((provider) => ({ provider, reason: h.reason }));
|
|
684
|
+
}
|
|
685
|
+
await m.postAutoSkipLines(ctx, { accountId: h.accountId, prId: h.prId, headSha: h.headSha }, skips);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
catch (err) {
|
|
689
|
+
ctx.log.warn(`auto review skipped lines: ${err instanceof Error ? err.message : String(err)}`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
576
692
|
function isSettleWait(r) {
|
|
577
693
|
return r === 'comments' || r === 'commits' || r === 'ci';
|
|
578
694
|
}
|
|
@@ -5,7 +5,8 @@ import { getTicketSource } from '../../tracker/ticket-source.js';
|
|
|
5
5
|
import { agenticRunReady } from './ai-ready.js';
|
|
6
6
|
import { automaticRunAllowed } from '../budget-gate.js';
|
|
7
7
|
import { detectWake, FORCE_RELEASE_MS, runsToInterrupt, WATCHDOG_TICK_MS } from './run-watchdog.js';
|
|
8
|
-
import { automaticRunProviders, isLockedOut, LOCKOUT_MAX_MS, noteProviderFailure, restoreUsageLockouts } from '../../ai/usage-lockout.js';
|
|
8
|
+
import { automaticRunProviders, isLockedOut, LOCKOUT_MAX_MS, noteProviderFailure, restoreUsageLockouts, usageLockout, } from '../../ai/usage-lockout.js';
|
|
9
|
+
import { postAutoSkipLinesLater } from './team-claim-skip.js';
|
|
9
10
|
import { decideReviewMode } from './routing.js';
|
|
10
11
|
import { offeredSpecialists } from './specialists.js';
|
|
11
12
|
import { buildUserPrompt, peerRef, pickReviewNonce, skipSummary, sonarHasContent, systemPromptForMode, untrustedTexts, } from './prompts.js';
|
|
@@ -339,11 +340,25 @@ async function startAutoItem(a) {
|
|
|
339
340
|
let launched = false;
|
|
340
341
|
// A claim posted before the row could be written is released ("free to claim") below.
|
|
341
342
|
let teamClaim = null;
|
|
343
|
+
// THE SKIPPED LINE (team-claim-skip.ts): an item dropped here for the budget, a usage limit or AI not
|
|
344
|
+
// ready says so on the PR head, once — only a first review or a moved head (a retry's or a same-head
|
|
345
|
+
// comment burst's head already carries this Limn's own line). Posted in the background.
|
|
346
|
+
const sayOnPr = a.retryOfReviewId == null && a.commentsAtMs == null;
|
|
347
|
+
const sayNotReady = () => {
|
|
348
|
+
if (!sayOnPr)
|
|
349
|
+
return;
|
|
350
|
+
postAutoSkipLinesLater(ctx, { accountId, prId }, async () => {
|
|
351
|
+
const { activeRuntimeStatus } = await import('../../ai/active-runtime.js');
|
|
352
|
+
return [{ provider: activeProviderId(), reason: 'not_ready', notSetUp: activeRuntimeStatus().runtime !== 'ready' }];
|
|
353
|
+
});
|
|
354
|
+
};
|
|
342
355
|
try {
|
|
343
356
|
// AI went away since the sweep (runtime removed, signed out): drop the item BEFORE its row
|
|
344
357
|
// exists, or the PR's one automatic review is spent on a failure (ai-ready.ts).
|
|
345
|
-
if (!agenticRunReady(ctx, 'review'))
|
|
358
|
+
if (!agenticRunReady(ctx, 'review')) {
|
|
359
|
+
sayNotReady();
|
|
346
360
|
return;
|
|
361
|
+
}
|
|
347
362
|
// An AUTOMATIC run goes to the DEFAULT provider (Settings → AI), read now — unless the workspace
|
|
348
363
|
// asks for Claude + Codex (`auto_review_providers = 'both'`): then a PAIR when both can review,
|
|
349
364
|
// else the one that can. ⚠ A provider LOCKED OUT by its usage limit (ai/usage-lockout.ts) never
|
|
@@ -368,6 +383,18 @@ async function startAutoItem(a) {
|
|
|
368
383
|
providers = pick(true);
|
|
369
384
|
if (providers.length === 0) {
|
|
370
385
|
ctx.log.info(`auto claude review pr ${prId}: skipped — no provider outside its usage limit`);
|
|
386
|
+
// Each provider that would have run: over its usage limit (with its end), else not signed in.
|
|
387
|
+
if (sayOnPr) {
|
|
388
|
+
const wanted = wantsPair ? [...PAIR_ORDER] : [activeProviderId()];
|
|
389
|
+
const skips = wanted.flatMap((p) => {
|
|
390
|
+
if (isLockedOut(p, nowMs))
|
|
391
|
+
return [{ provider: p, reason: 'usage_limit', untilMs: usageLockout(p, nowMs)?.untilMs ?? null }];
|
|
392
|
+
// A pair half that is simply not signed in is not a skip of a review it would run.
|
|
393
|
+
return wantsPair ? [] : [{ provider: p, reason: 'not_ready' }];
|
|
394
|
+
});
|
|
395
|
+
if (skips.length > 0)
|
|
396
|
+
postAutoSkipLinesLater(ctx, { accountId, prId }, skips);
|
|
397
|
+
}
|
|
371
398
|
return;
|
|
372
399
|
}
|
|
373
400
|
let provider = providers[0];
|
|
@@ -377,8 +404,11 @@ async function startAutoItem(a) {
|
|
|
377
404
|
// THE CLAUDE SPEND BUDGET (ai/budget.ts): over it, drop the item before its row exists — the
|
|
378
405
|
// sweeper re-derives it once the budget is under its limit again.
|
|
379
406
|
// A READER'S PRESS is not asked again: they chose it (the SPA's over-budget confirm ran).
|
|
380
|
-
if (!a.pressed && !(await automaticRunAllowed(ctx, accountId, 'auto review', provider)))
|
|
407
|
+
if (!a.pressed && !(await automaticRunAllowed(ctx, accountId, 'auto review', provider))) {
|
|
408
|
+
if (sayOnPr)
|
|
409
|
+
postAutoSkipLinesLater(ctx, { accountId, prId }, providers.map((p) => ({ provider: p, reason: 'budget' })));
|
|
381
410
|
return;
|
|
411
|
+
}
|
|
382
412
|
const prCtx = await getReviewPrContext(ctx, prId, accountId);
|
|
383
413
|
if (!prCtx?.headSha)
|
|
384
414
|
return;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { and, eq, isNull } from 'drizzle-orm';
|
|
2
|
+
import { claimErrorKind, defaultTeamClaimGithub, isFreeTeamClaimLine, newestLine, skippedLine, teamClaimContext, teamClaimsOnForPr, } from './team-claim.js';
|
|
3
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
4
|
+
const s = (ctx) => ctx.schema;
|
|
5
|
+
const errText = (err) => (err instanceof Error ? err.message : String(err));
|
|
6
|
+
// The GitHub adapter (a test pins its own). Read at call time, never at import.
|
|
7
|
+
let githubOverride = null;
|
|
8
|
+
const gh = () => githubOverride ?? defaultTeamClaimGithub;
|
|
9
|
+
/** Test hook. */
|
|
10
|
+
export function _setSkipLineGithubForTest(g) {
|
|
11
|
+
githubOverride = g;
|
|
12
|
+
}
|
|
13
|
+
// Posts started without awaiting (the manager's drop paths): a test awaits them.
|
|
14
|
+
const inFlight = new Set();
|
|
15
|
+
/** Test hook: wait for every skipped line being written. */
|
|
16
|
+
export async function _skipLinesSettledForTest() {
|
|
17
|
+
while (inFlight.size > 0)
|
|
18
|
+
await Promise.allSettled([...inFlight]);
|
|
19
|
+
}
|
|
20
|
+
/** Post in the background (the manager must release its seat at once). Never throws. */
|
|
21
|
+
export function postAutoSkipLinesLater(ctx, a, skips) {
|
|
22
|
+
const p = (async () => postAutoSkipLines(ctx, a, typeof skips === 'function' ? await skips() : skips))()
|
|
23
|
+
.catch((err) => ctx.log.warn(`skipped line pr ${a.prId}: ${errText(err)}`))
|
|
24
|
+
.finally(() => inFlight.delete(p));
|
|
25
|
+
inFlight.add(p);
|
|
26
|
+
}
|
|
27
|
+
async function prFacts(ctx, accountId, prId) {
|
|
28
|
+
const { pullRequests: prs, repos } = s(ctx);
|
|
29
|
+
const rows = (await ctx.db
|
|
30
|
+
.select({
|
|
31
|
+
owner: repos.owner,
|
|
32
|
+
name: repos.name,
|
|
33
|
+
number: prs.number,
|
|
34
|
+
headSha: prs.headSha,
|
|
35
|
+
state: prs.state,
|
|
36
|
+
draft: prs.isDraft,
|
|
37
|
+
authorId: prs.authorId,
|
|
38
|
+
})
|
|
39
|
+
.from(prs)
|
|
40
|
+
.innerJoin(repos, and(eq(repos.id, prs.repoId), eq(repos.accountId, accountId)))
|
|
41
|
+
.where(and(eq(prs.id, prId), eq(prs.accountId, accountId)))
|
|
42
|
+
.limit(1)
|
|
43
|
+
.execute());
|
|
44
|
+
const r = rows[0];
|
|
45
|
+
if (!r)
|
|
46
|
+
return null;
|
|
47
|
+
return {
|
|
48
|
+
owner: r.owner,
|
|
49
|
+
name: r.name,
|
|
50
|
+
number: r.number,
|
|
51
|
+
headSha: r.headSha,
|
|
52
|
+
open: (r.state ?? '').toLowerCase() === 'open',
|
|
53
|
+
draft: r.draft === true,
|
|
54
|
+
authorId: r.authorId,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Any review row (not a replies pass) of this PR at this head: this account's own line may be there. */
|
|
58
|
+
async function reviewRowAtHead(ctx, accountId, prId, headSha) {
|
|
59
|
+
const cr = s(ctx).claudeReviews;
|
|
60
|
+
const rows = (await ctx.db
|
|
61
|
+
.select({ id: cr.id })
|
|
62
|
+
.from(cr)
|
|
63
|
+
.where(and(eq(cr.prId, prId), eq(cr.accountId, accountId), eq(cr.headSha, headSha), isNull(cr.kind)))
|
|
64
|
+
.limit(1)
|
|
65
|
+
.execute());
|
|
66
|
+
return rows.length > 0;
|
|
67
|
+
}
|
|
68
|
+
async function isBotAuthor(authorId) {
|
|
69
|
+
if (authorId == null)
|
|
70
|
+
return false;
|
|
71
|
+
try {
|
|
72
|
+
const { globalAutomationUserIds } = await import('../../db/automation-ids.js');
|
|
73
|
+
return (await globalAutomationUserIds()).has(authorId);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return true; // unknown ⇒ the safe side: post nothing
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Post the skipped lines for ONE PR head (each provider, each reason, at most once). `headSha`: the
|
|
81
|
+
* head the decision was about; absent ⇒ the synced head. Never throws.
|
|
82
|
+
*/
|
|
83
|
+
export async function postAutoSkipLines(ctx, a, skips, nowMs = Date.now()) {
|
|
84
|
+
const t = s(ctx).teamClaimSkipLines;
|
|
85
|
+
if (!t || skips.length === 0)
|
|
86
|
+
return [];
|
|
87
|
+
const { accountId, prId } = a;
|
|
88
|
+
const all = (o) => skips.map(() => o);
|
|
89
|
+
try {
|
|
90
|
+
if (!(await teamClaimsOnForPr(ctx, accountId, prId)))
|
|
91
|
+
return all('off');
|
|
92
|
+
const pr = await prFacts(ctx, accountId, prId);
|
|
93
|
+
const headSha = a.headSha ?? pr?.headSha ?? null;
|
|
94
|
+
if (!pr || !headSha || pr.headSha !== headSha || !pr.open || pr.draft)
|
|
95
|
+
return all('not_eligible');
|
|
96
|
+
if (await reviewRowAtHead(ctx, accountId, prId, headSha))
|
|
97
|
+
return all('not_eligible');
|
|
98
|
+
if (await isBotAuthor(pr.authorId))
|
|
99
|
+
return all('not_eligible');
|
|
100
|
+
const out = [];
|
|
101
|
+
let login;
|
|
102
|
+
for (const sk of skips) {
|
|
103
|
+
const key = and(eq(t.accountId, accountId), eq(t.prId, prId), eq(t.headSha, headSha), eq(t.provider, sk.provider), eq(t.reason, sk.reason));
|
|
104
|
+
const seen = (await ctx.db.select({ id: t.id }).from(t).where(key).limit(1).execute());
|
|
105
|
+
if (seen.length > 0) {
|
|
106
|
+
out.push('already');
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (login === undefined)
|
|
110
|
+
login = await gh().login(accountId).catch(() => null);
|
|
111
|
+
if (!login) {
|
|
112
|
+
out.push('no_login');
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
out.push(await postOne(ctx, { accountId, prId, headSha, pr, login, skip: sk, nowMs }));
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
ctx.log.warn(`skipped line pr ${prId}: ${errText(err)}`);
|
|
121
|
+
return all('failed');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function postOne(ctx, a) {
|
|
125
|
+
const t = s(ctx).teamClaimSkipLines;
|
|
126
|
+
const { accountId, prId, headSha, pr, login, skip } = a;
|
|
127
|
+
const context = teamClaimContext(skip.provider);
|
|
128
|
+
const description = skippedLine(login, skip.reason, skip.provider, {
|
|
129
|
+
untilMs: skip.untilMs ?? null,
|
|
130
|
+
notSetUp: skip.notSetUp === true,
|
|
131
|
+
nowMs: a.nowMs,
|
|
132
|
+
});
|
|
133
|
+
// READ FIRST (a read is not a write): a line that means something stays the newest one.
|
|
134
|
+
let needed = true;
|
|
135
|
+
let readError = null;
|
|
136
|
+
try {
|
|
137
|
+
const newest = newestLine(await gh().listLines(accountId, pr.owner, pr.name, headSha, context));
|
|
138
|
+
needed = newest == null || (newest.state === 'success' && isFreeTeamClaimLine(newest));
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
claimErrorKind(err, accountId); // tells the rate budget about a limit
|
|
142
|
+
readError = errText(err);
|
|
143
|
+
}
|
|
144
|
+
// THE CLAIM, before any GitHub write: the unique key makes it once per (PR, head, provider, reason).
|
|
145
|
+
const claimed = (await ctx.db
|
|
146
|
+
.insert(t)
|
|
147
|
+
.values({
|
|
148
|
+
accountId,
|
|
149
|
+
prId,
|
|
150
|
+
headSha,
|
|
151
|
+
provider: skip.provider,
|
|
152
|
+
reason: skip.reason,
|
|
153
|
+
state: readError != null ? 'failed' : needed ? 'posting' : 'not_needed',
|
|
154
|
+
description,
|
|
155
|
+
lastError: readError,
|
|
156
|
+
})
|
|
157
|
+
.onConflictDoNothing()
|
|
158
|
+
.returning({ id: t.id })
|
|
159
|
+
.execute());
|
|
160
|
+
const id = claimed[0]?.id;
|
|
161
|
+
if (id == null)
|
|
162
|
+
return 'already';
|
|
163
|
+
if (readError != null)
|
|
164
|
+
return 'failed';
|
|
165
|
+
if (!needed)
|
|
166
|
+
return 'not_needed';
|
|
167
|
+
const settle = (state, lastError) => ctx.db.update(t).set({ state, lastError, updatedAt: new Date() }).where(eq(t.id, id)).execute();
|
|
168
|
+
try {
|
|
169
|
+
await gh().postLine(accountId, pr.owner, pr.name, headSha, context, { state: 'success', description });
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
const kind = claimErrorKind(err, accountId);
|
|
173
|
+
await settle('failed', kind === 'error' ? errText(err) : kind).catch(() => { });
|
|
174
|
+
ctx.log.warn(`skipped line pr ${prId}: not written (${errText(err)}); not retried`);
|
|
175
|
+
return 'failed';
|
|
176
|
+
}
|
|
177
|
+
await settle('posted', null).catch(() => { });
|
|
178
|
+
return 'posted';
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The providers an auto review of this PR would run with right now (the workspace's pair, else the
|
|
182
|
+
* default provider), ignoring usage limits — whose context a skipped line goes on. Never empty: the
|
|
183
|
+
* default provider when none can review. Never throws.
|
|
184
|
+
*/
|
|
185
|
+
export async function autoProvidersForPr(ctx, accountId, prId) {
|
|
186
|
+
const [{ readAutoReviewProvidersForPr, PAIR_ORDER }, { automaticRunProviders }, { activeProviderId }, { preflightFor }] = await Promise.all([
|
|
187
|
+
import('./pair.js'),
|
|
188
|
+
import('../../ai/usage-lockout.js'),
|
|
189
|
+
import('../../ai/provider-setting.js'),
|
|
190
|
+
import('../../ai/run-choice.js'),
|
|
191
|
+
]);
|
|
192
|
+
const fallback = activeProviderId();
|
|
193
|
+
try {
|
|
194
|
+
const detect = preflightFor(ctx);
|
|
195
|
+
const pair = (await readAutoReviewProvidersForPr(ctx, accountId, prId)) === 'both';
|
|
196
|
+
const out = automaticRunProviders({
|
|
197
|
+
defaultProvider: fallback,
|
|
198
|
+
pair,
|
|
199
|
+
order: PAIR_ORDER,
|
|
200
|
+
ready: (p) => detect(p, 'review').status === 'ok',
|
|
201
|
+
locked: () => false,
|
|
202
|
+
});
|
|
203
|
+
return out.length > 0 ? out : [fallback];
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return [fallback];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
//# sourceMappingURL=team-claim-skip.js.map
|