@bli-cockpit/cli 0.2.52 → 0.2.53
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/commands/clean.js +58 -5
- package/dist/commands/doctor.js +7 -2
- package/dist/commands/local-args-collector.js +12 -3
- package/dist/commands/local-help.js +12 -4
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-attribution.js +55 -0
- package/dist/commands/session-sync-failures.js +140 -0
- package/dist/commands/session-sync-health.js +102 -0
- package/dist/commands/session-sync-plan.js +81 -0
- package/dist/commands/session-sync-record.js +279 -0
- package/dist/commands/session-sync-scan.js +209 -0
- package/dist/commands/session-sync-types.js +12 -0
- package/dist/commands/session-sync-upload.js +215 -0
- package/dist/commands/session-sync.js +44 -987
- package/dist/commands/sync-followups.js +47 -2
- package/dist/cursors/raw-evidence-reconcile-cursor.js +132 -0
- package/dist/disk-usage.js +55 -0
- package/dist/evidence-reconcile-client.js +224 -0
- package/package.json +3 -3
|
@@ -1,217 +1,51 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
/**
|
|
2
|
+
* One attributed sync pass: scan both session stores, attribute what they hold,
|
|
3
|
+
* upload it through every target worktree, write down what happened, and judge
|
|
4
|
+
* the tick.
|
|
5
|
+
*
|
|
6
|
+
* This file is the sequence and nothing else. Each step lives in a named
|
|
7
|
+
* sibling, and every public name below is still importable from
|
|
8
|
+
* `./session-sync.js`, which is the address `sync.ts`, `onboard.ts`,
|
|
9
|
+
* `collection-report.ts` and the suites already use:
|
|
10
|
+
*
|
|
11
|
+
* - `session-sync-types.ts` — the shapes each step hands to the next.
|
|
12
|
+
* - `session-sync-plan.ts` — what this tick is bound by before it reads a
|
|
13
|
+
* file: Claude on or off, the approved roots, both cursors, and how wide a
|
|
14
|
+
* retry has to reopen the window.
|
|
15
|
+
* - `session-sync-attribution.ts` — which sessions live sync may collect and
|
|
16
|
+
* which worktrees it collects them through, including the synthesized
|
|
17
|
+
* fallback targets git discovery never produced.
|
|
18
|
+
* - `session-sync-scan.ts` — reading both session stores under the window the
|
|
19
|
+
* plan chose, and settling each source's scan-retry bookkeeping.
|
|
20
|
+
* - `session-sync-upload.ts` — sending each worktree's transcripts under one
|
|
21
|
+
* shared raw-evidence budget, with the Claude growth damper.
|
|
22
|
+
* - `session-sync-record.ts` — the session report, then both cursors, in that
|
|
23
|
+
* order.
|
|
24
|
+
* - `session-sync-failures.ts` — the ONE place a label is written; the
|
|
25
|
+
* class-lock test reads that file (BLI-3551).
|
|
26
|
+
* - `session-sync-health.ts` — the verdict, and the notice a quiet-but-working
|
|
27
|
+
* machine still emits.
|
|
28
|
+
*
|
|
29
|
+
* What a run REPORTS — the per-session rows and the CLI funnel counts — lives
|
|
30
|
+
* next door in `agent-session-report.ts` and is re-exported from here
|
|
31
|
+
* (BLI-3572). A new sibling must also join `scripts/build-public-cli.mjs`
|
|
32
|
+
* `runtimeFiles`, or the repo tests stay green while the packed CLI breaks.
|
|
33
|
+
*/
|
|
9
34
|
import os from "node:os";
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
|
|
18
|
-
import { CLAUDE_CURSOR_FILENAME, countStaleSessions, emptyRawEvidenceCursorState, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor } from "../cursors/raw-evidence-cursor.js";
|
|
19
|
-
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
20
|
-
import { clearSourceRetryFailure, readLocalUploadSpoolState, recordSourceRetryFailure, } from "../spool/local-spool.js";
|
|
21
|
-
import { isLiveRawEvidenceSyncAttribution } from "../raw-evidence-attribution-policy.js";
|
|
35
|
+
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
36
|
+
import { buildAgentSessionReport, buildAgentSessionSummary, } from "./agent-session-report.js";
|
|
37
|
+
import { planSyncFromLocalState } from "./session-sync-plan.js";
|
|
38
|
+
import { scanAndAttributeBothSources } from "./session-sync-scan.js";
|
|
39
|
+
import { syncEveryTargetWorktree } from "./session-sync-upload.js";
|
|
40
|
+
import { reportSessionsAndAdvanceCursors } from "./session-sync-record.js";
|
|
41
|
+
import { decideSyncHealth } from "./session-sync-health.js";
|
|
22
42
|
// What a run reports lives in `agent-session-report.ts`; it is re-exported here
|
|
23
43
|
// because `./session-sync.js` is the address every caller already knows.
|
|
24
44
|
export { ATTRIBUTION_STATE_RANK, buildAgentSessionReport, } from "./agent-session-report.js";
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
* gate is real but its reason is unrecorded, which is a bug in this file, and
|
|
30
|
-
* it should be visible as one instead of blending into the generic bucket.
|
|
31
|
-
*/
|
|
32
|
-
export const SYNC_FAILED_WITHOUT_REASON = "sync_failed_reason_not_recorded";
|
|
33
|
-
const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
|
|
34
|
-
const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
|
|
35
|
-
const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
36
|
-
/**
|
|
37
|
-
* Live sync remains reason-allowlisted even though historical backfill accepts
|
|
38
|
-
* every deterministic fallback state.
|
|
39
|
-
*/
|
|
40
|
-
export function isLiveSyncCollectableAttributionState(state, reason, hasApprovedWorkspace = false) {
|
|
41
|
-
return isLiveRawEvidenceSyncAttribution(state, reason, hasApprovedWorkspace);
|
|
42
|
-
}
|
|
43
|
-
function cursorHasUndurableCollectableSession(cursor) {
|
|
44
|
-
return Object.values(cursor.sessions).some((entry) => liveSyncCursorEntryRequiresRetry(entry));
|
|
45
|
-
}
|
|
46
|
-
export function liveSyncCursorEntryRequiresRetry(entry) {
|
|
47
|
-
return (!entry.uploaded_object_key &&
|
|
48
|
-
(isLiveSyncCollectableAttributionState(entry.state, entry.reason, Boolean(entry.worktree_fingerprint)) || entry.reason === "repo_not_on_disk"));
|
|
49
|
-
}
|
|
50
|
-
function allLocalHistorySinceMinutes(now) {
|
|
51
|
-
// A cutoff just before the Unix epoch is effectively unbounded for Codex and
|
|
52
|
-
// Claude session files while keeping date arithmetic finite and portable.
|
|
53
|
-
return Math.ceil(now.getTime() / 60_000) + 24 * 60;
|
|
54
|
-
}
|
|
55
|
-
/** Whether the spool already recorded a source-scan retry for this source. */
|
|
56
|
-
function sourceScanRetryIsPending(uploadSpool, source) {
|
|
57
|
-
return Boolean(uploadSpool?.pending_source_retries.some((entry) => entry.source === source));
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Does this source have a reason to widen its scan window to all local
|
|
61
|
-
* history instead of the normal live-sync window? Used to be two separately
|
|
62
|
-
* written boolean chains, one per source, that happened to agree on shape by
|
|
63
|
-
* hand — asymmetric to read even though the rule is identical for both
|
|
64
|
-
* (BLI-3394): a legacy spooled upload with no recorded source, a scan-retry
|
|
65
|
-
* the spool already remembers, a spooled upload that names this source, or an
|
|
66
|
-
* undurable session this source's own cursor is still carrying.
|
|
67
|
-
*/
|
|
68
|
-
function sourceHasPendingRetries(source, uploadSpool, legacyRetryPending, sourceScanRetryPending, cursor) {
|
|
69
|
-
return (legacyRetryPending ||
|
|
70
|
-
sourceScanRetryPending ||
|
|
71
|
-
Boolean(uploadSpool?.pending_uploads.some((entry) => entry.retry_sources.includes(source))) ||
|
|
72
|
-
cursorHasUndurableCollectableSession(cursor));
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Scan and attribute Codex sessions for this sync, widening the window to all
|
|
76
|
-
* local history when a retry is pending. Codex has no first-run backfill
|
|
77
|
-
* concept (that is a Claude-only window, see `scanClaudeSessionsForSync`) and
|
|
78
|
-
* is always scanned, unlike Claude which can be disabled entirely.
|
|
79
|
-
*/
|
|
80
|
-
async function scanCodexSessionsForSync(options) {
|
|
81
|
-
const sinceMinutes = options.retryPending
|
|
82
|
-
? options.allHistorySinceMinutes
|
|
83
|
-
: CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES;
|
|
84
|
-
let scan = await scanAndAttributeCodexSessions({
|
|
85
|
-
sessionsDirs: defaultCodexSessionDirs(options.homeDir),
|
|
86
|
-
worktrees: options.worktrees,
|
|
87
|
-
now: options.now,
|
|
88
|
-
sinceMinutes,
|
|
89
|
-
limit: CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT,
|
|
90
|
-
collectionRoots: options.collectionRoots,
|
|
91
|
-
});
|
|
92
|
-
if (scan.session_limit_applied) {
|
|
93
|
-
// Re-run once, bounded by exactly what the first pass discovered, so a
|
|
94
|
-
// capped scan still returns every session it found instead of silently
|
|
95
|
-
// truncating at the window's default limit.
|
|
96
|
-
scan = await scanAndAttributeCodexSessions({
|
|
97
|
-
sessionsDirs: defaultCodexSessionDirs(options.homeDir),
|
|
98
|
-
worktrees: options.worktrees,
|
|
99
|
-
now: options.now,
|
|
100
|
-
sinceMinutes,
|
|
101
|
-
limit: scan.discovered_file_count,
|
|
102
|
-
collectionRoots: options.collectionRoots,
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
return scan;
|
|
106
|
-
}
|
|
107
|
-
/**
|
|
108
|
-
* Scan and attribute Claude sessions for this sync — the Claude counterpart to
|
|
109
|
-
* `scanCodexSessionsForSync`. Disabled collection returns an empty scan
|
|
110
|
-
* up front; a first sync (no cursor yet) widens the window to 14 days instead
|
|
111
|
-
* of the normal 24h so it captures retroactive history.
|
|
112
|
-
*/
|
|
113
|
-
async function scanClaudeSessionsForSync(options) {
|
|
114
|
-
if (!options.claudeEnabled)
|
|
115
|
-
return emptyClaudeScan();
|
|
116
|
-
const sinceMinutes = options.retryPending
|
|
117
|
-
? options.allHistorySinceMinutes
|
|
118
|
-
: options.firstRunBackfill
|
|
119
|
-
? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
|
|
120
|
-
: undefined;
|
|
121
|
-
const projectsDir = path.join(options.homeDir, ".claude", "projects");
|
|
122
|
-
let scan = await scanAndAttributeClaudeSessions({
|
|
123
|
-
projectsDir,
|
|
124
|
-
worktrees: options.worktrees,
|
|
125
|
-
now: options.now,
|
|
126
|
-
collectionRoots: options.collectionRoots,
|
|
127
|
-
sinceMinutes,
|
|
128
|
-
});
|
|
129
|
-
if (scan.session_limit_applied) {
|
|
130
|
-
scan = await scanAndAttributeClaudeSessions({
|
|
131
|
-
projectsDir,
|
|
132
|
-
worktrees: options.worktrees,
|
|
133
|
-
now: options.now,
|
|
134
|
-
collectionRoots: options.collectionRoots,
|
|
135
|
-
sinceMinutes,
|
|
136
|
-
limit: scan.discovered_session_count,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
return scan;
|
|
140
|
-
}
|
|
141
|
-
export function sourceScanRetryReason(source, scan) {
|
|
142
|
-
const reasons = new Set();
|
|
143
|
-
const failure = sourceScanFailureReason(source, scan);
|
|
144
|
-
if (failure)
|
|
145
|
-
reasons.add(failure);
|
|
146
|
-
// Kept HERE and nowhere else (BLI-3551): a repo that is not on disk is a
|
|
147
|
-
// reason to widen the next scan window, because the transcript fallback can
|
|
148
|
-
// still attribute it. It is not a reason to call this sync failed — see
|
|
149
|
-
// `sourceScanFailureReason`.
|
|
150
|
-
if (scan.results.some((result) => result.reason === "repo_not_on_disk")) {
|
|
151
|
-
reasons.add("repo_not_on_disk");
|
|
152
|
-
}
|
|
153
|
-
return reasons.size > 0 ? [...reasons].sort().join(",") : null;
|
|
154
|
-
}
|
|
155
|
-
/**
|
|
156
|
-
* The part of the scan outcome that is a genuine FAILURE: the session store
|
|
157
|
-
* itself could not be read, so sessions that exist were not seen.
|
|
158
|
-
*
|
|
159
|
-
* Split from {@link sourceScanRetryReason} in BLI-3551. The two used to be one
|
|
160
|
-
* function, so `repo_not_on_disk` — a label the attribution umbrella finding
|
|
161
|
-
* already established is not a defect (nothing was deleted; the transcript
|
|
162
|
-
* names a path git no longer tracks) — failed the sync on every tick for three
|
|
163
|
-
* operators. A retry hint and a failure are different claims.
|
|
164
|
-
*/
|
|
165
|
-
export function sourceScanFailureReason(source, scan) {
|
|
166
|
-
const readFailureCount = source === "codex"
|
|
167
|
-
? codexAttributionReadFailureCount(scan)
|
|
168
|
-
: claudeAttributionReadFailureCount(scan);
|
|
169
|
-
return readFailureCount > 0 ? `${source}_session_store_read_failed` : null;
|
|
170
|
-
}
|
|
171
|
-
async function reconcileSourceScanRetry(options) {
|
|
172
|
-
if (options.reason) {
|
|
173
|
-
await recordSourceRetryFailure(options.paths, {
|
|
174
|
-
source: options.source,
|
|
175
|
-
reason: options.reason,
|
|
176
|
-
attemptedAt: options.attemptedAt,
|
|
177
|
-
});
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
if (options.pendingBefore) {
|
|
181
|
-
await clearSourceRetryFailure(options.paths, options.source, options.attemptedAt);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
export function matchesLiveSyncWorktree(result, target) {
|
|
185
|
-
return (isLiveRawEvidenceSyncAttribution(result.state, result.reason, result.worktree !== null) &&
|
|
186
|
-
result.worktree !== null &&
|
|
187
|
-
liveSyncTargetKey(result.worktree) === liveSyncTargetKey(target));
|
|
188
|
-
}
|
|
189
|
-
function liveSyncTargetKey(worktree) {
|
|
190
|
-
return `${worktree.repo_fingerprint}:${worktree.worktree_fingerprint}`;
|
|
191
|
-
}
|
|
192
|
-
/**
|
|
193
|
-
* Folder and deleted-repo fallbacks can intentionally synthesize a worktree
|
|
194
|
-
* identity that is not present in git discovery. Include those identities as
|
|
195
|
-
* sync targets so a wrapper-root session is not observed and then stranded.
|
|
196
|
-
*/
|
|
197
|
-
export function liveSyncTargetWorktrees(discovered, results) {
|
|
198
|
-
const targets = [...discovered];
|
|
199
|
-
const seen = new Set(discovered.map(liveSyncTargetKey));
|
|
200
|
-
for (const result of results) {
|
|
201
|
-
const targetKey = result.worktree
|
|
202
|
-
? liveSyncTargetKey(result.worktree)
|
|
203
|
-
: null;
|
|
204
|
-
if (!isLiveSyncCollectableAttributionState(result.state, result.reason, result.worktree !== null) ||
|
|
205
|
-
!result.worktree ||
|
|
206
|
-
!targetKey ||
|
|
207
|
-
seen.has(targetKey)) {
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
210
|
-
seen.add(targetKey);
|
|
211
|
-
targets.push(result.worktree);
|
|
212
|
-
}
|
|
213
|
-
return targets;
|
|
214
|
-
}
|
|
45
|
+
export { isLiveSyncCollectableAttributionState, liveSyncCursorEntryRequiresRetry, liveSyncTargetWorktrees, matchesLiveSyncWorktree, } from "./session-sync-attribution.js";
|
|
46
|
+
export { sourceScanFailureReason, sourceScanRetryReason, } from "./session-sync-scan.js";
|
|
47
|
+
export { SYNC_FAILED_WITHOUT_REASON } from "./session-sync-failures.js";
|
|
48
|
+
export { nothingInRootCount } from "./session-sync-health.js";
|
|
215
49
|
/**
|
|
216
50
|
* Shared dual-source sync orchestration for single-repo and parent-folder
|
|
217
51
|
* modes. Codex AND Claude Code sessions are scanned and attributed once across
|
|
@@ -295,781 +129,4 @@ export async function runAttributedWorktreeSync(options) {
|
|
|
295
129
|
claudeAttribution: scan.claudeAttribution,
|
|
296
130
|
summary,
|
|
297
131
|
};
|
|
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);
|
|
309
|
-
const claudeEnabled = config?.collect_claude_jsonl !== false;
|
|
310
|
-
const collectionRoots = normalizeCollectionRoots(options.approvedCollectionRoots ?? config?.default_repo_paths ?? []);
|
|
311
|
-
const [codexCursorBefore, claudeCursorBefore, uploadSpool] = await Promise.all([
|
|
312
|
-
readRawEvidenceCursor(options.paths).catch(() => emptyRawEvidenceCursorState()),
|
|
313
|
-
claudeEnabled
|
|
314
|
-
? readRawEvidenceCursor(options.paths, {
|
|
315
|
-
filename: CLAUDE_CURSOR_FILENAME,
|
|
316
|
-
}).catch(() => emptyRawEvidenceCursorState())
|
|
317
|
-
: Promise.resolve(emptyRawEvidenceCursorState()),
|
|
318
|
-
readLocalUploadSpoolState(options.paths).catch(() => null),
|
|
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.
|
|
322
|
-
const legacyRetryPending = Boolean(uploadSpool?.pending_uploads.some((entry) => entry.raw_evidence_file_count > 0 && entry.retry_sources.length === 0));
|
|
323
|
-
const codexSourceRetryPending = sourceScanRetryIsPending(uploadSpool, "codex");
|
|
324
|
-
const claudeSourceRetryPending = sourceScanRetryIsPending(uploadSpool, "claude_code");
|
|
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;
|
|
348
|
-
const codexAttribution = await scanCodexSessionsForSync({
|
|
349
|
-
homeDir: options.homeDir,
|
|
350
|
-
worktrees: options.worktrees,
|
|
351
|
-
now: options.now,
|
|
352
|
-
collectionRoots: plan.collectionRoots,
|
|
353
|
-
retryPending: plan.codexRetryPending,
|
|
354
|
-
allHistorySinceMinutes: plan.allHistorySinceMinutes,
|
|
355
|
-
});
|
|
356
|
-
// First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
|
|
357
|
-
// days so the first sync captures retroactive history instead of only 24h.
|
|
358
|
-
const claudeCursorExists = await fileExists(path.join(options.paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
|
|
359
|
-
const firstRunBackfill = plan.claudeEnabled && !claudeCursorExists;
|
|
360
|
-
const claudeAttribution = await scanClaudeSessionsForSync({
|
|
361
|
-
claudeEnabled: plan.claudeEnabled,
|
|
362
|
-
homeDir: options.homeDir,
|
|
363
|
-
worktrees: options.worktrees,
|
|
364
|
-
now: options.now,
|
|
365
|
-
collectionRoots: plan.collectionRoots,
|
|
366
|
-
retryPending: plan.claudeRetryPending,
|
|
367
|
-
allHistorySinceMinutes: plan.allHistorySinceMinutes,
|
|
368
|
-
firstRunBackfill,
|
|
369
|
-
});
|
|
370
|
-
await reconcileSourceScanRetry({
|
|
371
|
-
paths: options.paths,
|
|
372
|
-
source: "codex",
|
|
373
|
-
attemptedAt: options.now.toISOString(),
|
|
374
|
-
pendingBefore: plan.codexSourceRetryPending,
|
|
375
|
-
reason: sourceScanRetryReason("codex", codexAttribution),
|
|
376
|
-
});
|
|
377
|
-
if (plan.claudeEnabled) {
|
|
378
|
-
await reconcileSourceScanRetry({
|
|
379
|
-
paths: options.paths,
|
|
380
|
-
source: "claude_code",
|
|
381
|
-
attemptedAt: options.now.toISOString(),
|
|
382
|
-
pendingBefore: plan.claudeSourceRetryPending,
|
|
383
|
-
reason: sourceScanRetryReason("claude_code", claudeAttribution),
|
|
384
|
-
});
|
|
385
|
-
}
|
|
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,
|
|
400
|
-
]);
|
|
401
|
-
const discoveredWorktreeKeys = new Set(run.worktrees.map(liveSyncTargetKey));
|
|
402
|
-
// sessionId -> prior durable pointer for damped sessions (drives the
|
|
403
|
-
// growth_damped count + the skip_main decision).
|
|
404
|
-
const dampedClaudePointers = new Map();
|
|
405
|
-
const rawEvidenceBudget = {
|
|
406
|
-
remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
|
|
407
|
-
remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
|
|
408
|
-
};
|
|
409
|
-
const outcomes = [];
|
|
410
|
-
let everyWorktreeUploaded = true;
|
|
411
|
-
for (const worktree of syncWorktrees) {
|
|
412
|
-
const outcome = await syncOneWorktree({
|
|
413
|
-
run,
|
|
414
|
-
now: options.now,
|
|
415
|
-
plan: options.plan,
|
|
416
|
-
scan,
|
|
417
|
-
worktree,
|
|
418
|
-
syncWorktrees,
|
|
419
|
-
rawEvidenceBudget,
|
|
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();
|
|
486
|
-
sync = await syncLocalAmbientEnvelope(syncOptions);
|
|
487
|
-
}
|
|
488
|
-
else {
|
|
489
|
-
throw error;
|
|
490
|
-
}
|
|
491
|
-
}
|
|
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,
|
|
507
|
-
});
|
|
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) {
|
|
632
|
-
const codexStaleCount = await recordCodexSessionObservationsForSync({
|
|
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,
|
|
639
|
-
});
|
|
640
|
-
const claudeStaleCount = await recordClaudeSessionObservationsForSync({
|
|
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,
|
|
648
|
-
});
|
|
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 {
|
|
658
|
-
posted: false,
|
|
659
|
-
reason: options.sessionCount === 0
|
|
660
|
-
? "no_sessions_observed"
|
|
661
|
-
: "no_successful_sync",
|
|
662
|
-
};
|
|
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,
|
|
689
|
-
outcomes,
|
|
690
|
-
reportPosted: report.posted,
|
|
691
|
-
reportReason: report.reason,
|
|
692
|
-
});
|
|
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,
|
|
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) {
|
|
732
|
-
for (const { worktree, sync } of outcomes) {
|
|
733
|
-
if (sync.status !== "uploaded") {
|
|
734
|
-
ledger.add(sync.status === "spooled" && sync.failure_reason
|
|
735
|
-
? {
|
|
736
|
-
label: sync.failure_class,
|
|
737
|
-
rendered: `${worktree.worktree_label}:${sync.failure_reason}`,
|
|
738
|
-
http_status: sync.failure_http_status,
|
|
739
|
-
}
|
|
740
|
-
: {
|
|
741
|
-
label: "upload_not_completed",
|
|
742
|
-
rendered: `${worktree.worktree_label}:upload_${sync.status}`,
|
|
743
|
-
});
|
|
744
|
-
}
|
|
745
|
-
for (const reason of sync.raw_evidence_failure_reasons ?? []) {
|
|
746
|
-
ledger.add({
|
|
747
|
-
label: "raw_evidence_upload_failed",
|
|
748
|
-
rendered: `raw_evidence:${reason}`,
|
|
749
|
-
});
|
|
750
|
-
}
|
|
751
|
-
for (const reason of sync.raw_evidence_retry_reasons ?? []) {
|
|
752
|
-
ledger.add({
|
|
753
|
-
label: "raw_evidence_retry_required",
|
|
754
|
-
rendered: `raw_evidence_retry:${reason}`,
|
|
755
|
-
});
|
|
756
|
-
}
|
|
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");
|
|
759
|
-
}
|
|
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");
|
|
777
|
-
const codexScanFailure = sourceScanFailureReason("codex", codexAttribution);
|
|
778
|
-
if (codexScanFailure) {
|
|
779
|
-
ledger.add({
|
|
780
|
-
label: "codex_scan_read_failed",
|
|
781
|
-
rendered: `codex_scan:${codexScanFailure}`,
|
|
782
|
-
});
|
|
783
|
-
}
|
|
784
|
-
const claudeScanFailure = sourceScanFailureReason("claude_code", claudeAttribution);
|
|
785
|
-
if (claudeScanFailure) {
|
|
786
|
-
ledger.add({
|
|
787
|
-
label: "claude_scan_read_failed",
|
|
788
|
-
rendered: `claude_scan:${claudeScanFailure}`,
|
|
789
|
-
});
|
|
790
|
-
}
|
|
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",
|
|
800
|
-
sessions_outside_root: sessionsOutsideRoot,
|
|
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)),
|
|
820
|
-
};
|
|
821
|
-
}
|
|
822
|
-
/**
|
|
823
|
-
* Reasons attribution gives when a session's working directory is not inside
|
|
824
|
-
* any approved collection root.
|
|
825
|
-
*
|
|
826
|
-
* Exact labels, not a pattern — the same discipline the classifier now follows.
|
|
827
|
-
* `attribution-core.ts` writes both of these and nothing else means
|
|
828
|
-
* "outside the boundary".
|
|
829
|
-
*/
|
|
830
|
-
const OUTSIDE_APPROVED_ROOT_REASONS = new Set([
|
|
831
|
-
"cwd_outside_scanned_worktrees",
|
|
832
|
-
"no_matching_worktree_signals",
|
|
833
|
-
]);
|
|
834
|
-
/**
|
|
835
|
-
* How many observed sessions were outside the approved roots, when that
|
|
836
|
-
* accounts for ALL of them and nothing else went wrong — otherwise `null`.
|
|
837
|
-
*
|
|
838
|
-
* Deliberately narrow. It requires that no worktree was synced at all (so no
|
|
839
|
-
* upload could have succeeded or failed), that every session observed this tick
|
|
840
|
-
* names an outside-the-root reason, and that the unposted report is the
|
|
841
|
-
* `no_successful_sync` shape rather than a spooled report that failed to flush.
|
|
842
|
-
* Anything else keeps its failure.
|
|
843
|
-
*/
|
|
844
|
-
export function nothingInRootCount(options) {
|
|
845
|
-
if (options.reportPosted)
|
|
846
|
-
return null;
|
|
847
|
-
if (options.reportReason !== "no_successful_sync")
|
|
848
|
-
return null;
|
|
849
|
-
if (options.outcomes.length > 0)
|
|
850
|
-
return null;
|
|
851
|
-
if (options.sessionCount === 0)
|
|
852
|
-
return null;
|
|
853
|
-
return options.outsideRootCount === options.sessionCount
|
|
854
|
-
? options.outsideRootCount
|
|
855
|
-
: null;
|
|
856
|
-
}
|
|
857
|
-
/**
|
|
858
|
-
* Record this sync's Codex session observations into the Codex cursor and
|
|
859
|
-
* return the stale count. Best-effort by design: a broken local state dir
|
|
860
|
-
* must not turn an already-completed sync into a CLI crash, so a failure here
|
|
861
|
-
* only logs — the stale count simply keeps whatever value it had when the
|
|
862
|
-
* failure happened (0 if the read itself failed).
|
|
863
|
-
*
|
|
864
|
-
* Reads the cursor fresh from disk right before recording rather than reusing
|
|
865
|
-
* `priorCursor`, so a concurrent writer's update is not clobbered. Claude's
|
|
866
|
-
* counterpart does not do this extra read (see
|
|
867
|
-
* `recordClaudeSessionObservationsForSync`) — a real, intentional asymmetry
|
|
868
|
-
* kept as-is rather than forced to match.
|
|
869
|
-
*/
|
|
870
|
-
async function recordCodexSessionObservationsForSync(options) {
|
|
871
|
-
let staleCount = 0;
|
|
872
|
-
try {
|
|
873
|
-
const codexCursor = await readRawEvidenceCursor(options.paths);
|
|
874
|
-
staleCount = recordSourceObservations({
|
|
875
|
-
cursor: codexCursor,
|
|
876
|
-
results: options.codexAttribution.results,
|
|
877
|
-
sessions: options.sessions,
|
|
878
|
-
source: "codex",
|
|
879
|
-
sessionIdOf: (result) => result.codex_session_id,
|
|
880
|
-
now: options.now,
|
|
881
|
-
priorCursor: options.priorCursor,
|
|
882
|
-
terminalizeMissingUndurable: options.codexRetryPending &&
|
|
883
|
-
codexAttributionReadFailureCount(options.codexAttribution) === 0,
|
|
884
|
-
});
|
|
885
|
-
codexCursor.updated_at = options.now.toISOString();
|
|
886
|
-
await writeRawEvidenceCursor(options.paths, codexCursor);
|
|
887
|
-
}
|
|
888
|
-
catch (error) {
|
|
889
|
-
// Best-effort: stale counts read 0 and observations re-record next sync —
|
|
890
|
-
// which is fine ONCE. A cursor write that keeps failing means the sessions
|
|
891
|
-
// cursor never advances, every sync re-does the same work, and the only
|
|
892
|
-
// symptom is a stale count that is permanently zero (BLI-3238).
|
|
893
|
-
console.error("[session-sync] Codex session observations were not recorded", JSON.stringify({
|
|
894
|
-
reason: "session_cursor_update_failed",
|
|
895
|
-
source: "codex",
|
|
896
|
-
session_count: options.sessions.length,
|
|
897
|
-
...describeError(error),
|
|
898
|
-
}));
|
|
899
|
-
}
|
|
900
|
-
return staleCount;
|
|
901
|
-
}
|
|
902
|
-
/**
|
|
903
|
-
* Record this sync's Claude session observations into the Claude cursor and
|
|
904
|
-
* return the stale count — the Claude counterpart to
|
|
905
|
-
* `recordCodexSessionObservationsForSync`. A no-op returning 0 when Claude
|
|
906
|
-
* collection is disabled, matching the source-scan side's own disabled state.
|
|
907
|
-
*/
|
|
908
|
-
async function recordClaudeSessionObservationsForSync(options) {
|
|
909
|
-
if (!options.claudeEnabled)
|
|
910
|
-
return 0;
|
|
911
|
-
let staleCount = 0;
|
|
912
|
-
try {
|
|
913
|
-
staleCount = recordSourceObservations({
|
|
914
|
-
cursor: options.claudeCursorBefore,
|
|
915
|
-
results: options.claudeAttribution.results,
|
|
916
|
-
sessions: options.sessions,
|
|
917
|
-
source: "claude_code",
|
|
918
|
-
sessionIdOf: (result) => result.claude_session_id,
|
|
919
|
-
now: options.now,
|
|
920
|
-
priorCursor: options.claudeCursorBefore,
|
|
921
|
-
terminalizeMissingUndurable: options.claudeRetryPending &&
|
|
922
|
-
claudeAttributionReadFailureCount(options.claudeAttribution) === 0,
|
|
923
|
-
});
|
|
924
|
-
options.claudeCursorBefore.updated_at = options.now.toISOString();
|
|
925
|
-
await writeRawEvidenceCursor(options.paths, options.claudeCursorBefore, {
|
|
926
|
-
filename: CLAUDE_CURSOR_FILENAME,
|
|
927
|
-
sessionsOnly: true,
|
|
928
|
-
});
|
|
929
|
-
}
|
|
930
|
-
catch (error) {
|
|
931
|
-
// Best-effort: a broken Claude cursor must not fail the sync. It must
|
|
932
|
-
// still say it is broken — otherwise the Claude half of collection
|
|
933
|
-
// quietly repeats itself forever.
|
|
934
|
-
console.error("[session-sync] Claude session observations were not recorded", JSON.stringify({
|
|
935
|
-
reason: "session_cursor_update_failed",
|
|
936
|
-
source: "claude_code",
|
|
937
|
-
session_count: options.sessions.length,
|
|
938
|
-
...describeError(error),
|
|
939
|
-
}));
|
|
940
|
-
}
|
|
941
|
-
return staleCount;
|
|
942
|
-
}
|
|
943
|
-
/**
|
|
944
|
-
* Records per-source session observations into its cursor and returns the stale
|
|
945
|
-
* count. Damped/reused Claude sessions carry forward their prior upload
|
|
946
|
-
* timestamp + byte size so the 6h damping window keeps counting from the real
|
|
947
|
-
* last upload (otherwise a slowly-growing file would never re-upload — D21).
|
|
948
|
-
*/
|
|
949
|
-
function recordSourceObservations(options) {
|
|
950
|
-
const seen = new Set(options.results.map((result) => options.sessionIdOf(result)));
|
|
951
|
-
if (options.terminalizeMissingUndurable) {
|
|
952
|
-
for (const [sessionId, entry] of Object.entries(options.cursor.sessions)) {
|
|
953
|
-
if (seen.has(sessionId) ||
|
|
954
|
-
!liveSyncCursorEntryRequiresRetry(entry)) {
|
|
955
|
-
continue;
|
|
956
|
-
}
|
|
957
|
-
options.cursor.sessions[sessionId] = {
|
|
958
|
-
...entry,
|
|
959
|
-
state: "skipped",
|
|
960
|
-
reason: "retry_source_missing",
|
|
961
|
-
last_seen_at: options.now.toISOString(),
|
|
962
|
-
};
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
const stale = countStaleSessions(options.cursor, seen);
|
|
966
|
-
for (const result of options.results) {
|
|
967
|
-
const sessionId = options.sessionIdOf(result);
|
|
968
|
-
const reported = options.sessions.find((session) => session.source === options.source &&
|
|
969
|
-
session.codex_session_id === sessionId);
|
|
970
|
-
const uploadedThisSync = reported?.upload_state === "uploaded";
|
|
971
|
-
const durableThisSync = reported?.upload_state === "uploaded" ||
|
|
972
|
-
reported?.upload_state === "reused_existing";
|
|
973
|
-
const prior = options.priorCursor?.sessions[sessionId];
|
|
974
|
-
// D21 / no-flip-flop: a sync that is spooled (offline), budget-deferred, or
|
|
975
|
-
// upload-failed for a session that was ALREADY durable must NOT wipe the
|
|
976
|
-
// prior durable state — otherwise damping is forfeited forever and the
|
|
977
|
-
// store row oscillates uploaded -> not_uploaded hourly. Carry the prior
|
|
978
|
-
// durable pointer/timestamp/size forward unless we durably uploaded anew.
|
|
979
|
-
const uploadedObjectKey = durableThisSync
|
|
980
|
-
? (reported?.raw_evidence_pointer_id ?? prior?.uploaded_object_key ?? null)
|
|
981
|
-
: (prior?.uploaded_object_key ?? null);
|
|
982
|
-
const uploadedAt = uploadedThisSync
|
|
983
|
-
? options.now.toISOString()
|
|
984
|
-
: (prior?.uploaded_at ?? (durableThisSync ? options.now.toISOString() : null));
|
|
985
|
-
const uploadedByteSize = uploadedThisSync
|
|
986
|
-
? result.byte_size
|
|
987
|
-
: (prior?.uploaded_byte_size ??
|
|
988
|
-
(durableThisSync ? result.byte_size : null));
|
|
989
|
-
const entry = {
|
|
990
|
-
file_hash_sha256: result.content_hash_sha256,
|
|
991
|
-
file_mtime_ms: result.session_file_mtime_ms,
|
|
992
|
-
byte_size: result.byte_size,
|
|
993
|
-
// Durable byte offset reflects how many bytes are durable remotely (the
|
|
994
|
-
// last uploaded size), not the current file size.
|
|
995
|
-
byte_offset: uploadedByteSize ?? 0,
|
|
996
|
-
state: result.state,
|
|
997
|
-
reason: result.reason,
|
|
998
|
-
worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
|
|
999
|
-
uploaded_object_key: uploadedObjectKey,
|
|
1000
|
-
uploaded_at: uploadedAt,
|
|
1001
|
-
uploaded_byte_size: uploadedByteSize,
|
|
1002
|
-
last_seen_at: options.now.toISOString(),
|
|
1003
|
-
};
|
|
1004
|
-
recordSessionObservation(options.cursor, sessionId, entry);
|
|
1005
|
-
}
|
|
1006
|
-
return stale;
|
|
1007
|
-
}
|
|
1008
|
-
function shouldDampClaudeMain(result, cursor, now) {
|
|
1009
|
-
const entry = cursor.sessions[result.claude_session_id];
|
|
1010
|
-
if (!entry ||
|
|
1011
|
-
!entry.uploaded_object_key ||
|
|
1012
|
-
!entry.uploaded_at ||
|
|
1013
|
-
entry.uploaded_byte_size == null) {
|
|
1014
|
-
return false;
|
|
1015
|
-
}
|
|
1016
|
-
const grew = result.byte_size > entry.uploaded_byte_size;
|
|
1017
|
-
if (!grew)
|
|
1018
|
-
return false; // unchanged content reuses via the object cursor
|
|
1019
|
-
const growth = result.byte_size - entry.uploaded_byte_size;
|
|
1020
|
-
const ageMs = now.getTime() - Date.parse(entry.uploaded_at);
|
|
1021
|
-
return (growth <= CLAUDE_DAMP_GROWTH_BYTES &&
|
|
1022
|
-
Number.isFinite(ageMs) &&
|
|
1023
|
-
ageMs <= CLAUDE_DAMP_MAX_AGE_MS);
|
|
1024
|
-
}
|
|
1025
|
-
function emptyClaudeScan() {
|
|
1026
|
-
return {
|
|
1027
|
-
results: [],
|
|
1028
|
-
discovered_session_count: 0,
|
|
1029
|
-
scanned_session_count: 0,
|
|
1030
|
-
since_minutes: 0,
|
|
1031
|
-
session_limit: 0,
|
|
1032
|
-
session_limit_applied: false,
|
|
1033
|
-
max_file_bytes: 0,
|
|
1034
|
-
max_sidecar_files: 0,
|
|
1035
|
-
max_line_buffer_bytes: 0,
|
|
1036
|
-
project_dirs_skipped: 0,
|
|
1037
|
-
project_dir_read_failed_count: 0,
|
|
1038
|
-
session_stat_failed_count: 0,
|
|
1039
|
-
sidecar_dir_read_failed_count: 0,
|
|
1040
|
-
sidecar_stat_failed_count: 0,
|
|
1041
|
-
disabled_reason: "claude_collection_disabled_by_config",
|
|
1042
|
-
counts: {
|
|
1043
|
-
attributed: 0,
|
|
1044
|
-
attributed_fallback: 0,
|
|
1045
|
-
ambiguous: 0,
|
|
1046
|
-
unattributed: 0,
|
|
1047
|
-
skipped: 0,
|
|
1048
|
-
mains_oversized: 0,
|
|
1049
|
-
oversized_lines_skipped: 0,
|
|
1050
|
-
sessions_schema_drift: 0,
|
|
1051
|
-
sidecars_capped: 0,
|
|
1052
|
-
},
|
|
1053
|
-
};
|
|
1054
|
-
}
|
|
1055
|
-
function worktreeInventoryForRepo(current, worktrees) {
|
|
1056
|
-
return worktrees
|
|
1057
|
-
.filter((worktree) => worktree.repo_fingerprint
|
|
1058
|
-
? worktree.repo_fingerprint === current.repo_fingerprint
|
|
1059
|
-
: worktree.repo_label === current.repo_label)
|
|
1060
|
-
.map((worktree) => ({
|
|
1061
|
-
repo: worktree.repo_root,
|
|
1062
|
-
repo_label: worktree.repo_label,
|
|
1063
|
-
repo_fingerprint: worktree.repo_fingerprint,
|
|
1064
|
-
repo_origin_url: worktree.repo_origin_url ?? undefined,
|
|
1065
|
-
head_sha: worktree.head_sha ?? undefined,
|
|
1066
|
-
worktree_label: worktree.worktree_label,
|
|
1067
|
-
worktree_fingerprint: worktree.worktree_fingerprint,
|
|
1068
|
-
worktree_is_primary: worktree.worktree_is_primary,
|
|
1069
|
-
branch: worktree.branch,
|
|
1070
|
-
}));
|
|
1071
|
-
}
|
|
1072
|
-
async function fileExists(filePath) {
|
|
1073
|
-
const { stat } = await import("node:fs/promises");
|
|
1074
|
-
return stat(filePath).then(() => true, () => false);
|
|
1075
132
|
}
|