@bli-cockpit/cli 0.2.25 → 0.2.26

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.
@@ -15,8 +15,9 @@ const GIT_DIFF_TIMEOUT_MS = 3_000;
15
15
  export const RAW_EVIDENCE_BUCKET = "ambient-raw-evidence";
16
16
  export const RAW_EVIDENCE_RETENTION_MODE = "remote_durable";
17
17
  // Per-sync upload budgets enforced at COLLECTION time (D7b). A single marathon
18
- // transcript can approach the 256 MiB wire cap, so 2 GiB leaves room for
19
- // several files without starving the sync; overflow still defers and converges.
18
+ // transcript can approach the 500 MiB wire cap (RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES),
19
+ // so 2 GiB leaves room for several files without starving the sync; overflow
20
+ // still defers and converges.
20
21
  export const RAW_EVIDENCE_DEFAULT_BYTE_BUDGET = 2 * 1024 * 1024 * 1024;
21
22
  export const RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET = 300;
22
23
  const CLAUDE_MAX_COLLECT_FILE_BYTES = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES;
@@ -129,6 +129,23 @@ export async function runBackfill(command, io) {
129
129
  });
130
130
  }
131
131
  const reasonCounts = reasonCountsFor(scan.candidates, guards.counts, scan.issues);
132
+ // BLI-2727: a deterministic, labeled oversized skip must never poison
133
+ // completion — it is a permanent, non-retryable fact about the file, not an
134
+ // in-flight problem a rerun can fix. `scan.issues`/`retryable_candidate_keys`
135
+ // still carry it (so it's never silently dropped from reporting); every
136
+ // completion-gating computation below excludes it explicitly instead.
137
+ const oversizedCandidateKeys = oversizedBackfillCandidateKeys(scan.candidates);
138
+ // Two scan issues describe the exact same oversized-main candidates:
139
+ // `backfillScanIssues` pushes the Claude-specific `claude_main_file_too_large`
140
+ // (from `claude?.main_file_oversized`) and `countReadOnlyGuards` separately
141
+ // pushes the source-agnostic `file_too_large` (same predicate as
142
+ // `oversizedBackfillCandidateKeys`, so this list can never drift from it).
143
+ // Both must be excluded from completion-gating together.
144
+ const OVERSIZED_SCAN_ISSUE_REASONS = new Set([
145
+ "file_too_large",
146
+ "claude_main_file_too_large",
147
+ ]);
148
+ const blockingScanIssues = (issues) => issues.filter((issue) => !OVERSIZED_SCAN_ISSUE_REASONS.has(issue.reason));
132
149
  if (command.all && !command.yes) {
133
150
  if (!command.json) {
134
151
  writeDryRunSummary(io, {
@@ -186,11 +203,11 @@ export async function runBackfill(command, io) {
186
203
  scan,
187
204
  reasonCounts,
188
205
  }),
189
- status: scan.issues.length > 0 ? "partial" : "complete",
206
+ status: blockingScanIssues(scan.issues).length > 0 ? "partial" : "complete",
190
207
  dry_run: true,
191
208
  retry_command: retryCommand,
192
- ...(scan.issues.length > 0
193
- ? { failure_reason: scan.issues[0]?.reason }
209
+ ...(blockingScanIssues(scan.issues).length > 0
210
+ ? { failure_reason: blockingScanIssues(scan.issues)[0]?.reason }
194
211
  : {}),
195
212
  };
196
213
  }
@@ -276,7 +293,7 @@ export async function runBackfill(command, io) {
276
293
  let backfilledSessions = 0;
277
294
  const durableCandidateKeys = new Set();
278
295
  let blockedAt;
279
- let failureReason = scan.issues[0]?.reason;
296
+ let failureReason = blockingScanIssues(scan.issues)[0]?.reason;
280
297
  for (const [index, batch] of batches.entries()) {
281
298
  await lock.handle.heartbeat();
282
299
  const sync = await syncBackfillBatch({
@@ -300,7 +317,12 @@ export async function runBackfill(command, io) {
300
317
  for (const key of durableInBatch)
301
318
  durableCandidateKeys.add(key);
302
319
  backfilledSessions = durableCandidateKeys.size;
303
- const missingDurableMain = batch.candidates.length - durableInBatch.size;
320
+ // A candidate whose main is durably oversized-skipped (BLI-2727) is
321
+ // accounted for, not missing — it will never earn a durable pointer
322
+ // under the current cap, and treating it as "still missing" would fail
323
+ // every batch it happens to share with genuinely uploaded siblings.
324
+ const missingDurableMain = batch.candidates.filter((candidate) => !durableInBatch.has(candidateCursorKey(candidate)) &&
325
+ !oversizedCandidateKeys.has(candidateCursorKey(candidate))).length;
304
326
  const batchFailed = sync.status !== "uploaded" ||
305
327
  countSessionUploadFailures(sync) > 0 ||
306
328
  missingDurableMain > 0;
@@ -414,8 +436,11 @@ export async function runBackfill(command, io) {
414
436
  const unresolvedUploadableKeys = new Set(uploadable
415
437
  .filter((candidate) => !durableCandidateKeys.has(candidateCursorKey(candidate)))
416
438
  .map(candidateCursorKey));
417
- const unresolvedUploadable = unresolvedUploadableKeys.size;
418
- const unresolvedRetryable = scan.retryable_candidate_keys.size;
439
+ // "Blocking" views exclude a deterministic oversized skip (BLI-2727): it is
440
+ // still unresolved (it never gets a durable pointer), but it is a permanent,
441
+ // labeled fact rather than something completion should wait on forever.
442
+ const unresolvedUploadableBlocking = [...unresolvedUploadableKeys].filter((key) => !oversizedCandidateKeys.has(key)).length;
443
+ const unresolvedRetryableBlocking = [...scan.retryable_candidate_keys].filter((key) => !oversizedCandidateKeys.has(key)).length;
419
444
  const unresolvedKnownKeys = new Set([
420
445
  ...unresolvedUploadableKeys,
421
446
  ...scan.retryable_candidate_keys,
@@ -429,22 +454,27 @@ export async function runBackfill(command, io) {
429
454
  // chunks already accepted by the server are retried idempotently when
430
455
  // another required chunk lacks an acknowledgement.
431
456
  : Math.max(1, sessions.length);
457
+ // `remaining` is a reporting total, not a completion gate: it still counts
458
+ // every unresolved candidate, including oversized skips, so the JSON output
459
+ // never goes silent about them (BLI-2727).
432
460
  const remaining = unresolvedKnownKeys.size +
433
461
  scan.omitted_candidate_count +
434
462
  unseenGlobalFailures +
435
463
  reportRetryable;
436
- failed = Math.max(failed, unresolvedUploadable);
437
- const completionBlocked = scan.issues.length > 0 ||
438
- unresolvedUploadable > 0 ||
439
- unresolvedRetryable > 0 ||
464
+ failed = Math.max(failed, unresolvedUploadableBlocking);
465
+ const blockingIssues = blockingScanIssues(scan.issues);
466
+ const completionBlocked = blockingIssues.length > 0 ||
467
+ unresolvedUploadableBlocking > 0 ||
468
+ unresolvedRetryableBlocking > 0 ||
440
469
  deferred > 0 ||
441
470
  failed > 0 ||
442
471
  !reportAcknowledged;
443
472
  if (!failureReason && completionBlocked) {
444
- const retryableCandidateReason = scan.candidates.find((candidate) => scan.retryable_candidate_keys.has(candidateCursorKey(candidate)))?.reason;
473
+ const retryableCandidateReason = scan.candidates.find((candidate) => scan.retryable_candidate_keys.has(candidateCursorKey(candidate)) &&
474
+ !oversizedCandidateKeys.has(candidateCursorKey(candidate)))?.reason;
445
475
  failureReason =
446
- scan.issues[0]?.reason ??
447
- (unresolvedUploadable > 0
476
+ blockingIssues[0]?.reason ??
477
+ (unresolvedUploadableBlocking > 0
448
478
  ? "durable_session_pointer_missing"
449
479
  : retryableCandidateReason ?? "backfill_incomplete");
450
480
  }
@@ -455,6 +485,7 @@ export async function runBackfill(command, io) {
455
485
  if (status === "complete" && command.all) {
456
486
  recordBackfillScanCoverage(cursor, sources, now, now);
457
487
  await writeBackfillCursor(paths, cursor);
488
+ const oversizedCandidates = scan.candidates.filter((candidate) => oversizedCandidateKeys.has(candidateCursorKey(candidate)));
458
489
  await writeBackfillCompletionMarker(paths, {
459
490
  schema_version: "cockpit-backfill-complete.v2",
460
491
  coverage_version: BACKFILL_COVERAGE_VERSION,
@@ -463,6 +494,15 @@ export async function runBackfill(command, io) {
463
494
  completed_at: now.toISOString(),
464
495
  revalidate_after: new Date(now.getTime() + BACKFILL_COMPLETION_RECHECK_MS).toISOString(),
465
496
  cursor,
497
+ ...(oversizedCandidates.length > 0
498
+ ? {
499
+ oversized_skips: {
500
+ reason: "file_too_large",
501
+ count: oversizedCandidates.length,
502
+ byte_sizes: oversizedCandidates.map((candidate) => candidate.byte_size),
503
+ },
504
+ }
505
+ : {}),
466
506
  });
467
507
  }
468
508
  return {
@@ -889,6 +929,27 @@ function retryableCandidateKeys(candidates) {
889
929
  sidecar.skipped_reason === "file_too_large")))
890
930
  .map(candidateCursorKey));
891
931
  }
932
+ /**
933
+ * A main session file whose only story is "too large to upload under the
934
+ * current cap" (BLI-2727). This mirrors exactly the two branches in
935
+ * `countReadOnlyGuards` that emit the `file_too_large` reason, so a candidate
936
+ * is in this set if and only if it contributed to that scan issue's count —
937
+ * one predicate, no drift between "why the issue fired" and "which candidate
938
+ * caused it". Deterministic and non-retryable: rerunning backfill cannot
939
+ * resolve it (only a larger cap or a smaller file can), so unlike a transient
940
+ * read failure it must never poison completion or a batch's success.
941
+ */
942
+ function oversizedBackfillCandidateKeys(candidates) {
943
+ const keys = new Set();
944
+ for (const candidate of candidates) {
945
+ if ((candidate.source === "claude_code" &&
946
+ candidate.claude?.main_file_oversized) ||
947
+ candidate.byte_size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
948
+ keys.add(candidateCursorKey(candidate));
949
+ }
950
+ }
951
+ return keys;
952
+ }
892
953
  async function countUnreadableClaudeSidecarDirs(candidates) {
893
954
  let unreadable = 0;
894
955
  for (const candidate of candidates) {
@@ -243,13 +243,36 @@ async function fixAutostartState(context) {
243
243
  }
244
244
  return ok("autostart-alive", "installed", "autostart installed and loaded");
245
245
  }
246
+ /**
247
+ * Pure so it can be unit-tested without touching the real machine's home
248
+ * directory (`getCollectorRuntimePaths()` defaults to `os.homedir()` and
249
+ * doctor never threads `--home` through the backfill steps). Returns `null`
250
+ * when the marker does not cover the roots/sources — the caller falls
251
+ * through to the lock/never-run diagnosis in that case.
252
+ *
253
+ * BLI-2727: a marker whose only outstanding entries are deterministic
254
+ * oversized-file skips is still a completed backfill — it reads green with a
255
+ * named note, never a red `needs_fix`/`fail`, so an unliftable file cap never
256
+ * reads as "backfill never completed" on repeat doctor runs.
257
+ */
258
+ export function backfillCompletionStepState(marker, roots) {
259
+ if (!backfillCompletionCovers(marker, roots, ["codex", "claude_code"])) {
260
+ return null;
261
+ }
262
+ const oversized = marker?.oversized_skips;
263
+ if (oversized && oversized.count > 0) {
264
+ return ok("backfill-complete", "complete_with_oversized_skips", `backfill completion covers the current saved roots and both session sources ` +
265
+ `(complete_with_oversized_skips · ${oversized.count} file${oversized.count === 1 ? "" : "s"} over the upload cap)`);
266
+ }
267
+ return ok("backfill-complete", "complete", "backfill completion covers the current saved roots and both session sources");
268
+ }
246
269
  async function checkBackfillState(context) {
247
270
  const paths = getCollectorRuntimePaths();
248
271
  const roots = await doctorRoots(context);
249
272
  const marker = await readBackfillCompletionMarker(paths);
250
- if (backfillCompletionCovers(marker, roots, ["codex", "claude_code"])) {
251
- return ok("backfill-complete", "complete", "backfill completion covers the current saved roots and both session sources");
252
- }
273
+ const covered = backfillCompletionStepState(marker, roots);
274
+ if (covered)
275
+ return covered;
253
276
  const lock = await inspectBackfillLock(paths);
254
277
  if (lock.held) {
255
278
  return needsFix("backfill-complete", "backfill_already_running", `backfill completion is not yet proven; another run holds the lock since ${lock.held_since ?? "unknown"}`);
@@ -268,6 +291,13 @@ async function fixBackfillState(context) {
268
291
  }, capture.io);
269
292
  const output = capture.stdout() + "\n" + capture.stderr();
270
293
  if (code === 0) {
294
+ // Re-read the marker this run just wrote instead of hand-rolling a second
295
+ // message: `checkBackfillState`'s pure core already knows how to say
296
+ // "complete" vs "complete_with_oversized_skips" (BLI-2727), and this way
297
+ // the two can never say something different for the same marker.
298
+ const recheck = await checkBackfillState(context);
299
+ if (recheck.status === "ok")
300
+ return recheck;
271
301
  return ok("backfill-complete", "completed", "ran `cockpit backfill --all --yes`");
272
302
  }
273
303
  const reason = jsonField(output, "failure_reason");
@@ -308,6 +338,66 @@ async function checkSyncState(context) {
308
338
  // and only turns green from those command receipts.
309
339
  return needsFix("sync-fresh", "per_root_verification_required", `fresh upload proof is required for ${roots.length} saved root${roots.length === 1 ? "" : "s"}`);
310
340
  }
341
+ /**
342
+ * `cockpit sync --json` prints exactly one JSON document to stdout (stderr is
343
+ * for human text; see AGENTS.md logging conventions), so this is a real parse
344
+ * rather than the doctor module's usual regex field-scrape — which cannot
345
+ * disambiguate same-named fields nested under `codex_sessions.codex` vs
346
+ * `codex_sessions.claude` (BLI-2728).
347
+ */
348
+ function parseDoctorSyncJson(stdout) {
349
+ try {
350
+ const parsed = JSON.parse(stdout.trim());
351
+ return parsed && typeof parsed === "object" ? parsed : null;
352
+ }
353
+ catch {
354
+ return null;
355
+ }
356
+ }
357
+ /**
358
+ * BLI-2728: a tick that only deferred objects past the per-tick raw-evidence
359
+ * object budget (`RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET`, adapters/raw-evidence.ts)
360
+ * is a backlog that is draining, not a failure — `attributedSyncRunStatus`
361
+ * marks the run not-fully-`ok` (so `cockpit sync` exits non-zero and doctor's
362
+ * exec sees `code !== 0`) purely because objects remain queued, with the
363
+ * per-repo upload itself still having succeeded. A genuine failure (auth,
364
+ * server rejection, network, a real upload_failed outcome, an unposted
365
+ * session report) must still read red — this only fires when NOTHING else in
366
+ * the tick's own summary looks wrong. Pure so it is unit-testable without a
367
+ * live exec/fs harness; the remaining-object count is read straight from the
368
+ * tick's own summary, never recomputed.
369
+ */
370
+ export function syncBacklogDrainingVerdict(parsed) {
371
+ if (!parsed)
372
+ return null;
373
+ const deferredObjects = positiveNumberOrZero(parsed.raw_evidence_deferred_object_budget);
374
+ if (deferredObjects <= 0)
375
+ return null;
376
+ const deferredBytes = positiveNumberOrZero(parsed.raw_evidence_deferred_byte_budget);
377
+ const failedCount = positiveNumberOrZero(parsed.raw_evidence_failed_count);
378
+ const retryReasons = Array.isArray(parsed.raw_evidence_retry_reasons)
379
+ ? parsed.raw_evidence_retry_reasons.length
380
+ : 0;
381
+ const sessions = asRecord(parsed.codex_sessions);
382
+ const reportPosted = sessions?.["report_posted"];
383
+ const codexReadFailures = positiveNumberOrZero(asRecord(sessions?.["codex"])?.["read_failures"]);
384
+ const claudeSessions = asRecord(sessions?.["claude"]);
385
+ const claudeReadFailures = positiveNumberOrZero(claudeSessions?.["read_failures"]);
386
+ const claudeSidecarsFailed = positiveNumberOrZero(claudeSessions?.["sidecars_failed"]);
387
+ const onlyDeferredObjectBudget = deferredBytes === 0 &&
388
+ failedCount === 0 &&
389
+ retryReasons === 0 &&
390
+ reportPosted === true &&
391
+ codexReadFailures === 0 &&
392
+ claudeReadFailures === 0 &&
393
+ claudeSidecarsFailed === 0;
394
+ return onlyDeferredObjectBudget ? { remainingObjects: deferredObjects } : null;
395
+ }
396
+ function positiveNumberOrZero(value) {
397
+ return typeof value === "number" && Number.isFinite(value) && value > 0
398
+ ? value
399
+ : 0;
400
+ }
311
401
  async function fixSyncState(context) {
312
402
  const exec = context.io.exec;
313
403
  if (!exec)
@@ -326,9 +416,16 @@ async function fixSyncState(context) {
326
416
  args.push("--dashboard-url", context.command.dashboardUrl);
327
417
  }
328
418
  const result = await exec("cockpit", args);
419
+ const parsed = parseDoctorSyncJson(result.stdout);
329
420
  const output = `${result.stdout}\n${result.stderr}`;
330
421
  const status = jsonField(output, "status");
331
422
  if (result.code !== 0) {
423
+ const draining = syncBacklogDrainingVerdict(parsed);
424
+ if (draining) {
425
+ return needsFix("sync-fresh", "backlog_draining", `${repoRoot}: raw-evidence backlog is still draining (${draining.remainingObjects} ` +
426
+ `object${draining.remainingObjects === 1 ? "" : "s"} deferred this tick); ` +
427
+ "rerun `cockpit sync` to continue");
428
+ }
332
429
  return fail("sync-fresh", status ?? "sync_failed", `sync failed for ${repoRoot}`);
333
430
  }
334
431
  if (status !== "uploaded") {
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.25");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.26");
19
19
  return 0;
20
20
  }
21
21
 
@@ -239,8 +239,26 @@ function parseBackfillCompletionMarker(value) {
239
239
  completed_at: completedAt,
240
240
  revalidate_after: revalidateAfter,
241
241
  cursor: parseBackfillCursor(record["cursor"]),
242
+ ...(parseOversizedSkips(record["oversized_skips"])
243
+ ? { oversized_skips: parseOversizedSkips(record["oversized_skips"]) }
244
+ : {}),
242
245
  };
243
246
  }
247
+ function parseOversizedSkips(value) {
248
+ if (!value || typeof value !== "object")
249
+ return null;
250
+ const record = value;
251
+ if (record["reason"] !== "file_too_large")
252
+ return null;
253
+ const count = optionalNumber(record["count"]);
254
+ if (count === null || count <= 0)
255
+ return null;
256
+ const rawByteSizes = record["byte_sizes"];
257
+ const byteSizes = Array.isArray(rawByteSizes)
258
+ ? rawByteSizes.filter((entry) => typeof entry === "number" && Number.isFinite(entry) && entry >= 0)
259
+ : [];
260
+ return { reason: "file_too_large", count, byte_sizes: byteSizes };
261
+ }
244
262
  function normalizeScopeRoot(value) {
245
263
  const windowsStyle = path.win32.isAbsolute(value) && !path.posix.isAbsolute(value);
246
264
  if (windowsStyle)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.25",
3
+ "version": "0.2.26",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,6 @@
26
26
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
27
27
  },
28
28
  "dependencies": {
29
- "@bli-cockpit/telemetry-core": "0.1.19"
29
+ "@bli-cockpit/telemetry-core": "0.1.20"
30
30
  }
31
31
  }