@bli-cockpit/cli 0.2.52 → 0.2.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/clean.js +58 -5
- package/dist/commands/doctor.js +7 -2
- package/dist/commands/local-args-collector.js +12 -3
- package/dist/commands/local-help.js +12 -4
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-attribution.js +55 -0
- package/dist/commands/session-sync-failures.js +140 -0
- package/dist/commands/session-sync-health.js +102 -0
- package/dist/commands/session-sync-plan.js +81 -0
- package/dist/commands/session-sync-record.js +279 -0
- package/dist/commands/session-sync-scan.js +209 -0
- package/dist/commands/session-sync-types.js +12 -0
- package/dist/commands/session-sync-upload.js +215 -0
- package/dist/commands/session-sync.js +44 -987
- package/dist/commands/sync-followups.js +47 -2
- package/dist/cursors/raw-evidence-reconcile-cursor.js +132 -0
- package/dist/disk-usage.js +55 -0
- package/dist/evidence-reconcile-client.js +224 -0
- package/package.json +3 -3
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing down what this tick saw: the session report first, then both cursors.
|
|
3
|
+
*
|
|
4
|
+
* The order is the whole reason these live together. The report is queued to
|
|
5
|
+
* the spool BEFORE either cursor moves, so a process that dies in between
|
|
6
|
+
* leaves an exact retry copy behind rather than a cursor that has aged an
|
|
7
|
+
* unreported session out of the live window.
|
|
8
|
+
*/
|
|
9
|
+
import { describeError } from "../health-detail.js";
|
|
10
|
+
import { flushPendingCodexSessionReports, queueCodexSessionReport, } from "../upload.js";
|
|
11
|
+
import { CLAUDE_CURSOR_FILENAME, countStaleSessions, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
12
|
+
import { claudeAttributionReadFailureCount, codexAttributionReadFailureCount, } from "./agent-session-report.js";
|
|
13
|
+
import { liveSyncCursorEntryRequiresRetry } from "./session-sync-attribution.js";
|
|
14
|
+
/**
|
|
15
|
+
* Report this tick's sessions and advance both cursors — in that order, which
|
|
16
|
+
* is the load-bearing part.
|
|
17
|
+
*
|
|
18
|
+
* The report is queued to the spool BEFORE either cursor moves. If the process
|
|
19
|
+
* exits in between, the spool keeps an exact retry copy; if queueing itself
|
|
20
|
+
* fails, the cursors stay at their prior retryable positions instead of aging
|
|
21
|
+
* an unreported session out of the live window.
|
|
22
|
+
*/
|
|
23
|
+
export async function reportSessionsAndAdvanceCursors(options) {
|
|
24
|
+
const hadPendingSessionReports = (options.plan.uploadSpool?.pending_session_reports.length ?? 0) > 0;
|
|
25
|
+
const queuedCurrentSessionReport = await queueSessionReportForDelivery({
|
|
26
|
+
homeDir: options.run.homeDir,
|
|
27
|
+
outcomes: options.outcomes,
|
|
28
|
+
sessions: options.sessions,
|
|
29
|
+
now: options.now,
|
|
30
|
+
});
|
|
31
|
+
const staleCounts = await recordBothSourceCursorObservations({
|
|
32
|
+
paths: options.paths,
|
|
33
|
+
plan: options.plan,
|
|
34
|
+
scan: options.scan,
|
|
35
|
+
sessions: options.sessions,
|
|
36
|
+
now: options.now,
|
|
37
|
+
});
|
|
38
|
+
const report = await deliverOrExplainSessionReport({
|
|
39
|
+
hadPendingSessionReports,
|
|
40
|
+
queuedCurrentSessionReport,
|
|
41
|
+
sessionCount: options.sessions.length,
|
|
42
|
+
homeDir: options.run.homeDir,
|
|
43
|
+
fetchImpl: options.run.fetchImpl,
|
|
44
|
+
now: options.now,
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
report,
|
|
48
|
+
reportRequired: options.sessions.length > 0 ||
|
|
49
|
+
hadPendingSessionReports ||
|
|
50
|
+
queuedCurrentSessionReport,
|
|
51
|
+
...staleCounts,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Persist the metadata-only session report to the spool, before either source
|
|
56
|
+
* cursor advances.
|
|
57
|
+
*
|
|
58
|
+
* If the process exits after this point, the spool keeps an exact retry copy.
|
|
59
|
+
* If queueing itself fails, the cursors remain at their prior retryable
|
|
60
|
+
* positions instead of aging an unreported session out of the live window.
|
|
61
|
+
* Returns whether a report for THIS tick was queued.
|
|
62
|
+
*/
|
|
63
|
+
async function queueSessionReportForDelivery(options) {
|
|
64
|
+
const firstUploaded = options.outcomes.find((outcome) => outcome.sync.status === "uploaded");
|
|
65
|
+
if (!firstUploaded || options.sessions.length === 0)
|
|
66
|
+
return false;
|
|
67
|
+
const queued = await queueCodexSessionReport({
|
|
68
|
+
homeDir: options.homeDir,
|
|
69
|
+
dashboardUrl: firstUploaded.sync.dashboard_url,
|
|
70
|
+
generatedAt: options.now.toISOString(),
|
|
71
|
+
workContextId: firstUploaded.sync.work_context_id,
|
|
72
|
+
repoLabel: firstUploaded.worktree.repo_label,
|
|
73
|
+
branch: firstUploaded.worktree.branch,
|
|
74
|
+
repoFingerprint: firstUploaded.worktree.repo_fingerprint,
|
|
75
|
+
repoOriginUrl: firstUploaded.worktree.repo_origin_url,
|
|
76
|
+
worktreeLabel: firstUploaded.worktree.worktree_label,
|
|
77
|
+
worktreeFingerprint: firstUploaded.worktree.worktree_fingerprint,
|
|
78
|
+
worktreeIsPrimary: firstUploaded.worktree.worktree_is_primary,
|
|
79
|
+
sessions: options.sessions,
|
|
80
|
+
});
|
|
81
|
+
return Boolean(queued);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Advance both source cursors to what this tick observed.
|
|
85
|
+
*
|
|
86
|
+
* The sessions cursor is an optimization; a broken local state dir must not
|
|
87
|
+
* turn an already-completed sync into a CLI crash, so each side is best-effort
|
|
88
|
+
* and names its own failure.
|
|
89
|
+
*/
|
|
90
|
+
async function recordBothSourceCursorObservations(options) {
|
|
91
|
+
const codexStaleCount = await recordCodexSessionObservationsForSync({
|
|
92
|
+
paths: options.paths,
|
|
93
|
+
codexAttribution: options.scan.codexAttribution,
|
|
94
|
+
sessions: options.sessions,
|
|
95
|
+
now: options.now,
|
|
96
|
+
priorCursor: options.plan.codexCursorBefore,
|
|
97
|
+
codexRetryPending: options.plan.codexRetryPending,
|
|
98
|
+
});
|
|
99
|
+
const claudeStaleCount = await recordClaudeSessionObservationsForSync({
|
|
100
|
+
claudeEnabled: options.plan.claudeEnabled,
|
|
101
|
+
paths: options.paths,
|
|
102
|
+
claudeAttribution: options.scan.claudeAttribution,
|
|
103
|
+
sessions: options.sessions,
|
|
104
|
+
now: options.now,
|
|
105
|
+
claudeCursorBefore: options.plan.claudeCursorBefore,
|
|
106
|
+
claudeRetryPending: options.plan.claudeRetryPending,
|
|
107
|
+
});
|
|
108
|
+
return { codexStaleCount, claudeStaleCount };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Flush the queued session reports — or, when there is nothing queued, say why
|
|
112
|
+
* nothing was posted instead of returning a bare false.
|
|
113
|
+
*/
|
|
114
|
+
async function deliverOrExplainSessionReport(options) {
|
|
115
|
+
if (!options.hadPendingSessionReports && !options.queuedCurrentSessionReport) {
|
|
116
|
+
return {
|
|
117
|
+
posted: false,
|
|
118
|
+
reason: options.sessionCount === 0
|
|
119
|
+
? "no_sessions_observed"
|
|
120
|
+
: "no_successful_sync",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return flushPendingCodexSessionReports({
|
|
124
|
+
homeDir: options.homeDir,
|
|
125
|
+
fetch: options.fetchImpl,
|
|
126
|
+
now: options.now,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Record this sync's Codex session observations into the Codex cursor and
|
|
131
|
+
* return the stale count. Best-effort by design: a broken local state dir
|
|
132
|
+
* must not turn an already-completed sync into a CLI crash, so a failure here
|
|
133
|
+
* only logs — the stale count simply keeps whatever value it had when the
|
|
134
|
+
* failure happened (0 if the read itself failed).
|
|
135
|
+
*
|
|
136
|
+
* Reads the cursor fresh from disk right before recording rather than reusing
|
|
137
|
+
* `priorCursor`, so a concurrent writer's update is not clobbered. Claude's
|
|
138
|
+
* counterpart does not do this extra read (see
|
|
139
|
+
* `recordClaudeSessionObservationsForSync`) — a real, intentional asymmetry
|
|
140
|
+
* kept as-is rather than forced to match.
|
|
141
|
+
*/
|
|
142
|
+
async function recordCodexSessionObservationsForSync(options) {
|
|
143
|
+
let staleCount = 0;
|
|
144
|
+
try {
|
|
145
|
+
const codexCursor = await readRawEvidenceCursor(options.paths);
|
|
146
|
+
staleCount = recordSourceObservations({
|
|
147
|
+
cursor: codexCursor,
|
|
148
|
+
results: options.codexAttribution.results,
|
|
149
|
+
sessions: options.sessions,
|
|
150
|
+
source: "codex",
|
|
151
|
+
sessionIdOf: (result) => result.codex_session_id,
|
|
152
|
+
now: options.now,
|
|
153
|
+
priorCursor: options.priorCursor,
|
|
154
|
+
terminalizeMissingUndurable: options.codexRetryPending &&
|
|
155
|
+
codexAttributionReadFailureCount(options.codexAttribution) === 0,
|
|
156
|
+
});
|
|
157
|
+
codexCursor.updated_at = options.now.toISOString();
|
|
158
|
+
await writeRawEvidenceCursor(options.paths, codexCursor);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
// Best-effort: stale counts read 0 and observations re-record next sync —
|
|
162
|
+
// which is fine ONCE. A cursor write that keeps failing means the sessions
|
|
163
|
+
// cursor never advances, every sync re-does the same work, and the only
|
|
164
|
+
// symptom is a stale count that is permanently zero (BLI-3238).
|
|
165
|
+
console.error("[session-sync] Codex session observations were not recorded", JSON.stringify({
|
|
166
|
+
reason: "session_cursor_update_failed",
|
|
167
|
+
source: "codex",
|
|
168
|
+
session_count: options.sessions.length,
|
|
169
|
+
...describeError(error),
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
return staleCount;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Record this sync's Claude session observations into the Claude cursor and
|
|
176
|
+
* return the stale count — the Claude counterpart to
|
|
177
|
+
* `recordCodexSessionObservationsForSync`. A no-op returning 0 when Claude
|
|
178
|
+
* collection is disabled, matching the source-scan side's own disabled state.
|
|
179
|
+
*/
|
|
180
|
+
async function recordClaudeSessionObservationsForSync(options) {
|
|
181
|
+
if (!options.claudeEnabled)
|
|
182
|
+
return 0;
|
|
183
|
+
let staleCount = 0;
|
|
184
|
+
try {
|
|
185
|
+
staleCount = recordSourceObservations({
|
|
186
|
+
cursor: options.claudeCursorBefore,
|
|
187
|
+
results: options.claudeAttribution.results,
|
|
188
|
+
sessions: options.sessions,
|
|
189
|
+
source: "claude_code",
|
|
190
|
+
sessionIdOf: (result) => result.claude_session_id,
|
|
191
|
+
now: options.now,
|
|
192
|
+
priorCursor: options.claudeCursorBefore,
|
|
193
|
+
terminalizeMissingUndurable: options.claudeRetryPending &&
|
|
194
|
+
claudeAttributionReadFailureCount(options.claudeAttribution) === 0,
|
|
195
|
+
});
|
|
196
|
+
options.claudeCursorBefore.updated_at = options.now.toISOString();
|
|
197
|
+
await writeRawEvidenceCursor(options.paths, options.claudeCursorBefore, {
|
|
198
|
+
filename: CLAUDE_CURSOR_FILENAME,
|
|
199
|
+
sessionsOnly: true,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
// Best-effort: a broken Claude cursor must not fail the sync. It must
|
|
204
|
+
// still say it is broken — otherwise the Claude half of collection
|
|
205
|
+
// quietly repeats itself forever.
|
|
206
|
+
console.error("[session-sync] Claude session observations were not recorded", JSON.stringify({
|
|
207
|
+
reason: "session_cursor_update_failed",
|
|
208
|
+
source: "claude_code",
|
|
209
|
+
session_count: options.sessions.length,
|
|
210
|
+
...describeError(error),
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
return staleCount;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Records per-source session observations into its cursor and returns the stale
|
|
217
|
+
* count. Damped/reused Claude sessions carry forward their prior upload
|
|
218
|
+
* timestamp + byte size so the 6h damping window keeps counting from the real
|
|
219
|
+
* last upload (otherwise a slowly-growing file would never re-upload — D21).
|
|
220
|
+
*/
|
|
221
|
+
function recordSourceObservations(options) {
|
|
222
|
+
const seen = new Set(options.results.map((result) => options.sessionIdOf(result)));
|
|
223
|
+
if (options.terminalizeMissingUndurable) {
|
|
224
|
+
for (const [sessionId, entry] of Object.entries(options.cursor.sessions)) {
|
|
225
|
+
if (seen.has(sessionId) ||
|
|
226
|
+
!liveSyncCursorEntryRequiresRetry(entry)) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
options.cursor.sessions[sessionId] = {
|
|
230
|
+
...entry,
|
|
231
|
+
state: "skipped",
|
|
232
|
+
reason: "retry_source_missing",
|
|
233
|
+
last_seen_at: options.now.toISOString(),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const stale = countStaleSessions(options.cursor, seen);
|
|
238
|
+
for (const result of options.results) {
|
|
239
|
+
const sessionId = options.sessionIdOf(result);
|
|
240
|
+
const reported = options.sessions.find((session) => session.source === options.source &&
|
|
241
|
+
session.codex_session_id === sessionId);
|
|
242
|
+
const uploadedThisSync = reported?.upload_state === "uploaded";
|
|
243
|
+
const durableThisSync = reported?.upload_state === "uploaded" ||
|
|
244
|
+
reported?.upload_state === "reused_existing";
|
|
245
|
+
const prior = options.priorCursor?.sessions[sessionId];
|
|
246
|
+
// D21 / no-flip-flop: a sync that is spooled (offline), budget-deferred, or
|
|
247
|
+
// upload-failed for a session that was ALREADY durable must NOT wipe the
|
|
248
|
+
// prior durable state — otherwise damping is forfeited forever and the
|
|
249
|
+
// store row oscillates uploaded -> not_uploaded hourly. Carry the prior
|
|
250
|
+
// durable pointer/timestamp/size forward unless we durably uploaded anew.
|
|
251
|
+
const uploadedObjectKey = durableThisSync
|
|
252
|
+
? (reported?.raw_evidence_pointer_id ?? prior?.uploaded_object_key ?? null)
|
|
253
|
+
: (prior?.uploaded_object_key ?? null);
|
|
254
|
+
const uploadedAt = uploadedThisSync
|
|
255
|
+
? options.now.toISOString()
|
|
256
|
+
: (prior?.uploaded_at ?? (durableThisSync ? options.now.toISOString() : null));
|
|
257
|
+
const uploadedByteSize = uploadedThisSync
|
|
258
|
+
? result.byte_size
|
|
259
|
+
: (prior?.uploaded_byte_size ??
|
|
260
|
+
(durableThisSync ? result.byte_size : null));
|
|
261
|
+
const entry = {
|
|
262
|
+
file_hash_sha256: result.content_hash_sha256,
|
|
263
|
+
file_mtime_ms: result.session_file_mtime_ms,
|
|
264
|
+
byte_size: result.byte_size,
|
|
265
|
+
// Durable byte offset reflects how many bytes are durable remotely (the
|
|
266
|
+
// last uploaded size), not the current file size.
|
|
267
|
+
byte_offset: uploadedByteSize ?? 0,
|
|
268
|
+
state: result.state,
|
|
269
|
+
reason: result.reason,
|
|
270
|
+
worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
|
|
271
|
+
uploaded_object_key: uploadedObjectKey,
|
|
272
|
+
uploaded_at: uploadedAt,
|
|
273
|
+
uploaded_byte_size: uploadedByteSize,
|
|
274
|
+
last_seen_at: options.now.toISOString(),
|
|
275
|
+
};
|
|
276
|
+
recordSessionObservation(options.cursor, sessionId, entry);
|
|
277
|
+
}
|
|
278
|
+
return stale;
|
|
279
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading both session stores, and settling what the reading owes.
|
|
3
|
+
*
|
|
4
|
+
* Codex and Claude are scanned once per tick each, under a window this step
|
|
5
|
+
* chooses: the normal live window, the 14-day first-run backfill, or all local
|
|
6
|
+
* history when a retry is pending. It also holds the BLI-3551 split that a
|
|
7
|
+
* scan's RETRY reason and a scan's FAILURE reason are two different claims —
|
|
8
|
+
* `repo_not_on_disk` widens the next window and never fails a sync.
|
|
9
|
+
*/
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
|
|
12
|
+
import { scanAndAttributeClaudeSessions, } from "../adapters/claude-attribution.js";
|
|
13
|
+
import { CLAUDE_CURSOR_FILENAME } from "../cursors/raw-evidence-cursor.js";
|
|
14
|
+
import { clearSourceRetryFailure, recordSourceRetryFailure, } from "../spool/local-spool.js";
|
|
15
|
+
import { claudeAttributionReadFailureCount, codexAttributionReadFailureCount, } from "./agent-session-report.js";
|
|
16
|
+
const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
|
|
17
|
+
/**
|
|
18
|
+
* Scan and attribute both session sources, then settle each source's scan-retry
|
|
19
|
+
* bookkeeping against what the scan actually found.
|
|
20
|
+
*
|
|
21
|
+
* Codex first, Claude second, and Claude's first-run window is decided between
|
|
22
|
+
* them because it depends on whether the Claude cursor file exists yet.
|
|
23
|
+
*/
|
|
24
|
+
export async function scanAndAttributeBothSources(options) {
|
|
25
|
+
const { plan } = options;
|
|
26
|
+
const codexAttribution = await scanCodexSessionsForSync({
|
|
27
|
+
homeDir: options.homeDir,
|
|
28
|
+
worktrees: options.worktrees,
|
|
29
|
+
now: options.now,
|
|
30
|
+
collectionRoots: plan.collectionRoots,
|
|
31
|
+
retryPending: plan.codexRetryPending,
|
|
32
|
+
allHistorySinceMinutes: plan.allHistorySinceMinutes,
|
|
33
|
+
});
|
|
34
|
+
// First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
|
|
35
|
+
// days so the first sync captures retroactive history instead of only 24h.
|
|
36
|
+
const claudeCursorExists = await fileExists(path.join(options.paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
|
|
37
|
+
const firstRunBackfill = plan.claudeEnabled && !claudeCursorExists;
|
|
38
|
+
const claudeAttribution = await scanClaudeSessionsForSync({
|
|
39
|
+
claudeEnabled: plan.claudeEnabled,
|
|
40
|
+
homeDir: options.homeDir,
|
|
41
|
+
worktrees: options.worktrees,
|
|
42
|
+
now: options.now,
|
|
43
|
+
collectionRoots: plan.collectionRoots,
|
|
44
|
+
retryPending: plan.claudeRetryPending,
|
|
45
|
+
allHistorySinceMinutes: plan.allHistorySinceMinutes,
|
|
46
|
+
firstRunBackfill,
|
|
47
|
+
});
|
|
48
|
+
await reconcileSourceScanRetry({
|
|
49
|
+
paths: options.paths,
|
|
50
|
+
source: "codex",
|
|
51
|
+
attemptedAt: options.now.toISOString(),
|
|
52
|
+
pendingBefore: plan.codexSourceRetryPending,
|
|
53
|
+
reason: sourceScanRetryReason("codex", codexAttribution),
|
|
54
|
+
});
|
|
55
|
+
if (plan.claudeEnabled) {
|
|
56
|
+
await reconcileSourceScanRetry({
|
|
57
|
+
paths: options.paths,
|
|
58
|
+
source: "claude_code",
|
|
59
|
+
attemptedAt: options.now.toISOString(),
|
|
60
|
+
pendingBefore: plan.claudeSourceRetryPending,
|
|
61
|
+
reason: sourceScanRetryReason("claude_code", claudeAttribution),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return { codexAttribution, claudeAttribution, firstRunBackfill };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Scan and attribute Codex sessions for this sync, widening the window to all
|
|
68
|
+
* local history when a retry is pending. Codex has no first-run backfill
|
|
69
|
+
* concept (that is a Claude-only window, see `scanClaudeSessionsForSync`) and
|
|
70
|
+
* is always scanned, unlike Claude which can be disabled entirely.
|
|
71
|
+
*/
|
|
72
|
+
async function scanCodexSessionsForSync(options) {
|
|
73
|
+
const sinceMinutes = options.retryPending
|
|
74
|
+
? options.allHistorySinceMinutes
|
|
75
|
+
: CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES;
|
|
76
|
+
let scan = await scanAndAttributeCodexSessions({
|
|
77
|
+
sessionsDirs: defaultCodexSessionDirs(options.homeDir),
|
|
78
|
+
worktrees: options.worktrees,
|
|
79
|
+
now: options.now,
|
|
80
|
+
sinceMinutes,
|
|
81
|
+
limit: CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT,
|
|
82
|
+
collectionRoots: options.collectionRoots,
|
|
83
|
+
});
|
|
84
|
+
if (scan.session_limit_applied) {
|
|
85
|
+
// Re-run once, bounded by exactly what the first pass discovered, so a
|
|
86
|
+
// capped scan still returns every session it found instead of silently
|
|
87
|
+
// truncating at the window's default limit.
|
|
88
|
+
scan = await scanAndAttributeCodexSessions({
|
|
89
|
+
sessionsDirs: defaultCodexSessionDirs(options.homeDir),
|
|
90
|
+
worktrees: options.worktrees,
|
|
91
|
+
now: options.now,
|
|
92
|
+
sinceMinutes,
|
|
93
|
+
limit: scan.discovered_file_count,
|
|
94
|
+
collectionRoots: options.collectionRoots,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return scan;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Scan and attribute Claude sessions for this sync — the Claude counterpart to
|
|
101
|
+
* `scanCodexSessionsForSync`. Disabled collection returns an empty scan
|
|
102
|
+
* up front; a first sync (no cursor yet) widens the window to 14 days instead
|
|
103
|
+
* of the normal 24h so it captures retroactive history.
|
|
104
|
+
*/
|
|
105
|
+
async function scanClaudeSessionsForSync(options) {
|
|
106
|
+
if (!options.claudeEnabled)
|
|
107
|
+
return emptyClaudeScan();
|
|
108
|
+
const sinceMinutes = options.retryPending
|
|
109
|
+
? options.allHistorySinceMinutes
|
|
110
|
+
: options.firstRunBackfill
|
|
111
|
+
? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
|
|
112
|
+
: undefined;
|
|
113
|
+
const projectsDir = path.join(options.homeDir, ".claude", "projects");
|
|
114
|
+
let scan = await scanAndAttributeClaudeSessions({
|
|
115
|
+
projectsDir,
|
|
116
|
+
worktrees: options.worktrees,
|
|
117
|
+
now: options.now,
|
|
118
|
+
collectionRoots: options.collectionRoots,
|
|
119
|
+
sinceMinutes,
|
|
120
|
+
});
|
|
121
|
+
if (scan.session_limit_applied) {
|
|
122
|
+
scan = await scanAndAttributeClaudeSessions({
|
|
123
|
+
projectsDir,
|
|
124
|
+
worktrees: options.worktrees,
|
|
125
|
+
now: options.now,
|
|
126
|
+
collectionRoots: options.collectionRoots,
|
|
127
|
+
sinceMinutes,
|
|
128
|
+
limit: scan.discovered_session_count,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return scan;
|
|
132
|
+
}
|
|
133
|
+
export function sourceScanRetryReason(source, scan) {
|
|
134
|
+
const reasons = new Set();
|
|
135
|
+
const failure = sourceScanFailureReason(source, scan);
|
|
136
|
+
if (failure)
|
|
137
|
+
reasons.add(failure);
|
|
138
|
+
// Kept HERE and nowhere else (BLI-3551): a repo that is not on disk is a
|
|
139
|
+
// reason to widen the next scan window, because the transcript fallback can
|
|
140
|
+
// still attribute it. It is not a reason to call this sync failed — see
|
|
141
|
+
// `sourceScanFailureReason`.
|
|
142
|
+
if (scan.results.some((result) => result.reason === "repo_not_on_disk")) {
|
|
143
|
+
reasons.add("repo_not_on_disk");
|
|
144
|
+
}
|
|
145
|
+
return reasons.size > 0 ? [...reasons].sort().join(",") : null;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The part of the scan outcome that is a genuine FAILURE: the session store
|
|
149
|
+
* itself could not be read, so sessions that exist were not seen.
|
|
150
|
+
*
|
|
151
|
+
* Split from {@link sourceScanRetryReason} in BLI-3551. The two used to be one
|
|
152
|
+
* function, so `repo_not_on_disk` — a label the attribution umbrella finding
|
|
153
|
+
* already established is not a defect (nothing was deleted; the transcript
|
|
154
|
+
* names a path git no longer tracks) — failed the sync on every tick for three
|
|
155
|
+
* operators. A retry hint and a failure are different claims.
|
|
156
|
+
*/
|
|
157
|
+
export function sourceScanFailureReason(source, scan) {
|
|
158
|
+
const readFailureCount = source === "codex"
|
|
159
|
+
? codexAttributionReadFailureCount(scan)
|
|
160
|
+
: claudeAttributionReadFailureCount(scan);
|
|
161
|
+
return readFailureCount > 0 ? `${source}_session_store_read_failed` : null;
|
|
162
|
+
}
|
|
163
|
+
async function reconcileSourceScanRetry(options) {
|
|
164
|
+
if (options.reason) {
|
|
165
|
+
await recordSourceRetryFailure(options.paths, {
|
|
166
|
+
source: options.source,
|
|
167
|
+
reason: options.reason,
|
|
168
|
+
attemptedAt: options.attemptedAt,
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (options.pendingBefore) {
|
|
173
|
+
await clearSourceRetryFailure(options.paths, options.source, options.attemptedAt);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function emptyClaudeScan() {
|
|
177
|
+
return {
|
|
178
|
+
results: [],
|
|
179
|
+
discovered_session_count: 0,
|
|
180
|
+
scanned_session_count: 0,
|
|
181
|
+
since_minutes: 0,
|
|
182
|
+
session_limit: 0,
|
|
183
|
+
session_limit_applied: false,
|
|
184
|
+
max_file_bytes: 0,
|
|
185
|
+
max_sidecar_files: 0,
|
|
186
|
+
max_line_buffer_bytes: 0,
|
|
187
|
+
project_dirs_skipped: 0,
|
|
188
|
+
project_dir_read_failed_count: 0,
|
|
189
|
+
session_stat_failed_count: 0,
|
|
190
|
+
sidecar_dir_read_failed_count: 0,
|
|
191
|
+
sidecar_stat_failed_count: 0,
|
|
192
|
+
disabled_reason: "claude_collection_disabled_by_config",
|
|
193
|
+
counts: {
|
|
194
|
+
attributed: 0,
|
|
195
|
+
attributed_fallback: 0,
|
|
196
|
+
ambiguous: 0,
|
|
197
|
+
unattributed: 0,
|
|
198
|
+
skipped: 0,
|
|
199
|
+
mains_oversized: 0,
|
|
200
|
+
oversized_lines_skipped: 0,
|
|
201
|
+
sessions_schema_drift: 0,
|
|
202
|
+
sidecars_capped: 0,
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async function fileExists(filePath) {
|
|
207
|
+
const { stat } = await import("node:fs/promises");
|
|
208
|
+
return stat(filePath).then(() => true, () => false);
|
|
209
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes one attributed sync pass hands from step to step.
|
|
3
|
+
*
|
|
4
|
+
* They live together, away from any step, because each one is a contract
|
|
5
|
+
* BETWEEN two steps: the plan the scan reads, the scan the upload reads, the
|
|
6
|
+
* upload the recorder reads. A step that owned its own output type would make
|
|
7
|
+
* the reader open the producer to learn what the consumer receives.
|
|
8
|
+
*
|
|
9
|
+
* Every name here is re-exported from `./session-sync.js`, which is the address
|
|
10
|
+
* callers already know.
|
|
11
|
+
*/
|
|
12
|
+
export {};
|