@bli-cockpit/cli 0.2.26 → 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.
package/dist/upload.js CHANGED
@@ -5,6 +5,7 @@ import { runLocalSourceCollectors } from "./adapters/local-sources.js";
5
5
  import { defaultCodexSessionDirs, } from "./adapters/codex-attribution.js";
6
6
  import { uploadRawEvidenceFilesChunked, } from "./evidence-upload-client.js";
7
7
  import { markObjectCommitted, readRawEvidenceCursor, writeRawEvidenceCursor, } from "./cursors/raw-evidence-cursor.js";
8
+ import { clearDeliveryAttempt, DELIVERY_BACKOFF_HOLDING_REASON, deliveryHold, readRawEvidenceStagingState, recordDeliveryFailure, summarizeStuckEvidence, writeRawEvidenceStagingState, } from "./raw-evidence-staging.js";
8
9
  import { readLocalUploadSpoolState, recordPendingSessionReport, recordSessionReportFailure, recordSessionReportSuccess, recordUploadBlocked, recordUploadFailure, recordUploadSuccess, } from "./spool/local-spool.js";
9
10
  export class LocalUploadBlockedError extends Error {
10
11
  blocker;
@@ -150,18 +151,37 @@ export async function syncLocalAmbientEnvelope(options = {}) {
150
151
  }
151
152
  let uploadOutcomes = [];
152
153
  let uploadedChunkCount = 0;
154
+ const attemptedDate = options.now ?? new Date(attemptedAt);
155
+ const staging = await readRawEvidenceStagingState(paths.state_dir);
153
156
  try {
154
157
  if (built.raw_evidence_upload_files.length > 0 && provenance) {
155
- const upload = await uploadRawEvidenceFilesChunked({
156
- fetchImpl,
157
- dashboardUrl: built.dashboard_url,
158
- deviceToken: built.device_token,
159
- provenance,
160
- generatedAt: built.envelope.generated_at,
161
- files: built.raw_evidence_upload_files,
162
- });
163
- uploadOutcomes = upload.outcomes;
164
- uploadedChunkCount = upload.uploaded_chunk_count;
158
+ // Objects still inside their backoff window are not offered at all. They
159
+ // become held outcomes below, which keeps them out of the envelope, keeps
160
+ // the sync in retry_pending, and names the reason — the 559th identical
161
+ // attempt is what BLI-3066 made impossible.
162
+ const { deliverable, held } = partitionHeldEvidenceFiles(built.raw_evidence_upload_files, staging, attemptedDate);
163
+ if (held.length > 0) {
164
+ console.error("[cockpit-sync] raw evidence held by delivery backoff", JSON.stringify({
165
+ attempted_at: attemptedAt,
166
+ reason: DELIVERY_BACKOFF_HOLDING_REASON,
167
+ object_count: held.length,
168
+ byte_size: held.reduce((sum, outcome) => sum + (outcome.pointer.byte_size ?? 0), 0),
169
+ }));
170
+ }
171
+ uploadOutcomes = held;
172
+ if (deliverable.length > 0) {
173
+ const upload = await uploadRawEvidenceFilesChunked({
174
+ fetchImpl,
175
+ dashboardUrl: built.dashboard_url,
176
+ deviceToken: built.device_token,
177
+ provenance,
178
+ generatedAt: built.envelope.generated_at,
179
+ files: deliverable,
180
+ });
181
+ uploadOutcomes = [...held, ...upload.outcomes];
182
+ uploadedChunkCount = upload.uploaded_chunk_count;
183
+ }
184
+ await persistDeliveryAttempts(paths.state_dir, staging, uploadOutcomes, attemptedDate);
165
185
  }
166
186
  const envelope = applyRawEvidenceUploadOutcomes(built.envelope, uploadOutcomes);
167
187
  const response = await fetchImpl(`${built.dashboard_url}/api/ambient/ingest`, {
@@ -267,7 +287,7 @@ export async function syncLocalAmbientEnvelope(options = {}) {
267
287
  source_scan_count: built.source_scan_count,
268
288
  risk_flag_count: built.risk_flag_count,
269
289
  http_status: response.status,
270
- ...rawEvidenceSummary(built, uploadOutcomes, uploadedChunkCount, cursor),
290
+ ...rawEvidenceSummary(built, uploadOutcomes, uploadedChunkCount, cursor, staging, attemptedDate),
271
291
  };
272
292
  }
273
293
  catch (error) {
@@ -304,10 +324,98 @@ export async function syncLocalAmbientEnvelope(options = {}) {
304
324
  failure_reason: spooledFailureReason,
305
325
  spool_entry_id: entry.spool_id,
306
326
  retry_command: entry.retry_command,
307
- ...rawEvidenceSummary(built, uploadOutcomes, uploadedChunkCount, cursor),
327
+ ...rawEvidenceSummary(built, uploadOutcomes, uploadedChunkCount, cursor, staging, attemptedDate),
308
328
  };
309
329
  }
310
330
  }
331
+ /**
332
+ * Split the pack's files into "offer these now" and "still in backoff".
333
+ *
334
+ * A held file becomes an `upload_failed` outcome labelled
335
+ * `delivery_backoff_holding`. That is deliberate rather than a quiet omission:
336
+ * the pointer gets pruned from the envelope (the object is genuinely not
337
+ * durable), the sync stays in `retry_pending`, and the reason travels to the
338
+ * status output. A hold that read as success would be the green-status-hiding-
339
+ * missing-collection failure the fleet contract forbids.
340
+ */
341
+ export function partitionHeldEvidenceFiles(files, staging, now) {
342
+ const deliverable = [];
343
+ const held = [];
344
+ for (const file of files) {
345
+ const hold = deliveryHold(staging, file.pointer.content_hash_sha256, now);
346
+ if (!hold) {
347
+ deliverable.push(file);
348
+ continue;
349
+ }
350
+ held.push({
351
+ pointer: file.pointer,
352
+ object_key: file.pointer.object_key ?? "",
353
+ codex_session_id: file.codex_session_id ?? null,
354
+ kind: file.kind ?? "raw_evidence",
355
+ ...(file.artifact_metadata
356
+ ? { artifact_metadata: file.artifact_metadata }
357
+ : {}),
358
+ upload_state: "upload_failed",
359
+ reason: DELIVERY_BACKOFF_HOLDING_REASON,
360
+ uploaded_chunk_count: 0,
361
+ });
362
+ }
363
+ return { deliverable, held };
364
+ }
365
+ /**
366
+ * Count what just happened to each object, and when it may be offered again.
367
+ *
368
+ * Written immediately after the upload pass and before ingest, so an ingest
369
+ * failure cannot lose the attempt counts — losing them resets every backoff to
370
+ * zero and the fleet is back to 15-minute retries forever. A held outcome is
371
+ * not itself an attempt: counting it would push its own next attempt further
372
+ * out on every sync and eventually never retry at all.
373
+ */
374
+ async function persistDeliveryAttempts(stateDir, staging, outcomes, attemptedAt) {
375
+ let changed = false;
376
+ for (const outcome of outcomes) {
377
+ const contentHash = outcome.pointer.content_hash_sha256;
378
+ if (!contentHash)
379
+ continue;
380
+ if (outcome.reason === DELIVERY_BACKOFF_HOLDING_REASON)
381
+ continue;
382
+ if (outcome.upload_state === "upload_failed") {
383
+ const entry = recordDeliveryFailure(staging, contentHash, {
384
+ reason: outcome.reason ?? "upload_failed",
385
+ attemptedAt,
386
+ byteSize: outcome.pointer.byte_size ?? 0,
387
+ });
388
+ changed = true;
389
+ console.error("[cockpit-sync] raw evidence delivery failed", JSON.stringify({
390
+ reason: entry.last_reason,
391
+ kind: outcome.kind,
392
+ attempts: entry.attempts,
393
+ first_failed_at: entry.first_failed_at,
394
+ next_attempt_at: entry.next_attempt_at,
395
+ byte_size: entry.byte_size,
396
+ }));
397
+ continue;
398
+ }
399
+ if (clearDeliveryAttempt(staging, contentHash)) {
400
+ changed = true;
401
+ console.error("[cockpit-sync] raw evidence delivery recovered", JSON.stringify({
402
+ reason: "delivery_recovered",
403
+ kind: outcome.kind,
404
+ upload_state: outcome.upload_state,
405
+ }));
406
+ }
407
+ }
408
+ if (!changed)
409
+ return;
410
+ staging.updated_at = attemptedAt.toISOString();
411
+ await writeRawEvidenceStagingState(stateDir, staging).catch((error) => {
412
+ console.error("[cockpit-sync] delivery attempt state write failed", JSON.stringify({
413
+ reason: "staging_state_write_failed",
414
+ detail: error instanceof Error ? error.name : typeof error,
415
+ tracked_count: Object.keys(staging.delivery_attempts).length,
416
+ }));
417
+ });
418
+ }
311
419
  function retrySourcesForFailedSync(options, facts) {
312
420
  const sources = new Set();
313
421
  if ((options.codexSessionFiles?.length ?? 0) > 0 ||
@@ -372,7 +480,20 @@ function hasRetryableEvidenceGap(facts, outcomes) {
372
480
  facts.evidence_completeness.failure_reasons.length > 0) {
373
481
  return true;
374
482
  }
375
- return facts.evidence_completeness.skip_reasons.some(({ reason }) => /(?:read|stat|directory)_failed|session_limit_overflow/iu.test(reason));
483
+ return facts.evidence_completeness.skip_reasons.some(({ reason }) => isRetryableEvidenceSkipReason(reason));
484
+ }
485
+ /**
486
+ * Skip reasons a later sync can still turn into evidence.
487
+ *
488
+ * `delivery_backoff_holding` is one of them, and it has to be: a source held by
489
+ * backoff is missing collection right now. If it did not land here the sync
490
+ * would read clean while a transcript sat undelivered for nine days, which is
491
+ * the exact shape of BLI-3066.
492
+ */
493
+ function isRetryableEvidenceSkipReason(reason) {
494
+ if (reason === DELIVERY_BACKOFF_HOLDING_REASON)
495
+ return true;
496
+ return /(?:read|stat|directory)_failed|session_limit_overflow/iu.test(reason);
376
497
  }
377
498
  function retryableEvidenceGapReason(facts, outcomes) {
378
499
  const reasons = new Set();
@@ -393,7 +514,7 @@ function retryableEvidenceGapReason(facts, outcomes) {
393
514
  reasons.add("evidence_completeness_failed");
394
515
  }
395
516
  for (const { reason } of facts.evidence_completeness.skip_reasons) {
396
- if (/(?:read|stat|directory)_failed|session_limit_overflow/iu.test(reason)) {
517
+ if (isRetryableEvidenceSkipReason(reason)) {
397
518
  reasons.add(reason);
398
519
  }
399
520
  }
@@ -861,7 +982,8 @@ function agentArtifactFromMetadata(options) {
861
982
  },
862
983
  };
863
984
  }
864
- function rawEvidenceSummary(built, outcomes, uploadedChunkCount, cursor) {
985
+ function rawEvidenceSummary(built, outcomes, uploadedChunkCount, cursor, staging, now) {
986
+ const stuck = summarizeStuckEvidence(staging, now);
865
987
  const cursorReused = built.raw_evidence_facts?.reused ?? [];
866
988
  const serverReusedCount = outcomes.filter((outcome) => outcome.upload_state === "reused_existing").length;
867
989
  const failed = outcomes.filter((outcome) => outcome.upload_state === "upload_failed");
@@ -914,6 +1036,10 @@ function rawEvidenceSummary(built, outcomes, uploadedChunkCount, cursor) {
914
1036
  raw_evidence_deferred_byte_budget: built.raw_evidence_facts?.deferred_byte_budget_count ?? 0,
915
1037
  raw_evidence_deferred_object_budget: built.raw_evidence_facts?.deferred_object_budget_count ?? 0,
916
1038
  cursor_tracked_object_count: Object.keys(cursor.objects).length,
1039
+ raw_evidence_delivery_held_count: outcomes.filter((outcome) => outcome.reason === DELIVERY_BACKOFF_HOLDING_REASON).length + (built.raw_evidence_facts?.delivery_held_count ?? 0),
1040
+ raw_evidence_stuck_object_count: stuck.stuck_object_count,
1041
+ raw_evidence_max_delivery_attempts: stuck.max_attempts,
1042
+ raw_evidence_oldest_delivery_failure_at: stuck.oldest_first_failed_at,
917
1043
  };
918
1044
  }
919
1045
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.26",
3
+ "version": "0.2.28",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {