@bli-cockpit/cli 0.2.25 → 0.2.27

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;
package/dist/autostart.js CHANGED
@@ -226,6 +226,22 @@ async function installWindowsTask(options) {
226
226
  "-File",
227
227
  registrationPath,
228
228
  ]);
229
+ // BLI-2996: before this, a nonzero exit here (schtasks rejected the
230
+ // registration script, or Set-ScheduledTask threw) skipped straight to the
231
+ // return with `verified: null` and never logged anything — the doctor row
232
+ // said "needs repair" every run with no way to tell "the rewrite itself
233
+ // never ran" apart from "it ran and Windows still disagrees" (BLI-2996,
234
+ // Brandon's machine: same silent non-convergence under two different
235
+ // validator messages across CLI versions). Metadata only: exit code and
236
+ // whether the process produced any output, never the stderr/stdout text
237
+ // (it can carry the state directory or a workspace path).
238
+ if (created.code !== 0) {
239
+ console.error("[autostart] windows task repair rewrite failed", JSON.stringify({
240
+ task_name: WINDOWS_AUTOSTART_TASK_NAME,
241
+ exit_code: created.code,
242
+ had_output: Boolean(created.stderr.trim() || created.stdout.trim()),
243
+ }));
244
+ }
229
245
  const verified = created.code === 0
230
246
  ? await windowsTaskStatus({
231
247
  ...options,
@@ -238,6 +254,14 @@ async function installWindowsTask(options) {
238
254
  })
239
255
  : null;
240
256
  const loaded = verified?.status === "loaded";
257
+ // windowsTaskStatus() above already logs the needs-repair case (with the
258
+ // problem list) when the rewrite ran but the read-back still disagrees; log
259
+ // the converged branch here too, tagged as a repair outcome and by name, so
260
+ // "did the rewrite actually fix it this run" never depends on inferring it
261
+ // from a routine status log emitted for an unrelated reason.
262
+ if (created.code === 0 && loaded) {
263
+ console.error("[autostart] windows task repair converged", JSON.stringify({ task_name: WINDOWS_AUTOSTART_TASK_NAME }));
264
+ }
241
265
  return {
242
266
  status: "installed",
243
267
  label: AUTOSTART_LABEL,
@@ -549,10 +573,17 @@ function windowsTaskRegistrationProblems(taskXml, expected) {
549
573
  .replace(/<Exec(?:\s[^>]*)?>[\s\S]*?<\/Exec>/giu, "")
550
574
  .trim();
551
575
  const exactExecBlock = execBlocks.length === 1 && actionRemainder === "" ? execBlocks[0] : "";
552
- const command = exactExecBlock
576
+ // Strip a wrapping quote pair defensively: every real capture we have shows
577
+ // Task Scheduler storing <Command> unquoted, but the writer builds the /TR
578
+ // executable segment with the same \"-escaped quoting it uses for the
579
+ // launcher argument (BLI-2598), and there is no verified capture proving
580
+ // schtasks' own TR-splitting heuristic always discards those quotes rather
581
+ // than leaving them as literal text (BLI-2996 canary-gated gap). A stray
582
+ // pair of quotes should never be the reason a correctly-registered task
583
+ // reads as broken.
584
+ const command = stripSurroundingQuotes(exactExecBlock
553
585
  ?.match(/<Command>\s*([^<]*?)\s*<\/Command>/iu)?.[1]
554
- ?.trim() ??
555
- "";
586
+ ?.trim() ?? "");
556
587
  const actionArguments = exactExecBlock
557
588
  .match(/<Arguments>\s*([^<]*?)\s*<\/Arguments>/iu)?.[1]
558
589
  ?.trim() ?? "";
@@ -654,16 +685,35 @@ function windowsWScriptPath(env = process.env) {
654
685
  * DESKTOP-G2UO1GK (CLI 0.2.13, 2026-08-12), where the stored value differed
655
686
  * from the expected one only by those quotes (BLI-2541).
656
687
  *
657
- * Compare the flags exactly, because those are ours, and compare the launcher
658
- * as a path, because that is what Windows normalizes. Each failure returns its
659
- * own reason so a repair message says which half is wrong rather than
660
- * "arguments differ".
688
+ * BLI-2996: that lesson was only ever applied to the pre-BLI-2677 `-File`
689
+ * action. The wscript launcher pair (`//B "<launcher>"`) has NO verified
690
+ * real-Windows capture `windowlessWindowsTaskXml()` in autostart.test.ts is
691
+ * SYNTHETIC, built by hand-editing the one real capture we have rather than
692
+ * exported from a machine actually running this action. A repair that rewrites
693
+ * the task correctly but is judged against a guessed shape can fail forever
694
+ * without ever being wrong (Brandon's machine: same non-convergence under two
695
+ * different validator messages across two CLI versions, neither of which ever
696
+ * repaired anything). So this comparison is deliberately structural, not
697
+ * positional: tokenize the way CommandLineToArgvW groups quoted/unquoted
698
+ * arguments, then check flag-token-then-path-token semantically (a real path
699
+ * comparison, case- and quote-insensitive) instead of demanding the exact
700
+ * byte layout we happened to send. Whatever the wscript action's real
701
+ * normalization turns out to be once a canary captures it, this only fails on
702
+ * an actual different flag or different script — not on Windows' own
703
+ * reformatting.
661
704
  */
662
705
  function windowsActionArgumentProblem(actionArguments, expectedFlags, expectedLauncherPath) {
663
- if (!actionArguments.startsWith(expectedFlags)) {
706
+ const tokens = splitWindowsArgumentTokens(actionArguments);
707
+ if (tokens.length === 0) {
708
+ return "task action names no sync launcher to run";
709
+ }
710
+ if ((tokens[0] ?? "").toUpperCase() !== expectedFlags.toUpperCase()) {
664
711
  return "task action does not run the launcher with the expected batch-mode flag";
665
712
  }
666
- const launcherArgument = unquoteWindowsArgument(actionArguments.slice(expectedFlags.length));
713
+ if (tokens.length > 2) {
714
+ return "task action passes unexpected extra arguments to the sync launcher";
715
+ }
716
+ const launcherArgument = tokens[1];
667
717
  if (!launcherArgument) {
668
718
  return "task action names no sync launcher to run";
669
719
  }
@@ -672,10 +722,46 @@ function windowsActionArgumentProblem(actionArguments, expectedFlags, expectedLa
672
722
  }
673
723
  return null;
674
724
  }
675
- function unquoteWindowsArgument(value) {
676
- const trimmed = value.trim();
677
- const quoted = trimmed.match(/^"([\s\S]*)"$/u) ?? trimmed.match(/^'([\s\S]*)'$/u);
678
- return (quoted?.[1] ?? trimmed).trim();
725
+ /**
726
+ * Quote-aware whitespace tokenizer for an already-XML-decoded `<Arguments>`
727
+ * value: a `"..."` run is one token (quotes stripped, whitespace inside kept),
728
+ * everything else splits on whitespace. Windows paths never contain a quote
729
+ * character, so this does not need CommandLineToArgvW's backslash-escaping
730
+ * rules — only its quoting rule — to tell "one argument with a space in it"
731
+ * apart from "two arguments".
732
+ */
733
+ function splitWindowsArgumentTokens(value) {
734
+ const tokens = [];
735
+ let current = "";
736
+ let inQuotes = false;
737
+ let hasToken = false;
738
+ for (const char of value) {
739
+ if (char === '"') {
740
+ inQuotes = !inQuotes;
741
+ hasToken = true;
742
+ continue;
743
+ }
744
+ if (!inQuotes && /\s/u.test(char)) {
745
+ if (hasToken) {
746
+ tokens.push(current);
747
+ current = "";
748
+ hasToken = false;
749
+ }
750
+ continue;
751
+ }
752
+ current += char;
753
+ hasToken = true;
754
+ }
755
+ if (hasToken)
756
+ tokens.push(current);
757
+ return tokens;
758
+ }
759
+ /** Removes one wrapping pair of double quotes, if present. Used only to
760
+ * normalize before a path comparison — never to reconstruct a shell-safe
761
+ * value. */
762
+ function stripSurroundingQuotes(value) {
763
+ const match = value.match(/^"([\s\S]*)"$/u);
764
+ return match ? match[1] : value;
679
765
  }
680
766
  function sameWindowsPath(left, right) {
681
767
  if (!left || !right)
@@ -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.27");
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.27",
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
  }