@bli-cockpit/cli 0.2.34 → 0.2.35
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/commands/backfill.js +493 -408
- package/dist/commands/local.js +377 -303
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync.js +323 -210
- package/package.json +2 -2
|
@@ -7,7 +7,7 @@ import { describeError } from "../health-detail.js";
|
|
|
7
7
|
import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
|
|
8
8
|
import { defaultCodexSessionDirs, scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
|
|
9
9
|
import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
|
|
10
|
-
import { acquireBackfillLock } from "../backfill-lock.js";
|
|
10
|
+
import { acquireBackfillLock, } from "../backfill-lock.js";
|
|
11
11
|
import { BACKFILL_COMPLETION_RECHECK_MS, BACKFILL_COVERAGE_VERSION, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCursor, recordBackfillCursorObservations, recordBackfillScanCoverage, writeBackfillCompletionMarker, writeBackfillCursor, } from "../cursors/backfill-cursor.js";
|
|
12
12
|
import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
13
13
|
import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, startLocalWorkContext } from "../local-state.js";
|
|
@@ -56,19 +56,111 @@ export async function runBackfillCommand(command, io) {
|
|
|
56
56
|
writeHumanBackfillResult(result, io);
|
|
57
57
|
return result.status === "complete" ? 0 : 1;
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* The backfill runbook: SCAN what history exists and whether this machine can
|
|
61
|
+
* even run, PLAN whether to actually proceed (confirmation, dry run), UPLOAD
|
|
62
|
+
* every batch under the shared collection lock, REPORT the outcome and update
|
|
63
|
+
* every durable marker. Each stage is a named function below; this function is
|
|
64
|
+
* only the sequence and the lock lifecycle, which straddles UPLOAD and REPORT
|
|
65
|
+
* and so stays here rather than being split across them.
|
|
66
|
+
*/
|
|
59
67
|
export async function runBackfill(command, io) {
|
|
60
68
|
const now = new Date();
|
|
69
|
+
const scanned = await scanBackfillRun(command, io, now);
|
|
70
|
+
if (scanned.kind === "blocked")
|
|
71
|
+
return scanned.result;
|
|
72
|
+
const plan = await planBackfillRun(command, io, scanned);
|
|
73
|
+
if (plan.kind === "result")
|
|
74
|
+
return plan.result;
|
|
75
|
+
const { paths } = scanned;
|
|
76
|
+
const lock = await acquireBackfillLock(paths, now);
|
|
77
|
+
if (!lock.acquired) {
|
|
78
|
+
return {
|
|
79
|
+
...baseBackfillResult(command, backfillResultBaseArgs(scanned)),
|
|
80
|
+
status: "blocked",
|
|
81
|
+
retry_command: scanned.retryCommand,
|
|
82
|
+
failure_reason: "backfill_already_running",
|
|
83
|
+
blocked_at: {
|
|
84
|
+
what: "backfill lock held",
|
|
85
|
+
batch_index: 0,
|
|
86
|
+
batch_total: 0,
|
|
87
|
+
done: 0,
|
|
88
|
+
total: scanned.scan.candidates.length,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
let collectionLock;
|
|
93
|
+
try {
|
|
94
|
+
collectionLock = await acquireSyncLock(paths);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
await lock.handle.release();
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
if (!collectionLock.acquired) {
|
|
101
|
+
await lock.handle.release();
|
|
102
|
+
return {
|
|
103
|
+
...baseBackfillResult(command, backfillResultBaseArgs(scanned)),
|
|
104
|
+
status: "blocked",
|
|
105
|
+
retry_command: scanned.retryCommand,
|
|
106
|
+
failure_reason: "sync_already_running",
|
|
107
|
+
blocked_at: {
|
|
108
|
+
what: "sync collection lock held",
|
|
109
|
+
batch_index: 0,
|
|
110
|
+
batch_total: 0,
|
|
111
|
+
done: 0,
|
|
112
|
+
total: scanned.scan.candidates.length,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
// Backfill and scheduled/manual sync share upload spool and raw-evidence
|
|
118
|
+
// cursors. Holding the same collection lock makes their read-modify-write
|
|
119
|
+
// updates serial, while the dedicated backfill lock still prevents two
|
|
120
|
+
// historical scans from running together.
|
|
121
|
+
if (scanned.scopedCursor.reset) {
|
|
122
|
+
await writeBackfillCursor(paths, scanned.cursor);
|
|
123
|
+
}
|
|
124
|
+
const upload = await uploadBackfillBatches(command, io, lock, scanned);
|
|
125
|
+
return await reportBackfillOutcome(command, io, scanned, upload);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
await collectionLock.handle.release();
|
|
129
|
+
await lock.handle.release();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** The `{now, dashboardUrl, sources, window, cursor, scan, reasonCounts}` bag every `baseBackfillResult` call needs. */
|
|
133
|
+
function backfillResultBaseArgs(ctx) {
|
|
134
|
+
return {
|
|
135
|
+
now: ctx.now,
|
|
136
|
+
dashboardUrl: ctx.dashboardUrl,
|
|
137
|
+
sources: ctx.sources,
|
|
138
|
+
window: ctx.window,
|
|
139
|
+
cursor: ctx.cursor,
|
|
140
|
+
scan: ctx.scan,
|
|
141
|
+
reasonCounts: ctx.reasonCounts,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* SCAN: is this machine paired, what roots/sources/window apply, and what did
|
|
146
|
+
* the archived Codex + Claude session stores actually contain. Returns either
|
|
147
|
+
* a terminal "not paired" result or everything PLAN/UPLOAD/REPORT need next.
|
|
148
|
+
*/
|
|
149
|
+
async function scanBackfillRun(command, io, now) {
|
|
61
150
|
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
62
151
|
const config = await readLocalCollectorConfig(paths);
|
|
63
152
|
const sessionFile = await readLocalCollectorSessionFile(paths);
|
|
64
153
|
const session = await readLocalSessionReference(paths);
|
|
65
154
|
if (session.session_state !== "valid") {
|
|
66
|
-
return
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
155
|
+
return {
|
|
156
|
+
kind: "blocked",
|
|
157
|
+
result: blockedBackfillResult(command, {
|
|
158
|
+
now,
|
|
159
|
+
dashboardUrl: sessionFile.dashboard_url ?? config.dashboard_url,
|
|
160
|
+
reason: "collector_not_paired",
|
|
161
|
+
cursor: await readBackfillCursor(paths),
|
|
162
|
+
}),
|
|
163
|
+
};
|
|
72
164
|
}
|
|
73
165
|
const pairedAt = parseRequiredDate(sessionFile.paired_at, "paired_at");
|
|
74
166
|
const dashboardUrl = normalizeDashboardUrl(sessionFile.dashboard_url ?? config.dashboard_url);
|
|
@@ -101,52 +193,43 @@ export async function runBackfill(command, io) {
|
|
|
101
193
|
pointerlessTerminalSessions,
|
|
102
194
|
now,
|
|
103
195
|
});
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
scope: "global",
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
scan.issues.sort((a, b) => scanIssuePriority(a.scope) - scanIssuePriority(b.scope) ||
|
|
112
|
-
a.reason.localeCompare(b.reason));
|
|
196
|
+
// The scan issue ledger: every write below goes through a named operation
|
|
197
|
+
// (see "Scan issue ledger" further down) instead of a bare push/sort.
|
|
198
|
+
addRepoDiscoveryIssues(scan.issues, worktreeDiscovery.incomplete_reasons);
|
|
199
|
+
sortScanIssuesByPriority(scan.issues);
|
|
113
200
|
const guards = await countReadOnlyGuards(scan.candidates);
|
|
114
201
|
scan.retryable_candidate_keys = new Set([
|
|
115
202
|
...scan.retryable_candidate_keys,
|
|
116
203
|
...guards.retryable_candidate_keys,
|
|
117
204
|
]);
|
|
118
|
-
|
|
119
|
-
scan.issues.push({
|
|
120
|
-
reason: "candidate_file_read_failed",
|
|
121
|
-
count: guards.counts.get("file_read_failed") ?? 0,
|
|
122
|
-
scope: "candidate",
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
if (guards.counts.get("file_too_large")) {
|
|
126
|
-
scan.issues.push({
|
|
127
|
-
reason: "file_too_large",
|
|
128
|
-
count: guards.counts.get("file_too_large") ?? 0,
|
|
129
|
-
scope: "candidate",
|
|
130
|
-
});
|
|
131
|
-
}
|
|
205
|
+
addReadOnlyGuardIssues(scan.issues, guards.counts);
|
|
132
206
|
const reasonCounts = reasonCountsFor(scan.candidates, guards.counts, scan.issues);
|
|
133
|
-
// BLI-2727: a deterministic, labeled oversized skip must never poison
|
|
134
|
-
// completion — it is a permanent, non-retryable fact about the file, not an
|
|
135
|
-
// in-flight problem a rerun can fix. `scan.issues`/`retryable_candidate_keys`
|
|
136
|
-
// still carry it (so it's never silently dropped from reporting); every
|
|
137
|
-
// completion-gating computation below excludes it explicitly instead.
|
|
138
207
|
const oversizedCandidateKeys = oversizedBackfillCandidateKeys(scan.candidates);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
208
|
+
return {
|
|
209
|
+
kind: "scanned",
|
|
210
|
+
now,
|
|
211
|
+
paths,
|
|
212
|
+
dashboardUrl,
|
|
213
|
+
sources,
|
|
214
|
+
window,
|
|
215
|
+
collectionRoots,
|
|
216
|
+
worktrees,
|
|
217
|
+
retryCommand,
|
|
218
|
+
scopedCursor,
|
|
219
|
+
cursor,
|
|
220
|
+
scan,
|
|
221
|
+
reasonCounts,
|
|
222
|
+
oversizedCandidateKeys,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* PLAN: given the scan, should this run actually upload anything right now?
|
|
227
|
+
* `--all` without `--yes` needs interactive confirmation; `--dry-run` reports
|
|
228
|
+
* what would happen and stops there. Either returns a terminal result;
|
|
229
|
+
* anything else proceeds to UPLOAD.
|
|
230
|
+
*/
|
|
231
|
+
async function planBackfillRun(command, io, ctx) {
|
|
232
|
+
const { dashboardUrl, scan, reasonCounts, retryCommand } = ctx;
|
|
150
233
|
if (command.all && !command.yes) {
|
|
151
234
|
if (!command.json) {
|
|
152
235
|
writeDryRunSummary(io, {
|
|
@@ -159,29 +242,16 @@ export async function runBackfill(command, io) {
|
|
|
159
242
|
const confirmed = await confirmAllBackfill(io);
|
|
160
243
|
if (!confirmed) {
|
|
161
244
|
return {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
status: "blocked",
|
|
172
|
-
counts: {
|
|
173
|
-
...baseBackfillResult(command, {
|
|
174
|
-
now,
|
|
175
|
-
dashboardUrl,
|
|
176
|
-
sources,
|
|
177
|
-
window,
|
|
178
|
-
cursor,
|
|
179
|
-
scan,
|
|
180
|
-
reasonCounts,
|
|
181
|
-
}).counts,
|
|
182
|
-
remaining: scan.candidates.length,
|
|
245
|
+
kind: "result",
|
|
246
|
+
result: {
|
|
247
|
+
...baseBackfillResult(command, backfillResultBaseArgs(ctx)),
|
|
248
|
+
status: "blocked",
|
|
249
|
+
counts: {
|
|
250
|
+
...baseBackfillResult(command, backfillResultBaseArgs(ctx)).counts,
|
|
251
|
+
remaining: scan.candidates.length,
|
|
252
|
+
},
|
|
253
|
+
failure_reason: "confirmation_declined",
|
|
183
254
|
},
|
|
184
|
-
failure_reason: "confirmation_declined",
|
|
185
255
|
};
|
|
186
256
|
}
|
|
187
257
|
}
|
|
@@ -195,363 +265,313 @@ export async function runBackfill(command, io) {
|
|
|
195
265
|
});
|
|
196
266
|
}
|
|
197
267
|
return {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
scan
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
status: blockingScanIssues(scan.issues).length > 0 ? "partial" : "complete",
|
|
208
|
-
dry_run: true,
|
|
209
|
-
retry_command: retryCommand,
|
|
210
|
-
...(blockingScanIssues(scan.issues).length > 0
|
|
211
|
-
? { failure_reason: blockingScanIssues(scan.issues)[0]?.reason }
|
|
212
|
-
: {}),
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
const lock = await acquireBackfillLock(paths, now);
|
|
216
|
-
if (!lock.acquired) {
|
|
217
|
-
return {
|
|
218
|
-
...baseBackfillResult(command, {
|
|
219
|
-
now,
|
|
220
|
-
dashboardUrl,
|
|
221
|
-
sources,
|
|
222
|
-
window,
|
|
223
|
-
cursor,
|
|
224
|
-
scan,
|
|
225
|
-
reasonCounts,
|
|
226
|
-
}),
|
|
227
|
-
status: "blocked",
|
|
228
|
-
retry_command: retryCommand,
|
|
229
|
-
failure_reason: "backfill_already_running",
|
|
230
|
-
blocked_at: {
|
|
231
|
-
what: "backfill lock held",
|
|
232
|
-
batch_index: 0,
|
|
233
|
-
batch_total: 0,
|
|
234
|
-
done: 0,
|
|
235
|
-
total: scan.candidates.length,
|
|
236
|
-
},
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
let collectionLock;
|
|
240
|
-
try {
|
|
241
|
-
collectionLock = await acquireSyncLock(paths);
|
|
242
|
-
}
|
|
243
|
-
catch (error) {
|
|
244
|
-
await lock.handle.release();
|
|
245
|
-
throw error;
|
|
246
|
-
}
|
|
247
|
-
if (!collectionLock.acquired) {
|
|
248
|
-
await lock.handle.release();
|
|
249
|
-
return {
|
|
250
|
-
...baseBackfillResult(command, {
|
|
251
|
-
now,
|
|
252
|
-
dashboardUrl,
|
|
253
|
-
sources,
|
|
254
|
-
window,
|
|
255
|
-
cursor,
|
|
256
|
-
scan,
|
|
257
|
-
reasonCounts,
|
|
258
|
-
}),
|
|
259
|
-
status: "blocked",
|
|
260
|
-
retry_command: retryCommand,
|
|
261
|
-
failure_reason: "sync_already_running",
|
|
262
|
-
blocked_at: {
|
|
263
|
-
what: "sync collection lock held",
|
|
264
|
-
batch_index: 0,
|
|
265
|
-
batch_total: 0,
|
|
266
|
-
done: 0,
|
|
267
|
-
total: scan.candidates.length,
|
|
268
|
+
kind: "result",
|
|
269
|
+
result: {
|
|
270
|
+
...baseBackfillResult(command, backfillResultBaseArgs(ctx)),
|
|
271
|
+
status: blockingScanIssues(scan.issues).length > 0 ? "partial" : "complete",
|
|
272
|
+
dry_run: true,
|
|
273
|
+
retry_command: retryCommand,
|
|
274
|
+
...(blockingScanIssues(scan.issues).length > 0
|
|
275
|
+
? { failure_reason: blockingScanIssues(scan.issues)[0]?.reason }
|
|
276
|
+
: {}),
|
|
268
277
|
},
|
|
269
278
|
};
|
|
270
279
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
280
|
+
return { kind: "proceed" };
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* UPLOAD: sync every uploadable candidate in fixed-size batches, heartbeating
|
|
284
|
+
* the backfill lock between batches, stopping early on an exhausted budget or
|
|
285
|
+
* three consecutive failed batches. A candidate whose main is durably
|
|
286
|
+
* oversized-skipped (BLI-2727) is accounted for, not missing — it will never
|
|
287
|
+
* earn a durable pointer under the current cap, and treating it as "still
|
|
288
|
+
* missing" would fail every batch it happens to share with genuinely uploaded
|
|
289
|
+
* siblings, so it is excluded via `oversizedCandidateKeys` throughout.
|
|
290
|
+
*/
|
|
291
|
+
async function uploadBackfillBatches(command, io, lock, ctx) {
|
|
292
|
+
const { worktrees, scan, oversizedCandidateKeys } = ctx;
|
|
293
|
+
const uploadable = uploadableCandidates(scan.candidates);
|
|
294
|
+
const batches = buildBackfillBatches(uploadable);
|
|
295
|
+
const rawEvidenceBudget = {
|
|
296
|
+
remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
|
|
297
|
+
remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
|
|
298
|
+
};
|
|
299
|
+
const syncResults = [];
|
|
300
|
+
let completedBatches = 0;
|
|
301
|
+
let failedBatches = 0;
|
|
302
|
+
let consecutiveFailures = 0;
|
|
303
|
+
let done = 0;
|
|
304
|
+
let failed = 0;
|
|
305
|
+
let deferred = 0;
|
|
306
|
+
let uploadedObjects = 0;
|
|
307
|
+
let uploadedChunks = 0;
|
|
308
|
+
let backfilledSessions = 0;
|
|
309
|
+
const durableCandidateKeys = new Set();
|
|
310
|
+
let blockedAt;
|
|
311
|
+
let failureReason = blockingScanIssues(scan.issues)[0]?.reason;
|
|
312
|
+
for (const [index, batch] of batches.entries()) {
|
|
313
|
+
await lock.handle.heartbeat();
|
|
314
|
+
const sync = await syncBackfillBatch({
|
|
315
|
+
command,
|
|
316
|
+
batch,
|
|
317
|
+
worktrees,
|
|
318
|
+
codexAttribution: scan.codexAttribution,
|
|
319
|
+
claudeAttribution: scan.claudeAttribution,
|
|
320
|
+
rawEvidenceBudget,
|
|
321
|
+
fetchImpl: io.fetch,
|
|
322
|
+
});
|
|
323
|
+
syncResults.push(sync);
|
|
324
|
+
done += batch.candidates.length;
|
|
325
|
+
uploadedObjects += sync.raw_evidence_uploaded_object_count;
|
|
326
|
+
uploadedChunks += sync.raw_evidence_uploaded_chunk_count;
|
|
327
|
+
failed += countSessionUploadFailures(sync);
|
|
328
|
+
deferred +=
|
|
329
|
+
sync.raw_evidence_deferred_byte_budget +
|
|
330
|
+
sync.raw_evidence_deferred_object_budget;
|
|
331
|
+
const durableInBatch = durableBackfillCandidateKeys(batch, sync);
|
|
332
|
+
for (const key of durableInBatch)
|
|
333
|
+
durableCandidateKeys.add(key);
|
|
334
|
+
backfilledSessions = durableCandidateKeys.size;
|
|
335
|
+
const missingDurableMain = batch.candidates.filter((candidate) => !durableInBatch.has(candidateCursorKey(candidate)) &&
|
|
336
|
+
!oversizedCandidateKeys.has(candidateCursorKey(candidate))).length;
|
|
337
|
+
const batchFailed = sync.status !== "uploaded" ||
|
|
338
|
+
countSessionUploadFailures(sync) > 0 ||
|
|
339
|
+
missingDurableMain > 0;
|
|
340
|
+
const batchDeferred = sync.raw_evidence_deferred_byte_budget +
|
|
341
|
+
sync.raw_evidence_deferred_object_budget >
|
|
342
|
+
0;
|
|
343
|
+
if (batchFailed) {
|
|
344
|
+
failedBatches += 1;
|
|
345
|
+
consecutiveFailures += 1;
|
|
278
346
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const rawEvidenceBudget = {
|
|
282
|
-
remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
|
|
283
|
-
remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
|
|
284
|
-
};
|
|
285
|
-
const syncResults = [];
|
|
286
|
-
let completedBatches = 0;
|
|
287
|
-
let failedBatches = 0;
|
|
288
|
-
let consecutiveFailures = 0;
|
|
289
|
-
let done = 0;
|
|
290
|
-
let failed = 0;
|
|
291
|
-
let deferred = 0;
|
|
292
|
-
let uploadedObjects = 0;
|
|
293
|
-
let uploadedChunks = 0;
|
|
294
|
-
let backfilledSessions = 0;
|
|
295
|
-
const durableCandidateKeys = new Set();
|
|
296
|
-
let blockedAt;
|
|
297
|
-
let failureReason = blockingScanIssues(scan.issues)[0]?.reason;
|
|
298
|
-
for (const [index, batch] of batches.entries()) {
|
|
299
|
-
await lock.handle.heartbeat();
|
|
300
|
-
const sync = await syncBackfillBatch({
|
|
301
|
-
command,
|
|
302
|
-
batch,
|
|
303
|
-
worktrees,
|
|
304
|
-
codexAttribution: scan.codexAttribution,
|
|
305
|
-
claudeAttribution: scan.claudeAttribution,
|
|
306
|
-
rawEvidenceBudget,
|
|
307
|
-
fetchImpl: io.fetch,
|
|
308
|
-
});
|
|
309
|
-
syncResults.push(sync);
|
|
310
|
-
done += batch.candidates.length;
|
|
311
|
-
uploadedObjects += sync.raw_evidence_uploaded_object_count;
|
|
312
|
-
uploadedChunks += sync.raw_evidence_uploaded_chunk_count;
|
|
313
|
-
failed += countSessionUploadFailures(sync);
|
|
314
|
-
deferred +=
|
|
315
|
-
sync.raw_evidence_deferred_byte_budget +
|
|
316
|
-
sync.raw_evidence_deferred_object_budget;
|
|
317
|
-
const durableInBatch = durableBackfillCandidateKeys(batch, sync);
|
|
318
|
-
for (const key of durableInBatch)
|
|
319
|
-
durableCandidateKeys.add(key);
|
|
320
|
-
backfilledSessions = durableCandidateKeys.size;
|
|
321
|
-
// A candidate whose main is durably oversized-skipped (BLI-2727) is
|
|
322
|
-
// accounted for, not missing — it will never earn a durable pointer
|
|
323
|
-
// under the current cap, and treating it as "still missing" would fail
|
|
324
|
-
// every batch it happens to share with genuinely uploaded siblings.
|
|
325
|
-
const missingDurableMain = batch.candidates.filter((candidate) => !durableInBatch.has(candidateCursorKey(candidate)) &&
|
|
326
|
-
!oversizedCandidateKeys.has(candidateCursorKey(candidate))).length;
|
|
327
|
-
const batchFailed = sync.status !== "uploaded" ||
|
|
328
|
-
countSessionUploadFailures(sync) > 0 ||
|
|
329
|
-
missingDurableMain > 0;
|
|
330
|
-
const batchDeferred = sync.raw_evidence_deferred_byte_budget +
|
|
331
|
-
sync.raw_evidence_deferred_object_budget >
|
|
332
|
-
0;
|
|
333
|
-
if (batchFailed) {
|
|
334
|
-
failedBatches += 1;
|
|
335
|
-
consecutiveFailures += 1;
|
|
336
|
-
}
|
|
337
|
-
else {
|
|
338
|
-
consecutiveFailures = 0;
|
|
339
|
-
}
|
|
340
|
-
if (!batchFailed && !batchDeferred) {
|
|
341
|
-
completedBatches += 1;
|
|
342
|
-
}
|
|
343
|
-
if (!command.json) {
|
|
344
|
-
writeLine(io.stdout, `Uploaded ${done}/${uploadable.length} (batch ${index + 1}/${batches.length})`);
|
|
345
|
-
}
|
|
346
|
-
await yieldToEventLoop();
|
|
347
|
-
if (batchDeferred) {
|
|
348
|
-
blockedAt = {
|
|
349
|
-
what: "raw evidence budget exhausted",
|
|
350
|
-
batch_index: index + 1,
|
|
351
|
-
batch_total: batches.length,
|
|
352
|
-
done,
|
|
353
|
-
total: uploadable.length,
|
|
354
|
-
};
|
|
355
|
-
failureReason = "deferred_budget_exhausted";
|
|
356
|
-
break;
|
|
357
|
-
}
|
|
358
|
-
if (consecutiveFailures >= BACKFILL_MAX_CONSECUTIVE_FAILURES) {
|
|
359
|
-
blockedAt = {
|
|
360
|
-
what: "consecutive upload failures",
|
|
361
|
-
batch_index: index + 1,
|
|
362
|
-
batch_total: batches.length,
|
|
363
|
-
done,
|
|
364
|
-
total: uploadable.length,
|
|
365
|
-
};
|
|
366
|
-
failureReason =
|
|
367
|
-
sync.status === "spooled"
|
|
368
|
-
? sync.failure_reason
|
|
369
|
-
: missingDurableMain > 0
|
|
370
|
-
? "durable_session_pointer_missing"
|
|
371
|
-
: "upload_failed";
|
|
372
|
-
break;
|
|
373
|
-
}
|
|
347
|
+
else {
|
|
348
|
+
consecutiveFailures = 0;
|
|
374
349
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
paths,
|
|
384
|
-
collectionRoots,
|
|
385
|
-
worktrees,
|
|
386
|
-
candidates: scan.candidates,
|
|
387
|
-
})
|
|
388
|
-
: null;
|
|
389
|
-
const report = sessions.length
|
|
390
|
-
? await postCodexSessionReport({
|
|
391
|
-
homeDir: command.homeDir,
|
|
392
|
-
repoRoot: reportContext?.repoRoot,
|
|
393
|
-
dashboardUrl,
|
|
394
|
-
sessions,
|
|
395
|
-
fetch: io.fetch,
|
|
396
|
-
now,
|
|
397
|
-
})
|
|
398
|
-
: emptyReport("no_sessions_observed");
|
|
399
|
-
const reportAcknowledged = sessions.length === 0 ||
|
|
400
|
-
(report.posted && report.recorded_count >= sessions.length);
|
|
401
|
-
if (!reportAcknowledged && !blockedAt) {
|
|
350
|
+
if (!batchFailed && !batchDeferred) {
|
|
351
|
+
completedBatches += 1;
|
|
352
|
+
}
|
|
353
|
+
if (!command.json) {
|
|
354
|
+
writeLine(io.stdout, `Uploaded ${done}/${uploadable.length} (batch ${index + 1}/${batches.length})`);
|
|
355
|
+
}
|
|
356
|
+
await yieldToEventLoop();
|
|
357
|
+
if (batchDeferred) {
|
|
402
358
|
blockedAt = {
|
|
403
|
-
what: "
|
|
404
|
-
batch_index:
|
|
359
|
+
what: "raw evidence budget exhausted",
|
|
360
|
+
batch_index: index + 1,
|
|
405
361
|
batch_total: batches.length,
|
|
406
362
|
done,
|
|
407
363
|
total: uploadable.length,
|
|
408
364
|
};
|
|
409
|
-
failureReason
|
|
410
|
-
|
|
411
|
-
? "session_report_ack_incomplete"
|
|
412
|
-
: report.reason;
|
|
413
|
-
}
|
|
414
|
-
if (reportAcknowledged && durableCandidateKeys.size > 0) {
|
|
415
|
-
await recordBackfillDurableSessionPointers({
|
|
416
|
-
paths,
|
|
417
|
-
candidates: scan.candidates,
|
|
418
|
-
syncResults,
|
|
419
|
-
now,
|
|
420
|
-
});
|
|
421
|
-
}
|
|
422
|
-
// Raw evidence durability is necessary but not sufficient: the server must
|
|
423
|
-
// also acknowledge the session attribution rows before their historical
|
|
424
|
-
// cursor positions become irreversible.
|
|
425
|
-
if (reportAcknowledged) {
|
|
426
|
-
const cursorAdvanced = advanceBackfillCursorThroughResolvedPrefix({
|
|
427
|
-
cursor,
|
|
428
|
-
candidates: scan.candidates,
|
|
429
|
-
durableCandidateKeys,
|
|
430
|
-
retryableCandidateKeys: scan.retryable_candidate_keys,
|
|
431
|
-
discoveryComplete: !scan.issues.some((issue) => issue.scope === "global"),
|
|
432
|
-
now,
|
|
433
|
-
});
|
|
434
|
-
if (cursorAdvanced)
|
|
435
|
-
await writeBackfillCursor(paths, cursor);
|
|
365
|
+
failureReason = "deferred_budget_exhausted";
|
|
366
|
+
break;
|
|
436
367
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
const unresolvedKnownKeys = new Set([
|
|
446
|
-
...unresolvedUploadableKeys,
|
|
447
|
-
...scan.retryable_candidate_keys,
|
|
448
|
-
]);
|
|
449
|
-
const unseenGlobalFailures = scan.issues
|
|
450
|
-
.filter((issue) => issue.scope === "global")
|
|
451
|
-
.reduce((total, issue) => total + issue.count, 0);
|
|
452
|
-
const reportRetryable = reportAcknowledged
|
|
453
|
-
? 0
|
|
454
|
-
// The cursor is intentionally all-or-nothing for the report. Even
|
|
455
|
-
// chunks already accepted by the server are retried idempotently when
|
|
456
|
-
// another required chunk lacks an acknowledgement.
|
|
457
|
-
: Math.max(1, sessions.length);
|
|
458
|
-
// `remaining` is a reporting total, not a completion gate: it still counts
|
|
459
|
-
// every unresolved candidate, including oversized skips, so the JSON output
|
|
460
|
-
// never goes silent about them (BLI-2727).
|
|
461
|
-
const remaining = unresolvedKnownKeys.size +
|
|
462
|
-
scan.omitted_candidate_count +
|
|
463
|
-
unseenGlobalFailures +
|
|
464
|
-
reportRetryable;
|
|
465
|
-
failed = Math.max(failed, unresolvedUploadableBlocking);
|
|
466
|
-
const blockingIssues = blockingScanIssues(scan.issues);
|
|
467
|
-
const completionBlocked = blockingIssues.length > 0 ||
|
|
468
|
-
unresolvedUploadableBlocking > 0 ||
|
|
469
|
-
unresolvedRetryableBlocking > 0 ||
|
|
470
|
-
deferred > 0 ||
|
|
471
|
-
failed > 0 ||
|
|
472
|
-
!reportAcknowledged;
|
|
473
|
-
if (!failureReason && completionBlocked) {
|
|
474
|
-
const retryableCandidateReason = scan.candidates.find((candidate) => scan.retryable_candidate_keys.has(candidateCursorKey(candidate)) &&
|
|
475
|
-
!oversizedCandidateKeys.has(candidateCursorKey(candidate)))?.reason;
|
|
368
|
+
if (consecutiveFailures >= BACKFILL_MAX_CONSECUTIVE_FAILURES) {
|
|
369
|
+
blockedAt = {
|
|
370
|
+
what: "consecutive upload failures",
|
|
371
|
+
batch_index: index + 1,
|
|
372
|
+
batch_total: batches.length,
|
|
373
|
+
done,
|
|
374
|
+
total: uploadable.length,
|
|
375
|
+
};
|
|
476
376
|
failureReason =
|
|
477
|
-
|
|
478
|
-
|
|
377
|
+
sync.status === "spooled"
|
|
378
|
+
? sync.failure_reason
|
|
379
|
+
: missingDurableMain > 0
|
|
479
380
|
? "durable_session_pointer_missing"
|
|
480
|
-
:
|
|
381
|
+
: "upload_failed";
|
|
382
|
+
break;
|
|
481
383
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
blocked_at: blockedAt,
|
|
548
|
-
failure_reason: failureReason,
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
syncResults,
|
|
387
|
+
uploadable,
|
|
388
|
+
batches,
|
|
389
|
+
completedBatches,
|
|
390
|
+
failedBatches,
|
|
391
|
+
done,
|
|
392
|
+
failed,
|
|
393
|
+
deferred,
|
|
394
|
+
uploadedObjects,
|
|
395
|
+
uploadedChunks,
|
|
396
|
+
backfilledSessions,
|
|
397
|
+
durableCandidateKeys,
|
|
398
|
+
blockedAt,
|
|
399
|
+
failureReason,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* REPORT: post the session report, record durable pointers, advance the
|
|
404
|
+
* cursor through the resolved contiguous prefix, decide completion, write the
|
|
405
|
+
* all-history completion marker when this run actually finished it, and
|
|
406
|
+
* assemble the final result. `remaining`/`completionBlocked` deliberately
|
|
407
|
+
* treat an oversized skip differently (BLI-2727): it still counts toward
|
|
408
|
+
* `remaining` so the JSON output never goes silent about it, but it never
|
|
409
|
+
* blocks completion on its own — every completion-gating computation below
|
|
410
|
+
* excludes it explicitly via `oversizedCandidateKeys`/`blockingScanIssues`.
|
|
411
|
+
*/
|
|
412
|
+
async function reportBackfillOutcome(command, io, ctx, upload) {
|
|
413
|
+
const { now, paths, dashboardUrl, sources, collectionRoots, worktrees, retryCommand, scopedCursor, cursor, scan, oversizedCandidateKeys, } = ctx;
|
|
414
|
+
const { syncResults, uploadable, batches, completedBatches, failedBatches, done, deferred, uploadedObjects, uploadedChunks, backfilledSessions, durableCandidateKeys, } = upload;
|
|
415
|
+
let { blockedAt, failureReason, failed } = upload;
|
|
416
|
+
const sessions = buildBackfillSessionReport({
|
|
417
|
+
candidates: scan.candidates,
|
|
418
|
+
syncResults,
|
|
419
|
+
now,
|
|
420
|
+
});
|
|
421
|
+
const reportContext = sessions.length
|
|
422
|
+
? await ensureBackfillReportContext({
|
|
423
|
+
homeDir: command.homeDir,
|
|
424
|
+
paths,
|
|
425
|
+
collectionRoots,
|
|
426
|
+
worktrees,
|
|
427
|
+
candidates: scan.candidates,
|
|
428
|
+
})
|
|
429
|
+
: null;
|
|
430
|
+
const report = sessions.length
|
|
431
|
+
? await postCodexSessionReport({
|
|
432
|
+
homeDir: command.homeDir,
|
|
433
|
+
repoRoot: reportContext?.repoRoot,
|
|
434
|
+
dashboardUrl,
|
|
435
|
+
sessions,
|
|
436
|
+
fetch: io.fetch,
|
|
437
|
+
now,
|
|
438
|
+
})
|
|
439
|
+
: emptyReport("no_sessions_observed");
|
|
440
|
+
const reportAcknowledged = sessions.length === 0 ||
|
|
441
|
+
(report.posted && report.recorded_count >= sessions.length);
|
|
442
|
+
if (!reportAcknowledged && !blockedAt) {
|
|
443
|
+
blockedAt = {
|
|
444
|
+
what: "session report failed",
|
|
445
|
+
batch_index: completedBatches,
|
|
446
|
+
batch_total: batches.length,
|
|
447
|
+
done,
|
|
448
|
+
total: uploadable.length,
|
|
549
449
|
};
|
|
450
|
+
failureReason ??=
|
|
451
|
+
report.posted && report.recorded_count < sessions.length
|
|
452
|
+
? "session_report_ack_incomplete"
|
|
453
|
+
: report.reason;
|
|
454
|
+
}
|
|
455
|
+
if (reportAcknowledged && durableCandidateKeys.size > 0) {
|
|
456
|
+
await recordBackfillDurableSessionPointers({
|
|
457
|
+
paths,
|
|
458
|
+
candidates: scan.candidates,
|
|
459
|
+
syncResults,
|
|
460
|
+
now,
|
|
461
|
+
});
|
|
550
462
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
463
|
+
// Raw evidence durability is necessary but not sufficient: the server must
|
|
464
|
+
// also acknowledge the session attribution rows before their historical
|
|
465
|
+
// cursor positions become irreversible.
|
|
466
|
+
if (reportAcknowledged) {
|
|
467
|
+
const cursorAdvanced = advanceBackfillCursorThroughResolvedPrefix({
|
|
468
|
+
cursor,
|
|
469
|
+
candidates: scan.candidates,
|
|
470
|
+
durableCandidateKeys,
|
|
471
|
+
retryableCandidateKeys: scan.retryable_candidate_keys,
|
|
472
|
+
discoveryComplete: !scan.issues.some((issue) => issue.scope === "global"),
|
|
473
|
+
now,
|
|
474
|
+
});
|
|
475
|
+
if (cursorAdvanced)
|
|
476
|
+
await writeBackfillCursor(paths, cursor);
|
|
554
477
|
}
|
|
478
|
+
const unresolvedUploadableKeys = new Set(uploadable
|
|
479
|
+
.filter((candidate) => !durableCandidateKeys.has(candidateCursorKey(candidate)))
|
|
480
|
+
.map(candidateCursorKey));
|
|
481
|
+
// "Blocking" views exclude a deterministic oversized skip (BLI-2727): it is
|
|
482
|
+
// still unresolved (it never gets a durable pointer), but it is a permanent,
|
|
483
|
+
// labeled fact rather than something completion should wait on forever.
|
|
484
|
+
const unresolvedUploadableBlocking = [...unresolvedUploadableKeys].filter((key) => !oversizedCandidateKeys.has(key)).length;
|
|
485
|
+
const unresolvedRetryableBlocking = [...scan.retryable_candidate_keys].filter((key) => !oversizedCandidateKeys.has(key)).length;
|
|
486
|
+
const unresolvedKnownKeys = new Set([
|
|
487
|
+
...unresolvedUploadableKeys,
|
|
488
|
+
...scan.retryable_candidate_keys,
|
|
489
|
+
]);
|
|
490
|
+
const unseenGlobalFailures = scan.issues
|
|
491
|
+
.filter((issue) => issue.scope === "global")
|
|
492
|
+
.reduce((total, issue) => total + issue.count, 0);
|
|
493
|
+
const reportRetryable = reportAcknowledged
|
|
494
|
+
? 0
|
|
495
|
+
// The cursor is intentionally all-or-nothing for the report. Even
|
|
496
|
+
// chunks already accepted by the server are retried idempotently when
|
|
497
|
+
// another required chunk lacks an acknowledgement.
|
|
498
|
+
: Math.max(1, sessions.length);
|
|
499
|
+
// `remaining` is a reporting total, not a completion gate: it still counts
|
|
500
|
+
// every unresolved candidate, including oversized skips, so the JSON output
|
|
501
|
+
// never goes silent about them (BLI-2727).
|
|
502
|
+
const remaining = unresolvedKnownKeys.size +
|
|
503
|
+
scan.omitted_candidate_count +
|
|
504
|
+
unseenGlobalFailures +
|
|
505
|
+
reportRetryable;
|
|
506
|
+
failed = Math.max(failed, unresolvedUploadableBlocking);
|
|
507
|
+
const blockingIssues = blockingScanIssues(scan.issues);
|
|
508
|
+
const completionBlocked = blockingIssues.length > 0 ||
|
|
509
|
+
unresolvedUploadableBlocking > 0 ||
|
|
510
|
+
unresolvedRetryableBlocking > 0 ||
|
|
511
|
+
deferred > 0 ||
|
|
512
|
+
failed > 0 ||
|
|
513
|
+
!reportAcknowledged;
|
|
514
|
+
if (!failureReason && completionBlocked) {
|
|
515
|
+
const retryableCandidateReason = scan.candidates.find((candidate) => scan.retryable_candidate_keys.has(candidateCursorKey(candidate)) &&
|
|
516
|
+
!oversizedCandidateKeys.has(candidateCursorKey(candidate)))?.reason;
|
|
517
|
+
failureReason =
|
|
518
|
+
blockingIssues[0]?.reason ??
|
|
519
|
+
(unresolvedUploadableBlocking > 0
|
|
520
|
+
? "durable_session_pointer_missing"
|
|
521
|
+
: retryableCandidateReason ?? "backfill_incomplete");
|
|
522
|
+
}
|
|
523
|
+
const status = blockedAt || completionBlocked ? "partial" : "complete";
|
|
524
|
+
// The marker is consumed by doctor/status as proof that archived history
|
|
525
|
+
// is covered. A bounded --since-days run may complete its requested
|
|
526
|
+
// window, but it is not proof of an all-history backfill.
|
|
527
|
+
if (status === "complete" && command.all) {
|
|
528
|
+
recordBackfillScanCoverage(cursor, sources, now, now);
|
|
529
|
+
await writeBackfillCursor(paths, cursor);
|
|
530
|
+
const oversizedCandidates = scan.candidates.filter((candidate) => oversizedCandidateKeys.has(candidateCursorKey(candidate)));
|
|
531
|
+
await writeBackfillCompletionMarker(paths, {
|
|
532
|
+
schema_version: "cockpit-backfill-complete.v2",
|
|
533
|
+
coverage_version: BACKFILL_COVERAGE_VERSION,
|
|
534
|
+
collection_scope_id: scopedCursor.collection_scope_id,
|
|
535
|
+
sources,
|
|
536
|
+
completed_at: now.toISOString(),
|
|
537
|
+
revalidate_after: new Date(now.getTime() + BACKFILL_COMPLETION_RECHECK_MS).toISOString(),
|
|
538
|
+
cursor,
|
|
539
|
+
...(oversizedCandidates.length > 0
|
|
540
|
+
? {
|
|
541
|
+
oversized_skips: {
|
|
542
|
+
reason: "file_too_large",
|
|
543
|
+
count: oversizedCandidates.length,
|
|
544
|
+
byte_sizes: oversizedCandidates.map((candidate) => candidate.byte_size),
|
|
545
|
+
},
|
|
546
|
+
}
|
|
547
|
+
: {}),
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
...baseBackfillResult(command, backfillResultBaseArgs(ctx)),
|
|
552
|
+
status,
|
|
553
|
+
retry_command: retryCommand,
|
|
554
|
+
counts: {
|
|
555
|
+
...baseBackfillResult(command, backfillResultBaseArgs(ctx)).counts,
|
|
556
|
+
backfilled: backfilledSessions,
|
|
557
|
+
failed,
|
|
558
|
+
deferred,
|
|
559
|
+
remaining,
|
|
560
|
+
},
|
|
561
|
+
batches: {
|
|
562
|
+
total: batches.length,
|
|
563
|
+
completed: completedBatches,
|
|
564
|
+
failed: failedBatches,
|
|
565
|
+
},
|
|
566
|
+
report,
|
|
567
|
+
server_acknowledged: {
|
|
568
|
+
codex_session_report_recorded_count: report.recorded_count,
|
|
569
|
+
raw_evidence_uploaded_object_count: uploadedObjects,
|
|
570
|
+
raw_evidence_uploaded_chunk_count: uploadedChunks,
|
|
571
|
+
},
|
|
572
|
+
blocked_at: blockedAt,
|
|
573
|
+
failure_reason: failureReason,
|
|
574
|
+
};
|
|
555
575
|
}
|
|
556
576
|
function defaultBackfillWindowNotice() {
|
|
557
577
|
return [
|
|
@@ -951,6 +971,71 @@ function oversizedBackfillCandidateKeys(candidates) {
|
|
|
951
971
|
}
|
|
952
972
|
return keys;
|
|
953
973
|
}
|
|
974
|
+
// --- Scan issue ledger --------------------------------------------------
|
|
975
|
+
//
|
|
976
|
+
// `scan.issues` is the running ledger of everything the scan could not
|
|
977
|
+
// account for cleanly. Every write to it goes through one of the named
|
|
978
|
+
// operations below instead of a bare `.push`/`.sort`, so the ledger's shape
|
|
979
|
+
// (global vs candidate vs selection scope, priority order) has one owner.
|
|
980
|
+
/** Record one issue on the ledger. */
|
|
981
|
+
function addScanIssue(issues, issue) {
|
|
982
|
+
issues.push(issue);
|
|
983
|
+
}
|
|
984
|
+
/** Repo discovery could not fully enumerate a root: one global issue per reason. */
|
|
985
|
+
function addRepoDiscoveryIssues(issues, incompleteReasons) {
|
|
986
|
+
for (const reason of incompleteReasons) {
|
|
987
|
+
addScanIssue(issues, {
|
|
988
|
+
reason: `repo_discovery_${reason}`,
|
|
989
|
+
count: 1,
|
|
990
|
+
scope: "global",
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
/** Stable read order: global issues first, then candidate, then selection; alphabetical within a scope. */
|
|
995
|
+
function sortScanIssuesByPriority(issues) {
|
|
996
|
+
issues.sort((a, b) => scanIssuePriority(a.scope) - scanIssuePriority(b.scope) ||
|
|
997
|
+
a.reason.localeCompare(b.reason));
|
|
998
|
+
}
|
|
999
|
+
/** The read-only guard pass's two aggregate outcomes, each recorded once if it fired at all. */
|
|
1000
|
+
function addReadOnlyGuardIssues(issues, guardCounts) {
|
|
1001
|
+
const readFailed = guardCounts.get("file_read_failed");
|
|
1002
|
+
if (readFailed) {
|
|
1003
|
+
addScanIssue(issues, {
|
|
1004
|
+
reason: "candidate_file_read_failed",
|
|
1005
|
+
count: readFailed,
|
|
1006
|
+
scope: "candidate",
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
const tooLarge = guardCounts.get("file_too_large");
|
|
1010
|
+
if (tooLarge) {
|
|
1011
|
+
addScanIssue(issues, {
|
|
1012
|
+
reason: "file_too_large",
|
|
1013
|
+
count: tooLarge,
|
|
1014
|
+
scope: "candidate",
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
// BLI-2727: a deterministic, labeled oversized skip must never poison
|
|
1019
|
+
// completion — it is a permanent, non-retryable fact about the file, not an
|
|
1020
|
+
// in-flight problem a rerun can fix. `scan.issues`/`retryable_candidate_keys`
|
|
1021
|
+
// still carry it (so it's never silently dropped from reporting); every
|
|
1022
|
+
// completion-gating computation excludes it explicitly instead, by filtering
|
|
1023
|
+
// through `blockingScanIssues` below.
|
|
1024
|
+
//
|
|
1025
|
+
// Two scan issues describe the exact same oversized-main candidates:
|
|
1026
|
+
// `backfillScanIssues` pushes the Claude-specific `claude_main_file_too_large`
|
|
1027
|
+
// (from `claude?.main_file_oversized`) and `countReadOnlyGuards` (via
|
|
1028
|
+
// `addReadOnlyGuardIssues` above) separately pushes the source-agnostic
|
|
1029
|
+
// `file_too_large` (same predicate as `oversizedBackfillCandidateKeys`, so
|
|
1030
|
+
// this list can never drift from it). Both must be excluded from
|
|
1031
|
+
// completion-gating together.
|
|
1032
|
+
const OVERSIZED_SCAN_ISSUE_REASONS = new Set([
|
|
1033
|
+
"file_too_large",
|
|
1034
|
+
"claude_main_file_too_large",
|
|
1035
|
+
]);
|
|
1036
|
+
function blockingScanIssues(issues) {
|
|
1037
|
+
return issues.filter((issue) => !OVERSIZED_SCAN_ISSUE_REASONS.has(issue.reason));
|
|
1038
|
+
}
|
|
954
1039
|
async function countUnreadableClaudeSidecarDirs(candidates) {
|
|
955
1040
|
let unreadable = 0;
|
|
956
1041
|
for (const candidate of candidates) {
|