@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.
- package/dist/commands/clean.js +244 -0
- package/dist/commands/doctor.js +73 -0
- package/dist/commands/jarvis.js +101 -9
- package/dist/commands/local-args-collector.js +30 -0
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-help.js +26 -0
- package/dist/commands/local.js +3 -0
- 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 +140 -2
- package/dist/commands/sync.js +4 -1
- package/dist/cursors/raw-evidence-reconcile-cursor.js +132 -0
- package/dist/disk-prune.js +246 -0
- package/dist/disk-retention.js +157 -0
- package/dist/disk-usage.js +392 -0
- package/dist/evidence-reconcile-client.js +224 -0
- package/dist/log-rotation.js +106 -2
- package/dist/tower-stream.js +5 -1
- package/package.json +3 -3
|
@@ -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.
|
|
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
|
+
}
|