@bli-cockpit/cli 0.2.150 → 0.2.154

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.
Files changed (31) hide show
  1. package/dist/adapters/local-sources.js +1 -0
  2. package/dist/adapters/raw-evidence-collection-state.js +1 -0
  3. package/dist/adapters/raw-evidence-facts.js +1 -0
  4. package/dist/adapters/raw-evidence-manifest.js +5 -0
  5. package/dist/adapters/raw-evidence-sanitize.js +82 -20
  6. package/dist/adapters/raw-evidence-snapshot-supersede.js +82 -0
  7. package/dist/adapters/raw-evidence-transcript-file.js +10 -0
  8. package/dist/agent-rules.js +3 -3
  9. package/dist/commands/backfill-lock-wait.js +49 -0
  10. package/dist/commands/backfill-outside-roots.js +97 -0
  11. package/dist/commands/backfill-reasons.js +17 -0
  12. package/dist/commands/backfill-scan.js +8 -3
  13. package/dist/commands/backfill-scope.js +14 -1
  14. package/dist/commands/backfill-session-report.js +3 -0
  15. package/dist/commands/backfill-upload.js +6 -0
  16. package/dist/commands/backfill.js +11 -5
  17. package/dist/commands/doctor-budget.js +69 -0
  18. package/dist/commands/doctor-lock-wait.js +12 -3
  19. package/dist/commands/doctor-pipeline-verdicts.js +2 -0
  20. package/dist/commands/doctor-pipeline.js +5 -2
  21. package/dist/commands/doctor.js +26 -4
  22. package/dist/commands/local-arg-values.js +9 -0
  23. package/dist/commands/local-args-collector-setup.js +4 -2
  24. package/dist/commands/local-args-collector-work.js +9 -1
  25. package/dist/commands/local-help-commands.js +4 -1
  26. package/dist/commands/local-help.js +1 -1
  27. package/dist/commands/public-root.js +1 -1
  28. package/dist/cursors/raw-evidence-cursor.js +14 -0
  29. package/dist/upload-envelope-build.js +9 -0
  30. package/dist/upload-sync.js +10 -22
  31. package/package.json +5 -5
@@ -34,6 +34,7 @@ export async function runLocalSourceCollectors(options) {
34
34
  claudeSessionFiles: options.rawEvidenceClaudeSessionFiles,
35
35
  claudeAttributionScan: options.rawEvidenceClaudeAttributionScan,
36
36
  skipContentHashes: options.rawEvidenceSkipContentHashes,
37
+ priorSnapshots: options.rawEvidencePriorSnapshots,
37
38
  byteBudget: options.rawEvidenceByteBudget,
38
39
  objectBudget: options.rawEvidenceObjectBudget,
39
40
  budget: options.rawEvidenceBudget,
@@ -73,6 +73,7 @@ export async function openCollection(context, options, places) {
73
73
  scanned: new Map(),
74
74
  caps: startingCaps({ byteBudget, objectBudget, budget: options.budget }),
75
75
  skipContentHashes: options.skipContentHashes ?? new Set(),
76
+ priorSnapshots: options.priorSnapshots ?? new Map(),
76
77
  budget: options.budget ?? {
77
78
  remainingBytes: byteBudget,
78
79
  remainingObjects: objectBudget,
@@ -268,6 +268,7 @@ function uploadFileFromEntry(entry) {
268
268
  ...(entry.artifact_metadata
269
269
  ? { artifact_metadata: entry.artifact_metadata }
270
270
  : {}),
271
+ ...(entry.snapshot ? { source_key: entry.source_key, snapshot: entry.snapshot } : {}),
271
272
  };
272
273
  }
273
274
  function stageReasonLabel(state, priorPackCount) {
@@ -24,6 +24,8 @@ export function evidenceEntry(options) {
24
24
  codex_session_id: options.codexSessionId ?? null,
25
25
  staged_in_pack: options.stagedInPack !== false,
26
26
  source_key: options.sourceKey ?? null,
27
+ ...(options.snapshot ? { snapshot: options.snapshot } : {}),
28
+ ...(options.supersedes ? { supersedes: options.supersedes } : {}),
27
29
  ...(options.artifactMetadata
28
30
  ? {
29
31
  artifact_metadata: {
@@ -54,6 +56,9 @@ export function pointerFromEntry(entry) {
54
56
  media_type: entry.media_type,
55
57
  redacted_summary: entry.redacted_summary,
56
58
  ...(entry.redaction ? { redaction: entry.redaction } : {}),
59
+ ...(entry.supersedes
60
+ ? { supersedes_pointer_id: entry.supersedes.pointer_id, supersede_rule: entry.supersedes.rule }
61
+ : {}),
57
62
  };
58
63
  }
59
64
  export function makeManifest(options) {
@@ -4,11 +4,13 @@ import { describeError } from "../health-detail.js";
4
4
  /** BLI-3238: a crashed redactor has to say so; see the catch below. */
5
5
  export function sanitizeTextEvidenceForUpload(options) {
6
6
  const originalBytes = options.originalBytes ?? Buffer.from(options.text, "utf8");
7
- // Deliberately outside the try: the content guard is the cheap check that
8
- // decides which masking strategy applies, and it is not the thing whose
9
- // crash the stub below is for.
10
- const secretLikeContent = containsSecretLikeContent(options.text);
11
7
  try {
8
+ // BLI-11094: inside the try. The content guard decides which masking
9
+ // strategy applies; it used to sit outside on the theory that it cannot
10
+ // crash, but it runs regexes over the whole file, and a throw from here
11
+ // escaped this function entirely, which is the one outcome the header
12
+ // promises never happens. A crash anywhere now lands on the stub.
13
+ const secretLikeContent = containsSecretLikeContent(options.text);
12
14
  const redactionResult = (options.redact ?? redactSecretLikeContent)(options.text, {
13
15
  appliedBy: "local_collector",
14
16
  redactedFields: options.redactedFields,
@@ -140,28 +142,88 @@ function stubForCrashedRedaction(input) {
140
142
  completenessLabel: "redaction_crashed_stubbed",
141
143
  };
142
144
  }
145
+ /**
146
+ * Replace every line a redacted range touches, keep every other line as is.
147
+ *
148
+ * BLI-11094: this used to split the whole file with
149
+ * `text.match(/[^\n]*(?:\n|$)/gu)`. With the `u` flag V8 cannot run `[^\n]*`
150
+ * as a flat loop (a code point may be two UTF-16 units), so it pushes one
151
+ * backtrack entry per character, and a single line of about 10 million
152
+ * characters in a two-byte string (a Codex tool output: every crashing file on
153
+ * Edward's Mac had a 12 to 14 MB line) threw `RangeError: Maximum call stack
154
+ * size exceeded`. The catch above then uploaded a stub instead of the session.
155
+ *
156
+ * Now the lines are found with `indexOf("\n")`, which has no stack at all, and
157
+ * the output is the untouched text between masked lines, so the cost is one
158
+ * pass plus the number of ranges, whatever the longest line. The contract is
159
+ * unchanged and byte-identical (pinned by
160
+ * `raw-evidence-sanitize-legacy.test.ts`): a segment ends after each `\n`,
161
+ * offsets are UTF-16 and file-absolute, and a line takes the rule of the FIRST
162
+ * range in array order that overlaps it.
163
+ */
143
164
  function maskSecretBearingLines(text, redaction) {
144
165
  if (redaction.redacted_ranges.length === 0) {
145
166
  return "[REDACTED_LINE:secret_like_content_guard]\n";
146
167
  }
147
- const segments = text.match(/[^\n]*(?:\n|$)/gu)?.filter(Boolean) ?? [];
148
- let offset = 0;
149
- return segments
150
- .map((segment) => {
151
- const start = offset;
152
- const end = offset + segment.length;
153
- offset = end;
154
- const matched = redaction.redacted_ranges.find((range) => range.start < end && start < range.end);
155
- if (!matched)
156
- return segment;
157
- return `[REDACTED_LINE:${matched.rule_id}]${lineEndingOf(segment)}`;
158
- })
159
- .join("");
168
+ const lineStarts = lineStartOffsets(text);
169
+ const ruleByLine = new Map();
170
+ for (const range of redaction.redacted_ranges) {
171
+ let line = firstLineEndingAfter(lineStarts, text.length, range.start);
172
+ for (; line < lineStarts.length; line += 1) {
173
+ const start = lineStarts[line];
174
+ const end = lineEndOf(lineStarts, text.length, line);
175
+ if (start >= range.end)
176
+ break;
177
+ if (range.start < end && start < range.end && !ruleByLine.has(line)) {
178
+ ruleByLine.set(line, range.rule_id);
179
+ }
180
+ }
181
+ }
182
+ const maskedLines = [...ruleByLine.keys()].sort((a, b) => a - b);
183
+ const pieces = [];
184
+ let cursor = 0;
185
+ for (const line of maskedLines) {
186
+ const start = lineStarts[line];
187
+ const end = lineEndOf(lineStarts, text.length, line);
188
+ pieces.push(text.slice(cursor, start));
189
+ pieces.push(`[REDACTED_LINE:${ruleByLine.get(line)}]${lineEndingOf(text, start, end)}`);
190
+ cursor = end;
191
+ }
192
+ pieces.push(text.slice(cursor));
193
+ return pieces.join("");
160
194
  }
161
- function lineEndingOf(segment) {
162
- if (segment.endsWith("\r\n"))
195
+ /** Where each non-empty segment starts; a segment ends just after its `\n`. */
196
+ function lineStartOffsets(text) {
197
+ const starts = [];
198
+ let position = 0;
199
+ while (position < text.length) {
200
+ starts.push(position);
201
+ const newline = text.indexOf("\n", position);
202
+ position = newline === -1 ? text.length : newline + 1;
203
+ }
204
+ return starts;
205
+ }
206
+ function lineEndOf(lineStarts, textLength, line) {
207
+ return lineStarts[line + 1] ?? textLength;
208
+ }
209
+ /** Index of the first line whose end is past `offset` (binary search). */
210
+ function firstLineEndingAfter(lineStarts, textLength, offset) {
211
+ let low = 0;
212
+ let high = lineStarts.length;
213
+ while (low < high) {
214
+ const middle = (low + high) >>> 1;
215
+ if (lineEndOf(lineStarts, textLength, middle) > offset)
216
+ high = middle;
217
+ else
218
+ low = middle + 1;
219
+ }
220
+ return low;
221
+ }
222
+ function lineEndingOf(text, start, end) {
223
+ if (end - start >= 2 && text[end - 2] === "\r" && text[end - 1] === "\n") {
163
224
  return "\r\n";
164
- if (segment.endsWith("\n"))
225
+ }
226
+ if (end - start >= 1 && text[end - 1] === "\n")
165
227
  return "\n";
166
228
  return "";
167
229
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Does this transcript snapshot hold every byte of the last one we committed
3
+ * for the same file (BLI-11088)?
4
+ *
5
+ * Every sync stages the current bytes of every live transcript, so a file that
6
+ * grew lands as a new object beside the old one. When the last committed
7
+ * snapshot is a byte prefix of the new bytes, the upload carries a CLAIM
8
+ * (`supersedes_pointer_id` + `supersede_rule: "prefix"`) and the ingest door,
9
+ * after re-deriving it from Storage, deletes the old object. The rule is
10
+ * defined once in `@bli-cockpit/telemetry-core/snapshot-supersede`.
11
+ *
12
+ * The collector never keeps old bytes. What it remembers about a committed
13
+ * snapshot lives in the primary cursor's `objects` map (content hash, size,
14
+ * source key, truncated prefix checkpoints), and that is all the decision
15
+ * reads. Comparing with the snapshot committed LAST is also what makes a
16
+ * one-time history rewrite harmless: the first snapshot after it keeps both,
17
+ * and every later one is a prefix of its predecessor again.
18
+ *
19
+ * A snapshot that is not a prefix successor is uploaded exactly as before, and
20
+ * says so: `snapshot_rewritten`, both sizes, and the byte range the first
21
+ * difference falls in. Never content.
22
+ */
23
+ import { firstDifferenceRange, isPrefixSuccessor, prefixCheckpoints, } from "@bli-cockpit/telemetry-core/snapshot-supersede";
24
+ /**
25
+ * The newest committed snapshot per source key, from the cursor's objects map,
26
+ * restricted to objects this work context owns (the server refuses a claim
27
+ * across work sessions, so a claim across contexts would only be refused).
28
+ */
29
+ export function priorSnapshotsFromCursor(objects, belongsToContext) {
30
+ const out = new Map();
31
+ for (const [contentHash, entry] of Object.entries(objects)) {
32
+ if (!entry.source_key || !belongsToContext(entry.object_key))
33
+ continue;
34
+ const current = out.get(entry.source_key);
35
+ const newer = !current
36
+ || entry.committed_at > current.committed_at
37
+ || (entry.committed_at === current.committed_at && entry.byte_size > current.byte_size);
38
+ if (!newer)
39
+ continue;
40
+ out.set(entry.source_key, {
41
+ raw_evidence_pointer_id: entry.object_key,
42
+ content_hash_sha256: contentHash,
43
+ byte_size: entry.byte_size,
44
+ checkpoints: entry.checkpoints ?? null,
45
+ committed_at: entry.committed_at,
46
+ });
47
+ }
48
+ return out;
49
+ }
50
+ export function describeSnapshot(bytes) {
51
+ return { checkpoints: prefixCheckpoints(bytes) };
52
+ }
53
+ /**
54
+ * The claim this snapshot may make, or null. Logs every decision that had a
55
+ * prior to compare against; a source seen for the first time has nothing to
56
+ * decide and says nothing.
57
+ */
58
+ export function supersedeClaimFor(options) {
59
+ const { prior } = options;
60
+ if (!prior || prior.content_hash_sha256 === options.contentHash)
61
+ return null;
62
+ if (isPrefixSuccessor(options.bytes, prior)) {
63
+ console.error("[evidence-upload] snapshot supersedes the previous one", JSON.stringify({
64
+ reason: "snapshot_supersede_claimed",
65
+ rule: "prefix",
66
+ kind: options.kind,
67
+ previous_byte_size: prior.byte_size,
68
+ byte_size: options.bytes.byteLength,
69
+ }));
70
+ return { pointer_id: prior.raw_evidence_pointer_id, rule: "prefix" };
71
+ }
72
+ const range = firstDifferenceRange(options.bytes, prior);
73
+ console.error("[evidence-upload] snapshot rewritten, keeping both", JSON.stringify({
74
+ reason: "snapshot_rewritten",
75
+ kind: options.kind,
76
+ previous_byte_size: prior.byte_size,
77
+ byte_size: options.bytes.byteLength,
78
+ first_differing_offset_at_least: range.at_least,
79
+ first_differing_offset_below: range.below,
80
+ }));
81
+ return null;
82
+ }
@@ -19,6 +19,7 @@ import { isSecretLikePath, sha256, shortHash, } from "./raw-evidence-keys.js";
19
19
  import { maskSecretsInTranscript, } from "./raw-evidence-sanitize.js";
20
20
  import { markBudgetCapApplied, markCapApplied, } from "./raw-evidence-completeness.js";
21
21
  import { evidenceEntry } from "./raw-evidence-manifest.js";
22
+ import { describeSnapshot, supersedeClaimFor } from "./raw-evidence-snapshot-supersede.js";
22
23
  import { admitToBudget, stageEvidenceBytes, } from "./raw-evidence-collection-state.js";
23
24
  /**
24
25
  * Reads, secret-guards, content-addresses, budget-checks, and copies one
@@ -135,6 +136,15 @@ async function stageOneTranscript(collection, file) {
135
136
  contentAddress: file.contentAddress(file.contentHash.slice(0, 16)),
136
137
  stagedInPack: staged.staged_in_pack,
137
138
  sourceKey: file.names.sourceKey,
139
+ // BLI-11088: remember this snapshot's shape, and claim the last one when
140
+ // these bytes rebuild it.
141
+ snapshot: describeSnapshot(file.sanitized.bytes),
142
+ supersedes: supersedeClaimFor({
143
+ bytes: file.sanitized.bytes,
144
+ contentHash: file.contentHash,
145
+ kind: file.kind,
146
+ prior: collection.priorSnapshots.get(file.names.sourceKey),
147
+ }),
138
148
  }));
139
149
  }
140
150
  /**
@@ -61,11 +61,11 @@ async function installAgentRulesForHost(host, options = {}, hostsInCall = [host]
61
61
  }
62
62
  async function foldClaudeIntoCodexInclude(host, rulesFile, existing, block) {
63
63
  if (!hasManagedBlock(existing)) {
64
- console.info("[agent-rules] claude rules already covered by codex include, skipping duplicate write", JSON.stringify({ reason: "claude_includes_codex_agents_file", host }));
64
+ console.error("[agent-rules] claude rules already covered by codex include, skipping duplicate write", JSON.stringify({ reason: "claude_includes_codex_agents_file", host }));
65
65
  return agentRulesResult(host, rulesFile, "unchanged", block, "included");
66
66
  }
67
67
  await writeFile(rulesFile, removeManagedBlock(existing), "utf8");
68
- console.info("[agent-rules] removed a duplicate managed block now covered by the codex include", JSON.stringify({ reason: "claude_includes_codex_agents_file", host }));
68
+ console.error("[agent-rules] removed a duplicate managed block now covered by the codex include", JSON.stringify({ reason: "claude_includes_codex_agents_file", host }));
69
69
  return agentRulesResult(host, rulesFile, "updated", block, "included");
70
70
  }
71
71
  export async function uninstallCodexAgentRules(options = {}) {
@@ -147,7 +147,7 @@ async function inspectAgentRulesForHost(host, options = {}, hostsInCall = [host]
147
147
  };
148
148
  }
149
149
  if (claudeFoldsIntoCodexInclude(host, hostsInCall, existing, homeDir)) {
150
- console.info("[agent-rules] claude rules read as installed via the codex include", JSON.stringify({ reason: "claude_includes_codex_agents_file", host }));
150
+ console.error("[agent-rules] claude rules read as installed via the codex include", JSON.stringify({ reason: "claude_includes_codex_agents_file", host }));
151
151
  return {
152
152
  ...agentRulesResult(host, rulesFile, "unchanged", block, "included"),
153
153
  installed: true,
@@ -0,0 +1,49 @@
1
+ import { acquireSyncLock, inspectSyncLock, } from "../sync-lock.js";
2
+ export const BACKFILL_SYNC_LOCK_WAIT_MS = 10 * 60_000;
3
+ const LOG_EVERY_MS = 30_000;
4
+ const POLL_EVERY_MS = 1_000;
5
+ const DEFAULT_DEPS = {
6
+ now: () => Date.now(),
7
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
8
+ acquire: acquireSyncLock,
9
+ inspect: inspectSyncLock,
10
+ };
11
+ /**
12
+ * Acquire the collection lock, waiting up to `waitMs`. `waitMs <= 0` tries
13
+ * exactly once (the caller has its own wait, as doctor does).
14
+ */
15
+ export async function acquireSyncLockWithWait(paths, waitMs = BACKFILL_SYNC_LOCK_WAIT_MS, deps = DEFAULT_DEPS) {
16
+ const started = deps.now();
17
+ let nextLogAt = started;
18
+ for (;;) {
19
+ const attempt = await deps.acquire(paths, new Date(deps.now()));
20
+ const waited = deps.now() - started;
21
+ if (attempt.acquired) {
22
+ if (waited > 0) {
23
+ console.error("[backfill] collection lock acquired after waiting", JSON.stringify({ waited_ms: waited }));
24
+ }
25
+ return { acquired: true, lock: attempt, waited_ms: waited };
26
+ }
27
+ if (waited >= waitMs) {
28
+ const holder = await holderFields(paths, deps);
29
+ console.error("[backfill] gave up waiting for collection lock", JSON.stringify({ reason: "sync_lock_held", waited_ms: waited, limit_ms: waitMs, ...holder }));
30
+ return { acquired: false, waited_ms: waited };
31
+ }
32
+ if (deps.now() >= nextLogAt) {
33
+ const holder = await holderFields(paths, deps);
34
+ console.error("[backfill] waiting for collection lock", JSON.stringify({ waited_ms: waited, limit_ms: waitMs, ...holder }));
35
+ nextLogAt += LOG_EVERY_MS;
36
+ }
37
+ await deps.sleep(Math.min(POLL_EVERY_MS, waitMs - waited));
38
+ }
39
+ }
40
+ /** Who holds the lock and how stale its heartbeat is: a pid and milliseconds, nothing else. */
41
+ async function holderFields(paths, deps) {
42
+ const owner = await deps.inspect(paths);
43
+ const heartbeatMs = owner ? Date.parse(owner.heartbeat_at) : Number.NaN;
44
+ return {
45
+ holder: "sync",
46
+ holder_pid: owner && owner.pid > 0 ? owner.pid : null,
47
+ heartbeat_age_ms: Number.isFinite(heartbeatMs) ? Math.max(0, deps.now() - heartbeatMs) : null,
48
+ };
49
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * BLI-11091: roots are labels on the backfill path too.
3
+ *
4
+ * Edward's ruling of 2026-09-17 (ticket 10734) made approved collection roots a
5
+ * LABEL on the upload, never a gate; PR 1453 carried it through the scheduled
6
+ * sync. The backfill planner still refused every session whose cwd sat outside
7
+ * the scanned roots (`cwd_outside_scanned_worktrees`): measured on the
8
+ * reference Mac, 901 of 1,513 Codex sessions, many of them the only full copy
9
+ * of that history.
10
+ *
11
+ * This module is the one place that closes that gap. A candidate whose
12
+ * attribution reason is one of the "outside every approved root" reasons (the
13
+ * SAME closed set `rootScopeForReason` reads, so one vocabulary) and which has
14
+ * no worktree gets the synthetic `outside-approved-root` workspace the sync
15
+ * path already uses for its report identity. Its attribution state and reason
16
+ * are left untouched: the workspace is transport identity only, never a claim
17
+ * that the session belongs to any repo. The upload itself runs through the
18
+ * ordinary sync envelope, so redaction applies exactly as it does to an
19
+ * inside-root session, and the explicit Claude opt-out
20
+ * (`collect_claude_jsonl: false`) is honoured before any store is read
21
+ * (`backfill-scope.ts`).
22
+ */
23
+ import fs from "node:fs/promises";
24
+ import path from "node:path";
25
+ import { getCollectorRuntimePaths } from "../local-state.js";
26
+ import { repoFingerprintFromLocalRoot, stableWorktreeFingerprint, } from "../repo-identity.js";
27
+ import { rootScopeForReason } from "./agent-session-root-scope.js";
28
+ /**
29
+ * The label the sync path writes for a report with no real worktree
30
+ * (`session-sync-record.ts`). Reused verbatim: one label, not a second one.
31
+ */
32
+ export const OUTSIDE_ROOTS_WORKSPACE_LABEL = "outside-approved-root";
33
+ /**
34
+ * The synthetic workspace an outside-root session uploads under. It lives in
35
+ * the collector's own state directory, so it is always a real, owner-only,
36
+ * empty folder the envelope builder can stand in, on macOS and Windows alike,
37
+ * and never a directory of the operator's that git or any source collector
38
+ * would read.
39
+ */
40
+ export function outsideRootsWorkspace(homeDir) {
41
+ const root = path.resolve(getCollectorRuntimePaths(homeDir).state_dir, OUTSIDE_ROOTS_WORKSPACE_LABEL);
42
+ return {
43
+ requested_path: root,
44
+ repo_root: root,
45
+ repo_label: OUTSIDE_ROOTS_WORKSPACE_LABEL,
46
+ repo_fingerprint: repoFingerprintFromLocalRoot(root),
47
+ repo_origin_url: null,
48
+ branch: "unknown",
49
+ head_sha: null,
50
+ worktree_label: OUTSIDE_ROOTS_WORKSPACE_LABEL,
51
+ worktree_fingerprint: stableWorktreeFingerprint(root),
52
+ worktree_is_primary: true,
53
+ };
54
+ }
55
+ /** Does this candidate sit outside every approved root, by its own attribution reason? */
56
+ export function isOutsideRootCandidate(candidate) {
57
+ return rootScopeForReason(candidate.reason) === "outside";
58
+ }
59
+ /**
60
+ * Attaches the outside-roots workspace to every outside-root candidate that
61
+ * has no worktree, and says how many it labelled. A candidate that already has
62
+ * a worktree keeps it; nothing else about any candidate changes.
63
+ */
64
+ export function labelOutsideRootCandidates(candidates, workspace) {
65
+ let codex = 0;
66
+ let claude = 0;
67
+ const labelled = candidates.map((candidate) => {
68
+ if (candidate.worktree || !isOutsideRootCandidate(candidate))
69
+ return candidate;
70
+ if (candidate.source === "codex")
71
+ codex += 1;
72
+ else
73
+ claude += 1;
74
+ return { ...candidate, worktree: workspace };
75
+ });
76
+ console.error("[backfill] outside-root sessions labelled for upload", JSON.stringify({
77
+ root_scope: "outside",
78
+ count: codex + claude,
79
+ codex_count: codex,
80
+ claude_count: claude,
81
+ candidate_count: candidates.length,
82
+ }));
83
+ return labelled;
84
+ }
85
+ /** Is this worktree the synthetic outside-roots workspace (and not a real repo)? */
86
+ export function isOutsideRootsWorkspace(worktree) {
87
+ return (worktree.repo_label === OUTSIDE_ROOTS_WORKSPACE_LABEL &&
88
+ path.basename(worktree.repo_root) === OUTSIDE_ROOTS_WORKSPACE_LABEL);
89
+ }
90
+ /**
91
+ * Creates the synthetic workspace folder before the first upload that needs
92
+ * it. Only the UPLOAD stage calls this, so a `--dry-run` still writes nothing.
93
+ */
94
+ export async function ensureOutsideRootsWorkspace(worktree) {
95
+ await fs.mkdir(worktree.repo_root, { recursive: true, mode: 0o700 });
96
+ console.error("[backfill] outside-roots workspace ready", JSON.stringify({ repo_label: worktree.repo_label, worktree_fingerprint: worktree.worktree_fingerprint }));
97
+ }
@@ -58,6 +58,23 @@ const REASON_VERDICTS = new Map([
58
58
  "repo_not_on_disk",
59
59
  { classification: "retryable", note: "repo must exist on disk" },
60
60
  ],
61
+ // BLI-11091: roots are a label, never a gate. These two sessions upload
62
+ // under the outside-roots workspace with `root_scope: "outside"`; the row
63
+ // says so instead of calling them skipped.
64
+ [
65
+ "cwd_outside_scanned_worktrees",
66
+ {
67
+ classification: "permanent",
68
+ note: "uploaded with the outside-roots label (root_scope outside)",
69
+ },
70
+ ],
71
+ [
72
+ "no_matching_worktree_signals",
73
+ {
74
+ classification: "permanent",
75
+ note: "uploaded with the outside-roots label (root_scope outside)",
76
+ },
77
+ ],
61
78
  [
62
79
  "cwd_not_a_repo",
63
80
  {
@@ -17,6 +17,7 @@ import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, } from "../cursors/raw-e
17
17
  import { getCollectorRuntimePaths } from "../local-state.js";
18
18
  import { candidateCursorKey, compareBackfillCandidates, isAfterCursor, } from "./backfill-candidates.js";
19
19
  import { addReadOnlyGuardIssues, addRepoDiscoveryIssues, backfillScanIssues, countReadOnlyGuards, deterministicSkipCandidateKeys, oversizedBackfillCandidateKeys, retryableCandidateKeys, sortScanIssuesByPriority, } from "./backfill-issues.js";
20
+ import { labelOutsideRootCandidates, outsideRootsWorkspace, } from "./backfill-outside-roots.js";
20
21
  import { reasonCountsFor } from "./backfill-reasons.js";
21
22
  import { blockedBackfillResult } from "./backfill-result.js";
22
23
  import { resolveBackfillScope } from "./backfill-scope.js";
@@ -117,20 +118,24 @@ async function scanBackfillSessions(options) {
117
118
  pointerlessTerminalSessions: options.pointerlessTerminalSessions,
118
119
  maxFiles: options.command.maxFiles,
119
120
  });
121
+ // BLI-11091: a session outside every approved root is labelled, not
122
+ // refused. It rides the synthetic outside-roots workspace from here on, so
123
+ // batching, retry, cursor and report treat it like any other upload.
124
+ const candidates = labelOutsideRootCandidates(selection.candidates, outsideRootsWorkspace(options.command.homeDir));
120
125
  const issues = await backfillScanIssues({
121
126
  codexAttribution,
122
127
  claudeAttribution,
123
128
  codexSessionDirs,
124
129
  claudeProjectsDir,
125
- candidates: selection.candidates,
130
+ candidates,
126
131
  omittedCandidateCount: selection.omittedCandidateCount,
127
132
  });
128
133
  return {
129
- candidates: selection.candidates,
134
+ candidates,
130
135
  codexAttribution,
131
136
  claudeAttribution,
132
137
  issues,
133
- retryable_candidate_keys: retryableCandidateKeys(selection.candidates),
138
+ retryable_candidate_keys: retryableCandidateKeys(candidates),
134
139
  omitted_candidate_count: selection.omittedCandidateCount,
135
140
  };
136
141
  }
@@ -44,10 +44,23 @@ export async function resolveBackfillScope(command, io, now, paths) {
44
44
  collectionRoots,
45
45
  worktreeDiscovery,
46
46
  retryCommand: backfillDiscoveryRetryCommand(command, worktreeDiscovery),
47
- sources: selectedSources(command.source),
47
+ sources: honourClaudeOptOut(selectedSources(command.source), config.collect_claude_jsonl),
48
48
  window,
49
49
  };
50
50
  }
51
+ /**
52
+ * Session-first (BLI-11091): now that outside-root sessions upload, an
53
+ * explicit operator opt-out is the only thing that withholds one. The
54
+ * per-machine Claude opt-out (`collect_claude_jsonl: false`) the scheduled
55
+ * sync already honours (`session-sync-plan.ts`) therefore holds here too:
56
+ * backfill never reads the Claude store on a machine that said no.
57
+ */
58
+ function honourClaudeOptOut(sources, collectClaudeJsonl) {
59
+ if (collectClaudeJsonl !== false || !sources.includes("claude_code"))
60
+ return sources;
61
+ console.error("[backfill] claude source withheld by operator opt-out", JSON.stringify({ reason: "collect_claude_jsonl_false", sources_before: sources.length }));
62
+ return sources.filter((source) => source !== "claude_code");
63
+ }
51
64
  function backfillCollectionRoots(command, savedRoots) {
52
65
  if (command.repoRoot)
53
66
  return [path.resolve(command.repoRoot)];
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { NO_UPLOAD_ATTEMPT_RECORDED, notUploadableAttributionStateReason, } from "@bli-cockpit/telemetry-core";
11
11
  import { isRawEvidenceUploadableAttributionState } from "../raw-evidence-attribution-policy.js";
12
+ import { rootScopeForReason } from "./agent-session-root-scope.js";
12
13
  /**
13
14
  * One attribution row per session this run saw, uploaded or not — the only
14
15
  * record the server ever gets of a session the collector could not upload.
@@ -80,6 +81,8 @@ function sessionAttributionRow(candidate, uploads, now) {
80
81
  attribution_reason: candidate.reason,
81
82
  attribution_score: candidate.attribution_score,
82
83
  path_score: candidate.path_score,
84
+ // BLI-11091: the same inside/outside label the sync path writes (PR 1453).
85
+ root_scope: rootScopeForReason(candidate.reason),
83
86
  signals: candidate.signals,
84
87
  ...(candidate.content_hash_sha256
85
88
  ? { session_file_hash_sha256: candidate.content_hash_sha256 }
@@ -5,6 +5,7 @@ import { buildBackfillBatches, uploadableCandidates, } from "./backfill-batches.
5
5
  import { candidateCursorKey } from "./backfill-candidates.js";
6
6
  import { countSessionUploadFailures, deferredEvidenceCount, durableBackfillCandidateKeys, } from "./backfill-evidence-outcomes.js";
7
7
  import { blockingScanIssues } from "./backfill-issues.js";
8
+ import { ensureOutsideRootsWorkspace, isOutsideRootsWorkspace, } from "./backfill-outside-roots.js";
8
9
  import { writeLine } from "./backfill-output.js";
9
10
  const BACKFILL_MAX_CONSECUTIVE_FAILURES = 3;
10
11
  /**
@@ -58,6 +59,11 @@ export async function uploadBackfillBatches(command, io, lock, ctx) {
58
59
  });
59
60
  };
60
61
  reportProgress();
62
+ // BLI-11091: outside-root sessions upload under a synthetic workspace that
63
+ // only exists once something needs it (a dry run never gets here).
64
+ const outsideWorkspace = batches.find((batch) => isOutsideRootsWorkspace(batch.worktree))?.worktree;
65
+ if (outsideWorkspace)
66
+ await ensureOutsideRootsWorkspace(outsideWorkspace);
61
67
  for (const [index, batch] of batches.entries()) {
62
68
  await lock.handle.heartbeat();
63
69
  if (command.deadlineMs !== undefined && Date.now() >= command.deadlineMs) {
@@ -9,6 +9,7 @@
9
9
  * stores into candidates; census the gaps
10
10
  * PLAN backfill-plan.ts confirm `--all`, or answer a `--dry-run`
11
11
  * UPLOAD backfill-upload.ts sync in batches under the shared lock
12
+ * (backfill-lock-wait.ts waits for it)
12
13
  * REPORT backfill-report.ts post, checkpoint, decide, assemble
13
14
  *
14
15
  * The supporting modules each own one thing the stages share:
@@ -18,6 +19,7 @@
18
19
  * backfill-scope.ts paired? which roots, sources and window
19
20
  * backfill-candidates.ts one session's identity and ordering
20
21
  * backfill-batches.ts what is uploadable, cut into batches
22
+ * backfill-outside-roots.ts outside-root sessions: a label, not a skip
21
23
  * backfill-issues.ts the ledger of what could not be accounted for
22
24
  * backfill-reasons.ts the skip-reason table an operator reads
23
25
  * backfill-evidence-outcomes.ts what one upload achieved per session
@@ -36,9 +38,9 @@
36
38
  */
37
39
  import { acquireBackfillLock } from "../backfill-lock.js";
38
40
  import { writeBackfillCursor } from "../cursors/backfill-cursor.js";
39
- import { acquireSyncLock } from "../sync-lock.js";
40
41
  import { DEFAULT_BACKFILL_SINCE_DAYS, backfillRetryCommand, defaultBackfillWindowNotice, } from "./backfill-command-line.js";
41
42
  import { isInteractiveStdin, writeHumanBackfillResult, writeLine, } from "./backfill-output.js";
43
+ import { BACKFILL_SYNC_LOCK_WAIT_MS, acquireSyncLockWithWait, } from "./backfill-lock-wait.js";
42
44
  import { planBackfillRun } from "./backfill-plan.js";
43
45
  import { reportBackfillOutcome } from "./backfill-report.js";
44
46
  import { lockHeldResult } from "./backfill-result.js";
@@ -106,21 +108,25 @@ export async function runBackfill(command, io) {
106
108
  what: "backfill lock held",
107
109
  });
108
110
  }
109
- let collectionLock;
111
+ // BLI-11091: the scheduled sync holds this lock most of the time an agent
112
+ // is working, so wait for it (bounded) instead of exiting at batch 0. Giving
113
+ // up is `sync_lock_held`, a blocked result and so a non-zero exit.
114
+ let waited;
110
115
  try {
111
- collectionLock = await acquireSyncLock(paths);
116
+ waited = await acquireSyncLockWithWait(paths, command.syncLockWaitMs ?? BACKFILL_SYNC_LOCK_WAIT_MS);
112
117
  }
113
118
  catch (error) {
114
119
  await lock.handle.release();
115
120
  throw error;
116
121
  }
117
- if (!collectionLock.acquired) {
122
+ if (!waited.acquired) {
118
123
  await lock.handle.release();
119
124
  return lockHeldResult(command, scanned, {
120
- failureReason: "sync_already_running",
125
+ failureReason: "sync_lock_held",
121
126
  what: "sync collection lock held",
122
127
  });
123
128
  }
129
+ const collectionLock = waited.lock;
124
130
  try {
125
131
  // Backfill and scheduled/manual sync share upload spool and raw-evidence
126
132
  // cursors. Holding the same collection lock makes their read-modify-write
@@ -0,0 +1,69 @@
1
+ /**
2
+ * BLI-11101: `cockpit doctor --json` answers within one bounded budget.
3
+ *
4
+ * A person at a terminal gets the long defaults (a 900-second catch-up and a
5
+ * 600-second wait for the collection lock) with progress every 30 seconds. An
6
+ * agent or script calling `--json` gets one answer: on Edward's Mac the long
7
+ * defaults ran past 9.5 minutes behind the 15-minute scheduled sync, and a
8
+ * caller that times out learns nothing. So `--json` without `--lock-wait` or
9
+ * `--backfill-budget` runs every repair under one deadline measured from the
10
+ * start of the run, and a repair that does not fit says so by name instead of
11
+ * queueing. Passing either flag opts back into the long run.
12
+ */
13
+ export const DOCTOR_JSON_BUDGET_SECONDS = 45;
14
+ export const DEFAULT_LOCK_WAIT_SECONDS = 600;
15
+ export const DEFAULT_BACKFILL_BUDGET_SECONDS = 900;
16
+ /** A repair that execs a whole sync or catch-up chunk is not started with less than this left. */
17
+ export const SLOW_FIX_MIN_SECONDS = {
18
+ "backfill-complete": 10,
19
+ "sync-fresh": 15,
20
+ };
21
+ /** The run deadline in epoch ms, or `null` when the caller gets the long defaults. */
22
+ export function doctorRunDeadline(command, startedMs) {
23
+ if (!command.json)
24
+ return null;
25
+ if (command.lockWaitSeconds !== undefined || command.backfillBudgetSeconds !== undefined)
26
+ return null;
27
+ return startedMs + DOCTOR_JSON_BUDGET_SECONDS * 1000;
28
+ }
29
+ function remainingSeconds(context, now) {
30
+ if (context.deadlineMs === undefined || context.deadlineMs === null)
31
+ return null;
32
+ return Math.max(0, (context.deadlineMs - now) / 1000);
33
+ }
34
+ /** How long a repair may wait for the collection lock. */
35
+ export function doctorLockWaitSeconds(context, now = Date.now()) {
36
+ if (context.command.lockWaitSeconds !== undefined)
37
+ return context.command.lockWaitSeconds;
38
+ const remaining = remainingSeconds(context, now);
39
+ return remaining === null ? DEFAULT_LOCK_WAIT_SECONDS : Math.min(DEFAULT_LOCK_WAIT_SECONDS, remaining);
40
+ }
41
+ /** How long the catch-up repair may run, lock waiting included. */
42
+ export function doctorBackfillBudgetSeconds(context, now = Date.now()) {
43
+ if (context.command.backfillBudgetSeconds !== undefined)
44
+ return context.command.backfillBudgetSeconds;
45
+ const remaining = remainingSeconds(context, now);
46
+ return remaining === null ? DEFAULT_BACKFILL_BUDGET_SECONDS : Math.min(DEFAULT_BACKFILL_BUDGET_SECONDS, remaining);
47
+ }
48
+ /**
49
+ * The row a slow repair returns instead of starting when the run budget cannot
50
+ * hold it; `null` means go ahead. Logged either way the budget decides.
51
+ */
52
+ export function budgetSpentRow(context, row, now = Date.now()) {
53
+ const floor = SLOW_FIX_MIN_SECONDS[row.id];
54
+ const remaining = remainingSeconds(context, now);
55
+ if (floor === undefined || remaining === null)
56
+ return null;
57
+ if (remaining >= floor) {
58
+ console.error("[doctor] repair fits the json budget", JSON.stringify({ row: row.id, remaining_seconds: Math.floor(remaining), floor_seconds: floor }));
59
+ return null;
60
+ }
61
+ console.error("[doctor] repair skipped, json budget spent", JSON.stringify({ reason: "doctor_budget_spent", row: row.id, remaining_seconds: Math.floor(remaining), floor_seconds: floor, budget_seconds: DOCTOR_JSON_BUDGET_SECONDS }));
62
+ return {
63
+ ...row,
64
+ status: "needs_fix",
65
+ code: "doctor_budget_spent",
66
+ nextAction: "cockpit doctor",
67
+ message: `\`cockpit doctor --json\` answers within ${DOCTOR_JSON_BUDGET_SECONDS}s and this repair needs at least ${floor}s; ${Math.floor(remaining)}s were left (was: ${row.code}). Run \`cockpit doctor\` to let it finish.`,
68
+ };
69
+ }
@@ -1,12 +1,13 @@
1
1
  import { inspectBackfillLock } from "../backfill-lock.js";
2
2
  import { getCollectorRuntimePaths } from "../local-state.js";
3
3
  import { inspectSyncLock } from "../sync-lock.js";
4
- const BUSY = new Set(["sync_already_running", "backfill_already_running", "live_sync_paused_during_backfill"]);
4
+ import { doctorLockWaitSeconds } from "./doctor-budget.js";
5
+ const BUSY = new Set(["sync_already_running", "sync_lock_held", "backfill_already_running", "live_sync_paused_during_backfill"]);
5
6
  /** Retry the operation itself: its existing exclusive lock acquisition owns the
6
7
  * handoff. Merely observing a free lock never grants permission to collect. */
7
8
  export async function withDoctorLockWait(context, run, timing = { now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
8
9
  const started = timing.now();
9
- const limit = (context.command.lockWaitSeconds ?? 600) * 1000;
10
+ const limit = doctorLockWaitSeconds(context) * 1000;
10
11
  let nextProgress = 0;
11
12
  let row = await run();
12
13
  while (BUSY.has(row.code)) {
@@ -27,7 +28,15 @@ export async function withDoctorLockWait(context, run, timing = { now: () => Dat
27
28
  if (result.code === 0 && result.stdout.trim())
28
29
  name = result.stdout.trim().split("\n")[0] ?? name;
29
30
  }
30
- return { ...row, status: "needs_fix", code: "lock_wait_timeout", nextAction: "cockpit doctor --lock-wait 600", message: `Waited ${Math.round(elapsed / 1000)}s for the collection lock; owner pid ${pid ?? "unknown"}, ${name}. Run \`cockpit doctor --lock-wait 600\`.` };
31
+ // BLI-11101: the scheduled sync holding the lock is not a fault; it is
32
+ // doing the upload this row would prove. Say so by name.
33
+ const syncOwns = !backfill.held && Boolean(sync?.held);
34
+ const code = syncOwns ? "sync_running" : "lock_wait_timeout";
35
+ console.error(`[doctor] ${row.id} lock wait ended`, JSON.stringify({ reason: code, owner_pid: pid ?? null, waited_seconds: Math.round(elapsed / 1000), limit_seconds: Math.round(limit / 1000) }));
36
+ const message = syncOwns
37
+ ? `The background sync (pid ${pid ?? "unknown"}, ${name}) is running and holds the collection lock; waited ${Math.round(elapsed / 1000)}s. Run \`cockpit doctor\` after it finishes, or \`cockpit doctor --lock-wait 600\` to wait for it.`
38
+ : `Waited ${Math.round(elapsed / 1000)}s for the collection lock; owner pid ${pid ?? "unknown"}, ${name}. Run \`cockpit doctor --lock-wait 600\`.`;
39
+ return { ...row, status: "needs_fix", code, nextAction: "cockpit doctor --lock-wait 600", message };
31
40
  }
32
41
  if (elapsed >= nextProgress) {
33
42
  const seconds = Math.floor(elapsed / 1000);
@@ -43,6 +43,8 @@ const COLLECTION_BUSY_REASONS = new Set([
43
43
  "live_sync_paused_during_backfill",
44
44
  // `cockpit backfill` stood aside for another backfill.
45
45
  "backfill_already_running",
46
+ // `cockpit backfill` waited for the collection lock and gave up (BLI-11091).
47
+ "sync_lock_held",
46
48
  ]);
47
49
  export function isCollectionBusyReason(reason) {
48
50
  return typeof reason === "string" && COLLECTION_BUSY_REASONS.has(reason);
@@ -1,4 +1,5 @@
1
1
  import { withDoctorLockWait } from "./doctor-lock-wait.js";
2
+ import { doctorBackfillBudgetSeconds, doctorLockWaitSeconds } from "./doctor-budget.js";
2
3
  import fs from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { inspectBackfillLock } from "../backfill-lock.js";
@@ -48,7 +49,7 @@ export async function checkBackfillState(context) {
48
49
  }
49
50
  export async function fixBackfillState(context) {
50
51
  const started = Date.now();
51
- const deadline = started + (context.command.backfillBudgetSeconds ?? 900) * 1000;
52
+ const deadline = started + doctorBackfillBudgetSeconds(context, started) * 1000;
52
53
  let caughtUp = 0;
53
54
  let chunks = 0;
54
55
  let live = null;
@@ -79,7 +80,7 @@ export async function fixBackfillState(context) {
79
80
  // Every chunk reacquires the collector's locks. Bound lock waiting by
80
81
  // the overall budget too; never abandon an in-flight cursor write.
81
82
  const chunkContext = { ...context, command: { ...context.command,
82
- lockWaitSeconds: Math.min(context.command.lockWaitSeconds ?? 600, (deadline - Date.now()) / 1000),
83
+ lockWaitSeconds: Math.min(doctorLockWaitSeconds(context), (deadline - Date.now()) / 1000),
83
84
  } };
84
85
  const row = await withDoctorLockWait(chunkContext, async () => {
85
86
  if (Date.now() >= deadline)
@@ -91,6 +92,8 @@ export async function fixBackfillState(context) {
91
92
  homeDir: context.command.homeDir, repoRoot: context.command.repoRoot,
92
93
  all: true, dryRun: false, yes: true, json: true,
93
94
  deadlineMs: deadline,
95
+ // Doctor's own `withDoctorLockWait` owns the waiting (BLI-11091).
96
+ syncLockWaitMs: 0,
94
97
  onProgress: (progress) => { live = progress; },
95
98
  }, capture.io);
96
99
  const parsed = parseDoctorBackfillJson(capture.stdout());
@@ -5,6 +5,7 @@ import { withStage } from "../crash-guard.js";
5
5
  import { checkSingleInstallState, readRootsCoverWork, fixAuthState, fixRootState, readAuthState, readRootState, } from "./doctor-access.js";
6
6
  import { backfillCompletionStepState, backfillFixVerdict, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, syncStandAsideVerdict, } from "./doctor-pipeline.js";
7
7
  import { checkMcpAnswersState } from "./doctor-mcp.js";
8
+ import { budgetSpentRow, doctorRunDeadline, DOCTOR_JSON_BUDGET_SECONDS } from "./doctor-budget.js";
8
9
  import { checkMemoryDaemonState } from "./doctor-memory-daemon.js";
9
10
  import { checkDoctorAgentRules, fixDoctorAgentRules, checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
10
11
  import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, doctorNeedsPerson, } from "./doctor-report.js";
@@ -15,12 +16,19 @@ export async function runDoctor(command, io, hooks, overrides = {}) {
15
16
  return runDoctorWithDeps(command, io, deps);
16
17
  }
17
18
  export async function runDoctorWithDeps(command, io, deps) {
18
- const context = { command, io, deps };
19
+ const runStarted = Date.now();
20
+ const deadlineMs = doctorRunDeadline(command, runStarted);
21
+ const context = { command, io, deps, deadlineMs };
22
+ console.error("[doctor] run started", JSON.stringify({ json: command.json, check_only: Boolean(command.dryRun || command.checkOnly), budget_seconds: deadlineMs === null ? null : DOCTOR_JSON_BUDGET_SECONDS }));
19
23
  const rows = [];
20
24
  const repairs = [];
21
25
  const diagnoseOnly = command.dryRun || command.checkOnly;
22
26
  const diagnosed = new Map();
27
+ // BLI-11101: every row says how long it took, so the next slow run names
28
+ // the row without a profiler. A killed run still leaves every finished row.
29
+ const checkMs = new Map();
23
30
  for (const invariant of doctorInvariants()) {
31
+ const started = Date.now();
24
32
  try {
25
33
  diagnosed.set(invariant.id, await withStage(`doctor:check:${invariant.id}`, () => invariant.check(context)));
26
34
  }
@@ -28,16 +36,24 @@ export async function runDoctorWithDeps(command, io, deps) {
28
36
  console.error(`[doctor] ${invariant.id} diagnosis_failed`, JSON.stringify({ reason: "check_threw", ...describeError(error) }));
29
37
  diagnosed.set(invariant.id, { id: invariant.id, status: "fail", code: "check_threw", message: "Run `cockpit doctor` to retry this check." });
30
38
  }
39
+ checkMs.set(invariant.id, Date.now() - started);
40
+ console.error("[doctor] row checked", JSON.stringify({ row: invariant.id, elapsed_ms: checkMs.get(invariant.id), verdict: diagnosed.get(invariant.id)?.status, reason: diagnosed.get(invariant.id)?.code }));
31
41
  }
32
42
  for (const invariant of doctorInvariants()) {
33
43
  let row;
44
+ const fixStarted = Date.now();
34
45
  try {
35
46
  row = diagnosed.get(invariant.id);
36
47
  const needsCollection = ["backfill-complete", "sync-fresh"].includes(invariant.id);
37
48
  const prerequisitesReady = !needsCollection || rows.filter((prior) => prior.id === "authed" || prior.id === "roots-ok").every((prior) => prior.status === "ok");
38
49
  const canFix = !diagnoseOnly && prerequisitesReady && invariant.fix &&
39
50
  (!invariant.requiresInteractiveFix || isInteractiveDoctorFix(context) || Boolean(command.collectionRoots?.length));
40
- if (row.status !== "ok" && row.status !== "skipped" && canFix && invariant.fix) {
51
+ const spent = row.status !== "ok" && row.status !== "skipped" && canFix ? budgetSpentRow(context, row) : null;
52
+ if (spent) {
53
+ row = spent;
54
+ repairs.push({ step: invariant.id, outcome: "needs_person", reason: row.code });
55
+ }
56
+ else if (row.status !== "ok" && row.status !== "skipped" && canFix && invariant.fix) {
41
57
  row = await withStage(`doctor:fix:${invariant.id}`, () => invariant.fix(context, row));
42
58
  repairs.push({ step: invariant.id, outcome: row.status === "ok" ? "repaired" : "needs_person", reason: row.code });
43
59
  // The replacement process owns the one final receipt, including JSON.
@@ -57,6 +73,9 @@ export async function runDoctorWithDeps(command, io, deps) {
57
73
  repairs.push({ step: invariant.id, outcome: "needs_person", reason: row.code });
58
74
  }
59
75
  console.error(`[doctor] ${invariant.id} ${row.status}`, JSON.stringify({ reason: row.code, repaired: row.fixed ?? false }));
76
+ const fixMs = Date.now() - fixStarted;
77
+ const rowCheckMs = checkMs.get(invariant.id) ?? 0;
78
+ console.error("[doctor] row done", JSON.stringify({ row: invariant.id, elapsed_ms: rowCheckMs + fixMs, check_ms: rowCheckMs, fix_ms: fixMs, verdict: row.status, reason: row.code }));
60
79
  rows.push(row);
61
80
  }
62
81
  // Re-read host state after repairs. Sync is proven by its upload receipts;
@@ -68,10 +87,11 @@ export async function runDoctorWithDeps(command, io, deps) {
68
87
  const index = rows.findIndex((row) => row.id === invariant.id);
69
88
  if (rows[index]?.status === "fail")
70
89
  continue;
90
+ const verifyStarted = Date.now();
71
91
  try {
72
92
  const checked = await invariant.check(context);
73
93
  rows[index] = { ...checked, fixed: rows[index]?.fixed && checked.status === "ok" };
74
- console.error(`[doctor] ${invariant.id} verified`, JSON.stringify({ reason: checked.code, status: checked.status }));
94
+ console.error(`[doctor] ${invariant.id} verified`, JSON.stringify({ reason: checked.code, status: checked.status, elapsed_ms: Date.now() - verifyStarted }));
75
95
  if (checked.status === "needs_fix" || checked.status === "fail") {
76
96
  const repair = repairs.find((entry) => entry.step === invariant.id);
77
97
  if (repair) {
@@ -102,8 +122,10 @@ export async function runDoctorWithDeps(command, io, deps) {
102
122
  rows.push({ id: "memory-registered", status: "needs_fix", code: "receipt_read_threw", message: "Run `cockpit doctor` to read the final setup receipt." });
103
123
  }
104
124
  }
125
+ const needsPerson = doctorNeedsPerson(rows, receipt).length;
126
+ console.error("[doctor] run done", JSON.stringify({ elapsed_ms: Date.now() - runStarted, rows: rows.length, needs_person: needsPerson, check_only: Boolean(diagnoseOnly) }));
105
127
  writeDoctorOutput(command, io, rows, receipt, repairs);
106
- return doctorNeedsPerson(rows, receipt).length === 0 ? 0 : 1;
128
+ return needsPerson === 0 ? 0 : 1;
107
129
  }
108
130
  function doctorInvariants() {
109
131
  return [
@@ -105,6 +105,15 @@ export function optionalPositiveInteger(value, flag) {
105
105
  }
106
106
  return parsed;
107
107
  }
108
+ export function optionalNonNegativeInteger(value, flag) {
109
+ if (value === undefined)
110
+ return undefined;
111
+ const parsed = Number(value);
112
+ if (!Number.isInteger(parsed) || parsed < 0) {
113
+ throw new Error(`${flag} must be a whole number of 0 or more.`);
114
+ }
115
+ return parsed;
116
+ }
108
117
  export function optionalConfidence(value, flag) {
109
118
  if (value === undefined)
110
119
  return undefined;
@@ -142,8 +142,10 @@ export function parseDoctorArgs(alias, args) {
142
142
  return {
143
143
  kind: "doctor",
144
144
  checkOnly: values.booleans.has("--check") || values.booleans.has("--no-repair"),
145
- backfillBudgetSeconds: optionalPositiveInteger(values.flags.get("--backfill-budget"), "--backfill-budget") ?? 900,
146
- lockWaitSeconds: optionalPositiveInteger(values.flags.get("--lock-wait"), "--lock-wait") ?? 600,
145
+ // Absent stays absent (BLI-11101): `doctor-budget.ts` owns the defaults,
146
+ // and `--json` without either flag runs under one bounded budget.
147
+ backfillBudgetSeconds: optionalPositiveInteger(values.flags.get("--backfill-budget"), "--backfill-budget"),
148
+ lockWaitSeconds: optionalPositiveInteger(values.flags.get("--lock-wait"), "--lock-wait"),
147
149
  alias,
148
150
  homeDir: optionalNonEmpty(values.flags.get("--home")),
149
151
  repoRoot: optionalNonEmpty(workRootFlagValue(values)),
@@ -8,7 +8,7 @@
8
8
  * defaults, same refusal sentences.
9
9
  */
10
10
  import { IntentSourceSchema, WorkIntentSchema, WorkPhaseSchema, } from "@bli-cockpit/telemetry-core";
11
- import { assertNoPositionals, optionalConfidence, optionalNonEmpty, optionalPositiveInteger, optionalSchemaValue, optionalUrl, parseNamedArgs, workRootFlagValue, } from "./local-arg-values.js";
11
+ import { assertNoPositionals, optionalConfidence, optionalNonEmpty, optionalNonNegativeInteger, optionalPositiveInteger, optionalSchemaValue, optionalUrl, parseNamedArgs, workRootFlagValue, } from "./local-arg-values.js";
12
12
  export function parseStartArgs(args) {
13
13
  const values = parseNamedArgs(args, {
14
14
  allowedFlags: [
@@ -134,6 +134,7 @@ export function parseBackfillArgs(args) {
134
134
  "--max-files",
135
135
  "--max-depth",
136
136
  "--max-repos",
137
+ "--lock-wait",
137
138
  "--yes",
138
139
  "--json",
139
140
  ],
@@ -146,6 +147,7 @@ export function parseBackfillArgs(args) {
146
147
  "--max-files",
147
148
  "--max-depth",
148
149
  "--max-repos",
150
+ "--lock-wait",
149
151
  ],
150
152
  });
151
153
  assertNoPositionals(values.positionals, "backfill");
@@ -169,7 +171,13 @@ export function parseBackfillArgs(args) {
169
171
  maxFiles: optionalPositiveInteger(values.flags.get("--max-files"), "--max-files"),
170
172
  maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
171
173
  maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
174
+ ...lockWaitMs(values.flags.get("--lock-wait")),
172
175
  yes: values.booleans.has("--yes"),
173
176
  json: values.booleans.has("--json"),
174
177
  };
178
+ }
179
+ /** `--lock-wait <seconds>` (0 = try once); absent means the 10-minute default (BLI-11091). */
180
+ function lockWaitMs(value) {
181
+ const seconds = optionalNonNegativeInteger(value, "--lock-wait");
182
+ return seconds === undefined ? {} : { syncLockWaitMs: seconds * 1000 };
175
183
  }
@@ -95,6 +95,7 @@ export function localSubcommandHelp(command) {
95
95
  "--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
96
96
  "--backfill-budget defaults to 900 seconds; catch-up repeats chunks until complete, with progress every 30 seconds.",
97
97
  "--lock-wait defaults to 600 seconds; progress prints every 30 seconds.",
98
+ "--json without either flag answers within 45 seconds; a repair that does not fit reads doctor_budget_spent.",
98
99
  "--json includes steps, repairs, and needs_person. Exit 0 means nothing needs you.",
99
100
  "Maintainers: --update-tag next keeps self-update on the prerelease candidate.",
100
101
  ],
@@ -267,7 +268,7 @@ export function localSubcommandHelp(command) {
267
268
  [
268
269
  "backfill",
269
270
  [
270
- "Usage: cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
271
+ "Usage: cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--lock-wait <seconds>] [--yes] [--workspace <path>] [--json]",
271
272
  "",
272
273
  "Backfills historical Codex and Claude Code session evidence using the saved collection roots from local config when --workspace is omitted.",
273
274
  "Omit --source to scan both sources; there is no --source all literal.",
@@ -276,6 +277,8 @@ export function localSubcommandHelp(command) {
276
277
  "Repo discovery scans 3 folder levels and up to 50 repos by default.",
277
278
  "Raise --max-depth or --max-repos when a partial result reports a discovery cap.",
278
279
  "--dry-run validates the paired collector and dashboard reachability, prints the same summary tables, and writes nothing.",
280
+ "Sessions outside every saved root upload too, labelled outside; only an explicit opt-out withholds a session.",
281
+ "When the scheduled sync holds the collection lock, backfill waits up to --lock-wait seconds (default 600) and exits 1 with sync_lock_held if it never frees.",
279
282
  ],
280
283
  ],
281
284
  [
@@ -109,7 +109,7 @@ export function localCommandHelp(command) {
109
109
  " cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
110
110
  " cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|move <id>|place <id>] [--folder <path>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--apply] [--yes] [--dashboard-url <url>] [--json]",
111
111
  " cockpit notes [accounts [--show-webhook <account-id>]|connect --provider fellow --workspace <sub> --key-stdin [--name <n>]|webhook <account-id> --secret-stdin|detach <account-id>|sync <account-id>] [--json] (the key and the signing secret are read from stdin, never a flag)",
112
- " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
112
+ " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--lock-wait <seconds>] [--yes] [--workspace <path>] [--json]",
113
113
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
114
114
  " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
115
115
  " cockpit serve [--port <port>] [--workspace <path>]",
@@ -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.150");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.154");
25
25
  return 0;
26
26
  }
27
27
 
@@ -165,10 +165,14 @@ function parseObjectEntry(value) {
165
165
  const committedAt = optionalString(record["committed_at"]);
166
166
  if (!objectKey || !committedAt)
167
167
  return null;
168
+ const sourceKey = optionalString(record["source_key"]);
168
169
  return {
169
170
  object_key: objectKey,
170
171
  byte_size: optionalNumber(record["byte_size"]),
171
172
  committed_at: committedAt,
173
+ ...(sourceKey
174
+ ? { source_key: sourceKey, checkpoints: parseCheckpoints(record["checkpoints"]) }
175
+ : {}),
172
176
  };
173
177
  }
174
178
  function parseSessionEntry(value) {
@@ -193,6 +197,16 @@ function parseSessionEntry(value) {
193
197
  last_seen_at: lastSeenAt,
194
198
  };
195
199
  }
200
+ function parseCheckpoints(value) {
201
+ if (!value || typeof value !== "object")
202
+ return null;
203
+ const out = {};
204
+ for (const [offset, hash] of Object.entries(value)) {
205
+ if (/^\d+$/.test(offset) && typeof hash === "string")
206
+ out[offset] = hash;
207
+ }
208
+ return out;
209
+ }
196
210
  function rawEvidenceCursorPath(paths, filename = PRIMARY_CURSOR_FILENAME) {
197
211
  return path.join(paths.cursors_dir, filename);
198
212
  }
@@ -14,6 +14,7 @@
14
14
  * `upload-session-reports.ts`, which builds its own provenance without a
15
15
  * full envelope.
16
16
  */
17
+ import { priorSnapshotsFromCursor, } from "./adapters/raw-evidence-snapshot-supersede.js";
17
18
  import { TelemetryIngestEnvelopeSchema, } from "@bli-cockpit/telemetry-core";
18
19
  import path from "node:path";
19
20
  import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, } from "./local-state.js";
@@ -120,6 +121,10 @@ export async function buildLocalAmbientEnvelope(options = {}) {
120
121
  operatorId: session.operator_id,
121
122
  workContextId: uploadContext.work_context_id,
122
123
  }),
124
+ rawEvidencePriorSnapshots: priorSnapshotsFromCursor(transcriptCursorObjects(options.cursorObjects), (objectKey) => rawEvidenceObjectKeyBelongsToWorkContext(objectKey, {
125
+ operatorId: session.operator_id,
126
+ workContextId: uploadContext.work_context_id,
127
+ })),
123
128
  rawEvidenceByteBudget: options.rawEvidenceByteBudget,
124
129
  rawEvidenceObjectBudget: options.rawEvidenceObjectBudget,
125
130
  rawEvidenceBudget: options.rawEvidenceBudget,
@@ -188,6 +193,10 @@ function reusableContentHashes(options, context) {
188
193
  .filter(([, entry]) => rawEvidenceObjectKeyBelongsToWorkContext(entry.object_key, context))
189
194
  .map(([hash]) => hash));
190
195
  }
196
+ /** Cursor entries that remember a transcript source (BLI-11088); the rest are ignored. */
197
+ function transcriptCursorObjects(objects) {
198
+ return Object.fromEntries(Object.entries(objects ?? {}).filter((pair) => "source_key" in pair[1] && Boolean(pair[1].source_key)));
199
+ }
191
200
  export function makeUploadWorkContext(options) {
192
201
  const provenance = makeCollectorProvenance({
193
202
  context: options.activeContext,
@@ -1,23 +1,3 @@
1
- /**
2
- * `syncLocalAmbientEnvelope`: one ambient sync, end to end.
3
- *
4
- * 1. Assemble the envelope — or record why this machine cannot upload at all
5
- * and rethrow, so `cockpit status` names the blocker.
6
- * 2. Deliver the raw evidence objects, skipping anything inside its delivery
7
- * backoff window (`upload-evidence-delivery.ts`).
8
- * 3. Reconcile the envelope with what actually became durable, POST it to
9
- * ingest, and refuse to believe a receipt we cannot read
10
- * (`upload-ingest-receipt.ts`).
11
- * 4. Advance the content cursor, report any agent images, and write the ledger
12
- * row — a retry when a later sync could still rescue the gap, a named
13
- * blocker when it never can, nothing extra when the sync was clean.
14
- * 5. On any failure, spool a retry that carries its own reason.
15
- *
16
- * The whole file is built around one rule from the fleet contract: a sync that
17
- * did not collect everything must never read green. Objects held by backoff are
18
- * reported as named failures, permanent rejections are logged and blocked
19
- * rather than silently retried forever, and the ledger row always says why.
20
- */
21
1
  import { getCollectorRuntimePaths } from "./local-state.js";
22
2
  import { uploadRawEvidenceFilesChunked, } from "./evidence-upload-client.js";
23
3
  import { markObjectCommitted, readRawEvidenceCursor, writeRawEvidenceCursor, } from "./cursors/raw-evidence-cursor.js";
@@ -80,7 +60,7 @@ export async function syncLocalAmbientEnvelope(options = {}) {
80
60
  built,
81
61
  envelope: applyRawEvidenceUploadOutcomes(built.envelope, uploadOutcomes),
82
62
  });
83
- advanceRawEvidenceCursor(cursor, uploadOutcomes, attemptedAt);
63
+ advanceRawEvidenceCursor(cursor, uploadOutcomes, attemptedAt, built.raw_evidence_upload_files);
84
64
  await writeRawEvidenceCursor(paths, cursor);
85
65
  await reportAgentImageArtifacts({
86
66
  fetchImpl,
@@ -221,17 +201,25 @@ async function buildEnvelopeOrRecordBlocker(paths, options, cursorObjects, attem
221
201
  * bytes instead of re-uploading them. A failed upload is deliberately absent:
222
202
  * recording it would make the next sync skip a file that never landed.
223
203
  */
224
- function advanceRawEvidenceCursor(cursor, outcomes, attemptedAt) {
204
+ function advanceRawEvidenceCursor(cursor, outcomes, attemptedAt, files = []) {
205
+ // BLI-11088: a transcript's source key and snapshot facts ride into the
206
+ // cursor, so the next sync can prove its snapshot rebuilds this one.
207
+ const snapshotByHash = new Map(files.filter((file) => file.snapshot && file.source_key && file.pointer.content_hash_sha256)
208
+ .map((file) => [file.pointer.content_hash_sha256, file]));
225
209
  for (const outcome of outcomes) {
226
210
  if (outcome.upload_state === "upload_failed")
227
211
  continue;
228
212
  const contentHash = outcome.pointer.content_hash_sha256;
229
213
  if (!contentHash)
230
214
  continue;
215
+ const source = snapshotByHash.get(contentHash);
231
216
  markObjectCommitted(cursor, contentHash, {
232
217
  object_key: outcome.object_key,
233
218
  byte_size: outcome.pointer.byte_size ?? 0,
234
219
  committed_at: attemptedAt,
220
+ ...(source
221
+ ? { source_key: source.source_key, checkpoints: source.snapshot.checkpoints }
222
+ : {}),
235
223
  });
236
224
  }
237
225
  cursor.updated_at = attemptedAt;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.150",
3
+ "version": "0.2.154",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,11 +24,11 @@
24
24
  "pretypecheck": "node ../../scripts/build-workspace-dep.mjs @bli-cockpit/cli",
25
25
  "typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
26
26
  "pretest": "node ../../scripts/build-workspace-dep.mjs @bli-cockpit/cli",
27
- "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
27
+ "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.39",
31
- "@bli-cockpit/mcp": "0.1.73",
32
- "@bli-cockpit/telemetry-core": "0.1.51"
30
+ "@bli-cockpit/memory-mcp": "0.1.41",
31
+ "@bli-cockpit/mcp": "0.1.75",
32
+ "@bli-cockpit/telemetry-core": "0.1.53"
33
33
  }
34
34
  }