@bli-cockpit/cli 0.2.51 → 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.
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.51");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.53");
19
19
  return 0;
20
20
  }
21
21
 
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Which sessions live sync may collect, and which worktrees it collects them
3
+ * through.
4
+ *
5
+ * One place answers all four questions the rest of the pass asks about an
6
+ * attribution result: is this state collectable at all, does this result belong
7
+ * to the worktree currently syncing, which worktrees are targets this tick, and
8
+ * does a cursor entry still owe a retry. Live sync is reason-ALLOWLISTED where
9
+ * historical backfill is not, so these answers are deliberately narrower than
10
+ * the shared policy in `raw-evidence-attribution-policy.ts` and must not be
11
+ * re-derived anywhere else.
12
+ */
13
+ import { isLiveRawEvidenceSyncAttribution } from "../raw-evidence-attribution-policy.js";
14
+ /**
15
+ * Live sync remains reason-allowlisted even though historical backfill accepts
16
+ * every deterministic fallback state.
17
+ */
18
+ export function isLiveSyncCollectableAttributionState(state, reason, hasApprovedWorkspace = false) {
19
+ return isLiveRawEvidenceSyncAttribution(state, reason, hasApprovedWorkspace);
20
+ }
21
+ export function liveSyncCursorEntryRequiresRetry(entry) {
22
+ return (!entry.uploaded_object_key &&
23
+ (isLiveSyncCollectableAttributionState(entry.state, entry.reason, Boolean(entry.worktree_fingerprint)) || entry.reason === "repo_not_on_disk"));
24
+ }
25
+ export function matchesLiveSyncWorktree(result, target) {
26
+ return (isLiveRawEvidenceSyncAttribution(result.state, result.reason, result.worktree !== null) &&
27
+ result.worktree !== null &&
28
+ liveSyncTargetKey(result.worktree) === liveSyncTargetKey(target));
29
+ }
30
+ export function liveSyncTargetKey(worktree) {
31
+ return `${worktree.repo_fingerprint}:${worktree.worktree_fingerprint}`;
32
+ }
33
+ /**
34
+ * Folder and deleted-repo fallbacks can intentionally synthesize a worktree
35
+ * identity that is not present in git discovery. Include those identities as
36
+ * sync targets so a wrapper-root session is not observed and then stranded.
37
+ */
38
+ export function liveSyncTargetWorktrees(discovered, results) {
39
+ const targets = [...discovered];
40
+ const seen = new Set(discovered.map(liveSyncTargetKey));
41
+ for (const result of results) {
42
+ const targetKey = result.worktree
43
+ ? liveSyncTargetKey(result.worktree)
44
+ : null;
45
+ if (!isLiveSyncCollectableAttributionState(result.state, result.reason, result.worktree !== null) ||
46
+ !result.worktree ||
47
+ !targetKey ||
48
+ seen.has(targetKey)) {
49
+ continue;
50
+ }
51
+ seen.add(targetKey);
52
+ targets.push(result.worktree);
53
+ }
54
+ return targets;
55
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The ONE place a sync writes down that something failed, and what it is called.
3
+ *
4
+ * Every reason label this pass can produce is written here and nowhere else, so
5
+ * the closed registry in `sync-health-class.ts` can be checked against a single
6
+ * file: `sync-health-class.test.ts` reads this source and refuses a label with
7
+ * no declared class (BLI-3551). Two consequences bind anyone editing this file:
8
+ * the ledger's methods keep the names `add` and `fail`, because that test reads
9
+ * those exact call shapes, and a new label goes in the registry first.
10
+ *
11
+ * The recorders below are the decision table itself — worktree delivery, source
12
+ * scan, the unposted session report, and the sentinel for a gate that fired
13
+ * without saying why.
14
+ */
15
+ import { claudeAttributionReadFailureCount, codexAttributionReadFailureCount, } from "./agent-session-report.js";
16
+ import { sourceScanFailureReason } from "./session-sync-scan.js";
17
+ /**
18
+ * The label used when a sync fails and nothing on the way there said why.
19
+ *
20
+ * A deliberate sentinel rather than a fallback to `sync_failed`: it means the
21
+ * gate is real but its reason is unrecorded, which is a bug in this file, and
22
+ * it should be visible as one instead of blending into the generic bucket.
23
+ */
24
+ export const SYNC_FAILED_WITHOUT_REASON = "sync_failed_reason_not_recorded";
25
+ export function createSyncFailureLedger() {
26
+ const recordsByRenderedReason = new Map();
27
+ const add = (record) => {
28
+ if (!recordsByRenderedReason.has(record.rendered)) {
29
+ recordsByRenderedReason.set(record.rendered, record);
30
+ }
31
+ };
32
+ return {
33
+ add,
34
+ fail(condition, label, rendered = label) {
35
+ if (condition)
36
+ add({ label, rendered });
37
+ },
38
+ isEmpty: () => recordsByRenderedReason.size === 0,
39
+ sortedRecords: () => [...recordsByRenderedReason.values()].sort((a, b) => a.rendered.localeCompare(b.rendered)),
40
+ };
41
+ }
42
+ /**
43
+ * Every worktree that did not finish, and every raw-evidence gap it reported.
44
+ *
45
+ * The spooled reason is the most specific thing anyone has, so it leads, and it
46
+ * names the worktree it belongs to — a fleet failure is usually one repo, and
47
+ * "which one" is the first question asked.
48
+ */
49
+ export function recordWorktreeDeliveryFailures(ledger, outcomes) {
50
+ for (const { worktree, sync } of outcomes) {
51
+ if (sync.status !== "uploaded") {
52
+ ledger.add(sync.status === "spooled" && sync.failure_reason
53
+ ? {
54
+ label: sync.failure_class,
55
+ rendered: `${worktree.worktree_label}:${sync.failure_reason}`,
56
+ http_status: sync.failure_http_status,
57
+ }
58
+ : {
59
+ label: "upload_not_completed",
60
+ rendered: `${worktree.worktree_label}:upload_${sync.status}`,
61
+ });
62
+ }
63
+ for (const reason of sync.raw_evidence_failure_reasons ?? []) {
64
+ ledger.add({
65
+ label: "raw_evidence_upload_failed",
66
+ rendered: `raw_evidence:${reason}`,
67
+ });
68
+ }
69
+ for (const reason of sync.raw_evidence_retry_reasons ?? []) {
70
+ ledger.add({
71
+ label: "raw_evidence_retry_required",
72
+ rendered: `raw_evidence_retry:${reason}`,
73
+ });
74
+ }
75
+ ledger.fail(sync.raw_evidence_deferred_byte_budget > 0, "deferred_byte_budget");
76
+ ledger.fail(sync.raw_evidence_deferred_object_budget > 0, "deferred_object_budget");
77
+ }
78
+ }
79
+ /**
80
+ * What the scan itself got wrong: a window that hit its cap, sessions that
81
+ * could not be read, or a session store that could not be read at all.
82
+ *
83
+ * BLI-3551: the scan's RETRY reason and the scan's FAILURE reason are two
84
+ * different questions, and answering both with one function is what put
85
+ * `claude_scan:repo_not_on_disk` on every tick of three machines. A repo that
86
+ * is not on disk is a label on the session (the attribution umbrella finding:
87
+ * nothing was deleted, the transcript simply names a path git no longer
88
+ * knows). It still widens the next scan window; it is not a failed sync.
89
+ */
90
+ export function recordSourceScanFailures(ledger, scan) {
91
+ const { codexAttribution, claudeAttribution } = scan;
92
+ ledger.fail(codexAttribution.session_limit_applied, "codex_session_limit_applied");
93
+ ledger.fail(claudeAttribution.session_limit_applied, "claude_session_limit_applied");
94
+ ledger.fail(codexAttributionReadFailureCount(codexAttribution) > 0, "codex_session_read_failed");
95
+ ledger.fail(claudeAttributionReadFailureCount(claudeAttribution) > 0, "claude_session_read_failed");
96
+ const codexScanFailure = sourceScanFailureReason("codex", codexAttribution);
97
+ if (codexScanFailure) {
98
+ ledger.add({
99
+ label: "codex_scan_read_failed",
100
+ rendered: `codex_scan:${codexScanFailure}`,
101
+ });
102
+ }
103
+ const claudeScanFailure = sourceScanFailureReason("claude_code", claudeAttribution);
104
+ if (claudeScanFailure) {
105
+ ledger.add({
106
+ label: "claude_scan_read_failed",
107
+ rendered: `claude_scan:${claudeScanFailure}`,
108
+ });
109
+ }
110
+ }
111
+ /**
112
+ * A report that was owed and did not go out — unless the reason it did not go
113
+ * out is that there was nothing inside the approved roots to report.
114
+ *
115
+ * BLI-3551: a tick that observed only sessions from outside the operator's
116
+ * approved roots has nothing to post, and that is the consent boundary
117
+ * working — not a failure. It used to fail as
118
+ * `session_report_unposted:no_successful_sync`, whose word "session" then
119
+ * classified as `auth_failed`; one machine reported a broken credential 377
120
+ * times in 38 hours while its token had eleven weeks left. The withhold
121
+ * decision itself is untouched (adapters/attribution-core.ts) — only what it
122
+ * is CALLED.
123
+ */
124
+ export function recordUnpostedSessionReportFailure(ledger, options) {
125
+ const report = options.delivery.report;
126
+ ledger.fail(options.delivery.reportRequired &&
127
+ !report.posted &&
128
+ !options.explainedByNothingInRoot, "session_report_unposted", `session_report_unposted:${report.reason ?? "unknown"}`);
129
+ }
130
+ /**
131
+ * A gate that fired without recording a reason still has to say so. Reached
132
+ * only when `ok` is false and the ledger is empty, which is a bug in this
133
+ * family rather than a condition of the machine.
134
+ */
135
+ export function recordUnexplainedFailure(ledger) {
136
+ ledger.add({
137
+ label: SYNC_FAILED_WITHOUT_REASON,
138
+ rendered: SYNC_FAILED_WITHOUT_REASON,
139
+ });
140
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The verdict on one tick — and the one true thing said about a tick that did
3
+ * not fail.
4
+ *
5
+ * This used to be a single boolean chain: correct, and completely mute. A
6
+ * failed sync exited 1 saying only `sync_failed`, which named no cause and
7
+ * supported no repair (BLI-2526). The conditions are unchanged; they are now
8
+ * asked in order, each answered by a recorder in `session-sync-failures.ts`
9
+ * that writes the closed-registry LABEL the health receipt is classified by.
10
+ * Nothing here parses a rendered sentence back apart.
11
+ */
12
+ import { createSyncFailureLedger, recordSourceScanFailures, recordUnexplainedFailure, recordUnpostedSessionReportFailure, recordWorktreeDeliveryFailures, } from "./session-sync-failures.js";
13
+ /**
14
+ * Decide whether this tick failed, and record every condition that decided it.
15
+ *
16
+ * The order below is the decision table: what delivery got wrong, what the scan
17
+ * got wrong, whether the report was owed, and only then whether the run is ok.
18
+ */
19
+ export function decideSyncHealth(options) {
20
+ const { outcomes } = options.worktreePass;
21
+ const ledger = createSyncFailureLedger();
22
+ recordWorktreeDeliveryFailures(ledger, outcomes);
23
+ recordSourceScanFailures(ledger, options.scan);
24
+ const sessionsOutsideRoot = options.sessions.filter((session) => OUTSIDE_APPROVED_ROOT_REASONS.has(session.attribution_reason)).length;
25
+ const nothingInRoot = nothingInRootCount({
26
+ sessionCount: options.sessions.length,
27
+ outsideRootCount: sessionsOutsideRoot,
28
+ outcomes,
29
+ reportPosted: options.delivery.report.posted,
30
+ reportReason: options.delivery.report.reason,
31
+ });
32
+ recordUnpostedSessionReportFailure(ledger, {
33
+ delivery: options.delivery,
34
+ explainedByNothingInRoot: nothingInRoot !== null,
35
+ });
36
+ // `everyWorktreeUploaded` may already be false; the delivery recorder above
37
+ // re-derives that from the same outcomes, so the two agree by construction.
38
+ const ok = options.worktreePass.everyWorktreeUploaded && ledger.isEmpty();
39
+ if (!ok && ledger.isEmpty()) {
40
+ recordUnexplainedFailure(ledger);
41
+ }
42
+ const notice = ok && nothingInRoot !== null ? `nothing_in_root:${nothingInRoot}` : null;
43
+ if (nothingInRoot !== null && notice) {
44
+ announceNothingInRoot(nothingInRoot, options.collectionRootCount);
45
+ }
46
+ const records = ledger.sortedRecords();
47
+ return {
48
+ ok,
49
+ failure_reasons: records.map((record) => record.rendered),
50
+ failure_records: records,
51
+ notice,
52
+ sessions_outside_root: sessionsOutsideRoot,
53
+ };
54
+ }
55
+ /**
56
+ * Reasons attribution gives when a session's working directory is not inside
57
+ * any approved collection root.
58
+ *
59
+ * Exact labels, not a pattern — the same discipline the classifier now follows.
60
+ * `attribution-core.ts` writes both of these and nothing else means
61
+ * "outside the boundary".
62
+ */
63
+ const OUTSIDE_APPROVED_ROOT_REASONS = new Set([
64
+ "cwd_outside_scanned_worktrees",
65
+ "no_matching_worktree_signals",
66
+ ]);
67
+ /**
68
+ * How many observed sessions were outside the approved roots, when that
69
+ * accounts for ALL of them and nothing else went wrong — otherwise `null`.
70
+ *
71
+ * Deliberately narrow. It requires that no worktree was synced at all (so no
72
+ * upload could have succeeded or failed), that every session observed this tick
73
+ * names an outside-the-root reason, and that the unposted report is the
74
+ * `no_successful_sync` shape rather than a spooled report that failed to flush.
75
+ * Anything else keeps its failure.
76
+ */
77
+ export function nothingInRootCount(options) {
78
+ if (options.reportPosted)
79
+ return null;
80
+ if (options.reportReason !== "no_successful_sync")
81
+ return null;
82
+ if (options.outcomes.length > 0)
83
+ return null;
84
+ if (options.sessionCount === 0)
85
+ return null;
86
+ return options.outsideRootCount === options.sessionCount
87
+ ? options.outsideRootCount
88
+ : null;
89
+ }
90
+ /**
91
+ * The success branch says something too: this is the receipt that proves a
92
+ * quiet machine is a working machine, and the count is what tells a coach that
93
+ * someone is working entirely outside the approved boundary.
94
+ */
95
+ function announceNothingInRoot(sessionsOutsideRoot, collectionRootCount) {
96
+ console.error("[session-sync] nothing to collect inside the approved roots", JSON.stringify({
97
+ reason: "nothing_in_root",
98
+ sessions_outside_root: sessionsOutsideRoot,
99
+ collection_root_count: collectionRootCount,
100
+ next_action: "widen the approved roots (an operator decision) if this machine should be collecting here",
101
+ }));
102
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * What this tick is bound by before it reads a single session file.
3
+ *
4
+ * Every read here degrades on purpose: a missing or corrupt local file becomes
5
+ * an empty cursor or a null spool, because a collector that cannot read its own
6
+ * bookkeeping must still collect. The one decision this step makes that changes
7
+ * what gets collected is the retry width — whether a source reopens all local
8
+ * history instead of its normal live window.
9
+ */
10
+ import { readLocalCollectorConfig, } from "../local-state.js";
11
+ import { CLAUDE_CURSOR_FILENAME, emptyRawEvidenceCursorState, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
12
+ import { normalizeCollectionRoots } from "../root-normalization.js";
13
+ import { readLocalUploadSpoolState, } from "../spool/local-spool.js";
14
+ import { liveSyncCursorEntryRequiresRetry } from "./session-sync-attribution.js";
15
+ /**
16
+ * Read the local state this sync is bound by — config, both cursors, the upload
17
+ * spool — and turn it into the decisions the rest of the pass reads.
18
+ *
19
+ * Every read here degrades on purpose: a missing or corrupt local file becomes
20
+ * an empty cursor or a null spool, because a collector that cannot read its own
21
+ * bookkeeping must still collect.
22
+ */
23
+ export async function planSyncFromLocalState(options) {
24
+ const config = await readLocalCollectorConfig(options.paths).catch(() => null);
25
+ const claudeEnabled = config?.collect_claude_jsonl !== false;
26
+ const collectionRoots = normalizeCollectionRoots(options.approvedCollectionRoots ?? config?.default_repo_paths ?? []);
27
+ const [codexCursorBefore, claudeCursorBefore, uploadSpool] = await Promise.all([
28
+ readRawEvidenceCursor(options.paths).catch(() => emptyRawEvidenceCursorState()),
29
+ claudeEnabled
30
+ ? readRawEvidenceCursor(options.paths, {
31
+ filename: CLAUDE_CURSOR_FILENAME,
32
+ }).catch(() => emptyRawEvidenceCursorState())
33
+ : Promise.resolve(emptyRawEvidenceCursorState()),
34
+ readLocalUploadSpoolState(options.paths).catch(() => null),
35
+ ]);
36
+ // A spooled upload from a CLI old enough not to have recorded its source
37
+ // could have come from either one, so both sources own it until it clears.
38
+ const legacyRetryPending = Boolean(uploadSpool?.pending_uploads.some((entry) => entry.raw_evidence_file_count > 0 && entry.retry_sources.length === 0));
39
+ const codexSourceRetryPending = sourceScanRetryIsPending(uploadSpool, "codex");
40
+ const claudeSourceRetryPending = sourceScanRetryIsPending(uploadSpool, "claude_code");
41
+ return {
42
+ claudeEnabled,
43
+ collectionRoots,
44
+ codexCursorBefore,
45
+ claudeCursorBefore,
46
+ uploadSpool,
47
+ codexSourceRetryPending,
48
+ claudeSourceRetryPending,
49
+ codexRetryPending: sourceHasPendingRetries("codex", uploadSpool, legacyRetryPending, codexSourceRetryPending, codexCursorBefore),
50
+ claudeRetryPending: claudeEnabled &&
51
+ sourceHasPendingRetries("claude_code", uploadSpool, legacyRetryPending, claudeSourceRetryPending, claudeCursorBefore),
52
+ allHistorySinceMinutes: allLocalHistorySinceMinutes(options.now),
53
+ };
54
+ }
55
+ function allLocalHistorySinceMinutes(now) {
56
+ // A cutoff just before the Unix epoch is effectively unbounded for Codex and
57
+ // Claude session files while keeping date arithmetic finite and portable.
58
+ return Math.ceil(now.getTime() / 60_000) + 24 * 60;
59
+ }
60
+ /** Whether the spool already recorded a source-scan retry for this source. */
61
+ function sourceScanRetryIsPending(uploadSpool, source) {
62
+ return Boolean(uploadSpool?.pending_source_retries.some((entry) => entry.source === source));
63
+ }
64
+ /**
65
+ * Does this source have a reason to widen its scan window to all local
66
+ * history instead of the normal live-sync window? Used to be two separately
67
+ * written boolean chains, one per source, that happened to agree on shape by
68
+ * hand — asymmetric to read even though the rule is identical for both
69
+ * (BLI-3394): a legacy spooled upload with no recorded source, a scan-retry
70
+ * the spool already remembers, a spooled upload that names this source, or an
71
+ * undurable session this source's own cursor is still carrying.
72
+ */
73
+ function sourceHasPendingRetries(source, uploadSpool, legacyRetryPending, sourceScanRetryPending, cursor) {
74
+ return (legacyRetryPending ||
75
+ sourceScanRetryPending ||
76
+ Boolean(uploadSpool?.pending_uploads.some((entry) => entry.retry_sources.includes(source))) ||
77
+ cursorHasUndurableCollectableSession(cursor));
78
+ }
79
+ function cursorHasUndurableCollectableSession(cursor) {
80
+ return Object.values(cursor.sessions).some((entry) => liveSyncCursorEntryRequiresRetry(entry));
81
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Writing down what this tick saw: the session report first, then both cursors.
3
+ *
4
+ * The order is the whole reason these live together. The report is queued to
5
+ * the spool BEFORE either cursor moves, so a process that dies in between
6
+ * leaves an exact retry copy behind rather than a cursor that has aged an
7
+ * unreported session out of the live window.
8
+ */
9
+ import { describeError } from "../health-detail.js";
10
+ import { flushPendingCodexSessionReports, queueCodexSessionReport, } from "../upload.js";
11
+ import { CLAUDE_CURSOR_FILENAME, countStaleSessions, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
12
+ import { claudeAttributionReadFailureCount, codexAttributionReadFailureCount, } from "./agent-session-report.js";
13
+ import { liveSyncCursorEntryRequiresRetry } from "./session-sync-attribution.js";
14
+ /**
15
+ * Report this tick's sessions and advance both cursors — in that order, which
16
+ * is the load-bearing part.
17
+ *
18
+ * The report is queued to the spool BEFORE either cursor moves. If the process
19
+ * exits in between, the spool keeps an exact retry copy; if queueing itself
20
+ * fails, the cursors stay at their prior retryable positions instead of aging
21
+ * an unreported session out of the live window.
22
+ */
23
+ export async function reportSessionsAndAdvanceCursors(options) {
24
+ const hadPendingSessionReports = (options.plan.uploadSpool?.pending_session_reports.length ?? 0) > 0;
25
+ const queuedCurrentSessionReport = await queueSessionReportForDelivery({
26
+ homeDir: options.run.homeDir,
27
+ outcomes: options.outcomes,
28
+ sessions: options.sessions,
29
+ now: options.now,
30
+ });
31
+ const staleCounts = await recordBothSourceCursorObservations({
32
+ paths: options.paths,
33
+ plan: options.plan,
34
+ scan: options.scan,
35
+ sessions: options.sessions,
36
+ now: options.now,
37
+ });
38
+ const report = await deliverOrExplainSessionReport({
39
+ hadPendingSessionReports,
40
+ queuedCurrentSessionReport,
41
+ sessionCount: options.sessions.length,
42
+ homeDir: options.run.homeDir,
43
+ fetchImpl: options.run.fetchImpl,
44
+ now: options.now,
45
+ });
46
+ return {
47
+ report,
48
+ reportRequired: options.sessions.length > 0 ||
49
+ hadPendingSessionReports ||
50
+ queuedCurrentSessionReport,
51
+ ...staleCounts,
52
+ };
53
+ }
54
+ /**
55
+ * Persist the metadata-only session report to the spool, before either source
56
+ * cursor advances.
57
+ *
58
+ * If the process exits after this point, the spool keeps an exact retry copy.
59
+ * If queueing itself fails, the cursors remain at their prior retryable
60
+ * positions instead of aging an unreported session out of the live window.
61
+ * Returns whether a report for THIS tick was queued.
62
+ */
63
+ async function queueSessionReportForDelivery(options) {
64
+ const firstUploaded = options.outcomes.find((outcome) => outcome.sync.status === "uploaded");
65
+ if (!firstUploaded || options.sessions.length === 0)
66
+ return false;
67
+ const queued = await queueCodexSessionReport({
68
+ homeDir: options.homeDir,
69
+ dashboardUrl: firstUploaded.sync.dashboard_url,
70
+ generatedAt: options.now.toISOString(),
71
+ workContextId: firstUploaded.sync.work_context_id,
72
+ repoLabel: firstUploaded.worktree.repo_label,
73
+ branch: firstUploaded.worktree.branch,
74
+ repoFingerprint: firstUploaded.worktree.repo_fingerprint,
75
+ repoOriginUrl: firstUploaded.worktree.repo_origin_url,
76
+ worktreeLabel: firstUploaded.worktree.worktree_label,
77
+ worktreeFingerprint: firstUploaded.worktree.worktree_fingerprint,
78
+ worktreeIsPrimary: firstUploaded.worktree.worktree_is_primary,
79
+ sessions: options.sessions,
80
+ });
81
+ return Boolean(queued);
82
+ }
83
+ /**
84
+ * Advance both source cursors to what this tick observed.
85
+ *
86
+ * The sessions cursor is an optimization; a broken local state dir must not
87
+ * turn an already-completed sync into a CLI crash, so each side is best-effort
88
+ * and names its own failure.
89
+ */
90
+ async function recordBothSourceCursorObservations(options) {
91
+ const codexStaleCount = await recordCodexSessionObservationsForSync({
92
+ paths: options.paths,
93
+ codexAttribution: options.scan.codexAttribution,
94
+ sessions: options.sessions,
95
+ now: options.now,
96
+ priorCursor: options.plan.codexCursorBefore,
97
+ codexRetryPending: options.plan.codexRetryPending,
98
+ });
99
+ const claudeStaleCount = await recordClaudeSessionObservationsForSync({
100
+ claudeEnabled: options.plan.claudeEnabled,
101
+ paths: options.paths,
102
+ claudeAttribution: options.scan.claudeAttribution,
103
+ sessions: options.sessions,
104
+ now: options.now,
105
+ claudeCursorBefore: options.plan.claudeCursorBefore,
106
+ claudeRetryPending: options.plan.claudeRetryPending,
107
+ });
108
+ return { codexStaleCount, claudeStaleCount };
109
+ }
110
+ /**
111
+ * Flush the queued session reports — or, when there is nothing queued, say why
112
+ * nothing was posted instead of returning a bare false.
113
+ */
114
+ async function deliverOrExplainSessionReport(options) {
115
+ if (!options.hadPendingSessionReports && !options.queuedCurrentSessionReport) {
116
+ return {
117
+ posted: false,
118
+ reason: options.sessionCount === 0
119
+ ? "no_sessions_observed"
120
+ : "no_successful_sync",
121
+ };
122
+ }
123
+ return flushPendingCodexSessionReports({
124
+ homeDir: options.homeDir,
125
+ fetch: options.fetchImpl,
126
+ now: options.now,
127
+ });
128
+ }
129
+ /**
130
+ * Record this sync's Codex session observations into the Codex cursor and
131
+ * return the stale count. Best-effort by design: a broken local state dir
132
+ * must not turn an already-completed sync into a CLI crash, so a failure here
133
+ * only logs — the stale count simply keeps whatever value it had when the
134
+ * failure happened (0 if the read itself failed).
135
+ *
136
+ * Reads the cursor fresh from disk right before recording rather than reusing
137
+ * `priorCursor`, so a concurrent writer's update is not clobbered. Claude's
138
+ * counterpart does not do this extra read (see
139
+ * `recordClaudeSessionObservationsForSync`) — a real, intentional asymmetry
140
+ * kept as-is rather than forced to match.
141
+ */
142
+ async function recordCodexSessionObservationsForSync(options) {
143
+ let staleCount = 0;
144
+ try {
145
+ const codexCursor = await readRawEvidenceCursor(options.paths);
146
+ staleCount = recordSourceObservations({
147
+ cursor: codexCursor,
148
+ results: options.codexAttribution.results,
149
+ sessions: options.sessions,
150
+ source: "codex",
151
+ sessionIdOf: (result) => result.codex_session_id,
152
+ now: options.now,
153
+ priorCursor: options.priorCursor,
154
+ terminalizeMissingUndurable: options.codexRetryPending &&
155
+ codexAttributionReadFailureCount(options.codexAttribution) === 0,
156
+ });
157
+ codexCursor.updated_at = options.now.toISOString();
158
+ await writeRawEvidenceCursor(options.paths, codexCursor);
159
+ }
160
+ catch (error) {
161
+ // Best-effort: stale counts read 0 and observations re-record next sync —
162
+ // which is fine ONCE. A cursor write that keeps failing means the sessions
163
+ // cursor never advances, every sync re-does the same work, and the only
164
+ // symptom is a stale count that is permanently zero (BLI-3238).
165
+ console.error("[session-sync] Codex session observations were not recorded", JSON.stringify({
166
+ reason: "session_cursor_update_failed",
167
+ source: "codex",
168
+ session_count: options.sessions.length,
169
+ ...describeError(error),
170
+ }));
171
+ }
172
+ return staleCount;
173
+ }
174
+ /**
175
+ * Record this sync's Claude session observations into the Claude cursor and
176
+ * return the stale count — the Claude counterpart to
177
+ * `recordCodexSessionObservationsForSync`. A no-op returning 0 when Claude
178
+ * collection is disabled, matching the source-scan side's own disabled state.
179
+ */
180
+ async function recordClaudeSessionObservationsForSync(options) {
181
+ if (!options.claudeEnabled)
182
+ return 0;
183
+ let staleCount = 0;
184
+ try {
185
+ staleCount = recordSourceObservations({
186
+ cursor: options.claudeCursorBefore,
187
+ results: options.claudeAttribution.results,
188
+ sessions: options.sessions,
189
+ source: "claude_code",
190
+ sessionIdOf: (result) => result.claude_session_id,
191
+ now: options.now,
192
+ priorCursor: options.claudeCursorBefore,
193
+ terminalizeMissingUndurable: options.claudeRetryPending &&
194
+ claudeAttributionReadFailureCount(options.claudeAttribution) === 0,
195
+ });
196
+ options.claudeCursorBefore.updated_at = options.now.toISOString();
197
+ await writeRawEvidenceCursor(options.paths, options.claudeCursorBefore, {
198
+ filename: CLAUDE_CURSOR_FILENAME,
199
+ sessionsOnly: true,
200
+ });
201
+ }
202
+ catch (error) {
203
+ // Best-effort: a broken Claude cursor must not fail the sync. It must
204
+ // still say it is broken — otherwise the Claude half of collection
205
+ // quietly repeats itself forever.
206
+ console.error("[session-sync] Claude session observations were not recorded", JSON.stringify({
207
+ reason: "session_cursor_update_failed",
208
+ source: "claude_code",
209
+ session_count: options.sessions.length,
210
+ ...describeError(error),
211
+ }));
212
+ }
213
+ return staleCount;
214
+ }
215
+ /**
216
+ * Records per-source session observations into its cursor and returns the stale
217
+ * count. Damped/reused Claude sessions carry forward their prior upload
218
+ * timestamp + byte size so the 6h damping window keeps counting from the real
219
+ * last upload (otherwise a slowly-growing file would never re-upload — D21).
220
+ */
221
+ function recordSourceObservations(options) {
222
+ const seen = new Set(options.results.map((result) => options.sessionIdOf(result)));
223
+ if (options.terminalizeMissingUndurable) {
224
+ for (const [sessionId, entry] of Object.entries(options.cursor.sessions)) {
225
+ if (seen.has(sessionId) ||
226
+ !liveSyncCursorEntryRequiresRetry(entry)) {
227
+ continue;
228
+ }
229
+ options.cursor.sessions[sessionId] = {
230
+ ...entry,
231
+ state: "skipped",
232
+ reason: "retry_source_missing",
233
+ last_seen_at: options.now.toISOString(),
234
+ };
235
+ }
236
+ }
237
+ const stale = countStaleSessions(options.cursor, seen);
238
+ for (const result of options.results) {
239
+ const sessionId = options.sessionIdOf(result);
240
+ const reported = options.sessions.find((session) => session.source === options.source &&
241
+ session.codex_session_id === sessionId);
242
+ const uploadedThisSync = reported?.upload_state === "uploaded";
243
+ const durableThisSync = reported?.upload_state === "uploaded" ||
244
+ reported?.upload_state === "reused_existing";
245
+ const prior = options.priorCursor?.sessions[sessionId];
246
+ // D21 / no-flip-flop: a sync that is spooled (offline), budget-deferred, or
247
+ // upload-failed for a session that was ALREADY durable must NOT wipe the
248
+ // prior durable state — otherwise damping is forfeited forever and the
249
+ // store row oscillates uploaded -> not_uploaded hourly. Carry the prior
250
+ // durable pointer/timestamp/size forward unless we durably uploaded anew.
251
+ const uploadedObjectKey = durableThisSync
252
+ ? (reported?.raw_evidence_pointer_id ?? prior?.uploaded_object_key ?? null)
253
+ : (prior?.uploaded_object_key ?? null);
254
+ const uploadedAt = uploadedThisSync
255
+ ? options.now.toISOString()
256
+ : (prior?.uploaded_at ?? (durableThisSync ? options.now.toISOString() : null));
257
+ const uploadedByteSize = uploadedThisSync
258
+ ? result.byte_size
259
+ : (prior?.uploaded_byte_size ??
260
+ (durableThisSync ? result.byte_size : null));
261
+ const entry = {
262
+ file_hash_sha256: result.content_hash_sha256,
263
+ file_mtime_ms: result.session_file_mtime_ms,
264
+ byte_size: result.byte_size,
265
+ // Durable byte offset reflects how many bytes are durable remotely (the
266
+ // last uploaded size), not the current file size.
267
+ byte_offset: uploadedByteSize ?? 0,
268
+ state: result.state,
269
+ reason: result.reason,
270
+ worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
271
+ uploaded_object_key: uploadedObjectKey,
272
+ uploaded_at: uploadedAt,
273
+ uploaded_byte_size: uploadedByteSize,
274
+ last_seen_at: options.now.toISOString(),
275
+ };
276
+ recordSessionObservation(options.cursor, sessionId, entry);
277
+ }
278
+ return stale;
279
+ }