@bli-cockpit/cli 0.2.4 → 0.2.7
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/README.md +27 -7
- package/dist/adapters/attribution-core.js +152 -31
- package/dist/adapters/claude-attribution.js +14 -7
- package/dist/adapters/codex-attribution.js +8 -2
- package/dist/adapters/raw-evidence.js +33 -12
- package/dist/autostart.js +460 -20
- package/dist/commands/backfill.js +582 -87
- package/dist/commands/doctor.js +72 -60
- package/dist/commands/local-args.js +19 -2
- package/dist/commands/local.js +518 -247
- package/dist/commands/public-root.js +6 -1
- package/dist/commands/session-sync.js +259 -54
- package/dist/cursors/backfill-cursor.js +169 -1
- package/dist/cursors/raw-evidence-cursor.js +16 -2
- package/dist/evidence-upload-client.js +139 -107
- package/dist/local-state.js +82 -6
- package/dist/onboarding-roots.js +42 -17
- package/dist/process-runner.js +103 -0
- package/dist/raw-evidence-attribution-policy.js +25 -0
- package/dist/repo-identity.js +142 -26
- package/dist/root-normalization.js +29 -16
- package/dist/spool/install-event-outbox.js +191 -0
- package/dist/spool/local-spool.js +335 -58
- package/dist/upload.js +309 -66
- package/package.json +2 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { containsSecretLikeContent, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import crypto from "node:crypto";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
@@ -6,38 +7,41 @@ import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.j
|
|
|
6
7
|
import { defaultCodexSessionDirs, scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
|
|
7
8
|
import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
|
|
8
9
|
import { acquireBackfillLock } from "../backfill-lock.js";
|
|
9
|
-
import { emptyBackfillCursorState, readBackfillCursor, recordBackfillCursorObservations, writeBackfillCompletionMarker, writeBackfillCursor } from "../cursors/backfill-cursor.js";
|
|
10
|
-
import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext } from "../local-state.js";
|
|
11
|
-
import {
|
|
10
|
+
import { BACKFILL_COMPLETION_RECHECK_MS, BACKFILL_COVERAGE_VERSION, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCursor, recordBackfillCursorObservations, recordBackfillScanCoverage, writeBackfillCompletionMarker, writeBackfillCursor, } from "../cursors/backfill-cursor.js";
|
|
11
|
+
import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, startLocalWorkContext } from "../local-state.js";
|
|
12
|
+
import { collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
|
|
13
|
+
import { isRawEvidenceUploadableAttributionState } from "../raw-evidence-attribution-policy.js";
|
|
12
14
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
15
|
+
import { acquireSyncLock } from "../sync-lock.js";
|
|
13
16
|
import { LocalUploadBlockedError, postCodexSessionReport, syncLocalAmbientEnvelope } from "../upload.js";
|
|
14
17
|
const BACKFILL_UPLOAD_BATCH_SESSIONS = 25;
|
|
15
18
|
const BACKFILL_MAX_CONSECUTIVE_FAILURES = 3;
|
|
16
19
|
const ALL_BACKFILL_SINCE_MINUTES = 20 * 365 * 24 * 60;
|
|
17
20
|
const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
|
|
18
21
|
const DEFAULT_DISCOVERY_MAX_REPOS = 50;
|
|
19
|
-
const RETRY_COMMAND = "cockpit backfill";
|
|
20
22
|
export async function runBackfillCommand(command, io) {
|
|
21
23
|
if (!command.all && command.sinceDays === undefined) {
|
|
22
24
|
const message = bareBackfillMessage();
|
|
25
|
+
const retryCommand = backfillRetryCommand(command);
|
|
23
26
|
writeLine(io.stderr, message);
|
|
24
27
|
if (command.json) {
|
|
25
28
|
writeLine(io.stdout, JSON.stringify({
|
|
26
29
|
status: "blocked",
|
|
27
30
|
reason: "missing_window",
|
|
28
|
-
retry_command:
|
|
31
|
+
retry_command: retryCommand,
|
|
29
32
|
}, null, 2));
|
|
30
33
|
}
|
|
31
34
|
return 1;
|
|
32
35
|
}
|
|
33
36
|
if (command.all && !command.yes && !isInteractiveStdin(io)) {
|
|
34
37
|
const message = "--all requires TTY confirmation; pass --yes for agent runs.";
|
|
38
|
+
const retryCommand = backfillRetryCommand(command);
|
|
35
39
|
writeLine(io.stderr, message);
|
|
36
40
|
if (command.json) {
|
|
37
41
|
writeLine(io.stdout, JSON.stringify({
|
|
38
42
|
status: "blocked",
|
|
39
43
|
reason: "all_requires_confirmation",
|
|
40
|
-
retry_command:
|
|
44
|
+
retry_command: retryCommand,
|
|
41
45
|
}, null, 2));
|
|
42
46
|
}
|
|
43
47
|
return 1;
|
|
@@ -67,8 +71,10 @@ export async function runBackfill(command, io) {
|
|
|
67
71
|
const pairedAt = parseRequiredDate(sessionFile.paired_at, "paired_at");
|
|
68
72
|
const dashboardUrl = normalizeDashboardUrl(sessionFile.dashboard_url ?? config.dashboard_url);
|
|
69
73
|
const roots = backfillCollectionRoots(command, config.default_repo_paths);
|
|
70
|
-
const
|
|
71
|
-
const
|
|
74
|
+
const collectionRoots = normalizeCollectionRoots(await collectionRootPathAliases(roots));
|
|
75
|
+
const worktreeDiscovery = await discoverBackfillWorktrees(collectionRoots, command);
|
|
76
|
+
const retryCommand = backfillDiscoveryRetryCommand(command, worktreeDiscovery);
|
|
77
|
+
const worktrees = worktreeDiscovery.worktrees;
|
|
72
78
|
const sources = selectedSources(command.source);
|
|
73
79
|
const window = backfillWindow(command, now, pairedAt);
|
|
74
80
|
await validateBackfillReachability({
|
|
@@ -76,9 +82,11 @@ export async function runBackfill(command, io) {
|
|
|
76
82
|
dashboardUrl,
|
|
77
83
|
dryRun: command.dryRun,
|
|
78
84
|
});
|
|
79
|
-
const
|
|
85
|
+
const storedCursor = command.dryRun
|
|
80
86
|
? emptyBackfillCursorState()
|
|
81
87
|
: await readBackfillCursor(paths);
|
|
88
|
+
const scopedCursor = prepareBackfillCursorForScope(storedCursor, collectionRoots, sources);
|
|
89
|
+
const cursor = scopedCursor.cursor;
|
|
82
90
|
const scan = await scanBackfillSessions({
|
|
83
91
|
command,
|
|
84
92
|
homeDir: command.homeDir ?? os.homedir(),
|
|
@@ -89,8 +97,42 @@ export async function runBackfill(command, io) {
|
|
|
89
97
|
cursor,
|
|
90
98
|
now,
|
|
91
99
|
});
|
|
92
|
-
const
|
|
93
|
-
|
|
100
|
+
for (const reason of worktreeDiscovery.incomplete_reasons) {
|
|
101
|
+
scan.issues.push({
|
|
102
|
+
reason: `repo_discovery_${reason}`,
|
|
103
|
+
count: 1,
|
|
104
|
+
scope: "global",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
scan.issues.sort((a, b) => scanIssuePriority(a.scope) - scanIssuePriority(b.scope) ||
|
|
108
|
+
a.reason.localeCompare(b.reason));
|
|
109
|
+
const guards = await countReadOnlyGuards(scan.candidates);
|
|
110
|
+
for (const candidate of scan.candidates) {
|
|
111
|
+
const permanentReason = guards.permanent_skip_reasons.get(candidateCursorKey(candidate));
|
|
112
|
+
if (!permanentReason)
|
|
113
|
+
continue;
|
|
114
|
+
candidate.state = "skipped";
|
|
115
|
+
candidate.reason = permanentReason;
|
|
116
|
+
}
|
|
117
|
+
scan.retryable_candidate_keys = new Set([
|
|
118
|
+
...scan.retryable_candidate_keys,
|
|
119
|
+
...guards.retryable_candidate_keys,
|
|
120
|
+
]);
|
|
121
|
+
if (guards.counts.get("file_read_failed")) {
|
|
122
|
+
scan.issues.push({
|
|
123
|
+
reason: "candidate_file_read_failed",
|
|
124
|
+
count: guards.counts.get("file_read_failed") ?? 0,
|
|
125
|
+
scope: "candidate",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (guards.counts.get("file_too_large")) {
|
|
129
|
+
scan.issues.push({
|
|
130
|
+
reason: "file_too_large",
|
|
131
|
+
count: guards.counts.get("file_too_large") ?? 0,
|
|
132
|
+
scope: "candidate",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const reasonCounts = reasonCountsFor(scan.candidates, guards.counts, scan.issues);
|
|
94
136
|
if (command.all && !command.yes) {
|
|
95
137
|
if (!command.json) {
|
|
96
138
|
writeDryRunSummary(io, {
|
|
@@ -148,8 +190,12 @@ export async function runBackfill(command, io) {
|
|
|
148
190
|
scan,
|
|
149
191
|
reasonCounts,
|
|
150
192
|
}),
|
|
151
|
-
status: scan.
|
|
193
|
+
status: scan.issues.length > 0 ? "partial" : "complete",
|
|
152
194
|
dry_run: true,
|
|
195
|
+
retry_command: retryCommand,
|
|
196
|
+
...(scan.issues.length > 0
|
|
197
|
+
? { failure_reason: scan.issues[0]?.reason }
|
|
198
|
+
: {}),
|
|
153
199
|
};
|
|
154
200
|
}
|
|
155
201
|
const lock = await acquireBackfillLock(paths, now);
|
|
@@ -165,6 +211,7 @@ export async function runBackfill(command, io) {
|
|
|
165
211
|
reasonCounts,
|
|
166
212
|
}),
|
|
167
213
|
status: "blocked",
|
|
214
|
+
retry_command: retryCommand,
|
|
168
215
|
failure_reason: "backfill_already_running",
|
|
169
216
|
blocked_at: {
|
|
170
217
|
what: "backfill lock held",
|
|
@@ -175,7 +222,46 @@ export async function runBackfill(command, io) {
|
|
|
175
222
|
},
|
|
176
223
|
};
|
|
177
224
|
}
|
|
225
|
+
let collectionLock;
|
|
178
226
|
try {
|
|
227
|
+
collectionLock = await acquireSyncLock(paths);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
await lock.handle.release();
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
if (!collectionLock.acquired) {
|
|
234
|
+
await lock.handle.release();
|
|
235
|
+
return {
|
|
236
|
+
...baseBackfillResult(command, {
|
|
237
|
+
now,
|
|
238
|
+
dashboardUrl,
|
|
239
|
+
sources,
|
|
240
|
+
window,
|
|
241
|
+
cursor,
|
|
242
|
+
scan,
|
|
243
|
+
reasonCounts,
|
|
244
|
+
}),
|
|
245
|
+
status: "blocked",
|
|
246
|
+
retry_command: retryCommand,
|
|
247
|
+
failure_reason: "sync_already_running",
|
|
248
|
+
blocked_at: {
|
|
249
|
+
what: "sync collection lock held",
|
|
250
|
+
batch_index: 0,
|
|
251
|
+
batch_total: 0,
|
|
252
|
+
done: 0,
|
|
253
|
+
total: scan.candidates.length,
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
// Backfill and scheduled/manual sync share upload spool and raw-evidence
|
|
259
|
+
// cursors. Holding the same collection lock makes their read-modify-write
|
|
260
|
+
// updates serial, while the dedicated backfill lock still prevents two
|
|
261
|
+
// historical scans from running together.
|
|
262
|
+
if (scopedCursor.reset) {
|
|
263
|
+
await writeBackfillCursor(paths, cursor);
|
|
264
|
+
}
|
|
179
265
|
const uploadable = uploadableCandidates(scan.candidates);
|
|
180
266
|
const batches = buildBackfillBatches(uploadable);
|
|
181
267
|
const rawEvidenceBudget = {
|
|
@@ -192,8 +278,9 @@ export async function runBackfill(command, io) {
|
|
|
192
278
|
let uploadedObjects = 0;
|
|
193
279
|
let uploadedChunks = 0;
|
|
194
280
|
let backfilledSessions = 0;
|
|
281
|
+
const durableCandidateKeys = new Set();
|
|
195
282
|
let blockedAt;
|
|
196
|
-
let failureReason;
|
|
283
|
+
let failureReason = scan.issues[0]?.reason;
|
|
197
284
|
for (const [index, batch] of batches.entries()) {
|
|
198
285
|
await lock.handle.heartbeat();
|
|
199
286
|
const sync = await syncBackfillBatch({
|
|
@@ -209,12 +296,18 @@ export async function runBackfill(command, io) {
|
|
|
209
296
|
done += batch.candidates.length;
|
|
210
297
|
uploadedObjects += sync.raw_evidence_uploaded_object_count;
|
|
211
298
|
uploadedChunks += sync.raw_evidence_uploaded_chunk_count;
|
|
212
|
-
failed += sync
|
|
299
|
+
failed += countSessionUploadFailures(sync);
|
|
213
300
|
deferred +=
|
|
214
301
|
sync.raw_evidence_deferred_byte_budget +
|
|
215
302
|
sync.raw_evidence_deferred_object_budget;
|
|
216
|
-
|
|
217
|
-
const
|
|
303
|
+
const durableInBatch = durableBackfillCandidateKeys(batch, sync);
|
|
304
|
+
for (const key of durableInBatch)
|
|
305
|
+
durableCandidateKeys.add(key);
|
|
306
|
+
backfilledSessions = durableCandidateKeys.size;
|
|
307
|
+
const missingDurableMain = batch.candidates.length - durableInBatch.size;
|
|
308
|
+
const batchFailed = sync.status !== "uploaded" ||
|
|
309
|
+
countSessionUploadFailures(sync) > 0 ||
|
|
310
|
+
missingDurableMain > 0;
|
|
218
311
|
const batchDeferred = sync.raw_evidence_deferred_byte_budget +
|
|
219
312
|
sync.raw_evidence_deferred_object_budget >
|
|
220
313
|
0;
|
|
@@ -227,16 +320,10 @@ export async function runBackfill(command, io) {
|
|
|
227
320
|
}
|
|
228
321
|
if (!batchFailed && !batchDeferred) {
|
|
229
322
|
completedBatches += 1;
|
|
230
|
-
recordBackfillCursorObservations(cursor, batch.candidates.map((candidate) => ({
|
|
231
|
-
source: candidate.source,
|
|
232
|
-
state: candidate.state,
|
|
233
|
-
reason: candidate.reason,
|
|
234
|
-
session_file_mtime_ms: candidate.session_file_mtime_ms,
|
|
235
|
-
session_file_mtime: candidate.session_file_mtime,
|
|
236
|
-
})), now);
|
|
237
|
-
await writeBackfillCursor(paths, cursor);
|
|
238
323
|
}
|
|
239
|
-
|
|
324
|
+
if (!command.json) {
|
|
325
|
+
writeLine(io.stdout, `Uploaded ${done}/${uploadable.length} (batch ${index + 1}/${batches.length})`);
|
|
326
|
+
}
|
|
240
327
|
await yieldToEventLoop();
|
|
241
328
|
if (batchDeferred) {
|
|
242
329
|
blockedAt = {
|
|
@@ -258,7 +345,11 @@ export async function runBackfill(command, io) {
|
|
|
258
345
|
total: uploadable.length,
|
|
259
346
|
};
|
|
260
347
|
failureReason =
|
|
261
|
-
sync.status === "spooled"
|
|
348
|
+
sync.status === "spooled"
|
|
349
|
+
? sync.failure_reason
|
|
350
|
+
: missingDurableMain > 0
|
|
351
|
+
? "durable_session_pointer_missing"
|
|
352
|
+
: "upload_failed";
|
|
262
353
|
break;
|
|
263
354
|
}
|
|
264
355
|
}
|
|
@@ -267,17 +358,28 @@ export async function runBackfill(command, io) {
|
|
|
267
358
|
syncResults,
|
|
268
359
|
now,
|
|
269
360
|
});
|
|
361
|
+
const reportContext = sessions.length
|
|
362
|
+
? await ensureBackfillReportContext({
|
|
363
|
+
homeDir: command.homeDir,
|
|
364
|
+
paths,
|
|
365
|
+
collectionRoots,
|
|
366
|
+
worktrees,
|
|
367
|
+
candidates: scan.candidates,
|
|
368
|
+
})
|
|
369
|
+
: null;
|
|
270
370
|
const report = sessions.length
|
|
271
371
|
? await postCodexSessionReport({
|
|
272
372
|
homeDir: command.homeDir,
|
|
273
|
-
repoRoot:
|
|
373
|
+
repoRoot: reportContext?.repoRoot,
|
|
274
374
|
dashboardUrl,
|
|
275
375
|
sessions,
|
|
276
376
|
fetch: io.fetch,
|
|
277
377
|
now,
|
|
278
378
|
})
|
|
279
379
|
: emptyReport("no_sessions_observed");
|
|
280
|
-
|
|
380
|
+
const reportAcknowledged = sessions.length === 0 ||
|
|
381
|
+
(report.posted && report.recorded_count >= sessions.length);
|
|
382
|
+
if (!reportAcknowledged && !blockedAt) {
|
|
281
383
|
blockedAt = {
|
|
282
384
|
what: "session report failed",
|
|
283
385
|
batch_index: completedBatches,
|
|
@@ -285,20 +387,77 @@ export async function runBackfill(command, io) {
|
|
|
285
387
|
done,
|
|
286
388
|
total: uploadable.length,
|
|
287
389
|
};
|
|
288
|
-
failureReason
|
|
390
|
+
failureReason ??=
|
|
391
|
+
report.posted && report.recorded_count < sessions.length
|
|
392
|
+
? "session_report_ack_incomplete"
|
|
393
|
+
: report.reason;
|
|
289
394
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
395
|
+
// Raw evidence durability is necessary but not sufficient: the server must
|
|
396
|
+
// also acknowledge the session attribution rows before their historical
|
|
397
|
+
// cursor positions become irreversible.
|
|
398
|
+
if (reportAcknowledged) {
|
|
399
|
+
const cursorAdvanced = advanceBackfillCursorThroughResolvedPrefix({
|
|
400
|
+
cursor,
|
|
401
|
+
candidates: scan.candidates,
|
|
402
|
+
durableCandidateKeys,
|
|
403
|
+
retryableCandidateKeys: scan.retryable_candidate_keys,
|
|
404
|
+
discoveryComplete: !scan.issues.some((issue) => issue.scope === "global"),
|
|
405
|
+
now,
|
|
406
|
+
});
|
|
407
|
+
if (cursorAdvanced)
|
|
408
|
+
await writeBackfillCursor(paths, cursor);
|
|
409
|
+
}
|
|
410
|
+
const unresolvedUploadableKeys = new Set(uploadable
|
|
411
|
+
.filter((candidate) => !durableCandidateKeys.has(candidateCursorKey(candidate)))
|
|
412
|
+
.map(candidateCursorKey));
|
|
413
|
+
const unresolvedUploadable = unresolvedUploadableKeys.size;
|
|
414
|
+
const unresolvedRetryable = scan.retryable_candidate_keys.size;
|
|
415
|
+
const unresolvedKnownKeys = new Set([
|
|
416
|
+
...unresolvedUploadableKeys,
|
|
417
|
+
...scan.retryable_candidate_keys,
|
|
418
|
+
]);
|
|
419
|
+
const unseenGlobalFailures = scan.issues
|
|
420
|
+
.filter((issue) => issue.scope === "global")
|
|
421
|
+
.reduce((total, issue) => total + issue.count, 0);
|
|
422
|
+
const reportRetryable = reportAcknowledged
|
|
423
|
+
? 0
|
|
424
|
+
// The cursor is intentionally all-or-nothing for the report. Even
|
|
425
|
+
// chunks already accepted by the server are retried idempotently when
|
|
426
|
+
// another required chunk lacks an acknowledgement.
|
|
427
|
+
: Math.max(1, sessions.length);
|
|
428
|
+
const remaining = unresolvedKnownKeys.size +
|
|
429
|
+
scan.omitted_candidate_count +
|
|
430
|
+
unseenGlobalFailures +
|
|
431
|
+
reportRetryable;
|
|
432
|
+
failed = Math.max(failed, unresolvedUploadable);
|
|
433
|
+
const completionBlocked = scan.issues.length > 0 ||
|
|
434
|
+
unresolvedUploadable > 0 ||
|
|
435
|
+
unresolvedRetryable > 0 ||
|
|
436
|
+
deferred > 0 ||
|
|
437
|
+
failed > 0 ||
|
|
438
|
+
!reportAcknowledged;
|
|
439
|
+
if (!failureReason && completionBlocked) {
|
|
440
|
+
const retryableCandidateReason = scan.candidates.find((candidate) => scan.retryable_candidate_keys.has(candidateCursorKey(candidate)))?.reason;
|
|
441
|
+
failureReason =
|
|
442
|
+
scan.issues[0]?.reason ??
|
|
443
|
+
(unresolvedUploadable > 0
|
|
444
|
+
? "durable_session_pointer_missing"
|
|
445
|
+
: retryableCandidateReason ?? "backfill_incomplete");
|
|
446
|
+
}
|
|
447
|
+
const status = blockedAt || completionBlocked ? "partial" : "complete";
|
|
448
|
+
// The marker is consumed by doctor/status as proof that archived history
|
|
449
|
+
// is covered. A bounded --since-days run may complete its requested
|
|
450
|
+
// window, but it is not proof of an all-history backfill.
|
|
451
|
+
if (status === "complete" && command.all) {
|
|
452
|
+
recordBackfillScanCoverage(cursor, sources, now, now);
|
|
453
|
+
await writeBackfillCursor(paths, cursor);
|
|
299
454
|
await writeBackfillCompletionMarker(paths, {
|
|
300
|
-
schema_version: "cockpit-backfill-complete.
|
|
455
|
+
schema_version: "cockpit-backfill-complete.v2",
|
|
456
|
+
coverage_version: BACKFILL_COVERAGE_VERSION,
|
|
457
|
+
collection_scope_id: scopedCursor.collection_scope_id,
|
|
458
|
+
sources,
|
|
301
459
|
completed_at: now.toISOString(),
|
|
460
|
+
revalidate_after: new Date(now.getTime() + BACKFILL_COMPLETION_RECHECK_MS).toISOString(),
|
|
302
461
|
cursor,
|
|
303
462
|
});
|
|
304
463
|
}
|
|
@@ -313,6 +472,7 @@ export async function runBackfill(command, io) {
|
|
|
313
472
|
reasonCounts,
|
|
314
473
|
}),
|
|
315
474
|
status,
|
|
475
|
+
retry_command: retryCommand,
|
|
316
476
|
counts: {
|
|
317
477
|
...baseBackfillResult(command, {
|
|
318
478
|
now,
|
|
@@ -344,6 +504,7 @@ export async function runBackfill(command, io) {
|
|
|
344
504
|
};
|
|
345
505
|
}
|
|
346
506
|
finally {
|
|
507
|
+
await collectionLock.handle.release();
|
|
347
508
|
await lock.handle.release();
|
|
348
509
|
}
|
|
349
510
|
}
|
|
@@ -354,6 +515,58 @@ function bareBackfillMessage() {
|
|
|
354
515
|
"Use `cockpit backfill --all` only after reviewing a dry-run; on headless agent runs add `--yes`.",
|
|
355
516
|
].join("\n");
|
|
356
517
|
}
|
|
518
|
+
/**
|
|
519
|
+
* Produces a copyable retry that preserves the requested history window and
|
|
520
|
+
* every discovery/selection override. Values that need quoting use syntax
|
|
521
|
+
* accepted by the supported native shells: POSIX shells on macOS and
|
|
522
|
+
* PowerShell on Windows.
|
|
523
|
+
*/
|
|
524
|
+
export function backfillRetryCommand(command) {
|
|
525
|
+
const parts = ["cockpit", "backfill"];
|
|
526
|
+
if (command.all) {
|
|
527
|
+
parts.push("--all", "--yes");
|
|
528
|
+
}
|
|
529
|
+
else if (command.sinceDays !== undefined) {
|
|
530
|
+
parts.push("--since-days", String(command.sinceDays));
|
|
531
|
+
}
|
|
532
|
+
if (command.source)
|
|
533
|
+
parts.push("--source", command.source);
|
|
534
|
+
if (command.maxFiles !== undefined) {
|
|
535
|
+
parts.push("--max-files", String(command.maxFiles));
|
|
536
|
+
}
|
|
537
|
+
if (command.maxDepth !== undefined) {
|
|
538
|
+
parts.push("--max-depth", String(command.maxDepth));
|
|
539
|
+
}
|
|
540
|
+
if (command.maxRepos !== undefined) {
|
|
541
|
+
parts.push("--max-repos", String(command.maxRepos));
|
|
542
|
+
}
|
|
543
|
+
if (command.repoRoot) {
|
|
544
|
+
parts.push("--workspace", quoteCliArgument(command.repoRoot));
|
|
545
|
+
}
|
|
546
|
+
if (command.dryRun)
|
|
547
|
+
parts.push("--dry-run");
|
|
548
|
+
return parts.join(" ");
|
|
549
|
+
}
|
|
550
|
+
function backfillDiscoveryRetryCommand(command, discovery) {
|
|
551
|
+
const retry = { ...command };
|
|
552
|
+
if (discovery.incomplete_reasons.includes("max_depth_reached")) {
|
|
553
|
+
retry.maxDepth =
|
|
554
|
+
(command.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH) + 1;
|
|
555
|
+
}
|
|
556
|
+
if (discovery.incomplete_reasons.includes("max_worktrees_reached")) {
|
|
557
|
+
retry.maxRepos =
|
|
558
|
+
(command.maxRepos ?? DEFAULT_DISCOVERY_MAX_REPOS) * 2;
|
|
559
|
+
}
|
|
560
|
+
return backfillRetryCommand(retry);
|
|
561
|
+
}
|
|
562
|
+
function quoteCliArgument(value) {
|
|
563
|
+
if (/^[a-z0-9_./:\\-]+$/iu.test(value))
|
|
564
|
+
return value;
|
|
565
|
+
const escaped = process.platform === "win32"
|
|
566
|
+
? value.replaceAll("'", "''")
|
|
567
|
+
: value.replaceAll("'", `'\"'\"'`);
|
|
568
|
+
return `'${escaped}'`;
|
|
569
|
+
}
|
|
357
570
|
function backfillCollectionRoots(command, savedRoots) {
|
|
358
571
|
if (command.repoRoot)
|
|
359
572
|
return [path.resolve(command.repoRoot)];
|
|
@@ -363,27 +576,11 @@ function backfillCollectionRoots(command, savedRoots) {
|
|
|
363
576
|
}
|
|
364
577
|
return roots;
|
|
365
578
|
}
|
|
366
|
-
async function discoverBackfillWorktrees(roots) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
maxWorktrees: DEFAULT_DISCOVERY_MAX_REPOS,
|
|
372
|
-
})));
|
|
373
|
-
}
|
|
374
|
-
const seen = new Set();
|
|
375
|
-
const deduped = [];
|
|
376
|
-
for (const worktree of discovered) {
|
|
377
|
-
const key = worktree.worktree_fingerprint || path.resolve(worktree.repo_root);
|
|
378
|
-
if (seen.has(key))
|
|
379
|
-
continue;
|
|
380
|
-
seen.add(key);
|
|
381
|
-
deduped.push(worktree);
|
|
382
|
-
}
|
|
383
|
-
if (deduped.length === 0) {
|
|
384
|
-
throw new Error("No git repos found in the backfill collection roots.");
|
|
385
|
-
}
|
|
386
|
-
return deduped;
|
|
579
|
+
async function discoverBackfillWorktrees(roots, command) {
|
|
580
|
+
return discoverGitWorktreesInRootsWithStatus(roots, {
|
|
581
|
+
maxDepth: command.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
|
|
582
|
+
maxWorktrees: command.maxRepos ?? DEFAULT_DISCOVERY_MAX_REPOS,
|
|
583
|
+
});
|
|
387
584
|
}
|
|
388
585
|
function selectedSources(source) {
|
|
389
586
|
if (source === "codex")
|
|
@@ -426,9 +623,11 @@ async function validateBackfillReachability(options) {
|
|
|
426
623
|
}
|
|
427
624
|
async function scanBackfillSessions(options) {
|
|
428
625
|
const scanLimit = options.command.maxFiles ?? 10_000;
|
|
429
|
-
const
|
|
626
|
+
const codexSessionDirs = defaultCodexSessionDirs(options.homeDir);
|
|
627
|
+
const claudeProjectsDir = path.join(options.homeDir, ".claude", "projects");
|
|
628
|
+
let codexAttribution = options.sources.includes("codex")
|
|
430
629
|
? await scanAndAttributeCodexSessions({
|
|
431
|
-
sessionsDirs:
|
|
630
|
+
sessionsDirs: codexSessionDirs,
|
|
432
631
|
worktrees: options.worktrees,
|
|
433
632
|
now: options.now,
|
|
434
633
|
sinceMinutes: options.window.since_minutes,
|
|
@@ -436,9 +635,22 @@ async function scanBackfillSessions(options) {
|
|
|
436
635
|
collectionRoots: options.collectionRoots,
|
|
437
636
|
})
|
|
438
637
|
: null;
|
|
439
|
-
|
|
638
|
+
if (codexAttribution?.session_limit_applied) {
|
|
639
|
+
// The adapter limit is a memory guard, not permission to silently leave
|
|
640
|
+
// history undiscovered. Re-scan the exact discovered population so a
|
|
641
|
+
// user-facing --max-files cap can resume from a truthful ordering.
|
|
642
|
+
codexAttribution = await scanAndAttributeCodexSessions({
|
|
643
|
+
sessionsDirs: codexSessionDirs,
|
|
644
|
+
worktrees: options.worktrees,
|
|
645
|
+
now: options.now,
|
|
646
|
+
sinceMinutes: options.window.since_minutes,
|
|
647
|
+
limit: Math.max(scanLimit + 1, codexAttribution.discovered_file_count),
|
|
648
|
+
collectionRoots: options.collectionRoots,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
let claudeAttribution = options.sources.includes("claude_code")
|
|
440
652
|
? await scanAndAttributeClaudeSessions({
|
|
441
|
-
projectsDir:
|
|
653
|
+
projectsDir: claudeProjectsDir,
|
|
442
654
|
worktrees: options.worktrees,
|
|
443
655
|
now: options.now,
|
|
444
656
|
sinceMinutes: options.window.since_minutes,
|
|
@@ -446,14 +658,40 @@ async function scanBackfillSessions(options) {
|
|
|
446
658
|
collectionRoots: options.collectionRoots,
|
|
447
659
|
})
|
|
448
660
|
: null;
|
|
449
|
-
|
|
661
|
+
if (claudeAttribution?.session_limit_applied) {
|
|
662
|
+
claudeAttribution = await scanAndAttributeClaudeSessions({
|
|
663
|
+
projectsDir: claudeProjectsDir,
|
|
664
|
+
worktrees: options.worktrees,
|
|
665
|
+
now: options.now,
|
|
666
|
+
sinceMinutes: options.window.since_minutes,
|
|
667
|
+
limit: Math.max(scanLimit + 1, claudeAttribution.discovered_session_count),
|
|
668
|
+
collectionRoots: options.collectionRoots,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
const allCandidates = [
|
|
450
672
|
...(codexAttribution?.results.map(normalizeCodexCandidate) ?? []),
|
|
451
673
|
...(claudeAttribution?.results.map(normalizeClaudeCandidate) ?? []),
|
|
452
674
|
]
|
|
453
675
|
.filter((candidate) => isAfterCursor(candidate, options.cursor))
|
|
454
|
-
.sort(
|
|
455
|
-
|
|
456
|
-
|
|
676
|
+
.sort(compareBackfillCandidates);
|
|
677
|
+
const candidates = allCandidates.slice(0, options.command.maxFiles ?? Number.MAX_SAFE_INTEGER);
|
|
678
|
+
const omittedCandidateCount = allCandidates.length - candidates.length;
|
|
679
|
+
const issues = await backfillScanIssues({
|
|
680
|
+
codexAttribution,
|
|
681
|
+
claudeAttribution,
|
|
682
|
+
codexSessionDirs,
|
|
683
|
+
claudeProjectsDir,
|
|
684
|
+
candidates,
|
|
685
|
+
omittedCandidateCount,
|
|
686
|
+
});
|
|
687
|
+
return {
|
|
688
|
+
candidates,
|
|
689
|
+
codexAttribution,
|
|
690
|
+
claudeAttribution,
|
|
691
|
+
issues,
|
|
692
|
+
retryable_candidate_keys: retryableCandidateKeys(candidates),
|
|
693
|
+
omitted_candidate_count: omittedCandidateCount,
|
|
694
|
+
};
|
|
457
695
|
}
|
|
458
696
|
function normalizeCodexCandidate(result) {
|
|
459
697
|
return {
|
|
@@ -495,43 +733,191 @@ function normalizeClaudeCandidate(result) {
|
|
|
495
733
|
};
|
|
496
734
|
}
|
|
497
735
|
function isAfterCursor(candidate, cursor) {
|
|
498
|
-
const
|
|
499
|
-
|
|
736
|
+
const source = cursor.sources[candidate.source];
|
|
737
|
+
const oldest = source?.oldest_mtime_ms_processed;
|
|
738
|
+
if (oldest === null || oldest === undefined)
|
|
739
|
+
return true;
|
|
740
|
+
if (candidate.session_file_mtime_ms < oldest)
|
|
741
|
+
return true;
|
|
742
|
+
const key = candidateCursorKey(candidate);
|
|
743
|
+
if (candidate.session_file_mtime_ms === oldest) {
|
|
744
|
+
return !source.processed_keys_at_oldest_mtime.includes(key);
|
|
745
|
+
}
|
|
746
|
+
const newest = source.newest_mtime_ms_covered;
|
|
747
|
+
// A cursor written before upper-edge coverage existed gets one safe
|
|
748
|
+
// migration pass across the previously processed interval. Once that pass is
|
|
749
|
+
// acknowledged, subsequent --all runs only inspect genuinely newer files.
|
|
750
|
+
if (newest === null)
|
|
751
|
+
return true;
|
|
752
|
+
if (candidate.session_file_mtime_ms > newest)
|
|
753
|
+
return true;
|
|
754
|
+
if (candidate.session_file_mtime_ms < newest)
|
|
755
|
+
return false;
|
|
756
|
+
// A legacy cursor has no boundary identities. Rechecking the equal-time
|
|
757
|
+
// boundary is safe because durable evidence is content-addressed.
|
|
758
|
+
return !source.processed_keys_at_newest_mtime.includes(key);
|
|
759
|
+
}
|
|
760
|
+
function compareBackfillCandidates(a, b) {
|
|
761
|
+
return (b.session_file_mtime_ms - a.session_file_mtime_ms ||
|
|
762
|
+
candidateCursorKey(a).localeCompare(candidateCursorKey(b)));
|
|
763
|
+
}
|
|
764
|
+
function candidateCursorKey(candidate) {
|
|
765
|
+
return crypto
|
|
766
|
+
.createHash("sha256")
|
|
767
|
+
.update(JSON.stringify([
|
|
768
|
+
candidate.source,
|
|
769
|
+
candidate.session_id,
|
|
770
|
+
normalizedCursorPath(candidate.file_path),
|
|
771
|
+
]))
|
|
772
|
+
.digest("hex");
|
|
773
|
+
}
|
|
774
|
+
function normalizedCursorPath(filePath) {
|
|
775
|
+
const windowsStyle = path.win32.isAbsolute(filePath) && !path.posix.isAbsolute(filePath);
|
|
776
|
+
if (windowsStyle)
|
|
777
|
+
return path.win32.normalize(filePath).toLowerCase();
|
|
778
|
+
const normalized = path.resolve(filePath);
|
|
779
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
780
|
+
}
|
|
781
|
+
async function backfillScanIssues(options) {
|
|
782
|
+
const issues = [];
|
|
783
|
+
const add = (reason, count, scope) => {
|
|
784
|
+
if (count > 0)
|
|
785
|
+
issues.push({ reason, count, scope });
|
|
786
|
+
};
|
|
787
|
+
if (options.codexAttribution) {
|
|
788
|
+
add("codex_session_limit_applied", options.codexAttribution.session_limit_applied
|
|
789
|
+
? Math.max(1, options.codexAttribution.discovered_file_count -
|
|
790
|
+
options.codexAttribution.scanned_file_count)
|
|
791
|
+
: 0, "global");
|
|
792
|
+
const missingTopLevelDirs = (await Promise.all(options.codexSessionDirs.map(isMissingPath))).filter(Boolean).length;
|
|
793
|
+
add("codex_directory_read_failed", Math.max(0, options.codexAttribution.directory_read_failed_count -
|
|
794
|
+
missingTopLevelDirs), "global");
|
|
795
|
+
add("codex_session_stat_failed", options.codexAttribution.stat_failed_count, "global");
|
|
796
|
+
}
|
|
797
|
+
if (options.claudeAttribution) {
|
|
798
|
+
add("claude_session_limit_applied", options.claudeAttribution.session_limit_applied
|
|
799
|
+
? Math.max(1, options.claudeAttribution.discovered_session_count -
|
|
800
|
+
options.claudeAttribution.scanned_session_count)
|
|
801
|
+
: 0, "global");
|
|
802
|
+
const projectsDirMissing = await isMissingPath(options.claudeProjectsDir);
|
|
803
|
+
add("claude_project_dir_read_failed", Math.max(0, options.claudeAttribution.project_dir_read_failed_count -
|
|
804
|
+
(projectsDirMissing ? 1 : 0)), "global");
|
|
805
|
+
add("claude_session_stat_failed", options.claudeAttribution.session_stat_failed_count, "global");
|
|
806
|
+
add("claude_sidecar_stat_failed", options.claudeAttribution.sidecar_stat_failed_count, "global");
|
|
807
|
+
add("claude_sidecar_dir_read_failed", await countUnreadableClaudeSidecarDirs(options.candidates), "global");
|
|
808
|
+
}
|
|
809
|
+
add("backfill_max_files_applied", options.omittedCandidateCount, "selection");
|
|
810
|
+
add("candidate_file_read_failed", options.candidates.filter((candidate) => candidate.reason === "file_read_failed").length, "candidate");
|
|
811
|
+
add("candidate_worktree_unavailable", options.candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state) &&
|
|
812
|
+
candidate.worktree === null).length, "candidate");
|
|
813
|
+
add("claude_main_file_too_large", options.candidates.filter((candidate) => candidate.source === "claude_code" &&
|
|
814
|
+
candidate.claude?.main_file_oversized).length, "candidate");
|
|
815
|
+
add("claude_sidecar_limit_applied", options.candidates.reduce((total, candidate) => total + (candidate.claude?.sidecars_capped ?? 0), 0), "candidate");
|
|
816
|
+
add("claude_sidecar_file_unreadable", options.candidates.reduce((total, candidate) => total +
|
|
817
|
+
(candidate.claude?.sidecar_files.filter((sidecar) => sidecar.skipped_reason === "file_read_failed" ||
|
|
818
|
+
sidecar.skipped_reason === "file_too_large").length ?? 0), 0), "candidate");
|
|
819
|
+
return issues.sort((a, b) => scanIssuePriority(a.scope) - scanIssuePriority(b.scope) ||
|
|
820
|
+
a.reason.localeCompare(b.reason));
|
|
821
|
+
}
|
|
822
|
+
function scanIssuePriority(scope) {
|
|
823
|
+
if (scope === "global")
|
|
824
|
+
return 0;
|
|
825
|
+
if (scope === "candidate")
|
|
826
|
+
return 1;
|
|
827
|
+
return 2;
|
|
828
|
+
}
|
|
829
|
+
function retryableCandidateKeys(candidates) {
|
|
830
|
+
return new Set(candidates
|
|
831
|
+
.filter((candidate) => candidate.reason === "file_read_failed" ||
|
|
832
|
+
candidate.reason === "repo_not_on_disk" ||
|
|
833
|
+
(isRawEvidenceUploadableAttributionState(candidate.state) &&
|
|
834
|
+
!candidate.worktree) ||
|
|
835
|
+
Boolean(candidate.claude?.main_file_oversized) ||
|
|
836
|
+
(candidate.claude?.sidecars_capped ?? 0) > 0 ||
|
|
837
|
+
Boolean(candidate.claude?.sidecar_files.some((sidecar) => sidecar.skipped_reason === "file_read_failed" ||
|
|
838
|
+
sidecar.skipped_reason === "file_too_large")))
|
|
839
|
+
.map(candidateCursorKey));
|
|
840
|
+
}
|
|
841
|
+
async function countUnreadableClaudeSidecarDirs(candidates) {
|
|
842
|
+
let unreadable = 0;
|
|
843
|
+
for (const candidate of candidates) {
|
|
844
|
+
if (candidate.source !== "claude_code")
|
|
845
|
+
continue;
|
|
846
|
+
const subagentsDir = path.join(path.dirname(candidate.file_path), path.basename(candidate.file_path).replace(/\.jsonl$/i, ""), "subagents");
|
|
847
|
+
try {
|
|
848
|
+
await fs.readdir(subagentsDir);
|
|
849
|
+
}
|
|
850
|
+
catch (error) {
|
|
851
|
+
if (!isMissingFsError(error))
|
|
852
|
+
unreadable += 1;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
return unreadable;
|
|
856
|
+
}
|
|
857
|
+
async function isMissingPath(filePath) {
|
|
858
|
+
try {
|
|
859
|
+
await fs.stat(filePath);
|
|
860
|
+
return false;
|
|
861
|
+
}
|
|
862
|
+
catch (error) {
|
|
863
|
+
return isMissingFsError(error);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
function isMissingFsError(error) {
|
|
867
|
+
if (!error || typeof error !== "object")
|
|
868
|
+
return false;
|
|
869
|
+
const code = error.code;
|
|
870
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
500
871
|
}
|
|
501
872
|
async function countReadOnlyGuards(candidates) {
|
|
502
873
|
const counts = new Map();
|
|
874
|
+
const retryableCandidateKeys = new Set();
|
|
875
|
+
const permanentSkipReasons = new Map();
|
|
503
876
|
for (const candidate of candidates) {
|
|
504
|
-
if (candidate.reason === "repo_not_on_disk")
|
|
877
|
+
if (candidate.reason === "repo_not_on_disk") {
|
|
505
878
|
increment(counts, "repo_not_on_disk");
|
|
879
|
+
retryableCandidateKeys.add(candidateCursorKey(candidate));
|
|
880
|
+
}
|
|
506
881
|
if (candidate.source === "claude_code" && candidate.claude?.main_file_oversized) {
|
|
507
882
|
increment(counts, "file_too_large");
|
|
883
|
+
retryableCandidateKeys.add(candidateCursorKey(candidate));
|
|
508
884
|
continue;
|
|
509
885
|
}
|
|
510
886
|
if (candidate.byte_size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
|
|
511
887
|
increment(counts, "file_too_large");
|
|
888
|
+
retryableCandidateKeys.add(candidateCursorKey(candidate));
|
|
512
889
|
continue;
|
|
513
890
|
}
|
|
514
|
-
if (!
|
|
891
|
+
if (!isRawEvidenceUploadableAttributionState(candidate.state))
|
|
515
892
|
continue;
|
|
516
893
|
try {
|
|
517
894
|
const raw = await fs.readFile(candidate.file_path, "utf8");
|
|
518
895
|
if (containsSecretLikeContent(raw)) {
|
|
519
896
|
increment(counts, "secret_like_content_guard");
|
|
897
|
+
permanentSkipReasons.set(candidateCursorKey(candidate), "secret_like_content_guard");
|
|
520
898
|
}
|
|
521
899
|
}
|
|
522
900
|
catch {
|
|
523
901
|
increment(counts, "file_read_failed");
|
|
902
|
+
retryableCandidateKeys.add(candidateCursorKey(candidate));
|
|
524
903
|
}
|
|
525
904
|
}
|
|
526
|
-
return
|
|
905
|
+
return {
|
|
906
|
+
counts,
|
|
907
|
+
retryable_candidate_keys: retryableCandidateKeys,
|
|
908
|
+
permanent_skip_reasons: permanentSkipReasons,
|
|
909
|
+
};
|
|
527
910
|
}
|
|
528
|
-
function reasonCountsFor(candidates, guardCounts) {
|
|
911
|
+
function reasonCountsFor(candidates, guardCounts, scanIssues) {
|
|
529
912
|
const counts = new Map();
|
|
530
913
|
for (const candidate of candidates)
|
|
531
914
|
increment(counts, candidate.reason);
|
|
532
915
|
for (const [reason, count] of guardCounts) {
|
|
533
916
|
counts.set(reason, Math.max(counts.get(reason) ?? 0, count));
|
|
534
917
|
}
|
|
918
|
+
for (const issue of scanIssues) {
|
|
919
|
+
counts.set(issue.reason, Math.max(counts.get(issue.reason) ?? 0, issue.count));
|
|
920
|
+
}
|
|
535
921
|
for (const required of [
|
|
536
922
|
"secret_like_content_guard",
|
|
537
923
|
"file_too_large",
|
|
@@ -602,10 +988,8 @@ function reasonClassification(reason) {
|
|
|
602
988
|
};
|
|
603
989
|
}
|
|
604
990
|
function uploadableCandidates(candidates) {
|
|
605
|
-
return candidates.filter((candidate) =>
|
|
606
|
-
|
|
607
|
-
function isUploadableState(state) {
|
|
608
|
-
return state === "attributed" || state === "attributed_fallback";
|
|
991
|
+
return candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state) &&
|
|
992
|
+
candidate.worktree);
|
|
609
993
|
}
|
|
610
994
|
function buildBackfillBatches(candidates) {
|
|
611
995
|
const byWorktree = new Map();
|
|
@@ -629,6 +1013,29 @@ function buildBackfillBatches(candidates) {
|
|
|
629
1013
|
}
|
|
630
1014
|
return batches;
|
|
631
1015
|
}
|
|
1016
|
+
async function ensureBackfillReportContext(options) {
|
|
1017
|
+
const candidateWorktree = options.candidates.find((candidate) => candidate.worktree)?.worktree;
|
|
1018
|
+
const representative = candidateWorktree ?? options.worktrees[0] ?? null;
|
|
1019
|
+
// A deleted repo or an approved folder workspace can legitimately produce
|
|
1020
|
+
// report-only rows with no current git worktree. Use the exact approved root
|
|
1021
|
+
// as a synthetic local context instead of falling through to process.cwd().
|
|
1022
|
+
const repoRoot = representative?.repo_root ??
|
|
1023
|
+
options.collectionRoots[0] ??
|
|
1024
|
+
process.cwd();
|
|
1025
|
+
try {
|
|
1026
|
+
await readLocalWorkContextForRepo(options.paths, repoRoot);
|
|
1027
|
+
}
|
|
1028
|
+
catch {
|
|
1029
|
+
// Context creation is best-effort here. postCodexSessionReport converts a
|
|
1030
|
+
// remaining local-context failure into a retryable report reason.
|
|
1031
|
+
await startLocalWorkContext({
|
|
1032
|
+
homeDir: options.homeDir,
|
|
1033
|
+
repoRoot,
|
|
1034
|
+
branch: representative?.branch,
|
|
1035
|
+
}).catch(() => undefined);
|
|
1036
|
+
}
|
|
1037
|
+
return { repoRoot };
|
|
1038
|
+
}
|
|
632
1039
|
async function syncBackfillBatch(options) {
|
|
633
1040
|
const syncOptions = {
|
|
634
1041
|
homeDir: options.command.homeDir,
|
|
@@ -737,7 +1144,7 @@ function buildBackfillSessionReport(options) {
|
|
|
737
1144
|
raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
|
|
738
1145
|
upload_state: upload.upload_state,
|
|
739
1146
|
}
|
|
740
|
-
:
|
|
1147
|
+
: isRawEvidenceUploadableAttributionState(candidate.state)
|
|
741
1148
|
? { upload_state: "not_uploaded" }
|
|
742
1149
|
: {}),
|
|
743
1150
|
};
|
|
@@ -759,17 +1166,105 @@ function rank(state) {
|
|
|
759
1166
|
return 0;
|
|
760
1167
|
}
|
|
761
1168
|
}
|
|
762
|
-
function
|
|
763
|
-
const
|
|
1169
|
+
function durableBackfillCandidateKeys(batch, sync) {
|
|
1170
|
+
const durable = new Set();
|
|
1171
|
+
if (sync.status !== "uploaded")
|
|
1172
|
+
return durable;
|
|
1173
|
+
if (sync.raw_evidence_deferred_byte_budget > 0 ||
|
|
1174
|
+
sync.raw_evidence_deferred_object_budget > 0) {
|
|
1175
|
+
// Deferred outcomes have no per-file identity. Advancing any candidate in
|
|
1176
|
+
// this batch could therefore strand the deferred main transcript.
|
|
1177
|
+
return durable;
|
|
1178
|
+
}
|
|
1179
|
+
const outcomesBySession = new Map();
|
|
764
1180
|
for (const outcome of sync.raw_evidence_outcomes) {
|
|
765
|
-
|
|
766
|
-
|
|
1181
|
+
const source = backfillSourceForEvidenceKind(outcome.kind);
|
|
1182
|
+
if (!source || !outcome.codex_session_id)
|
|
1183
|
+
continue;
|
|
1184
|
+
const key = `${source}:${outcome.codex_session_id}`;
|
|
1185
|
+
const summary = outcomesBySession.get(key) ?? {
|
|
1186
|
+
durableMainCount: 0,
|
|
1187
|
+
durableSidecarCount: 0,
|
|
1188
|
+
failed: false,
|
|
1189
|
+
};
|
|
1190
|
+
if (outcome.upload_state === "upload_failed") {
|
|
1191
|
+
summary.failed = true;
|
|
1192
|
+
}
|
|
1193
|
+
else if (outcome.raw_evidence_pointer_id &&
|
|
767
1194
|
(outcome.upload_state === "uploaded" ||
|
|
768
1195
|
outcome.upload_state === "reused_existing")) {
|
|
769
|
-
|
|
1196
|
+
if (outcome.kind === "codex_jsonl" ||
|
|
1197
|
+
outcome.kind === "claude_jsonl") {
|
|
1198
|
+
summary.durableMainCount += 1;
|
|
1199
|
+
}
|
|
1200
|
+
else if (outcome.kind === "claude_jsonl_sidecar") {
|
|
1201
|
+
summary.durableSidecarCount += 1;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
outcomesBySession.set(key, summary);
|
|
1205
|
+
}
|
|
1206
|
+
for (const candidate of batch.candidates) {
|
|
1207
|
+
const summary = outcomesBySession.get(`${candidate.source}:${candidate.session_id}`);
|
|
1208
|
+
const expectedSidecars = candidate.source === "claude_code"
|
|
1209
|
+
? (candidate.claude?.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length ?? 0)
|
|
1210
|
+
: 0;
|
|
1211
|
+
if (summary &&
|
|
1212
|
+
!summary.failed &&
|
|
1213
|
+
summary.durableMainCount > 0 &&
|
|
1214
|
+
summary.durableSidecarCount >= expectedSidecars) {
|
|
1215
|
+
durable.add(candidateCursorKey(candidate));
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
return durable;
|
|
1219
|
+
}
|
|
1220
|
+
function countSessionUploadFailures(sync) {
|
|
1221
|
+
return sync.raw_evidence_outcomes.filter((outcome) => Boolean(outcome.codex_session_id) &&
|
|
1222
|
+
outcome.upload_state === "upload_failed" &&
|
|
1223
|
+
Boolean(backfillSourceForEvidenceKind(outcome.kind))).length;
|
|
1224
|
+
}
|
|
1225
|
+
function backfillSourceForEvidenceKind(kind) {
|
|
1226
|
+
if (kind === "codex_jsonl" || kind === "codex_image_attachment") {
|
|
1227
|
+
return "codex";
|
|
1228
|
+
}
|
|
1229
|
+
if (kind === "claude_jsonl" ||
|
|
1230
|
+
kind === "claude_jsonl_sidecar" ||
|
|
1231
|
+
kind === "claude_image_attachment") {
|
|
1232
|
+
return "claude_code";
|
|
1233
|
+
}
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
function advanceBackfillCursorThroughResolvedPrefix(options) {
|
|
1237
|
+
if (!options.discoveryComplete)
|
|
1238
|
+
return false;
|
|
1239
|
+
const observations = [];
|
|
1240
|
+
for (const source of ["codex", "claude_code"]) {
|
|
1241
|
+
const remaining = options.candidates
|
|
1242
|
+
.filter((candidate) => candidate.source === source &&
|
|
1243
|
+
isAfterCursor(candidate, options.cursor))
|
|
1244
|
+
.sort(compareBackfillCandidates);
|
|
1245
|
+
for (const candidate of remaining) {
|
|
1246
|
+
const key = candidateCursorKey(candidate);
|
|
1247
|
+
if (options.retryableCandidateKeys.has(key))
|
|
1248
|
+
break;
|
|
1249
|
+
const resolved = isRawEvidenceUploadableAttributionState(candidate.state)
|
|
1250
|
+
? options.durableCandidateKeys.has(key)
|
|
1251
|
+
: true;
|
|
1252
|
+
if (!resolved)
|
|
1253
|
+
break;
|
|
1254
|
+
observations.push({
|
|
1255
|
+
source: candidate.source,
|
|
1256
|
+
cursor_key: key,
|
|
1257
|
+
state: candidate.state,
|
|
1258
|
+
reason: candidate.reason,
|
|
1259
|
+
session_file_mtime_ms: candidate.session_file_mtime_ms,
|
|
1260
|
+
session_file_mtime: candidate.session_file_mtime,
|
|
1261
|
+
});
|
|
770
1262
|
}
|
|
771
1263
|
}
|
|
772
|
-
|
|
1264
|
+
if (observations.length === 0)
|
|
1265
|
+
return false;
|
|
1266
|
+
recordBackfillCursorObservations(options.cursor, observations, options.now);
|
|
1267
|
+
return true;
|
|
773
1268
|
}
|
|
774
1269
|
function worktreeInventoryForRepo(current, worktrees) {
|
|
775
1270
|
return worktrees
|
|
@@ -820,7 +1315,7 @@ function baseBackfillResult(command, options) {
|
|
|
820
1315
|
failed: 0,
|
|
821
1316
|
},
|
|
822
1317
|
resume_cursor: options.cursor,
|
|
823
|
-
retry_command:
|
|
1318
|
+
retry_command: backfillRetryCommand(command),
|
|
824
1319
|
report: emptyReport("not_posted"),
|
|
825
1320
|
server_acknowledged: {
|
|
826
1321
|
codex_session_report_recorded_count: 0,
|
|
@@ -860,7 +1355,7 @@ function blockedBackfillResult(command, options) {
|
|
|
860
1355
|
},
|
|
861
1356
|
batches: { total: 0, completed: 0, failed: 0 },
|
|
862
1357
|
resume_cursor: options.cursor,
|
|
863
|
-
retry_command:
|
|
1358
|
+
retry_command: backfillRetryCommand(command),
|
|
864
1359
|
report: emptyReport("not_posted"),
|
|
865
1360
|
server_acknowledged: {
|
|
866
1361
|
codex_session_report_recorded_count: 0,
|