@bli-cockpit/cli 0.2.47 → 0.2.49

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.
Files changed (42) hide show
  1. package/dist/adapters/raw-evidence-attribution-gaps.js +133 -0
  2. package/dist/adapters/raw-evidence.js +360 -349
  3. package/dist/autostart-contract.js +79 -0
  4. package/dist/autostart-darwin-plist.js +265 -0
  5. package/dist/autostart-darwin.js +171 -0
  6. package/dist/autostart-windows-scripts.js +310 -0
  7. package/dist/autostart-windows-task-xml.js +260 -0
  8. package/dist/autostart-windows.js +237 -0
  9. package/dist/autostart-xml.js +23 -0
  10. package/dist/autostart.js +35 -1148
  11. package/dist/commands/agent-rules-command.js +55 -0
  12. package/dist/commands/agent-session-report.js +290 -0
  13. package/dist/commands/analyze.js +131 -0
  14. package/dist/commands/autostart-command.js +105 -0
  15. package/dist/commands/backfill.js +824 -551
  16. package/dist/commands/cli-io.js +13 -0
  17. package/dist/commands/heartbeat.js +18 -0
  18. package/dist/commands/install-receipts.js +34 -0
  19. package/dist/commands/jarvis.js +179 -3
  20. package/dist/commands/local-arg-values.js +169 -0
  21. package/dist/commands/local-args-collector.js +578 -0
  22. package/dist/commands/local-args-tower.js +870 -0
  23. package/dist/commands/local-args.js +8 -1549
  24. package/dist/commands/local-help.js +11 -3
  25. package/dist/commands/local.js +18 -1786
  26. package/dist/commands/login.js +53 -0
  27. package/dist/commands/logout.js +66 -0
  28. package/dist/commands/onboard-receipts.js +66 -0
  29. package/dist/commands/onboard-report.js +274 -0
  30. package/dist/commands/onboard.js +449 -0
  31. package/dist/commands/ops-render.js +36 -0
  32. package/dist/commands/public-root.js +1 -1
  33. package/dist/commands/serve.js +13 -0
  34. package/dist/commands/session-sync.js +513 -534
  35. package/dist/commands/settings-render.js +28 -0
  36. package/dist/commands/settings.js +66 -2
  37. package/dist/commands/start.js +47 -0
  38. package/dist/commands/sync-followups.js +203 -0
  39. package/dist/commands/sync.js +381 -0
  40. package/dist/dev-build.js +186 -0
  41. package/dist/tower-stream.js +20 -4
  42. package/package.json +2 -2
@@ -1,3 +1,25 @@
1
+ /**
2
+ * `cockpit backfill` — collect the archived Codex and Claude history that the
3
+ * every-15-minutes sync was never running to see.
4
+ *
5
+ * Read `runBackfill` below as the table of contents. It is four stages, in
6
+ * order, and every function in this file belongs to exactly one of them:
7
+ *
8
+ * SCAN resolveBackfillScope paired? which roots, stores and window
9
+ * scanBackfillSessions read both stores into candidates
10
+ * auditScannedCandidates census what could not be accounted for
11
+ * PLAN planBackfillRun confirm `--all`, or answer a `--dry-run`
12
+ * UPLOAD uploadBackfillBatches sync in batches under the shared lock
13
+ * REPORT reportBackfillOutcome post, checkpoint, decide, assemble
14
+ *
15
+ * Two rules run through all four and explain most of the apparent complexity.
16
+ * First, a session may never be silently dropped: everything the scan could
17
+ * not account for lands on the issue ledger and everything a person reads
18
+ * names its own reason. Second, the cursor is the only irreversible thing here
19
+ * — it advances solely through a contiguous prefix of sessions that both
20
+ * earned a durable pointer and were acknowledged by the server, so a
21
+ * misjudged "resolved" loses that history for good.
22
+ */
1
23
  import { NO_UPLOAD_ATTEMPT_RECORDED, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, notUploadableAttributionStateReason } from "@bli-cockpit/telemetry-core";
2
24
  import crypto from "node:crypto";
3
25
  import fs from "node:fs/promises";
@@ -75,19 +97,10 @@ export async function runBackfill(command, io) {
75
97
  const { paths } = scanned;
76
98
  const lock = await acquireBackfillLock(paths, now);
77
99
  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
- };
100
+ return lockHeldResult(command, scanned, {
101
+ failureReason: "backfill_already_running",
102
+ what: "backfill lock held",
103
+ });
91
104
  }
92
105
  let collectionLock;
93
106
  try {
@@ -99,19 +112,10 @@ export async function runBackfill(command, io) {
99
112
  }
100
113
  if (!collectionLock.acquired) {
101
114
  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
+ return lockHeldResult(command, scanned, {
116
+ failureReason: "sync_already_running",
117
+ what: "sync collection lock held",
118
+ });
115
119
  }
116
120
  try {
117
121
  // Backfill and scheduled/manual sync share upload spool and raw-evidence
@@ -129,6 +133,26 @@ export async function runBackfill(command, io) {
129
133
  await lock.handle.release();
130
134
  }
131
135
  }
136
+ /**
137
+ * Someone else is already holding a lock this run needs. Reported as blocked
138
+ * with nothing done rather than as a failure — the scan is still valid, and
139
+ * the retry command in it works as soon as the other run finishes.
140
+ */
141
+ function lockHeldResult(command, scanned, blocker) {
142
+ return {
143
+ ...baseBackfillResult(command, backfillResultBaseArgs(scanned)),
144
+ status: "blocked",
145
+ retry_command: scanned.retryCommand,
146
+ failure_reason: blocker.failureReason,
147
+ blocked_at: {
148
+ what: blocker.what,
149
+ batch_index: 0,
150
+ batch_total: 0,
151
+ done: 0,
152
+ total: scanned.scan.candidates.length,
153
+ },
154
+ };
155
+ }
132
156
  /** The `{now, dashboardUrl, sources, window, cursor, scan, reasonCounts}` bag every `baseBackfillResult` call needs. */
133
157
  function backfillResultBaseArgs(ctx) {
134
158
  return {
@@ -148,34 +172,19 @@ function backfillResultBaseArgs(ctx) {
148
172
  */
149
173
  async function scanBackfillRun(command, io, now) {
150
174
  const paths = getCollectorRuntimePaths(command.homeDir);
151
- const config = await readLocalCollectorConfig(paths);
152
- const sessionFile = await readLocalCollectorSessionFile(paths);
153
- const session = await readLocalSessionReference(paths);
154
- if (session.session_state !== "valid") {
175
+ const scope = await resolveBackfillScope(command, io, now, paths);
176
+ if (scope.kind === "not_paired") {
155
177
  return {
156
178
  kind: "blocked",
157
179
  result: blockedBackfillResult(command, {
158
180
  now,
159
- dashboardUrl: sessionFile.dashboard_url ?? config.dashboard_url,
181
+ dashboardUrl: scope.dashboardUrl,
160
182
  reason: "collector_not_paired",
161
183
  cursor: await readBackfillCursor(paths),
162
184
  }),
163
185
  };
164
186
  }
165
- const pairedAt = parseRequiredDate(sessionFile.paired_at, "paired_at");
166
- const dashboardUrl = normalizeDashboardUrl(sessionFile.dashboard_url ?? config.dashboard_url);
167
- const roots = backfillCollectionRoots(command, config.default_repo_paths);
168
- const collectionRoots = normalizeCollectionRoots(await collectionRootPathAliases(roots));
169
- const worktreeDiscovery = await discoverBackfillWorktrees(collectionRoots, command);
170
- const retryCommand = backfillDiscoveryRetryCommand(command, worktreeDiscovery);
171
- const worktrees = worktreeDiscovery.worktrees;
172
- const sources = selectedSources(command.source);
173
- const window = backfillWindow(command, now, pairedAt);
174
- await validateBackfillReachability({
175
- fetchImpl: io.fetch,
176
- dashboardUrl,
177
- dryRun: command.dryRun,
178
- });
187
+ const { collectionRoots, sources, worktreeDiscovery } = scope;
179
188
  const storedCursor = command.dryRun
180
189
  ? emptyBackfillCursorState()
181
190
  : await readBackfillCursor(paths);
@@ -185,43 +194,88 @@ async function scanBackfillRun(command, io, now) {
185
194
  const scan = await scanBackfillSessions({
186
195
  command,
187
196
  homeDir: command.homeDir ?? os.homedir(),
188
- worktrees,
197
+ worktrees: worktreeDiscovery.worktrees,
189
198
  collectionRoots,
190
199
  sources,
191
- window,
200
+ window: scope.window,
192
201
  cursor,
193
202
  pointerlessTerminalSessions,
194
203
  now,
195
204
  });
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);
200
- const guards = await countReadOnlyGuards(scan.candidates);
201
- scan.retryable_candidate_keys = new Set([
202
- ...scan.retryable_candidate_keys,
203
- ...guards.retryable_candidate_keys,
204
- ]);
205
- addReadOnlyGuardIssues(scan.issues, guards.counts);
206
- const reasonCounts = reasonCountsFor(scan.candidates, guards.counts, scan.issues);
207
- const oversizedCandidateKeys = oversizedBackfillCandidateKeys(scan.candidates);
205
+ const reasonCounts = await auditScannedCandidates(scan, worktreeDiscovery.incomplete_reasons);
208
206
  return {
209
207
  kind: "scanned",
210
208
  now,
211
209
  paths,
212
- dashboardUrl,
210
+ dashboardUrl: scope.dashboardUrl,
213
211
  sources,
214
- window,
212
+ window: scope.window,
215
213
  collectionRoots,
216
- worktrees,
217
- retryCommand,
214
+ worktrees: worktreeDiscovery.worktrees,
215
+ retryCommand: scope.retryCommand,
218
216
  scopedCursor,
219
217
  cursor,
220
218
  scan,
221
219
  reasonCounts,
222
- oversizedCandidateKeys,
220
+ oversizedCandidateKeys: oversizedBackfillCandidateKeys(scan.candidates),
223
221
  };
224
222
  }
223
+ /**
224
+ * Answers the four questions every later stage assumes: is this collector
225
+ * paired, which approved roots and repos does it cover, which stores and how
226
+ * far back was it asked for, and — on a dry run — can it even reach the
227
+ * dashboard. Nothing here reads a session; a machine that is not paired stops
228
+ * before any history is touched.
229
+ */
230
+ async function resolveBackfillScope(command, io, now, paths) {
231
+ const config = await readLocalCollectorConfig(paths);
232
+ const sessionFile = await readLocalCollectorSessionFile(paths);
233
+ const session = await readLocalSessionReference(paths);
234
+ if (session.session_state !== "valid") {
235
+ return {
236
+ kind: "not_paired",
237
+ dashboardUrl: sessionFile.dashboard_url ?? config.dashboard_url,
238
+ };
239
+ }
240
+ const pairedAt = parseRequiredDate(sessionFile.paired_at, "paired_at");
241
+ const dashboardUrl = normalizeDashboardUrl(sessionFile.dashboard_url ?? config.dashboard_url);
242
+ const roots = backfillCollectionRoots(command, config.default_repo_paths);
243
+ const collectionRoots = normalizeCollectionRoots(await collectionRootPathAliases(roots));
244
+ const worktreeDiscovery = await discoverBackfillWorktrees(collectionRoots, command);
245
+ const window = backfillWindow(command, now, pairedAt);
246
+ await validateBackfillReachability({
247
+ fetchImpl: io.fetch,
248
+ dashboardUrl,
249
+ dryRun: command.dryRun,
250
+ });
251
+ return {
252
+ kind: "ready",
253
+ dashboardUrl,
254
+ collectionRoots,
255
+ worktreeDiscovery,
256
+ retryCommand: backfillDiscoveryRetryCommand(command, worktreeDiscovery),
257
+ sources: selectedSources(command.source),
258
+ window,
259
+ };
260
+ }
261
+ /**
262
+ * Finishes the scan's ledger before anything acts on it: adds what repo
263
+ * discovery could not enumerate, reads every candidate file once to find the
264
+ * ones this machine cannot actually deliver, and folds all of it into the
265
+ * reason table the operator sees. Mutates `scan` in place — it is the one
266
+ * owner of `issues` and `retryable_candidate_keys` — and returns the table.
267
+ */
268
+ async function auditScannedCandidates(scan, discoveryIncompleteReasons) {
269
+ addRepoDiscoveryIssues(scan.issues, discoveryIncompleteReasons);
270
+ sortScanIssuesByPriority(scan.issues);
271
+ const guards = await countReadOnlyGuards(scan.candidates);
272
+ scan.retryable_candidate_keys = new Set([
273
+ ...scan.retryable_candidate_keys,
274
+ ...guards.retryable_candidate_keys,
275
+ ]);
276
+ addReadOnlyGuardIssues(scan.issues, guards.counts);
277
+ return reasonCountsFor(scan.candidates, guards.counts, scan.issues);
278
+ }
225
279
  /**
226
280
  * PLAN: given the scan, should this run actually upload anything right now?
227
281
  * `--all` without `--yes` needs interactive confirmation; `--dry-run` reports
@@ -229,7 +283,7 @@ async function scanBackfillRun(command, io, now) {
229
283
  * anything else proceeds to UPLOAD.
230
284
  */
231
285
  async function planBackfillRun(command, io, ctx) {
232
- const { dashboardUrl, scan, reasonCounts, retryCommand } = ctx;
286
+ const { dashboardUrl, scan, reasonCounts } = ctx;
233
287
  if (command.all && !command.yes) {
234
288
  if (!command.json) {
235
289
  writeDryRunSummary(io, {
@@ -241,18 +295,7 @@ async function planBackfillRun(command, io, ctx) {
241
295
  }
242
296
  const confirmed = await confirmAllBackfill(io);
243
297
  if (!confirmed) {
244
- return {
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",
254
- },
255
- };
298
+ return { kind: "result", result: confirmationDeclinedResult(command, ctx) };
256
299
  }
257
300
  }
258
301
  if (command.dryRun) {
@@ -264,21 +307,37 @@ async function planBackfillRun(command, io, ctx) {
264
307
  dryRunOnly: true,
265
308
  });
266
309
  }
267
- return {
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
- : {}),
277
- },
278
- };
310
+ return { kind: "result", result: dryRunResult(command, ctx) };
279
311
  }
280
312
  return { kind: "proceed" };
281
313
  }
314
+ /** The operator saw the `--all` review and said no. Nothing was written; everything remains. */
315
+ function confirmationDeclinedResult(command, ctx) {
316
+ const base = baseBackfillResult(command, backfillResultBaseArgs(ctx));
317
+ return {
318
+ ...base,
319
+ status: "blocked",
320
+ counts: { ...base.counts, remaining: ctx.scan.candidates.length },
321
+ failure_reason: "confirmation_declined",
322
+ };
323
+ }
324
+ /**
325
+ * What a `--dry-run` would have done. It completes unless the scan itself hit
326
+ * something blocking, because a dry run that reports "complete" over a scan
327
+ * that could not see all the history would be the confident wrong answer.
328
+ */
329
+ function dryRunResult(command, ctx) {
330
+ const blockingIssues = blockingScanIssues(ctx.scan.issues);
331
+ return {
332
+ ...baseBackfillResult(command, backfillResultBaseArgs(ctx)),
333
+ status: blockingIssues.length > 0 ? "partial" : "complete",
334
+ dry_run: true,
335
+ retry_command: ctx.retryCommand,
336
+ ...(blockingIssues.length > 0
337
+ ? { failure_reason: blockingIssues[0]?.reason }
338
+ : {}),
339
+ };
340
+ }
282
341
  /**
283
342
  * UPLOAD: sync every uploadable candidate in fixed-size batches, heartbeating
284
343
  * the backfill lock between batches, stopping early on an exhausted budget or
@@ -289,116 +348,124 @@ async function planBackfillRun(command, io, ctx) {
289
348
  * siblings, so it is excluded via `oversizedCandidateKeys` throughout.
290
349
  */
291
350
  async function uploadBackfillBatches(command, io, lock, ctx) {
292
- const { worktrees, scan, oversizedCandidateKeys } = ctx;
293
- const uploadable = uploadableCandidates(scan.candidates);
351
+ const uploadable = uploadableCandidates(ctx.scan.candidates);
294
352
  const batches = buildBackfillBatches(uploadable);
295
353
  const rawEvidenceBudget = {
296
354
  remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
297
355
  remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
298
356
  };
299
- const syncResults = [];
300
- let completedBatches = 0;
301
- let failedBatches = 0;
357
+ const tally = emptyUploadTally();
302
358
  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
359
  let blockedAt;
311
- let failureReason = blockingScanIssues(scan.issues)[0]?.reason;
360
+ let failureReason = blockingScanIssues(ctx.scan.issues)[0]?.reason;
361
+ const stoppedAt = (what, index) => ({
362
+ what,
363
+ batch_index: index + 1,
364
+ batch_total: batches.length,
365
+ done: tally.done,
366
+ total: uploadable.length,
367
+ });
312
368
  for (const [index, batch] of batches.entries()) {
313
369
  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;
346
- }
347
- else {
348
- consecutiveFailures = 0;
349
- }
350
- if (!batchFailed && !batchDeferred) {
351
- completedBatches += 1;
352
- }
370
+ const outcome = await uploadOneBackfillBatch(command, io, ctx, batch, rawEvidenceBudget);
371
+ foldBatchIntoTally(tally, batch, outcome);
372
+ consecutiveFailures = outcome.failed ? consecutiveFailures + 1 : 0;
353
373
  if (!command.json) {
354
- writeLine(io.stdout, `Uploaded ${done}/${uploadable.length} (batch ${index + 1}/${batches.length})`);
374
+ writeLine(io.stdout, `Uploaded ${tally.done}/${uploadable.length} (batch ${index + 1}/${batches.length})`);
355
375
  }
356
376
  await yieldToEventLoop();
357
- if (batchDeferred) {
358
- blockedAt = {
359
- what: "raw evidence budget exhausted",
360
- batch_index: index + 1,
361
- batch_total: batches.length,
362
- done,
363
- total: uploadable.length,
364
- };
377
+ if (outcome.deferred) {
378
+ blockedAt = stoppedAt("raw evidence budget exhausted", index);
365
379
  failureReason = "deferred_budget_exhausted";
366
380
  break;
367
381
  }
368
382
  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
- };
376
- failureReason =
377
- sync.status === "spooled"
378
- ? sync.failure_reason
379
- : missingDurableMain > 0
380
- ? "durable_session_pointer_missing"
381
- : "upload_failed";
383
+ blockedAt = stoppedAt("consecutive upload failures", index);
384
+ failureReason = failedBatchReason(outcome.sync, outcome.missingDurableMain);
382
385
  break;
383
386
  }
384
387
  }
388
+ return { ...tally, uploadable, batches, blockedAt, failureReason };
389
+ }
390
+ /** Uploads one batch and judges it; every judgement here is a named predicate. */
391
+ async function uploadOneBackfillBatch(command, io, ctx, batch, rawEvidenceBudget) {
392
+ const sync = await syncBackfillBatch({
393
+ command,
394
+ batch,
395
+ worktrees: ctx.worktrees,
396
+ codexAttribution: ctx.scan.codexAttribution,
397
+ claudeAttribution: ctx.scan.claudeAttribution,
398
+ rawEvidenceBudget,
399
+ fetchImpl: io.fetch,
400
+ });
401
+ const durableInBatch = durableBackfillCandidateKeys(batch, sync);
402
+ const missingDurableMain = countMissingDurableMains(batch, durableInBatch, ctx.oversizedCandidateKeys);
385
403
  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,
404
+ sync,
405
+ durableInBatch,
406
+ missingDurableMain,
407
+ failed: didBatchFail(sync, missingDurableMain),
408
+ deferred: deferredEvidenceCount(sync) > 0,
409
+ };
410
+ }
411
+ function emptyUploadTally() {
412
+ return {
413
+ syncResults: [],
414
+ completedBatches: 0,
415
+ failedBatches: 0,
416
+ done: 0,
417
+ failed: 0,
418
+ deferred: 0,
419
+ uploadedObjects: 0,
420
+ uploadedChunks: 0,
421
+ backfilledSessions: 0,
422
+ durableCandidateKeys: new Set(),
400
423
  };
401
424
  }
425
+ function foldBatchIntoTally(tally, batch, outcome) {
426
+ tally.syncResults.push(outcome.sync);
427
+ tally.done += batch.candidates.length;
428
+ tally.uploadedObjects += outcome.sync.raw_evidence_uploaded_object_count;
429
+ tally.uploadedChunks += outcome.sync.raw_evidence_uploaded_chunk_count;
430
+ tally.failed += countSessionUploadFailures(outcome.sync);
431
+ tally.deferred += deferredEvidenceCount(outcome.sync);
432
+ for (const key of outcome.durableInBatch)
433
+ tally.durableCandidateKeys.add(key);
434
+ tally.backfilledSessions = tally.durableCandidateKeys.size;
435
+ if (outcome.failed)
436
+ tally.failedBatches += 1;
437
+ if (!outcome.failed && !outcome.deferred)
438
+ tally.completedBatches += 1;
439
+ }
440
+ /** Evidence the server accepted the batch for but had no budget left to store. */
441
+ function deferredEvidenceCount(sync) {
442
+ return (sync.raw_evidence_deferred_byte_budget +
443
+ sync.raw_evidence_deferred_object_budget);
444
+ }
445
+ /**
446
+ * Candidates in this batch that still owe a durable main transcript. An
447
+ * oversized skip is excluded (BLI-2727): it can never earn a pointer under the
448
+ * current cap, and counting it here would fail every batch it happens to share
449
+ * with genuinely uploaded siblings.
450
+ */
451
+ function countMissingDurableMains(batch, durableInBatch, oversizedCandidateKeys) {
452
+ return batch.candidates.filter((candidate) => !durableInBatch.has(candidateCursorKey(candidate)) &&
453
+ !oversizedCandidateKeys.has(candidateCursorKey(candidate))).length;
454
+ }
455
+ /** A batch counts as failed if it did not upload, lost a session, or left one pointerless. */
456
+ function didBatchFail(sync, missingDurableMain) {
457
+ return (sync.status !== "uploaded" ||
458
+ countSessionUploadFailures(sync) > 0 ||
459
+ missingDurableMain > 0);
460
+ }
461
+ /** Names which of the three failure shapes ended the run, most specific first. */
462
+ function failedBatchReason(sync, missingDurableMain) {
463
+ if (sync.status === "spooled")
464
+ return sync.failure_reason;
465
+ if (missingDurableMain > 0)
466
+ return "durable_session_pointer_missing";
467
+ return "upload_failed";
468
+ }
402
469
  /**
403
470
  * REPORT: post the session report, record durable pointers, advance the
404
471
  * cursor through the resolved contiguous prefix, decide completion, write the
@@ -410,107 +477,150 @@ async function uploadBackfillBatches(command, io, lock, ctx) {
410
477
  * excludes it explicitly via `oversizedCandidateKeys`/`blockingScanIssues`.
411
478
  */
412
479
  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;
480
+ let { blockedAt, failureReason } = upload;
481
+ const posted = await postBackfillSessionReport(command, io, ctx, upload);
482
+ if (!posted.acknowledged && !blockedAt) {
483
+ blockedAt = sessionReportStopPoint(upload);
484
+ failureReason ??= sessionReportFailureReason(posted);
485
+ }
486
+ if (posted.acknowledged) {
487
+ await checkpointResolvedBackfillProgress(ctx, upload);
488
+ }
489
+ const completion = summarizeBackfillCompletion({
490
+ ctx,
491
+ upload,
492
+ posted,
493
+ stoppedEarly: Boolean(blockedAt),
494
+ failureReason,
495
+ });
496
+ if (completion.status === "complete" && command.all) {
497
+ await writeAllHistoryCompletionMarker(ctx);
498
+ }
499
+ return assembleBackfillResult({
500
+ command,
501
+ ctx,
502
+ upload,
503
+ posted,
504
+ completion,
505
+ blockedAt,
506
+ });
507
+ }
508
+ /**
509
+ * Tells the server what this run observed about every session, uploaded or
510
+ * not. Its acknowledgement — not raw-evidence durability alone — is what makes
511
+ * a historical cursor position irreversible, so the caller gates the whole
512
+ * checkpoint on the `acknowledged` flag returned here.
513
+ */
514
+ async function postBackfillSessionReport(command, io, ctx, upload) {
416
515
  const sessions = buildBackfillSessionReport({
417
- candidates: scan.candidates,
418
- syncResults,
419
- now,
516
+ candidates: ctx.scan.candidates,
517
+ syncResults: upload.syncResults,
518
+ now: ctx.now,
420
519
  });
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,
520
+ if (sessions.length === 0) {
521
+ return {
435
522
  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,
523
+ report: emptyReport("no_sessions_observed"),
524
+ acknowledged: true,
449
525
  };
450
- failureReason ??=
451
- report.posted && report.recorded_count < sessions.length
452
- ? "session_report_ack_incomplete"
453
- : report.reason;
454
526
  }
455
- if (reportAcknowledged && durableCandidateKeys.size > 0) {
527
+ const reportContext = await ensureBackfillReportContext({
528
+ homeDir: command.homeDir,
529
+ paths: ctx.paths,
530
+ collectionRoots: ctx.collectionRoots,
531
+ worktrees: ctx.worktrees,
532
+ candidates: ctx.scan.candidates,
533
+ });
534
+ const report = await postCodexSessionReport({
535
+ homeDir: command.homeDir,
536
+ repoRoot: reportContext.repoRoot,
537
+ dashboardUrl: ctx.dashboardUrl,
538
+ sessions,
539
+ fetch: io.fetch,
540
+ now: ctx.now,
541
+ });
542
+ return {
543
+ sessions,
544
+ report,
545
+ acknowledged: report.posted && report.recorded_count >= sessions.length,
546
+ };
547
+ }
548
+ /** Where a run that uploaded cleanly but could not file its report stopped. */
549
+ function sessionReportStopPoint(upload) {
550
+ return {
551
+ what: "session report failed",
552
+ batch_index: upload.completedBatches,
553
+ batch_total: upload.batches.length,
554
+ done: upload.done,
555
+ total: upload.uploadable.length,
556
+ };
557
+ }
558
+ /** A partial acknowledgement is its own reason; otherwise the post's own. */
559
+ function sessionReportFailureReason(posted) {
560
+ return posted.report.posted &&
561
+ posted.report.recorded_count < posted.sessions.length
562
+ ? "session_report_ack_incomplete"
563
+ : posted.report.reason;
564
+ }
565
+ /**
566
+ * Makes this run's progress durable once the server has acknowledged it:
567
+ * session pointers first, then the backfill cursor through the contiguous
568
+ * resolved prefix. Raw-evidence durability is necessary but not sufficient —
569
+ * both writes wait on the report acknowledgement, because a cursor advanced
570
+ * past a session the server never recorded can never be walked back.
571
+ */
572
+ async function checkpointResolvedBackfillProgress(ctx, upload) {
573
+ const { paths, scan, cursor, now } = ctx;
574
+ if (upload.durableCandidateKeys.size > 0) {
456
575
  await recordBackfillDurableSessionPointers({
457
576
  paths,
458
577
  candidates: scan.candidates,
459
- syncResults,
460
- now,
461
- });
462
- }
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"),
578
+ syncResults: upload.syncResults,
473
579
  now,
474
580
  });
475
- if (cursorAdvanced)
476
- await writeBackfillCursor(paths, cursor);
477
581
  }
478
- const unresolvedUploadableKeys = new Set(uploadable
479
- .filter((candidate) => !durableCandidateKeys.has(candidateCursorKey(candidate)))
582
+ const cursorAdvanced = advanceBackfillCursorThroughResolvedPrefix({
583
+ cursor,
584
+ candidates: scan.candidates,
585
+ durableCandidateKeys: upload.durableCandidateKeys,
586
+ retryableCandidateKeys: scan.retryable_candidate_keys,
587
+ discoveryComplete: !scan.issues.some((issue) => issue.scope === "global"),
588
+ now,
589
+ });
590
+ if (cursorAdvanced)
591
+ await writeBackfillCursor(paths, cursor);
592
+ }
593
+ /**
594
+ * Answers "is this backfill finished, and if not, why not" as pure arithmetic
595
+ * over what the scan found and what the upload resolved — no side effects, so
596
+ * the one place BLI-2727's oversized-skip rule is applied is readable in full.
597
+ * `remaining` is a reporting total that still counts oversized skips so the
598
+ * JSON never goes silent about them; every `*Blocking` count excludes them,
599
+ * because a file too large for the current cap is a permanent labeled fact
600
+ * that no rerun can resolve and completion must not wait on forever.
601
+ */
602
+ function summarizeBackfillCompletion(options) {
603
+ const { scan, oversizedCandidateKeys } = options.ctx;
604
+ const { upload, posted } = options;
605
+ const unresolvedUploadableKeys = new Set(upload.uploadable
606
+ .filter((candidate) => !upload.durableCandidateKeys.has(candidateCursorKey(candidate)))
480
607
  .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
608
  const unresolvedUploadableBlocking = [...unresolvedUploadableKeys].filter((key) => !oversizedCandidateKeys.has(key)).length;
485
609
  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);
610
+ const remaining = countRemainingHistory({
611
+ scan,
612
+ unresolvedUploadableKeys,
613
+ posted,
614
+ });
615
+ const failed = Math.max(upload.failed, unresolvedUploadableBlocking);
507
616
  const blockingIssues = blockingScanIssues(scan.issues);
508
617
  const completionBlocked = blockingIssues.length > 0 ||
509
618
  unresolvedUploadableBlocking > 0 ||
510
619
  unresolvedRetryableBlocking > 0 ||
511
- deferred > 0 ||
620
+ upload.deferred > 0 ||
512
621
  failed > 0 ||
513
- !reportAcknowledged;
622
+ !posted.acknowledged;
623
+ let failureReason = options.failureReason;
514
624
  if (!failureReason && completionBlocked) {
515
625
  const retryableCandidateReason = scan.candidates.find((candidate) => scan.retryable_candidate_keys.has(candidateCursorKey(candidate)) &&
516
626
  !oversizedCandidateKeys.has(candidateCursorKey(candidate)))?.reason;
@@ -518,59 +628,98 @@ async function reportBackfillOutcome(command, io, ctx, upload) {
518
628
  blockingIssues[0]?.reason ??
519
629
  (unresolvedUploadableBlocking > 0
520
630
  ? "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
- });
631
+ : (retryableCandidateReason ?? "backfill_incomplete"));
549
632
  }
550
633
  return {
551
- ...baseBackfillResult(command, backfillResultBaseArgs(ctx)),
552
- status,
553
- retry_command: retryCommand,
634
+ remaining,
635
+ failed,
636
+ status: options.stoppedEarly || completionBlocked ? "partial" : "complete",
637
+ failureReason,
638
+ };
639
+ }
640
+ /**
641
+ * How much history a rerun still has to do. A reporting total, not a
642
+ * completion gate: it counts every unresolved candidate — oversized skips
643
+ * included, so `--json` never goes silent about them (BLI-2727) — plus what
644
+ * selection dropped, what discovery never saw, and the report itself when the
645
+ * server has not acknowledged it.
646
+ */
647
+ function countRemainingHistory(options) {
648
+ const unresolvedKnownKeys = new Set([
649
+ ...options.unresolvedUploadableKeys,
650
+ ...options.scan.retryable_candidate_keys,
651
+ ]);
652
+ const unseenGlobalFailures = options.scan.issues
653
+ .filter((issue) => issue.scope === "global")
654
+ .reduce((total, issue) => total + issue.count, 0);
655
+ const reportRetryable = options.posted.acknowledged
656
+ ? 0
657
+ : // The cursor is intentionally all-or-nothing for the report. Even
658
+ // chunks already accepted by the server are retried idempotently when
659
+ // another required chunk lacks an acknowledgement.
660
+ Math.max(1, options.posted.sessions.length);
661
+ return (unresolvedKnownKeys.size +
662
+ options.scan.omitted_candidate_count +
663
+ unseenGlobalFailures +
664
+ reportRetryable);
665
+ }
666
+ /**
667
+ * Proof, for doctor and status, that archived history is covered. Only an
668
+ * `--all` run earns it: a bounded `--since-days` run may complete its
669
+ * requested window, but that is not proof of an all-history backfill.
670
+ */
671
+ async function writeAllHistoryCompletionMarker(ctx) {
672
+ const { paths, cursor, sources, scopedCursor, scan, oversizedCandidateKeys, now } = ctx;
673
+ recordBackfillScanCoverage(cursor, sources, now, now);
674
+ await writeBackfillCursor(paths, cursor);
675
+ const oversizedCandidates = scan.candidates.filter((candidate) => oversizedCandidateKeys.has(candidateCursorKey(candidate)));
676
+ await writeBackfillCompletionMarker(paths, {
677
+ schema_version: "cockpit-backfill-complete.v2",
678
+ coverage_version: BACKFILL_COVERAGE_VERSION,
679
+ collection_scope_id: scopedCursor.collection_scope_id,
680
+ sources,
681
+ completed_at: now.toISOString(),
682
+ revalidate_after: new Date(now.getTime() + BACKFILL_COMPLETION_RECHECK_MS).toISOString(),
683
+ cursor,
684
+ ...(oversizedCandidates.length > 0
685
+ ? {
686
+ oversized_skips: {
687
+ reason: "file_too_large",
688
+ count: oversizedCandidates.length,
689
+ byte_sizes: oversizedCandidates.map((candidate) => candidate.byte_size),
690
+ },
691
+ }
692
+ : {}),
693
+ });
694
+ }
695
+ /** The `--json` payload: every stage's numbers merged onto the scan's baseline. */
696
+ function assembleBackfillResult(options) {
697
+ const { command, ctx, upload, posted, completion } = options;
698
+ const base = baseBackfillResult(command, backfillResultBaseArgs(ctx));
699
+ return {
700
+ ...base,
701
+ status: completion.status,
702
+ retry_command: ctx.retryCommand,
554
703
  counts: {
555
- ...baseBackfillResult(command, backfillResultBaseArgs(ctx)).counts,
556
- backfilled: backfilledSessions,
557
- failed,
558
- deferred,
559
- remaining,
704
+ ...base.counts,
705
+ backfilled: upload.backfilledSessions,
706
+ failed: completion.failed,
707
+ deferred: upload.deferred,
708
+ remaining: completion.remaining,
560
709
  },
561
710
  batches: {
562
- total: batches.length,
563
- completed: completedBatches,
564
- failed: failedBatches,
711
+ total: upload.batches.length,
712
+ completed: upload.completedBatches,
713
+ failed: upload.failedBatches,
565
714
  },
566
- report,
715
+ report: posted.report,
567
716
  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,
717
+ codex_session_report_recorded_count: posted.report.recorded_count,
718
+ raw_evidence_uploaded_object_count: upload.uploadedObjects,
719
+ raw_evidence_uploaded_chunk_count: upload.uploadedChunks,
571
720
  },
572
- blocked_at: blockedAt,
573
- failure_reason: failureReason,
721
+ blocked_at: options.blockedAt,
722
+ failure_reason: completion.failureReason,
574
723
  };
575
724
  }
576
725
  function defaultBackfillWindowNotice() {
@@ -686,77 +835,98 @@ async function validateBackfillReachability(options) {
686
835
  throw new Error(`Dashboard reachability failed with HTTP ${response.status}.`);
687
836
  }
688
837
  }
838
+ /**
839
+ * Reads both archived session stores and turns them into the candidates this
840
+ * run will consider: scan each selected store, keep what the cursor has not
841
+ * already resolved, order it, apply the operator's `--max-files` cap, then
842
+ * census what could not be accounted for.
843
+ */
689
844
  async function scanBackfillSessions(options) {
690
845
  const scanLimit = options.command.maxFiles ?? 10_000;
691
846
  const codexSessionDirs = defaultCodexSessionDirs(options.homeDir);
692
847
  const claudeProjectsDir = path.join(options.homeDir, ".claude", "projects");
693
- let codexAttribution = options.sources.includes("codex")
694
- ? await scanAndAttributeCodexSessions({
695
- sessionsDirs: codexSessionDirs,
696
- worktrees: options.worktrees,
697
- now: options.now,
698
- sinceMinutes: options.window.since_minutes,
699
- limit: scanLimit,
700
- collectionRoots: options.collectionRoots,
701
- })
848
+ const codexAttribution = options.sources.includes("codex")
849
+ ? await scanCodexHistory(options, codexSessionDirs, scanLimit)
702
850
  : null;
703
- if (codexAttribution?.session_limit_applied) {
704
- // The adapter limit is a memory guard, not permission to silently leave
705
- // history undiscovered. Re-scan the exact discovered population so a
706
- // user-facing --max-files cap can resume from a truthful ordering.
707
- codexAttribution = await scanAndAttributeCodexSessions({
708
- sessionsDirs: codexSessionDirs,
709
- worktrees: options.worktrees,
710
- now: options.now,
711
- sinceMinutes: options.window.since_minutes,
712
- limit: Math.max(scanLimit + 1, codexAttribution.discovered_file_count),
713
- collectionRoots: options.collectionRoots,
714
- });
715
- }
716
- let claudeAttribution = options.sources.includes("claude_code")
717
- ? await scanAndAttributeClaudeSessions({
718
- projectsDir: claudeProjectsDir,
719
- worktrees: options.worktrees,
720
- now: options.now,
721
- sinceMinutes: options.window.since_minutes,
722
- limit: scanLimit,
723
- collectionRoots: options.collectionRoots,
724
- })
851
+ const claudeAttribution = options.sources.includes("claude_code")
852
+ ? await scanClaudeHistory(options, claudeProjectsDir, scanLimit)
725
853
  : null;
726
- if (claudeAttribution?.session_limit_applied) {
727
- claudeAttribution = await scanAndAttributeClaudeSessions({
728
- projectsDir: claudeProjectsDir,
729
- worktrees: options.worktrees,
730
- now: options.now,
731
- sinceMinutes: options.window.since_minutes,
732
- limit: Math.max(scanLimit + 1, claudeAttribution.discovered_session_count),
733
- collectionRoots: options.collectionRoots,
734
- });
735
- }
736
- const allCandidates = [
737
- ...(codexAttribution?.results.map(normalizeCodexCandidate) ?? []),
738
- ...(claudeAttribution?.results.map(normalizeClaudeCandidate) ?? []),
739
- ]
740
- .filter((candidate) => isAfterCursor(candidate, options.cursor) ||
741
- isPointerlessTerminalRetry(candidate, options.pointerlessTerminalSessions))
742
- .sort((a, b) => compareBackfillCandidatesForRetry(a, b, options.pointerlessTerminalSessions));
743
- const candidates = allCandidates.slice(0, options.command.maxFiles ?? Number.MAX_SAFE_INTEGER);
744
- const omittedCandidateCount = allCandidates.length - candidates.length;
854
+ const selection = selectBackfillCandidates({
855
+ codexAttribution,
856
+ claudeAttribution,
857
+ cursor: options.cursor,
858
+ pointerlessTerminalSessions: options.pointerlessTerminalSessions,
859
+ maxFiles: options.command.maxFiles,
860
+ });
745
861
  const issues = await backfillScanIssues({
746
862
  codexAttribution,
747
863
  claudeAttribution,
748
864
  codexSessionDirs,
749
865
  claudeProjectsDir,
750
- candidates,
751
- omittedCandidateCount,
866
+ candidates: selection.candidates,
867
+ omittedCandidateCount: selection.omittedCandidateCount,
752
868
  });
753
869
  return {
754
- candidates,
870
+ candidates: selection.candidates,
755
871
  codexAttribution,
756
872
  claudeAttribution,
757
873
  issues,
758
- retryable_candidate_keys: retryableCandidateKeys(candidates),
759
- omitted_candidate_count: omittedCandidateCount,
874
+ retryable_candidate_keys: retryableCandidateKeys(selection.candidates),
875
+ omitted_candidate_count: selection.omittedCandidateCount,
876
+ };
877
+ }
878
+ /**
879
+ * The adapter's own limit is a memory guard, not permission to silently leave
880
+ * history undiscovered. When it fires, re-scan the exact discovered population
881
+ * so a user-facing `--max-files` cap can resume from a truthful ordering.
882
+ */
883
+ async function scanCodexHistory(request, sessionsDirs, scanLimit) {
884
+ const scan = (limit) => scanAndAttributeCodexSessions({
885
+ sessionsDirs,
886
+ worktrees: request.worktrees,
887
+ now: request.now,
888
+ sinceMinutes: request.window.since_minutes,
889
+ limit,
890
+ collectionRoots: request.collectionRoots,
891
+ });
892
+ const firstPass = await scan(scanLimit);
893
+ if (!firstPass.session_limit_applied)
894
+ return firstPass;
895
+ return scan(Math.max(scanLimit + 1, firstPass.discovered_file_count));
896
+ }
897
+ /** Same rule for the Claude store: a capped first pass is rescanned in full. */
898
+ async function scanClaudeHistory(request, projectsDir, scanLimit) {
899
+ const scan = (limit) => scanAndAttributeClaudeSessions({
900
+ projectsDir,
901
+ worktrees: request.worktrees,
902
+ now: request.now,
903
+ sinceMinutes: request.window.since_minutes,
904
+ limit,
905
+ collectionRoots: request.collectionRoots,
906
+ });
907
+ const firstPass = await scan(scanLimit);
908
+ if (!firstPass.session_limit_applied)
909
+ return firstPass;
910
+ return scan(Math.max(scanLimit + 1, firstPass.discovered_session_count));
911
+ }
912
+ /**
913
+ * Which scanned sessions this run will actually work on. A session is in play
914
+ * if the cursor has not passed it, or if it is a terminal session that never
915
+ * earned a pointer and so deserves another attempt; those retries sort first
916
+ * so a `--max-files` cap spends its budget on them before newer history.
917
+ */
918
+ function selectBackfillCandidates(options) {
919
+ const allCandidates = [
920
+ ...(options.codexAttribution?.results.map(normalizeCodexCandidate) ?? []),
921
+ ...(options.claudeAttribution?.results.map(normalizeClaudeCandidate) ?? []),
922
+ ]
923
+ .filter((candidate) => isAfterCursor(candidate, options.cursor) ||
924
+ isPointerlessTerminalRetry(candidate, options.pointerlessTerminalSessions))
925
+ .sort((a, b) => compareBackfillCandidatesForRetry(a, b, options.pointerlessTerminalSessions));
926
+ const candidates = allCandidates.slice(0, options.maxFiles ?? Number.MAX_SAFE_INTEGER);
927
+ return {
928
+ candidates,
929
+ omittedCandidateCount: allCandidates.length - candidates.length,
760
930
  };
761
931
  }
762
932
  async function loadPointerlessTerminalSessions(paths, sources) {
@@ -890,46 +1060,62 @@ function normalizedCursorPath(filePath) {
890
1060
  const normalized = path.resolve(filePath);
891
1061
  return process.platform === "win32" ? normalized.toLowerCase() : normalized;
892
1062
  }
1063
+ /**
1064
+ * Everything the scan could not account for cleanly, in one ledger. Read in
1065
+ * three passes — what the Codex store hid, what the Claude store hid, then
1066
+ * what is wrong with the candidates that survived — because the first two are
1067
+ * global (discovery may have missed a session at any mtime, so no watermark is
1068
+ * safe to advance) and the third only stops the contiguous cursor prefix.
1069
+ */
893
1070
  async function backfillScanIssues(options) {
894
1071
  const issues = [];
895
- const add = (reason, count, scope) => {
896
- if (count > 0)
897
- issues.push({ reason, count, scope });
898
- };
899
1072
  if (options.codexAttribution) {
900
- add("codex_session_limit_applied", options.codexAttribution.session_limit_applied
901
- ? Math.max(1, options.codexAttribution.discovered_file_count -
902
- options.codexAttribution.scanned_file_count)
903
- : 0, "global");
904
- const missingTopLevelDirs = (await Promise.all(options.codexSessionDirs.map(isMissingPath))).filter(Boolean).length;
905
- add("codex_directory_read_failed", Math.max(0, options.codexAttribution.directory_read_failed_count -
906
- missingTopLevelDirs), "global");
907
- add("codex_session_stat_failed", options.codexAttribution.stat_failed_count, "global");
1073
+ await addCodexStoreScanIssues(issues, options.codexAttribution, options.codexSessionDirs);
908
1074
  }
909
1075
  if (options.claudeAttribution) {
910
- add("claude_session_limit_applied", options.claudeAttribution.session_limit_applied
911
- ? Math.max(1, options.claudeAttribution.discovered_session_count -
912
- options.claudeAttribution.scanned_session_count)
913
- : 0, "global");
914
- const projectsDirMissing = await isMissingPath(options.claudeProjectsDir);
915
- add("claude_project_dir_read_failed", Math.max(0, options.claudeAttribution.project_dir_read_failed_count -
916
- (projectsDirMissing ? 1 : 0)), "global");
917
- add("claude_session_stat_failed", options.claudeAttribution.session_stat_failed_count, "global");
918
- add("claude_sidecar_stat_failed", options.claudeAttribution.sidecar_stat_failed_count, "global");
919
- add("claude_sidecar_dir_read_failed", await countUnreadableClaudeSidecarDirs(options.candidates), "global");
920
- }
921
- add("backfill_max_files_applied", options.omittedCandidateCount, "selection");
922
- add("candidate_file_read_failed", options.candidates.filter((candidate) => candidate.reason === "file_read_failed").length, "candidate");
923
- add("candidate_worktree_unavailable", options.candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null) &&
924
- candidate.worktree === null).length, "candidate");
925
- add("claude_main_file_too_large", options.candidates.filter((candidate) => candidate.source === "claude_code" &&
1076
+ await addClaudeStoreScanIssues(issues, options.claudeAttribution, options.claudeProjectsDir, options.candidates);
1077
+ }
1078
+ addCandidateScanIssues(issues, options.candidates, options.omittedCandidateCount);
1079
+ sortScanIssuesByPriority(issues);
1080
+ return issues;
1081
+ }
1082
+ /** What the archived Codex store could not tell us — every one of these hides history. */
1083
+ async function addCodexStoreScanIssues(issues, codexAttribution, codexSessionDirs) {
1084
+ countScanIssue(issues, "codex_session_limit_applied", codexAttribution.session_limit_applied
1085
+ ? Math.max(1, codexAttribution.discovered_file_count -
1086
+ codexAttribution.scanned_file_count)
1087
+ : 0, "global");
1088
+ // A session directory that simply does not exist on this machine is not a
1089
+ // read failure; subtract those before reporting one.
1090
+ const missingTopLevelDirs = (await Promise.all(codexSessionDirs.map(isMissingPath))).filter(Boolean).length;
1091
+ countScanIssue(issues, "codex_directory_read_failed", Math.max(0, codexAttribution.directory_read_failed_count - missingTopLevelDirs), "global");
1092
+ countScanIssue(issues, "codex_session_stat_failed", codexAttribution.stat_failed_count, "global");
1093
+ }
1094
+ /** The same census for the Claude store, which also has sidecars to account for. */
1095
+ async function addClaudeStoreScanIssues(issues, claudeAttribution, claudeProjectsDir, candidates) {
1096
+ countScanIssue(issues, "claude_session_limit_applied", claudeAttribution.session_limit_applied
1097
+ ? Math.max(1, claudeAttribution.discovered_session_count -
1098
+ claudeAttribution.scanned_session_count)
1099
+ : 0, "global");
1100
+ const projectsDirMissing = await isMissingPath(claudeProjectsDir);
1101
+ countScanIssue(issues, "claude_project_dir_read_failed", Math.max(0, claudeAttribution.project_dir_read_failed_count -
1102
+ (projectsDirMissing ? 1 : 0)), "global");
1103
+ countScanIssue(issues, "claude_session_stat_failed", claudeAttribution.session_stat_failed_count, "global");
1104
+ countScanIssue(issues, "claude_sidecar_stat_failed", claudeAttribution.sidecar_stat_failed_count, "global");
1105
+ countScanIssue(issues, "claude_sidecar_dir_read_failed", await countUnreadableClaudeSidecarDirs(candidates), "global");
1106
+ }
1107
+ /** What is wrong with the sessions that did survive discovery, plus the one we chose to drop. */
1108
+ function addCandidateScanIssues(issues, candidates, omittedCandidateCount) {
1109
+ countScanIssue(issues, "backfill_max_files_applied", omittedCandidateCount, "selection");
1110
+ countScanIssue(issues, "candidate_file_read_failed", candidates.filter((candidate) => candidate.reason === "file_read_failed")
1111
+ .length, "candidate");
1112
+ countScanIssue(issues, "candidate_worktree_unavailable", candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null) && candidate.worktree === null).length, "candidate");
1113
+ countScanIssue(issues, "claude_main_file_too_large", candidates.filter((candidate) => candidate.source === "claude_code" &&
926
1114
  candidate.claude?.main_file_oversized).length, "candidate");
927
- add("claude_sidecar_limit_applied", options.candidates.reduce((total, candidate) => total + (candidate.claude?.sidecars_capped ?? 0), 0), "candidate");
928
- add("claude_sidecar_file_unreadable", options.candidates.reduce((total, candidate) => total +
1115
+ countScanIssue(issues, "claude_sidecar_limit_applied", candidates.reduce((total, candidate) => total + (candidate.claude?.sidecars_capped ?? 0), 0), "candidate");
1116
+ countScanIssue(issues, "claude_sidecar_file_unreadable", candidates.reduce((total, candidate) => total +
929
1117
  (candidate.claude?.sidecar_files.filter((sidecar) => sidecar.skipped_reason === "file_read_failed" ||
930
1118
  sidecar.skipped_reason === "file_too_large").length ?? 0), 0), "candidate");
931
- return issues.sort((a, b) => scanIssuePriority(a.scope) - scanIssuePriority(b.scope) ||
932
- a.reason.localeCompare(b.reason));
933
1119
  }
934
1120
  function scanIssuePriority(scope) {
935
1121
  if (scope === "global")
@@ -981,6 +1167,11 @@ function oversizedBackfillCandidateKeys(candidates) {
981
1167
  function addScanIssue(issues, issue) {
982
1168
  issues.push(issue);
983
1169
  }
1170
+ /** Record one issue only if it actually happened; a zero count is not a finding. */
1171
+ function countScanIssue(issues, reason, count, scope) {
1172
+ if (count > 0)
1173
+ addScanIssue(issues, { reason, count, scope });
1174
+ }
984
1175
  /** Repo discovery could not fully enumerate a root: one global issue per reason. */
985
1176
  function addRepoDiscoveryIssues(issues, incompleteReasons) {
986
1177
  for (const reason of incompleteReasons) {
@@ -1135,59 +1326,84 @@ function reasonCountsFor(candidates, guardCounts, scanIssues) {
1135
1326
  ...reasonClassification(reason),
1136
1327
  }));
1137
1328
  }
1138
- function reasonClassification(reason) {
1139
- if (reason === "secret_like_content_guard" || reason === "secret_redaction_failed") {
1140
- return {
1329
+ /**
1330
+ * The skip-reason table printed under every backfill run. These strings are
1331
+ * what a person reads when coverage is short, so each note says what would
1332
+ * have to change, not merely that something went wrong. Reason labels here
1333
+ * must match the ones the scan and the guards emit verbatim.
1334
+ */
1335
+ const REASON_VERDICTS = new Map([
1336
+ [
1337
+ "secret_like_content_guard",
1338
+ {
1141
1339
  classification: "retryable",
1142
1340
  note: "historical guard result; collector now masks and retries",
1143
- };
1144
- }
1145
- if (reason === "file_too_large") {
1146
- return {
1341
+ },
1342
+ ],
1343
+ [
1344
+ "secret_redaction_failed",
1345
+ {
1147
1346
  classification: "retryable",
1148
- note: "until the evidence file cap is raised",
1149
- };
1150
- }
1151
- if (reason === "repo_not_on_disk") {
1152
- return {
1347
+ note: "historical guard result; collector now masks and retries",
1348
+ },
1349
+ ],
1350
+ [
1351
+ "file_too_large",
1352
+ {
1153
1353
  classification: "retryable",
1154
- note: "repo must exist on disk",
1155
- };
1156
- }
1157
- if (reason === "cwd_not_a_repo") {
1158
- return {
1354
+ note: "until the evidence file cap is raised",
1355
+ },
1356
+ ],
1357
+ [
1358
+ "repo_not_on_disk",
1359
+ { classification: "retryable", note: "repo must exist on disk" },
1360
+ ],
1361
+ [
1362
+ "cwd_not_a_repo",
1363
+ {
1159
1364
  classification: "permanent",
1160
1365
  note: "cwd exists but is not a repo or folder workspace",
1161
- };
1162
- }
1163
- if (reason === "multiple_transcript_origins") {
1164
- return {
1366
+ },
1367
+ ],
1368
+ [
1369
+ "multiple_transcript_origins",
1370
+ {
1165
1371
  classification: "permanent",
1166
1372
  note: "multiple transcript origins; attribution is ambiguous",
1167
- };
1168
- }
1169
- if (reason === "single_repo_folder_fallback") {
1170
- return {
1373
+ },
1374
+ ],
1375
+ [
1376
+ "single_repo_folder_fallback",
1377
+ {
1171
1378
  classification: "permanent",
1172
1379
  note: "uploadable single-repo folder workspace fallback",
1173
- };
1174
- }
1175
- if (reason === "multi_repo_folder_workspace") {
1176
- return {
1380
+ },
1381
+ ],
1382
+ [
1383
+ "multi_repo_folder_workspace",
1384
+ {
1177
1385
  classification: "permanent",
1178
1386
  note: "uploadable multi-repo folder workspace fallback",
1179
- };
1180
- }
1181
- if (reason.startsWith("deferred_")) {
1182
- return {
1183
- classification: "retryable",
1184
- note: "rerun cockpit backfill to continue",
1185
- };
1186
- }
1187
- return {
1188
- classification: "retryable",
1189
- note: "rerun after fixing source or collector state",
1190
- };
1387
+ },
1388
+ ],
1389
+ ]);
1390
+ const DEFERRED_REASON_VERDICT = {
1391
+ classification: "retryable",
1392
+ note: "rerun cockpit backfill to continue",
1393
+ };
1394
+ const UNKNOWN_REASON_VERDICT = {
1395
+ classification: "retryable",
1396
+ note: "rerun after fixing source or collector state",
1397
+ };
1398
+ function reasonClassification(reason) {
1399
+ const known = REASON_VERDICTS.get(reason);
1400
+ if (known)
1401
+ return known;
1402
+ // A budget deferral is the one family, rather than one label: whichever
1403
+ // budget ran out, the remedy is the same rerun.
1404
+ if (reason.startsWith("deferred_"))
1405
+ return DEFERRED_REASON_VERDICT;
1406
+ return UNKNOWN_REASON_VERDICT;
1191
1407
  }
1192
1408
  function uploadableCandidates(candidates) {
1193
1409
  return candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null) &&
@@ -1248,8 +1464,33 @@ async function ensureBackfillReportContext(options) {
1248
1464
  }
1249
1465
  return { repoRoot };
1250
1466
  }
1467
+ /**
1468
+ * Hands one batch to the ordinary sync path. A batch whose repo has no local
1469
+ * work context yet is not a failure — backfill routinely visits repos this
1470
+ * machine has never synced — so that one blocker is answered by creating the
1471
+ * context and retrying; every other error propagates.
1472
+ */
1251
1473
  async function syncBackfillBatch(options) {
1252
- const syncOptions = {
1474
+ const syncOptions = backfillBatchSyncOptions(options);
1475
+ try {
1476
+ return await syncLocalAmbientEnvelope(syncOptions);
1477
+ }
1478
+ catch (error) {
1479
+ if (error instanceof LocalUploadBlockedError &&
1480
+ error.blocker === "missing_context") {
1481
+ await startLocalWorkContext({
1482
+ homeDir: options.command.homeDir,
1483
+ repoRoot: options.batch.worktree.repo_root,
1484
+ branch: options.batch.worktree.branch,
1485
+ });
1486
+ return await syncLocalAmbientEnvelope(syncOptions);
1487
+ }
1488
+ throw error;
1489
+ }
1490
+ }
1491
+ /** The batch translated into the live sync path's envelope, session files and all. */
1492
+ function backfillBatchSyncOptions(options) {
1493
+ return {
1253
1494
  homeDir: options.command.homeDir,
1254
1495
  repoRoot: options.batch.worktree.repo_root,
1255
1496
  worktreeInventory: worktreeInventoryForRepo(options.batch.worktree, options.worktrees),
@@ -1282,29 +1523,21 @@ async function syncBackfillBatch(options) {
1282
1523
  evidenceDeliveryMode: "operator_retry",
1283
1524
  fetch: options.fetchImpl,
1284
1525
  };
1285
- try {
1286
- return await syncLocalAmbientEnvelope(syncOptions);
1287
- }
1288
- catch (error) {
1289
- if (error instanceof LocalUploadBlockedError &&
1290
- error.blocker === "missing_context") {
1291
- await startLocalWorkContext({
1292
- homeDir: options.command.homeDir,
1293
- repoRoot: options.batch.worktree.repo_root,
1294
- branch: options.batch.worktree.branch,
1295
- });
1296
- return await syncLocalAmbientEnvelope(syncOptions);
1297
- }
1298
- throw error;
1299
- }
1300
1526
  }
1301
- /** Exported for the BLI-3272 regression test; not part of the CLI surface. */
1527
+ /**
1528
+ * One attribution row per session this run saw, uploaded or not — the only
1529
+ * record the server ever gets of a session the collector could not upload.
1530
+ * Exported for the BLI-3272 regression test; not part of the CLI surface.
1531
+ */
1302
1532
  export function buildBackfillSessionReport(options) {
1303
- const uploadByKey = new Map();
1304
- // BLI-2107: an outcome that names a failure but has no pointer used to be
1305
- // dropped on the floor here, taking its reason with it.
1533
+ const uploads = indexMainTranscriptUploads(options.syncResults);
1534
+ const bestBySession = bestCandidatePerSession(options.candidates);
1535
+ return [...bestBySession.values()].map((candidate) => sessionAttributionRow(candidate, uploads, options.now));
1536
+ }
1537
+ function indexMainTranscriptUploads(syncResults) {
1538
+ const bySourceAndSession = new Map();
1306
1539
  const noUploadReasonBySessionId = new Map();
1307
- for (const sync of options.syncResults) {
1540
+ for (const sync of syncResults) {
1308
1541
  if (sync.status !== "uploaded")
1309
1542
  continue;
1310
1543
  for (const outcome of sync.raw_evidence_outcomes) {
@@ -1323,15 +1556,23 @@ export function buildBackfillSessionReport(options) {
1323
1556
  }
1324
1557
  continue;
1325
1558
  }
1326
- uploadByKey.set(`${source}:${outcome.codex_session_id}`, {
1559
+ bySourceAndSession.set(`${source}:${outcome.codex_session_id}`, {
1327
1560
  upload_state: outcome.upload_state,
1328
1561
  raw_evidence_pointer_id: outcome.raw_evidence_pointer_id,
1329
1562
  reason: outcome.reason,
1330
1563
  });
1331
1564
  }
1332
1565
  }
1566
+ return { bySourceAndSession, noUploadReasonBySessionId };
1567
+ }
1568
+ /**
1569
+ * One row per session, not per file: the same session can be scanned from
1570
+ * several paths, so the best-attributed sighting wins and the most recent one
1571
+ * breaks a tie.
1572
+ */
1573
+ function bestCandidatePerSession(candidates) {
1333
1574
  const bestByKey = new Map();
1334
- for (const candidate of options.candidates) {
1575
+ for (const candidate of candidates) {
1335
1576
  const key = `${candidate.source}:${candidate.session_id}`;
1336
1577
  const existing = bestByKey.get(key);
1337
1578
  if (!existing || rank(candidate.state) > rank(existing.state)) {
@@ -1343,60 +1584,63 @@ export function buildBackfillSessionReport(options) {
1343
1584
  bestByKey.set(key, candidate);
1344
1585
  }
1345
1586
  }
1346
- return [...bestByKey.values()].map((candidate) => {
1347
- const upload = uploadByKey.get(`${candidate.source}:${candidate.session_id}`);
1587
+ return bestByKey;
1588
+ }
1589
+ function sessionAttributionRow(candidate, uploads, now) {
1590
+ return {
1591
+ codex_session_id: candidate.session_id,
1592
+ source: candidate.source,
1593
+ observed_at: now.toISOString(),
1594
+ attribution_state: candidate.state,
1595
+ attribution_reason: candidate.reason,
1596
+ attribution_score: candidate.attribution_score,
1597
+ path_score: candidate.path_score,
1598
+ signals: candidate.signals,
1599
+ ...(candidate.content_hash_sha256
1600
+ ? { session_file_hash_sha256: candidate.content_hash_sha256 }
1601
+ : {}),
1602
+ session_file_byte_size: candidate.byte_size,
1603
+ session_file_mtime: candidate.session_file_mtime,
1604
+ ...(candidate.worktree
1605
+ ? {
1606
+ repo_fingerprint: candidate.worktree.repo_fingerprint,
1607
+ worktree_fingerprint: candidate.worktree.worktree_fingerprint,
1608
+ repo_label: candidate.worktree.repo_label,
1609
+ branch: candidate.worktree.branch,
1610
+ }
1611
+ : {}),
1612
+ ...(candidate.cwd_basename ? { cwd_basename: candidate.cwd_basename } : {}),
1613
+ ...(candidate.cwd_hash ? { cwd_hash: candidate.cwd_hash } : {}),
1614
+ ...sessionUploadFields(candidate, uploads),
1615
+ };
1616
+ }
1617
+ /**
1618
+ * The upload half of a report row. Every branch names an outcome: a pointer
1619
+ * when one exists, and otherwise WHY there is none — BLI-2107 for an
1620
+ * attributed session and BLI-3272 for a refused one. Backfill is the path that
1621
+ * revisits old sessions, so a silent branch here would keep rewriting the very
1622
+ * NULL/NULL rows those tickets found.
1623
+ */
1624
+ function sessionUploadFields(candidate, uploads) {
1625
+ const upload = uploads.bySourceAndSession.get(`${candidate.source}:${candidate.session_id}`);
1626
+ if (upload) {
1348
1627
  return {
1349
- codex_session_id: candidate.session_id,
1350
- source: candidate.source,
1351
- observed_at: options.now.toISOString(),
1352
- attribution_state: candidate.state,
1353
- attribution_reason: candidate.reason,
1354
- attribution_score: candidate.attribution_score,
1355
- path_score: candidate.path_score,
1356
- signals: candidate.signals,
1357
- ...(candidate.content_hash_sha256
1358
- ? { session_file_hash_sha256: candidate.content_hash_sha256 }
1359
- : {}),
1360
- session_file_byte_size: candidate.byte_size,
1361
- session_file_mtime: candidate.session_file_mtime,
1362
- ...(candidate.worktree
1363
- ? {
1364
- repo_fingerprint: candidate.worktree.repo_fingerprint,
1365
- worktree_fingerprint: candidate.worktree.worktree_fingerprint,
1366
- repo_label: candidate.worktree.repo_label,
1367
- branch: candidate.worktree.branch,
1368
- }
1628
+ raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
1629
+ upload_state: upload.upload_state,
1630
+ ...(upload.upload_state === "upload_failed"
1631
+ ? { upload_reason: upload.reason ?? NO_UPLOAD_ATTEMPT_RECORDED }
1369
1632
  : {}),
1370
- ...(candidate.cwd_basename ? { cwd_basename: candidate.cwd_basename } : {}),
1371
- ...(candidate.cwd_hash ? { cwd_hash: candidate.cwd_hash } : {}),
1372
- ...(upload
1373
- ? {
1374
- raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
1375
- upload_state: upload.upload_state,
1376
- ...(upload.upload_state === "upload_failed"
1377
- ? { upload_reason: upload.reason ?? NO_UPLOAD_ATTEMPT_RECORDED }
1378
- : {}),
1379
- }
1380
- : isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null)
1381
- ? {
1382
- upload_state: "not_uploaded",
1383
- // BLI-2107: same rule as live sync — an attributed session with
1384
- // no pointer always says why, even when the answer is that this
1385
- // path never recorded one.
1386
- upload_reason: noUploadReasonBySessionId.get(candidate.session_id) ??
1387
- NO_UPLOAD_ATTEMPT_RECORDED,
1388
- }
1389
- : {
1390
- // BLI-3272: and the refused-attribution branch says why too. Same
1391
- // NULL/NULL hole as live sync, same fix — backfill is the path
1392
- // that revisits old sessions, so leaving it silent would keep
1393
- // rewriting the very rows this ticket found.
1394
- upload_state: "not_uploaded",
1395
- upload_reason: noUploadReasonBySessionId.get(candidate.session_id) ??
1396
- notUploadableAttributionStateReason(candidate.reason),
1397
- }),
1398
1633
  };
1399
- });
1634
+ }
1635
+ const observedReason = uploads.noUploadReasonBySessionId.get(candidate.session_id);
1636
+ const wasUploadable = isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null);
1637
+ return {
1638
+ upload_state: "not_uploaded",
1639
+ upload_reason: observedReason ??
1640
+ (wasUploadable
1641
+ ? NO_UPLOAD_ATTEMPT_RECORDED
1642
+ : notUploadableAttributionStateReason(candidate.reason)),
1643
+ };
1400
1644
  }
1401
1645
  function rank(state) {
1402
1646
  switch (state) {
@@ -1414,16 +1658,35 @@ function rank(state) {
1414
1658
  return 0;
1415
1659
  }
1416
1660
  }
1661
+ /**
1662
+ * Which candidates in this batch are now safe to leave behind forever: a
1663
+ * session counts as durable only when its main transcript AND every sidecar
1664
+ * we expected earned a pointer, and nothing in it failed. This set is what the
1665
+ * cursor advances through, so an over-generous answer here loses history
1666
+ * permanently.
1667
+ */
1417
1668
  function durableBackfillCandidateKeys(batch, sync) {
1418
1669
  const durable = new Set();
1419
1670
  if (sync.status !== "uploaded")
1420
1671
  return durable;
1421
- if (sync.raw_evidence_deferred_byte_budget > 0 ||
1422
- sync.raw_evidence_deferred_object_budget > 0) {
1672
+ if (deferredEvidenceCount(sync) > 0) {
1423
1673
  // Deferred outcomes have no per-file identity. Advancing any candidate in
1424
1674
  // this batch could therefore strand the deferred main transcript.
1425
1675
  return durable;
1426
1676
  }
1677
+ const outcomesBySession = summarizeEvidenceOutcomesBySession(sync);
1678
+ for (const candidate of batch.candidates) {
1679
+ const summary = outcomesBySession.get(`${candidate.source}:${candidate.session_id}`);
1680
+ if (summary &&
1681
+ !summary.failed &&
1682
+ summary.durableMainCount > 0 &&
1683
+ summary.durableSidecarCount >= expectedSidecarCount(candidate)) {
1684
+ durable.add(candidateCursorKey(candidate));
1685
+ }
1686
+ }
1687
+ return durable;
1688
+ }
1689
+ function summarizeEvidenceOutcomesBySession(sync) {
1427
1690
  const outcomesBySession = new Map();
1428
1691
  for (const outcome of sync.raw_evidence_outcomes) {
1429
1692
  const source = backfillSourceForEvidenceKind(outcome.kind);
@@ -1441,8 +1704,7 @@ function durableBackfillCandidateKeys(batch, sync) {
1441
1704
  else if (outcome.raw_evidence_pointer_id &&
1442
1705
  (outcome.upload_state === "uploaded" ||
1443
1706
  outcome.upload_state === "reused_existing")) {
1444
- if (outcome.kind === "codex_jsonl" ||
1445
- outcome.kind === "claude_jsonl") {
1707
+ if (outcome.kind === "codex_jsonl" || outcome.kind === "claude_jsonl") {
1446
1708
  summary.durableMainCount += 1;
1447
1709
  }
1448
1710
  else if (outcome.kind === "claude_jsonl_sidecar") {
@@ -1451,35 +1713,22 @@ function durableBackfillCandidateKeys(batch, sync) {
1451
1713
  }
1452
1714
  outcomesBySession.set(key, summary);
1453
1715
  }
1454
- for (const candidate of batch.candidates) {
1455
- const summary = outcomesBySession.get(`${candidate.source}:${candidate.session_id}`);
1456
- const expectedSidecars = candidate.source === "claude_code"
1457
- ? (candidate.claude?.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length ?? 0)
1458
- : 0;
1459
- if (summary &&
1460
- !summary.failed &&
1461
- summary.durableMainCount > 0 &&
1462
- summary.durableSidecarCount >= expectedSidecars) {
1463
- durable.add(candidateCursorKey(candidate));
1464
- }
1465
- }
1466
- return durable;
1716
+ return outcomesBySession;
1467
1717
  }
1718
+ /** Sidecars this session owes a pointer for; one already skipped at scan time is not owed. */
1719
+ function expectedSidecarCount(candidate) {
1720
+ if (candidate.source !== "claude_code")
1721
+ return 0;
1722
+ return (candidate.claude?.sidecar_files.filter((sidecar) => !sidecar.skipped_reason)
1723
+ .length ?? 0);
1724
+ }
1725
+ /**
1726
+ * Writes the pointer a session earned back into the live raw-evidence cursor,
1727
+ * so the next scheduled sync knows it is already delivered. Backfill and sync
1728
+ * share these cursors; the caller holds the collection lock for exactly this.
1729
+ */
1468
1730
  async function recordBackfillDurableSessionPointers(options) {
1469
- const durableObjectBySession = new Map();
1470
- for (const sync of options.syncResults) {
1471
- for (const outcome of sync.raw_evidence_outcomes) {
1472
- if (!outcome.codex_session_id ||
1473
- (outcome.kind !== "codex_jsonl" && outcome.kind !== "claude_jsonl") ||
1474
- (outcome.upload_state !== "uploaded" &&
1475
- outcome.upload_state !== "reused_existing") ||
1476
- !outcome.object_key) {
1477
- continue;
1478
- }
1479
- const source = outcome.kind === "codex_jsonl" ? "codex" : "claude_code";
1480
- durableObjectBySession.set(`${source}:${outcome.codex_session_id}`, outcome.object_key);
1481
- }
1482
- }
1731
+ const durableObjectBySession = indexDurableMainObjectKeys(options.syncResults);
1483
1732
  let recordedCount = 0;
1484
1733
  for (const source of ["codex", "claude_code"]) {
1485
1734
  const filename = source === "claude_code" ? CLAUDE_CURSOR_FILENAME : undefined;
@@ -1490,22 +1739,10 @@ async function recordBackfillDurableSessionPointers(options) {
1490
1739
  continue;
1491
1740
  const objectKey = durableObjectBySession.get(`${source}:${candidate.session_id}`);
1492
1741
  const prior = cursor.sessions[candidate.session_id];
1742
+ // No pointer, no prior entry, or already pointed: nothing this pass owes.
1493
1743
  if (!objectKey || !prior || prior.uploaded_object_key)
1494
1744
  continue;
1495
- cursor.sessions[candidate.session_id] = {
1496
- ...prior,
1497
- file_hash_sha256: candidate.content_hash_sha256,
1498
- file_mtime_ms: candidate.session_file_mtime_ms,
1499
- byte_size: candidate.byte_size,
1500
- byte_offset: candidate.byte_size,
1501
- state: candidate.state,
1502
- reason: candidate.reason,
1503
- worktree_fingerprint: candidate.worktree?.worktree_fingerprint ?? null,
1504
- uploaded_object_key: objectKey,
1505
- uploaded_at: options.now.toISOString(),
1506
- uploaded_byte_size: candidate.byte_size,
1507
- last_seen_at: options.now.toISOString(),
1508
- };
1745
+ cursor.sessions[candidate.session_id] = deliveredCursorEntry(prior, candidate, objectKey, options.now);
1509
1746
  changed = true;
1510
1747
  recordedCount += 1;
1511
1748
  }
@@ -1520,6 +1757,42 @@ async function recordBackfillDurableSessionPointers(options) {
1520
1757
  console.error("[backfill] terminal session pointers recorded", JSON.stringify({ count: recordedCount }));
1521
1758
  }
1522
1759
  }
1760
+ /** Storage keys for main transcripts that actually landed, keyed by source and session. */
1761
+ function indexDurableMainObjectKeys(syncResults) {
1762
+ const durableObjectBySession = new Map();
1763
+ for (const sync of syncResults) {
1764
+ for (const outcome of sync.raw_evidence_outcomes) {
1765
+ if (!outcome.codex_session_id ||
1766
+ (outcome.kind !== "codex_jsonl" && outcome.kind !== "claude_jsonl") ||
1767
+ (outcome.upload_state !== "uploaded" &&
1768
+ outcome.upload_state !== "reused_existing") ||
1769
+ !outcome.object_key) {
1770
+ continue;
1771
+ }
1772
+ const source = outcome.kind === "codex_jsonl" ? "codex" : "claude_code";
1773
+ durableObjectBySession.set(`${source}:${outcome.codex_session_id}`, outcome.object_key);
1774
+ }
1775
+ }
1776
+ return durableObjectBySession;
1777
+ }
1778
+ /** The cursor entry for a session backfill has just delivered in full. */
1779
+ function deliveredCursorEntry(prior, candidate, objectKey, now) {
1780
+ return {
1781
+ ...prior,
1782
+ file_hash_sha256: candidate.content_hash_sha256,
1783
+ file_mtime_ms: candidate.session_file_mtime_ms,
1784
+ byte_size: candidate.byte_size,
1785
+ // The whole file was delivered, so the incremental reader starts at its end.
1786
+ byte_offset: candidate.byte_size,
1787
+ state: candidate.state,
1788
+ reason: candidate.reason,
1789
+ worktree_fingerprint: candidate.worktree?.worktree_fingerprint ?? null,
1790
+ uploaded_object_key: objectKey,
1791
+ uploaded_at: now.toISOString(),
1792
+ uploaded_byte_size: candidate.byte_size,
1793
+ last_seen_at: now.toISOString(),
1794
+ };
1795
+ }
1523
1796
  function countSessionUploadFailures(sync) {
1524
1797
  return sync.raw_evidence_outcomes.filter((outcome) => Boolean(outcome.codex_session_id) &&
1525
1798
  outcome.upload_state === "upload_failed" &&