@bli-cockpit/cli 0.2.249 → 0.2.251

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 (32) hide show
  1. package/dist/adapters/raw-evidence-collection-state.js +2 -0
  2. package/dist/adapters/raw-evidence-prepare-memo.js +212 -0
  3. package/dist/adapters/raw-evidence-transcript-file.js +122 -9
  4. package/dist/adapters/raw-evidence.js +20 -0
  5. package/dist/autostart-darwin-plist.js +21 -5
  6. package/dist/autostart.js +1 -1
  7. package/dist/commands/backfill-live-window.js +41 -0
  8. package/dist/commands/backfill-report.js +11 -1
  9. package/dist/commands/backfill-scan.js +8 -4
  10. package/dist/commands/doctor-disk-words.js +1 -1
  11. package/dist/commands/local-args-tower-manager.js +28 -17
  12. package/dist/commands/local-args-tower-notes.js +1 -1
  13. package/dist/commands/local-args-tower-settings.js +24 -3
  14. package/dist/commands/local-help-commands-tower-ops.js +20 -2
  15. package/dist/commands/local-help-commands-tower.js +2 -2
  16. package/dist/commands/local-help-commands.js +1 -1
  17. package/dist/commands/local-help-project.js +1 -1
  18. package/dist/commands/local-help.js +1 -1
  19. package/dist/commands/manager-decisions.js +189 -0
  20. package/dist/commands/manager-hints.js +2 -0
  21. package/dist/commands/manager-limits.json +42 -1
  22. package/dist/commands/manager-meetings.js +184 -0
  23. package/dist/commands/manager-requests.js +15 -0
  24. package/dist/commands/manager.js +16 -2
  25. package/dist/commands/notes-accounts.js +1 -0
  26. package/dist/commands/public-root.js +1 -1
  27. package/dist/commands/settings.js +1 -1
  28. package/dist/evidence-upload-terminal.js +47 -5
  29. package/dist/image-description-session.js +20 -9
  30. package/dist/raw-evidence-staging.js +2 -1
  31. package/dist/upload-evidence-delivery-offer.js +3 -0
  32. package/package.json +2 -2
@@ -23,6 +23,7 @@
23
23
  * `.staging-<pid>-<rand>` directory that `raw-evidence-facts.ts` promotes once
24
24
  * collection is done.
25
25
  */
26
+ import { openPrepareMemo } from "./raw-evidence-prepare-memo.js";
26
27
  import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
27
28
  import crypto from "node:crypto";
28
29
  import fs from "node:fs/promises";
@@ -63,6 +64,7 @@ export async function openCollection(context, options, places) {
63
64
  if (options.images)
64
65
  budget.images ??= options.images;
65
66
  const stagingAllowance = (budget.staging ??= await openStagingAllowance(options.stateDir, options.env ?? process.env, { statfs: options.statfs }));
67
+ budget.prepareMemo ??= await openPrepareMemo(options.stateDir);
66
68
  return {
67
69
  context,
68
70
  filesDir: path.join(places.stagingDir, "files"),
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Remember what a transcript became, so a tick does not rebuild it (BLI-3936).
3
+ *
4
+ * Turning a transcript into the bytes that go up costs a read, the image step, the slim rules, the
5
+ * secret mask and a sha256 of the result. The result only matters for ONE decision: is this
6
+ * content already durable remotely? Before this file, every 15-minute tick (and every worktree
7
+ * pass inside it) paid that whole cost for every recent transcript to learn the answer it had
8
+ * already learned the tick before: 280 slim passes and 0.38 GB of JSON per tick on the reference
9
+ * Mac, plus 82 mask passes, for transcripts that had not changed.
10
+ *
11
+ * The memo answers the question without the work. It is keyed by the same source key the delivery
12
+ * ledger uses, and an entry is trusted only when ALL of these still hold:
13
+ *
14
+ * - the file's size, inode and mtime/ctime (nanoseconds) are exactly what they were when the
15
+ * entry was written, so the file has not been appended to, rewritten or replaced;
16
+ * - the recipe is the same: collector version (slim rules and secret rules ship with it), the
17
+ * machine's image-handling mode, and whether an image step ran at all;
18
+ * - the entry is not `volatile`: a transcript that still held an image with no stored
19
+ * description (or whose slim step failed) can change shape on the next tick without the file
20
+ * changing, so it is always rebuilt.
21
+ *
22
+ * The caller then reuses the entry ONLY when the remembered content hash is already durable
23
+ * remotely (`skipContentHashes`). Anything else, including every file that is not yet delivered,
24
+ * takes the normal full path, so the memo can only ever remove work whose outcome is already
25
+ * known. A memo that is lost, corrupt, or wrong in the safe direction costs one slow tick, never
26
+ * a missed upload. It holds ids, sizes, hashes and redaction metadata, never a byte of content.
27
+ */
28
+ import path from "node:path";
29
+ import fs from "node:fs/promises";
30
+ import { isMissingFileFailure } from "../health-detail.js";
31
+ import { readJsonFile, writeJsonFileAtomic } from "../local-state-files.js";
32
+ import { LOCAL_COLLECTOR_VERSION } from "../local-state-config.js";
33
+ export const PREPARE_MEMO_FILENAME = "transcript-prepare-memo.json";
34
+ const MEMO_FORMAT = "prepare-memo.v1";
35
+ /** About 700 bytes an entry, so this is at most a few MB; the oldest leave first. */
36
+ export const PREPARE_MEMO_MAX_ENTRIES = 20_000;
37
+ /** A source not seen for this long is a deleted or rotated session. */
38
+ const FORGET_AFTER_MS = 14 * 24 * 3_600_000;
39
+ /** Refresh `seen_ms` at most this often, so an unchanged tick does not rewrite the file. */
40
+ const SEEN_REFRESH_MS = 24 * 3_600_000;
41
+ const TAG = "[raw-evidence-memo]";
42
+ export function sourceStampOf(stat) {
43
+ return {
44
+ size: Number(stat.size),
45
+ identity: [stat.ino, stat.mtimeNs, stat.ctimeNs].map(String).join(":"),
46
+ };
47
+ }
48
+ /** Stat a file the way the memo wants it. Null when it cannot be stat'd: the caller takes the normal path, which says why. */
49
+ export async function statSourceForMemo(filePath) {
50
+ try {
51
+ return sourceStampOf(await fs.stat(filePath, { bigint: true }));
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ /** Everything besides the file that decides what its prepared bytes are. */
58
+ export function prepareRecipe(options) {
59
+ return [MEMO_FORMAT, LOCAL_COLLECTOR_VERSION, options.kind, options.imageMode ?? "no_image_step"].join("|");
60
+ }
61
+ export function prepareMemoPath(stateDir) {
62
+ return path.join(stateDir, PREPARE_MEMO_FILENAME);
63
+ }
64
+ const EMPTY_COUNTS = { skipped_count: 0, skipped_bytes: 0, rebuilt_count: 0, rebuilt_bytes: 0, volatile_count: 0, volatile_reused_count: 0, volatile_reused_bytes: 0 };
65
+ export async function openPrepareMemo(stateDir) {
66
+ const entries = await readMemoEntries(stateDir);
67
+ let dirty = false;
68
+ let counts = { ...EMPTY_COUNTS };
69
+ /** Sources recalled since the last `settle`: only these have their `seen_ms` refreshed. */
70
+ const recalled = new Set();
71
+ /** In-memory only, never persisted: see `rememberForThisRun`. */
72
+ const thisRun = new Map();
73
+ return {
74
+ recall(sourceKey, stamp, recipe) {
75
+ const entry = entries.get(sourceKey);
76
+ if (!entry)
77
+ return recallThisRun(thisRun, sourceKey, stamp, recipe);
78
+ if (entry.volatile)
79
+ return recallThisRun(thisRun, sourceKey, stamp, recipe);
80
+ if (entry.recipe !== recipe)
81
+ return null;
82
+ if (entry.size !== stamp.size || entry.identity !== stamp.identity)
83
+ return null;
84
+ recalled.add(sourceKey);
85
+ return entry;
86
+ },
87
+ remember(sourceKey, entry, now = Date.now()) {
88
+ entries.set(sourceKey, { ...entry, seen_ms: now });
89
+ thisRun.delete(sourceKey);
90
+ dirty = true;
91
+ },
92
+ rememberForThisRun(sourceKey, entry) {
93
+ thisRun.set(sourceKey, { ...entry, volatile: true, seen_ms: Date.now() });
94
+ },
95
+ count(outcome, bytes) {
96
+ if (outcome === "volatile_reused") {
97
+ counts.volatile_reused_count += 1;
98
+ counts.volatile_reused_bytes += bytes;
99
+ return;
100
+ }
101
+ if (outcome === "skipped") {
102
+ counts.skipped_count += 1;
103
+ counts.skipped_bytes += bytes;
104
+ return;
105
+ }
106
+ counts.rebuilt_count += 1;
107
+ counts.rebuilt_bytes += bytes;
108
+ if (outcome === "volatile")
109
+ counts.volatile_count += 1;
110
+ },
111
+ async settle(now = Date.now()) {
112
+ const done = counts;
113
+ counts = { ...EMPTY_COUNTS };
114
+ dirty = refreshSeen(entries, recalled, now) || dirty;
115
+ recalled.clear();
116
+ if (!dirty)
117
+ return done;
118
+ const kept = trimEntries(entries, now);
119
+ try {
120
+ await writeJsonFileAtomic(prepareMemoPath(stateDir), { schema_version: MEMO_FORMAT, entries: Object.fromEntries(kept) });
121
+ dirty = false;
122
+ }
123
+ catch (error) {
124
+ console.error(TAG + " memo not written; the next tick rebuilds what it would have skipped", JSON.stringify({ reason: "prepare_memo_write_failed", entries: kept.length, error_name: error instanceof Error ? error.name : typeof error }));
125
+ }
126
+ return done;
127
+ },
128
+ };
129
+ }
130
+ function recallThisRun(thisRun, sourceKey, stamp, recipe) {
131
+ const entry = thisRun.get(sourceKey);
132
+ if (!entry || entry.recipe !== recipe)
133
+ return null;
134
+ if (entry.size !== stamp.size || entry.identity !== stamp.identity)
135
+ return null;
136
+ return entry;
137
+ }
138
+ /**
139
+ * Entries recalled this pass keep their `seen_ms` fresh, lazily, so they are not forgotten while in
140
+ * use. Only those: an entry nobody recalls (a deleted or rotated session) ages out after
141
+ * FORGET_AFTER_MS.
142
+ */
143
+ function refreshSeen(entries, recalled, now) {
144
+ let touched = false;
145
+ for (const key of recalled) {
146
+ const entry = entries.get(key);
147
+ if (entry && now - entry.seen_ms > SEEN_REFRESH_MS) {
148
+ entry.seen_ms = now;
149
+ touched = true;
150
+ }
151
+ }
152
+ return touched;
153
+ }
154
+ function trimEntries(entries, now) {
155
+ for (const [key, entry] of entries) {
156
+ if (now - entry.seen_ms > FORGET_AFTER_MS)
157
+ entries.delete(key);
158
+ }
159
+ const newestFirst = [...entries.entries()].sort((a, b) => b[1].seen_ms - a[1].seen_ms);
160
+ const kept = newestFirst.slice(0, PREPARE_MEMO_MAX_ENTRIES);
161
+ if (kept.length < entries.size) {
162
+ entries.clear();
163
+ for (const [key, entry] of kept)
164
+ entries.set(key, entry);
165
+ }
166
+ return kept;
167
+ }
168
+ async function readMemoEntries(stateDir) {
169
+ const entries = new Map();
170
+ let raw;
171
+ try {
172
+ raw = await readJsonFile(prepareMemoPath(stateDir));
173
+ }
174
+ catch (error) {
175
+ if (!isMissingFileFailure(error)) {
176
+ console.error(TAG + " memo unreadable; every transcript is rebuilt once", JSON.stringify({ reason: "prepare_memo_unreadable" }));
177
+ }
178
+ return entries;
179
+ }
180
+ const stored = raw;
181
+ if (stored?.schema_version !== MEMO_FORMAT || !stored.entries || typeof stored.entries !== "object")
182
+ return entries;
183
+ let dropped = 0;
184
+ for (const [key, value] of Object.entries(stored.entries)) {
185
+ const entry = parseEntry(value);
186
+ if (entry)
187
+ entries.set(key, entry);
188
+ else
189
+ dropped += 1;
190
+ }
191
+ if (dropped > 0) {
192
+ console.error(TAG + " memo entries dropped as malformed", JSON.stringify({ reason: "prepare_memo_entries_malformed", dropped, kept: entries.size }));
193
+ }
194
+ return entries;
195
+ }
196
+ function parseEntry(value) {
197
+ if (!value || typeof value !== "object")
198
+ return null;
199
+ const entry = value;
200
+ if (typeof entry["size"] !== "number" || typeof entry["identity"] !== "string")
201
+ return null;
202
+ if (typeof entry["recipe"] !== "string" || typeof entry["byte_size"] !== "number")
203
+ return null;
204
+ if (typeof entry["content_hash"] !== "string" || !/^[0-9a-f]{64}$/u.test(entry["content_hash"]))
205
+ return null;
206
+ if (typeof entry["volatile"] !== "boolean" || typeof entry["seen_ms"] !== "number")
207
+ return null;
208
+ const redacted = entry["redacted"];
209
+ if (redacted !== null && (typeof redacted !== "object" || typeof redacted.completenessLabel !== "string"))
210
+ return null;
211
+ return entry;
212
+ }
@@ -10,6 +10,11 @@
10
10
  *
11
11
  * Every refusal pushes a named skip into the gap ledger first, so a file this
12
12
  * module declines is never silently absent.
13
+ *
14
+ * BLI-3936: the read, the image step, the slim rules, the mask and the hash exist to answer one
15
+ * question, "is this content already durable remotely?". A transcript whose file has not changed
16
+ * since the tick that answered it is not rebuilt: `reuseUnchangedTranscript` replays the answer
17
+ * from the prepare memo (`raw-evidence-prepare-memo.ts`) and says so in the pass's summary line.
13
18
  */
14
19
  import fs from "node:fs/promises";
15
20
  import path from "node:path";
@@ -20,6 +25,7 @@ import { maskSecretsInTranscript, } from "./raw-evidence-sanitize.js";
20
25
  import { mightHoldEmbeddedImages } from "@bli-cockpit/telemetry-core";
21
26
  import { markBudgetCapApplied, markCapApplied, } from "./raw-evidence-completeness.js";
22
27
  import { evidenceEntry } from "./raw-evidence-manifest.js";
28
+ import { prepareRecipe, statSourceForMemo, } from "./raw-evidence-prepare-memo.js";
23
29
  import { describeSnapshot, stubReplacementClaim, supersedeClaimFor, } from "./raw-evidence-snapshot-supersede.js";
24
30
  import { admitEvidence, stageEvidenceBytes, } from "./raw-evidence-collection-state.js";
25
31
  /**
@@ -44,6 +50,12 @@ export async function collectOneEvidenceFile(collection, options) {
44
50
  logDeliveryBackoffHold(options.kind, names.sourceKey);
45
51
  return false;
46
52
  }
53
+ // BLI-3936: stat BEFORE the read. The same stamp, taken again after the work, proves the file
54
+ // did not move underneath it; only then is the result remembered.
55
+ const stamp = await statSourceForMemo(options.filePath);
56
+ const recipe = prepareRecipe({ imageMode: collection.budget.images?.mode, kind: options.kind });
57
+ if (await reuseUnchangedTranscript(collection, options, names, stamp, recipe))
58
+ return true;
47
59
  const read = await readEvidenceFileWithinCap(options.filePath, options.maxFileBytes);
48
60
  if (read.status === "read_failed")
49
61
  return skip("file_read_failed");
@@ -51,7 +63,10 @@ export async function collectOneEvidenceFile(collection, options) {
51
63
  markCapApplied(collection, options.kind, "max_file_bytes");
52
64
  return skip("file_too_large");
53
65
  }
54
- const { sanitized, contentHash, stubClaim } = await prepareTranscriptForUpload(collection, options, names, read.bytes);
66
+ const { sanitized, contentHash, stubClaim, volatile, deferredOnly, redactedEntry } = await prepareTranscriptForUpload(collection, options, names, read.bytes);
67
+ await rememberPreparedTranscript(collection, options, names, {
68
+ stamp, recipe, readBytes: read.bytes.byteLength, contentHash, byteSize: sanitized.bytes.byteLength, volatile, deferredOnly, redactedEntry,
69
+ });
55
70
  if (collection.skipContentHashes.has(contentHash) && !stubClaim) {
56
71
  collection.reused.push({
57
72
  kind: options.kind,
@@ -82,17 +97,20 @@ function recordEvidenceSkip(collection, kind, label, reason) {
82
97
  async function prepareTranscriptForUpload(collection, options, names, bytes) {
83
98
  // BLI-16977: describe each distinct embedded image once and, when the one switch says so, swap the
84
99
  // base64 for a short marker BEFORE masking, so the masker and the content hash see the final bytes.
85
- const imagesPrepared = await prepareEmbeddedImages(collection, {
100
+ const images = await prepareEmbeddedImages(collection, {
86
101
  bytes,
87
102
  kind: options.kind,
88
103
  sessionId: options.sessionId,
89
104
  });
105
+ const redactedBefore = collection.redacted.length;
90
106
  const sanitized = maskSecretsInTranscript(collection, {
91
- raw: imagesPrepared,
107
+ raw: images.bytes,
92
108
  kind: options.kind,
93
109
  evidenceLabel: names.evidenceLabel,
94
110
  secretLikeFileName: names.secretLikeFileName,
95
111
  });
112
+ // The ledger entry this mask pushed (if any), kept so a later skip can replay it.
113
+ const redactedEntry = collection.redacted.length > redactedBefore ? collection.redacted[collection.redacted.length - 1] : null;
96
114
  const contentHash = sha256(sanitized.bytes);
97
115
  // BLI-11102: a session the server named a redaction stub is re-offered even
98
116
  // when these bytes are already durable, because the stub is not a delivery.
@@ -103,7 +121,7 @@ async function prepareTranscriptForUpload(collection, options, names, bytes) {
103
121
  byteSize: sanitized.bytes.byteLength,
104
122
  alreadyDurable: collection.skipContentHashes.has(contentHash),
105
123
  });
106
- return { sanitized, contentHash, stubClaim };
124
+ return { sanitized, contentHash, stubClaim, volatile: images.volatile, deferredOnly: images.deferredOnly, redactedEntry };
107
125
  }
108
126
  /**
109
127
  * The transcript bytes after the image step. Unchanged (the very same buffer) unless this sync has
@@ -115,24 +133,119 @@ async function prepareEmbeddedImages(collection, file) {
115
133
  const session = collection.budget.images;
116
134
  const source = file.kind.startsWith("codex") ? "codex" : file.kind.startsWith("claude") ? "claude_code" : null;
117
135
  if (!session || !source)
118
- return file.bytes;
136
+ return { bytes: file.bytes, volatile: false, deferredOnly: false };
119
137
  // Drop mode slims every transcript, image-bearing or not; keep mode only describes the ones that can hold an image.
120
138
  if (session.mode !== "describe_and_drop" && !mightHoldEmbeddedImages(file.bytes))
121
- return file.bytes;
139
+ return { bytes: file.bytes, volatile: false, deferredOnly: false };
122
140
  try {
123
141
  const prepared = await session.prepareTranscript({
124
142
  text: file.bytes.toString("utf8"),
125
143
  source,
126
144
  sessionId: file.sessionId,
127
145
  });
146
+ // BLI-3936: "volatile" = the next tick could build different bytes, or do image work, from
147
+ // this very file: an image still without a stored description (keep mode never replaces, so
148
+ // any image counts) or a slim step that failed and should be retried.
149
+ const imagesPending = session.mode === "describe_and_drop"
150
+ ? prepared.imageCount > prepared.replacedCount
151
+ : prepared.imageCount > 0;
152
+ const slimFailed = session.mode === "describe_and_drop" && prepared.slim === null;
153
+ const volatile = imagesPending || slimFailed;
154
+ // BLI-22785: volatile ONLY because the budget or a backoff held images back, and the slim step held up.
155
+ const deferredOnly = volatile && prepared.deferredOnly && !slimFailed;
128
156
  if (!prepared.changed)
129
- return file.bytes;
130
- return Buffer.from(prepared.text, "utf8");
157
+ return { bytes: file.bytes, volatile, deferredOnly };
158
+ return { bytes: Buffer.from(prepared.text, "utf8"), volatile, deferredOnly };
131
159
  }
132
160
  catch (error) {
133
161
  console.error("[raw-evidence] image or slim step failed, uploading the transcript as it was", JSON.stringify({ reason: "image_step_failed", kind: file.kind, ...describeError(error) }));
134
- return file.bytes;
162
+ return { bytes: file.bytes, volatile: true, deferredOnly: false };
163
+ }
164
+ }
165
+ /**
166
+ * BLI-3936: the answer for a transcript whose file has not changed since a tick that found its
167
+ * content already durable remotely. Replays exactly what the full path would have recorded (the
168
+ * reuse entry, and the redaction ledger entry if the mask had fired) without reading, slimming,
169
+ * masking or hashing a byte. Anything else, including every file not yet delivered, returns false
170
+ * and takes the full path, so this can only remove work whose outcome is already known.
171
+ */
172
+ async function reuseUnchangedTranscript(collection, options, names, stamp, recipe) {
173
+ const memo = collection.budget.prepareMemo;
174
+ if (!memo || !stamp)
175
+ return false;
176
+ // A session the server named a redaction stub is re-offered whatever is durable (BLI-11102).
177
+ if (collection.stubReoffers.has(options.sessionId))
178
+ return false;
179
+ // A file over this pass's cap takes the full path, which names the cap it hit.
180
+ if (options.maxFileBytes && stamp.size > options.maxFileBytes)
181
+ return false;
182
+ const known = memo.recall(names.sourceKey, stamp, recipe);
183
+ if (!known)
184
+ return false;
185
+ if (!collection.skipContentHashes.has(known.content_hash)) {
186
+ if (known.volatile)
187
+ logVolatileReuse(options.kind, names.sourceKey, known.content_hash, "hash_not_durable_full_path");
188
+ return false;
189
+ }
190
+ if (known.redacted) {
191
+ collection.redacted.push({
192
+ kind: options.kind,
193
+ label: names.evidenceLabel,
194
+ redaction: known.redacted.redaction,
195
+ completenessLabel: known.redacted.completenessLabel,
196
+ });
197
+ }
198
+ collection.reused.push({
199
+ kind: options.kind,
200
+ label: names.evidenceLabel,
201
+ content_hash_sha256: known.content_hash,
202
+ codex_session_id: options.sessionId,
203
+ });
204
+ if (known.volatile) {
205
+ // BLI-22785: images held back by this run's budget or a backoff; rebuilding gives the same bytes.
206
+ memo.count("volatile_reused", stamp.size);
207
+ logVolatileReuse(options.kind, names.sourceKey, known.content_hash, "images_deferred_budget_cannot_refill_this_run");
208
+ return true;
209
+ }
210
+ memo.count("skipped", stamp.size);
211
+ return true;
212
+ }
213
+ function logVolatileReuse(kind, sourceKey, contentHash, reason) {
214
+ console.error("[raw-evidence] volatile transcript " + (reason === "hash_not_durable_full_path" ? "rebuilt" : "reused this run"), JSON.stringify({ reason, kind, source_key: sourceKey, hash_prefix: contentHash.slice(0, 12) }));
215
+ }
216
+ /** Remember what this transcript became, but only if the file stayed put while it was built. */
217
+ async function rememberPreparedTranscript(collection, options, names, built) {
218
+ const memo = collection.budget.prepareMemo;
219
+ if (!memo)
220
+ return;
221
+ memo.count(built.volatile ? "volatile" : "rebuilt", built.readBytes);
222
+ if ((built.volatile && !built.deferredOnly) || !built.stamp)
223
+ return;
224
+ const after = await statSourceForMemo(options.filePath);
225
+ const stable = after !== null
226
+ && after.size === built.stamp.size
227
+ && after.identity === built.stamp.identity
228
+ && built.readBytes === built.stamp.size;
229
+ if (!stable)
230
+ return;
231
+ const entry = {
232
+ ...built.stamp,
233
+ recipe: built.recipe,
234
+ content_hash: built.contentHash,
235
+ byte_size: built.byteSize,
236
+ redacted: built.redactedEntry
237
+ ? { redaction: built.redactedEntry.redaction, completenessLabel: built.redactedEntry.completenessLabel }
238
+ : null,
239
+ };
240
+ if (built.volatile) {
241
+ // BLI-22785: in memory for this process only; the memo file keeps no volatile entry.
242
+ memo.rememberForThisRun(names.sourceKey, entry);
243
+ return;
135
244
  }
245
+ const known = memo.recall(names.sourceKey, built.stamp, built.recipe);
246
+ if (known && !known.volatile && known.content_hash === built.contentHash)
247
+ return;
248
+ memo.remember(names.sourceKey, { ...entry, volatile: false });
136
249
  }
137
250
  function nameEvidenceFile(options) {
138
251
  const fileName = path.basename(options.filePath);
@@ -92,6 +92,7 @@ export async function collectRawEvidencePack(context, options) {
92
92
  await ensurePrivateDir(collection.filesDir);
93
93
  recordAttributionCompleteness(collection, options);
94
94
  await collectEverySource(collection, options, sinceMinutes);
95
+ await reportPrepareMemo(collection);
95
96
  return collection.entries.length === 0
96
97
  ? await finishWithEmptyPack(collection, options, run)
97
98
  : await finishWithPromotedPack(collection, options, run);
@@ -100,6 +101,25 @@ export async function collectRawEvidencePack(context, options) {
100
101
  return await finishWithFailedPack(collection, { ...run, error });
101
102
  }
102
103
  }
104
+ /**
105
+ * BLI-3936: say how many transcripts this pass did not have to rebuild, and keep the memo that
106
+ * made it possible. Counts and bytes only. Never throws: the memo is a cache.
107
+ */
108
+ async function reportPrepareMemo(collection) {
109
+ const counts = await collection.budget.prepareMemo?.settle();
110
+ if (!counts || counts.skipped_count + counts.rebuilt_count + counts.volatile_reused_count === 0)
111
+ return;
112
+ console.error(counts.skipped_count + counts.volatile_reused_count > 0 ? "[raw-evidence] slim skipped unchanged" : "[raw-evidence] no unchanged transcripts to skip", JSON.stringify({
113
+ reason: "source_unchanged_since_last_prepare",
114
+ count: counts.skipped_count,
115
+ bytes: counts.skipped_bytes,
116
+ rebuilt_count: counts.rebuilt_count,
117
+ rebuilt_bytes: counts.rebuilt_bytes,
118
+ rebuilt_volatile_count: counts.volatile_count,
119
+ rebuilt_volatile_reused_count: counts.volatile_reused_count,
120
+ rebuilt_volatile_reused_bytes: counts.volatile_reused_bytes,
121
+ }));
122
+ }
103
123
  /**
104
124
  * Every kind of evidence this repo can offer, in the order it is read. Codex and
105
125
  * Claude transcripts are each switchable off by the caller; the git diffs are
@@ -25,6 +25,17 @@ import { decodeXmlEntities, xmlEscape } from "./autostart-xml.js";
25
25
  * cheap — the collection lock turns overlap into a named no-op.
26
26
  */
27
27
  export const DARWIN_NETWORK_CHANGE_SIGNAL = "/private/var/run/resolv.conf";
28
+ /**
29
+ * The least time launchd leaves between two starts of the tick (BLI-3936). Its default is 10
30
+ * seconds, and WatchPaths on the session directories fires on every transcript write, so on a
31
+ * busy Mac the tick restarted the moment it exited. launchd delays a throttled start rather than
32
+ * dropping it, so a transcript write or a network change inside the window still produces exactly
33
+ * one run, at the end of it. Five minutes keeps capture near-real-time (a session is collected
34
+ * within five minutes of its last write) and caps the tick at 12 runs an hour however many
35
+ * agents are writing. Applies to every trigger: launchd cannot say which one fired, which is
36
+ * why this is the whole debounce and no code-side "which trigger was it" check exists.
37
+ */
38
+ export const DARWIN_MIN_SECONDS_BETWEEN_TICKS = 300;
28
39
  /**
29
40
  * What the tick says to sync.err.log when neither the registered node binary
30
41
  * nor a login-shell node exists. Named, actionable, path-free (BLI-3553).
@@ -165,12 +176,13 @@ function renderPlist(options) {
165
176
  ` <integer>${options.intervalSeconds}</integer>`,
166
177
  " <key>RunAtLoad</key>",
167
178
  " <true/>",
168
- // WatchPaths makes a transcript write fire `cockpit sync` within seconds, so
169
- // captures land near-real-time; StartInterval above is the safety-net floor.
170
- // It fires often, but launchd single-flights the job, sync holds its own
171
- // lock, and growing-transcript re-upload is damped — so cost stays bounded
172
- // and no extra debounce is needed.
179
+ // WatchPaths makes a transcript write fire `cockpit sync` soon after, so captures land
180
+ // near-real-time; StartInterval above is the safety-net floor. launchd single-flights the job
181
+ // and sync holds its own lock, but neither stops a busy Mac restarting the tick the moment it
182
+ // exits; ThrottleInterval is the minimum gap between starts (BLI-3936).
173
183
  ...watchPathsBlock(options.watchPaths),
184
+ " <key>ThrottleInterval</key>",
185
+ ` <integer>${DARWIN_MIN_SECONDS_BETWEEN_TICKS}</integer>`,
174
186
  " <key>StandardOutPath</key>",
175
187
  ` <string>${xmlEscape(options.stdoutPath)}</string>`,
176
188
  " <key>StandardErrorPath</key>",
@@ -262,6 +274,10 @@ function darwinAgentRegistrationProblems(plist, expected) {
262
274
  if (!plist.includes(` <integer>${expected.intervalSeconds}</integer>`)) {
263
275
  problems.push(`cadence is not ${expected.intervalSeconds} seconds`);
264
276
  }
277
+ // BLI-3936: a plist written before the throttle existed has no minimum gap between ticks.
278
+ if (!plist.includes(` <key>ThrottleInterval</key>\n <integer>${DARWIN_MIN_SECONDS_BETWEEN_TICKS}</integer>`)) {
279
+ problems.push(`minimum gap between ticks is not ${DARWIN_MIN_SECONDS_BETWEEN_TICKS} seconds`);
280
+ }
265
281
  const expectedCommand = renderDarwinSyncCommand({
266
282
  discoveryArgs: expected.discoveryArgs,
267
283
  workDirs: expected.workDirs,
package/dist/autostart.js CHANGED
@@ -22,7 +22,7 @@ import { installRemindAgent, remindAgentStatus, uninstallRemindAgent } from "./a
22
22
  * import site the rest of the collector already knows (BLI-3571).
23
23
  */
24
24
  export { AUTOSTART_LABEL, DEFAULT_AUTOSTART_INTERVAL_SECONDS, WINDOWS_AUTOSTART_TASK_NAME, REMIND_AUTOSTART_LABEL, WINDOWS_REMIND_TASK_NAME, } from "./autostart-contract.js";
25
- export { DARWIN_NETWORK_CHANGE_SIGNAL, DARWIN_NODE_MISSING_MESSAGE, writeDarwinAutostartPlist, } from "./autostart-darwin-plist.js";
25
+ export { DARWIN_MIN_SECONDS_BETWEEN_TICKS, DARWIN_NETWORK_CHANGE_SIGNAL, DARWIN_NODE_MISSING_MESSAGE, writeDarwinAutostartPlist, } from "./autostart-darwin-plist.js";
26
26
  export { launchdUserDomain, reregisterDarwinAgent, } from "./autostart-darwin.js";
27
27
  /**
28
28
  * Installs (or refreshes) the scheduled `cockpit sync` this machine's OS
@@ -0,0 +1,41 @@
1
+ /**
2
+ * BLI-22790: backfill is history. A session file modified inside the live window belongs to the
3
+ * normal sync tick, which collects the same `DEFAULT_SINCE_MINUTES` (24 h) every ~15 minutes and
4
+ * already uploads these sessions (`riding_already_durable`). Re-preparing a live, still-growing
5
+ * session in backfill held the collection lock for 232 s per tick and kept the run "partial"
6
+ * forever, so doctor re-ran the backfill on every tick.
7
+ */
8
+ import { DEFAULT_SINCE_MINUTES } from "../adapters/raw-evidence-collection-state.js";
9
+ import { stubSessionKey } from "../evidence-stubs-client.js";
10
+ export const BACKFILL_LIVE_WINDOW_MS = DEFAULT_SINCE_MINUTES * 60 * 1000;
11
+ /** The window this run uses: none for a stub pass (it names its sessions), else the test seam or the default. */
12
+ export function liveWindowMsFor(command) {
13
+ return command.stubPass ? 0 : (command.liveWindowMs ?? BACKFILL_LIVE_WINDOW_MS);
14
+ }
15
+ /** `onlySessions` (`codex:<id>` / `claude_code:<id>`) when set, then the live window. */
16
+ export function scopeBackfillCandidates(candidates, options) {
17
+ const key = (c) => stubSessionKey(c.source, c.session_id);
18
+ const scoped = options.onlySessions ? candidates.filter((c) => options.onlySessions.has(key(c))) : candidates;
19
+ return leaveLiveSessionsToTheTick(scoped, options.live);
20
+ }
21
+ /**
22
+ * Drop the candidates the tick owns: modified within `liveWindowMs` (0 keeps all), unless the
23
+ * server names the session a redaction stub (BLI-11102), which is re-offered even while live.
24
+ * Logs the count either way.
25
+ */
26
+ export function leaveLiveSessionsToTheTick(candidates, options) {
27
+ let liveExcluded = 0;
28
+ const kept = options.liveWindowMs <= 0 ? candidates : candidates.filter((candidate) => {
29
+ const live = options.now.getTime() - candidate.session_file_mtime_ms < options.liveWindowMs
30
+ && !options.stubbedSessions.has(stubSessionKey(candidate.source, candidate.session_id));
31
+ if (live)
32
+ liveExcluded += 1;
33
+ return !live;
34
+ });
35
+ console.error("[backfill] live sessions left to the tick", JSON.stringify({
36
+ reason: liveExcluded > 0 ? "live_owned_by_tick" : "no_live_sessions",
37
+ live_excluded: liveExcluded,
38
+ live_window_minutes: Math.round(options.liveWindowMs / 60_000),
39
+ }));
40
+ return { kept, liveExcluded };
41
+ }
@@ -228,7 +228,17 @@ function countRemainingHistory(options) {
228
228
  async function writeAllHistoryCompletionMarker(ctx) {
229
229
  const { paths, cursor, sources, scopedCursor, scan, oversizedCandidateKeys, now } = ctx;
230
230
  const cappedSidecars = cappedSidecarCount(scan.candidates);
231
- recordBackfillScanCoverage(cursor, sources, now, now);
231
+ // BLI-22790: sessions left to the tick were NOT covered here. Mark coverage only up to the
232
+ // start of the live window, so once they age out a later backfill still sees them.
233
+ const coveredThrough = scan.live_excluded_count > 0
234
+ ? new Date(now.getTime() - scan.live_window_ms)
235
+ : now;
236
+ console.error("[backfill] history coverage recorded", JSON.stringify({
237
+ reason: scan.live_excluded_count > 0 ? "covered_through_live_window_start" : "covered_through_now",
238
+ live_excluded: scan.live_excluded_count,
239
+ live_window_minutes: Math.round(scan.live_window_ms / 60_000),
240
+ }));
241
+ recordBackfillScanCoverage(cursor, sources, coveredThrough, now);
232
242
  await writeBackfillCursor(paths, cursor);
233
243
  const oversizedCandidates = scan.candidates.filter((candidate) => oversizedCandidateKeys.has(candidateCursorKey(candidate)));
234
244
  await writeBackfillCompletionMarker(paths, {
@@ -14,6 +14,7 @@ import { scanAndAttributeClaudeSessions, } from "../adapters/claude-attribution.
14
14
  import { defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
15
15
  import { emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCursor, } from "../cursors/backfill-cursor.js";
16
16
  import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
17
+ import { liveWindowMsFor, scopeBackfillCandidates } from "./backfill-live-window.js";
17
18
  import { getCollectorRuntimePaths } from "../local-state.js";
18
19
  import { candidateCursorKey, compareBackfillCandidates, isAfterCursor, } from "./backfill-candidates.js";
19
20
  import { addReadOnlyGuardIssues, addRepoDiscoveryIssues, backfillScanIssues, countReadOnlyGuards, deterministicSkipCandidateKeys, oversizedBackfillCandidateKeys, retryableCandidateKeys, sortScanIssuesByPriority, } from "./backfill-issues.js";
@@ -23,7 +24,6 @@ import { blockedBackfillResult } from "./backfill-result.js";
23
24
  import { emptyStubReofferPlan, loadStubReofferPlan, reopenStubbedSessions, } from "./backfill-stub-reoffer.js";
24
25
  import { loadStubCursorHashes, locateStubbedSessions } from "./backfill-stub-locate.js";
25
26
  import { resolveBackfillScope } from "./backfill-scope.js";
26
- import { stubSessionKey } from "../evidence-stubs-client.js";
27
27
  /**
28
28
  * SCAN: is this machine paired, what roots/sources/window apply, and what did
29
29
  * the archived Codex + Claude session stores actually contain. Returns either
@@ -149,11 +149,13 @@ async function scanBackfillSessions(options) {
149
149
  cursorHashes: options.stubCursorHashes,
150
150
  sinceMinutes: options.window.since_minutes,
151
151
  });
152
+ const liveWindowMs = liveWindowMsFor(options.command);
152
153
  const selection = selectBackfillCandidates({
153
154
  scanned,
154
155
  cursor: options.cursor,
155
156
  pointerlessTerminalSessions: options.pointerlessTerminalSessions,
156
157
  maxFiles: options.command.maxFiles,
158
+ live: { liveWindowMs, now: options.now, stubbedSessions: options.stubReoffer.sessions },
157
159
  onlySessions: options.command.stubPass
158
160
  ? new Set(options.command.stubPass.plan.sessions.keys())
159
161
  : undefined,
@@ -177,6 +179,8 @@ async function scanBackfillSessions(options) {
177
179
  issues,
178
180
  retryable_candidate_keys: retryableCandidateKeys(candidates),
179
181
  omitted_candidate_count: selection.omittedCandidateCount,
182
+ live_excluded_count: selection.liveExcluded,
183
+ live_window_ms: liveWindowMs,
180
184
  };
181
185
  }
182
186
  /**
@@ -220,9 +224,8 @@ async function scanClaudeHistory(request, projectsDir, scanLimit) {
220
224
  * so a `--max-files` cap spends its budget on them before newer history.
221
225
  */
222
226
  function selectBackfillCandidates(options) {
223
- const allCandidates = options.scanned
224
- .filter((candidate) => !options.onlySessions ||
225
- options.onlySessions.has(stubSessionKey(candidate.source, candidate.session_id)))
227
+ const { kept, liveExcluded } = scopeBackfillCandidates(options.scanned, options);
228
+ const allCandidates = kept
226
229
  .filter((candidate) => isAfterCursor(candidate, options.cursor) ||
227
230
  isPointerlessTerminalRetry(candidate, options.pointerlessTerminalSessions))
228
231
  .sort((a, b) => compareBackfillCandidatesForRetry(a, b, options.pointerlessTerminalSessions));
@@ -230,6 +233,7 @@ function selectBackfillCandidates(options) {
230
233
  return {
231
234
  candidates,
232
235
  omittedCandidateCount: allCandidates.length - candidates.length,
236
+ liveExcluded,
233
237
  };
234
238
  }
235
239
  async function loadPointerlessTerminalSessions(paths, sources) {
@@ -19,7 +19,7 @@ export function uncommittedReasonWords(reason) {
19
19
  }
20
20
  /** The same for objects the server refused outright (terminal). */
21
21
  export function terminalReasonWords(reason) {
22
- if (reason === "storage_rejected_object_too_large" || /_failed_http_413/u.test(reason))
22
+ if (reason === "storage_rejected_object_too_large" || reason === "evidence_too_large_http_413" || /_failed_http_413/u.test(reason))
23
23
  return "too large for storage";
24
24
  if (reason === "storage_rejected_media_type")
25
25
  return "a file type storage refuses";