@bli-cockpit/cli 0.2.156 → 0.2.158
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/codex-attribution.js +2 -0
- package/dist/commands/backfill-checkpoint.js +11 -2
- package/dist/commands/backfill-report.js +12 -4
- package/dist/commands/backfill-scan.js +35 -14
- package/dist/commands/backfill-stub-locate.js +104 -0
- package/dist/commands/backfill-stub-reoffer.js +46 -7
- package/dist/commands/backfill-upload.js +10 -3
- package/dist/commands/backfill.js +31 -17
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-record.js +12 -0
- package/dist/commands/session-sync-upload.js +13 -9
- package/dist/commands/session-sync.js +1 -0
- package/dist/commands/sync-run.js +8 -1
- package/dist/commands/sync-stub-heal.js +155 -0
- package/dist/commands/sync-yield.js +61 -0
- package/dist/cursors/raw-evidence-cursor.js +10 -1
- package/dist/cursors/raw-evidence-stub-entry.js +39 -0
- package/package.json +1 -1
|
@@ -167,6 +167,7 @@ async function attributeOneSession(file, worktrees, collectionRoots) {
|
|
|
167
167
|
file_path: file.file,
|
|
168
168
|
file_name: fileName,
|
|
169
169
|
codex_session_id: sessionIdFromFileName(fileName) ?? shortHash(file.file),
|
|
170
|
+
session_id_source: sessionIdFromFileName(fileName) ? "file_name" : "path_hash",
|
|
170
171
|
cwd_basename: null,
|
|
171
172
|
cwd_hash: null,
|
|
172
173
|
session_file_mtime: new Date(file.mtimeMs).toISOString(),
|
|
@@ -197,6 +198,7 @@ async function attributeOneSession(file, worktrees, collectionRoots) {
|
|
|
197
198
|
const metaSessionId = sanitizeSessionId(signals.session_ids[0]);
|
|
198
199
|
if (metaSessionId) {
|
|
199
200
|
base.codex_session_id = metaSessionId;
|
|
201
|
+
base.session_id_source = "session_meta";
|
|
200
202
|
}
|
|
201
203
|
const primaryCwd = signals.cwds[0] ?? null;
|
|
202
204
|
if (primaryCwd) {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { recordBackfillCursorObservations, } from "../cursors/backfill-cursor.js";
|
|
12
12
|
import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
13
|
+
import { isRedactionStubObjectKey, markCursorEntryStubbed } from "../cursors/raw-evidence-stub-entry.js";
|
|
13
14
|
import { isRawEvidenceUploadableAttributionState } from "../raw-evidence-attribution-policy.js";
|
|
14
15
|
import { candidateCursorKey, compareBackfillCandidates, isAfterCursor, } from "./backfill-candidates.js";
|
|
15
16
|
import { indexDurableMainObjectKeys } from "./backfill-evidence-outcomes.js";
|
|
@@ -34,10 +35,11 @@ export async function recordBackfillDurableSessionPointers(options) {
|
|
|
34
35
|
// No pointer, no prior entry, or already pointed: nothing this pass owes.
|
|
35
36
|
// BLI-11102: a pointer the server named a redaction stub is not a
|
|
36
37
|
// delivery, so a real pointer replaces it and the mark clears.
|
|
37
|
-
|
|
38
|
+
// BLI-11119: an entry the cursor itself knows is `stubbed` counts too.
|
|
39
|
+
const replacesStub = Boolean(prior?.stub_reoffer || prior?.stubbed) && prior?.uploaded_object_key !== objectKey;
|
|
38
40
|
if (!objectKey || !prior || (prior.uploaded_object_key && !replacesStub))
|
|
39
41
|
continue;
|
|
40
|
-
if (replacesStub)
|
|
42
|
+
if (replacesStub && !isRedactionStubObjectKey(objectKey))
|
|
41
43
|
stubsReplaced += 1;
|
|
42
44
|
cursor.sessions[candidate.session_id] = deliveredCursorEntry(prior, candidate, objectKey, options.now);
|
|
43
45
|
changed = true;
|
|
@@ -71,8 +73,15 @@ function deliveredCursorEntry(prior, candidate, objectKey, now) {
|
|
|
71
73
|
uploaded_byte_size: candidate.byte_size,
|
|
72
74
|
last_seen_at: now.toISOString(),
|
|
73
75
|
};
|
|
76
|
+
// BLI-11119: a re-offer that stubbed again earned another marker, not the
|
|
77
|
+
// transcript. The entry says so and keeps its mark for the next pass.
|
|
78
|
+
if (isRedactionStubObjectKey(objectKey)) {
|
|
79
|
+
markCursorEntryStubbed(entry);
|
|
80
|
+
return entry;
|
|
81
|
+
}
|
|
74
82
|
// The mark says "this pointer is a stub"; the pointer written above is not.
|
|
75
83
|
delete entry.stub_reoffer;
|
|
84
|
+
delete entry.stubbed;
|
|
76
85
|
return entry;
|
|
77
86
|
}
|
|
78
87
|
export function advanceBackfillCursorThroughResolvedPrefix(options) {
|
|
@@ -26,7 +26,11 @@ export async function reportBackfillOutcome(command, io, ctx, upload) {
|
|
|
26
26
|
failureReason ??= sessionReportFailureReason(posted);
|
|
27
27
|
}
|
|
28
28
|
if (posted.acknowledged) {
|
|
29
|
-
|
|
29
|
+
// BLI-11112: the sync's stub pass saw only stubbed sessions, so it records
|
|
30
|
+
// their pointers and never moves the history cursor past anything else.
|
|
31
|
+
await checkpointResolvedBackfillProgress(ctx, upload, {
|
|
32
|
+
advanceCursor: !command.stubPass,
|
|
33
|
+
});
|
|
30
34
|
}
|
|
31
35
|
const completion = summarizeBackfillCompletion({
|
|
32
36
|
ctx,
|
|
@@ -35,7 +39,7 @@ export async function reportBackfillOutcome(command, io, ctx, upload) {
|
|
|
35
39
|
stoppedEarly: Boolean(blockedAt),
|
|
36
40
|
failureReason,
|
|
37
41
|
});
|
|
38
|
-
if (completion.status === "complete" && command.all) {
|
|
42
|
+
if (completion.status === "complete" && command.all && !command.stubPass) {
|
|
39
43
|
await writeAllHistoryCompletionMarker(ctx);
|
|
40
44
|
}
|
|
41
45
|
return {
|
|
@@ -47,7 +51,9 @@ export async function reportBackfillOutcome(command, io, ctx, upload) {
|
|
|
47
51
|
completion,
|
|
48
52
|
blockedAt,
|
|
49
53
|
}),
|
|
50
|
-
stub_reoffer: summarizeStubReoffer(ctx.stubReoffer, upload.uploadable),
|
|
54
|
+
stub_reoffer: summarizeStubReoffer(ctx.stubReoffer, upload.uploadable, posted.acknowledged ? upload.durableCandidateKeys : new Set(),
|
|
55
|
+
// One sync result per batch the loop reached; a stopped run reached fewer.
|
|
56
|
+
upload.batches.slice(0, upload.syncResults.length).flatMap((batch) => batch.candidates)),
|
|
51
57
|
};
|
|
52
58
|
}
|
|
53
59
|
/**
|
|
@@ -114,7 +120,7 @@ function sessionReportFailureReason(posted) {
|
|
|
114
120
|
* both writes wait on the report acknowledgement, because a cursor advanced
|
|
115
121
|
* past a session the server never recorded can never be walked back.
|
|
116
122
|
*/
|
|
117
|
-
async function checkpointResolvedBackfillProgress(ctx, upload) {
|
|
123
|
+
async function checkpointResolvedBackfillProgress(ctx, upload, options) {
|
|
118
124
|
const { paths, scan, cursor, now, deterministicSkipCandidateKeys } = ctx;
|
|
119
125
|
if (upload.durableCandidateKeys.size > 0) {
|
|
120
126
|
await recordBackfillDurableSessionPointers({
|
|
@@ -124,6 +130,8 @@ async function checkpointResolvedBackfillProgress(ctx, upload) {
|
|
|
124
130
|
now,
|
|
125
131
|
});
|
|
126
132
|
}
|
|
133
|
+
if (!options.advanceCursor)
|
|
134
|
+
return;
|
|
127
135
|
const cursorAdvanced = advanceBackfillCursorThroughResolvedPrefix({
|
|
128
136
|
cursor,
|
|
129
137
|
candidates: scan.candidates,
|
|
@@ -21,7 +21,9 @@ import { labelOutsideRootCandidates, outsideRootsWorkspace, } from "./backfill-o
|
|
|
21
21
|
import { reasonCountsFor } from "./backfill-reasons.js";
|
|
22
22
|
import { blockedBackfillResult } from "./backfill-result.js";
|
|
23
23
|
import { emptyStubReofferPlan, loadStubReofferPlan, reopenStubbedSessions, } from "./backfill-stub-reoffer.js";
|
|
24
|
+
import { loadStubCursorHashes, locateStubbedSessions } from "./backfill-stub-locate.js";
|
|
24
25
|
import { resolveBackfillScope } from "./backfill-scope.js";
|
|
26
|
+
import { stubSessionKey } from "../evidence-stubs-client.js";
|
|
25
27
|
/**
|
|
26
28
|
* SCAN: is this machine paired, what roots/sources/window apply, and what did
|
|
27
29
|
* the archived Codex + Claude session stores actually contain. Returns either
|
|
@@ -51,15 +53,19 @@ export async function scanBackfillRun(command, io, now) {
|
|
|
51
53
|
// BLI-11102: a redaction-crash stub is not a delivery. The server names
|
|
52
54
|
// them; those sessions reopen exactly like pointer-less ones. A dry run
|
|
53
55
|
// keeps its one-request contract (the reachability probe) and does not ask.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
// BLI-11112: the sync's stub pass brings the door's answer with it.
|
|
57
|
+
const stubReoffer = command.stubPass
|
|
58
|
+
? command.stubPass.plan
|
|
59
|
+
: command.dryRun
|
|
60
|
+
? emptyStubReofferPlan("dry_run")
|
|
61
|
+
: await loadStubReofferPlan({
|
|
62
|
+
paths,
|
|
63
|
+
dashboardUrl: scope.dashboardUrl,
|
|
64
|
+
sources,
|
|
65
|
+
fetchImpl: io.fetch,
|
|
66
|
+
});
|
|
62
67
|
reopenStubbedSessions(pointerlessTerminalSessions, stubReoffer);
|
|
68
|
+
const stubCursorHashes = await loadStubCursorHashes(paths, stubReoffer);
|
|
63
69
|
const scan = await scanBackfillSessions({
|
|
64
70
|
command,
|
|
65
71
|
homeDir: command.homeDir ?? os.homedir(),
|
|
@@ -69,6 +75,8 @@ export async function scanBackfillRun(command, io, now) {
|
|
|
69
75
|
window: scope.window,
|
|
70
76
|
cursor,
|
|
71
77
|
pointerlessTerminalSessions,
|
|
78
|
+
stubReoffer,
|
|
79
|
+
stubCursorHashes,
|
|
72
80
|
now,
|
|
73
81
|
});
|
|
74
82
|
const reasonCounts = await auditScannedCandidates(scan, worktreeDiscovery.incomplete_reasons);
|
|
@@ -125,12 +133,25 @@ async function scanBackfillSessions(options) {
|
|
|
125
133
|
const claudeAttribution = options.sources.includes("claude_code")
|
|
126
134
|
? await scanClaudeHistory(options, claudeProjectsDir, scanLimit)
|
|
127
135
|
: null;
|
|
136
|
+
// BLI-11119: a stubbed session is found by the id in its content or by the
|
|
137
|
+
// file hash its cursor entry recorded, across every root the scan walked.
|
|
138
|
+
const scanned = locateStubbedSessions({
|
|
139
|
+
plan: options.stubReoffer,
|
|
140
|
+
candidates: [
|
|
141
|
+
...(codexAttribution?.results.map(normalizeCodexCandidate) ?? []),
|
|
142
|
+
...(claudeAttribution?.results.map(normalizeClaudeCandidate) ?? []),
|
|
143
|
+
],
|
|
144
|
+
cursorHashes: options.stubCursorHashes,
|
|
145
|
+
sinceMinutes: options.window.since_minutes,
|
|
146
|
+
});
|
|
128
147
|
const selection = selectBackfillCandidates({
|
|
129
|
-
|
|
130
|
-
claudeAttribution,
|
|
148
|
+
scanned,
|
|
131
149
|
cursor: options.cursor,
|
|
132
150
|
pointerlessTerminalSessions: options.pointerlessTerminalSessions,
|
|
133
151
|
maxFiles: options.command.maxFiles,
|
|
152
|
+
onlySessions: options.command.stubPass
|
|
153
|
+
? new Set(options.command.stubPass.plan.sessions.keys())
|
|
154
|
+
: undefined,
|
|
134
155
|
});
|
|
135
156
|
// BLI-11091: a session outside every approved root is labelled, not
|
|
136
157
|
// refused. It rides the synthetic outside-roots workspace from here on, so
|
|
@@ -194,10 +215,9 @@ async function scanClaudeHistory(request, projectsDir, scanLimit) {
|
|
|
194
215
|
* so a `--max-files` cap spends its budget on them before newer history.
|
|
195
216
|
*/
|
|
196
217
|
function selectBackfillCandidates(options) {
|
|
197
|
-
const allCandidates =
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
]
|
|
218
|
+
const allCandidates = options.scanned
|
|
219
|
+
.filter((candidate) => !options.onlySessions ||
|
|
220
|
+
options.onlySessions.has(stubSessionKey(candidate.source, candidate.session_id)))
|
|
201
221
|
.filter((candidate) => isAfterCursor(candidate, options.cursor) ||
|
|
202
222
|
isPointerlessTerminalRetry(candidate, options.pointerlessTerminalSessions))
|
|
203
223
|
.sort((a, b) => compareBackfillCandidatesForRetry(a, b, options.pointerlessTerminalSessions));
|
|
@@ -257,6 +277,7 @@ function normalizeCodexCandidate(result) {
|
|
|
257
277
|
return {
|
|
258
278
|
source: "codex",
|
|
259
279
|
session_id: result.codex_session_id,
|
|
280
|
+
session_id_source: result.session_id_source,
|
|
260
281
|
file_path: result.file_path,
|
|
261
282
|
state: result.state,
|
|
262
283
|
reason: result.reason,
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where on this machine is the transcript behind a stub? (BLI-11119)
|
|
3
|
+
*
|
|
4
|
+
* The stubs door names a session by the id inside its object key, and the
|
|
5
|
+
* collector wrote that key with the id it read from the transcript's CONTENT
|
|
6
|
+
* (`session_meta.id` for Codex). A rollout's file name does not always carry
|
|
7
|
+
* that id: a resumed or forked session is filed as
|
|
8
|
+
* `rollout-<time>-<parent>_<child>.jsonl` while its first line names another
|
|
9
|
+
* id, and Codex moves files from `sessions/` into `archived_sessions/`. So a
|
|
10
|
+
* stubbed session is located the way it was keyed, across every root the scan
|
|
11
|
+
* walked, never by file name alone:
|
|
12
|
+
*
|
|
13
|
+
* 1. a scanned session whose id matches (`found_by: session_meta`, or
|
|
14
|
+
* `file_name` when the file's first line gave no id and the scan fell back
|
|
15
|
+
* to the name, which is then the id the key used too);
|
|
16
|
+
* 2. otherwise a scanned file whose sha256 equals the cursor entry's
|
|
17
|
+
* `file_hash_sha256` for that session (`found_by: file_hash`); it is offered
|
|
18
|
+
* under the STUBBED session's id, because the server retires a stub only for
|
|
19
|
+
* a successor that names the same session;
|
|
20
|
+
* 3. otherwise `not_found`, recorded on the cursor so the scheduled sync stops
|
|
21
|
+
* rescanning history for it every tick (see `sync-stub-heal.ts`).
|
|
22
|
+
*/
|
|
23
|
+
import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
24
|
+
import { stubSessionKey } from "../evidence-stubs-client.js";
|
|
25
|
+
/** How many per-session lines the locate log carries; the counts cover the rest. */
|
|
26
|
+
const LOGGED_SESSION_LIMIT = 25;
|
|
27
|
+
/** `codex:<id>` -> the file hash the cursor last recorded for that stubbed session. */
|
|
28
|
+
export async function loadStubCursorHashes(paths, plan) {
|
|
29
|
+
const hashes = new Map();
|
|
30
|
+
if (plan.sessions.size === 0)
|
|
31
|
+
return hashes;
|
|
32
|
+
for (const source of ["codex", "claude_code"]) {
|
|
33
|
+
const filename = source === "claude_code" ? CLAUDE_CURSOR_FILENAME : undefined;
|
|
34
|
+
const cursor = await readRawEvidenceCursor(paths, { filename }).catch(() => null);
|
|
35
|
+
if (!cursor)
|
|
36
|
+
continue;
|
|
37
|
+
for (const key of plan.sessions.keys()) {
|
|
38
|
+
if (!key.startsWith(`${source}:`))
|
|
39
|
+
continue;
|
|
40
|
+
const hash = cursor.sessions[key.slice(source.length + 1)]?.file_hash_sha256;
|
|
41
|
+
if (hash)
|
|
42
|
+
hashes.set(key, hash);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return hashes;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Match every stubbed session to a scanned file, record how on `plan.found_by`,
|
|
49
|
+
* and return the scanned candidates plus one candidate per hash match, carrying
|
|
50
|
+
* the stubbed session's id. Logs the outcome, found or not.
|
|
51
|
+
*/
|
|
52
|
+
export function locateStubbedSessions(options) {
|
|
53
|
+
const { plan } = options;
|
|
54
|
+
if (plan.sessions.size === 0)
|
|
55
|
+
return options.candidates;
|
|
56
|
+
const byKey = new Map();
|
|
57
|
+
const byHash = new Map();
|
|
58
|
+
for (const candidate of options.candidates) {
|
|
59
|
+
byKey.set(stubSessionKey(candidate.source, candidate.session_id), candidate);
|
|
60
|
+
if (candidate.content_hash_sha256) {
|
|
61
|
+
const hashKey = `${candidate.source}:${candidate.content_hash_sha256}`;
|
|
62
|
+
if (!byHash.has(hashKey))
|
|
63
|
+
byHash.set(hashKey, candidate);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const extra = [];
|
|
67
|
+
const sessions = [];
|
|
68
|
+
for (const key of plan.sessions.keys()) {
|
|
69
|
+
const found = locateOne(key, byKey, byHash, options.cursorHashes);
|
|
70
|
+
plan.found_by.set(key, found.foundBy);
|
|
71
|
+
if (found.candidate)
|
|
72
|
+
extra.push(found.candidate);
|
|
73
|
+
sessions.push({ session: key, found_by: found.foundBy });
|
|
74
|
+
}
|
|
75
|
+
const counts = { session_meta: 0, file_name: 0, file_hash: 0, not_found: 0 };
|
|
76
|
+
for (const session of sessions)
|
|
77
|
+
counts[session.found_by] += 1;
|
|
78
|
+
console.error("[backfill] stubbed sessions located", JSON.stringify({
|
|
79
|
+
reason: counts.not_found === sessions.length ? "stub_sessions_not_found" : "stub_sessions_located",
|
|
80
|
+
sessions: sessions.length,
|
|
81
|
+
...counts,
|
|
82
|
+
scanned_candidates: options.candidates.length,
|
|
83
|
+
since_minutes: options.sinceMinutes,
|
|
84
|
+
found_by: sessions.slice(0, LOGGED_SESSION_LIMIT),
|
|
85
|
+
}));
|
|
86
|
+
return extra.length > 0 ? [...options.candidates, ...extra] : options.candidates;
|
|
87
|
+
}
|
|
88
|
+
function locateOne(key, byKey, byHash, cursorHashes) {
|
|
89
|
+
const byId = byKey.get(key);
|
|
90
|
+
if (byId) {
|
|
91
|
+
const fromContent = byId.source === "codex" && byId.session_id_source === "session_meta";
|
|
92
|
+
return { foundBy: fromContent ? "session_meta" : "file_name" };
|
|
93
|
+
}
|
|
94
|
+
const at = key.indexOf(":");
|
|
95
|
+
const source = key.slice(0, at);
|
|
96
|
+
const hash = cursorHashes.get(key);
|
|
97
|
+
const byContent = hash ? byHash.get(`${source}:${hash}`) : undefined;
|
|
98
|
+
if (!byContent)
|
|
99
|
+
return { foundBy: "not_found" };
|
|
100
|
+
return {
|
|
101
|
+
foundBy: "file_hash",
|
|
102
|
+
candidate: { ...byContent, session_id: key.slice(at + 1), session_id_source: undefined },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
2
|
+
import { markCursorEntryStubbed } from "../cursors/raw-evidence-stub-entry.js";
|
|
2
3
|
import { fetchStubbedSessions, stubSessionKey } from "../evidence-stubs-client.js";
|
|
3
4
|
import { describeError } from "../health-detail.js";
|
|
4
5
|
import { readLocalCollectorSessionFile } from "../local-state.js";
|
|
6
|
+
import { candidateCursorKey } from "./backfill-candidates.js";
|
|
5
7
|
export function emptyStubReofferPlan(reason) {
|
|
6
|
-
return {
|
|
8
|
+
return {
|
|
9
|
+
status: "skipped",
|
|
10
|
+
reason,
|
|
11
|
+
sessions: new Map(),
|
|
12
|
+
stub_count: 0,
|
|
13
|
+
marked_local_sessions: 0,
|
|
14
|
+
found_by: new Map(),
|
|
15
|
+
};
|
|
7
16
|
}
|
|
8
17
|
/** Ask the server which of this operator's pointers are stubs. Never throws; writes nothing. */
|
|
9
18
|
export async function loadStubReofferPlan(options) {
|
|
@@ -24,6 +33,7 @@ export async function loadStubReofferPlan(options) {
|
|
|
24
33
|
sessions,
|
|
25
34
|
stub_count: answer.unique_stub_count,
|
|
26
35
|
marked_local_sessions: 0,
|
|
36
|
+
found_by: new Map(),
|
|
27
37
|
};
|
|
28
38
|
}
|
|
29
39
|
/**
|
|
@@ -33,7 +43,7 @@ export async function loadStubReofferPlan(options) {
|
|
|
33
43
|
export async function markStubReoffers(paths, plan, now) {
|
|
34
44
|
if (plan.sessions.size === 0)
|
|
35
45
|
return;
|
|
36
|
-
plan.marked_local_sessions = await markStubbedSessions(paths, plan
|
|
46
|
+
plan.marked_local_sessions = await markStubbedSessions(paths, plan, now);
|
|
37
47
|
}
|
|
38
48
|
/** Reopen stubbed sessions exactly like pointer-less ones: selected past the cursor, sorted first. */
|
|
39
49
|
export function reopenStubbedSessions(reopen, plan) {
|
|
@@ -53,8 +63,10 @@ export function stubClaimsForCandidates(candidates, plan) {
|
|
|
53
63
|
}
|
|
54
64
|
return claims;
|
|
55
65
|
}
|
|
56
|
-
export function summarizeStubReoffer(plan, candidates) {
|
|
57
|
-
const
|
|
66
|
+
export function summarizeStubReoffer(plan, candidates, durableCandidateKeys = new Set(), attempted = candidates) {
|
|
67
|
+
const offered = candidates.filter((candidate) => plan.sessions.has(stubSessionKey(candidate.source, candidate.session_id)));
|
|
68
|
+
const stubsOf = (list) => list.reduce((total, candidate) => total + (plan.sessions.get(stubSessionKey(candidate.source, candidate.session_id))?.length ?? 0), 0);
|
|
69
|
+
const reoffered = offered.length;
|
|
58
70
|
const summary = {
|
|
59
71
|
status: plan.status,
|
|
60
72
|
reason: plan.reason,
|
|
@@ -62,6 +74,9 @@ export function summarizeStubReoffer(plan, candidates) {
|
|
|
62
74
|
sessions: plan.sessions.size,
|
|
63
75
|
marked_local_sessions: plan.marked_local_sessions,
|
|
64
76
|
reoffered_sessions: reoffered,
|
|
77
|
+
reoffered_stubs: stubsOf(offered.filter((candidate) => attempted.includes(candidate))),
|
|
78
|
+
replaced_stubs: stubsOf(offered.filter((candidate) => durableCandidateKeys.has(candidateCursorKey(candidate)))),
|
|
79
|
+
found_by: countFoundBy(plan),
|
|
65
80
|
};
|
|
66
81
|
if (plan.sessions.size > 0) {
|
|
67
82
|
console.error("[backfill] redaction-stubbed sessions re-offered", JSON.stringify({
|
|
@@ -74,20 +89,43 @@ export function summarizeStubReoffer(plan, candidates) {
|
|
|
74
89
|
}
|
|
75
90
|
return summary;
|
|
76
91
|
}
|
|
77
|
-
|
|
92
|
+
function countFoundBy(plan) {
|
|
93
|
+
const counts = { session_meta: 0, file_name: 0, file_hash: 0, not_found: 0 };
|
|
94
|
+
for (const method of plan.found_by.values())
|
|
95
|
+
counts[method] += 1;
|
|
96
|
+
return counts;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Mark every door-named session this cursor tracks, and make its entry tell
|
|
100
|
+
* the truth (BLI-11119): an entry written before the stub rule existed claimed
|
|
101
|
+
* the full file as uploaded. The door is the source of truth, so each such
|
|
102
|
+
* entry is reconciled to `stubbed: true` with the marker's bytes, counted and
|
|
103
|
+
* logged as `stub_cursor_reconciled`. A session the scan could not find in any
|
|
104
|
+
* root keeps `not_found_at`, so the scheduled sync waits before looking again.
|
|
105
|
+
*/
|
|
106
|
+
async function markStubbedSessions(paths, plan, now) {
|
|
78
107
|
let marked = 0;
|
|
108
|
+
let reconciled = 0;
|
|
79
109
|
for (const source of ["codex", "claude_code"]) {
|
|
80
110
|
const filename = source === "claude_code" ? CLAUDE_CURSOR_FILENAME : undefined;
|
|
81
111
|
try {
|
|
82
112
|
const cursor = await readRawEvidenceCursor(paths, { filename });
|
|
83
113
|
let changed = false;
|
|
84
|
-
for (const [key, pointerIds] of sessions) {
|
|
114
|
+
for (const [key, pointerIds] of plan.sessions) {
|
|
85
115
|
if (!key.startsWith(`${source}:`))
|
|
86
116
|
continue;
|
|
87
117
|
const entry = cursor.sessions[key.slice(source.length + 1)];
|
|
88
118
|
if (!entry)
|
|
89
119
|
continue;
|
|
90
|
-
|
|
120
|
+
const notFound = plan.found_by.get(key) === "not_found";
|
|
121
|
+
entry.stub_reoffer = {
|
|
122
|
+
reason: "stub_reoffered",
|
|
123
|
+
stub_pointer_ids: pointerIds,
|
|
124
|
+
marked_at: now.toISOString(),
|
|
125
|
+
...(notFound ? { not_found_at: now.toISOString() } : {}),
|
|
126
|
+
};
|
|
127
|
+
if (markCursorEntryStubbed(entry))
|
|
128
|
+
reconciled += 1;
|
|
91
129
|
changed = true;
|
|
92
130
|
marked += 1;
|
|
93
131
|
}
|
|
@@ -100,5 +138,6 @@ async function markStubbedSessions(paths, sessions, now) {
|
|
|
100
138
|
console.error("[backfill] stub mark not written to the cursor", JSON.stringify({ reason: "stub_mark_write_failed", source, ...describeError(error) }));
|
|
101
139
|
}
|
|
102
140
|
}
|
|
141
|
+
console.error("[backfill] stubbed sessions' cursor entries reconciled with the stubs door", JSON.stringify({ reason: "stub_cursor_reconciled", count: reconciled, marked_local_sessions: marked }));
|
|
103
142
|
return marked;
|
|
104
143
|
}
|
|
@@ -20,10 +20,11 @@ const BACKFILL_MAX_CONSECUTIVE_FAILURES = 3;
|
|
|
20
20
|
*/
|
|
21
21
|
export async function uploadBackfillBatches(command, io, lock, ctx) {
|
|
22
22
|
const uploadable = uploadableCandidates(ctx.scan.candidates);
|
|
23
|
-
// Doctor needs a boundary after each session
|
|
24
|
-
//
|
|
23
|
+
// Doctor needs a boundary after each session, and so does the scheduled
|
|
24
|
+
// sync's stub pass (BLI-11112), which hands the lock to a waiting backfill
|
|
25
|
+
// at the next one. Ordinary backfill retains its existing batching.
|
|
25
26
|
const plannedBatches = buildBackfillBatches(uploadable);
|
|
26
|
-
const batches = command.deadlineMs !== undefined || command.onProgress
|
|
27
|
+
const batches = command.deadlineMs !== undefined || command.onProgress || command.stubPass
|
|
27
28
|
? plannedBatches.flatMap((batch) => batch.candidates.map((candidate) => ({
|
|
28
29
|
worktree: batch.worktree, candidates: [candidate],
|
|
29
30
|
})))
|
|
@@ -72,6 +73,12 @@ export async function uploadBackfillBatches(command, io, lock, ctx) {
|
|
|
72
73
|
failureReason = "deferred_budget_exhausted";
|
|
73
74
|
break;
|
|
74
75
|
}
|
|
76
|
+
if (command.stubPass?.shouldYield && (await command.stubPass.shouldYield())) {
|
|
77
|
+
blockedAt = stoppedAt("yielded to a waiting backfill", index);
|
|
78
|
+
failureReason = "yielded_to_backfill";
|
|
79
|
+
console.error("[collector stubs] stub pass yielded the collection lock to a waiting backfill", JSON.stringify({ reason: "backfill_waiting", sessions_done: index, sessions_left: batches.length - index }));
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
75
82
|
const outcome = await uploadOneBackfillBatch(command, io, ctx, batch, rawEvidenceBudget);
|
|
76
83
|
foldBatchIntoTally(tally, batch, outcome);
|
|
77
84
|
reportProgress();
|
|
@@ -103,7 +103,14 @@ export async function runBackfill(command, io) {
|
|
|
103
103
|
if (plan.kind === "result")
|
|
104
104
|
return plan.result;
|
|
105
105
|
const { paths } = scanned;
|
|
106
|
-
|
|
106
|
+
// BLI-11112: the sync's stub pass does not take the backfill lock. It runs
|
|
107
|
+
// under the collection lock, which already serialises it against any
|
|
108
|
+
// backfill, and the backfill lock is the flag a WAITING backfill raises: a
|
|
109
|
+
// person's backfill that arrives mid-pass must wait its turn, not bounce off
|
|
110
|
+
// `backfill_already_running`.
|
|
111
|
+
const lock = command.stubPass
|
|
112
|
+
? { acquired: true, handle: { release: async () => undefined, heartbeat: async () => undefined } }
|
|
113
|
+
: await acquireBackfillLock(paths, now);
|
|
107
114
|
if (!lock.acquired) {
|
|
108
115
|
return lockHeldResult(command, scanned, {
|
|
109
116
|
failureReason: "backfill_already_running",
|
|
@@ -112,29 +119,36 @@ export async function runBackfill(command, io) {
|
|
|
112
119
|
}
|
|
113
120
|
// BLI-11091: the scheduled sync holds this lock most of the time an agent
|
|
114
121
|
// is working, so wait for it (bounded) instead of exiting at batch 0. Giving
|
|
115
|
-
// up is `sync_lock_held`, a blocked result and so a non-zero exit.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
catch (error) {
|
|
121
|
-
await lock.handle.release();
|
|
122
|
-
throw error;
|
|
122
|
+
// up is `sync_lock_held`, a blocked result and so a non-zero exit. The
|
|
123
|
+
// sync's own stub pass (BLI-11112) runs inside the sync's lock and skips it.
|
|
124
|
+
let collectionLock;
|
|
125
|
+
if (command.stubPass) {
|
|
126
|
+
collectionLock = { handle: { release: async () => undefined } };
|
|
123
127
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
else {
|
|
129
|
+
let waited;
|
|
130
|
+
try {
|
|
131
|
+
waited = await acquireSyncLockWithWait(paths, command.syncLockWaitMs ?? BACKFILL_SYNC_LOCK_WAIT_MS);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
await lock.handle.release();
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
if (!waited.acquired) {
|
|
138
|
+
await lock.handle.release();
|
|
139
|
+
return lockHeldResult(command, scanned, {
|
|
140
|
+
failureReason: "sync_lock_held",
|
|
141
|
+
what: "sync collection lock held",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
collectionLock = waited.lock;
|
|
130
145
|
}
|
|
131
|
-
const collectionLock = waited.lock;
|
|
132
146
|
try {
|
|
133
147
|
// Backfill and scheduled/manual sync share upload spool and raw-evidence
|
|
134
148
|
// cursors. Holding the same collection lock makes their read-modify-write
|
|
135
149
|
// updates serial, while the dedicated backfill lock still prevents two
|
|
136
150
|
// historical scans from running together.
|
|
137
|
-
if (scanned.scopedCursor.reset) {
|
|
151
|
+
if (scanned.scopedCursor.reset && !command.stubPass) {
|
|
138
152
|
await writeBackfillCursor(paths, scanned.cursor);
|
|
139
153
|
}
|
|
140
154
|
// BLI-11102: mark server-named stubs as not delivered, under the lock
|
|
@@ -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.158");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -11,6 +11,7 @@ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConf
|
|
|
11
11
|
import { normalizeDashboardUrl } from "../upload-http.js";
|
|
12
12
|
import { flushPendingCodexSessionReports, queueCodexSessionReport, } from "../upload.js";
|
|
13
13
|
import { CLAUDE_CURSOR_FILENAME, countStaleSessions, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
14
|
+
import { isRedactionStubObjectKey, markCursorEntryStubbed } from "../cursors/raw-evidence-stub-entry.js";
|
|
14
15
|
import { claudeAttributionReadFailureCount, codexAttributionReadFailureCount, } from "./agent-session-report.js";
|
|
15
16
|
import { liveSyncCursorEntryRequiresRetry } from "./session-sync-attribution.js";
|
|
16
17
|
/**
|
|
@@ -331,5 +332,16 @@ function recordSourceObservation(options, result) {
|
|
|
331
332
|
uploaded_byte_size: uploadedByteSize,
|
|
332
333
|
last_seen_at: options.now.toISOString(),
|
|
333
334
|
};
|
|
335
|
+
// BLI-11119: a pointer that is the redaction-crash marker is not a delivery.
|
|
336
|
+
// The entry records the marker's bytes, and keeps the re-offer mark while
|
|
337
|
+
// the stub still stands.
|
|
338
|
+
if (isRedactionStubObjectKey(uploadedObjectKey)) {
|
|
339
|
+
markCursorEntryStubbed(entry);
|
|
340
|
+
if (prior?.stub_reoffer)
|
|
341
|
+
entry.stub_reoffer = prior.stub_reoffer;
|
|
342
|
+
if (uploadedThisSync) {
|
|
343
|
+
console.error("[collector sync] session delivered only as a redaction-crash stub", JSON.stringify({ reason: "stub_cursor_recorded", source: options.source, byte_size: result.byte_size }));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
334
346
|
recordSessionObservation(options.cursor, sessionId, entry);
|
|
335
347
|
}
|
|
@@ -10,6 +10,7 @@ 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 { runYieldingAtBoundaries } from "./sync-yield.js";
|
|
13
14
|
const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
|
|
14
15
|
const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
15
16
|
/**
|
|
@@ -33,10 +34,13 @@ export async function syncEveryTargetWorktree(options) {
|
|
|
33
34
|
remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
|
|
34
35
|
remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
|
|
35
36
|
};
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
// BLI-11112: a waiting backfill takes the lock at the next worktree
|
|
38
|
+
// boundary; the worktrees not reached stay pending for it or the next tick.
|
|
39
|
+
const loop = await runYieldingAtBoundaries({
|
|
40
|
+
items: syncWorktrees,
|
|
41
|
+
shouldYield: run.yieldCheck,
|
|
42
|
+
describe: (outcome) => ({ status: outcome.sync.status }),
|
|
43
|
+
runOne: (worktree) => syncOneWorktree({
|
|
40
44
|
run,
|
|
41
45
|
now: options.now,
|
|
42
46
|
plan: options.plan,
|
|
@@ -50,16 +54,16 @@ export async function syncEveryTargetWorktree(options) {
|
|
|
50
54
|
recordDampedClaudeSession: (sessionId, priorPointer) => {
|
|
51
55
|
dampedClaudePointers.set(sessionId, priorPointer);
|
|
52
56
|
},
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
const outcomes = loop.results;
|
|
60
|
+
const everyWorktreeUploaded = outcomes.every((outcome) => outcome.sync.status === "uploaded");
|
|
58
61
|
return {
|
|
59
62
|
outcomes,
|
|
60
63
|
everyWorktreeUploaded,
|
|
61
64
|
claudePriorDurablePointers: claudeDurablePointersFromCursor(options.plan.claudeCursorBefore),
|
|
62
65
|
growthDampedSessionCount: dampedClaudePointers.size,
|
|
66
|
+
yieldedWorktreesLeft: loop.left,
|
|
63
67
|
};
|
|
64
68
|
}
|
|
65
69
|
/** Every Claude session the cursor already holds a durable pointer for. */
|
|
@@ -151,6 +151,7 @@ export async function runAttributedWorktreeSync(options) {
|
|
|
151
151
|
failure_records: health.failure_records,
|
|
152
152
|
notice: health.notice,
|
|
153
153
|
hold: health.hold,
|
|
154
|
+
yielded_worktrees_left: worktreePass.yieldedWorktreesLeft,
|
|
154
155
|
sessions_observed: sessions.length,
|
|
155
156
|
sessions_outside_root: health.sessions_outside_root,
|
|
156
157
|
outside_root_directories: health.outside_root_directories,
|
|
@@ -3,6 +3,8 @@ import { discoverCommandWorktrees } from "./local-discovery.js";
|
|
|
3
3
|
import { runAttributedWorktreeSync } from "./session-sync.js";
|
|
4
4
|
import { reportMultiRepoSync, reportNoWorktreeSync, reportSingleRepoSync, } from "./sync-report.js";
|
|
5
5
|
import { resolveSyncCollectionRoots } from "./sync-roots.js";
|
|
6
|
+
import { healStubsDuringTick } from "./sync-stub-heal.js";
|
|
7
|
+
import { backfillWaitingCheck } from "./sync-yield.js";
|
|
6
8
|
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
7
9
|
import { rawEvidenceDedupSummary, sweepDuplicateStagedRawEvidence, } from "../raw-evidence-gc.js";
|
|
8
10
|
/**
|
|
@@ -18,7 +20,8 @@ export async function runSyncLocked(command, io) {
|
|
|
18
20
|
// holds 559 copies of one rollout needs the disk back before it stages
|
|
19
21
|
// anything else (BLI-3066). Safe by construction — a duplicate is identical
|
|
20
22
|
// by content hash to the survivor.
|
|
21
|
-
const
|
|
23
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
24
|
+
const dedup = await sweepDuplicateStagedRawEvidence(paths, io.env);
|
|
22
25
|
if (!dedup.skipped && dedup.removed_dirs > 0) {
|
|
23
26
|
writeLine(io.stdout, rawEvidenceDedupSummary(dedup));
|
|
24
27
|
}
|
|
@@ -37,7 +40,11 @@ export async function runSyncLocked(command, io) {
|
|
|
37
40
|
startContexts: false,
|
|
38
41
|
worktrees,
|
|
39
42
|
fetchImpl: io.fetch,
|
|
43
|
+
yieldCheck: backfillWaitingCheck(paths),
|
|
40
44
|
});
|
|
45
|
+
// After the tick's own uploads, still under the lock: re-offer up to 25 of
|
|
46
|
+
// this machine's stubbed sessions. Never fails the tick.
|
|
47
|
+
await healStubsDuringTick(command, io);
|
|
41
48
|
if (discoveryNotice) {
|
|
42
49
|
run.discovery_partial = true;
|
|
43
50
|
run.notice = [discoveryNotice, run.notice].filter(Boolean).join("; ");
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { runBackfill } from "./backfill.js";
|
|
2
|
+
import { loadStubReofferPlan } from "./backfill-stub-reoffer.js";
|
|
3
|
+
import { backfillWaitingCheck } from "./sync-yield.js";
|
|
4
|
+
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
5
|
+
import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
6
|
+
import { describeError } from "../health-detail.js";
|
|
7
|
+
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
|
|
8
|
+
/** At most this many stubbed sessions are re-offered per tick (one backfill batch). */
|
|
9
|
+
export const SYNC_STUB_SESSIONS_PER_TICK = 25;
|
|
10
|
+
const DAY_MS = 24 * 60 * 60_000;
|
|
11
|
+
/**
|
|
12
|
+
* BLI-11119: a stubbed session a re-offer could not find in any root, by
|
|
13
|
+
* content id or file hash, is looked for again after this long. Without it
|
|
14
|
+
* every tick rescanned (and hashed) every transcript back to the session's
|
|
15
|
+
* date for a file that is gone.
|
|
16
|
+
*/
|
|
17
|
+
export const STUB_NOT_FOUND_RETRY_MS = DAY_MS;
|
|
18
|
+
const DEFAULT_DEPS = { runBackfill, loadPlan: loadStubReofferPlan };
|
|
19
|
+
export async function healStubsDuringTick(command, io, deps = DEFAULT_DEPS, now = new Date()) {
|
|
20
|
+
const summary = {
|
|
21
|
+
reason: "unknown",
|
|
22
|
+
stubs_asked: 0,
|
|
23
|
+
stubs_reoffered: 0,
|
|
24
|
+
stubs_remaining: 0,
|
|
25
|
+
sessions_local: 0,
|
|
26
|
+
sessions_selected: 0,
|
|
27
|
+
sessions_not_found_recently: 0,
|
|
28
|
+
};
|
|
29
|
+
try {
|
|
30
|
+
summary.reason = await runStubPass(command, io, deps, now, summary);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
summary.reason = "stub_pass_threw";
|
|
34
|
+
console.error("[collector stubs] tick stub pass threw; the tick is unaffected", JSON.stringify({ reason: "stub_pass_threw", ...describeError(error) }));
|
|
35
|
+
}
|
|
36
|
+
console.error("[collector stubs] tick", JSON.stringify(summary));
|
|
37
|
+
return summary;
|
|
38
|
+
}
|
|
39
|
+
async function runStubPass(command, io, deps, now, summary) {
|
|
40
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
41
|
+
// A backfill waiting for the lock heals stubs itself, with the whole door.
|
|
42
|
+
if ((await inspectBackfillLock(paths)).held)
|
|
43
|
+
return "backfill_running";
|
|
44
|
+
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
45
|
+
const sessionFile = await readLocalCollectorSessionFile(paths).catch(() => null);
|
|
46
|
+
if (!sessionFile)
|
|
47
|
+
return "no_device_session";
|
|
48
|
+
const sources = config?.collect_claude_jsonl === false ? ["codex"] : ["codex", "claude_code"];
|
|
49
|
+
const dashboardUrl = (sessionFile.dashboard_url ?? config?.dashboard_url ?? DEFAULT_DASHBOARD_URL)
|
|
50
|
+
.trim()
|
|
51
|
+
.replace(/\/+$/, "");
|
|
52
|
+
const plan = await deps.loadPlan({ paths, dashboardUrl, sources, fetchImpl: io.fetch });
|
|
53
|
+
summary.stubs_asked = plan.stub_count;
|
|
54
|
+
summary.stubs_remaining = plan.stub_count;
|
|
55
|
+
if (plan.status === "skipped")
|
|
56
|
+
return plan.reason;
|
|
57
|
+
if (plan.status === "unavailable")
|
|
58
|
+
return `stub_door_unavailable:${plan.reason}`;
|
|
59
|
+
if (plan.sessions.size === 0)
|
|
60
|
+
return "no_stubs";
|
|
61
|
+
const local = await locallyTrackedStubSessions(paths, plan);
|
|
62
|
+
summary.sessions_local = local.length;
|
|
63
|
+
if (local.length === 0)
|
|
64
|
+
return "stubs_not_on_this_machine";
|
|
65
|
+
const lookable = local.filter((session) => !notFoundRecently(session.notFoundAt, now));
|
|
66
|
+
summary.sessions_not_found_recently = local.length - lookable.length;
|
|
67
|
+
if (lookable.length === 0)
|
|
68
|
+
return "stubs_not_found_recently";
|
|
69
|
+
const selected = lookable.slice(0, SYNC_STUB_SESSIONS_PER_TICK);
|
|
70
|
+
summary.sessions_selected = selected.length;
|
|
71
|
+
const trimmed = {
|
|
72
|
+
...plan,
|
|
73
|
+
sessions: new Map(selected.map((session) => [session.key, session.pointerIds])),
|
|
74
|
+
stub_count: selected.reduce((total, session) => total + session.pointerIds.length, 0),
|
|
75
|
+
marked_local_sessions: 0,
|
|
76
|
+
found_by: new Map(),
|
|
77
|
+
};
|
|
78
|
+
const window = stubWindow(selected, sessionFile.paired_at, now);
|
|
79
|
+
const result = await deps.runBackfill({
|
|
80
|
+
homeDir: command.homeDir,
|
|
81
|
+
repoRoot: command.repoRoot,
|
|
82
|
+
maxDepth: command.maxDepth,
|
|
83
|
+
maxRepos: command.maxRepos,
|
|
84
|
+
...window,
|
|
85
|
+
dryRun: false,
|
|
86
|
+
yes: true,
|
|
87
|
+
json: true,
|
|
88
|
+
stubPass: { plan: trimmed, shouldYield: backfillWaitingCheck(paths) },
|
|
89
|
+
}, backfillIoFor(io));
|
|
90
|
+
const outcome = result.stub_reoffer;
|
|
91
|
+
summary.stubs_reoffered = outcome?.reoffered_stubs ?? 0;
|
|
92
|
+
summary.stubs_remaining = Math.max(0, plan.stub_count - (outcome?.replaced_stubs ?? 0));
|
|
93
|
+
return stubPassReason(result);
|
|
94
|
+
}
|
|
95
|
+
/** Door-order list of the stubbed sessions whose cursor entry lives on this machine. */
|
|
96
|
+
async function locallyTrackedStubSessions(paths, plan) {
|
|
97
|
+
const cursors = {
|
|
98
|
+
codex: await readRawEvidenceCursor(paths).catch(() => null),
|
|
99
|
+
claude_code: await readRawEvidenceCursor(paths, { filename: CLAUDE_CURSOR_FILENAME }).catch(() => null),
|
|
100
|
+
};
|
|
101
|
+
const local = [];
|
|
102
|
+
for (const [key, pointerIds] of plan.sessions) {
|
|
103
|
+
const at = key.indexOf(":");
|
|
104
|
+
const source = key.slice(0, at);
|
|
105
|
+
const entry = cursors[source]?.sessions[key.slice(at + 1)];
|
|
106
|
+
if (entry) {
|
|
107
|
+
local.push({ key, pointerIds, mtimeMs: entry.file_mtime_ms, notFoundAt: entry.stub_reoffer?.not_found_at ?? null });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return local;
|
|
111
|
+
}
|
|
112
|
+
function notFoundRecently(notFoundAt, now) {
|
|
113
|
+
if (!notFoundAt)
|
|
114
|
+
return false;
|
|
115
|
+
const at = Date.parse(notFoundAt);
|
|
116
|
+
return Number.isFinite(at) && now.getTime() - at < STUB_NOT_FOUND_RETRY_MS;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The narrowest backfill window that still reaches every selected session:
|
|
120
|
+
* back to the oldest one's file time plus a day. A backfill window never
|
|
121
|
+
* reaches before pairing, so an older session (or one with no recorded time)
|
|
122
|
+
* asks for all history instead.
|
|
123
|
+
*/
|
|
124
|
+
function stubWindow(selected, pairedAt, now) {
|
|
125
|
+
const times = selected.map((session) => session.mtimeMs);
|
|
126
|
+
const oldest = Math.min(...times);
|
|
127
|
+
const paired = pairedAt ? Date.parse(pairedAt) : Number.NaN;
|
|
128
|
+
if (times.some((time) => !Number.isFinite(time) || time <= 0))
|
|
129
|
+
return { all: true };
|
|
130
|
+
if (!Number.isFinite(paired) || oldest < paired)
|
|
131
|
+
return { all: true };
|
|
132
|
+
return { all: false, sinceDays: Math.max(1, Math.ceil((now.getTime() - oldest) / DAY_MS) + 1) };
|
|
133
|
+
}
|
|
134
|
+
function stubPassReason(result) {
|
|
135
|
+
const outcome = result.stub_reoffer;
|
|
136
|
+
if (result.failure_reason === "yielded_to_backfill")
|
|
137
|
+
return "yielded_to_backfill";
|
|
138
|
+
if (result.status === "blocked")
|
|
139
|
+
return `stub_backfill_blocked:${result.failure_reason ?? "unknown"}`;
|
|
140
|
+
if (!outcome || outcome.reoffered_sessions === 0)
|
|
141
|
+
return "stubs_not_found_locally";
|
|
142
|
+
if (outcome.replaced_stubs < outcome.reoffered_stubs) {
|
|
143
|
+
return `stubs_partly_replaced:${result.failure_reason ?? "unknown"}`;
|
|
144
|
+
}
|
|
145
|
+
return "stubs_reoffered";
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The backfill writes its `--json` result and progress to stdout; the sync's
|
|
149
|
+
* stdout is its own `--json` receipt, so those lines go nowhere and the
|
|
150
|
+
* backfill's own `[backfill]` logs still reach stderr.
|
|
151
|
+
*/
|
|
152
|
+
function backfillIoFor(io) {
|
|
153
|
+
const discard = { write: () => true };
|
|
154
|
+
return { stdin: io.stdin, stdout: discard, stderr: io.stderr, fetch: io.fetch };
|
|
155
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLI-11112: the scheduled sync hands the collection lock to a waiting
|
|
3
|
+
* backfill at its next worktree boundary.
|
|
4
|
+
*
|
|
5
|
+
* A tick on a machine with a backlog now runs 10 to 15 minutes, longer than
|
|
6
|
+
* the backfill's old wait, so a person who typed `cockpit backfill` sat out the
|
|
7
|
+
* whole tick and got `sync_lock_held`. The rule is plain: a backfill that wants
|
|
8
|
+
* the lock takes the backfill lock FIRST and then waits for the collection
|
|
9
|
+
* lock (`backfill-lock-wait.ts`). The backfill lock being held is the waiter's
|
|
10
|
+
* flag. Before each worktree upload the sync asks whether that flag is up; if
|
|
11
|
+
* it is, it finishes nothing new, writes down what it already did, and
|
|
12
|
+
* releases the lock. The worktrees it did not reach stay pending in the cursor
|
|
13
|
+
* and are exactly what the backfill (or the next tick) picks up. A worktree
|
|
14
|
+
* already uploading is never interrupted, so the wait is bounded by one
|
|
15
|
+
* worktree's upload, which is why each one logs its own elapsed time.
|
|
16
|
+
*/
|
|
17
|
+
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
18
|
+
/** The waiter's flag: the backfill lock is held while its owner waits for ours. */
|
|
19
|
+
export function backfillWaitingCheck(paths, inspect = inspectBackfillLock) {
|
|
20
|
+
return async () => {
|
|
21
|
+
try {
|
|
22
|
+
return (await inspect(paths)).held;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// A lock probe that cannot read the file means nobody is waiting; the
|
|
26
|
+
// sync carries on exactly as it did before this rule existed.
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Run `runOne` over `items` in order, asking `shouldYield` before each one.
|
|
33
|
+
* The first `true` stops the loop; nothing already started is interrupted.
|
|
34
|
+
*/
|
|
35
|
+
export async function runYieldingAtBoundaries(options) {
|
|
36
|
+
const now = options.now ?? (() => Date.now());
|
|
37
|
+
const results = [];
|
|
38
|
+
const started = now();
|
|
39
|
+
for (const [index, item] of options.items.entries()) {
|
|
40
|
+
if (options.shouldYield && (await options.shouldYield())) {
|
|
41
|
+
const left = options.items.length - index;
|
|
42
|
+
console.error("[session-sync] yielded the collection lock to a waiting backfill", JSON.stringify({
|
|
43
|
+
reason: "backfill_waiting",
|
|
44
|
+
worktrees_done: index,
|
|
45
|
+
worktrees_left: left,
|
|
46
|
+
elapsed_ms: now() - started,
|
|
47
|
+
}));
|
|
48
|
+
return { results, yielded: true, left };
|
|
49
|
+
}
|
|
50
|
+
const itemStarted = now();
|
|
51
|
+
const result = await options.runOne(item);
|
|
52
|
+
results.push(result);
|
|
53
|
+
console.error("[session-sync] worktree synced", JSON.stringify({
|
|
54
|
+
index: index + 1,
|
|
55
|
+
total: options.items.length,
|
|
56
|
+
elapsed_ms: now() - itemStarted,
|
|
57
|
+
...(options.describe ? options.describe(result) : {}),
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
return { results, yielded: false, left: 0 };
|
|
61
|
+
}
|
|
@@ -196,6 +196,7 @@ function parseSessionEntry(value) {
|
|
|
196
196
|
uploaded_byte_size: optionalNumberOrNull(record["uploaded_byte_size"]),
|
|
197
197
|
last_seen_at: lastSeenAt,
|
|
198
198
|
...parseStubReoffer(record["stub_reoffer"]),
|
|
199
|
+
...(record["stubbed"] === true ? { stubbed: true } : {}),
|
|
199
200
|
};
|
|
200
201
|
}
|
|
201
202
|
function parseStubReoffer(value) {
|
|
@@ -208,7 +209,15 @@ function parseStubReoffer(value) {
|
|
|
208
209
|
: [];
|
|
209
210
|
if (record["reason"] !== "stub_reoffered" || !markedAt || ids.length === 0)
|
|
210
211
|
return {};
|
|
211
|
-
|
|
212
|
+
const notFoundAt = optionalString(record["not_found_at"]);
|
|
213
|
+
return {
|
|
214
|
+
stub_reoffer: {
|
|
215
|
+
reason: "stub_reoffered",
|
|
216
|
+
stub_pointer_ids: ids,
|
|
217
|
+
marked_at: markedAt,
|
|
218
|
+
...(notFoundAt ? { not_found_at: notFoundAt } : {}),
|
|
219
|
+
},
|
|
220
|
+
};
|
|
212
221
|
}
|
|
213
222
|
function parseCheckpoints(value) {
|
|
214
223
|
if (!value || typeof value !== "object")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A redaction-crash stub is a promise to come back, not a delivery (BLI-11119).
|
|
3
|
+
*
|
|
4
|
+
* When the redactor crashed, the collector uploaded the 42-byte marker
|
|
5
|
+
* `REDACTION_STUB_TEXT` in place of the transcript, and the cursor entry then
|
|
6
|
+
* recorded the ORIGINAL file size as uploaded. Nothing local ever considered
|
|
7
|
+
* the session undelivered. This module is the one place a cursor entry is told
|
|
8
|
+
* the truth about a stub: `stubbed: true`, `uploaded_byte_size` the marker's
|
|
9
|
+
* 42 bytes, and `byte_offset` 0 because no session byte is durable.
|
|
10
|
+
*
|
|
11
|
+
* Two ways a stub is recognised, both deterministic:
|
|
12
|
+
* - at write time, by the object key: every transcript key's last segment
|
|
13
|
+
* starts with the first 16 hex of its content hash (a re-keyed pointer keeps
|
|
14
|
+
* that prefix), and the marker always hashes to `REDACTION_STUB_SHA256`;
|
|
15
|
+
* - after the fact, by the stubs door's answer, which is the source of truth
|
|
16
|
+
* for every entry written before this rule existed.
|
|
17
|
+
*/
|
|
18
|
+
import { REDACTION_STUB_BYTE_SIZE, REDACTION_STUB_SHA256, } from "@bli-cockpit/telemetry-core";
|
|
19
|
+
const STUB_KEY_PREFIX = REDACTION_STUB_SHA256.slice(0, 16);
|
|
20
|
+
/** True when this object key names the redaction-crash marker, not a transcript. */
|
|
21
|
+
export function isRedactionStubObjectKey(objectKey) {
|
|
22
|
+
if (!objectKey)
|
|
23
|
+
return false;
|
|
24
|
+
const segment = objectKey.slice(objectKey.lastIndexOf("/") + 1);
|
|
25
|
+
return segment.startsWith(STUB_KEY_PREFIX);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Make the entry say what the server holds: a stub. Returns whether anything
|
|
29
|
+
* changed, so a caller reconciling old entries can count what it corrected.
|
|
30
|
+
*/
|
|
31
|
+
export function markCursorEntryStubbed(entry) {
|
|
32
|
+
const changed = entry.stubbed !== true ||
|
|
33
|
+
entry.uploaded_byte_size !== REDACTION_STUB_BYTE_SIZE ||
|
|
34
|
+
entry.byte_offset !== 0;
|
|
35
|
+
entry.stubbed = true;
|
|
36
|
+
entry.uploaded_byte_size = REDACTION_STUB_BYTE_SIZE;
|
|
37
|
+
entry.byte_offset = 0;
|
|
38
|
+
return changed;
|
|
39
|
+
}
|