@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.
- package/dist/adapters/raw-evidence-attribution-gaps.js +133 -0
- package/dist/adapters/raw-evidence.js +360 -349
- package/dist/autostart-contract.js +79 -0
- package/dist/autostart-darwin-plist.js +265 -0
- package/dist/autostart-darwin.js +171 -0
- package/dist/autostart-windows-scripts.js +310 -0
- package/dist/autostart-windows-task-xml.js +260 -0
- package/dist/autostart-windows.js +237 -0
- package/dist/autostart-xml.js +23 -0
- package/dist/autostart.js +35 -1148
- package/dist/commands/agent-rules-command.js +55 -0
- package/dist/commands/agent-session-report.js +290 -0
- package/dist/commands/analyze.js +131 -0
- package/dist/commands/autostart-command.js +105 -0
- package/dist/commands/backfill.js +824 -551
- package/dist/commands/cli-io.js +13 -0
- package/dist/commands/jarvis.js +179 -3
- package/dist/commands/local-arg-values.js +169 -0
- package/dist/commands/local-args-collector.js +578 -0
- package/dist/commands/local-args-tower.js +870 -0
- package/dist/commands/local-args.js +8 -1549
- package/dist/commands/local-help.js +11 -3
- package/dist/commands/local.js +18 -1786
- package/dist/commands/login.js +53 -0
- package/dist/commands/logout.js +66 -0
- package/dist/commands/onboard-receipts.js +66 -0
- package/dist/commands/onboard-report.js +274 -0
- package/dist/commands/onboard.js +449 -0
- package/dist/commands/ops-render.js +36 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/serve.js +13 -0
- package/dist/commands/session-sync.js +513 -534
- package/dist/commands/settings-render.js +28 -0
- package/dist/commands/settings.js +66 -2
- package/dist/commands/start.js +47 -0
- package/dist/commands/sync-followups.js +203 -0
- package/dist/commands/sync.js +381 -0
- package/dist/tower-stream.js +20 -4
- 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
|
-
//
|
|
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 {
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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(
|
|
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
|
|
410
|
+
let everyWorktreeUploaded = true;
|
|
316
411
|
for (const worktree of syncWorktrees) {
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
:
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
376
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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:
|
|
659
|
+
reason: options.sessionCount === 0
|
|
660
|
+
? "no_sessions_observed"
|
|
661
|
+
: "no_successful_sync",
|
|
454
662
|
};
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
firstRunBackfill,
|
|
462
|
-
growthDamped: dampedClaudePointers.size,
|
|
463
|
-
report,
|
|
690
|
+
reportPosted: report.posted,
|
|
691
|
+
reportReason: report.reason,
|
|
464
692
|
});
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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({
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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: [],
|