@bli-cockpit/cli 0.2.27 → 0.2.28

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.
@@ -8,6 +8,7 @@ import { makeSourceAdapterIdentity, } from "./common.js";
8
8
  import { collectAgentImageEvidenceFromJsonlFile, } from "./agent-image-evidence.js";
9
9
  import { defaultCodexSessionDirs, } from "./codex-attribution.js";
10
10
  import { isLiveRawEvidenceSyncAttribution } from "../raw-evidence-attribution-policy.js";
11
+ import { contentKeyedRawEvidencePackId, DELIVERY_BACKOFF_HOLDING_REASON, evidenceSourceKey, heldSourceKeys, readRawEvidenceStagingState, recordStagedObject, resolveStagedObject, writeRawEvidenceStagingState, } from "../raw-evidence-staging.js";
11
12
  const DEFAULT_SINCE_MINUTES = 24 * 60;
12
13
  const DEFAULT_SESSION_LIMIT = 50;
13
14
  const MAX_GIT_DIFF_BYTES = 2 * 1024 * 1024;
@@ -23,9 +24,14 @@ export const RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET = 300;
23
24
  const CLAUDE_MAX_COLLECT_FILE_BYTES = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES;
24
25
  export async function collectRawEvidencePack(context, options) {
25
26
  const startedAt = context.now.toISOString();
26
- const packId = rawEvidencePackId(context);
27
- const evidenceDir = path.join(options.stateDir, "raw-evidence", packId);
28
- const filesDir = path.join(evidenceDir, "files");
27
+ const rawEvidenceRoot = path.join(options.stateDir, "raw-evidence");
28
+ // Staging first, promotion second (BLI-3066). The pack id cannot be known
29
+ // until the content is, so bytes land in a private staging directory and the
30
+ // directory is then renamed to its content-keyed name — or dropped, when an
31
+ // identical pack is already there.
32
+ const stagingDir = path.join(rawEvidenceRoot, `.staging-${process.pid}-${crypto.randomUUID().slice(0, 8)}`);
33
+ const filesDir = path.join(stagingDir, "files");
34
+ const staging = await readRawEvidenceStagingState(options.stateDir);
29
35
  const entries = [];
30
36
  const skipped = [];
31
37
  const truncated = [];
@@ -39,7 +45,13 @@ export async function collectRawEvidencePack(context, options) {
39
45
  const collection = {
40
46
  context,
41
47
  filesDir,
42
- packId,
48
+ rawEvidenceRoot,
49
+ packId: "",
50
+ staging,
51
+ heldSources: heldSourceKeys(staging, context.now),
52
+ stagedReusedCount: 0,
53
+ stagedNewCount: 0,
54
+ deliveryHeldCount: 0,
43
55
  entries,
44
56
  skipped,
45
57
  truncated,
@@ -101,7 +113,7 @@ export async function collectRawEvidencePack(context, options) {
101
113
  index: { value: 0 },
102
114
  };
103
115
  try {
104
- await ensurePrivateDir(evidenceDir);
116
+ await ensurePrivateDir(stagingDir);
105
117
  await ensurePrivateDir(filesDir);
106
118
  recordAttributionCompleteness(collection, {
107
119
  codex: options.codexAttributionScan,
@@ -125,6 +137,16 @@ export async function collectRawEvidencePack(context, options) {
125
137
  const deferredByteBudgetCount = skipped.filter((entry) => entry.reason === "deferred_byte_budget").reduce((sum, entry) => sum + evidenceEntryCount(entry), 0);
126
138
  const deferredObjectBudgetCount = skipped.filter((entry) => entry.reason === "deferred_object_budget").reduce((sum, entry) => sum + evidenceEntryCount(entry), 0);
127
139
  if (entries.length === 0) {
140
+ // Nothing collected: drop the staging directory instead of leaving an
141
+ // empty pack behind. This used to leave one empty `work-*` dir per sync.
142
+ await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
143
+ const packId = contentKeyedRawEvidencePackId({
144
+ workContextId: context.workContextId,
145
+ contentHashes: [],
146
+ });
147
+ collection.packId = packId;
148
+ const evidenceDir = path.join(rawEvidenceRoot, packId);
149
+ await persistStagingState(options.stateDir, collection);
128
150
  const evidenceCompleteness = makeEvidenceCompleteness(collection, {
129
151
  startedAt,
130
152
  finishedAt: context.now.toISOString(),
@@ -140,6 +162,10 @@ export async function collectRawEvidencePack(context, options) {
140
162
  skipped_count: countEvidenceEntries(skipped),
141
163
  sanitized_count: redacted.length,
142
164
  reused_count: reused.length,
165
+ staged_reused_count: collection.stagedReusedCount,
166
+ staged_new_count: collection.stagedNewCount,
167
+ delivery_held_count: collection.deliveryHeldCount,
168
+ stage_state: "empty",
143
169
  deferred_byte_budget_count: deferredByteBudgetCount,
144
170
  deferred_object_budget_count: deferredObjectBudgetCount,
145
171
  content_kinds: [],
@@ -158,18 +184,45 @@ export async function collectRawEvidencePack(context, options) {
158
184
  }),
159
185
  };
160
186
  }
161
- const manifestWithoutSelf = makeManifest({
187
+ // The pack is named by what is in it, never by when it was made. Identical
188
+ // content on the next sync resolves to the identical directory, which is
189
+ // the whole fix for BLI-3066.
190
+ const packId = contentKeyedRawEvidencePackId({
191
+ workContextId: context.workContextId,
192
+ contentHashes: entries.map((entry) => entry.content_hash_sha256),
193
+ });
194
+ collection.packId = packId;
195
+ const promotion = await promoteStagedPack({
196
+ rawEvidenceRoot,
197
+ stagingDir,
198
+ packId,
199
+ workContextId: context.workContextId,
200
+ entries,
201
+ });
202
+ const evidenceDir = promotion.evidenceDir;
203
+ for (const entry of entries) {
204
+ if (!entry.staged_in_pack)
205
+ continue;
206
+ entry.local_path = path.join(evidenceDir, "files", path.basename(entry.local_path));
207
+ recordStagedObject(collection.staging, entry.content_hash_sha256, {
208
+ pack_id: packId,
209
+ relative_path: `files/${path.basename(entry.local_path)}`,
210
+ byte_size: entry.byte_size,
211
+ source_key: entry.source_key,
212
+ staged_at: context.now.toISOString(),
213
+ });
214
+ }
215
+ const manifestPath = path.join(evidenceDir, "manifest.json");
216
+ const manifestBytes = await stageManifest({
162
217
  context,
163
218
  packId,
219
+ manifestPath,
164
220
  entries,
165
221
  skipped,
166
222
  redacted,
167
223
  reused,
224
+ reusePack: promotion.state === "reused",
168
225
  });
169
- const manifestPath = path.join(evidenceDir, "manifest.json");
170
- const manifestBytes = Buffer.from(`${JSON.stringify(manifestWithoutSelf, null, 2)}\n`, "utf8");
171
- await fs.writeFile(manifestPath, manifestBytes, { mode: 0o600 });
172
- await chmodPrivate(manifestPath, 0o600);
173
226
  const manifestEntry = evidenceEntry({
174
227
  context,
175
228
  kind: "manifest",
@@ -181,6 +234,25 @@ export async function collectRawEvidencePack(context, options) {
181
234
  bytes: manifestBytes,
182
235
  });
183
236
  entries.push(manifestEntry);
237
+ await persistStagingState(options.stateDir, collection);
238
+ console.error("[raw-evidence] pack staged", JSON.stringify({
239
+ pack_id: packId,
240
+ stage_state: promotion.state,
241
+ reason: promotion.state === "reused"
242
+ ? "staged_reused"
243
+ : promotion.state === "restaged_incomplete"
244
+ ? "restaged_incomplete"
245
+ : promotion.priorPackCount > 0
246
+ ? "restaged_content_changed"
247
+ : "staged_new",
248
+ prior_pack_count: promotion.priorPackCount,
249
+ refilled_file_count: promotion.refilledFileCount,
250
+ file_count: entries.length,
251
+ byte_size: entries.reduce((sum, entry) => sum + entry.byte_size, 0),
252
+ staged_new: collection.stagedNewCount,
253
+ staged_reused: collection.stagedReusedCount,
254
+ delivery_held: collection.deliveryHeldCount,
255
+ }));
184
256
  const evidenceCompleteness = makeEvidenceCompleteness(collection, {
185
257
  startedAt,
186
258
  finishedAt: context.now.toISOString(),
@@ -191,6 +263,10 @@ export async function collectRawEvidencePack(context, options) {
191
263
  manifest_path: manifestPath,
192
264
  evidence_dir: evidenceDir,
193
265
  storage_bucket: RAW_EVIDENCE_BUCKET,
266
+ staged_reused_count: collection.stagedReusedCount,
267
+ staged_new_count: collection.stagedNewCount,
268
+ delivery_held_count: collection.deliveryHeldCount,
269
+ stage_state: promotion.state,
194
270
  file_count: entries.length,
195
271
  byte_size: entries.reduce((sum, entry) => sum + entry.byte_size, 0),
196
272
  skipped_count: countEvidenceEntries(skipped),
@@ -222,11 +298,28 @@ export async function collectRawEvidencePack(context, options) {
222
298
  }),
223
299
  };
224
300
  }
225
- catch {
301
+ catch (error) {
226
302
  failed.push({
227
303
  kind: "raw_evidence",
228
304
  reason: "collection_failed",
229
305
  });
306
+ // Staging is per-attempt scratch: a crashed pass must not leave a partial
307
+ // directory behind to be counted, re-hashed or swept later.
308
+ await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
309
+ const packId = collection.packId ||
310
+ contentKeyedRawEvidencePackId({
311
+ workContextId: context.workContextId,
312
+ contentHashes: entries.map((entry) => entry.content_hash_sha256),
313
+ });
314
+ const evidenceDir = path.join(rawEvidenceRoot, packId);
315
+ console.error("[raw-evidence] pack collection failed", JSON.stringify({
316
+ pack_id: packId,
317
+ reason: "collection_failed",
318
+ detail: error instanceof Error ? error.name : typeof error,
319
+ collected_file_count: entries.length,
320
+ staged_new: collection.stagedNewCount,
321
+ staged_reused: collection.stagedReusedCount,
322
+ }));
230
323
  const evidenceCompleteness = makeEvidenceCompleteness(collection, {
231
324
  startedAt,
232
325
  finishedAt: context.now.toISOString(),
@@ -242,6 +335,10 @@ export async function collectRawEvidencePack(context, options) {
242
335
  skipped_count: countEvidenceEntries(skipped),
243
336
  sanitized_count: redacted.length,
244
337
  reused_count: reused.length,
338
+ staged_reused_count: collection.stagedReusedCount,
339
+ staged_new_count: collection.stagedNewCount,
340
+ delivery_held_count: collection.deliveryHeldCount,
341
+ stage_state: "empty",
245
342
  deferred_byte_budget_count: skipped
246
343
  .filter((entry) => entry.reason === "deferred_byte_budget")
247
344
  .reduce((sum, entry) => sum + evidenceEntryCount(entry), 0),
@@ -293,6 +390,10 @@ function makeRawEvidenceScan(options) {
293
390
  `bytes:${options.facts.byte_size}`,
294
391
  `skipped:${options.facts.skipped_count}`,
295
392
  `reused:${options.facts.reused_count}`,
393
+ `stage_state:${options.facts.stage_state}`,
394
+ `staged_new:${options.facts.staged_new_count}`,
395
+ `staged_reused:${options.facts.staged_reused_count}`,
396
+ `delivery_held:${options.facts.delivery_held_count}`,
296
397
  `completeness:${options.facts.evidence_completeness.status}`,
297
398
  `truncated:${options.facts.evidence_completeness.totals.truncated_count}`,
298
399
  `deferred:${options.facts.evidence_completeness.totals.deferred_count}`,
@@ -576,15 +677,26 @@ async function collectOneAgentImageFile(collection, options) {
576
677
  }
577
678
  collection.index.value += 1;
578
679
  const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-agent-image-${contentHash.slice(0, 16)}.${options.image.extension}`);
579
- const destination = path.join(collection.filesDir, path.basename(relativePath));
580
- await fs.writeFile(destination, raw, { mode: 0o600 });
581
- await chmodPrivate(destination, 0o600);
680
+ const imageSourceKey = evidenceSourceKey({
681
+ kind: options.kind,
682
+ sessionId: options.sessionId,
683
+ label: options.image.label,
684
+ });
685
+ const staged = await stageEvidenceBytes(collection, {
686
+ contentHash,
687
+ bytes: raw,
688
+ fileName: path.basename(relativePath),
689
+ kind: options.kind,
690
+ sourceKey: imageSourceKey,
691
+ });
582
692
  collection.entries.push(evidenceEntry({
583
693
  context: collection.context,
584
694
  kind: options.kind,
585
695
  packId: collection.packId,
586
- localPath: destination,
696
+ localPath: staged.local_path,
587
697
  relativePath,
698
+ stagedInPack: staged.staged_in_pack,
699
+ sourceKey: imageSourceKey,
588
700
  mediaType: metadata.media_type,
589
701
  redactedSummary: "Raw image explicitly attached to an agent session, preserved in private durable storage.",
590
702
  bytes: raw,
@@ -606,6 +718,29 @@ async function collectOneEvidenceFile(collection, options) {
606
718
  ? "[REDACTED_FILE_NAME]"
607
719
  : fileName;
608
720
  const packedFileName = secretLikeFileName ? "redacted-file.jsonl" : fileName;
721
+ const sourceKey = evidenceSourceKey({
722
+ kind: options.kind,
723
+ sessionId: options.sessionId,
724
+ sourcePath: options.filePath,
725
+ });
726
+ // Delivery backoff is checked BEFORE the file is read. An object whose commit
727
+ // has failed repeatedly costs nothing at all this cycle — no read, no hash,
728
+ // no copy, no request — and the hold is a named, retryable gap so a held
729
+ // session cannot make the sync look clean (BLI-3066).
730
+ if (collection.heldSources.has(sourceKey)) {
731
+ collection.deliveryHeldCount += 1;
732
+ collection.skipped.push({
733
+ kind: options.kind,
734
+ label: evidenceLabel,
735
+ reason: DELIVERY_BACKOFF_HOLDING_REASON,
736
+ });
737
+ console.error("[raw-evidence] delivery backoff holding source", JSON.stringify({
738
+ reason: DELIVERY_BACKOFF_HOLDING_REASON,
739
+ kind: options.kind,
740
+ source_key: sourceKey,
741
+ }));
742
+ return false;
743
+ }
609
744
  if (options.maxFileBytes) {
610
745
  let stat;
611
746
  try {
@@ -694,14 +829,18 @@ async function collectOneEvidenceFile(collection, options) {
694
829
  }
695
830
  collection.index.value += 1;
696
831
  const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-${shortHash(options.filePath)}-${packedFileName}`);
697
- const destination = path.join(collection.filesDir, path.basename(relativePath));
698
- await fs.writeFile(destination, evidenceBytes, { mode: 0o600 });
699
- await chmodPrivate(destination, 0o600);
832
+ const staged = await stageEvidenceBytes(collection, {
833
+ contentHash,
834
+ bytes: evidenceBytes,
835
+ fileName: path.basename(relativePath),
836
+ kind: options.kind,
837
+ sourceKey,
838
+ });
700
839
  collection.entries.push(evidenceEntry({
701
840
  context: collection.context,
702
841
  kind: options.kind,
703
842
  packId: collection.packId,
704
- localPath: destination,
843
+ localPath: staged.local_path,
705
844
  relativePath,
706
845
  mediaType: options.mediaType,
707
846
  redactedSummary: redaction
@@ -711,9 +850,38 @@ async function collectOneEvidenceFile(collection, options) {
711
850
  bytes: evidenceBytes,
712
851
  codexSessionId: options.sessionId,
713
852
  contentAddress: options.contentAddress(contentHash.slice(0, 16)),
853
+ stagedInPack: staged.staged_in_pack,
854
+ sourceKey,
714
855
  }));
715
856
  return true;
716
857
  }
858
+ /**
859
+ * Put these bytes on disk once.
860
+ *
861
+ * If an earlier sync already staged this exact content and the copy is still
862
+ * there, that copy is used — the uploader only needs a readable path, and it
863
+ * does not care which pack directory holds it. This is the branch that stops
864
+ * one 334 MB rollout from becoming 559 copies while its commit keeps failing.
865
+ */
866
+ async function stageEvidenceBytes(collection, options) {
867
+ const existing = await resolveStagedObject(collection.rawEvidenceRoot, collection.staging, options.contentHash);
868
+ if (existing) {
869
+ collection.stagedReusedCount += 1;
870
+ console.error("[raw-evidence] staged copy reused", JSON.stringify({
871
+ reason: "staged_reused",
872
+ kind: options.kind,
873
+ content_hash_prefix: options.contentHash.slice(0, 16),
874
+ byte_size: existing.entry.byte_size,
875
+ pack_id: existing.entry.pack_id,
876
+ }));
877
+ return { local_path: existing.local_path, staged_in_pack: false };
878
+ }
879
+ const destination = path.join(collection.filesDir, options.fileName);
880
+ await fs.writeFile(destination, options.bytes, { mode: 0o600 });
881
+ await chmodPrivate(destination, 0o600);
882
+ collection.stagedNewCount += 1;
883
+ return { local_path: destination, staged_in_pack: true };
884
+ }
717
885
  /**
718
886
  * Decrements the per-sync budget when a file fits, or returns a deferred-skip
719
887
  * reason when it does not. The object budget bounds request count; the byte
@@ -934,15 +1102,26 @@ async function collectGitDiffFiles(collection, repoRoot) {
934
1102
  continue;
935
1103
  }
936
1104
  const relativePath = path.join("files", `git-${target.label}.diff`);
937
- const destination = path.join(collection.filesDir, path.basename(relativePath));
938
- await fs.writeFile(destination, raw, { mode: 0o600 });
939
- await chmodPrivate(destination, 0o600);
1105
+ const diffSourceKey = evidenceSourceKey({
1106
+ kind: "git_diff",
1107
+ sessionId: collection.context.workContextId,
1108
+ label: target.label,
1109
+ });
1110
+ const staged = await stageEvidenceBytes(collection, {
1111
+ contentHash,
1112
+ bytes: raw,
1113
+ fileName: path.basename(relativePath),
1114
+ kind: "git_diff",
1115
+ sourceKey: diffSourceKey,
1116
+ });
940
1117
  collection.entries.push(evidenceEntry({
941
1118
  context: collection.context,
942
1119
  kind: "git_diff",
943
1120
  packId: collection.packId,
944
- localPath: destination,
1121
+ localPath: staged.local_path,
945
1122
  relativePath,
1123
+ stagedInPack: staged.staged_in_pack,
1124
+ sourceKey: diffSourceKey,
946
1125
  mediaType: "text/x-diff",
947
1126
  redactedSummary: redaction
948
1127
  ? `Raw git ${target.label} diff preserved locally with env/secret paths excluded and secret-like values deterministically redacted.`
@@ -1309,6 +1488,8 @@ function evidenceEntry(options) {
1309
1488
  redacted_summary: options.redactedSummary,
1310
1489
  ...(options.redaction ? { redaction: options.redaction } : {}),
1311
1490
  codex_session_id: options.codexSessionId ?? null,
1491
+ staged_in_pack: options.stagedInPack !== false,
1492
+ source_key: options.sourceKey ?? null,
1312
1493
  ...(options.artifactMetadata
1313
1494
  ? {
1314
1495
  artifact_metadata: {
@@ -1323,6 +1504,10 @@ function evidenceEntry(options) {
1323
1504
  : {}),
1324
1505
  };
1325
1506
  }
1507
+ // `local_path` is redacted out, and so are the staging bookkeeping fields:
1508
+ // where a copy happens to live on this disk is not part of the pack's identity,
1509
+ // and putting it in the manifest would make byte-identical content produce
1510
+ // different manifests.
1326
1511
  function redactManifestEntry(entry) {
1327
1512
  return {
1328
1513
  kind: entry.kind,
@@ -1417,13 +1602,125 @@ function readableKeySegment(value, fallback, options = {}) {
1417
1602
  function posixPath(parts) {
1418
1603
  return parts.join("/").replace(/\\/g, "/").replace(/\/+/g, "/");
1419
1604
  }
1420
- function rawEvidencePackId(context) {
1421
- const material = [
1422
- context.workContextId,
1423
- context.sessionId,
1424
- context.now.toISOString(),
1425
- ].join(":");
1426
- return `${context.workContextId}-${shortHash(material)}`;
1605
+ /**
1606
+ * Turn the staging directory into the content-keyed pack directory.
1607
+ *
1608
+ * Three outcomes, all of them named:
1609
+ * - the pack does not exist yet: rename staging into place (`new`);
1610
+ * - it exists and holds every file this pass staged: adopt it and delete the
1611
+ * staging copy (`reused`) — nothing is copied twice;
1612
+ * - it exists but is missing files: fill the gaps from staging
1613
+ * (`restaged_incomplete`), because a half-written pack must not be trusted.
1614
+ *
1615
+ * Never deletes an existing pack directory wholesale: another pack's entries
1616
+ * can point into it through the staged-object index.
1617
+ */
1618
+ async function promoteStagedPack(options) {
1619
+ const evidenceDir = path.join(options.rawEvidenceRoot, options.packId);
1620
+ const priorPackCount = await countPriorPacks(options.rawEvidenceRoot, options.workContextId, options.packId);
1621
+ const existing = await fs.stat(evidenceDir).catch(() => null);
1622
+ if (!existing?.isDirectory()) {
1623
+ try {
1624
+ await fs.rename(options.stagingDir, evidenceDir);
1625
+ return {
1626
+ state: "new",
1627
+ evidenceDir,
1628
+ priorPackCount,
1629
+ refilledFileCount: 0,
1630
+ };
1631
+ }
1632
+ catch {
1633
+ // A concurrent sync can win the race to the same content-keyed name.
1634
+ // Losing it is fine: the winner staged the identical bytes.
1635
+ }
1636
+ }
1637
+ let refilledFileCount = 0;
1638
+ await ensurePrivateDir(path.join(evidenceDir, "files"));
1639
+ for (const entry of options.entries) {
1640
+ if (!entry.staged_in_pack)
1641
+ continue;
1642
+ const fileName = path.basename(entry.local_path);
1643
+ const target = path.join(evidenceDir, "files", fileName);
1644
+ const info = await fs.stat(target).catch(() => null);
1645
+ if (info?.isFile() && info.size === entry.byte_size)
1646
+ continue;
1647
+ await fs.copyFile(entry.local_path, target).catch(() => undefined);
1648
+ await chmodPrivate(target, 0o600);
1649
+ refilledFileCount += 1;
1650
+ }
1651
+ await fs.rm(options.stagingDir, { recursive: true, force: true }).catch(() => undefined);
1652
+ return {
1653
+ state: refilledFileCount > 0 ? "restaged_incomplete" : "reused",
1654
+ evidenceDir,
1655
+ priorPackCount,
1656
+ refilledFileCount,
1657
+ };
1658
+ }
1659
+ async function countPriorPacks(rawEvidenceRoot, workContextId, packId) {
1660
+ const entries = await fs
1661
+ .readdir(rawEvidenceRoot, { withFileTypes: true })
1662
+ .catch(() => []);
1663
+ return entries.filter((entry) => entry.isDirectory() &&
1664
+ entry.name !== packId &&
1665
+ entry.name.startsWith(`${workContextId}-`)).length;
1666
+ }
1667
+ /**
1668
+ * Write the manifest, or keep the one already in a reused pack.
1669
+ *
1670
+ * Byte-stability matters here: the manifest's object key is the only key that
1671
+ * embeds the pack id, so rewriting it with a fresh `created_at` every sync
1672
+ * would push different bytes at the same content-addressed key forever. A
1673
+ * reused pack whose manifest already describes exactly these files keeps it.
1674
+ */
1675
+ async function stageManifest(options) {
1676
+ if (options.reusePack) {
1677
+ const existing = await fs.readFile(options.manifestPath).catch(() => null);
1678
+ if (existing && manifestDescribesEntries(existing, options.entries)) {
1679
+ return existing;
1680
+ }
1681
+ }
1682
+ const manifestBytes = Buffer.from(`${JSON.stringify(makeManifest({
1683
+ context: options.context,
1684
+ packId: options.packId,
1685
+ entries: options.entries,
1686
+ skipped: options.skipped,
1687
+ redacted: options.redacted,
1688
+ reused: options.reused,
1689
+ }), null, 2)}\n`, "utf8");
1690
+ await fs.writeFile(options.manifestPath, manifestBytes, { mode: 0o600 });
1691
+ await chmodPrivate(options.manifestPath, 0o600);
1692
+ return manifestBytes;
1693
+ }
1694
+ function manifestDescribesEntries(manifestBytes, entries) {
1695
+ try {
1696
+ const parsed = JSON.parse(manifestBytes.toString("utf8"));
1697
+ const recorded = (parsed.files ?? [])
1698
+ .map((file) => typeof file.content_hash_sha256 === "string"
1699
+ ? file.content_hash_sha256
1700
+ : "")
1701
+ .filter(Boolean)
1702
+ .sort();
1703
+ const expected = entries
1704
+ .map((entry) => entry.content_hash_sha256)
1705
+ .sort();
1706
+ return (recorded.length === expected.length &&
1707
+ recorded.every((hash, index) => hash === expected[index]));
1708
+ }
1709
+ catch {
1710
+ return false;
1711
+ }
1712
+ }
1713
+ async function persistStagingState(stateDir, collection) {
1714
+ collection.staging.updated_at = collection.context.now.toISOString();
1715
+ await writeRawEvidenceStagingState(stateDir, collection.staging).catch((error) => {
1716
+ // Losing this file costs reuse and attempt counts, not evidence, so it
1717
+ // must never fail a collection — but it must never be silent either.
1718
+ console.error("[raw-evidence] staging state write failed", JSON.stringify({
1719
+ reason: "staging_state_write_failed",
1720
+ detail: error instanceof Error ? error.name : typeof error,
1721
+ staged_count: Object.keys(collection.staging.staged).length,
1722
+ }));
1723
+ });
1427
1724
  }
1428
1725
  function shortHash(value) {
1429
1726
  return crypto.createHash("sha256").update(value, "utf8").digest("hex").slice(0, 12);
@@ -20,7 +20,7 @@ import { collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } fro
20
20
  import { resolveDiscoveryLimits, saveDiscoveryLimits, } from "../discovery-limits.js";
21
21
  import { runAttributedWorktreeSync, matchesLiveSyncWorktree, } from "./session-sync.js";
22
22
  import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, resolveOnboardingRoots, rootRejectionExplanation, } from "../onboarding-roots.js";
23
- import { rawEvidenceGcSummary, runRawEvidenceLocalGc, } from "../raw-evidence-gc.js";
23
+ import { rawEvidenceDedupSummary, rawEvidenceGcSummary, runRawEvidenceLocalGc, sweepDuplicateStagedRawEvidence, } from "../raw-evidence-gc.js";
24
24
  import { envWithNodeRuntimeOnPath, runScheduledSelfUpdate, } from "../scheduled-self-update.js";
25
25
  import { runAutostartSelfHeal, } from "../autostart-self-heal.js";
26
26
  import { enqueueInstallEventEntry, readPendingInstallEventEntries, recordInstallEventAttemptFailure, removeInstallEventEntry, } from "../spool/install-event-outbox.js";
@@ -1808,7 +1808,16 @@ function rawEvidenceSyncLine(sync) {
1808
1808
  const retries = sync.raw_evidence_retry_reasons.length > 0
1809
1809
  ? ` retry_required: ${sync.raw_evidence_retry_reasons.join(",")}`
1810
1810
  : "";
1811
- return `Raw evidence: uploaded ${sync.raw_evidence_uploaded_object_count} object(s) in ${sync.raw_evidence_uploaded_chunk_count} chunk(s), reused ${sync.raw_evidence_reused_count}, failed ${sync.raw_evidence_failed_count}${failures}${retries}`;
1811
+ // A held object and a nine-day-old first failure both belong on this line.
1812
+ // Neither used to appear anywhere, which is how a 1,030-attempt loop stayed
1813
+ // invisible (BLI-3066).
1814
+ const held = sync.raw_evidence_delivery_held_count > 0
1815
+ ? ` held: ${sync.raw_evidence_delivery_held_count}`
1816
+ : "";
1817
+ const stuck = sync.raw_evidence_stuck_object_count > 0
1818
+ ? ` stuck: ${sync.raw_evidence_stuck_object_count} (worst ${sync.raw_evidence_max_delivery_attempts} attempt(s) since ${sync.raw_evidence_oldest_delivery_failure_at ?? "unknown"})`
1819
+ : "";
1820
+ return `Raw evidence: uploaded ${sync.raw_evidence_uploaded_object_count} object(s) in ${sync.raw_evidence_uploaded_chunk_count} chunk(s), reused ${sync.raw_evidence_reused_count}, failed ${sync.raw_evidence_failed_count}${held}${stuck}${failures}${retries}`;
1812
1821
  }
1813
1822
  function cursorStatusLine(sync) {
1814
1823
  return `Cursor: ${sync.cursor_tracked_object_count} durable object(s) tracked`;
@@ -2488,6 +2497,15 @@ function syncResult(run) {
2488
2497
  }
2489
2498
  async function runSyncLocked(command, io) {
2490
2499
  const collectionRoots = await resolveSyncCollectionRoots(command);
2500
+ // Before anything is collected: collapse byte-identical staged packs. It runs
2501
+ // first, unconditionally and unthrottled, because a machine that already
2502
+ // holds 559 copies of one rollout needs the disk back before it stages
2503
+ // anything else (BLI-3066). Safe by construction — a duplicate is identical
2504
+ // by content hash to the survivor.
2505
+ const dedup = await sweepDuplicateStagedRawEvidence(getCollectorRuntimePaths(command.homeDir), io.env);
2506
+ if (!dedup.skipped && dedup.removed_dirs > 0) {
2507
+ writeLine(io.stdout, rawEvidenceDedupSummary(dedup));
2508
+ }
2491
2509
  const worktrees = await discoverCommandWorktrees(collectionRoots, {
2492
2510
  maxDepth: command.maxDepth,
2493
2511
  maxRepos: command.maxRepos,
@@ -2515,6 +2533,7 @@ async function runSyncLocked(command, io) {
2515
2533
  repos: rows,
2516
2534
  codex_sessions: run.summary,
2517
2535
  raw_evidence_gc: gc,
2536
+ raw_evidence_dedup: dedup,
2518
2537
  }, null, 2));
2519
2538
  return syncResult(run);
2520
2539
  }
@@ -2546,6 +2565,7 @@ async function runSyncLocked(command, io) {
2546
2565
  collection_complete: run.ok,
2547
2566
  codex_sessions: run.summary,
2548
2567
  raw_evidence_gc: gc,
2568
+ raw_evidence_dedup: dedup,
2549
2569
  }, null, 2));
2550
2570
  return syncResult(run);
2551
2571
  }
@@ -2568,6 +2588,7 @@ async function runSyncLocked(command, io) {
2568
2588
  collection_complete: run.ok,
2569
2589
  codex_sessions: run.summary,
2570
2590
  raw_evidence_gc: gc,
2591
+ raw_evidence_dedup: dedup,
2571
2592
  }, null, 2));
2572
2593
  return syncResult(run);
2573
2594
  }
@@ -2801,10 +2822,20 @@ async function runStatus(command, io) {
2801
2822
  writeLine(io.stdout, `pending_health_receipts: ${status.pending_health_receipt_count}`);
2802
2823
  writeLine(io.stdout, `last_health_receipt_failure: ${status.last_health_receipt_failure_reason ?? "none"}`);
2803
2824
  writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
2825
+ writeLine(io.stdout, `stuck_evidence: ${stuckEvidenceLine(status)}`);
2804
2826
  for (const detail of status.details)
2805
2827
  writeLine(io.stdout, `- ${detail}`);
2806
2828
  return 0;
2807
2829
  }
2830
+ /**
2831
+ * One line that cannot say "fine" while an object has never been accepted.
2832
+ * Named reasons, worst attempt count, and the date it started.
2833
+ */
2834
+ function stuckEvidenceLine(status) {
2835
+ if (status.stuck_evidence_object_count === 0)
2836
+ return "none";
2837
+ return `${status.stuck_evidence_object_count} object(s), ${status.stuck_evidence_held_count} held, worst ${status.stuck_evidence_max_attempts} attempt(s) since ${status.stuck_evidence_oldest_failure_at ?? "unknown"} (${status.stuck_evidence_reasons.join(",") || "unknown"})`;
2838
+ }
2808
2839
  function displayTicketId(ticketId) {
2809
2840
  return ticketId ?? "general ambient";
2810
2841
  }
@@ -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.27");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.28");
19
19
  return 0;
20
20
  }
21
21
 
@@ -8,6 +8,7 @@ import { normalizeGitOrigin, repoFingerprintFromLocalRoot, repoFingerprintFromOr
8
8
  import { isSamePath, normalizeCollectionRoots, } from "./root-normalization.js";
9
9
  import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
10
10
  import { summarizeInstallEventOutbox } from "./spool/install-event-outbox.js";
11
+ import { readRawEvidenceStagingState, summarizeStuckEvidence, } from "./raw-evidence-staging.js";
11
12
  const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
12
13
  export const LOCAL_COLLECTOR_VERSION = typeof localCollectorPackage.version === "string"
13
14
  ? localCollectorPackage.version
@@ -340,10 +341,12 @@ export async function inspectLocalCollectorStatus(options = {}) {
340
341
  const identity = await resolveIdentityOrFallback(repoRoot, options.branch);
341
342
  const context = await readLocalWorkContextForRepo(paths, repoRoot).catch(() => null);
342
343
  const branch = options.branch ?? identity.branch;
343
- const [uploadSpool, healthOutbox] = await Promise.all([
344
+ const [uploadSpool, healthOutbox, stagingState] = await Promise.all([
344
345
  summarizeLocalUploadSpool(paths),
345
346
  summarizeInstallEventOutbox(paths),
347
+ readRawEvidenceStagingState(paths.state_dir),
346
348
  ]);
349
+ const stuckEvidence = summarizeStuckEvidence(stagingState, now);
347
350
  const freshness = classifyCollectorFreshness(context, uploadSpool.last_upload_success_at, now);
348
351
  const uploadState = !config
349
352
  ? "not_installed"
@@ -377,6 +380,9 @@ export async function inspectLocalCollectorStatus(options = {}) {
377
380
  if (healthOutbox.pending_count > 0) {
378
381
  details.push(`Collector health retry pending: ${healthOutbox.pending_count} sanitized receipt(s) queued since ${healthOutbox.oldest_created_at ?? "unknown"}.`);
379
382
  }
383
+ if (stuckEvidence.stuck_object_count > 0) {
384
+ details.push(`Raw evidence stuck: ${stuckEvidence.stuck_object_count} object(s) have never been accepted (${stuckEvidence.held_object_count} waiting on backoff, worst ${stuckEvidence.max_attempts} attempt(s) since ${stuckEvidence.oldest_first_failed_at ?? "unknown"}) — reasons: ${stuckEvidence.reasons.join(", ") || "unknown"}.`);
385
+ }
380
386
  return {
381
387
  installed: Boolean(config),
382
388
  config_file: paths.config_file,
@@ -405,6 +411,11 @@ export async function inspectLocalCollectorStatus(options = {}) {
405
411
  pending_health_receipt_count: healthOutbox.pending_count,
406
412
  oldest_pending_health_receipt_at: healthOutbox.oldest_created_at,
407
413
  last_health_receipt_failure_reason: healthOutbox.last_failure_reason,
414
+ stuck_evidence_object_count: stuckEvidence.stuck_object_count,
415
+ stuck_evidence_held_count: stuckEvidence.held_object_count,
416
+ stuck_evidence_max_attempts: stuckEvidence.max_attempts,
417
+ stuck_evidence_oldest_failure_at: stuckEvidence.oldest_first_failed_at,
418
+ stuck_evidence_reasons: stuckEvidence.reasons,
408
419
  details,
409
420
  };
410
421
  }