@bli-cockpit/cli 0.2.4 → 0.2.7

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.
@@ -1,4 +1,5 @@
1
1
  import { containsSecretLikeContent, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
2
+ import crypto from "node:crypto";
2
3
  import fs from "node:fs/promises";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
@@ -6,38 +7,41 @@ import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.j
6
7
  import { defaultCodexSessionDirs, scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
7
8
  import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
8
9
  import { acquireBackfillLock } from "../backfill-lock.js";
9
- import { emptyBackfillCursorState, readBackfillCursor, recordBackfillCursorObservations, writeBackfillCompletionMarker, writeBackfillCursor } from "../cursors/backfill-cursor.js";
10
- import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext } from "../local-state.js";
11
- import { discoverGitWorktrees } from "../repo-identity.js";
10
+ import { BACKFILL_COMPLETION_RECHECK_MS, BACKFILL_COVERAGE_VERSION, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCursor, recordBackfillCursorObservations, recordBackfillScanCoverage, writeBackfillCompletionMarker, writeBackfillCursor, } from "../cursors/backfill-cursor.js";
11
+ import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, startLocalWorkContext } from "../local-state.js";
12
+ import { collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
13
+ import { isRawEvidenceUploadableAttributionState } from "../raw-evidence-attribution-policy.js";
12
14
  import { normalizeCollectionRoots } from "../root-normalization.js";
15
+ import { acquireSyncLock } from "../sync-lock.js";
13
16
  import { LocalUploadBlockedError, postCodexSessionReport, syncLocalAmbientEnvelope } from "../upload.js";
14
17
  const BACKFILL_UPLOAD_BATCH_SESSIONS = 25;
15
18
  const BACKFILL_MAX_CONSECUTIVE_FAILURES = 3;
16
19
  const ALL_BACKFILL_SINCE_MINUTES = 20 * 365 * 24 * 60;
17
20
  const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
18
21
  const DEFAULT_DISCOVERY_MAX_REPOS = 50;
19
- const RETRY_COMMAND = "cockpit backfill";
20
22
  export async function runBackfillCommand(command, io) {
21
23
  if (!command.all && command.sinceDays === undefined) {
22
24
  const message = bareBackfillMessage();
25
+ const retryCommand = backfillRetryCommand(command);
23
26
  writeLine(io.stderr, message);
24
27
  if (command.json) {
25
28
  writeLine(io.stdout, JSON.stringify({
26
29
  status: "blocked",
27
30
  reason: "missing_window",
28
- retry_command: RETRY_COMMAND,
31
+ retry_command: retryCommand,
29
32
  }, null, 2));
30
33
  }
31
34
  return 1;
32
35
  }
33
36
  if (command.all && !command.yes && !isInteractiveStdin(io)) {
34
37
  const message = "--all requires TTY confirmation; pass --yes for agent runs.";
38
+ const retryCommand = backfillRetryCommand(command);
35
39
  writeLine(io.stderr, message);
36
40
  if (command.json) {
37
41
  writeLine(io.stdout, JSON.stringify({
38
42
  status: "blocked",
39
43
  reason: "all_requires_confirmation",
40
- retry_command: RETRY_COMMAND,
44
+ retry_command: retryCommand,
41
45
  }, null, 2));
42
46
  }
43
47
  return 1;
@@ -67,8 +71,10 @@ export async function runBackfill(command, io) {
67
71
  const pairedAt = parseRequiredDate(sessionFile.paired_at, "paired_at");
68
72
  const dashboardUrl = normalizeDashboardUrl(sessionFile.dashboard_url ?? config.dashboard_url);
69
73
  const roots = backfillCollectionRoots(command, config.default_repo_paths);
70
- const worktrees = await discoverBackfillWorktrees(roots);
71
- const collectionRoots = normalizeCollectionRoots(roots);
74
+ const collectionRoots = normalizeCollectionRoots(await collectionRootPathAliases(roots));
75
+ const worktreeDiscovery = await discoverBackfillWorktrees(collectionRoots, command);
76
+ const retryCommand = backfillDiscoveryRetryCommand(command, worktreeDiscovery);
77
+ const worktrees = worktreeDiscovery.worktrees;
72
78
  const sources = selectedSources(command.source);
73
79
  const window = backfillWindow(command, now, pairedAt);
74
80
  await validateBackfillReachability({
@@ -76,9 +82,11 @@ export async function runBackfill(command, io) {
76
82
  dashboardUrl,
77
83
  dryRun: command.dryRun,
78
84
  });
79
- const cursor = command.dryRun
85
+ const storedCursor = command.dryRun
80
86
  ? emptyBackfillCursorState()
81
87
  : await readBackfillCursor(paths);
88
+ const scopedCursor = prepareBackfillCursorForScope(storedCursor, collectionRoots, sources);
89
+ const cursor = scopedCursor.cursor;
82
90
  const scan = await scanBackfillSessions({
83
91
  command,
84
92
  homeDir: command.homeDir ?? os.homedir(),
@@ -89,8 +97,42 @@ export async function runBackfill(command, io) {
89
97
  cursor,
90
98
  now,
91
99
  });
92
- const guardCounts = await countReadOnlyGuards(scan.candidates);
93
- const reasonCounts = reasonCountsFor(scan.candidates, guardCounts);
100
+ for (const reason of worktreeDiscovery.incomplete_reasons) {
101
+ scan.issues.push({
102
+ reason: `repo_discovery_${reason}`,
103
+ count: 1,
104
+ scope: "global",
105
+ });
106
+ }
107
+ scan.issues.sort((a, b) => scanIssuePriority(a.scope) - scanIssuePriority(b.scope) ||
108
+ a.reason.localeCompare(b.reason));
109
+ const guards = await countReadOnlyGuards(scan.candidates);
110
+ for (const candidate of scan.candidates) {
111
+ const permanentReason = guards.permanent_skip_reasons.get(candidateCursorKey(candidate));
112
+ if (!permanentReason)
113
+ continue;
114
+ candidate.state = "skipped";
115
+ candidate.reason = permanentReason;
116
+ }
117
+ scan.retryable_candidate_keys = new Set([
118
+ ...scan.retryable_candidate_keys,
119
+ ...guards.retryable_candidate_keys,
120
+ ]);
121
+ if (guards.counts.get("file_read_failed")) {
122
+ scan.issues.push({
123
+ reason: "candidate_file_read_failed",
124
+ count: guards.counts.get("file_read_failed") ?? 0,
125
+ scope: "candidate",
126
+ });
127
+ }
128
+ if (guards.counts.get("file_too_large")) {
129
+ scan.issues.push({
130
+ reason: "file_too_large",
131
+ count: guards.counts.get("file_too_large") ?? 0,
132
+ scope: "candidate",
133
+ });
134
+ }
135
+ const reasonCounts = reasonCountsFor(scan.candidates, guards.counts, scan.issues);
94
136
  if (command.all && !command.yes) {
95
137
  if (!command.json) {
96
138
  writeDryRunSummary(io, {
@@ -148,8 +190,12 @@ export async function runBackfill(command, io) {
148
190
  scan,
149
191
  reasonCounts,
150
192
  }),
151
- status: scan.candidates.length === 0 ? "blocked" : "complete",
193
+ status: scan.issues.length > 0 ? "partial" : "complete",
152
194
  dry_run: true,
195
+ retry_command: retryCommand,
196
+ ...(scan.issues.length > 0
197
+ ? { failure_reason: scan.issues[0]?.reason }
198
+ : {}),
153
199
  };
154
200
  }
155
201
  const lock = await acquireBackfillLock(paths, now);
@@ -165,6 +211,7 @@ export async function runBackfill(command, io) {
165
211
  reasonCounts,
166
212
  }),
167
213
  status: "blocked",
214
+ retry_command: retryCommand,
168
215
  failure_reason: "backfill_already_running",
169
216
  blocked_at: {
170
217
  what: "backfill lock held",
@@ -175,7 +222,46 @@ export async function runBackfill(command, io) {
175
222
  },
176
223
  };
177
224
  }
225
+ let collectionLock;
178
226
  try {
227
+ collectionLock = await acquireSyncLock(paths);
228
+ }
229
+ catch (error) {
230
+ await lock.handle.release();
231
+ throw error;
232
+ }
233
+ if (!collectionLock.acquired) {
234
+ await lock.handle.release();
235
+ return {
236
+ ...baseBackfillResult(command, {
237
+ now,
238
+ dashboardUrl,
239
+ sources,
240
+ window,
241
+ cursor,
242
+ scan,
243
+ reasonCounts,
244
+ }),
245
+ status: "blocked",
246
+ retry_command: retryCommand,
247
+ failure_reason: "sync_already_running",
248
+ blocked_at: {
249
+ what: "sync collection lock held",
250
+ batch_index: 0,
251
+ batch_total: 0,
252
+ done: 0,
253
+ total: scan.candidates.length,
254
+ },
255
+ };
256
+ }
257
+ try {
258
+ // Backfill and scheduled/manual sync share upload spool and raw-evidence
259
+ // cursors. Holding the same collection lock makes their read-modify-write
260
+ // updates serial, while the dedicated backfill lock still prevents two
261
+ // historical scans from running together.
262
+ if (scopedCursor.reset) {
263
+ await writeBackfillCursor(paths, cursor);
264
+ }
179
265
  const uploadable = uploadableCandidates(scan.candidates);
180
266
  const batches = buildBackfillBatches(uploadable);
181
267
  const rawEvidenceBudget = {
@@ -192,8 +278,9 @@ export async function runBackfill(command, io) {
192
278
  let uploadedObjects = 0;
193
279
  let uploadedChunks = 0;
194
280
  let backfilledSessions = 0;
281
+ const durableCandidateKeys = new Set();
195
282
  let blockedAt;
196
- let failureReason;
283
+ let failureReason = scan.issues[0]?.reason;
197
284
  for (const [index, batch] of batches.entries()) {
198
285
  await lock.handle.heartbeat();
199
286
  const sync = await syncBackfillBatch({
@@ -209,12 +296,18 @@ export async function runBackfill(command, io) {
209
296
  done += batch.candidates.length;
210
297
  uploadedObjects += sync.raw_evidence_uploaded_object_count;
211
298
  uploadedChunks += sync.raw_evidence_uploaded_chunk_count;
212
- failed += sync.raw_evidence_failed_count;
299
+ failed += countSessionUploadFailures(sync);
213
300
  deferred +=
214
301
  sync.raw_evidence_deferred_byte_budget +
215
302
  sync.raw_evidence_deferred_object_budget;
216
- backfilledSessions += countBackfilledSessions(sync);
217
- const batchFailed = sync.status !== "uploaded" || sync.raw_evidence_failed_count > 0;
303
+ const durableInBatch = durableBackfillCandidateKeys(batch, sync);
304
+ for (const key of durableInBatch)
305
+ durableCandidateKeys.add(key);
306
+ backfilledSessions = durableCandidateKeys.size;
307
+ const missingDurableMain = batch.candidates.length - durableInBatch.size;
308
+ const batchFailed = sync.status !== "uploaded" ||
309
+ countSessionUploadFailures(sync) > 0 ||
310
+ missingDurableMain > 0;
218
311
  const batchDeferred = sync.raw_evidence_deferred_byte_budget +
219
312
  sync.raw_evidence_deferred_object_budget >
220
313
  0;
@@ -227,16 +320,10 @@ export async function runBackfill(command, io) {
227
320
  }
228
321
  if (!batchFailed && !batchDeferred) {
229
322
  completedBatches += 1;
230
- recordBackfillCursorObservations(cursor, batch.candidates.map((candidate) => ({
231
- source: candidate.source,
232
- state: candidate.state,
233
- reason: candidate.reason,
234
- session_file_mtime_ms: candidate.session_file_mtime_ms,
235
- session_file_mtime: candidate.session_file_mtime,
236
- })), now);
237
- await writeBackfillCursor(paths, cursor);
238
323
  }
239
- writeLine(io.stdout, `Uploaded ${done}/${uploadable.length} (batch ${index + 1}/${batches.length})`);
324
+ if (!command.json) {
325
+ writeLine(io.stdout, `Uploaded ${done}/${uploadable.length} (batch ${index + 1}/${batches.length})`);
326
+ }
240
327
  await yieldToEventLoop();
241
328
  if (batchDeferred) {
242
329
  blockedAt = {
@@ -258,7 +345,11 @@ export async function runBackfill(command, io) {
258
345
  total: uploadable.length,
259
346
  };
260
347
  failureReason =
261
- sync.status === "spooled" ? sync.failure_reason : "upload_failed";
348
+ sync.status === "spooled"
349
+ ? sync.failure_reason
350
+ : missingDurableMain > 0
351
+ ? "durable_session_pointer_missing"
352
+ : "upload_failed";
262
353
  break;
263
354
  }
264
355
  }
@@ -267,17 +358,28 @@ export async function runBackfill(command, io) {
267
358
  syncResults,
268
359
  now,
269
360
  });
361
+ const reportContext = sessions.length
362
+ ? await ensureBackfillReportContext({
363
+ homeDir: command.homeDir,
364
+ paths,
365
+ collectionRoots,
366
+ worktrees,
367
+ candidates: scan.candidates,
368
+ })
369
+ : null;
270
370
  const report = sessions.length
271
371
  ? await postCodexSessionReport({
272
372
  homeDir: command.homeDir,
273
- repoRoot: worktrees[0]?.repo_root,
373
+ repoRoot: reportContext?.repoRoot,
274
374
  dashboardUrl,
275
375
  sessions,
276
376
  fetch: io.fetch,
277
377
  now,
278
378
  })
279
379
  : emptyReport("no_sessions_observed");
280
- if (sessions.length > 0 && !report.posted && !blockedAt) {
380
+ const reportAcknowledged = sessions.length === 0 ||
381
+ (report.posted && report.recorded_count >= sessions.length);
382
+ if (!reportAcknowledged && !blockedAt) {
281
383
  blockedAt = {
282
384
  what: "session report failed",
283
385
  batch_index: completedBatches,
@@ -285,20 +387,77 @@ export async function runBackfill(command, io) {
285
387
  done,
286
388
  total: uploadable.length,
287
389
  };
288
- failureReason = report.reason;
390
+ failureReason ??=
391
+ report.posted && report.recorded_count < sessions.length
392
+ ? "session_report_ack_incomplete"
393
+ : report.reason;
289
394
  }
290
- const remaining = Math.max(0, uploadable.length - done);
291
- const status = blockedAt
292
- ? remaining > 0 || deferred > 0
293
- ? "partial"
294
- : "blocked"
295
- : scan.candidates.length === 0
296
- ? "blocked"
297
- : "complete";
298
- if (status === "complete" && deferred === 0 && failed === 0) {
395
+ // Raw evidence durability is necessary but not sufficient: the server must
396
+ // also acknowledge the session attribution rows before their historical
397
+ // cursor positions become irreversible.
398
+ if (reportAcknowledged) {
399
+ const cursorAdvanced = advanceBackfillCursorThroughResolvedPrefix({
400
+ cursor,
401
+ candidates: scan.candidates,
402
+ durableCandidateKeys,
403
+ retryableCandidateKeys: scan.retryable_candidate_keys,
404
+ discoveryComplete: !scan.issues.some((issue) => issue.scope === "global"),
405
+ now,
406
+ });
407
+ if (cursorAdvanced)
408
+ await writeBackfillCursor(paths, cursor);
409
+ }
410
+ const unresolvedUploadableKeys = new Set(uploadable
411
+ .filter((candidate) => !durableCandidateKeys.has(candidateCursorKey(candidate)))
412
+ .map(candidateCursorKey));
413
+ const unresolvedUploadable = unresolvedUploadableKeys.size;
414
+ const unresolvedRetryable = scan.retryable_candidate_keys.size;
415
+ const unresolvedKnownKeys = new Set([
416
+ ...unresolvedUploadableKeys,
417
+ ...scan.retryable_candidate_keys,
418
+ ]);
419
+ const unseenGlobalFailures = scan.issues
420
+ .filter((issue) => issue.scope === "global")
421
+ .reduce((total, issue) => total + issue.count, 0);
422
+ const reportRetryable = reportAcknowledged
423
+ ? 0
424
+ // The cursor is intentionally all-or-nothing for the report. Even
425
+ // chunks already accepted by the server are retried idempotently when
426
+ // another required chunk lacks an acknowledgement.
427
+ : Math.max(1, sessions.length);
428
+ const remaining = unresolvedKnownKeys.size +
429
+ scan.omitted_candidate_count +
430
+ unseenGlobalFailures +
431
+ reportRetryable;
432
+ failed = Math.max(failed, unresolvedUploadable);
433
+ const completionBlocked = scan.issues.length > 0 ||
434
+ unresolvedUploadable > 0 ||
435
+ unresolvedRetryable > 0 ||
436
+ deferred > 0 ||
437
+ failed > 0 ||
438
+ !reportAcknowledged;
439
+ if (!failureReason && completionBlocked) {
440
+ const retryableCandidateReason = scan.candidates.find((candidate) => scan.retryable_candidate_keys.has(candidateCursorKey(candidate)))?.reason;
441
+ failureReason =
442
+ scan.issues[0]?.reason ??
443
+ (unresolvedUploadable > 0
444
+ ? "durable_session_pointer_missing"
445
+ : retryableCandidateReason ?? "backfill_incomplete");
446
+ }
447
+ const status = blockedAt || completionBlocked ? "partial" : "complete";
448
+ // The marker is consumed by doctor/status as proof that archived history
449
+ // is covered. A bounded --since-days run may complete its requested
450
+ // window, but it is not proof of an all-history backfill.
451
+ if (status === "complete" && command.all) {
452
+ recordBackfillScanCoverage(cursor, sources, now, now);
453
+ await writeBackfillCursor(paths, cursor);
299
454
  await writeBackfillCompletionMarker(paths, {
300
- schema_version: "cockpit-backfill-complete.v1",
455
+ schema_version: "cockpit-backfill-complete.v2",
456
+ coverage_version: BACKFILL_COVERAGE_VERSION,
457
+ collection_scope_id: scopedCursor.collection_scope_id,
458
+ sources,
301
459
  completed_at: now.toISOString(),
460
+ revalidate_after: new Date(now.getTime() + BACKFILL_COMPLETION_RECHECK_MS).toISOString(),
302
461
  cursor,
303
462
  });
304
463
  }
@@ -313,6 +472,7 @@ export async function runBackfill(command, io) {
313
472
  reasonCounts,
314
473
  }),
315
474
  status,
475
+ retry_command: retryCommand,
316
476
  counts: {
317
477
  ...baseBackfillResult(command, {
318
478
  now,
@@ -344,6 +504,7 @@ export async function runBackfill(command, io) {
344
504
  };
345
505
  }
346
506
  finally {
507
+ await collectionLock.handle.release();
347
508
  await lock.handle.release();
348
509
  }
349
510
  }
@@ -354,6 +515,58 @@ function bareBackfillMessage() {
354
515
  "Use `cockpit backfill --all` only after reviewing a dry-run; on headless agent runs add `--yes`.",
355
516
  ].join("\n");
356
517
  }
518
+ /**
519
+ * Produces a copyable retry that preserves the requested history window and
520
+ * every discovery/selection override. Values that need quoting use syntax
521
+ * accepted by the supported native shells: POSIX shells on macOS and
522
+ * PowerShell on Windows.
523
+ */
524
+ export function backfillRetryCommand(command) {
525
+ const parts = ["cockpit", "backfill"];
526
+ if (command.all) {
527
+ parts.push("--all", "--yes");
528
+ }
529
+ else if (command.sinceDays !== undefined) {
530
+ parts.push("--since-days", String(command.sinceDays));
531
+ }
532
+ if (command.source)
533
+ parts.push("--source", command.source);
534
+ if (command.maxFiles !== undefined) {
535
+ parts.push("--max-files", String(command.maxFiles));
536
+ }
537
+ if (command.maxDepth !== undefined) {
538
+ parts.push("--max-depth", String(command.maxDepth));
539
+ }
540
+ if (command.maxRepos !== undefined) {
541
+ parts.push("--max-repos", String(command.maxRepos));
542
+ }
543
+ if (command.repoRoot) {
544
+ parts.push("--workspace", quoteCliArgument(command.repoRoot));
545
+ }
546
+ if (command.dryRun)
547
+ parts.push("--dry-run");
548
+ return parts.join(" ");
549
+ }
550
+ function backfillDiscoveryRetryCommand(command, discovery) {
551
+ const retry = { ...command };
552
+ if (discovery.incomplete_reasons.includes("max_depth_reached")) {
553
+ retry.maxDepth =
554
+ (command.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH) + 1;
555
+ }
556
+ if (discovery.incomplete_reasons.includes("max_worktrees_reached")) {
557
+ retry.maxRepos =
558
+ (command.maxRepos ?? DEFAULT_DISCOVERY_MAX_REPOS) * 2;
559
+ }
560
+ return backfillRetryCommand(retry);
561
+ }
562
+ function quoteCliArgument(value) {
563
+ if (/^[a-z0-9_./:\\-]+$/iu.test(value))
564
+ return value;
565
+ const escaped = process.platform === "win32"
566
+ ? value.replaceAll("'", "''")
567
+ : value.replaceAll("'", `'\"'\"'`);
568
+ return `'${escaped}'`;
569
+ }
357
570
  function backfillCollectionRoots(command, savedRoots) {
358
571
  if (command.repoRoot)
359
572
  return [path.resolve(command.repoRoot)];
@@ -363,27 +576,11 @@ function backfillCollectionRoots(command, savedRoots) {
363
576
  }
364
577
  return roots;
365
578
  }
366
- async function discoverBackfillWorktrees(roots) {
367
- const discovered = [];
368
- for (const root of roots) {
369
- discovered.push(...(await discoverGitWorktrees(root, {
370
- maxDepth: DEFAULT_DISCOVERY_MAX_DEPTH,
371
- maxWorktrees: DEFAULT_DISCOVERY_MAX_REPOS,
372
- })));
373
- }
374
- const seen = new Set();
375
- const deduped = [];
376
- for (const worktree of discovered) {
377
- const key = worktree.worktree_fingerprint || path.resolve(worktree.repo_root);
378
- if (seen.has(key))
379
- continue;
380
- seen.add(key);
381
- deduped.push(worktree);
382
- }
383
- if (deduped.length === 0) {
384
- throw new Error("No git repos found in the backfill collection roots.");
385
- }
386
- return deduped;
579
+ async function discoverBackfillWorktrees(roots, command) {
580
+ return discoverGitWorktreesInRootsWithStatus(roots, {
581
+ maxDepth: command.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
582
+ maxWorktrees: command.maxRepos ?? DEFAULT_DISCOVERY_MAX_REPOS,
583
+ });
387
584
  }
388
585
  function selectedSources(source) {
389
586
  if (source === "codex")
@@ -426,9 +623,11 @@ async function validateBackfillReachability(options) {
426
623
  }
427
624
  async function scanBackfillSessions(options) {
428
625
  const scanLimit = options.command.maxFiles ?? 10_000;
429
- const codexAttribution = options.sources.includes("codex")
626
+ const codexSessionDirs = defaultCodexSessionDirs(options.homeDir);
627
+ const claudeProjectsDir = path.join(options.homeDir, ".claude", "projects");
628
+ let codexAttribution = options.sources.includes("codex")
430
629
  ? await scanAndAttributeCodexSessions({
431
- sessionsDirs: defaultCodexSessionDirs(options.homeDir),
630
+ sessionsDirs: codexSessionDirs,
432
631
  worktrees: options.worktrees,
433
632
  now: options.now,
434
633
  sinceMinutes: options.window.since_minutes,
@@ -436,9 +635,22 @@ async function scanBackfillSessions(options) {
436
635
  collectionRoots: options.collectionRoots,
437
636
  })
438
637
  : null;
439
- const claudeAttribution = options.sources.includes("claude_code")
638
+ if (codexAttribution?.session_limit_applied) {
639
+ // The adapter limit is a memory guard, not permission to silently leave
640
+ // history undiscovered. Re-scan the exact discovered population so a
641
+ // user-facing --max-files cap can resume from a truthful ordering.
642
+ codexAttribution = await scanAndAttributeCodexSessions({
643
+ sessionsDirs: codexSessionDirs,
644
+ worktrees: options.worktrees,
645
+ now: options.now,
646
+ sinceMinutes: options.window.since_minutes,
647
+ limit: Math.max(scanLimit + 1, codexAttribution.discovered_file_count),
648
+ collectionRoots: options.collectionRoots,
649
+ });
650
+ }
651
+ let claudeAttribution = options.sources.includes("claude_code")
440
652
  ? await scanAndAttributeClaudeSessions({
441
- projectsDir: path.join(options.homeDir, ".claude", "projects"),
653
+ projectsDir: claudeProjectsDir,
442
654
  worktrees: options.worktrees,
443
655
  now: options.now,
444
656
  sinceMinutes: options.window.since_minutes,
@@ -446,14 +658,40 @@ async function scanBackfillSessions(options) {
446
658
  collectionRoots: options.collectionRoots,
447
659
  })
448
660
  : null;
449
- const candidates = [
661
+ if (claudeAttribution?.session_limit_applied) {
662
+ claudeAttribution = await scanAndAttributeClaudeSessions({
663
+ projectsDir: claudeProjectsDir,
664
+ worktrees: options.worktrees,
665
+ now: options.now,
666
+ sinceMinutes: options.window.since_minutes,
667
+ limit: Math.max(scanLimit + 1, claudeAttribution.discovered_session_count),
668
+ collectionRoots: options.collectionRoots,
669
+ });
670
+ }
671
+ const allCandidates = [
450
672
  ...(codexAttribution?.results.map(normalizeCodexCandidate) ?? []),
451
673
  ...(claudeAttribution?.results.map(normalizeClaudeCandidate) ?? []),
452
674
  ]
453
675
  .filter((candidate) => isAfterCursor(candidate, options.cursor))
454
- .sort((a, b) => b.session_file_mtime_ms - a.session_file_mtime_ms)
455
- .slice(0, options.command.maxFiles ?? Number.MAX_SAFE_INTEGER);
456
- return { candidates, codexAttribution, claudeAttribution };
676
+ .sort(compareBackfillCandidates);
677
+ const candidates = allCandidates.slice(0, options.command.maxFiles ?? Number.MAX_SAFE_INTEGER);
678
+ const omittedCandidateCount = allCandidates.length - candidates.length;
679
+ const issues = await backfillScanIssues({
680
+ codexAttribution,
681
+ claudeAttribution,
682
+ codexSessionDirs,
683
+ claudeProjectsDir,
684
+ candidates,
685
+ omittedCandidateCount,
686
+ });
687
+ return {
688
+ candidates,
689
+ codexAttribution,
690
+ claudeAttribution,
691
+ issues,
692
+ retryable_candidate_keys: retryableCandidateKeys(candidates),
693
+ omitted_candidate_count: omittedCandidateCount,
694
+ };
457
695
  }
458
696
  function normalizeCodexCandidate(result) {
459
697
  return {
@@ -495,43 +733,191 @@ function normalizeClaudeCandidate(result) {
495
733
  };
496
734
  }
497
735
  function isAfterCursor(candidate, cursor) {
498
- const oldest = cursor.sources[candidate.source]?.oldest_mtime_ms_processed;
499
- return oldest === null || oldest === undefined || candidate.session_file_mtime_ms < oldest;
736
+ const source = cursor.sources[candidate.source];
737
+ const oldest = source?.oldest_mtime_ms_processed;
738
+ if (oldest === null || oldest === undefined)
739
+ return true;
740
+ if (candidate.session_file_mtime_ms < oldest)
741
+ return true;
742
+ const key = candidateCursorKey(candidate);
743
+ if (candidate.session_file_mtime_ms === oldest) {
744
+ return !source.processed_keys_at_oldest_mtime.includes(key);
745
+ }
746
+ const newest = source.newest_mtime_ms_covered;
747
+ // A cursor written before upper-edge coverage existed gets one safe
748
+ // migration pass across the previously processed interval. Once that pass is
749
+ // acknowledged, subsequent --all runs only inspect genuinely newer files.
750
+ if (newest === null)
751
+ return true;
752
+ if (candidate.session_file_mtime_ms > newest)
753
+ return true;
754
+ if (candidate.session_file_mtime_ms < newest)
755
+ return false;
756
+ // A legacy cursor has no boundary identities. Rechecking the equal-time
757
+ // boundary is safe because durable evidence is content-addressed.
758
+ return !source.processed_keys_at_newest_mtime.includes(key);
759
+ }
760
+ function compareBackfillCandidates(a, b) {
761
+ return (b.session_file_mtime_ms - a.session_file_mtime_ms ||
762
+ candidateCursorKey(a).localeCompare(candidateCursorKey(b)));
763
+ }
764
+ function candidateCursorKey(candidate) {
765
+ return crypto
766
+ .createHash("sha256")
767
+ .update(JSON.stringify([
768
+ candidate.source,
769
+ candidate.session_id,
770
+ normalizedCursorPath(candidate.file_path),
771
+ ]))
772
+ .digest("hex");
773
+ }
774
+ function normalizedCursorPath(filePath) {
775
+ const windowsStyle = path.win32.isAbsolute(filePath) && !path.posix.isAbsolute(filePath);
776
+ if (windowsStyle)
777
+ return path.win32.normalize(filePath).toLowerCase();
778
+ const normalized = path.resolve(filePath);
779
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
780
+ }
781
+ async function backfillScanIssues(options) {
782
+ const issues = [];
783
+ const add = (reason, count, scope) => {
784
+ if (count > 0)
785
+ issues.push({ reason, count, scope });
786
+ };
787
+ if (options.codexAttribution) {
788
+ add("codex_session_limit_applied", options.codexAttribution.session_limit_applied
789
+ ? Math.max(1, options.codexAttribution.discovered_file_count -
790
+ options.codexAttribution.scanned_file_count)
791
+ : 0, "global");
792
+ const missingTopLevelDirs = (await Promise.all(options.codexSessionDirs.map(isMissingPath))).filter(Boolean).length;
793
+ add("codex_directory_read_failed", Math.max(0, options.codexAttribution.directory_read_failed_count -
794
+ missingTopLevelDirs), "global");
795
+ add("codex_session_stat_failed", options.codexAttribution.stat_failed_count, "global");
796
+ }
797
+ if (options.claudeAttribution) {
798
+ add("claude_session_limit_applied", options.claudeAttribution.session_limit_applied
799
+ ? Math.max(1, options.claudeAttribution.discovered_session_count -
800
+ options.claudeAttribution.scanned_session_count)
801
+ : 0, "global");
802
+ const projectsDirMissing = await isMissingPath(options.claudeProjectsDir);
803
+ add("claude_project_dir_read_failed", Math.max(0, options.claudeAttribution.project_dir_read_failed_count -
804
+ (projectsDirMissing ? 1 : 0)), "global");
805
+ add("claude_session_stat_failed", options.claudeAttribution.session_stat_failed_count, "global");
806
+ add("claude_sidecar_stat_failed", options.claudeAttribution.sidecar_stat_failed_count, "global");
807
+ add("claude_sidecar_dir_read_failed", await countUnreadableClaudeSidecarDirs(options.candidates), "global");
808
+ }
809
+ add("backfill_max_files_applied", options.omittedCandidateCount, "selection");
810
+ add("candidate_file_read_failed", options.candidates.filter((candidate) => candidate.reason === "file_read_failed").length, "candidate");
811
+ add("candidate_worktree_unavailable", options.candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state) &&
812
+ candidate.worktree === null).length, "candidate");
813
+ add("claude_main_file_too_large", options.candidates.filter((candidate) => candidate.source === "claude_code" &&
814
+ candidate.claude?.main_file_oversized).length, "candidate");
815
+ add("claude_sidecar_limit_applied", options.candidates.reduce((total, candidate) => total + (candidate.claude?.sidecars_capped ?? 0), 0), "candidate");
816
+ add("claude_sidecar_file_unreadable", options.candidates.reduce((total, candidate) => total +
817
+ (candidate.claude?.sidecar_files.filter((sidecar) => sidecar.skipped_reason === "file_read_failed" ||
818
+ sidecar.skipped_reason === "file_too_large").length ?? 0), 0), "candidate");
819
+ return issues.sort((a, b) => scanIssuePriority(a.scope) - scanIssuePriority(b.scope) ||
820
+ a.reason.localeCompare(b.reason));
821
+ }
822
+ function scanIssuePriority(scope) {
823
+ if (scope === "global")
824
+ return 0;
825
+ if (scope === "candidate")
826
+ return 1;
827
+ return 2;
828
+ }
829
+ function retryableCandidateKeys(candidates) {
830
+ return new Set(candidates
831
+ .filter((candidate) => candidate.reason === "file_read_failed" ||
832
+ candidate.reason === "repo_not_on_disk" ||
833
+ (isRawEvidenceUploadableAttributionState(candidate.state) &&
834
+ !candidate.worktree) ||
835
+ Boolean(candidate.claude?.main_file_oversized) ||
836
+ (candidate.claude?.sidecars_capped ?? 0) > 0 ||
837
+ Boolean(candidate.claude?.sidecar_files.some((sidecar) => sidecar.skipped_reason === "file_read_failed" ||
838
+ sidecar.skipped_reason === "file_too_large")))
839
+ .map(candidateCursorKey));
840
+ }
841
+ async function countUnreadableClaudeSidecarDirs(candidates) {
842
+ let unreadable = 0;
843
+ for (const candidate of candidates) {
844
+ if (candidate.source !== "claude_code")
845
+ continue;
846
+ const subagentsDir = path.join(path.dirname(candidate.file_path), path.basename(candidate.file_path).replace(/\.jsonl$/i, ""), "subagents");
847
+ try {
848
+ await fs.readdir(subagentsDir);
849
+ }
850
+ catch (error) {
851
+ if (!isMissingFsError(error))
852
+ unreadable += 1;
853
+ }
854
+ }
855
+ return unreadable;
856
+ }
857
+ async function isMissingPath(filePath) {
858
+ try {
859
+ await fs.stat(filePath);
860
+ return false;
861
+ }
862
+ catch (error) {
863
+ return isMissingFsError(error);
864
+ }
865
+ }
866
+ function isMissingFsError(error) {
867
+ if (!error || typeof error !== "object")
868
+ return false;
869
+ const code = error.code;
870
+ return code === "ENOENT" || code === "ENOTDIR";
500
871
  }
501
872
  async function countReadOnlyGuards(candidates) {
502
873
  const counts = new Map();
874
+ const retryableCandidateKeys = new Set();
875
+ const permanentSkipReasons = new Map();
503
876
  for (const candidate of candidates) {
504
- if (candidate.reason === "repo_not_on_disk")
877
+ if (candidate.reason === "repo_not_on_disk") {
505
878
  increment(counts, "repo_not_on_disk");
879
+ retryableCandidateKeys.add(candidateCursorKey(candidate));
880
+ }
506
881
  if (candidate.source === "claude_code" && candidate.claude?.main_file_oversized) {
507
882
  increment(counts, "file_too_large");
883
+ retryableCandidateKeys.add(candidateCursorKey(candidate));
508
884
  continue;
509
885
  }
510
886
  if (candidate.byte_size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
511
887
  increment(counts, "file_too_large");
888
+ retryableCandidateKeys.add(candidateCursorKey(candidate));
512
889
  continue;
513
890
  }
514
- if (!isUploadableState(candidate.state))
891
+ if (!isRawEvidenceUploadableAttributionState(candidate.state))
515
892
  continue;
516
893
  try {
517
894
  const raw = await fs.readFile(candidate.file_path, "utf8");
518
895
  if (containsSecretLikeContent(raw)) {
519
896
  increment(counts, "secret_like_content_guard");
897
+ permanentSkipReasons.set(candidateCursorKey(candidate), "secret_like_content_guard");
520
898
  }
521
899
  }
522
900
  catch {
523
901
  increment(counts, "file_read_failed");
902
+ retryableCandidateKeys.add(candidateCursorKey(candidate));
524
903
  }
525
904
  }
526
- return counts;
905
+ return {
906
+ counts,
907
+ retryable_candidate_keys: retryableCandidateKeys,
908
+ permanent_skip_reasons: permanentSkipReasons,
909
+ };
527
910
  }
528
- function reasonCountsFor(candidates, guardCounts) {
911
+ function reasonCountsFor(candidates, guardCounts, scanIssues) {
529
912
  const counts = new Map();
530
913
  for (const candidate of candidates)
531
914
  increment(counts, candidate.reason);
532
915
  for (const [reason, count] of guardCounts) {
533
916
  counts.set(reason, Math.max(counts.get(reason) ?? 0, count));
534
917
  }
918
+ for (const issue of scanIssues) {
919
+ counts.set(issue.reason, Math.max(counts.get(issue.reason) ?? 0, issue.count));
920
+ }
535
921
  for (const required of [
536
922
  "secret_like_content_guard",
537
923
  "file_too_large",
@@ -602,10 +988,8 @@ function reasonClassification(reason) {
602
988
  };
603
989
  }
604
990
  function uploadableCandidates(candidates) {
605
- return candidates.filter((candidate) => isUploadableState(candidate.state) && candidate.worktree);
606
- }
607
- function isUploadableState(state) {
608
- return state === "attributed" || state === "attributed_fallback";
991
+ return candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state) &&
992
+ candidate.worktree);
609
993
  }
610
994
  function buildBackfillBatches(candidates) {
611
995
  const byWorktree = new Map();
@@ -629,6 +1013,29 @@ function buildBackfillBatches(candidates) {
629
1013
  }
630
1014
  return batches;
631
1015
  }
1016
+ async function ensureBackfillReportContext(options) {
1017
+ const candidateWorktree = options.candidates.find((candidate) => candidate.worktree)?.worktree;
1018
+ const representative = candidateWorktree ?? options.worktrees[0] ?? null;
1019
+ // A deleted repo or an approved folder workspace can legitimately produce
1020
+ // report-only rows with no current git worktree. Use the exact approved root
1021
+ // as a synthetic local context instead of falling through to process.cwd().
1022
+ const repoRoot = representative?.repo_root ??
1023
+ options.collectionRoots[0] ??
1024
+ process.cwd();
1025
+ try {
1026
+ await readLocalWorkContextForRepo(options.paths, repoRoot);
1027
+ }
1028
+ catch {
1029
+ // Context creation is best-effort here. postCodexSessionReport converts a
1030
+ // remaining local-context failure into a retryable report reason.
1031
+ await startLocalWorkContext({
1032
+ homeDir: options.homeDir,
1033
+ repoRoot,
1034
+ branch: representative?.branch,
1035
+ }).catch(() => undefined);
1036
+ }
1037
+ return { repoRoot };
1038
+ }
632
1039
  async function syncBackfillBatch(options) {
633
1040
  const syncOptions = {
634
1041
  homeDir: options.command.homeDir,
@@ -737,7 +1144,7 @@ function buildBackfillSessionReport(options) {
737
1144
  raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
738
1145
  upload_state: upload.upload_state,
739
1146
  }
740
- : isUploadableState(candidate.state)
1147
+ : isRawEvidenceUploadableAttributionState(candidate.state)
741
1148
  ? { upload_state: "not_uploaded" }
742
1149
  : {}),
743
1150
  };
@@ -759,17 +1166,105 @@ function rank(state) {
759
1166
  return 0;
760
1167
  }
761
1168
  }
762
- function countBackfilledSessions(sync) {
763
- const keys = new Set();
1169
+ function durableBackfillCandidateKeys(batch, sync) {
1170
+ const durable = new Set();
1171
+ if (sync.status !== "uploaded")
1172
+ return durable;
1173
+ if (sync.raw_evidence_deferred_byte_budget > 0 ||
1174
+ sync.raw_evidence_deferred_object_budget > 0) {
1175
+ // Deferred outcomes have no per-file identity. Advancing any candidate in
1176
+ // this batch could therefore strand the deferred main transcript.
1177
+ return durable;
1178
+ }
1179
+ const outcomesBySession = new Map();
764
1180
  for (const outcome of sync.raw_evidence_outcomes) {
765
- if ((outcome.kind === "codex_jsonl" || outcome.kind === "claude_jsonl") &&
766
- outcome.codex_session_id &&
1181
+ const source = backfillSourceForEvidenceKind(outcome.kind);
1182
+ if (!source || !outcome.codex_session_id)
1183
+ continue;
1184
+ const key = `${source}:${outcome.codex_session_id}`;
1185
+ const summary = outcomesBySession.get(key) ?? {
1186
+ durableMainCount: 0,
1187
+ durableSidecarCount: 0,
1188
+ failed: false,
1189
+ };
1190
+ if (outcome.upload_state === "upload_failed") {
1191
+ summary.failed = true;
1192
+ }
1193
+ else if (outcome.raw_evidence_pointer_id &&
767
1194
  (outcome.upload_state === "uploaded" ||
768
1195
  outcome.upload_state === "reused_existing")) {
769
- keys.add(`${outcome.kind}:${outcome.codex_session_id}`);
1196
+ if (outcome.kind === "codex_jsonl" ||
1197
+ outcome.kind === "claude_jsonl") {
1198
+ summary.durableMainCount += 1;
1199
+ }
1200
+ else if (outcome.kind === "claude_jsonl_sidecar") {
1201
+ summary.durableSidecarCount += 1;
1202
+ }
1203
+ }
1204
+ outcomesBySession.set(key, summary);
1205
+ }
1206
+ for (const candidate of batch.candidates) {
1207
+ const summary = outcomesBySession.get(`${candidate.source}:${candidate.session_id}`);
1208
+ const expectedSidecars = candidate.source === "claude_code"
1209
+ ? (candidate.claude?.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length ?? 0)
1210
+ : 0;
1211
+ if (summary &&
1212
+ !summary.failed &&
1213
+ summary.durableMainCount > 0 &&
1214
+ summary.durableSidecarCount >= expectedSidecars) {
1215
+ durable.add(candidateCursorKey(candidate));
1216
+ }
1217
+ }
1218
+ return durable;
1219
+ }
1220
+ function countSessionUploadFailures(sync) {
1221
+ return sync.raw_evidence_outcomes.filter((outcome) => Boolean(outcome.codex_session_id) &&
1222
+ outcome.upload_state === "upload_failed" &&
1223
+ Boolean(backfillSourceForEvidenceKind(outcome.kind))).length;
1224
+ }
1225
+ function backfillSourceForEvidenceKind(kind) {
1226
+ if (kind === "codex_jsonl" || kind === "codex_image_attachment") {
1227
+ return "codex";
1228
+ }
1229
+ if (kind === "claude_jsonl" ||
1230
+ kind === "claude_jsonl_sidecar" ||
1231
+ kind === "claude_image_attachment") {
1232
+ return "claude_code";
1233
+ }
1234
+ return null;
1235
+ }
1236
+ function advanceBackfillCursorThroughResolvedPrefix(options) {
1237
+ if (!options.discoveryComplete)
1238
+ return false;
1239
+ const observations = [];
1240
+ for (const source of ["codex", "claude_code"]) {
1241
+ const remaining = options.candidates
1242
+ .filter((candidate) => candidate.source === source &&
1243
+ isAfterCursor(candidate, options.cursor))
1244
+ .sort(compareBackfillCandidates);
1245
+ for (const candidate of remaining) {
1246
+ const key = candidateCursorKey(candidate);
1247
+ if (options.retryableCandidateKeys.has(key))
1248
+ break;
1249
+ const resolved = isRawEvidenceUploadableAttributionState(candidate.state)
1250
+ ? options.durableCandidateKeys.has(key)
1251
+ : true;
1252
+ if (!resolved)
1253
+ break;
1254
+ observations.push({
1255
+ source: candidate.source,
1256
+ cursor_key: key,
1257
+ state: candidate.state,
1258
+ reason: candidate.reason,
1259
+ session_file_mtime_ms: candidate.session_file_mtime_ms,
1260
+ session_file_mtime: candidate.session_file_mtime,
1261
+ });
770
1262
  }
771
1263
  }
772
- return keys.size;
1264
+ if (observations.length === 0)
1265
+ return false;
1266
+ recordBackfillCursorObservations(options.cursor, observations, options.now);
1267
+ return true;
773
1268
  }
774
1269
  function worktreeInventoryForRepo(current, worktrees) {
775
1270
  return worktrees
@@ -820,7 +1315,7 @@ function baseBackfillResult(command, options) {
820
1315
  failed: 0,
821
1316
  },
822
1317
  resume_cursor: options.cursor,
823
- retry_command: RETRY_COMMAND,
1318
+ retry_command: backfillRetryCommand(command),
824
1319
  report: emptyReport("not_posted"),
825
1320
  server_acknowledged: {
826
1321
  codex_session_report_recorded_count: 0,
@@ -860,7 +1355,7 @@ function blockedBackfillResult(command, options) {
860
1355
  },
861
1356
  batches: { total: 0, completed: 0, failed: 0 },
862
1357
  resume_cursor: options.cursor,
863
- retry_command: RETRY_COMMAND,
1358
+ retry_command: backfillRetryCommand(command),
864
1359
  report: emptyReport("not_posted"),
865
1360
  server_acknowledged: {
866
1361
  codex_session_report_recorded_count: 0,