@bli-cockpit/cli 0.2.210 → 0.2.211
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/agent-session-report.js +1 -1
- package/dist/commands/doctor-access.js +7 -5
- package/dist/commands/local-help-commands.js +1 -1
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/scout-render.js +2 -0
- package/dist/commands/session-sync-attribution.js +11 -3
- package/dist/commands/session-sync-health.js +1 -1
- package/dist/commands/session-sync-outside-roots.js +281 -0
- package/dist/commands/session-sync-plan.js +5 -2
- package/dist/commands/session-sync-scan.js +9 -4
- package/dist/commands/session-sync-upload.js +44 -25
- package/dist/commands/session-sync.js +29 -7
- package/dist/commands/sync-report.js +1 -1
- package/dist/local-state-work-context.js +3 -1
- package/package.json +1 -1
|
@@ -246,7 +246,7 @@ export function buildAgentSessionReport(options) {
|
|
|
246
246
|
*/
|
|
247
247
|
export function buildAgentSessionSummary(options) {
|
|
248
248
|
const sidecarOutcomes = options.outcomes.flatMap((outcome) => outcome.sync.raw_evidence_outcomes.filter((upload) => upload.kind === "claude_jsonl_sidecar"));
|
|
249
|
-
const attributedClaude = options.claudeAttribution.results.filter((result) => isLiveRawEvidenceSyncAttribution(result.state, result.reason));
|
|
249
|
+
const attributedClaude = options.claudeAttribution.results.filter((result) => isLiveRawEvidenceSyncAttribution(result.state, result.reason, result.worktree !== null));
|
|
250
250
|
const sidecarsCollected = attributedClaude.reduce((total, result) => total +
|
|
251
251
|
result.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length, 0);
|
|
252
252
|
const sidecarsSkipped = options.claudeAttribution.results.reduce((total, result) => total +
|
|
@@ -160,10 +160,12 @@ function shellQuote(value) {
|
|
|
160
160
|
* a gate on it, so a session sitting outside one is not a defect doctor found
|
|
161
161
|
* and cannot repair. This row is therefore always informational (`ok`): it
|
|
162
162
|
* reports how many of this scan's sessions were inside vs. outside, never
|
|
163
|
-
* asks for approval, and attempts no fix.
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
163
|
+
* asks for approval, and attempts no fix. Since BLI-17916 the scheduled sync
|
|
164
|
+
* uploads the redacted transcript of an outside-root session too (under the
|
|
165
|
+
* collector's outside-roots workspace), so widening a root buys only
|
|
166
|
+
* attribution to a repo; the message names `cockpit roots add` as an OPTIONAL
|
|
167
|
+
* step an operator can choose, never something doctor, or any person, is
|
|
168
|
+
* waiting on to approve. The
|
|
167
169
|
* row only turns red the ordinary way every other doctor check does: the scan
|
|
168
170
|
* itself throwing (`work_directory_scan_incomplete` from unreadable session
|
|
169
171
|
* folders), which `runDoctorWithDeps`'s existing `check_threw` handling
|
|
@@ -180,7 +182,7 @@ export async function readRootsCoverWork(context) {
|
|
|
180
182
|
if (!outsideCount)
|
|
181
183
|
return ok("roots-cover-work", "work_covered", `${scan.observed} sessions seen; no working folders outside your saved roots. ${sessionCoverageLine(scan, outsideCount)}`);
|
|
182
184
|
return {
|
|
183
|
-
...ok("roots-cover-work", "work_outside_roots_labelled", `${insideCount} of ${scan.observed} sessions inside your saved roots, ${outsideCount} outside — every one already reached the server, the outside ones carrying an outside label. Nothing needs approving. Optional: add a folder as a root to
|
|
185
|
+
...ok("roots-cover-work", "work_outside_roots_labelled", `${insideCount} of ${scan.observed} sessions inside your saved roots, ${outsideCount} outside — every one already reached the server, the outside ones carrying an outside label, and their redacted transcripts upload too, a few per sync, newest first. Nothing needs approving. Optional: add a folder as a root to attribute its sessions to a repo (scan ${scan.generated_at}):\n${outside.map(entry => `${entry.count}: ${entry.directory}\n ${rootsAddCommand(entry.directory, process.platform, homeDir)}`).join("\n")}\n\n${sessionCoverageLine(scan, outsideCount)}`),
|
|
184
186
|
nextAction: rootsAddCommand(outside[0].directory, process.platform, homeDir),
|
|
185
187
|
};
|
|
186
188
|
}
|
|
@@ -93,7 +93,7 @@ export function localSubcommandHelp(command) {
|
|
|
93
93
|
"",
|
|
94
94
|
"Diagnose, repair, and verify this machine.",
|
|
95
95
|
"Repairs CLI, background sync, sign-in, roots, memory and hooks, agent rules, catch-up, uploads, and cleanup.",
|
|
96
|
-
"roots-cover-work is informational: it names how many sessions are inside vs. outside saved roots (all already uploaded, outside ones labelled) and gives cockpit roots add commands for
|
|
96
|
+
"roots-cover-work is informational: it names how many sessions are inside vs. outside saved roots (all already uploaded, outside ones labelled) and gives cockpit roots add commands for attributing a folder's sessions to a repo (outside-root transcripts upload either way). It never needs a fix.",
|
|
97
97
|
"--no-usage-telemetry switches off Claude Code and Codex usage export, including later doctor runs.",
|
|
98
98
|
"--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
|
|
99
99
|
"--backfill-budget defaults to 900 seconds; catch-up repeats chunks until complete, with progress every 30 seconds.",
|
|
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
24
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
24
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.211");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -81,6 +81,8 @@ function renderExperiment(card, styled) {
|
|
|
81
81
|
for (const row of card.crossref ?? []) {
|
|
82
82
|
if (row.person) {
|
|
83
83
|
out.push(dim(` On your team · ${row.person} · ${row.observedPattern ?? ""}`, styled));
|
|
84
|
+
if (row.provenanceLine)
|
|
85
|
+
out.push(dim(` ${row.provenanceLine}`, styled));
|
|
84
86
|
}
|
|
85
87
|
}
|
|
86
88
|
if (card.expectedPayoff)
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* re-derived anywhere else.
|
|
12
12
|
*/
|
|
13
13
|
import { isLiveRawEvidenceSyncAttribution } from "../raw-evidence-attribution-policy.js";
|
|
14
|
+
import { outsideRootEntryOwesUpload } from "./session-sync-outside-roots.js";
|
|
14
15
|
/**
|
|
15
16
|
* Live sync remains reason-allowlisted even though historical backfill accepts
|
|
16
17
|
* every deterministic fallback state.
|
|
@@ -18,9 +19,16 @@ import { isLiveRawEvidenceSyncAttribution } from "../raw-evidence-attribution-po
|
|
|
18
19
|
export function isLiveSyncCollectableAttributionState(state, reason, hasApprovedWorkspace = false) {
|
|
19
20
|
return isLiveRawEvidenceSyncAttribution(state, reason, hasApprovedWorkspace);
|
|
20
21
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Does this cursor entry still owe a transcript upload, so the next scan window
|
|
24
|
+
* must reach back to it? An outside-root session owes one too (BLI-17916): the
|
|
25
|
+
* scheduled sync uploads it now, a few per tick, and the window stays open
|
|
26
|
+
* until the last of them is durable.
|
|
27
|
+
*/
|
|
28
|
+
export function liveSyncCursorEntryRequiresRetry(entry, now = new Date()) {
|
|
29
|
+
return ((!entry.uploaded_object_key &&
|
|
30
|
+
(isLiveSyncCollectableAttributionState(entry.state, entry.reason, Boolean(entry.worktree_fingerprint)) || entry.reason === "repo_not_on_disk")) ||
|
|
31
|
+
outsideRootEntryOwesUpload(entry, now));
|
|
24
32
|
}
|
|
25
33
|
export function matchesLiveSyncWorktree(result, target) {
|
|
26
34
|
return (isLiveRawEvidenceSyncAttribution(result.state, result.reason, result.worktree !== null) &&
|
|
@@ -118,6 +118,6 @@ function announceNothingInRoot(sessionsOutsideRoot, collectionRootCount) {
|
|
|
118
118
|
reason: "nothing_in_root",
|
|
119
119
|
sessions_outside_root: sessionsOutsideRoot,
|
|
120
120
|
collection_root_count: collectionRootCount,
|
|
121
|
-
next_action: "
|
|
121
|
+
next_action: "none needed: outside-root sessions upload under the outside-roots label (see the [session-sync] outside-root raw upload log line for why none did this tick); add a root only to attribute them to a repo",
|
|
122
122
|
}));
|
|
123
123
|
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLI-17916: the scheduled sync uploads the transcript of a session that sits
|
|
3
|
+
* outside every approved root, the same way `cockpit backfill` does.
|
|
4
|
+
*
|
|
5
|
+
* Edward's ruling of 2026-09-17 (ticket 10734) made approved roots a LABEL on
|
|
6
|
+
* the upload, never a gate. The scheduled sync honoured it for the session
|
|
7
|
+
* REPORT only: the redacted transcript bytes of an outside-root session went up
|
|
8
|
+
* through `cockpit backfill` and nothing else, so on a machine where backfill
|
|
9
|
+
* never finished about a third of the last 30 days of sessions (2,301 of 7,815)
|
|
10
|
+
* could never be summarised.
|
|
11
|
+
*
|
|
12
|
+
* This is the one step that closes it. After the scan, before the upload, it
|
|
13
|
+
* picks which outside-root sessions ride this tick and hands each the SAME
|
|
14
|
+
* synthetic workspace backfill uses (`backfill-outside-roots.ts`), so the rest
|
|
15
|
+
* of the pass needs no special case: a session with a workspace is an ordinary
|
|
16
|
+
* sync target, goes through the ordinary envelope, and meets the same redaction.
|
|
17
|
+
* Attribution is left alone: the state stays `unattributed`, the reason stays
|
|
18
|
+
* `cwd_outside_scanned_worktrees`, the row stays `root_scope: "outside"`, and
|
|
19
|
+
* the workspace is transport identity only, never a repo the machine owns.
|
|
20
|
+
*
|
|
21
|
+
* What this step decides, and says out loud:
|
|
22
|
+
*
|
|
23
|
+
* - A session already durable with unchanged content rides for free. It
|
|
24
|
+
* uploads nothing (the object cursor reuses it) and keeps its row stable.
|
|
25
|
+
* - A session that is new, changed or not yet uploaded is "owed". The newest
|
|
26
|
+
* ones ride this tick up to a per-tick session cap. The rest wait, and their
|
|
27
|
+
* row says `deferred_object_budget`, the existing "next tick has room"
|
|
28
|
+
* label, instead of the old "not uploadable" one.
|
|
29
|
+
* - A transcript larger than the wire cap can never upload, so it does not
|
|
30
|
+
* spend a slot every tick; its row says `file_too_large`.
|
|
31
|
+
* - Nothing here runs for a source the operator switched off:
|
|
32
|
+
* `collect_claude_jsonl: false` means the Claude store is never read (the
|
|
33
|
+
* plan, not this step, decides that), so there are no Claude results to pick.
|
|
34
|
+
*
|
|
35
|
+
* The cap, the byte allowance and the object allowance are per tick, so a
|
|
36
|
+
* 1,800-session backlog drains across many ticks and never blows one tick's
|
|
37
|
+
* time or the machine's memory. Newest first: the sessions a person is most
|
|
38
|
+
* likely to be waiting on summarise first, and a backlog of old ones never
|
|
39
|
+
* starves today's.
|
|
40
|
+
*
|
|
41
|
+
* A new sibling must also join `scripts/build-public-cli.mjs` `runtimeFiles`.
|
|
42
|
+
*/
|
|
43
|
+
import fs from "node:fs/promises";
|
|
44
|
+
import path from "node:path";
|
|
45
|
+
import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
|
|
46
|
+
import { isRedactionStubObjectKey } from "../cursors/raw-evidence-stub-entry.js";
|
|
47
|
+
import { describeError } from "../health-detail.js";
|
|
48
|
+
import { writeJsonFile } from "../local-state-files.js";
|
|
49
|
+
import { rootScopeForReason } from "./agent-session-root-scope.js";
|
|
50
|
+
import { ensureOutsideRootsWorkspace, isOutsideRootsWorkspace, outsideRootsWorkspace, } from "./backfill-outside-roots.js";
|
|
51
|
+
/** Most outside-root sessions that upload bytes in one tick. 4 ticks an hour, so up to 3,840 a day. */
|
|
52
|
+
export const OUTSIDE_ROOT_SESSIONS_PER_TICK = 40;
|
|
53
|
+
/** Redacted bytes an outside-root pass may stage in one tick, kept apart from the in-root allowance. */
|
|
54
|
+
export const OUTSIDE_ROOT_BYTES_PER_TICK = 256 * 1024 * 1024;
|
|
55
|
+
/** Files (a Claude main plus its sidecars count one each) an outside-root pass may collect in one tick. */
|
|
56
|
+
export const OUTSIDE_ROOT_OBJECTS_PER_TICK = 120;
|
|
57
|
+
/**
|
|
58
|
+
* How far back the one-time catch-up scan reaches, and the oldest outside-root
|
|
59
|
+
* session that still widens a later tick's window. Longer history stays
|
|
60
|
+
* `cockpit backfill`'s job.
|
|
61
|
+
*/
|
|
62
|
+
export const OUTSIDE_ROOT_HORIZON_DAYS = 30;
|
|
63
|
+
export const OUTSIDE_ROOT_HORIZON_MINUTES = OUTSIDE_ROOT_HORIZON_DAYS * 24 * 60;
|
|
64
|
+
/** The upload_reason a session carries while it waits for a later tick. The existing transient label. */
|
|
65
|
+
export const OUTSIDE_ROOT_WAITING_REASON = "deferred_object_budget";
|
|
66
|
+
/** The upload_reason of a transcript over the wire cap. The existing deterministic label. */
|
|
67
|
+
export const OUTSIDE_ROOT_TOO_LARGE_REASON = "file_too_large";
|
|
68
|
+
const CATCH_UP_MARKER = "outside-root-catchup.json";
|
|
69
|
+
export function outsideRootCatchUpMarkerPath(paths) {
|
|
70
|
+
return path.join(paths.cursors_dir, CATCH_UP_MARKER);
|
|
71
|
+
}
|
|
72
|
+
/** Has the one-time 30-day look-back already run on this machine? */
|
|
73
|
+
export async function outsideRootCatchUpIsPending(paths) {
|
|
74
|
+
try {
|
|
75
|
+
await fs.access(outsideRootCatchUpMarkerPath(paths));
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Record that the look-back ran, so later ticks go back to the normal window.
|
|
84
|
+
* Best effort: a marker that cannot be written only means the next tick looks
|
|
85
|
+
* back once more, and it says so.
|
|
86
|
+
*/
|
|
87
|
+
export async function markOutsideRootCatchUpDone(paths, facts) {
|
|
88
|
+
try {
|
|
89
|
+
await writeJsonFile(outsideRootCatchUpMarkerPath(paths), {
|
|
90
|
+
completed_at: facts.now.toISOString(),
|
|
91
|
+
window_days: OUTSIDE_ROOT_HORIZON_DAYS,
|
|
92
|
+
sessions_seen: facts.sessions_seen,
|
|
93
|
+
});
|
|
94
|
+
console.error("[session-sync] outside-root catch-up look-back done", JSON.stringify({ reason: "catch_up_marker_written", window_days: OUTSIDE_ROOT_HORIZON_DAYS, sessions_seen: facts.sessions_seen }));
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
console.error("[session-sync] outside-root catch-up marker not written", JSON.stringify({ reason: "catch_up_marker_write_failed", ...describeError(error) }));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Widens a scan window to the catch-up horizon while the look-back is owed. */
|
|
101
|
+
export function widenForCatchUp(sinceMinutes, catchUpPending, normalMinutes) {
|
|
102
|
+
if (!catchUpPending)
|
|
103
|
+
return sinceMinutes;
|
|
104
|
+
return Math.max(sinceMinutes ?? normalMinutes, OUTSIDE_ROOT_HORIZON_MINUTES);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Does this cursor entry belong to an outside-root session that still owes its
|
|
108
|
+
* transcript? Such a session keeps the next scan window open back to its age,
|
|
109
|
+
* the same way an undurable in-root session does. A transcript over the wire
|
|
110
|
+
* cap never counts (it can never become durable), and neither does one older
|
|
111
|
+
* than the horizon (that is backfill's).
|
|
112
|
+
*/
|
|
113
|
+
export function outsideRootEntryOwesUpload(entry, now = new Date()) {
|
|
114
|
+
if (rootScopeForReason(entry.reason) !== "outside")
|
|
115
|
+
return false;
|
|
116
|
+
if (entry.uploaded_object_key && !isRedactionStubObjectKey(entry.uploaded_object_key))
|
|
117
|
+
return false;
|
|
118
|
+
if (entry.byte_size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES)
|
|
119
|
+
return false;
|
|
120
|
+
return now.getTime() - entry.file_mtime_ms <= OUTSIDE_ROOT_HORIZON_MINUTES * 60_000;
|
|
121
|
+
}
|
|
122
|
+
/** Is this scan result a session outside every approved root with no worktree of its own? */
|
|
123
|
+
function isUnclaimedOutsideRootResult(result) {
|
|
124
|
+
return !result.worktree && rootScopeForReason(result.reason) === "outside";
|
|
125
|
+
}
|
|
126
|
+
/** Already durable remotely with the bytes the scan just read: it needs no upload. */
|
|
127
|
+
function isDurableAndUnchanged(result, entry) {
|
|
128
|
+
return Boolean(entry &&
|
|
129
|
+
entry.uploaded_object_key &&
|
|
130
|
+
!isRedactionStubObjectKey(entry.uploaded_object_key) &&
|
|
131
|
+
!entry.stubbed &&
|
|
132
|
+
result.content_hash_sha256 &&
|
|
133
|
+
entry.file_hash_sha256 === result.content_hash_sha256);
|
|
134
|
+
}
|
|
135
|
+
function sightingsOf(scan, plan) {
|
|
136
|
+
const sightings = [];
|
|
137
|
+
for (const result of scan.codexAttribution.results) {
|
|
138
|
+
if (isUnclaimedOutsideRootResult(result)) {
|
|
139
|
+
sightings.push({ source: "codex", sessionId: result.codex_session_id, result });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// A machine that opted out of Claude has no Claude results to begin with
|
|
143
|
+
// (`session-sync-scan.ts` returns an empty scan); the guard keeps this step
|
|
144
|
+
// honest on its own as well.
|
|
145
|
+
if (plan.claudeEnabled) {
|
|
146
|
+
for (const result of scan.claudeAttribution.results) {
|
|
147
|
+
if (isUnclaimedOutsideRootResult(result)) {
|
|
148
|
+
sightings.push({ source: "claude_code", sessionId: result.claude_session_id, result });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return sightings;
|
|
153
|
+
}
|
|
154
|
+
export function outsideRootKey(source, sessionId) {
|
|
155
|
+
return `${source}:${sessionId}`;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Pick this tick's outside-root sessions and give them the synthetic workspace.
|
|
159
|
+
* Returns the scan to upload from (the input scan when nothing is admitted) with
|
|
160
|
+
* the admission facts the upload and the report steps read.
|
|
161
|
+
*/
|
|
162
|
+
export async function admitOutsideRootSessions(options) {
|
|
163
|
+
const { plan, scan } = options;
|
|
164
|
+
const sightings = sightingsOf(scan, plan);
|
|
165
|
+
if (sightings.length === 0) {
|
|
166
|
+
return { ...scan, outsideRoots: null };
|
|
167
|
+
}
|
|
168
|
+
const cursorFor = (source) => source === "codex" ? plan.codexCursorBefore : plan.claudeCursorBefore;
|
|
169
|
+
const free = [];
|
|
170
|
+
const owed = [];
|
|
171
|
+
const holds = new Map();
|
|
172
|
+
let tooLarge = 0;
|
|
173
|
+
for (const sighting of sightings) {
|
|
174
|
+
const entry = cursorFor(sighting.source).sessions[sighting.sessionId];
|
|
175
|
+
if (sighting.result.byte_size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
|
|
176
|
+
holds.set(outsideRootKey(sighting.source, sighting.sessionId), OUTSIDE_ROOT_TOO_LARGE_REASON);
|
|
177
|
+
tooLarge += 1;
|
|
178
|
+
}
|
|
179
|
+
else if (isDurableAndUnchanged(sighting.result, entry)) {
|
|
180
|
+
free.push(sighting);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
owed.push(sighting);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Newest first, with the session id as a tie-break so two ticks over the same
|
|
187
|
+
// files pick the same sessions.
|
|
188
|
+
owed.sort((left, right) => right.result.session_file_mtime_ms - left.result.session_file_mtime_ms ||
|
|
189
|
+
left.sessionId.localeCompare(right.sessionId));
|
|
190
|
+
const riding = owed.slice(0, OUTSIDE_ROOT_SESSIONS_PER_TICK);
|
|
191
|
+
const waiting = owed.slice(OUTSIDE_ROOT_SESSIONS_PER_TICK);
|
|
192
|
+
for (const sighting of waiting) {
|
|
193
|
+
holds.set(outsideRootKey(sighting.source, sighting.sessionId), OUTSIDE_ROOT_WAITING_REASON);
|
|
194
|
+
}
|
|
195
|
+
const admitted = [...riding, ...free];
|
|
196
|
+
const counts = {
|
|
197
|
+
outside_root_sessions: sightings.length,
|
|
198
|
+
riding_owed: riding.length,
|
|
199
|
+
riding_already_durable: free.length,
|
|
200
|
+
waiting_next_tick: waiting.length,
|
|
201
|
+
too_large: tooLarge,
|
|
202
|
+
codex_riding: admitted.filter((sighting) => sighting.source === "codex").length,
|
|
203
|
+
claude_riding: admitted.filter((sighting) => sighting.source === "claude_code").length,
|
|
204
|
+
};
|
|
205
|
+
const budget = {
|
|
206
|
+
sessions_per_tick: OUTSIDE_ROOT_SESSIONS_PER_TICK,
|
|
207
|
+
bytes_per_tick: OUTSIDE_ROOT_BYTES_PER_TICK,
|
|
208
|
+
objects_per_tick: OUTSIDE_ROOT_OBJECTS_PER_TICK,
|
|
209
|
+
};
|
|
210
|
+
if (admitted.length === 0) {
|
|
211
|
+
console.error("[session-sync] outside-root raw upload", JSON.stringify({ reason: "nothing_to_upload_outside_roots", ...counts, ...budget }));
|
|
212
|
+
return { ...scan, outsideRoots: { workspace: null, holds, counts } };
|
|
213
|
+
}
|
|
214
|
+
const workspace = outsideRootsWorkspace(options.homeDir);
|
|
215
|
+
try {
|
|
216
|
+
await ensureOutsideRootsWorkspace(workspace);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
// Not silent, and not a crash: every outside-root session keeps waiting and
|
|
220
|
+
// says why, and the in-root sessions of this tick are unaffected.
|
|
221
|
+
for (const sighting of admitted) {
|
|
222
|
+
holds.set(outsideRootKey(sighting.source, sighting.sessionId), "outside_root_workspace_unavailable");
|
|
223
|
+
}
|
|
224
|
+
console.error("[session-sync] outside-root raw upload", JSON.stringify({ reason: "workspace_unavailable", ...counts, ...budget, ...describeError(error) }));
|
|
225
|
+
return { ...scan, outsideRoots: { workspace: null, holds, counts } };
|
|
226
|
+
}
|
|
227
|
+
const admittedKeys = new Set(admitted.map((sighting) => outsideRootKey(sighting.source, sighting.sessionId)));
|
|
228
|
+
const labelled = {
|
|
229
|
+
...scan,
|
|
230
|
+
codexAttribution: {
|
|
231
|
+
...scan.codexAttribution,
|
|
232
|
+
results: scan.codexAttribution.results.map((result) => admittedKeys.has(outsideRootKey("codex", result.codex_session_id)) && isUnclaimedOutsideRootResult(result)
|
|
233
|
+
? { ...result, worktree: workspace }
|
|
234
|
+
: result),
|
|
235
|
+
},
|
|
236
|
+
claudeAttribution: {
|
|
237
|
+
...scan.claudeAttribution,
|
|
238
|
+
results: scan.claudeAttribution.results.map((result) => admittedKeys.has(outsideRootKey("claude_code", result.claude_session_id)) && isUnclaimedOutsideRootResult(result)
|
|
239
|
+
? { ...result, worktree: workspace }
|
|
240
|
+
: result),
|
|
241
|
+
},
|
|
242
|
+
outsideRoots: { workspace, holds, counts },
|
|
243
|
+
};
|
|
244
|
+
console.error("[session-sync] outside-root raw upload", JSON.stringify({ reason: "outside_root_sessions_admitted", ...counts, ...budget }));
|
|
245
|
+
return labelled;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Says why an outside-root session did not ride this tick, on its report row.
|
|
249
|
+
* A row that has a pointer, or a reason the upload itself recorded, is left
|
|
250
|
+
* alone; the rest were never offered an upload, so the step that decided
|
|
251
|
+
* ("waiting for a later tick", "over the wire cap") names it instead of the old
|
|
252
|
+
* "not uploadable" label, which stopped being true with this ticket.
|
|
253
|
+
*/
|
|
254
|
+
export function applyOutsideRootHolds(rows, holds) {
|
|
255
|
+
if (!holds || holds.size === 0)
|
|
256
|
+
return rows;
|
|
257
|
+
return rows.map((row) => {
|
|
258
|
+
const hold = holds.get(outsideRootKey(row.source, row.codex_session_id));
|
|
259
|
+
if (!hold || row.raw_evidence_pointer_id || row.upload_state !== "not_uploaded")
|
|
260
|
+
return row;
|
|
261
|
+
if (row.upload_reason && !row.upload_reason.startsWith("not_uploadable_attribution_state"))
|
|
262
|
+
return row;
|
|
263
|
+
return { ...row, upload_reason: hold };
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The outside-root pass gets its own allowance, so a busy in-root tick cannot
|
|
268
|
+
* starve it and it cannot take the in-root room. The disk gate and the image
|
|
269
|
+
* description session stay shared (they live on the same budget object).
|
|
270
|
+
*/
|
|
271
|
+
export function giveOutsideRootsTheirOwnAllowance(budget) {
|
|
272
|
+
budget.remainingBytes = OUTSIDE_ROOT_BYTES_PER_TICK;
|
|
273
|
+
budget.remainingObjects = OUTSIDE_ROOT_OBJECTS_PER_TICK;
|
|
274
|
+
}
|
|
275
|
+
/** The sync target list with the outside-roots workspace moved last, so in-root evidence goes first. */
|
|
276
|
+
export function outsideRootsLast(worktrees) {
|
|
277
|
+
return [
|
|
278
|
+
...worktrees.filter((worktree) => !isOutsideRootsWorkspace(worktree)),
|
|
279
|
+
...worktrees.filter((worktree) => isOutsideRootsWorkspace(worktree)),
|
|
280
|
+
];
|
|
281
|
+
}
|
|
@@ -23,6 +23,7 @@ import { CLAUDE_CURSOR_FILENAME, emptyRawEvidenceCursorState, readRawEvidenceCur
|
|
|
23
23
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
24
24
|
import { readLocalUploadSpoolState, } from "../spool/local-spool.js";
|
|
25
25
|
import { liveSyncCursorEntryRequiresRetry } from "./session-sync-attribution.js";
|
|
26
|
+
import { outsideRootCatchUpIsPending } from "./session-sync-outside-roots.js";
|
|
26
27
|
/**
|
|
27
28
|
* The bound applied when a retry is pending but no cursor entry names the
|
|
28
29
|
* stuck file (e.g. a legacy spooled upload recorded before per-source retry
|
|
@@ -46,7 +47,7 @@ export async function planSyncFromLocalState(options) {
|
|
|
46
47
|
const config = await readLocalCollectorConfig(options.paths).catch(() => null);
|
|
47
48
|
const claudeEnabled = config?.collect_claude_jsonl !== false;
|
|
48
49
|
const collectionRoots = normalizeCollectionRoots(options.approvedCollectionRoots ?? config?.default_repo_paths ?? []);
|
|
49
|
-
const [codexCursorBefore, claudeCursorBefore, uploadSpool] = await Promise.all([
|
|
50
|
+
const [codexCursorBefore, claudeCursorBefore, uploadSpool, outsideRootCatchUpPending] = await Promise.all([
|
|
50
51
|
readRawEvidenceCursor(options.paths).catch(() => emptyRawEvidenceCursorState()),
|
|
51
52
|
claudeEnabled
|
|
52
53
|
? readRawEvidenceCursor(options.paths, {
|
|
@@ -54,6 +55,7 @@ export async function planSyncFromLocalState(options) {
|
|
|
54
55
|
}).catch(() => emptyRawEvidenceCursorState())
|
|
55
56
|
: Promise.resolve(emptyRawEvidenceCursorState()),
|
|
56
57
|
readLocalUploadSpoolState(options.paths).catch(() => null),
|
|
58
|
+
outsideRootCatchUpIsPending(options.paths),
|
|
57
59
|
]);
|
|
58
60
|
// A spooled upload from a CLI old enough not to have recorded its source
|
|
59
61
|
// could have come from either one, so both sources own it until it clears.
|
|
@@ -73,6 +75,7 @@ export async function planSyncFromLocalState(options) {
|
|
|
73
75
|
sourceHasPendingRetries("claude_code", uploadSpool, legacyRetryPending, claudeSourceRetryPending, claudeCursorBefore),
|
|
74
76
|
codexRetrySinceMinutes: boundedRetrySinceMinutes(codexCursorBefore, options.now),
|
|
75
77
|
claudeRetrySinceMinutes: boundedRetrySinceMinutes(claudeCursorBefore, options.now),
|
|
78
|
+
outsideRootCatchUpPending,
|
|
76
79
|
};
|
|
77
80
|
}
|
|
78
81
|
/**
|
|
@@ -85,7 +88,7 @@ export async function planSyncFromLocalState(options) {
|
|
|
85
88
|
export function boundedRetrySinceMinutes(cursor, now) {
|
|
86
89
|
let oldestUndurableMtimeMs = null;
|
|
87
90
|
for (const entry of Object.values(cursor.sessions)) {
|
|
88
|
-
if (!liveSyncCursorEntryRequiresRetry(entry))
|
|
91
|
+
if (!liveSyncCursorEntryRequiresRetry(entry, now))
|
|
89
92
|
continue;
|
|
90
93
|
if (oldestUndurableMtimeMs === null ||
|
|
91
94
|
entry.file_mtime_ms < oldestUndurableMtimeMs) {
|
|
@@ -14,7 +14,10 @@ import { scanAndAttributeClaudeSessions, } from "../adapters/claude-attribution.
|
|
|
14
14
|
import { CLAUDE_CURSOR_FILENAME } from "../cursors/raw-evidence-cursor.js";
|
|
15
15
|
import { clearSourceRetryFailure, recordSourceRetryFailure, } from "../spool/local-spool.js";
|
|
16
16
|
import { claudeAttributionReadFailureCount, codexAttributionReadFailureCount, } from "./agent-session-report.js";
|
|
17
|
+
import { widenForCatchUp } from "./session-sync-outside-roots.js";
|
|
17
18
|
const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
|
|
19
|
+
/** The Claude scan's own default window when nothing widens it: the last 24 hours. */
|
|
20
|
+
const CLAUDE_DEFAULT_WINDOW_MINUTES = 24 * 60;
|
|
18
21
|
/**
|
|
19
22
|
* Scan and attribute both session sources, then settle each source's scan-retry
|
|
20
23
|
* bookkeeping against what the scan actually found.
|
|
@@ -31,6 +34,7 @@ export async function scanAndAttributeBothSources(options) {
|
|
|
31
34
|
collectionRoots: plan.collectionRoots,
|
|
32
35
|
retryPending: plan.codexRetryPending,
|
|
33
36
|
retrySinceMinutes: plan.codexRetrySinceMinutes,
|
|
37
|
+
outsideRootCatchUpPending: plan.outsideRootCatchUpPending,
|
|
34
38
|
});
|
|
35
39
|
// First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
|
|
36
40
|
// days so the first sync captures retroactive history instead of only 24h.
|
|
@@ -45,6 +49,7 @@ export async function scanAndAttributeBothSources(options) {
|
|
|
45
49
|
retryPending: plan.claudeRetryPending,
|
|
46
50
|
retrySinceMinutes: plan.claudeRetrySinceMinutes,
|
|
47
51
|
firstRunBackfill,
|
|
52
|
+
outsideRootCatchUpPending: plan.outsideRootCatchUpPending,
|
|
48
53
|
});
|
|
49
54
|
await reconcileSourceScanRetry({
|
|
50
55
|
paths: options.paths,
|
|
@@ -72,9 +77,9 @@ export async function scanAndAttributeBothSources(options) {
|
|
|
72
77
|
* is always scanned, unlike Claude which can be disabled entirely.
|
|
73
78
|
*/
|
|
74
79
|
async function scanCodexSessionsForSync(options) {
|
|
75
|
-
const sinceMinutes = options.retryPending
|
|
80
|
+
const sinceMinutes = widenForCatchUp(options.retryPending
|
|
76
81
|
? options.retrySinceMinutes
|
|
77
|
-
: CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES;
|
|
82
|
+
: CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, options.outsideRootCatchUpPending, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES);
|
|
78
83
|
let scan = await scanAndAttributeCodexSessions({
|
|
79
84
|
sessionsDirs: defaultCodexSessionDirs(options.homeDir),
|
|
80
85
|
worktrees: options.worktrees,
|
|
@@ -107,11 +112,11 @@ async function scanCodexSessionsForSync(options) {
|
|
|
107
112
|
async function scanClaudeSessionsForSync(options) {
|
|
108
113
|
if (!options.claudeEnabled)
|
|
109
114
|
return emptyClaudeScan();
|
|
110
|
-
const sinceMinutes = options.retryPending
|
|
115
|
+
const sinceMinutes = widenForCatchUp(options.retryPending
|
|
111
116
|
? options.retrySinceMinutes
|
|
112
117
|
: options.firstRunBackfill
|
|
113
118
|
? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
|
|
114
|
-
: undefined;
|
|
119
|
+
: undefined, options.outsideRootCatchUpPending, CLAUDE_DEFAULT_WINDOW_MINUTES);
|
|
115
120
|
const projectsDir = path.join(options.homeDir, ".claude", "projects");
|
|
116
121
|
let scan = await scanAndAttributeClaudeSessions({
|
|
117
122
|
projectsDir,
|
|
@@ -10,6 +10,8 @@ import { startLocalWorkContext, startLocalWorkContextForAttributedTarget, } from
|
|
|
10
10
|
import { LocalUploadBlockedError, syncLocalAmbientEnvelope, } from "../upload.js";
|
|
11
11
|
import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET, } from "../adapters/raw-evidence.js";
|
|
12
12
|
import { liveSyncTargetKey, liveSyncTargetWorktrees, matchesLiveSyncWorktree, } from "./session-sync-attribution.js";
|
|
13
|
+
import { isOutsideRootsWorkspace } from "./backfill-outside-roots.js";
|
|
14
|
+
import { giveOutsideRootsTheirOwnAllowance, outsideRootsLast, } from "./session-sync-outside-roots.js";
|
|
13
15
|
import { runYieldingAtBoundaries } from "./sync-yield.js";
|
|
14
16
|
const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
|
|
15
17
|
const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
@@ -22,10 +24,12 @@ const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
|
22
24
|
*/
|
|
23
25
|
export async function syncEveryTargetWorktree(options) {
|
|
24
26
|
const { run, scan } = options;
|
|
25
|
-
|
|
27
|
+
// BLI-17916: the outside-roots workspace goes last, so in-root evidence is
|
|
28
|
+
// never held up behind it.
|
|
29
|
+
const syncWorktrees = outsideRootsLast(liveSyncTargetWorktrees(run.worktrees, [
|
|
26
30
|
...scan.codexAttribution.results,
|
|
27
31
|
...scan.claudeAttribution.results,
|
|
28
|
-
]);
|
|
32
|
+
]));
|
|
29
33
|
const discoveredWorktreeKeys = new Set(run.worktrees.map(liveSyncTargetKey));
|
|
30
34
|
// sessionId -> prior durable pointer for damped sessions (drives the
|
|
31
35
|
// growth_damped count + the skip_main decision).
|
|
@@ -40,21 +44,28 @@ export async function syncEveryTargetWorktree(options) {
|
|
|
40
44
|
items: syncWorktrees,
|
|
41
45
|
shouldYield: run.yieldCheck,
|
|
42
46
|
describe: (outcome) => ({ status: outcome.sync.status }),
|
|
43
|
-
runOne: (worktree) =>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
47
|
+
runOne: (worktree) => {
|
|
48
|
+
// The outside-root pass has its own per-tick allowance (bytes and files),
|
|
49
|
+
// so a busy in-root tick cannot starve it and it cannot take in-root room.
|
|
50
|
+
if (isOutsideRootsWorkspace(worktree)) {
|
|
51
|
+
giveOutsideRootsTheirOwnAllowance(rawEvidenceBudget);
|
|
52
|
+
}
|
|
53
|
+
return syncOneWorktree({
|
|
54
|
+
run,
|
|
55
|
+
now: options.now,
|
|
56
|
+
plan: options.plan,
|
|
57
|
+
scan,
|
|
58
|
+
worktree,
|
|
59
|
+
syncWorktrees,
|
|
60
|
+
rawEvidenceBudget,
|
|
61
|
+
// A target git discovery never produced is one the fallback attribution
|
|
62
|
+
// synthesized, and it syncs through its own attributed work context.
|
|
63
|
+
isDiscoveredWorktree: discoveredWorktreeKeys.has(liveSyncTargetKey(worktree)),
|
|
64
|
+
recordDampedClaudeSession: (sessionId, priorPointer) => {
|
|
65
|
+
dampedClaudePointers.set(sessionId, priorPointer);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
},
|
|
58
69
|
});
|
|
59
70
|
const outcomes = loop.results;
|
|
60
71
|
const everyWorktreeUploaded = outcomes.every((outcome) => outcome.sync.status === "uploaded");
|
|
@@ -87,12 +98,14 @@ function claudeDurablePointersFromCursor(claudeCursorBefore) {
|
|
|
87
98
|
*/
|
|
88
99
|
async function syncOneWorktree(options) {
|
|
89
100
|
const { run, worktree } = options;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
101
|
+
// The outside-roots workspace starts its work context the way `cockpit
|
|
102
|
+
// backfill` does (a plain folder, no attributed-target proof): it is a
|
|
103
|
+
// transport folder the collector owns, not a repo anyone attributed.
|
|
104
|
+
const outsideRoots = isOutsideRootsWorkspace(worktree);
|
|
105
|
+
const attributedSyntheticTarget = options.isDiscoveredWorktree || outsideRoots ? null : worktree;
|
|
106
|
+
const ensureContext = () => startWorktreeContext(options, attributedSyntheticTarget, outsideRoots);
|
|
94
107
|
let context = null;
|
|
95
|
-
if (run.startContexts || attributedSyntheticTarget) {
|
|
108
|
+
if (run.startContexts || attributedSyntheticTarget || outsideRoots) {
|
|
96
109
|
context = await ensureContext();
|
|
97
110
|
}
|
|
98
111
|
const syncOptions = ambientEnvelopeForWorktree(options);
|
|
@@ -112,15 +125,21 @@ async function syncOneWorktree(options) {
|
|
|
112
125
|
}
|
|
113
126
|
return { worktree, context, sync };
|
|
114
127
|
}
|
|
115
|
-
function startWorktreeContext(options, attributedSyntheticTarget) {
|
|
128
|
+
function startWorktreeContext(options, attributedSyntheticTarget, outsideRoots) {
|
|
116
129
|
const { run, worktree } = options;
|
|
117
130
|
const contextOptions = {
|
|
118
131
|
homeDir: run.homeDir,
|
|
119
132
|
repoRoot: worktree.repo_root,
|
|
120
|
-
|
|
133
|
+
// The caller's ticket and branch belong to the caller's repo, never to the
|
|
134
|
+
// outside-roots workspace.
|
|
135
|
+
...(outsideRoots ? {} : { activeTicketId: run.activeTicketId }),
|
|
121
136
|
operatorId: run.operatorId,
|
|
122
137
|
sessionId: run.sessionId,
|
|
123
|
-
...(
|
|
138
|
+
...(outsideRoots
|
|
139
|
+
? { branch: worktree.branch, leaveActiveContext: true }
|
|
140
|
+
: attributedSyntheticTarget
|
|
141
|
+
? {}
|
|
142
|
+
: { branch: run.branch }),
|
|
124
143
|
};
|
|
125
144
|
return attributedSyntheticTarget
|
|
126
145
|
? startLocalWorkContextForAttributedTarget(contextOptions, attributedSyntheticTarget)
|
|
@@ -43,7 +43,8 @@ import { getCollectorRuntimePaths } from "../local-state.js";
|
|
|
43
43
|
import { buildAgentSessionReport, buildAgentSessionSummary, } from "./agent-session-report.js";
|
|
44
44
|
import { collectSessionCommits, withSessionCommits } from "../session-commits.js";
|
|
45
45
|
import { planSyncFromLocalState } from "./session-sync-plan.js";
|
|
46
|
-
import { scanAndAttributeBothSources } from "./session-sync-scan.js";
|
|
46
|
+
import { scanAndAttributeBothSources, sourceScanFailureReason, } from "./session-sync-scan.js";
|
|
47
|
+
import { admitOutsideRootSessions, applyOutsideRootHolds, markOutsideRootCatchUpDone, } from "./session-sync-outside-roots.js";
|
|
47
48
|
import { syncEveryTargetWorktree } from "./session-sync-upload.js";
|
|
48
49
|
import { reportSessionsAndAdvanceCursors } from "./session-sync-record.js";
|
|
49
50
|
import { decideSyncHealth } from "./session-sync-health.js";
|
|
@@ -55,6 +56,11 @@ export { sourceScanFailureReason, sourceScanRetryReason, } from "./session-sync-
|
|
|
55
56
|
export { SYNC_FAILED_WITHOUT_REASON } from "./session-sync-failures.js";
|
|
56
57
|
export { nothingInRootCount } from "./session-sync-health.js";
|
|
57
58
|
export { isDeliveryHoldReason, summarizeDeliveryHold, } from "./session-sync-hold.js";
|
|
59
|
+
/** Did either session store fail to read this tick? A failed read means the scan saw less than exists. */
|
|
60
|
+
function scanHadReadFailure(scan) {
|
|
61
|
+
return (sourceScanFailureReason("codex", scan.codexAttribution) !== null ||
|
|
62
|
+
sourceScanFailureReason("claude_code", scan.claudeAttribution) !== null);
|
|
63
|
+
}
|
|
58
64
|
/**
|
|
59
65
|
* Shared dual-source sync orchestration for single-repo and parent-folder
|
|
60
66
|
* modes. Codex AND Claude Code sessions are scanned and attributed once across
|
|
@@ -62,7 +68,8 @@ export { isDeliveryHoldReason, summarizeDeliveryHold, } from "./session-sync-hol
|
|
|
62
68
|
* fallback-attributed transcripts (Codex + Claude main + sidecars). The
|
|
63
69
|
* ambiguous/unattributed/skipped sessions inside an approved root retain those
|
|
64
70
|
* labels while syncing once through that root's synthetic folder workspace.
|
|
65
|
-
* Outside-root sessions
|
|
71
|
+
* Outside-root sessions keep their labels and upload through the collector's
|
|
72
|
+
* own outside-roots workspace, a few per tick (BLI-17916). The session row's upload state
|
|
66
73
|
* maps ONLY from the main-file outcome (D3); sidecar outcomes aggregate into
|
|
67
74
|
* CLI counts.
|
|
68
75
|
*
|
|
@@ -80,13 +87,21 @@ export async function runAttributedWorktreeSync(options) {
|
|
|
80
87
|
now,
|
|
81
88
|
approvedCollectionRoots: options.collectionRoots,
|
|
82
89
|
});
|
|
83
|
-
const
|
|
90
|
+
const scanned = await scanAndAttributeBothSources({
|
|
84
91
|
paths,
|
|
85
92
|
homeDir,
|
|
86
93
|
now,
|
|
87
94
|
worktrees: options.worktrees,
|
|
88
95
|
plan,
|
|
89
96
|
});
|
|
97
|
+
// BLI-17916: roots are a label, never a gate. Outside-root sessions ride this
|
|
98
|
+
// tick's upload under the synthetic workspace, a few per tick, newest first.
|
|
99
|
+
const scan = await admitOutsideRootSessions({
|
|
100
|
+
homeDir: options.homeDir,
|
|
101
|
+
plan,
|
|
102
|
+
scan: scanned,
|
|
103
|
+
now,
|
|
104
|
+
});
|
|
90
105
|
const worktreePass = await syncEveryTargetWorktree({
|
|
91
106
|
run: options,
|
|
92
107
|
now,
|
|
@@ -102,18 +117,20 @@ export async function runAttributedWorktreeSync(options) {
|
|
|
102
117
|
}
|
|
103
118
|
console.error("[session-sync] bindings read", JSON.stringify({ bound_worktrees: ticketByWorktree.size, reason: "active_work_context" }));
|
|
104
119
|
// BLI-13028: which commits each session's working tree created while it ran.
|
|
120
|
+
// Read from the scan as it was BEFORE the outside-root step: the synthetic
|
|
121
|
+
// workspace is not a git tree, and no commit belongs to it.
|
|
105
122
|
const commitsBySession = await collectSessionCommits([
|
|
106
|
-
...
|
|
107
|
-
...
|
|
123
|
+
...scanned.codexAttribution.results.map((result) => ({ key: `codex:${result.codex_session_id}`, result })),
|
|
124
|
+
...scanned.claudeAttribution.results.map((result) => ({ key: `claude_code:${result.claude_session_id}`, result })),
|
|
108
125
|
]);
|
|
109
|
-
const sessions = withSessionCommits(buildAgentSessionReport({
|
|
126
|
+
const sessions = applyOutsideRootHolds(withSessionCommits(buildAgentSessionReport({
|
|
110
127
|
ticketByWorktree,
|
|
111
128
|
codexResults: scan.codexAttribution.results,
|
|
112
129
|
claudeResults: scan.claudeAttribution.results,
|
|
113
130
|
outcomes: worktreePass.outcomes,
|
|
114
131
|
now,
|
|
115
132
|
claudePriorDurablePointers: worktreePass.claudePriorDurablePointers,
|
|
116
|
-
}), commitsBySession);
|
|
133
|
+
}), commitsBySession), scan.outsideRoots?.holds);
|
|
117
134
|
// BLI-4341: every session should arrive counted. Say how many did, and say
|
|
118
135
|
// it on the success path too: a silent drop from "all" to "some" is exactly
|
|
119
136
|
// the kind of decay nobody notices.
|
|
@@ -133,6 +150,11 @@ export async function runAttributedWorktreeSync(options) {
|
|
|
133
150
|
sessions,
|
|
134
151
|
outcomes: worktreePass.outcomes,
|
|
135
152
|
});
|
|
153
|
+
// The one-time 30-day look-back is done once a tick read both stores without
|
|
154
|
+
// a failure and recorded what it saw in the cursors.
|
|
155
|
+
if (plan.outsideRootCatchUpPending && !scanHadReadFailure(scan)) {
|
|
156
|
+
await markOutsideRootCatchUpDone(paths, { now, sessions_seen: sessions.length });
|
|
157
|
+
}
|
|
136
158
|
const summary = buildAgentSessionSummary({
|
|
137
159
|
codexAttribution: scan.codexAttribution,
|
|
138
160
|
claudeAttribution: scan.claudeAttribution,
|
|
@@ -63,7 +63,7 @@ export async function reportNoWorktreeSync(command, io, run, dedup) {
|
|
|
63
63
|
if (run.notice?.startsWith("nothing_in_root")) {
|
|
64
64
|
// Says out loud what the receipt now says to the dashboard: the sessions
|
|
65
65
|
// this machine ran were all outside the folders it is allowed to look at.
|
|
66
|
-
writeLine(io.stdout, `Every session seen this run was outside your approved folders (${run.notice}).
|
|
66
|
+
writeLine(io.stdout, `Every session seen this run was outside your approved folders (${run.notice}). No transcript was uploaded for them this run (the log line "outside-root raw upload" says why), and nothing is broken.`);
|
|
67
67
|
}
|
|
68
68
|
writeAgentSessionSummary(io, run.summary);
|
|
69
69
|
if (gc && !gc.skipped)
|
|
@@ -64,7 +64,9 @@ async function writeLocalWorkContext(options, attributedIdentity) {
|
|
|
64
64
|
existingContext,
|
|
65
65
|
});
|
|
66
66
|
await writeJsonFile(workContextFile(paths, identity.worktree_fingerprint), context);
|
|
67
|
-
|
|
67
|
+
if (!options.leaveActiveContext) {
|
|
68
|
+
await writeJsonFile(paths.active_work_context_file, context);
|
|
69
|
+
}
|
|
68
70
|
return context;
|
|
69
71
|
}
|
|
70
72
|
/**
|