@bli-cockpit/cli 0.2.48 → 0.2.49

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.
Files changed (39) hide show
  1. package/dist/adapters/raw-evidence-attribution-gaps.js +133 -0
  2. package/dist/adapters/raw-evidence.js +360 -349
  3. package/dist/autostart-contract.js +79 -0
  4. package/dist/autostart-darwin-plist.js +265 -0
  5. package/dist/autostart-darwin.js +171 -0
  6. package/dist/autostart-windows-scripts.js +310 -0
  7. package/dist/autostart-windows-task-xml.js +260 -0
  8. package/dist/autostart-windows.js +237 -0
  9. package/dist/autostart-xml.js +23 -0
  10. package/dist/autostart.js +35 -1148
  11. package/dist/commands/agent-rules-command.js +55 -0
  12. package/dist/commands/agent-session-report.js +290 -0
  13. package/dist/commands/analyze.js +131 -0
  14. package/dist/commands/autostart-command.js +105 -0
  15. package/dist/commands/backfill.js +824 -551
  16. package/dist/commands/cli-io.js +13 -0
  17. package/dist/commands/jarvis.js +179 -3
  18. package/dist/commands/local-arg-values.js +169 -0
  19. package/dist/commands/local-args-collector.js +578 -0
  20. package/dist/commands/local-args-tower.js +870 -0
  21. package/dist/commands/local-args.js +8 -1549
  22. package/dist/commands/local-help.js +11 -3
  23. package/dist/commands/local.js +18 -1786
  24. package/dist/commands/login.js +53 -0
  25. package/dist/commands/logout.js +66 -0
  26. package/dist/commands/onboard-receipts.js +66 -0
  27. package/dist/commands/onboard-report.js +274 -0
  28. package/dist/commands/onboard.js +449 -0
  29. package/dist/commands/ops-render.js +36 -0
  30. package/dist/commands/public-root.js +1 -1
  31. package/dist/commands/serve.js +13 -0
  32. package/dist/commands/session-sync.js +513 -534
  33. package/dist/commands/settings-render.js +28 -0
  34. package/dist/commands/settings.js +66 -2
  35. package/dist/commands/start.js +47 -0
  36. package/dist/commands/sync-followups.js +203 -0
  37. package/dist/commands/sync.js +381 -0
  38. package/dist/tower-stream.js +20 -4
  39. package/package.json +1 -1
@@ -2,12 +2,15 @@
2
2
  // of commands/local.ts so the command runners read as a table of contents; this
3
3
  // is the core dual-source (Codex + Claude) scan/attribute/upload/report pass.
4
4
  //
5
- // Behavior-preserving extraction: moved verbatim from local.ts, no logic change.
5
+ // `runAttributedWorktreeSync` is the whole story in nine steps, in the order the
6
+ // pass runs them; everything below it is one of those steps. What a run REPORTS
7
+ // — the per-session rows and the CLI funnel counts — lives next door in
8
+ // `agent-session-report.ts` and is re-exported from here (BLI-3572).
6
9
  import os from "node:os";
7
10
  import path from "node:path";
8
11
  import { getCollectorRuntimePaths, readLocalCollectorConfig, startLocalWorkContext, startLocalWorkContextForAttributedTarget, } from "../local-state.js";
9
12
  import { describeError } from "../health-detail.js";
10
- import { CODEX_SESSION_ATTRIBUTION_STATE_RANK, NO_UPLOAD_ATTEMPT_RECORDED, notUploadableAttributionStateReason, } from "@bli-cockpit/telemetry-core";
13
+ import { buildAgentSessionReport, buildAgentSessionSummary, claudeAttributionReadFailureCount, codexAttributionReadFailureCount, } from "./agent-session-report.js";
11
14
  import { flushPendingCodexSessionReports, LocalUploadBlockedError, queueCodexSessionReport, syncLocalAmbientEnvelope, } from "../upload.js";
12
15
  import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
13
16
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
@@ -15,7 +18,10 @@ import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET }
15
18
  import { CLAUDE_CURSOR_FILENAME, countStaleSessions, emptyRawEvidenceCursorState, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor } from "../cursors/raw-evidence-cursor.js";
16
19
  import { normalizeCollectionRoots } from "../root-normalization.js";
17
20
  import { clearSourceRetryFailure, readLocalUploadSpoolState, recordSourceRetryFailure, } from "../spool/local-spool.js";
18
- import { isLiveRawEvidenceSyncAttribution, isRawEvidenceUploadableAttributionState, } from "../raw-evidence-attribution-policy.js";
21
+ import { isLiveRawEvidenceSyncAttribution } from "../raw-evidence-attribution-policy.js";
22
+ // What a run reports lives in `agent-session-report.ts`; it is re-exported here
23
+ // because `./session-sync.js` is the address every caller already knows.
24
+ export { ATTRIBUTION_STATE_RANK, buildAgentSessionReport, } from "./agent-session-report.js";
19
25
  /**
20
26
  * The label used when a sync fails and nothing on the way there said why.
21
27
  *
@@ -132,15 +138,6 @@ async function scanClaudeSessionsForSync(options) {
132
138
  }
133
139
  return scan;
134
140
  }
135
- function codexAttributionReadFailureCount(scan) {
136
- return scan.directory_read_failed_count + scan.stat_failed_count;
137
- }
138
- function claudeAttributionReadFailureCount(scan) {
139
- return (scan.project_dir_read_failed_count +
140
- scan.session_stat_failed_count +
141
- scan.sidecar_dir_read_failed_count +
142
- scan.sidecar_stat_failed_count);
143
- }
144
141
  export function sourceScanRetryReason(source, scan) {
145
142
  const reasons = new Set();
146
143
  const failure = sourceScanFailureReason(source, scan);
@@ -225,265 +222,516 @@ export function liveSyncTargetWorktrees(discovered, results) {
225
222
  * Outside-root sessions remain diagnostic-only. The session row's upload state
226
223
  * maps ONLY from the main-file outcome (D3); sidecar outcomes aggregate into
227
224
  * CLI counts.
225
+ *
226
+ * The order of the steps below IS the contract, and two of them are load
227
+ * bearing: the session report is queued before either cursor advances, and the
228
+ * run is judged last, after everything that could have a reason has recorded
229
+ * one.
228
230
  */
229
231
  export async function runAttributedWorktreeSync(options) {
230
232
  const now = new Date();
231
233
  const homeDir = options.homeDir ?? os.homedir();
232
234
  const paths = getCollectorRuntimePaths(options.homeDir);
233
- const config = await readLocalCollectorConfig(paths).catch(() => null);
235
+ const plan = await planSyncFromLocalState({
236
+ paths,
237
+ now,
238
+ approvedCollectionRoots: options.collectionRoots,
239
+ });
240
+ const scan = await scanAndAttributeBothSources({
241
+ paths,
242
+ homeDir,
243
+ now,
244
+ worktrees: options.worktrees,
245
+ plan,
246
+ });
247
+ const worktreePass = await syncEveryTargetWorktree({
248
+ run: options,
249
+ now,
250
+ plan,
251
+ scan,
252
+ });
253
+ const sessions = buildAgentSessionReport({
254
+ codexResults: scan.codexAttribution.results,
255
+ claudeResults: scan.claudeAttribution.results,
256
+ outcomes: worktreePass.outcomes,
257
+ now,
258
+ claudePriorDurablePointers: worktreePass.claudePriorDurablePointers,
259
+ });
260
+ const delivery = await reportSessionsAndAdvanceCursors({
261
+ run: options,
262
+ paths,
263
+ now,
264
+ plan,
265
+ scan,
266
+ sessions,
267
+ outcomes: worktreePass.outcomes,
268
+ });
269
+ const summary = buildAgentSessionSummary({
270
+ codexAttribution: scan.codexAttribution,
271
+ claudeAttribution: scan.claudeAttribution,
272
+ outcomes: worktreePass.outcomes,
273
+ codexStaleCount: delivery.codexStaleCount,
274
+ claudeStaleCount: delivery.claudeStaleCount,
275
+ firstRunBackfill: scan.firstRunBackfill,
276
+ growthDamped: worktreePass.growthDampedSessionCount,
277
+ report: delivery.report,
278
+ });
279
+ const health = decideSyncHealth({
280
+ worktreePass,
281
+ scan,
282
+ sessions,
283
+ delivery,
284
+ collectionRootCount: plan.collectionRoots.length,
285
+ });
286
+ return {
287
+ ok: health.ok,
288
+ failure_reasons: health.failure_reasons,
289
+ failure_records: health.failure_records,
290
+ notice: health.notice,
291
+ sessions_observed: sessions.length,
292
+ sessions_outside_root: health.sessions_outside_root,
293
+ outcomes: worktreePass.outcomes,
294
+ codexAttribution: scan.codexAttribution,
295
+ claudeAttribution: scan.claudeAttribution,
296
+ summary,
297
+ };
298
+ }
299
+ /**
300
+ * Read the local state this sync is bound by — config, both cursors, the upload
301
+ * spool — and turn it into the decisions the rest of the pass reads.
302
+ *
303
+ * Every read here degrades on purpose: a missing or corrupt local file becomes
304
+ * an empty cursor or a null spool, because a collector that cannot read its own
305
+ * bookkeeping must still collect.
306
+ */
307
+ async function planSyncFromLocalState(options) {
308
+ const config = await readLocalCollectorConfig(options.paths).catch(() => null);
234
309
  const claudeEnabled = config?.collect_claude_jsonl !== false;
235
- const collectionRoots = normalizeCollectionRoots(options.collectionRoots ?? config?.default_repo_paths ?? []);
310
+ const collectionRoots = normalizeCollectionRoots(options.approvedCollectionRoots ?? config?.default_repo_paths ?? []);
236
311
  const [codexCursorBefore, claudeCursorBefore, uploadSpool] = await Promise.all([
237
- readRawEvidenceCursor(paths).catch(() => emptyRawEvidenceCursorState()),
312
+ readRawEvidenceCursor(options.paths).catch(() => emptyRawEvidenceCursorState()),
238
313
  claudeEnabled
239
- ? readRawEvidenceCursor(paths, {
314
+ ? readRawEvidenceCursor(options.paths, {
240
315
  filename: CLAUDE_CURSOR_FILENAME,
241
316
  }).catch(() => emptyRawEvidenceCursorState())
242
317
  : Promise.resolve(emptyRawEvidenceCursorState()),
243
- readLocalUploadSpoolState(paths).catch(() => null),
318
+ readLocalUploadSpoolState(options.paths).catch(() => null),
244
319
  ]);
320
+ // A spooled upload from a CLI old enough not to have recorded its source
321
+ // could have come from either one, so both sources own it until it clears.
245
322
  const legacyRetryPending = Boolean(uploadSpool?.pending_uploads.some((entry) => entry.raw_evidence_file_count > 0 && entry.retry_sources.length === 0));
246
323
  const codexSourceRetryPending = sourceScanRetryIsPending(uploadSpool, "codex");
247
324
  const claudeSourceRetryPending = sourceScanRetryIsPending(uploadSpool, "claude_code");
248
- const codexRetryPending = sourceHasPendingRetries("codex", uploadSpool, legacyRetryPending, codexSourceRetryPending, codexCursorBefore);
249
- const claudeRetryPending = claudeEnabled &&
250
- sourceHasPendingRetries("claude_code", uploadSpool, legacyRetryPending, claudeSourceRetryPending, claudeCursorBefore);
251
- const allHistorySinceMinutes = allLocalHistorySinceMinutes(now);
325
+ return {
326
+ claudeEnabled,
327
+ collectionRoots,
328
+ codexCursorBefore,
329
+ claudeCursorBefore,
330
+ uploadSpool,
331
+ codexSourceRetryPending,
332
+ claudeSourceRetryPending,
333
+ codexRetryPending: sourceHasPendingRetries("codex", uploadSpool, legacyRetryPending, codexSourceRetryPending, codexCursorBefore),
334
+ claudeRetryPending: claudeEnabled &&
335
+ sourceHasPendingRetries("claude_code", uploadSpool, legacyRetryPending, claudeSourceRetryPending, claudeCursorBefore),
336
+ allHistorySinceMinutes: allLocalHistorySinceMinutes(options.now),
337
+ };
338
+ }
339
+ /**
340
+ * Scan and attribute both session sources, then settle each source's scan-retry
341
+ * bookkeeping against what the scan actually found.
342
+ *
343
+ * Codex first, Claude second, and Claude's first-run window is decided between
344
+ * them because it depends on whether the Claude cursor file exists yet.
345
+ */
346
+ async function scanAndAttributeBothSources(options) {
347
+ const { plan } = options;
252
348
  const codexAttribution = await scanCodexSessionsForSync({
253
- homeDir,
349
+ homeDir: options.homeDir,
254
350
  worktrees: options.worktrees,
255
- now,
256
- collectionRoots,
257
- retryPending: codexRetryPending,
258
- allHistorySinceMinutes,
351
+ now: options.now,
352
+ collectionRoots: plan.collectionRoots,
353
+ retryPending: plan.codexRetryPending,
354
+ allHistorySinceMinutes: plan.allHistorySinceMinutes,
259
355
  });
260
356
  // First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
261
357
  // days so the first sync captures retroactive history instead of only 24h.
262
- const claudeCursorExists = await fileExists(path.join(paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
263
- const firstRunBackfill = claudeEnabled && !claudeCursorExists;
358
+ const claudeCursorExists = await fileExists(path.join(options.paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
359
+ const firstRunBackfill = plan.claudeEnabled && !claudeCursorExists;
264
360
  const claudeAttribution = await scanClaudeSessionsForSync({
265
- claudeEnabled,
266
- homeDir,
361
+ claudeEnabled: plan.claudeEnabled,
362
+ homeDir: options.homeDir,
267
363
  worktrees: options.worktrees,
268
- now,
269
- collectionRoots,
270
- retryPending: claudeRetryPending,
271
- allHistorySinceMinutes,
364
+ now: options.now,
365
+ collectionRoots: plan.collectionRoots,
366
+ retryPending: plan.claudeRetryPending,
367
+ allHistorySinceMinutes: plan.allHistorySinceMinutes,
272
368
  firstRunBackfill,
273
369
  });
274
370
  await reconcileSourceScanRetry({
275
- paths,
371
+ paths: options.paths,
276
372
  source: "codex",
277
- attemptedAt: now.toISOString(),
278
- pendingBefore: codexSourceRetryPending,
373
+ attemptedAt: options.now.toISOString(),
374
+ pendingBefore: plan.codexSourceRetryPending,
279
375
  reason: sourceScanRetryReason("codex", codexAttribution),
280
376
  });
281
- if (claudeEnabled) {
377
+ if (plan.claudeEnabled) {
282
378
  await reconcileSourceScanRetry({
283
- paths,
379
+ paths: options.paths,
284
380
  source: "claude_code",
285
- attemptedAt: now.toISOString(),
286
- pendingBefore: claudeSourceRetryPending,
381
+ attemptedAt: options.now.toISOString(),
382
+ pendingBefore: plan.claudeSourceRetryPending,
287
383
  reason: sourceScanRetryReason("claude_code", claudeAttribution),
288
384
  });
289
385
  }
290
- const syncWorktrees = liveSyncTargetWorktrees(options.worktrees, [
291
- ...codexAttribution.results,
292
- ...claudeAttribution.results,
386
+ return { codexAttribution, claudeAttribution, firstRunBackfill };
387
+ }
388
+ /**
389
+ * Sync every worktree this tick is responsible for, in order, under one shared
390
+ * raw-evidence budget.
391
+ *
392
+ * The budget is per-sync (D7b), not per-worktree: a parent-folder sync over
393
+ * many worktrees honors a single byte/object cap rather than N times it.
394
+ */
395
+ async function syncEveryTargetWorktree(options) {
396
+ const { run, scan } = options;
397
+ const syncWorktrees = liveSyncTargetWorktrees(run.worktrees, [
398
+ ...scan.codexAttribution.results,
399
+ ...scan.claudeAttribution.results,
293
400
  ]);
294
- const discoveredWorktreeKeys = new Set(options.worktrees.map(liveSyncTargetKey));
401
+ const discoveredWorktreeKeys = new Set(run.worktrees.map(liveSyncTargetKey));
295
402
  // sessionId -> prior durable pointer for damped sessions (drives the
296
403
  // growth_damped count + the skip_main decision).
297
404
  const dampedClaudePointers = new Map();
298
- // sessionId -> prior durable pointer for EVERY already-durable Claude session
299
- // (superset of damped). A session that was durable before but had no fresh
300
- // upload this sync (damped, spooled, budget-deferred) reports reused_existing
301
- // with this pointer instead of not_uploaded, so the store row never flips.
302
- const claudePriorDurablePointers = new Map();
303
- for (const [sessionId, entry] of Object.entries(claudeCursorBefore.sessions)) {
304
- if (entry.uploaded_object_key) {
305
- claudePriorDurablePointers.set(sessionId, entry.uploaded_object_key);
306
- }
307
- }
308
- // One shared budget for the whole sync (D7b is per-sync): a parent-folder
309
- // sync over many worktrees honors a single byte/object cap rather than N×.
310
405
  const rawEvidenceBudget = {
311
406
  remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
312
407
  remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
313
408
  };
314
409
  const outcomes = [];
315
- let ok = true;
410
+ let everyWorktreeUploaded = true;
316
411
  for (const worktree of syncWorktrees) {
317
- const attributedSyntheticTarget = discoveredWorktreeKeys.has(liveSyncTargetKey(worktree))
318
- ? null
319
- : worktree;
320
- const contextOptions = {
321
- homeDir: options.homeDir,
322
- repoRoot: worktree.repo_root,
323
- activeTicketId: options.activeTicketId,
324
- operatorId: options.operatorId,
325
- sessionId: options.sessionId,
326
- ...(attributedSyntheticTarget ? {} : { branch: options.branch }),
327
- };
328
- const ensureContext = () => attributedSyntheticTarget
329
- ? startLocalWorkContextForAttributedTarget(contextOptions, attributedSyntheticTarget)
330
- : startLocalWorkContext(contextOptions);
331
- let context = null;
332
- if (options.startContexts || attributedSyntheticTarget) {
333
- context = await ensureContext();
334
- }
335
- const claudeSessionFiles = claudeAttribution.results
336
- .filter((result) => matchesLiveSyncWorktree(result, worktree))
337
- .map((result) => {
338
- const damped = !result.main_file_oversized &&
339
- shouldDampClaudeMain(result, claudeCursorBefore, now);
340
- if (damped) {
341
- dampedClaudePointers.set(result.claude_session_id, claudeCursorBefore.sessions[result.claude_session_id]
342
- ?.uploaded_object_key ?? null);
343
- }
344
- return {
345
- local_path: result.file_path,
346
- claude_session_id: result.claude_session_id,
347
- main_file_oversized: result.main_file_oversized,
348
- skip_main: damped,
349
- sidecar_files: result.sidecar_files
350
- .filter((sidecar) => !sidecar.skipped_reason)
351
- .map((sidecar) => ({ local_path: sidecar.local_path })),
352
- };
353
- });
354
- const syncOptions = {
355
- homeDir: options.homeDir,
356
- repoRoot: worktree.repo_root,
357
- dashboardUrl: options.dashboardUrl,
358
- worktreeInventory: worktreeInventoryForRepo(worktree, syncWorktrees),
359
- codexSessionFiles: codexAttribution.results
360
- .filter((result) => matchesLiveSyncWorktree(result, worktree))
361
- .map((result) => ({
362
- local_path: result.file_path,
363
- codex_session_id: result.codex_session_id,
364
- })),
365
- codexAttributionScan: codexAttribution,
366
- claudeSessionFiles,
367
- claudeAttributionScan: claudeAttribution,
412
+ const outcome = await syncOneWorktree({
413
+ run,
414
+ now: options.now,
415
+ plan: options.plan,
416
+ scan,
417
+ worktree,
418
+ syncWorktrees,
368
419
  rawEvidenceBudget,
369
- fetch: options.fetchImpl,
370
- };
371
- let sync;
372
- try {
420
+ // A target git discovery never produced is one the fallback attribution
421
+ // synthesized, and it syncs through its own attributed work context.
422
+ isDiscoveredWorktree: discoveredWorktreeKeys.has(liveSyncTargetKey(worktree)),
423
+ recordDampedClaudeSession: (sessionId, priorPointer) => {
424
+ dampedClaudePointers.set(sessionId, priorPointer);
425
+ },
426
+ });
427
+ everyWorktreeUploaded =
428
+ everyWorktreeUploaded && outcome.sync.status === "uploaded";
429
+ outcomes.push(outcome);
430
+ }
431
+ return {
432
+ outcomes,
433
+ everyWorktreeUploaded,
434
+ claudePriorDurablePointers: claudeDurablePointersFromCursor(options.plan.claudeCursorBefore),
435
+ growthDampedSessionCount: dampedClaudePointers.size,
436
+ };
437
+ }
438
+ /** Every Claude session the cursor already holds a durable pointer for. */
439
+ function claudeDurablePointersFromCursor(claudeCursorBefore) {
440
+ const pointersBySessionId = new Map();
441
+ for (const [sessionId, entry] of Object.entries(claudeCursorBefore.sessions)) {
442
+ if (entry.uploaded_object_key) {
443
+ pointersBySessionId.set(sessionId, entry.uploaded_object_key);
444
+ }
445
+ }
446
+ return pointersBySessionId;
447
+ }
448
+ /**
449
+ * Sync one worktree's attributed transcripts, starting a work context first
450
+ * when this target needs one.
451
+ *
452
+ * A newly cloned repo has no work context yet. Capture is permissive and ticket
453
+ * binding comes later, so a `missing_context` refusal starts general ambient
454
+ * capture for that repo and retries, instead of blocking every other repo's
455
+ * sync until someone runs `cockpit start` by hand.
456
+ */
457
+ async function syncOneWorktree(options) {
458
+ const { run, worktree } = options;
459
+ const attributedSyntheticTarget = options.isDiscoveredWorktree
460
+ ? null
461
+ : worktree;
462
+ const contextOptions = {
463
+ homeDir: run.homeDir,
464
+ repoRoot: worktree.repo_root,
465
+ activeTicketId: run.activeTicketId,
466
+ operatorId: run.operatorId,
467
+ sessionId: run.sessionId,
468
+ ...(attributedSyntheticTarget ? {} : { branch: run.branch }),
469
+ };
470
+ const ensureContext = () => attributedSyntheticTarget
471
+ ? startLocalWorkContextForAttributedTarget(contextOptions, attributedSyntheticTarget)
472
+ : startLocalWorkContext(contextOptions);
473
+ let context = null;
474
+ if (run.startContexts || attributedSyntheticTarget) {
475
+ context = await ensureContext();
476
+ }
477
+ const syncOptions = ambientEnvelopeForWorktree(options);
478
+ let sync;
479
+ try {
480
+ sync = await syncLocalAmbientEnvelope(syncOptions);
481
+ }
482
+ catch (error) {
483
+ if (error instanceof LocalUploadBlockedError &&
484
+ error.blocker === "missing_context") {
485
+ context = await ensureContext();
373
486
  sync = await syncLocalAmbientEnvelope(syncOptions);
374
487
  }
375
- catch (error) {
376
- // A newly cloned repo has no work context yet. Capture is permissive
377
- // and ticket binding comes later, so start general ambient capture for
378
- // it instead of blocking every other repo's sync until someone runs
379
- // `cockpit start` by hand.
380
- if (error instanceof LocalUploadBlockedError &&
381
- error.blocker === "missing_context") {
382
- context = await ensureContext();
383
- sync = await syncLocalAmbientEnvelope(syncOptions);
384
- }
385
- else {
386
- throw error;
387
- }
488
+ else {
489
+ throw error;
388
490
  }
389
- ok = ok && sync.status === "uploaded";
390
- outcomes.push({ worktree, context, sync });
391
491
  }
392
- const sessions = buildAgentSessionReport({
393
- codexResults: codexAttribution.results,
394
- claudeResults: claudeAttribution.results,
395
- outcomes,
396
- now,
397
- claudePriorDurablePointers,
492
+ return { worktree, context, sync };
493
+ }
494
+ /**
495
+ * Assemble what one worktree is about to upload: its sibling worktree
496
+ * inventory, its Codex mains, its Claude mains and sidecars, and its share of
497
+ * the sync-wide raw-evidence budget.
498
+ */
499
+ function ambientEnvelopeForWorktree(options) {
500
+ const { run, worktree, scan } = options;
501
+ const claudeSessionFiles = claudeSessionFilesForWorktree({
502
+ worktree,
503
+ claudeResults: scan.claudeAttribution.results,
504
+ claudeCursorBefore: options.plan.claudeCursorBefore,
505
+ now: options.now,
506
+ recordDampedClaudeSession: options.recordDampedClaudeSession,
398
507
  });
399
- // Persist the metadata-only report before advancing either source cursor.
400
- // If the process exits after this point, the spool keeps an exact retry copy.
401
- // If queueing itself fails, the cursors remain at their prior retryable
402
- // positions instead of aging an unreported session out of the live window.
403
- const firstUploaded = outcomes.find((outcome) => outcome.sync.status === "uploaded");
404
- const hadPendingSessionReports = (uploadSpool?.pending_session_reports.length ?? 0) > 0;
405
- let queuedCurrentSessionReport = false;
406
- if (firstUploaded && sessions.length > 0) {
407
- const queued = await queueCodexSessionReport({
408
- homeDir: options.homeDir,
409
- dashboardUrl: firstUploaded.sync.dashboard_url,
410
- generatedAt: now.toISOString(),
411
- workContextId: firstUploaded.sync.work_context_id,
412
- repoLabel: firstUploaded.worktree.repo_label,
413
- branch: firstUploaded.worktree.branch,
414
- repoFingerprint: firstUploaded.worktree.repo_fingerprint,
415
- repoOriginUrl: firstUploaded.worktree.repo_origin_url,
416
- worktreeLabel: firstUploaded.worktree.worktree_label,
417
- worktreeFingerprint: firstUploaded.worktree.worktree_fingerprint,
418
- worktreeIsPrimary: firstUploaded.worktree.worktree_is_primary,
419
- sessions,
420
- });
421
- queuedCurrentSessionReport = Boolean(queued);
422
- }
423
- const reportRequired = sessions.length > 0 ||
424
- hadPendingSessionReports ||
425
- queuedCurrentSessionReport;
426
- // The sessions cursor is an optimization; a broken local state dir must not
427
- // turn already-completed syncs into a CLI crash.
508
+ return {
509
+ homeDir: run.homeDir,
510
+ repoRoot: worktree.repo_root,
511
+ dashboardUrl: run.dashboardUrl,
512
+ worktreeInventory: worktreeInventoryForRepo(worktree, options.syncWorktrees),
513
+ codexSessionFiles: scan.codexAttribution.results
514
+ .filter((result) => matchesLiveSyncWorktree(result, worktree))
515
+ .map((result) => ({
516
+ local_path: result.file_path,
517
+ codex_session_id: result.codex_session_id,
518
+ })),
519
+ codexAttributionScan: scan.codexAttribution,
520
+ claudeSessionFiles,
521
+ claudeAttributionScan: scan.claudeAttribution,
522
+ rawEvidenceBudget: options.rawEvidenceBudget,
523
+ fetch: run.fetchImpl,
524
+ };
525
+ }
526
+ /**
527
+ * This worktree's Claude mains and sidecars, with the growth damper applied.
528
+ *
529
+ * A damped main is still reported — `skip_main` means "do not re-upload the
530
+ * body this tick", not "forget this session" — and its prior durable pointer is
531
+ * recorded so the session report can say `reused_existing` instead of flipping
532
+ * the row to not_uploaded.
533
+ */
534
+ function claudeSessionFilesForWorktree(options) {
535
+ return options.claudeResults
536
+ .filter((result) => matchesLiveSyncWorktree(result, options.worktree))
537
+ .map((result) => {
538
+ const damped = !result.main_file_oversized &&
539
+ shouldDampClaudeMain(result, options.claudeCursorBefore, options.now);
540
+ if (damped) {
541
+ options.recordDampedClaudeSession(result.claude_session_id, options.claudeCursorBefore.sessions[result.claude_session_id]
542
+ ?.uploaded_object_key ?? null);
543
+ }
544
+ return {
545
+ local_path: result.file_path,
546
+ claude_session_id: result.claude_session_id,
547
+ main_file_oversized: result.main_file_oversized,
548
+ skip_main: damped,
549
+ sidecar_files: result.sidecar_files
550
+ .filter((sidecar) => !sidecar.skipped_reason)
551
+ .map((sidecar) => ({ local_path: sidecar.local_path })),
552
+ };
553
+ });
554
+ }
555
+ /**
556
+ * Report this tick's sessions and advance both cursors — in that order, which
557
+ * is the load-bearing part.
558
+ *
559
+ * The report is queued to the spool BEFORE either cursor moves. If the process
560
+ * exits in between, the spool keeps an exact retry copy; if queueing itself
561
+ * fails, the cursors stay at their prior retryable positions instead of aging
562
+ * an unreported session out of the live window.
563
+ */
564
+ async function reportSessionsAndAdvanceCursors(options) {
565
+ const hadPendingSessionReports = (options.plan.uploadSpool?.pending_session_reports.length ?? 0) > 0;
566
+ const queuedCurrentSessionReport = await queueSessionReportForDelivery({
567
+ homeDir: options.run.homeDir,
568
+ outcomes: options.outcomes,
569
+ sessions: options.sessions,
570
+ now: options.now,
571
+ });
572
+ const staleCounts = await recordBothSourceCursorObservations({
573
+ paths: options.paths,
574
+ plan: options.plan,
575
+ scan: options.scan,
576
+ sessions: options.sessions,
577
+ now: options.now,
578
+ });
579
+ const report = await deliverOrExplainSessionReport({
580
+ hadPendingSessionReports,
581
+ queuedCurrentSessionReport,
582
+ sessionCount: options.sessions.length,
583
+ homeDir: options.run.homeDir,
584
+ fetchImpl: options.run.fetchImpl,
585
+ now: options.now,
586
+ });
587
+ return {
588
+ report,
589
+ reportRequired: options.sessions.length > 0 ||
590
+ hadPendingSessionReports ||
591
+ queuedCurrentSessionReport,
592
+ ...staleCounts,
593
+ };
594
+ }
595
+ /**
596
+ * Persist the metadata-only session report to the spool, before either source
597
+ * cursor advances.
598
+ *
599
+ * If the process exits after this point, the spool keeps an exact retry copy.
600
+ * If queueing itself fails, the cursors remain at their prior retryable
601
+ * positions instead of aging an unreported session out of the live window.
602
+ * Returns whether a report for THIS tick was queued.
603
+ */
604
+ async function queueSessionReportForDelivery(options) {
605
+ const firstUploaded = options.outcomes.find((outcome) => outcome.sync.status === "uploaded");
606
+ if (!firstUploaded || options.sessions.length === 0)
607
+ return false;
608
+ const queued = await queueCodexSessionReport({
609
+ homeDir: options.homeDir,
610
+ dashboardUrl: firstUploaded.sync.dashboard_url,
611
+ generatedAt: options.now.toISOString(),
612
+ workContextId: firstUploaded.sync.work_context_id,
613
+ repoLabel: firstUploaded.worktree.repo_label,
614
+ branch: firstUploaded.worktree.branch,
615
+ repoFingerprint: firstUploaded.worktree.repo_fingerprint,
616
+ repoOriginUrl: firstUploaded.worktree.repo_origin_url,
617
+ worktreeLabel: firstUploaded.worktree.worktree_label,
618
+ worktreeFingerprint: firstUploaded.worktree.worktree_fingerprint,
619
+ worktreeIsPrimary: firstUploaded.worktree.worktree_is_primary,
620
+ sessions: options.sessions,
621
+ });
622
+ return Boolean(queued);
623
+ }
624
+ /**
625
+ * Advance both source cursors to what this tick observed.
626
+ *
627
+ * The sessions cursor is an optimization; a broken local state dir must not
628
+ * turn an already-completed sync into a CLI crash, so each side is best-effort
629
+ * and names its own failure.
630
+ */
631
+ async function recordBothSourceCursorObservations(options) {
428
632
  const codexStaleCount = await recordCodexSessionObservationsForSync({
429
- paths,
430
- codexAttribution,
431
- sessions,
432
- now,
433
- priorCursor: codexCursorBefore,
434
- codexRetryPending,
633
+ paths: options.paths,
634
+ codexAttribution: options.scan.codexAttribution,
635
+ sessions: options.sessions,
636
+ now: options.now,
637
+ priorCursor: options.plan.codexCursorBefore,
638
+ codexRetryPending: options.plan.codexRetryPending,
435
639
  });
436
640
  const claudeStaleCount = await recordClaudeSessionObservationsForSync({
437
- claudeEnabled,
438
- paths,
439
- claudeAttribution,
440
- sessions,
441
- now,
442
- claudeCursorBefore,
443
- claudeRetryPending,
641
+ claudeEnabled: options.plan.claudeEnabled,
642
+ paths: options.paths,
643
+ claudeAttribution: options.scan.claudeAttribution,
644
+ sessions: options.sessions,
645
+ now: options.now,
646
+ claudeCursorBefore: options.plan.claudeCursorBefore,
647
+ claudeRetryPending: options.plan.claudeRetryPending,
444
648
  });
445
- const report = hadPendingSessionReports || queuedCurrentSessionReport
446
- ? await flushPendingCodexSessionReports({
447
- homeDir: options.homeDir,
448
- fetch: options.fetchImpl,
449
- now,
450
- })
451
- : {
649
+ return { codexStaleCount, claudeStaleCount };
650
+ }
651
+ /**
652
+ * Flush the queued session reports — or, when there is nothing queued, say why
653
+ * nothing was posted instead of returning a bare false.
654
+ */
655
+ async function deliverOrExplainSessionReport(options) {
656
+ if (!options.hadPendingSessionReports && !options.queuedCurrentSessionReport) {
657
+ return {
452
658
  posted: false,
453
- reason: sessions.length === 0 ? "no_sessions_observed" : "no_successful_sync",
659
+ reason: options.sessionCount === 0
660
+ ? "no_sessions_observed"
661
+ : "no_successful_sync",
454
662
  };
455
- const summary = buildAgentSessionSummary({
456
- codexAttribution,
457
- claudeAttribution,
663
+ }
664
+ return flushPendingCodexSessionReports({
665
+ homeDir: options.homeDir,
666
+ fetch: options.fetchImpl,
667
+ now: options.now,
668
+ });
669
+ }
670
+ /**
671
+ * Decide whether this tick failed, and record every condition that decided it.
672
+ *
673
+ * This used to be one boolean chain: correct, and completely mute — a failed
674
+ * sync exited 1 saying only `sync_failed` (BLI-2526). The conditions are the
675
+ * same; they now write down what they decided, each beside the closed-registry
676
+ * LABEL the health receipt is classified by (BLI-3551). Nothing parses the
677
+ * rendered sentence back apart.
678
+ */
679
+ function decideSyncHealth(options) {
680
+ const { outcomes } = options.worktreePass;
681
+ const report = options.delivery.report;
682
+ const ledger = createSyncFailureLedger();
683
+ recordWorktreeDeliveryFailures(ledger, outcomes);
684
+ recordSourceScanFailures(ledger, options.scan.codexAttribution, options.scan.claudeAttribution);
685
+ const sessionsOutsideRoot = options.sessions.filter((session) => OUTSIDE_APPROVED_ROOT_REASONS.has(session.attribution_reason)).length;
686
+ const nothingInRoot = nothingInRootCount({
687
+ sessionCount: options.sessions.length,
688
+ outsideRootCount: sessionsOutsideRoot,
458
689
  outcomes,
459
- codexStaleCount,
460
- claudeStaleCount,
461
- firstRunBackfill,
462
- growthDamped: dampedClaudePointers.size,
463
- report,
690
+ reportPosted: report.posted,
691
+ reportReason: report.reason,
464
692
  });
465
- // Same conditions as before, one per line, each writing down its own reason.
466
- // The old version was a single boolean chain: correct, and completely mute.
467
- //
468
- // Since BLI-3551 each condition also writes down the LABEL it is classified
469
- // by, beside the rendered string a person reads. The health receipt reads the
470
- // label; nothing parses the sentence back apart.
471
- const failureRecords = new Map();
472
- const add = (record) => {
473
- if (!failureRecords.has(record.rendered)) {
474
- failureRecords.set(record.rendered, record);
475
- }
476
- };
477
- const fail = (condition, label, rendered = label) => {
478
- if (condition)
479
- add({ label, rendered });
693
+ // BLI-3551: a tick that observed only sessions from outside the operator's
694
+ // approved roots has nothing to post, and that is the consent boundary
695
+ // working — not a failure. It used to fail as
696
+ // `session_report_unposted:no_successful_sync`, whose word "session" then
697
+ // classified as `auth_failed`; one machine reported a broken credential 377
698
+ // times in 38 hours while its token had eleven weeks left. The withhold
699
+ // decision itself is untouched (adapters/attribution-core.ts) — only what it
700
+ // is CALLED.
701
+ ledger.fail(options.delivery.reportRequired && !report.posted && nothingInRoot === null, "session_report_unposted", `session_report_unposted:${report.reason ?? "unknown"}`);
702
+ // `everyWorktreeUploaded` may already be false; the delivery recorder above
703
+ // re-derives that from the same outcomes, so the two agree by construction.
704
+ const ok = options.worktreePass.everyWorktreeUploaded && ledger.isEmpty();
705
+ if (!ok && ledger.isEmpty()) {
706
+ ledger.add({
707
+ label: SYNC_FAILED_WITHOUT_REASON,
708
+ rendered: SYNC_FAILED_WITHOUT_REASON,
709
+ });
710
+ }
711
+ const notice = ok && nothingInRoot !== null ? `nothing_in_root:${nothingInRoot}` : null;
712
+ if (nothingInRoot !== null && notice) {
713
+ announceNothingInRoot(nothingInRoot, options.collectionRootCount);
714
+ }
715
+ const records = ledger.sortedRecords();
716
+ return {
717
+ ok,
718
+ failure_reasons: records.map((record) => record.rendered),
719
+ failure_records: records,
720
+ notice,
721
+ sessions_outside_root: sessionsOutsideRoot,
480
722
  };
723
+ }
724
+ /**
725
+ * Every worktree that did not finish, and every raw-evidence gap it reported.
726
+ *
727
+ * The spooled reason is the most specific thing anyone has, so it leads, and it
728
+ * names the worktree it belongs to — a fleet failure is usually one repo, and
729
+ * "which one" is the first question asked.
730
+ */
731
+ function recordWorktreeDeliveryFailures(ledger, outcomes) {
481
732
  for (const { worktree, sync } of outcomes) {
482
733
  if (sync.status !== "uploaded") {
483
- // The spooled reason is the most specific thing anyone has, so lead with
484
- // it and name the worktree it belongs to — a fleet failure is usually one
485
- // repo, and "which one" is the first question asked.
486
- add(sync.status === "spooled" && sync.failure_reason
734
+ ledger.add(sync.status === "spooled" && sync.failure_reason
487
735
  ? {
488
736
  label: sync.failure_class,
489
737
  rendered: `${worktree.worktree_label}:${sync.failure_reason}`,
@@ -495,91 +743,80 @@ export async function runAttributedWorktreeSync(options) {
495
743
  });
496
744
  }
497
745
  for (const reason of sync.raw_evidence_failure_reasons ?? []) {
498
- add({
746
+ ledger.add({
499
747
  label: "raw_evidence_upload_failed",
500
748
  rendered: `raw_evidence:${reason}`,
501
749
  });
502
750
  }
503
751
  for (const reason of sync.raw_evidence_retry_reasons ?? []) {
504
- add({
752
+ ledger.add({
505
753
  label: "raw_evidence_retry_required",
506
754
  rendered: `raw_evidence_retry:${reason}`,
507
755
  });
508
756
  }
509
- fail(sync.raw_evidence_deferred_byte_budget > 0, "deferred_byte_budget");
510
- fail(sync.raw_evidence_deferred_object_budget > 0, "deferred_object_budget");
757
+ ledger.fail(sync.raw_evidence_deferred_byte_budget > 0, "deferred_byte_budget");
758
+ ledger.fail(sync.raw_evidence_deferred_object_budget > 0, "deferred_object_budget");
511
759
  }
512
- fail(codexAttribution.session_limit_applied, "codex_session_limit_applied");
513
- fail(claudeAttribution.session_limit_applied, "claude_session_limit_applied");
514
- fail(codexAttributionReadFailureCount(codexAttribution) > 0, "codex_session_read_failed");
515
- fail(claudeAttributionReadFailureCount(claudeAttribution) > 0, "claude_session_read_failed");
516
- // BLI-3551: the scan's RETRY reason and the scan's FAILURE reason are two
517
- // different questions, and answering both with one function is what put
518
- // `claude_scan:repo_not_on_disk` on every tick of three machines. A repo that
519
- // is not on disk is a label on the session (the attribution umbrella finding:
520
- // nothing was deleted, the transcript simply names a path git no longer
521
- // knows). It still widens the next scan window; it is not a failed sync.
760
+ }
761
+ /**
762
+ * What the scan itself got wrong: a window that hit its cap, sessions that
763
+ * could not be read, or a session store that could not be read at all.
764
+ *
765
+ * BLI-3551: the scan's RETRY reason and the scan's FAILURE reason are two
766
+ * different questions, and answering both with one function is what put
767
+ * `claude_scan:repo_not_on_disk` on every tick of three machines. A repo that
768
+ * is not on disk is a label on the session (the attribution umbrella finding:
769
+ * nothing was deleted, the transcript simply names a path git no longer
770
+ * knows). It still widens the next scan window; it is not a failed sync.
771
+ */
772
+ function recordSourceScanFailures(ledger, codexAttribution, claudeAttribution) {
773
+ ledger.fail(codexAttribution.session_limit_applied, "codex_session_limit_applied");
774
+ ledger.fail(claudeAttribution.session_limit_applied, "claude_session_limit_applied");
775
+ ledger.fail(codexAttributionReadFailureCount(codexAttribution) > 0, "codex_session_read_failed");
776
+ ledger.fail(claudeAttributionReadFailureCount(claudeAttribution) > 0, "claude_session_read_failed");
522
777
  const codexScanFailure = sourceScanFailureReason("codex", codexAttribution);
523
778
  if (codexScanFailure) {
524
- add({ label: "codex_scan_read_failed", rendered: `codex_scan:${codexScanFailure}` });
779
+ ledger.add({
780
+ label: "codex_scan_read_failed",
781
+ rendered: `codex_scan:${codexScanFailure}`,
782
+ });
525
783
  }
526
784
  const claudeScanFailure = sourceScanFailureReason("claude_code", claudeAttribution);
527
785
  if (claudeScanFailure) {
528
- add({
786
+ ledger.add({
529
787
  label: "claude_scan_read_failed",
530
788
  rendered: `claude_scan:${claudeScanFailure}`,
531
789
  });
532
790
  }
533
- // BLI-3551: a tick that observed only sessions from outside the operator's
534
- // approved roots has nothing to post, and that is the consent boundary
535
- // working not a failure. It used to fail as
536
- // `session_report_unposted:no_successful_sync`, whose word "session" then
537
- // classified as `auth_failed`; one machine reported a broken credential 377
538
- // times in 38 hours while its token had eleven weeks left. The withhold
539
- // decision itself is untouched (adapters/attribution-core.ts) — only what it
540
- // is CALLED.
541
- const sessionsOutsideRoot = sessions.filter((session) => OUTSIDE_APPROVED_ROOT_REASONS.has(session.attribution_reason)).length;
542
- const nothingInRoot = nothingInRootCount({
543
- sessionCount: sessions.length,
544
- outsideRootCount: sessionsOutsideRoot,
545
- outcomes,
546
- reportPosted: report.posted,
547
- reportReason: report.reason,
548
- });
549
- fail(reportRequired && !report.posted && nothingInRoot === null, "session_report_unposted", `session_report_unposted:${report.reason ?? "unknown"}`);
550
- // `ok` may already be false from the per-worktree loop above; the outcome
551
- // scan re-derives that, so the two agree by construction.
552
- ok = ok && failureRecords.size === 0;
553
- if (!ok && failureRecords.size === 0) {
554
- add({
555
- label: SYNC_FAILED_WITHOUT_REASON,
556
- rendered: SYNC_FAILED_WITHOUT_REASON,
557
- });
558
- }
559
- const notice = ok && nothingInRoot !== null ? `nothing_in_root:${nothingInRoot}` : null;
560
- if (notice) {
561
- // The success branch says something too: this is the receipt that proves a
562
- // quiet machine is a working machine, and the count is what tells a coach
563
- // that someone is working entirely outside the approved boundary.
564
- console.error("[session-sync] nothing to collect inside the approved roots", JSON.stringify({
565
- reason: "nothing_in_root",
566
- sessions_outside_root: nothingInRoot,
567
- collection_root_count: collectionRoots.length,
568
- next_action: "widen the approved roots (an operator decision) if this machine should be collecting here",
569
- }));
570
- }
571
- const records = [...failureRecords.values()].sort((a, b) => a.rendered.localeCompare(b.rendered));
572
- return {
573
- ok,
574
- failure_reasons: records.map((record) => record.rendered),
575
- failure_records: records,
576
- notice,
577
- sessions_observed: sessions.length,
791
+ }
792
+ /**
793
+ * The success branch says something too: this is the receipt that proves a
794
+ * quiet machine is a working machine, and the count is what tells a coach that
795
+ * someone is working entirely outside the approved boundary.
796
+ */
797
+ function announceNothingInRoot(sessionsOutsideRoot, collectionRootCount) {
798
+ console.error("[session-sync] nothing to collect inside the approved roots", JSON.stringify({
799
+ reason: "nothing_in_root",
578
800
  sessions_outside_root: sessionsOutsideRoot,
579
- outcomes,
580
- codexAttribution,
581
- claudeAttribution,
582
- summary,
801
+ collection_root_count: collectionRootCount,
802
+ next_action: "widen the approved roots (an operator decision) if this machine should be collecting here",
803
+ }));
804
+ }
805
+ function createSyncFailureLedger() {
806
+ const recordsByRenderedReason = new Map();
807
+ const add = (record) => {
808
+ if (!recordsByRenderedReason.has(record.rendered)) {
809
+ recordsByRenderedReason.set(record.rendered, record);
810
+ }
811
+ };
812
+ return {
813
+ add,
814
+ fail(condition, label, rendered = label) {
815
+ if (condition)
816
+ add({ label, rendered });
817
+ },
818
+ isEmpty: () => recordsByRenderedReason.size === 0,
819
+ sortedRecords: () => [...recordsByRenderedReason.values()].sort((a, b) => a.rendered.localeCompare(b.rendered)),
583
820
  };
584
821
  }
585
822
  /**
@@ -617,209 +854,6 @@ export function nothingInRootCount(options) {
617
854
  ? options.outsideRootCount
618
855
  : null;
619
856
  }
620
- export const ATTRIBUTION_STATE_RANK = CODEX_SESSION_ATTRIBUTION_STATE_RANK;
621
- function normalizeCodexResult(result) {
622
- return {
623
- source: "codex",
624
- session_id: result.codex_session_id,
625
- state: result.state,
626
- reason: result.reason,
627
- signals: result.signals,
628
- attribution_score: result.attribution_score,
629
- path_score: result.path_score,
630
- content_hash_sha256: result.content_hash_sha256,
631
- byte_size: result.byte_size,
632
- session_file_mtime: result.session_file_mtime,
633
- session_file_mtime_ms: result.session_file_mtime_ms,
634
- worktree: result.worktree,
635
- cwd_basename: result.cwd_basename,
636
- cwd_hash: result.cwd_hash,
637
- };
638
- }
639
- function normalizeClaudeResult(result) {
640
- return {
641
- source: "claude_code",
642
- session_id: result.claude_session_id,
643
- state: result.state,
644
- reason: result.reason,
645
- signals: result.signals,
646
- attribution_score: result.attribution_score,
647
- path_score: result.path_score,
648
- content_hash_sha256: result.content_hash_sha256,
649
- byte_size: result.byte_size,
650
- session_file_mtime: result.session_file_mtime,
651
- session_file_mtime_ms: result.session_file_mtime_ms,
652
- worktree: result.worktree,
653
- cwd_basename: result.cwd_basename,
654
- cwd_hash: result.cwd_hash,
655
- };
656
- }
657
- /**
658
- * Dedupe every source's attributed results down to the single best result per
659
- * `(source, session_id)`. Best means highest attribution-state rank, ties
660
- * broken by the newer file mtime. Codex and Claude sessions that happen to
661
- * share an id are never collapsed into each other because the key carries the
662
- * source.
663
- */
664
- function bestAttributedSessionsByKey(codexResults, claudeResults) {
665
- const normalized = [
666
- ...codexResults.map(normalizeCodexResult),
667
- ...claudeResults.map(normalizeClaudeResult),
668
- ];
669
- const bestByKey = new Map();
670
- for (const result of normalized) {
671
- const key = `${result.source}:${result.session_id}`;
672
- const existing = bestByKey.get(key);
673
- if (!existing ||
674
- (ATTRIBUTION_STATE_RANK[result.state] ?? 0) >
675
- (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) ||
676
- ((ATTRIBUTION_STATE_RANK[result.state] ?? 0) ===
677
- (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) &&
678
- result.session_file_mtime_ms > existing.session_file_mtime_ms)) {
679
- bestByKey.set(key, result);
680
- }
681
- }
682
- return bestByKey;
683
- }
684
- /**
685
- * Which sessions this sync actually uploaded, and why the rest did not — read
686
- * ONLY from main-file outcomes (kind `codex_jsonl` / `claude_jsonl`); a
687
- * sidecar making it must never mark a session uploaded when the main did not
688
- * (D3).
689
- */
690
- function sessionUploadOutcomesByKey(outcomes) {
691
- const uploadByKey = new Map();
692
- const noUploadReasonByKey = new Map();
693
- let anySyncIncomplete = false;
694
- for (const outcome of outcomes) {
695
- if (outcome.sync.status !== "uploaded") {
696
- anySyncIncomplete = true;
697
- continue;
698
- }
699
- for (const upload of outcome.sync.raw_evidence_outcomes) {
700
- if (!upload.codex_session_id)
701
- continue;
702
- const source = upload.kind === "claude_jsonl"
703
- ? "claude_code"
704
- : upload.kind === "codex_jsonl"
705
- ? "codex"
706
- : null;
707
- if (!source)
708
- continue; // sidecars and other kinds do not set session state
709
- const key = `${source}:${upload.codex_session_id}`;
710
- if (!upload.raw_evidence_pointer_id) {
711
- // An outcome with no pointer is a failure that named itself. Keep the
712
- // reason even though there is nothing to point at.
713
- if (upload.reason)
714
- noUploadReasonByKey.set(key, upload.reason);
715
- continue;
716
- }
717
- uploadByKey.set(key, upload);
718
- }
719
- }
720
- return { uploadByKey, noUploadReasonByKey, anySyncIncomplete };
721
- }
722
- /** Turn one deduped, best-ranked session result into its report row. */
723
- function sessionReportEntry(result, context) {
724
- const key = `${result.source}:${result.session_id}`;
725
- const upload = context.uploadByKey.get(key);
726
- // A previously-durable Claude session with no fresh main upload this sync
727
- // (damped / spooled / budget-deferred) reports reused_existing + its prior
728
- // pointer rather than not_uploaded, so the store row never flips.
729
- const priorDurablePointer = result.source === "claude_code"
730
- ? (context.claudePriorDurablePointers.get(result.session_id) ?? null)
731
- : null;
732
- return {
733
- codex_session_id: result.session_id,
734
- source: result.source,
735
- observed_at: context.now.toISOString(),
736
- attribution_state: result.state,
737
- attribution_reason: result.reason,
738
- attribution_score: result.attribution_score,
739
- path_score: result.path_score,
740
- signals: result.signals,
741
- ...(result.content_hash_sha256
742
- ? { session_file_hash_sha256: result.content_hash_sha256 }
743
- : {}),
744
- session_file_byte_size: result.byte_size,
745
- session_file_mtime: result.session_file_mtime,
746
- ...(result.worktree
747
- ? {
748
- repo_fingerprint: result.worktree.repo_fingerprint,
749
- worktree_fingerprint: result.worktree.worktree_fingerprint,
750
- repo_label: result.worktree.repo_label,
751
- branch: result.worktree.branch,
752
- }
753
- : {}),
754
- ...(result.cwd_basename ? { cwd_basename: result.cwd_basename } : {}),
755
- ...(result.cwd_hash ? { cwd_hash: result.cwd_hash } : {}),
756
- ...(upload
757
- ? {
758
- raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
759
- upload_state: upload.upload_state,
760
- // A failed upload names itself; a successful one has nothing to
761
- // explain.
762
- ...(upload.upload_state === "upload_failed"
763
- ? {
764
- upload_reason: upload.reason ?? NO_UPLOAD_ATTEMPT_RECORDED,
765
- }
766
- : {}),
767
- }
768
- : priorDurablePointer
769
- ? {
770
- raw_evidence_pointer_id: priorDurablePointer,
771
- upload_state: "reused_existing",
772
- }
773
- : isRawEvidenceUploadableAttributionState(result.state, result.worktree !== null)
774
- ? {
775
- upload_state: "not_uploaded",
776
- // BLI-2107: `not_uploaded` used to be the branch of last
777
- // resort, recording that nothing happened and never why. It
778
- // now always carries a cause, even when the cause is that we
779
- // have none — a session labelled NO_UPLOAD_ATTEMPT_RECORDED
780
- // is a path that still needs instrumenting, and saying so is
781
- // the point.
782
- upload_reason: context.noUploadReasonByKey.get(key) ??
783
- (context.anySyncIncomplete
784
- ? "sync_incomplete_this_pass"
785
- : NO_UPLOAD_ATTEMPT_RECORDED),
786
- }
787
- : {
788
- // BLI-3272: the upload policy refused this session's
789
- // attribution state. That refusal used to spread `{}` here, so
790
- // the row landed with upload_state NULL and upload_reason NULL
791
- // — a withhold that named nothing, on 1,256 production
792
- // sessions. It is a decision like any other and it says so.
793
- // Any reason the pipeline did record still wins: it is the more
794
- // specific answer.
795
- upload_state: "not_uploaded",
796
- upload_reason: context.noUploadReasonByKey.get(key) ??
797
- notUploadableAttributionStateReason(result.reason),
798
- }),
799
- };
800
- }
801
- /**
802
- * Generalizes the per-session report across sources. Dedupe is per
803
- * `(source, session_id)` so a Codex session and a Claude session that happen to
804
- * share an id are never collapsed. Upload state maps ONLY from the main-file
805
- * outcome (kind `codex_jsonl` / `claude_jsonl`); sidecar outcomes never set a
806
- * session's upload state (D3). Damped Claude sessions report `reused_existing`
807
- * carrying their prior durable pointer.
808
- *
809
- * The pass, named: pick the best-ranked result per session
810
- * (`bestAttributedSessionsByKey`), read back what this sync actually uploaded
811
- * (`sessionUploadOutcomesByKey`), then render one report row per session
812
- * (`sessionReportEntry`).
813
- */
814
- export function buildAgentSessionReport(options) {
815
- const bestByKey = bestAttributedSessionsByKey(options.codexResults, options.claudeResults);
816
- const uploadOutcomes = sessionUploadOutcomesByKey(options.outcomes);
817
- return [...bestByKey.values()].map((result) => sessionReportEntry(result, {
818
- ...uploadOutcomes,
819
- claudePriorDurablePointers: options.claudePriorDurablePointers,
820
- now: options.now,
821
- }));
822
- }
823
857
  /**
824
858
  * Record this sync's Codex session observations into the Codex cursor and
825
859
  * return the stale count. Best-effort by design: a broken local state dir
@@ -988,61 +1022,6 @@ function shouldDampClaudeMain(result, cursor, now) {
988
1022
  Number.isFinite(ageMs) &&
989
1023
  ageMs <= CLAUDE_DAMP_MAX_AGE_MS);
990
1024
  }
991
- function buildAgentSessionSummary(options) {
992
- const sidecarOutcomes = options.outcomes.flatMap((outcome) => outcome.sync.raw_evidence_outcomes.filter((upload) => upload.kind === "claude_jsonl_sidecar"));
993
- const attributedClaude = options.claudeAttribution.results.filter((result) => isLiveRawEvidenceSyncAttribution(result.state, result.reason));
994
- const sidecarsCollected = attributedClaude.reduce((total, result) => total +
995
- result.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length, 0);
996
- const sidecarsSkipped = options.claudeAttribution.results.reduce((total, result) => total +
997
- result.sidecar_files.filter((sidecar) => sidecar.skipped_reason).length, 0);
998
- const codex = {
999
- scanned: options.codexAttribution.scanned_file_count,
1000
- attributed: options.codexAttribution.counts.attributed,
1001
- attributed_fallback: options.codexAttribution.counts.attributed_fallback,
1002
- ambiguous: options.codexAttribution.counts.ambiguous,
1003
- unattributed: options.codexAttribution.counts.unattributed,
1004
- skipped: options.codexAttribution.counts.skipped,
1005
- stale: options.codexStaleCount,
1006
- read_failures: codexAttributionReadFailureCount(options.codexAttribution),
1007
- };
1008
- const claude = {
1009
- scanned: options.claudeAttribution.scanned_session_count,
1010
- attributed: options.claudeAttribution.counts.attributed,
1011
- attributed_fallback: options.claudeAttribution.counts.attributed_fallback,
1012
- ambiguous: options.claudeAttribution.counts.ambiguous,
1013
- unattributed: options.claudeAttribution.counts.unattributed,
1014
- skipped: options.claudeAttribution.counts.skipped,
1015
- stale: options.claudeStaleCount,
1016
- read_failures: claudeAttributionReadFailureCount(options.claudeAttribution),
1017
- sidecars_collected: sidecarsCollected,
1018
- sidecars_uploaded: sidecarOutcomes.filter((upload) => upload.upload_state === "uploaded" ||
1019
- upload.upload_state === "reused_existing").length,
1020
- sidecars_skipped: sidecarsSkipped,
1021
- sidecars_capped: options.claudeAttribution.counts.sidecars_capped,
1022
- sidecars_failed: sidecarOutcomes.filter((upload) => upload.upload_state === "upload_failed").length,
1023
- mains_oversized: options.claudeAttribution.counts.mains_oversized,
1024
- oversized_lines_skipped: options.claudeAttribution.counts.oversized_lines_skipped,
1025
- project_dirs_skipped: options.claudeAttribution.project_dirs_skipped,
1026
- sessions_schema_drift: options.claudeAttribution.counts.sessions_schema_drift,
1027
- growth_damped: options.growthDamped,
1028
- first_run_backfill: options.firstRunBackfill,
1029
- };
1030
- return {
1031
- scanned: codex.scanned,
1032
- attributed: codex.attributed,
1033
- attributed_fallback: codex.attributed_fallback,
1034
- ambiguous: codex.ambiguous,
1035
- unattributed: codex.unattributed,
1036
- skipped: codex.skipped,
1037
- stale: codex.stale,
1038
- report_posted: options.report.posted,
1039
- report_reason: options.report.reason,
1040
- codex,
1041
- claude,
1042
- files_deferred_byte_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_byte_budget, 0),
1043
- files_deferred_object_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_object_budget, 0),
1044
- };
1045
- }
1046
1025
  function emptyClaudeScan() {
1047
1026
  return {
1048
1027
  results: [],