@bli-cockpit/cli 0.2.34 → 0.2.36

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.
@@ -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 blockedBackfillResult(command, {
67
- now,
68
- dashboardUrl: sessionFile.dashboard_url ?? config.dashboard_url,
69
- reason: "collector_not_paired",
70
- cursor: await readBackfillCursor(paths),
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
- for (const reason of worktreeDiscovery.incomplete_reasons) {
105
- scan.issues.push({
106
- reason: `repo_discovery_${reason}`,
107
- count: 1,
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
- if (guards.counts.get("file_read_failed")) {
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
- // Two scan issues describe the exact same oversized-main candidates:
140
- // `backfillScanIssues` pushes the Claude-specific `claude_main_file_too_large`
141
- // (from `claude?.main_file_oversized`) and `countReadOnlyGuards` separately
142
- // pushes the source-agnostic `file_too_large` (same predicate as
143
- // `oversizedBackfillCandidateKeys`, so this list can never drift from it).
144
- // Both must be excluded from completion-gating together.
145
- const OVERSIZED_SCAN_ISSUE_REASONS = new Set([
146
- "file_too_large",
147
- "claude_main_file_too_large",
148
- ]);
149
- const blockingScanIssues = (issues) => issues.filter((issue) => !OVERSIZED_SCAN_ISSUE_REASONS.has(issue.reason));
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
- ...baseBackfillResult(command, {
163
- now,
164
- dashboardUrl,
165
- sources,
166
- window,
167
- cursor,
168
- scan,
169
- reasonCounts,
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
- ...baseBackfillResult(command, {
199
- now,
200
- dashboardUrl,
201
- sources,
202
- window,
203
- cursor,
204
- scan,
205
- reasonCounts,
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
- try {
272
- // Backfill and scheduled/manual sync share upload spool and raw-evidence
273
- // cursors. Holding the same collection lock makes their read-modify-write
274
- // updates serial, while the dedicated backfill lock still prevents two
275
- // historical scans from running together.
276
- if (scopedCursor.reset) {
277
- await writeBackfillCursor(paths, cursor);
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
- const uploadable = uploadableCandidates(scan.candidates);
280
- const batches = buildBackfillBatches(uploadable);
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
- const sessions = buildBackfillSessionReport({
376
- candidates: scan.candidates,
377
- syncResults,
378
- now,
379
- });
380
- const reportContext = sessions.length
381
- ? await ensureBackfillReportContext({
382
- homeDir: command.homeDir,
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: "session report failed",
404
- batch_index: completedBatches,
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
- report.posted && report.recorded_count < sessions.length
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
- const unresolvedUploadableKeys = new Set(uploadable
438
- .filter((candidate) => !durableCandidateKeys.has(candidateCursorKey(candidate)))
439
- .map(candidateCursorKey));
440
- // "Blocking" views exclude a deterministic oversized skip (BLI-2727): it is
441
- // still unresolved (it never gets a durable pointer), but it is a permanent,
442
- // labeled fact rather than something completion should wait on forever.
443
- const unresolvedUploadableBlocking = [...unresolvedUploadableKeys].filter((key) => !oversizedCandidateKeys.has(key)).length;
444
- const unresolvedRetryableBlocking = [...scan.retryable_candidate_keys].filter((key) => !oversizedCandidateKeys.has(key)).length;
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
- blockingIssues[0]?.reason ??
478
- (unresolvedUploadableBlocking > 0
377
+ sync.status === "spooled"
378
+ ? sync.failure_reason
379
+ : missingDurableMain > 0
479
380
  ? "durable_session_pointer_missing"
480
- : retryableCandidateReason ?? "backfill_incomplete");
381
+ : "upload_failed";
382
+ break;
481
383
  }
482
- const status = blockedAt || completionBlocked ? "partial" : "complete";
483
- // The marker is consumed by doctor/status as proof that archived history
484
- // is covered. A bounded --since-days run may complete its requested
485
- // window, but it is not proof of an all-history backfill.
486
- if (status === "complete" && command.all) {
487
- recordBackfillScanCoverage(cursor, sources, now, now);
488
- await writeBackfillCursor(paths, cursor);
489
- const oversizedCandidates = scan.candidates.filter((candidate) => oversizedCandidateKeys.has(candidateCursorKey(candidate)));
490
- await writeBackfillCompletionMarker(paths, {
491
- schema_version: "cockpit-backfill-complete.v2",
492
- coverage_version: BACKFILL_COVERAGE_VERSION,
493
- collection_scope_id: scopedCursor.collection_scope_id,
494
- sources,
495
- completed_at: now.toISOString(),
496
- revalidate_after: new Date(now.getTime() + BACKFILL_COMPLETION_RECHECK_MS).toISOString(),
497
- cursor,
498
- ...(oversizedCandidates.length > 0
499
- ? {
500
- oversized_skips: {
501
- reason: "file_too_large",
502
- count: oversizedCandidates.length,
503
- byte_sizes: oversizedCandidates.map((candidate) => candidate.byte_size),
504
- },
505
- }
506
- : {}),
507
- });
508
- }
509
- return {
510
- ...baseBackfillResult(command, {
511
- now,
512
- dashboardUrl,
513
- sources,
514
- window,
515
- cursor,
516
- scan,
517
- reasonCounts,
518
- }),
519
- status,
520
- retry_command: retryCommand,
521
- counts: {
522
- ...baseBackfillResult(command, {
523
- now,
524
- dashboardUrl,
525
- sources,
526
- window,
527
- cursor,
528
- scan,
529
- reasonCounts,
530
- }).counts,
531
- backfilled: backfilledSessions,
532
- failed,
533
- deferred,
534
- remaining,
535
- },
536
- batches: {
537
- total: batches.length,
538
- completed: completedBatches,
539
- failed: failedBatches,
540
- },
541
- report,
542
- server_acknowledged: {
543
- codex_session_report_recorded_count: report.recorded_count,
544
- raw_evidence_uploaded_object_count: uploadedObjects,
545
- raw_evidence_uploaded_chunk_count: uploadedChunks,
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
- finally {
552
- await collectionLock.handle.release();
553
- await lock.handle.release();
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) {