@vibe-commit/capture 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -1
- package/dist/agents/claude_code.js +13 -0
- package/dist/agents/claude_code.js.map +1 -1
- package/dist/agents/codex_cli.js +11 -0
- package/dist/agents/codex_cli.js.map +1 -1
- package/dist/agents/cursor.js +7 -1
- package/dist/agents/cursor.js.map +1 -1
- package/dist/agents/registry.d.ts +7 -0
- package/dist/agents/registry.js +9 -0
- package/dist/agents/registry.js.map +1 -1
- package/dist/agents/types.d.ts +32 -0
- package/dist/agents/types.js.map +1 -1
- package/dist/bounded_id.d.ts +29 -0
- package/dist/bounded_id.js +42 -0
- package/dist/bounded_id.js.map +1 -0
- package/dist/codex_folders.d.ts +340 -0
- package/dist/codex_folders.js +1448 -0
- package/dist/codex_folders.js.map +1 -0
- package/dist/commands/backfill.d.ts +29 -7
- package/dist/commands/backfill.js +387 -109
- package/dist/commands/backfill.js.map +1 -1
- package/dist/commands/connect.d.ts +1 -1
- package/dist/commands/connect.js +1 -1
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/connect_picker.js +48 -7
- package/dist/commands/connect_picker.js.map +1 -1
- package/dist/commands/finalize.d.ts +67 -0
- package/dist/commands/finalize.js +452 -114
- package/dist/commands/finalize.js.map +1 -1
- package/dist/commands/scan.d.ts +28 -0
- package/dist/commands/scan.js +11 -0
- package/dist/commands/scan.js.map +1 -1
- package/dist/commands/status_machine.js +47 -10
- package/dist/commands/status_machine.js.map +1 -1
- package/dist/commands/why.d.ts +19 -1
- package/dist/commands/why.js +35 -3
- package/dist/commands/why.js.map +1 -1
- package/dist/commit_files.d.ts +64 -0
- package/dist/commit_files.js +83 -0
- package/dist/commit_files.js.map +1 -0
- package/dist/consent.d.ts +22 -0
- package/dist/consent.js +38 -1
- package/dist/consent.js.map +1 -1
- package/dist/copy/strings.d.ts +55 -9
- package/dist/copy/strings.js +60 -12
- package/dist/copy/strings.js.map +1 -1
- package/dist/folder_grants.d.ts +44 -0
- package/dist/folder_grants.js +101 -0
- package/dist/folder_grants.js.map +1 -0
- package/dist/git.d.ts +26 -4
- package/dist/git.js +82 -11
- package/dist/git.js.map +1 -1
- package/dist/hooks/entry.d.ts +30 -50
- package/dist/hooks/entry.js +595 -559
- package/dist/hooks/entry.js.map +1 -1
- package/dist/hooks/fire_lock.d.ts +31 -0
- package/dist/hooks/fire_lock.js +120 -12
- package/dist/hooks/fire_lock.js.map +1 -1
- package/dist/hooks/post_commit.d.ts +9 -0
- package/dist/hooks/post_commit.js +9 -5
- package/dist/hooks/post_commit.js.map +1 -1
- package/dist/machine_state.d.ts +44 -1
- package/dist/machine_state.js +75 -12
- package/dist/machine_state.js.map +1 -1
- package/dist/oauth/discovery.js +2 -1
- package/dist/oauth/discovery.js.map +1 -1
- package/dist/oauth/pkce.js +2 -1
- package/dist/oauth/pkce.js.map +1 -1
- package/dist/paths.d.ts +34 -1
- package/dist/paths.js +62 -12
- package/dist/paths.js.map +1 -1
- package/dist/physical_path.d.ts +46 -0
- package/dist/physical_path.js +151 -0
- package/dist/physical_path.js.map +1 -0
- package/dist/picker.d.ts +52 -3
- package/dist/picker.js +129 -5
- package/dist/picker.js.map +1 -1
- package/dist/picker_checklist.d.ts +9 -1
- package/dist/picker_checklist.js +41 -2
- package/dist/picker_checklist.js.map +1 -1
- package/dist/policy.d.ts +9 -1
- package/dist/policy.js +11 -2
- package/dist/policy.js.map +1 -1
- package/dist/post.d.ts +25 -0
- package/dist/post.js +49 -13
- package/dist/post.js.map +1 -1
- package/dist/project.d.ts +89 -0
- package/dist/project.js +123 -0
- package/dist/project.js.map +1 -1
- package/dist/redact.d.ts +77 -2
- package/dist/redact.js +713 -47
- package/dist/redact.js.map +1 -1
- package/dist/session_targets.d.ts +713 -0
- package/dist/session_targets.js +2602 -0
- package/dist/session_targets.js.map +1 -0
- package/dist/spool.d.ts +23 -5
- package/dist/spool.js +37 -6
- package/dist/spool.js.map +1 -1
- package/dist/spool_union.d.ts +51 -0
- package/dist/spool_union.js +57 -0
- package/dist/spool_union.js.map +1 -0
- package/dist/state.d.ts +5 -0
- package/dist/state.js +19 -1
- package/dist/state.js.map +1 -1
- package/dist/text_tail.d.ts +8 -0
- package/dist/text_tail.js +19 -0
- package/dist/text_tail.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,2602 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `session_targets.ts` — what does this session belong to? (any-folder capture, R1 · R2 · R5 · R6 · R13 · R16 · R18)
|
|
3
|
+
*
|
|
4
|
+
* One module owns three things that used to be one hard-wired derivation ("the repo the turn ended in"):
|
|
5
|
+
*
|
|
6
|
+
* 1. **THE READER** — `scanTranscriptKeys`, the ONLY code that reads a field out of a transcript for attribution.
|
|
7
|
+
* D60 §D6 exception 1 was "one value (the first `cwd`), backfill only"; D2 · A widened it to two field kinds on
|
|
8
|
+
* every new record (each record's folder, and the path keys redaction already parses), in hooks and backfill, and
|
|
9
|
+
* D15 · A made that surface ONE function so one test file and one audit cover it. It parses only lines that name a
|
|
10
|
+
* key, keeps nothing but the set of strings found, and sends none of them anywhere.
|
|
11
|
+
* 2. **THE RESOLVER** — `resolveTargets` turns evidence into `Target[]`: which granted repos (and, at the end of a
|
|
12
|
+
* repo-free session, which granted folder) this session's bytes go to, under which slug, ledger key and redaction
|
|
13
|
+
* roots.
|
|
14
|
+
* 3. **THE TARGET RECORD** — per-session state that is sticky across firings: scan cursors, resolved roots, deferred
|
|
15
|
+
* roots, targets, undecided names.
|
|
16
|
+
*
|
|
17
|
+
* ## ⛔ What this module must never do
|
|
18
|
+
*
|
|
19
|
+
* - **Never mint a fallback identity.** A spawn refused while naming a repo DEFERS that root to the next firing; it never
|
|
20
|
+
* becomes `local:<hash of the developer's path>` (R16 · D18, `spawn_budget.ts`'s own warning).
|
|
21
|
+
* - **Never write `grants.json`.** Hooks never write the consent file; this module only READS consent.
|
|
22
|
+
* - **Never decide a folder target before the end** (R18 · D21): only SessionEnd, `finalize` and backfill, after every
|
|
23
|
+
* line was read.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
|
|
27
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
28
|
+
import { agentForTranscript, DIALECTS, dialectFor, transcriptRoots } from "./agents/registry.js";
|
|
29
|
+
import { CALL_LOG_BUDGET_BYTES, DIR_OVERHEAD_BYTES, boundedId, bytesOf, storedBytes } from "./bounded_id.js";
|
|
30
|
+
import { nearestExistingPhysical, pathTooLong, physicalPath } from "./physical_path.js";
|
|
31
|
+
import { ALL_UNKNOWN, CodexTracker, DROPPED_CALL, IdMap, UNJUDGED_FILE, boundedIdEntries, carriesNames, poisonRepeated, folderFromValue, lookupIn, placesIn, ownFoldersOf, recordFolders } from "./codex_folders.js";
|
|
32
|
+
import { writeFileAtomic } from "./atomic_write.js";
|
|
33
|
+
import { captureStanding, normalizeSlug } from "./consent.js";
|
|
34
|
+
import { folderStandingOf } from "./folder_grants.js";
|
|
35
|
+
import { strictRepoSlug } from "./git.js";
|
|
36
|
+
import { isInside, isInsideAny, repoKeyDigest, sessionFileStem, sessionsRoot, sessionStatePath, sessionTargetsPath, subagentFileKey, subagentsDir, targetsDir, } from "./paths.js";
|
|
37
|
+
import { confinementRoots, resolveProjectKeys } from "./project.js";
|
|
38
|
+
import { rulesFor, rulesForTranscript } from "./redact.js";
|
|
39
|
+
import { fileState, loadSessionState } from "./state.js";
|
|
40
|
+
import { MIN_SPAWN_TIMEOUT_MS, spawnTimeoutMs } from "./spawn_budget.js";
|
|
41
|
+
/** Bytes read per `read(2)`. */
|
|
42
|
+
const SCAN_CHUNK_BYTES = 64 * 1024;
|
|
43
|
+
/**
|
|
44
|
+
* The most bytes a firing STARTS scanning per stream (R16 · D18). A line that begins before this window is completed even
|
|
45
|
+
* if it ends beyond it (R18 · D21: a long line is read whole), so a firing always advances at least one line.
|
|
46
|
+
*
|
|
47
|
+
* ⚠ ITS OWN CONSTANT, not `pending_bound.ts`'s: that one bounds a different scan (the promotion bound) with different
|
|
48
|
+
* reasoning. The reasoning here is the spawn window: scanning feeds root resolution, and an unbounded first scan after an
|
|
49
|
+
* upgrade (a 14 MB session) could spend a firing.
|
|
50
|
+
*/
|
|
51
|
+
export const EVIDENCE_SCAN_WINDOW_BYTES = 8 * 1024 * 1024;
|
|
52
|
+
/**
|
|
53
|
+
* The longest single line the reader will hold to parse. Above it the line is SKIPPED for evidence, counted, and the
|
|
54
|
+
* session becomes "unknown" for folder purposes (R18). Equal to the window by design: "a line over 8 MiB is unknown".
|
|
55
|
+
*/
|
|
56
|
+
export const EVIDENCE_MAX_LINE_BYTES = 8 * 1024 * 1024;
|
|
57
|
+
/** Distinct strings kept per scan. A session that touched 50k files has named its repos long before this. */
|
|
58
|
+
const MAX_DISTINCT_HITS = 50_000;
|
|
59
|
+
/**
|
|
60
|
+
* ⛔ The spec backfill's folder check uses: a top-level `cwd` and NOTHING else (the 10-24 one-value reading of D60 §D6
|
|
61
|
+
* exception 1). The hook's resolver builds its own spec from the registry; backfill's gate must not widen when that does.
|
|
62
|
+
*/
|
|
63
|
+
export const TOP_LEVEL_CWD_ONLY = {
|
|
64
|
+
cwdKeys: [{ path: ["cwd"] }],
|
|
65
|
+
pathKeys: new Set(),
|
|
66
|
+
nestedJsonKeys: new Set(),
|
|
67
|
+
};
|
|
68
|
+
// folder-check:begin
|
|
69
|
+
/**
|
|
70
|
+
* Read `[from, …)` of a transcript and return the folders and paths its records name. ⛔ **THE ONLY TRANSCRIPT-FIELD
|
|
71
|
+
* READER** (D15 · A). It starts a line only before `to`, finishes that line however long (up to `maxLineBytes`), and stops.
|
|
72
|
+
* A line is parsed only if it mentions one of the keys; everything but the matching string values is discarded.
|
|
73
|
+
*
|
|
74
|
+
* ## ⛔ Bounded in what it READS, not only in what it parses (P1 review of A-T2)
|
|
75
|
+
*
|
|
76
|
+
* `maxReadBytes` caps the bytes read from disk in one call. A line the budget ends inside is NOT consumed: the cursor stays at
|
|
77
|
+
* its start and a fresh firing re-reads it whole (every line up to `maxLineBytes` fits one fresh budget). A line over
|
|
78
|
+
* `maxLineBytes` is never buffered: it is SKIPPED, counted once, and the scan keeps going to its newline; if the budget ends
|
|
79
|
+
* first, `skipping` is returned with the cursor advanced, and the next call passes `continueSkipping` so it resumes the skip
|
|
80
|
+
* instead of re-reading the line.
|
|
81
|
+
*
|
|
82
|
+
* `unreadable` (cannot open or stat) and a result with no hits are different facts and are told apart: a caller keeping sticky
|
|
83
|
+
* targets treats `unreadable` as "no NEW targets this firing" and carries on (Q4).
|
|
84
|
+
*
|
|
85
|
+
* ⚠ Keys are matched by their plain names. A writer that escaped an ASCII key (`"file\u005fpath"`) would be missed: no real
|
|
86
|
+
* transcript writer does (they emit keys verbatim), and the redactor, which parses the same lines, has the same reach.
|
|
87
|
+
*/
|
|
88
|
+
export function scanTranscriptKeys(path, from, to, spec, options = {}) {
|
|
89
|
+
const maxLine = options.maxLineBytes ?? EVIDENCE_MAX_LINE_BYTES;
|
|
90
|
+
// ⛔ PROGRESS IS GUARANTEED: a call that starts on a line start can always finish one maximal line (or skip a chunk of an
|
|
91
|
+
// oversize one). With a budget smaller than `maxLine` a line between the two could never complete and every firing would
|
|
92
|
+
// re-read it from its start. So the bound is the larger of the two, plus the chunk that crosses it.
|
|
93
|
+
const maxRead = Math.max(options.maxReadBytes ?? EVIDENCE_SCAN_WINDOW_BYTES, maxLine + SCAN_CHUNK_BYTES);
|
|
94
|
+
let fd;
|
|
95
|
+
try {
|
|
96
|
+
// ⛔ NON-BLOCKING, and REGULAR FILES ONLY. A FIFO or a socket named as a transcript would block `open(2)` forever when
|
|
97
|
+
// nothing is writing (the hook would hang to its watchdog), and a device would read without end. `O_NONBLOCK` makes the open
|
|
98
|
+
// return at once, and the `fstat` below refuses anything that is not a regular file before a byte is read. The fd stays
|
|
99
|
+
// non-blocking, which changes nothing for a regular file.
|
|
100
|
+
fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return { kind: "unreadable" };
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const stat = fstatSync(fd);
|
|
107
|
+
if (!stat.isFile())
|
|
108
|
+
return { kind: "unreadable" };
|
|
109
|
+
const size = stat.size;
|
|
110
|
+
const needles = needlesOf(spec, options.first === true);
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
const hits = [];
|
|
113
|
+
const maxHits = options.maxDistinctHits ?? MAX_DISTINCT_HITS;
|
|
114
|
+
let capped = false;
|
|
115
|
+
const note = (hit) => {
|
|
116
|
+
const id = `${hit.kind}\u0000${hit.value}`;
|
|
117
|
+
if (seen.has(id))
|
|
118
|
+
return;
|
|
119
|
+
if (seen.size >= maxHits) {
|
|
120
|
+
capped = true;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
seen.add(id);
|
|
124
|
+
hits.push(hit);
|
|
125
|
+
};
|
|
126
|
+
const opens = [];
|
|
127
|
+
const closes = [];
|
|
128
|
+
const callState = { overflow: false };
|
|
129
|
+
const codexState = spec.codexFolders === true
|
|
130
|
+
? {
|
|
131
|
+
tracker: new CodexTracker({ turn: options.codex?.turn ?? null, sessions: options.codex?.sessions ?? [], cells: options.codex?.cells ?? [], openCalls: options.codex?.openCalls ?? [] }, options.codex?.calls ?? (() => undefined), CALL_BOUNDS, options.codex?.seen),
|
|
132
|
+
changes: [],
|
|
133
|
+
unknown: false,
|
|
134
|
+
skipped: undefined,
|
|
135
|
+
}
|
|
136
|
+
: undefined;
|
|
137
|
+
let oversizeLines = 0;
|
|
138
|
+
/** Offset after the last line fully processed (or, while skipping, after the last byte consumed). */
|
|
139
|
+
let processedTo = from;
|
|
140
|
+
let parts = [];
|
|
141
|
+
let partBytes = 0;
|
|
142
|
+
let skipping = options.continueSkipping === true;
|
|
143
|
+
let done = false;
|
|
144
|
+
let readTotal = 0;
|
|
145
|
+
let torn = false;
|
|
146
|
+
const finishLine = (end) => {
|
|
147
|
+
if (!skipping) {
|
|
148
|
+
const line = (parts.length === 1 ? parts[0] : Buffer.concat(parts)).toString("utf8");
|
|
149
|
+
const calls = spec.trackCalls === true && options.first !== true ? { at: processedTo, opens, closes, state: callState, ...(codexState === undefined ? {} : { codex: codexState }) } : undefined;
|
|
150
|
+
if (collectFromLine(line, spec, needles, options.first === true, note, calls))
|
|
151
|
+
capped = true;
|
|
152
|
+
}
|
|
153
|
+
parts = [];
|
|
154
|
+
partBytes = 0;
|
|
155
|
+
skipping = false;
|
|
156
|
+
processedTo = end;
|
|
157
|
+
// A line that STARTED before `to` is complete; the next one starts at or beyond it → stop. `first` stops on a cwd.
|
|
158
|
+
if (processedTo >= to || (options.first === true && hits.length > 0))
|
|
159
|
+
done = true;
|
|
160
|
+
};
|
|
161
|
+
const block = Buffer.alloc(SCAN_CHUNK_BYTES);
|
|
162
|
+
let position = from;
|
|
163
|
+
while (!done && position < size && (processedTo < to || skipping)) {
|
|
164
|
+
if (readTotal >= maxRead)
|
|
165
|
+
break;
|
|
166
|
+
const n = readSync(fd, block, 0, Math.min(block.length, size - position), position);
|
|
167
|
+
if (n <= 0)
|
|
168
|
+
break;
|
|
169
|
+
readTotal += n;
|
|
170
|
+
let cursor = 0;
|
|
171
|
+
while (cursor < n && !done) {
|
|
172
|
+
const newline = block.subarray(0, n).indexOf(0x0a, cursor);
|
|
173
|
+
const segmentEnd = newline === -1 ? n : newline;
|
|
174
|
+
const segmentBytes = segmentEnd - cursor;
|
|
175
|
+
if (!skipping) {
|
|
176
|
+
if (partBytes + segmentBytes > maxLine) {
|
|
177
|
+
// Entering a skip: counted ONCE, here, and the partial buffer is dropped, never grown further.
|
|
178
|
+
skipping = true;
|
|
179
|
+
oversizeLines += 1;
|
|
180
|
+
parts = [];
|
|
181
|
+
partBytes = 0;
|
|
182
|
+
// C50 (4): a Codex line that was never read may have been a `turn_context` for another folder: the turn folder is UNKNOWN from this line until
|
|
183
|
+
// the next readable header, and the line itself cannot be placed.
|
|
184
|
+
if (codexState !== undefined) {
|
|
185
|
+
if (codexState.tracker.turn !== null)
|
|
186
|
+
codexState.changes.push({ at: processedTo, dir: null });
|
|
187
|
+
codexState.tracker.turn = null;
|
|
188
|
+
codexState.unknown = true;
|
|
189
|
+
// C56 (8): a skipped line may have held a call; an output whose call is unknown after it is unjudged (the stream's first skip is kept, sticky).
|
|
190
|
+
codexState.skipped = codexState.skipped ?? processedTo;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else if (segmentBytes > 0) {
|
|
194
|
+
parts.push(Buffer.from(block.subarray(cursor, segmentEnd)));
|
|
195
|
+
partBytes += segmentBytes;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (newline === -1) {
|
|
199
|
+
cursor = n;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
finishLine(position + newline + 1);
|
|
203
|
+
cursor = newline + 1;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
position += n;
|
|
207
|
+
// Mid-skip, every byte consumed is progress (nothing is buffered), so the cursor follows it.
|
|
208
|
+
if (skipping && !done)
|
|
209
|
+
processedTo = position;
|
|
210
|
+
}
|
|
211
|
+
// The tail after the last newline. While the session lives it is a line still being written: left for the next firing.
|
|
212
|
+
// When the caller says the file is final it counts ONLY if it is a complete record (it parses); a torn tail is not
|
|
213
|
+
// consumed, so the stream reads as unfinished (P1 review) instead of being declared read.
|
|
214
|
+
if (!done && options.includeUnterminatedTail === true && position >= size && !skipping && partBytes > 0) {
|
|
215
|
+
const text = (parts.length === 1 ? parts[0] : Buffer.concat(parts)).toString("utf8");
|
|
216
|
+
let complete = true;
|
|
217
|
+
try {
|
|
218
|
+
JSON.parse(text);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
complete = false;
|
|
222
|
+
}
|
|
223
|
+
if (complete)
|
|
224
|
+
finishLine(size);
|
|
225
|
+
else
|
|
226
|
+
torn = true;
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
kind: "ok",
|
|
230
|
+
hits,
|
|
231
|
+
scannedTo: processedTo,
|
|
232
|
+
oversizeLines,
|
|
233
|
+
capped,
|
|
234
|
+
skipping,
|
|
235
|
+
torn,
|
|
236
|
+
size,
|
|
237
|
+
calls: { opens, closes, overflow: callState.overflow },
|
|
238
|
+
folders: {
|
|
239
|
+
changes: codexState?.changes ?? [],
|
|
240
|
+
sessions: codexState === undefined ? [] : [...codexState.tracker.sessions],
|
|
241
|
+
cells: codexState === undefined ? [] : [...codexState.tracker.cells],
|
|
242
|
+
unknown: codexState?.unknown ?? false,
|
|
243
|
+
...(codexState?.skipped === undefined ? {} : { skipped: codexState.skipped }),
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return { kind: "unreadable" };
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
try {
|
|
252
|
+
closeSync(fd);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// Nothing useful to do.
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** The substrings a line must contain to be worth parsing. A line naming none of the keys is never parsed. */
|
|
260
|
+
function needlesOf(spec, cwdOnly) {
|
|
261
|
+
const names = new Set();
|
|
262
|
+
for (const key of spec.cwdKeys) {
|
|
263
|
+
const leaf = key.path[key.path.length - 1];
|
|
264
|
+
if (leaf !== undefined)
|
|
265
|
+
names.add(leaf);
|
|
266
|
+
}
|
|
267
|
+
if (!cwdOnly)
|
|
268
|
+
for (const name of spec.pathKeys)
|
|
269
|
+
names.add(name);
|
|
270
|
+
// A nested JSON document is an ESCAPED string inside the record, so its keys appear as `\"workdir\"`; the bare name matches both.
|
|
271
|
+
if (!cwdOnly)
|
|
272
|
+
for (const name of spec.nestedJsonKeys)
|
|
273
|
+
names.add(name);
|
|
274
|
+
// C44 (2): when calls are tracked, a line that holds a tool call or its result is parsed whatever else it names, and so is a line with a `\u` escape (an
|
|
275
|
+
// escaped key, `"file_path"`, is decoded by the parse and judged after).
|
|
276
|
+
if (!cwdOnly && spec.trackCalls === true)
|
|
277
|
+
for (const name of ["tool_use", "tool_result", "\\u"])
|
|
278
|
+
names.add(name);
|
|
279
|
+
return [...names];
|
|
280
|
+
}
|
|
281
|
+
/** Returns true when it could NOT look at everything (the depth limit): the caller treats that like a capped scan. */
|
|
282
|
+
/**
|
|
283
|
+
* The folders ONE parsed record names, by the dialect's `cwdKeys`. ⛔ **Part of the single transcript-field reader**: record
|
|
284
|
+
* isolation (R17) asks this same function which folder a record ran in, so "where a record ran" has one definition.
|
|
285
|
+
*/
|
|
286
|
+
export function cwdsOfRecord(record, cwdKeys) {
|
|
287
|
+
const out = [];
|
|
288
|
+
for (const key of cwdKeys) {
|
|
289
|
+
if (key.types !== undefined && !(typeof record.type === "string" && key.types.includes(record.type)))
|
|
290
|
+
continue;
|
|
291
|
+
let at = record;
|
|
292
|
+
for (const step of key.path) {
|
|
293
|
+
if (at === null || typeof at !== "object" || Array.isArray(at)) {
|
|
294
|
+
at = undefined;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
at = at[step];
|
|
298
|
+
}
|
|
299
|
+
if (typeof at === "string" && at !== "") {
|
|
300
|
+
// A finished command item names its folder as a `file://` URL (Codex): a path is what everything downstream compares.
|
|
301
|
+
const folder = at.startsWith("file://") ? folderFromValue(at) : at;
|
|
302
|
+
// C75 (F1): a folder longer than any path the kernel could have opened is not a folder (the ONE guard, `pathTooLong`): it is dropped at the source, like a
|
|
303
|
+
// non-string cwd, so it is never stored as `launchFolder` or noted as a candidate root. (Judged as before for isolation: a path that long names no work tree.)
|
|
304
|
+
if (folder !== null && folder !== "" && !pathTooLong(folder))
|
|
305
|
+
out.push(folder);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
function collectFromLine(line, spec, needles, cwdOnly, note, calls) {
|
|
311
|
+
if (spec.codexFolders !== true && !needles.some((needle) => line.includes(needle)))
|
|
312
|
+
return false;
|
|
313
|
+
let parsed;
|
|
314
|
+
try {
|
|
315
|
+
parsed = JSON.parse(line);
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
321
|
+
return false;
|
|
322
|
+
const record = parsed;
|
|
323
|
+
// C49 (7): a Codex record's own folder is read by the ONE definition the rule uses (`ownFoldersOf`, `file://` decoded, whatever the record's type).
|
|
324
|
+
for (const value of spec.codexFolders === true ? ownFoldersOf(record) : cwdsOfRecord(record, spec.cwdKeys))
|
|
325
|
+
note({ kind: "cwd", value });
|
|
326
|
+
if (cwdOnly)
|
|
327
|
+
return false;
|
|
328
|
+
const unjudged = calls !== undefined ? noteCalls(record, spec, calls, false, note) : false;
|
|
329
|
+
return walkForPaths(record, spec, note, 0) || unjudged;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Depth bound: a transcript record nests a handful of levels. Anything deeper is NOT silently ignored: the walk reports that it
|
|
333
|
+
* stopped looking, and the caller treats the scan as incomplete (a repo named only that deep would otherwise read as "no repo").
|
|
334
|
+
*/
|
|
335
|
+
const MAX_WALK_DEPTH = 24;
|
|
336
|
+
function walkForPaths(node, spec, note, depth) {
|
|
337
|
+
if (node === null || typeof node !== "object")
|
|
338
|
+
return false;
|
|
339
|
+
if (depth > MAX_WALK_DEPTH)
|
|
340
|
+
return true;
|
|
341
|
+
let truncated = false;
|
|
342
|
+
if (Array.isArray(node)) {
|
|
343
|
+
for (const item of node)
|
|
344
|
+
if (walkForPaths(item, spec, note, depth + 1))
|
|
345
|
+
truncated = true;
|
|
346
|
+
return truncated;
|
|
347
|
+
}
|
|
348
|
+
for (const [key, value] of Object.entries(node)) {
|
|
349
|
+
if (typeof value === "string") {
|
|
350
|
+
if (spec.pathKeys.has(key) && value !== "")
|
|
351
|
+
note({ kind: "path", value });
|
|
352
|
+
else if (spec.nestedJsonKeys.has(key) && walkNested(value, spec, note, depth + 1))
|
|
353
|
+
truncated = true;
|
|
354
|
+
}
|
|
355
|
+
else if (walkForPaths(value, spec, note, depth + 1)) {
|
|
356
|
+
truncated = true;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return truncated;
|
|
360
|
+
}
|
|
361
|
+
function walkNested(text, spec, note, depth) {
|
|
362
|
+
try {
|
|
363
|
+
return walkForPaths(JSON.parse(text), spec, note, depth);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
// Not JSON after all: nothing to read.
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Part (b): one Codex record's contribution to the checkpoint, through the SAME tracker the delivery's observer uses. A `session_meta` / `turn_context`
|
|
372
|
+
* cwd that differs from the turn folder in force is a change point; EVERY call is opened with the folder(s) it ran in (`[]` when they cannot be told) and
|
|
373
|
+
* the tool it named, and the output that carries its `call_id` closes it and teaches the session and cell maps. A record that cannot be placed marks the
|
|
374
|
+
* session's folders unknown. Folders a call names that the key walk cannot see (an `exec` program's `workdir`, a patch's files) are also evidence for the
|
|
375
|
+
* resolver: they are noted as paths.
|
|
376
|
+
*/
|
|
377
|
+
function noteCodex(record, calls, note) {
|
|
378
|
+
const cx = calls.codex;
|
|
379
|
+
if (cx === undefined)
|
|
380
|
+
return false;
|
|
381
|
+
const before = cx.tracker.turn;
|
|
382
|
+
const step = cx.tracker.step(record, calls.at);
|
|
383
|
+
if (step.turnChange !== undefined && step.turnChange !== before)
|
|
384
|
+
cx.changes.push({ at: calls.at, dir: step.turnChange });
|
|
385
|
+
if (step.open !== undefined) {
|
|
386
|
+
calls.opens.push({
|
|
387
|
+
id: step.open.id,
|
|
388
|
+
at: calls.at,
|
|
389
|
+
paths: step.open.paths,
|
|
390
|
+
tool: step.open.tool,
|
|
391
|
+
...(step.open.files === undefined ? {} : { files: step.open.files }),
|
|
392
|
+
// C56 (1): the checkpoint does not keep a call's evidence, only that it HAD names: evicting it must not lose them.
|
|
393
|
+
...(carriesNames(step.open) ? { named: true } : {}),
|
|
394
|
+
});
|
|
395
|
+
// C53: the folders and files a call names are evidence even when its placement is unknown.
|
|
396
|
+
if (note !== undefined)
|
|
397
|
+
for (const dir of [...step.open.paths, ...(step.open.evidence ?? [])])
|
|
398
|
+
note({ kind: "path", value: dir });
|
|
399
|
+
}
|
|
400
|
+
if (step.close !== undefined)
|
|
401
|
+
calls.closes.push({ id: step.close, at: calls.at });
|
|
402
|
+
if (step.unknown)
|
|
403
|
+
cx.unknown = true;
|
|
404
|
+
// C54 (1): a call that named more readable places than the client keeps cannot vouch for what it named: the evidence is incomplete, like a dropped hit
|
|
405
|
+
// (the session is multi). (A call with a path it could not resolve carries UNJUDGED_FILE, but names nothing extra: it is only withheld.)
|
|
406
|
+
// C75 (9): an id map collapsed an entry (its byte budget): evidence was lost, like a call that names more places than are kept.
|
|
407
|
+
return step.open?.overflow === true || step.collapsed === true;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* C40 (1): the calls of one Claude record. A `tool_use` block whose input holds a path key is OPENED with the paths it names; a `tool_result` block
|
|
411
|
+
* CLOSES the call with its `tool_use_id`. A call that names no file is not recorded (its result is the stated residual).
|
|
412
|
+
*/
|
|
413
|
+
function noteCalls(record, spec, calls,
|
|
414
|
+
/** C44 (7b): also record a call that names no file (empty `paths`). The checkpoint does not; the in-memory observer does. */
|
|
415
|
+
all = false,
|
|
416
|
+
/** The evidence note, for a dialect whose calls carry folders the key walk cannot see (Codex's JavaScript `exec` programs). */
|
|
417
|
+
note) {
|
|
418
|
+
if (spec.codexFolders === true)
|
|
419
|
+
return noteCodex(record, calls, note);
|
|
420
|
+
const message = record.message;
|
|
421
|
+
if (message === null || typeof message !== "object")
|
|
422
|
+
return false;
|
|
423
|
+
const blocks = message.content;
|
|
424
|
+
if (!Array.isArray(blocks))
|
|
425
|
+
return false;
|
|
426
|
+
for (const block of blocks) {
|
|
427
|
+
if (block === null || typeof block !== "object")
|
|
428
|
+
continue;
|
|
429
|
+
const b = block;
|
|
430
|
+
if (b.type === "tool_use" && typeof b.id === "string") {
|
|
431
|
+
const paths = new Set();
|
|
432
|
+
// C75 (F2): a path longer than any the kernel could have opened is never stored (the ONE guard, `pathTooLong`); the call is marked INCOMPLETE instead, so
|
|
433
|
+
// its result stays withheld in every copy (the C44 (4) state: the call named something the client cannot vouch for).
|
|
434
|
+
let tooLong = false;
|
|
435
|
+
const truncated = walkForPaths(b.input, spec, (hit) => {
|
|
436
|
+
if (hit.kind !== "path")
|
|
437
|
+
return;
|
|
438
|
+
if (pathTooLong(hit.value))
|
|
439
|
+
tooLong = true;
|
|
440
|
+
else
|
|
441
|
+
paths.add(hit.value);
|
|
442
|
+
}, 0);
|
|
443
|
+
// C75 (8): a call that alone names more path text than the log may store is not stored either: INCOMPLETE, its result is withheld.
|
|
444
|
+
const overBudget = bytesOf([...paths]) > CALL_LOG_BUDGET_BYTES;
|
|
445
|
+
if (overBudget)
|
|
446
|
+
paths.clear();
|
|
447
|
+
const incomplete = truncated || tooLong || overBudget;
|
|
448
|
+
// C44 (4): a call nested past the depth keeps whatever paths it showed, marked INCOMPLETE: its result is unknown, and the log is not overflowed for it.
|
|
449
|
+
if (paths.size > 0 || incomplete || all)
|
|
450
|
+
calls.opens.push({ id: boundedId(b.id), at: calls.at, paths: [...paths], ...(incomplete ? { incomplete: true } : {}) });
|
|
451
|
+
}
|
|
452
|
+
else if (b.type === "tool_result" && typeof b.tool_use_id === "string") {
|
|
453
|
+
calls.closes.push({ id: boundedId(b.tool_use_id), at: calls.at });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
// ===========================================================================
|
|
459
|
+
// The target record, the resolver
|
|
460
|
+
// ===========================================================================
|
|
461
|
+
/**
|
|
462
|
+
* How many firings may fail to name a root that has a `.git` before it is recorded `nonrepo`. A `.git` that `git rev-parse`
|
|
463
|
+
* will not answer for (dubious ownership, a corrupt repo, a stale worktree file, a timeout) is evidence of a repo we cannot
|
|
464
|
+
* name; it is retried a few times (a timeout heals) and then stops costing a slot per firing. Either way it STILL counts as a
|
|
465
|
+
* named repo for R6's folder rule: `nonrepo` here means "unnameable", never "not a repo".
|
|
466
|
+
*/
|
|
467
|
+
export const MAX_NAMING_FAILURES = 3;
|
|
468
|
+
/** Candidate roots resolved per firing (R16 · D18). Each costs up to four spawns; the rest wait for the next firing. */
|
|
469
|
+
export const MAX_NEW_ROOTS_PER_FIRING = 8;
|
|
470
|
+
/** The turn folder at the END of a stream's persisted timeline (where a resumed scan picks up), or null. */
|
|
471
|
+
function lastFolderOf(log) {
|
|
472
|
+
const changes = log?.changes;
|
|
473
|
+
return changes === undefined || changes.length === 0 ? null : changes[changes.length - 1].dir;
|
|
474
|
+
}
|
|
475
|
+
/** C40 (1): the most calls still waiting for a result that one stream's checkpoint keeps; the oldest is dropped (and the log is `overflowed`). */
|
|
476
|
+
export const MAX_OPEN_CALLS = 256;
|
|
477
|
+
/** C44 (7a): the most dropped open-call ids a log remembers before it falls back to the sticky `overflowed`. */
|
|
478
|
+
export const MAX_DROPPED_IDS = 4096;
|
|
479
|
+
/** Calls whose result was seen, kept until every copy is past them (a late target starts at offset 0); the oldest is dropped (the `horizon` rises). */
|
|
480
|
+
export const MAX_CLOSED_CALLS = 4096;
|
|
481
|
+
/** The call bounds the Codex tracker mirrors, so the scan and the delivery agree with the checkpoint (C49 (9)). */
|
|
482
|
+
const CALL_BOUNDS = { open: MAX_OPEN_CALLS, closed: MAX_CLOSED_CALLS, dropped: MAX_DROPPED_IDS };
|
|
483
|
+
/** Part (b): the most turn-folder change points one stream's timeline keeps; the oldest is dropped, and a position before the first kept point is unknown. */
|
|
484
|
+
export const MAX_FOLDER_CHANGES = 4096;
|
|
485
|
+
/**
|
|
486
|
+
* C75 (9): the timeline keeps at most `MAX_FOLDER_CHANGES` change points AND `CALL_LOG_BUDGET_BYTES` of folder text (4,096 points of 4,096 bytes are 16 MiB): the OLDEST points are
|
|
487
|
+
* dropped, and a position before the first kept point is unknown (the existing rule), so nothing is silently judged by a point that is gone.
|
|
488
|
+
*/
|
|
489
|
+
export function boundedChanges(changes) {
|
|
490
|
+
let from = Math.max(0, changes.length - MAX_FOLDER_CHANGES);
|
|
491
|
+
let bytes = 0;
|
|
492
|
+
const cost = (change) => DIR_OVERHEAD_BYTES + Buffer.byteLength(change.dir ?? "", "utf8");
|
|
493
|
+
for (let i = from; i < changes.length; i += 1)
|
|
494
|
+
bytes += cost(changes[i]);
|
|
495
|
+
while (bytes > CALL_LOG_BUDGET_BYTES && from < changes.length - 1) {
|
|
496
|
+
bytes -= cost(changes[from]);
|
|
497
|
+
from += 1;
|
|
498
|
+
}
|
|
499
|
+
return changes.slice(from).map((change) => ({ at: change.at, dir: change.dir }));
|
|
500
|
+
}
|
|
501
|
+
export function emptyTargetRecord(sessionId) {
|
|
502
|
+
return {
|
|
503
|
+
v: 1,
|
|
504
|
+
sessionId,
|
|
505
|
+
launchFolder: null,
|
|
506
|
+
scan: {},
|
|
507
|
+
calls: {},
|
|
508
|
+
folders: {},
|
|
509
|
+
oversize: false,
|
|
510
|
+
failures: {},
|
|
511
|
+
roots: {},
|
|
512
|
+
unresolved: [],
|
|
513
|
+
ledgerOnly: [],
|
|
514
|
+
targets: [],
|
|
515
|
+
undecided: [],
|
|
516
|
+
stopped: {},
|
|
517
|
+
settled: false,
|
|
518
|
+
transcriptPath: null,
|
|
519
|
+
firedAt: 0,
|
|
520
|
+
updatedAt: 0,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
/** The record, or null when absent or unreadable. ⚠ Null is a RESYNC, not an error: the scan restarts and ledgers are adopted. */
|
|
524
|
+
export function loadTargetRecord(home, sessionId) {
|
|
525
|
+
let raw;
|
|
526
|
+
try {
|
|
527
|
+
raw = readFileSync(sessionTargetsPath(home, sessionId), "utf8");
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
const parsed = JSON.parse(raw);
|
|
534
|
+
if (parsed === null || typeof parsed !== "object" || parsed.v !== 1 || parsed.sessionId !== sessionId)
|
|
535
|
+
return null;
|
|
536
|
+
return { ...emptyTargetRecord(sessionId), ...parsed };
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/** Atomic (temp + rename), mode 0600. ⛔ The CALLER holds the one per-session lock (O5); this module never takes it. */
|
|
543
|
+
export function saveTargetRecord(home, record) {
|
|
544
|
+
try {
|
|
545
|
+
const path = sessionTargetsPath(home, record.sessionId);
|
|
546
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
547
|
+
writeFileAtomic(path, `${JSON.stringify(record)}\n`, 0o600);
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
/** Every session's record on this machine, with its file and mtime, for the idle-session drain (R19). Unreadable ones are skipped. */
|
|
555
|
+
export function listTargetRecords(home) {
|
|
556
|
+
let entries;
|
|
557
|
+
try {
|
|
558
|
+
entries = readdirSync(targetsDir(home));
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
return [];
|
|
562
|
+
}
|
|
563
|
+
const found = [];
|
|
564
|
+
for (const entry of entries) {
|
|
565
|
+
if (!entry.endsWith(".targets"))
|
|
566
|
+
continue;
|
|
567
|
+
const path = join(targetsDir(home), entry);
|
|
568
|
+
try {
|
|
569
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
570
|
+
if (parsed === null || typeof parsed !== "object" || parsed.v !== 1 || typeof parsed.sessionId !== "string")
|
|
571
|
+
continue;
|
|
572
|
+
found.push({ path, mtimeMs: statSync(path).mtimeMs, record: { ...emptyTargetRecord(parsed.sessionId), ...parsed } });
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return found;
|
|
579
|
+
}
|
|
580
|
+
/** Names recent sessions' evidence left undecided, for `vibecommit status` (names only: no transcript is opened). */
|
|
581
|
+
export function readUndecidedNames(home, sinceMs) {
|
|
582
|
+
let entries;
|
|
583
|
+
try {
|
|
584
|
+
entries = readdirSync(targetsDir(home));
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
return [];
|
|
588
|
+
}
|
|
589
|
+
const latest = new Map();
|
|
590
|
+
for (const entry of entries) {
|
|
591
|
+
if (!entry.endsWith(".targets"))
|
|
592
|
+
continue;
|
|
593
|
+
let parsed;
|
|
594
|
+
try {
|
|
595
|
+
parsed = JSON.parse(readFileSync(join(targetsDir(home), entry), "utf8"));
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
for (const u of parsed.undecided ?? []) {
|
|
601
|
+
if (typeof u?.name !== "string" || typeof u.at !== "number" || u.at < sinceMs)
|
|
602
|
+
continue;
|
|
603
|
+
const id = `${u.kind}\u0000${u.name}`;
|
|
604
|
+
const have = latest.get(id);
|
|
605
|
+
if (have === undefined || u.at > have.at)
|
|
606
|
+
latest.set(id, u);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return [...latest.values()].sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* The folder targets that were stopped (C3: `folder_repository_forbidden`) in the last `sinceMs`, newest per folder, for
|
|
613
|
+
* `vibecommit status`. Reads only the per-session target records through the same directory listing the drain and
|
|
614
|
+
* `readUndecidedNames` use: no transcript is opened, and a record the drain has forgotten is simply not there.
|
|
615
|
+
*/
|
|
616
|
+
export function readStoppedFolders(home, sinceMs) {
|
|
617
|
+
let entries;
|
|
618
|
+
try {
|
|
619
|
+
entries = readdirSync(targetsDir(home));
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
return [];
|
|
623
|
+
}
|
|
624
|
+
const latest = new Map();
|
|
625
|
+
for (const entry of entries) {
|
|
626
|
+
if (!entry.endsWith(".targets"))
|
|
627
|
+
continue;
|
|
628
|
+
let parsed;
|
|
629
|
+
try {
|
|
630
|
+
parsed = JSON.parse(readFileSync(join(targetsDir(home), entry), "utf8"));
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
const stopped = parsed.stopped;
|
|
636
|
+
if (stopped === null || typeof stopped !== "object")
|
|
637
|
+
continue;
|
|
638
|
+
for (const target of parsed.targets ?? []) {
|
|
639
|
+
if (target?.kind !== "folder" || typeof target.folder !== "string" || typeof target.slug !== "string")
|
|
640
|
+
continue;
|
|
641
|
+
const stop = stopped[stopKeyOf(target)];
|
|
642
|
+
if (stop === undefined || typeof stop.at !== "number" || typeof stop.code !== "string" || stop.at < sinceMs)
|
|
643
|
+
continue;
|
|
644
|
+
const have = latest.get(target.folder);
|
|
645
|
+
if (have === undefined || stop.at > have.at)
|
|
646
|
+
latest.set(target.folder, { folder: target.folder, slug: target.slug, at: stop.at, code: stop.code });
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return [...latest.values()].sort((a, b) => (a.folder < b.folder ? -1 : 1));
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* ⛔ A FOLDER TARGET'S LEDGER KEY IS NAMESPACED (C17 #1): `folder:<realpath>`, never the bare path. A ledger is keyed by the string
|
|
653
|
+
* its digest is taken of, and a work tree's ledger key IS its path; if the folder later became a git repo and the session resumed,
|
|
654
|
+
* the repo target would have adopted the folder's ledger and sent its offsets to the repo's server stream. A prefixed key can never
|
|
655
|
+
* equal a work-tree path, so the two can never collide.
|
|
656
|
+
*/
|
|
657
|
+
export const FOLDER_LEDGER_PREFIX = "folder:";
|
|
658
|
+
export function folderLedgerKey(realLaunchFolder) {
|
|
659
|
+
return `${FOLDER_LEDGER_PREFIX}${realLaunchFolder}`;
|
|
660
|
+
}
|
|
661
|
+
/** `local:folder:<first 12 lowercase hex of sha256(realpath(launch folder))>` — C2. A no-repo remote keeps `local:<12 hex>`. */
|
|
662
|
+
export function folderSlug(realLaunchFolder) {
|
|
663
|
+
return `local:folder:${createHash("sha256").update(realLaunchFolder).digest("hex").slice(0, 12)}`;
|
|
664
|
+
}
|
|
665
|
+
/** Everything `resolveTargets` needs from its caller. The `seams` are for tests; production passes none. */
|
|
666
|
+
/** The caller's session lock was lost while it resolved targets (C25). Nothing was written. */
|
|
667
|
+
export class LockLostError extends Error {
|
|
668
|
+
constructor() {
|
|
669
|
+
super("the session writer lock is no longer ours");
|
|
670
|
+
this.name = "LockLostError";
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Every `agent-*.jsonl` under this session's `subagents/`, oldest first, each confined to the transcript roots.
|
|
675
|
+
*
|
|
676
|
+
* ⛔ **THE ONE COPY of sub-agent discovery** (A-T3 deletes `hooks/entry.ts`'s and imports this): `agent-*.jsonl`, never
|
|
677
|
+
* `agent-*` (the `.meta.json` sidecar would double every upload); every entry gated by `isInsideAny` so a symlink cannot walk
|
|
678
|
+
* out; the glob runs only for a dialect that declares `discover`.
|
|
679
|
+
*/
|
|
680
|
+
export function findSubagentTranscripts(home, env, transcriptPath) {
|
|
681
|
+
return findSubagentTranscriptsChecked(home, env, transcriptPath).found;
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* `findSubagentTranscripts` that also says whether the directory could not be LISTED (C17 #2): ENOENT is "no delegated streams",
|
|
685
|
+
* but EACCES or EIO is "unknown", and the resolver must not read unknown as empty (a folder could be authorized over a stream it
|
|
686
|
+
* never saw).
|
|
687
|
+
*/
|
|
688
|
+
export function findSubagentTranscriptsChecked(home, env, transcriptPath) {
|
|
689
|
+
if (dialectFor(agentForTranscript(home, env, transcriptPath)).delegatedTranscripts !== "discover")
|
|
690
|
+
return { found: [], listFailed: false };
|
|
691
|
+
const dir = subagentsDir(transcriptPath);
|
|
692
|
+
if (dir === null)
|
|
693
|
+
return { found: [], listFailed: false };
|
|
694
|
+
const roots = transcriptRoots(home, env);
|
|
695
|
+
let entries;
|
|
696
|
+
try {
|
|
697
|
+
entries = readdirSync(dir);
|
|
698
|
+
}
|
|
699
|
+
catch (error) {
|
|
700
|
+
return { found: [], listFailed: error.code !== "ENOENT" };
|
|
701
|
+
}
|
|
702
|
+
const found = [];
|
|
703
|
+
for (const entry of entries) {
|
|
704
|
+
const fileKey = subagentFileKey(entry);
|
|
705
|
+
if (fileKey === null)
|
|
706
|
+
continue;
|
|
707
|
+
const path = join(dir, entry);
|
|
708
|
+
if (!isInsideAny(roots, path))
|
|
709
|
+
continue;
|
|
710
|
+
try {
|
|
711
|
+
const stat = statSync(path);
|
|
712
|
+
if (!stat.isFile() || stat.size === 0)
|
|
713
|
+
continue;
|
|
714
|
+
found.push({ path, fileKey, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
// Vanished between readdir and stat.
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return { found: found.sort((a, b) => a.mtimeMs - b.mtimeMs), listFailed: false };
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Whether the dialect that owns this transcript can keep one repository's copy free of another's records (`isolatesContentRecords`).
|
|
724
|
+
* A transcript outside every dialect's root, or inside more than one, is NOT isolatable: fail closed.
|
|
725
|
+
*/
|
|
726
|
+
export function dialectIsolates(home, env, transcriptPath) {
|
|
727
|
+
// Which dialect OWNS the transcript is a policy question, so it must not depend on the file still existing (`isInside` resolves real
|
|
728
|
+
// paths and answers "outside" for a vanished file, which would flip a session's capability mid-session): the real-path test OR a
|
|
729
|
+
// plain prefix test. Confinement itself (symlinks out of the roots) is `admitsTranscript`'s job at every entry, not this function's.
|
|
730
|
+
const matches = DIALECTS.filter((dialect) => {
|
|
731
|
+
const root = dialect.transcriptRoot(home, env);
|
|
732
|
+
return isInside(root, transcriptPath) || transcriptPath.startsWith(`${root}/`);
|
|
733
|
+
});
|
|
734
|
+
return matches.length === 1 && matches[0].isolatesContentRecords;
|
|
735
|
+
}
|
|
736
|
+
/** The evidence spec for a transcript: redaction's key lists (A3: one list) plus the dialect's `cwdKeys`. */
|
|
737
|
+
export function evidenceSpecFor(home, env, transcriptPath) {
|
|
738
|
+
const matches = DIALECTS.filter((dialect) => isInside(dialect.transcriptRoot(home, env), transcriptPath));
|
|
739
|
+
const rules = matches.length === 1 ? rulesFor(matches[0].redaction) : rulesForTranscript(home, env, transcriptPath);
|
|
740
|
+
return {
|
|
741
|
+
cwdKeys: matches.length === 1 ? matches[0].cwdKeys : DIALECTS.flatMap((dialect) => dialect.cwdKeys),
|
|
742
|
+
pathKeys: rules.pathKeys,
|
|
743
|
+
nestedJsonKeys: rules.nestedJsonKeys,
|
|
744
|
+
// C40 (1): Claude's results can name no file (an image Read), so its calls are tracked; the other dialects are not scanned for evidence.
|
|
745
|
+
...(matches.length === 1 && matches[0].redaction === "claude-code" ? { trackCalls: true } : {}),
|
|
746
|
+
// Part (b): Codex records name their folder on few types; the scan keeps the turn-folder timeline and every call.
|
|
747
|
+
...(matches.length === 1 && matches[0].redaction === "codex-cli" ? { trackCalls: true, codexFolders: true } : {}),
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* The nearest existing ancestor of `path`, physically resolved (`/tmp` and `/private/tmp` are one place; a deleted file maps up). C70: `..` is resolved AFTER the
|
|
752
|
+
* links before it (`physicalPath`), as the kernel does, never lexically first.
|
|
753
|
+
*/
|
|
754
|
+
function nearestExisting(path) {
|
|
755
|
+
// C49 (8): a component that EXISTS but cannot be resolved (a dangling symlink, an unreadable folder) is never ascended past: `null`, and the caller fails closed.
|
|
756
|
+
return nearestExistingPhysical(path);
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* C50 (3): is this an absolute place that EXISTS but cannot be resolved (a dangling link, an unreadable folder)? That is not "in no work tree": nobody can say
|
|
760
|
+
* which tree it is in, so a record placed there is UNKNOWN and evidence naming it makes the session multi (the status of the session is unknown).
|
|
761
|
+
*/
|
|
762
|
+
function unresolvable(path, cache) {
|
|
763
|
+
const known = cache?.get(path);
|
|
764
|
+
if (known !== undefined)
|
|
765
|
+
return known;
|
|
766
|
+
const verdict = isAbsolute(path) && nearestExisting(path) === null;
|
|
767
|
+
cache?.set(path, verdict);
|
|
768
|
+
return verdict;
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* The work tree a place belongs to, found by `stat` alone (no spawn): the nearest ancestor holding `.git` (a directory, or
|
|
772
|
+
* the file a linked worktree and a submodule have). ⛔ NEAREST WINS, so a nested repo beats its parent (Q4).
|
|
773
|
+
*/
|
|
774
|
+
function workTreeRootOf(path, cache) {
|
|
775
|
+
if (!isAbsolute(path))
|
|
776
|
+
return null;
|
|
777
|
+
const start = nearestExisting(path);
|
|
778
|
+
if (start === null)
|
|
779
|
+
return null;
|
|
780
|
+
const walked = [];
|
|
781
|
+
let dir = start;
|
|
782
|
+
try {
|
|
783
|
+
if (!statSync(start).isDirectory())
|
|
784
|
+
dir = dirname(start);
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
let found = null;
|
|
790
|
+
for (;;) {
|
|
791
|
+
const known = cache.get(dir);
|
|
792
|
+
if (known !== undefined) {
|
|
793
|
+
found = known;
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
walked.push(dir);
|
|
797
|
+
if (existsSync(join(dir, ".git"))) {
|
|
798
|
+
found = dir;
|
|
799
|
+
break;
|
|
800
|
+
}
|
|
801
|
+
const parent = dirname(dir);
|
|
802
|
+
if (parent === dir)
|
|
803
|
+
break;
|
|
804
|
+
dir = parent;
|
|
805
|
+
}
|
|
806
|
+
for (const visited of walked)
|
|
807
|
+
cache.set(visited, found);
|
|
808
|
+
return found;
|
|
809
|
+
}
|
|
810
|
+
const idMaps = new WeakMap();
|
|
811
|
+
function idMapsOf(log) {
|
|
812
|
+
if (log === undefined)
|
|
813
|
+
return undefined;
|
|
814
|
+
let known = idMaps.get(log);
|
|
815
|
+
if (known === undefined) {
|
|
816
|
+
known = { sessions: new Map(log.sessions ?? []), cells: new Map(log.cells ?? []) };
|
|
817
|
+
idMaps.set(log, known);
|
|
818
|
+
}
|
|
819
|
+
return known;
|
|
820
|
+
}
|
|
821
|
+
/** The id entries as they stood at `start`: only what was learned before it (an unknown mark only if it was set before it). */
|
|
822
|
+
function idEntriesBefore(entries, start) {
|
|
823
|
+
const out = [];
|
|
824
|
+
for (const [id, entry] of entries ?? []) {
|
|
825
|
+
const dirs = entry.dirs.filter((learned) => learned.at < start);
|
|
826
|
+
const unknownAt = entry.unknownAt !== undefined && entry.unknownAt < start ? entry.unknownAt : undefined;
|
|
827
|
+
// C56 (audit): a TOMBSTONE (or the whole-map mark) replaced an entry that was learned before `start` and evicted after it: the entry's folders are gone from
|
|
828
|
+
// the log, but the id is known to have existed, so it is unknown FROM `start` (carrying the files it kept), never absent.
|
|
829
|
+
if (start > 0 && unknownAt === undefined && (entry.tomb === true || id === ALL_UNKNOWN)) {
|
|
830
|
+
out.push([
|
|
831
|
+
id,
|
|
832
|
+
{
|
|
833
|
+
dirs: [],
|
|
834
|
+
unknownAt: start - 1,
|
|
835
|
+
...(entry.tomb === true ? { tomb: true } : {}),
|
|
836
|
+
...(entry.files === undefined ? {} : { files: entry.files }),
|
|
837
|
+
...(entry.evidence === undefined ? {} : { evidence: entry.evidence }),
|
|
838
|
+
},
|
|
839
|
+
]);
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
if (dirs.length === 0 && unknownAt === undefined)
|
|
843
|
+
continue;
|
|
844
|
+
// C55 (1): the files and evidence of the UNKNOWN calls that announced the id carry no offset of their own, so ALL of them are carried (a file learned after
|
|
845
|
+
// `start` is learned again by the replay; carrying it early only withholds more, which fails closed).
|
|
846
|
+
out.push([
|
|
847
|
+
id,
|
|
848
|
+
{
|
|
849
|
+
dirs,
|
|
850
|
+
...(unknownAt === undefined ? {} : { unknownAt }),
|
|
851
|
+
...(entry.tomb === true && unknownAt !== undefined ? { tomb: true } : {}),
|
|
852
|
+
...(entry.files === undefined ? {} : { files: entry.files }),
|
|
853
|
+
...(entry.evidence === undefined ? {} : { evidence: entry.evidence }),
|
|
854
|
+
},
|
|
855
|
+
]);
|
|
856
|
+
}
|
|
857
|
+
return out;
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* A checkpoint entry as the tracker's `CallInfo` (one place, so no field a placement needs is dropped on the way). EVERY CallEntry field is accounted for:
|
|
861
|
+
* `id` is the key; `tool`, `paths`, `files` and `named` are carried (`named` is what eviction consults, C61 (2)); `at` and `closedAt` only select WHICH entries
|
|
862
|
+
* are open at a position (`openCallsAt`, `persistedCodexEnv`), a call table has no position of its own; `incomplete` is set only for a Claude call (a Codex scan
|
|
863
|
+
* never marks it), and Claude calls do not go through the tracker.
|
|
864
|
+
*/
|
|
865
|
+
function callInfoOf(entry) {
|
|
866
|
+
return {
|
|
867
|
+
tool: entry.tool ?? "",
|
|
868
|
+
paths: entry.paths,
|
|
869
|
+
...(entry.files === undefined ? {} : { files: entry.files }),
|
|
870
|
+
...(entry.named === true ? { named: true } : {}),
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
/** The calls of a persisted log still waiting for an output at `start`, oldest first, as the tracker holds them. */
|
|
874
|
+
function openCallsAt(log, start) {
|
|
875
|
+
const out = [];
|
|
876
|
+
for (const entry of log?.entries ?? []) {
|
|
877
|
+
if (entry.at < start && (entry.closedAt === null || entry.closedAt >= start)) {
|
|
878
|
+
out.push([entry.id, callInfoOf(entry)]);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return out;
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* C63: the ONE by-id lookup of the checkpoint's call table for placement. It returns the call as the tracker's `CallInfo`: the entry opened BEFORE `position` (at or
|
|
885
|
+
* before it when `inclusive`: the call record being judged), else `DROPPED_CALL` when the checkpoint evicted the id carrying names (or that set overflowed, C56 (1)),
|
|
886
|
+
* else undefined. A call opened at or after `position` is the future: it never places a record before it. Callers: the persisted env's callFolders and callFiles
|
|
887
|
+
* (exclusive at the record) and ownCall (inclusive), the seeded observer's `known` (exclusive at the observer's start) and the scan's `calls` (exclusive at the scan's
|
|
888
|
+
* start). Nothing else looks an entry up by id for placement.
|
|
889
|
+
*/
|
|
890
|
+
function persistedCall(log, id, position, inclusive) {
|
|
891
|
+
const entry = entryOf(log, id);
|
|
892
|
+
if (entry !== undefined && (inclusive ? entry.at <= position : entry.at < position))
|
|
893
|
+
return callInfoOf(entry);
|
|
894
|
+
return droppedCallInfo(log, id);
|
|
895
|
+
}
|
|
896
|
+
/** C62: a call log's entries by id, built once per log (a log is immutable once persisted; an id has at most one entry): O(1) per lookup, not a scan per record. */
|
|
897
|
+
const entryMaps = new WeakMap();
|
|
898
|
+
function entryOf(log, id) {
|
|
899
|
+
if (log === undefined)
|
|
900
|
+
return undefined;
|
|
901
|
+
let map = entryMaps.get(log);
|
|
902
|
+
if (map === undefined) {
|
|
903
|
+
map = new Map(log.entries.map((entry) => [entry.id, entry]));
|
|
904
|
+
entryMaps.set(log, map);
|
|
905
|
+
}
|
|
906
|
+
return map.get(id);
|
|
907
|
+
}
|
|
908
|
+
/** C62: the ids a call log has seen, built once per log: an entry's open offset, and the ids it dropped, dropped with names, or pruned. */
|
|
909
|
+
const seenIdMaps = new WeakMap();
|
|
910
|
+
/**
|
|
911
|
+
* Has the checkpoint seen a call with this id? `before` (an observer's start): only an entry opened before it counts, because the observer REPLAYS the bytes the scan
|
|
912
|
+
* already folded; an observer that starts at byte 0 replays everything and sees every id itself, so nothing counts. The scan passes no `before` (it reads new bytes
|
|
913
|
+
* only, so every id in the log is from an earlier firing).
|
|
914
|
+
*/
|
|
915
|
+
function seenCallId(log, id, before) {
|
|
916
|
+
if (log === undefined)
|
|
917
|
+
return false;
|
|
918
|
+
if (before === 0)
|
|
919
|
+
return false;
|
|
920
|
+
let seen = seenIdMaps.get(log);
|
|
921
|
+
if (seen === undefined) {
|
|
922
|
+
seen = { entries: new Map(log.entries.map((entry) => [entry.id, entry.at])), gone: new Set([...(log.droppedIds ?? []), ...(log.droppedNamed ?? []), ...(log.prunedIds ?? [])]) };
|
|
923
|
+
seenIdMaps.set(log, seen);
|
|
924
|
+
}
|
|
925
|
+
const at = seen.entries.get(id);
|
|
926
|
+
if (at !== undefined && (before === undefined || at < before))
|
|
927
|
+
return true;
|
|
928
|
+
return seen.gone.has(id);
|
|
929
|
+
}
|
|
930
|
+
/** `DROPPED_CALL` when the checkpoint evicted call `id` carrying files or evidence (or that set overflowed), else undefined. */
|
|
931
|
+
function droppedCallInfo(log, id) {
|
|
932
|
+
if (log === undefined)
|
|
933
|
+
return undefined;
|
|
934
|
+
if (log.droppedNamedAll === true)
|
|
935
|
+
return DROPPED_CALL;
|
|
936
|
+
if (log.droppedNamed === undefined)
|
|
937
|
+
return undefined;
|
|
938
|
+
let set = droppedNamedSets.get(log);
|
|
939
|
+
if (set === undefined) {
|
|
940
|
+
set = new Set(log.droppedNamed);
|
|
941
|
+
droppedNamedSets.set(log, set);
|
|
942
|
+
}
|
|
943
|
+
return set.has(id) ? DROPPED_CALL : undefined;
|
|
944
|
+
}
|
|
945
|
+
/** C62: membership of `droppedNamed` per call log, built once (a log is immutable once persisted). */
|
|
946
|
+
const droppedNamedSets = new WeakMap();
|
|
947
|
+
/** The seeded observer's `known`: the call table as it stood before the observer's `start` (see `persistedCall`). */
|
|
948
|
+
function persistedCallInfo(isolation, fileKey, id, start) {
|
|
949
|
+
return persistedCall(isolation.calls === undefined || isolation.calls === null ? undefined : isolation.calls[fileKey], id, start, false);
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* C56 (8)(9): may a call that is unknown at `position` have been LOST (a skipped line, a pruned or dropped entry) rather than never made? `viewStart` is where
|
|
953
|
+
* the view judging the record began reading the stream (the observer's start; `Infinity` for the persisted checkpoint alone): a view that began at byte 0 saw
|
|
954
|
+
* every call; one that began later cannot see a call the checkpoint no longer holds.
|
|
955
|
+
*/
|
|
956
|
+
function callsLostAt(isolation, fileKey, position, viewStart) {
|
|
957
|
+
const skipped = isolation.folders?.[fileKey]?.skipped;
|
|
958
|
+
if (skipped !== undefined && skipped < position && viewStart > skipped)
|
|
959
|
+
return true;
|
|
960
|
+
if (isolation.calls === undefined || isolation.calls === null)
|
|
961
|
+
return false;
|
|
962
|
+
const log = isolation.calls[fileKey];
|
|
963
|
+
if (log === undefined)
|
|
964
|
+
return viewStart > 0;
|
|
965
|
+
return position <= log.horizon && viewStart > 0;
|
|
966
|
+
}
|
|
967
|
+
/** What places a Codex record from the persisted checkpoint alone (a record before the observer's start, or a delivery with no observer). */
|
|
968
|
+
function persistedCodexEnv(isolation, fileKey, position) {
|
|
969
|
+
const log = isolation.folders?.[fileKey];
|
|
970
|
+
const maps = idMapsOf(log);
|
|
971
|
+
return {
|
|
972
|
+
turnFolder: () => lastChangeAtOrBefore(log?.changes, position),
|
|
973
|
+
callFolders: (id) => {
|
|
974
|
+
const logCalls = isolation.calls === undefined || isolation.calls === null ? undefined : isolation.calls[fileKey];
|
|
975
|
+
// C56 (1): a call the checkpoint evicted carrying files is found as `DROPPED_CALL` (no folders, the unjudged mark).
|
|
976
|
+
return persistedCall(logCalls, id, position, false)?.paths;
|
|
977
|
+
},
|
|
978
|
+
callFiles: (id) => {
|
|
979
|
+
const logCalls = isolation.calls === undefined || isolation.calls === null ? undefined : isolation.calls[fileKey];
|
|
980
|
+
return persistedCall(logCalls, id, position, false)?.files ?? [];
|
|
981
|
+
},
|
|
982
|
+
ownCall: (id) => {
|
|
983
|
+
const logCalls = isolation.calls === undefined || isolation.calls === null ? undefined : isolation.calls[fileKey];
|
|
984
|
+
return persistedCall(logCalls, id, position, true);
|
|
985
|
+
},
|
|
986
|
+
// C49 (2): only what was learned BEFORE the record being judged.
|
|
987
|
+
session: (id) => lookupIn(maps?.sessions, id, position),
|
|
988
|
+
cell: (id) => lookupIn(maps?.cells, id, position),
|
|
989
|
+
sessionPlaces: () => placesIn(maps?.sessions, position),
|
|
990
|
+
callsLost: () => callsLostAt(isolation, fileKey, position, Number.POSITIVE_INFINITY),
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
/** The directory of the last change point at or before `position` (binary search), or null when the timeline has none that early. */
|
|
994
|
+
function lastChangeAtOrBefore(changes, position) {
|
|
995
|
+
if (changes === undefined || changes.length === 0)
|
|
996
|
+
return null;
|
|
997
|
+
let lo = 0;
|
|
998
|
+
let hi = changes.length - 1;
|
|
999
|
+
let found = -1;
|
|
1000
|
+
while (lo <= hi) {
|
|
1001
|
+
const mid = (lo + hi) >> 1;
|
|
1002
|
+
if (changes[mid].at <= position) {
|
|
1003
|
+
found = mid;
|
|
1004
|
+
lo = mid + 1;
|
|
1005
|
+
}
|
|
1006
|
+
else {
|
|
1007
|
+
hi = mid - 1;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
return found === -1 ? null : changes[found].dir;
|
|
1011
|
+
}
|
|
1012
|
+
/** The most bytes a delivery will re-read to observe the gap between the scan cursor and where its span starts. Beyond it the copy fails closed. */
|
|
1013
|
+
const MAX_PAST_GAP_BYTES = 64 * 1024 * 1024;
|
|
1014
|
+
/** The gap is read in chunks of at most this many bytes, so the hook's budget is checked between them (a synchronous read cannot be preempted). */
|
|
1015
|
+
const PAST_GAP_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
1016
|
+
/** What one chunk is assumed to cost, for deciding whether the remaining budget can cover it (measured: about 8 ms per MiB idle, several times that under load). */
|
|
1017
|
+
const PAST_GAP_CHUNK_COST_MS = 150;
|
|
1018
|
+
/** C44 (6): a gap line longer than this is not parsed by the observer; the stream fails closed for the firing (the corpus has 0 lines over 8 MiB). */
|
|
1019
|
+
const PAST_GAP_MAX_LINE_BYTES = 4 * 1024 * 1024;
|
|
1020
|
+
/** C44 (6): the deadline is checked before parsing any gap line longer than this. */
|
|
1021
|
+
const PAST_GAP_CHECK_LINE_BYTES = 256 * 1024;
|
|
1022
|
+
/**
|
|
1023
|
+
* C42/C43: the observer for one delivery. `foreign` is the same stat-only test as record isolation (`workTreeRootOf`, nearest existing ancestor for a
|
|
1024
|
+
* gone path, no spawn, cached); a path outside every work tree (the global CLAUDE.md, /tmp) is NOT another work tree and sets nothing.
|
|
1025
|
+
*
|
|
1026
|
+
* A stream needs it when the read goes past the scan cursor (C42: calls and a second work tree the checkpoint never saw), and when the read reaches a
|
|
1027
|
+
* position at or below the call HORIZON (C43: calls were pruned once every then-existing copy was past them, so a copy created later, a repo granted
|
|
1028
|
+
* after the fact, cannot judge a result by the log). Then it observes its OWN delivered bytes from the first one it delivers, in order. A call seen
|
|
1029
|
+
* earlier gives its paths; one not seen gives `none` only when the observer started at byte 0 of the stream (it saw every byte before the result);
|
|
1030
|
+
* a copy that starts mid-stream below the horizon keeps the persisted verdict (`unknown`), so a call the 4,096-entry cap dropped stays withheld.
|
|
1031
|
+
*/
|
|
1032
|
+
export function pastCursorOf(isolation, cursors, options = {}) {
|
|
1033
|
+
const maxGapBytes = options.maxGapBytes ?? MAX_PAST_GAP_BYTES;
|
|
1034
|
+
const chunkBytes = options.chunkBytes ?? PAST_GAP_CHUNK_BYTES;
|
|
1035
|
+
const clock = options.nowMs ?? Date.now;
|
|
1036
|
+
const maxGapLineBytes = options.maxGapLineBytes ?? PAST_GAP_MAX_LINE_BYTES;
|
|
1037
|
+
const cache = new Map();
|
|
1038
|
+
const unresolvableCache = new Map();
|
|
1039
|
+
const own = new Set(isolation.ownRoots.map((root) => nearestExisting(root) ?? root));
|
|
1040
|
+
const verdicts = new Map();
|
|
1041
|
+
const foreign = (candidate) => {
|
|
1042
|
+
const known = verdicts.get(candidate);
|
|
1043
|
+
if (known !== undefined)
|
|
1044
|
+
return known;
|
|
1045
|
+
const root = workTreeRootOf(candidate, cache);
|
|
1046
|
+
const verdict = (root !== null && !own.has(root)) || unresolvable(candidate, unresolvableCache);
|
|
1047
|
+
verdicts.set(candidate, verdict);
|
|
1048
|
+
return verdict;
|
|
1049
|
+
};
|
|
1050
|
+
const facts = new Map();
|
|
1051
|
+
const persistedCodexInfo = (fileKey, id, start) => persistedCallInfo(isolation, fileKey, id, start);
|
|
1052
|
+
const needlesOfSpec = new Map();
|
|
1053
|
+
const needlesFor = (spec) => {
|
|
1054
|
+
let known = needlesOfSpec.get(spec);
|
|
1055
|
+
if (known === undefined) {
|
|
1056
|
+
known = needlesOf(spec, false);
|
|
1057
|
+
needlesOfSpec.set(spec, known);
|
|
1058
|
+
}
|
|
1059
|
+
return known;
|
|
1060
|
+
};
|
|
1061
|
+
// C44 (3): ONE multi flag for the whole delivery. `at` is a position in `fileKey`'s stream; another stream is multi from its start.
|
|
1062
|
+
let sessionMulti = null;
|
|
1063
|
+
const raiseMulti = (fileKey, at) => {
|
|
1064
|
+
// C66 (1): the first stream to raise it keeps it, but within that stream the flag only moves EARLIER (a later raise at an earlier position wins).
|
|
1065
|
+
if (sessionMulti === null || (sessionMulti.fileKey === fileKey && at < sessionMulti.at))
|
|
1066
|
+
sessionMulti = { fileKey, at };
|
|
1067
|
+
};
|
|
1068
|
+
const codexEnv = (fileKey, position) => {
|
|
1069
|
+
const f = facts.get(fileKey);
|
|
1070
|
+
const tracker = f?.tracker;
|
|
1071
|
+
if (f === undefined || tracker === undefined || position < f.start)
|
|
1072
|
+
return persistedCodexEnv(isolation, fileKey, position);
|
|
1073
|
+
return {
|
|
1074
|
+
turnFolder: () => tracker.turn,
|
|
1075
|
+
callFolders: (id) => tracker.callInfo(id)?.paths,
|
|
1076
|
+
callFiles: (id) => tracker.callInfo(id)?.files ?? [],
|
|
1077
|
+
ownCall: (id) => tracker.callInfo(id),
|
|
1078
|
+
session: (id) => lookupIn(tracker.sessions, id, position),
|
|
1079
|
+
cell: (id) => lookupIn(tracker.cells, id, position),
|
|
1080
|
+
// (No `sessionPlaces`: `observe` steps the tracker BEFORE it judges a call, so the call's own entry already carries the union of the sessions known at it.)
|
|
1081
|
+
callsLost: () => callsLostAt(isolation, fileKey, position, f.start),
|
|
1082
|
+
};
|
|
1083
|
+
};
|
|
1084
|
+
const observe = (fileKey, record, offset) => {
|
|
1085
|
+
const f = facts.get(fileKey);
|
|
1086
|
+
if (f === undefined || offset < f.start)
|
|
1087
|
+
return;
|
|
1088
|
+
const spec = f.spec;
|
|
1089
|
+
// A folder copy has no other work tree to name (a folder target exists only when the session named none): only its calls matter.
|
|
1090
|
+
if (sessionMulti === null && isolation.kind !== "folder") {
|
|
1091
|
+
// C52 (3): a Codex record's own folder is read by the ONE definition (`ownFoldersOf`: any record type, `file://` decoded), as the scan reads it.
|
|
1092
|
+
let sets = (spec.codexFolders === true ? ownFoldersOf(record) : cwdsOfRecord(record, spec.cwdKeys)).some(foreign);
|
|
1093
|
+
// A record nested past the walk's depth may name another work tree we cannot see: the resolver counts that as unknown, which is multi (C43).
|
|
1094
|
+
const truncated = !sets && walkForPaths(record, spec, (hit) => void (sets = sets || (hit.kind === "path" && foreign(hit.value))), 0);
|
|
1095
|
+
if (sets || truncated)
|
|
1096
|
+
raiseMulti(fileKey, offset);
|
|
1097
|
+
}
|
|
1098
|
+
if (spec.codexFolders === true) {
|
|
1099
|
+
// Part (b): a Codex stream. The tracker walks the record exactly as the resolver's scan does (turn change, call opened with its folders, output
|
|
1100
|
+
// closing and teaching the session and cell maps); a record whose effective folders are inside another work tree raises the session-wide multi flag.
|
|
1101
|
+
const stepped = f.tracker?.step(record, offset);
|
|
1102
|
+
// C75 (9): an id map collapsed an entry (its byte budget): evidence was lost, so the session is multi from this record (C54 (1)).
|
|
1103
|
+
if (stepped?.collapsed === true && sessionMulti === null && isolation.kind !== "folder")
|
|
1104
|
+
raiseMulti(fileKey, offset);
|
|
1105
|
+
if (sessionMulti === null && isolation.kind !== "folder") {
|
|
1106
|
+
const effective = recordFolders(record, codexEnv(fileKey, offset));
|
|
1107
|
+
// C53: an UNKNOWN placement still names the folders and files it could read; one in another work tree makes the span multi.
|
|
1108
|
+
// A CALL carrying UNJUDGED_FILE (more places than are kept, or one that cannot be resolved) cannot rule another work tree out: the span is multi too (C54 (1)).
|
|
1109
|
+
if (("dirs" in effective ? effective.dirs : (effective.evidence ?? [])).some(foreign) || ("unknown" in effective && effective.overflow === true))
|
|
1110
|
+
raiseMulti(fileKey, offset);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
else if (spec.trackCalls === true) {
|
|
1114
|
+
const events = { at: offset, opens: [], closes: [], state: { overflow: false } };
|
|
1115
|
+
// C44 (7b): EVERY tool call, with empty paths for one that names no file, so a mid-stream observer can answer `none` for it.
|
|
1116
|
+
noteCalls(record, spec, events, true);
|
|
1117
|
+
for (const open of events.opens)
|
|
1118
|
+
if (!f.calls.has(open.id))
|
|
1119
|
+
f.calls.set(open.id, { at: open.at, paths: open.paths, ...(open.incomplete === true ? { incomplete: true } : {}) });
|
|
1120
|
+
}
|
|
1121
|
+
};
|
|
1122
|
+
/** C65 (1): remember the facts as they stand at `at`, where the read about to be observed begins. */
|
|
1123
|
+
const snapshotAt = (f, at) => {
|
|
1124
|
+
f.snap = { at, ...(f.tracker === undefined ? {} : { tracker: f.tracker.clone() }), calls: new Map(f.calls), unreliable: f.unreliable };
|
|
1125
|
+
};
|
|
1126
|
+
const failClosed = (f, fileKey, from) => {
|
|
1127
|
+
f.unreliable = true;
|
|
1128
|
+
// C66 (1): from the earlier of where the unobserved bytes begin (`from` for a read below `coveredTo`, `coveredTo` for a gap before `from`): every byte
|
|
1129
|
+
// that was not observed in order is multi, never only the bytes past the old `coveredTo`.
|
|
1130
|
+
raiseMulti(fileKey, Math.min(from, f.coveredTo));
|
|
1131
|
+
f.coveredTo = from;
|
|
1132
|
+
};
|
|
1133
|
+
return {
|
|
1134
|
+
cursors,
|
|
1135
|
+
facts,
|
|
1136
|
+
observe,
|
|
1137
|
+
multi(fileKey, position) {
|
|
1138
|
+
return sessionMulti !== null && (sessionMulti.fileKey !== fileKey || position >= sessionMulti.at);
|
|
1139
|
+
},
|
|
1140
|
+
codexEnv,
|
|
1141
|
+
begin(spec, path, fileKey, from, to) {
|
|
1142
|
+
// No facts at all (a test) judges nothing; an unreadable record (`null`) judges every call unknown: neither has an observer to trust.
|
|
1143
|
+
if (isolation.calls === undefined || isolation.calls === null)
|
|
1144
|
+
return false;
|
|
1145
|
+
const cursor = cursors[fileKey] ?? 0;
|
|
1146
|
+
// Below this offset the persisted log cannot vouch for a result: a pruned call (the horizon), a stream with no log, an overflowed log.
|
|
1147
|
+
const log = isolation.calls[fileKey];
|
|
1148
|
+
const horizon = log === undefined || log.overflowed ? cursor : log.horizon;
|
|
1149
|
+
const belowHorizon = spec.trackCalls === true && from <= horizon;
|
|
1150
|
+
// C51 (1): a stream that already has facts (an earlier chunk of this delivery created the observer) is ALWAYS brought up to `from`, whatever the cursor and
|
|
1151
|
+
// the horizon say: `deliver()` skips a record that is too large to send without passing it through `readSpan`, and the bytes it skipped must still be observed.
|
|
1152
|
+
let f = facts.get(fileKey);
|
|
1153
|
+
if (to <= cursor && !belowHorizon && f === undefined)
|
|
1154
|
+
return false;
|
|
1155
|
+
if (f === undefined) {
|
|
1156
|
+
const start = belowHorizon ? Math.min(from, cursor) : cursor;
|
|
1157
|
+
const log = isolation.folders?.[fileKey];
|
|
1158
|
+
const tracker = spec.codexFolders === true
|
|
1159
|
+
? new CodexTracker({
|
|
1160
|
+
turn: lastChangeAtOrBefore(log?.changes, start),
|
|
1161
|
+
// C49 (2): the observer replays from `start`, so it starts with only what was learned BEFORE it (from byte 0: nothing).
|
|
1162
|
+
sessions: idEntriesBefore(log?.sessions, start),
|
|
1163
|
+
cells: idEntriesBefore(log?.cells, start),
|
|
1164
|
+
openCalls: openCallsAt(isolation.calls?.[fileKey], start),
|
|
1165
|
+
}, (id) => persistedCodexInfo(fileKey, id, start), CALL_BOUNDS, (id) => seenCallId(isolation.calls === undefined || isolation.calls === null ? undefined : isolation.calls[fileKey], id, start))
|
|
1166
|
+
: undefined;
|
|
1167
|
+
f = { start, spec, coveredTo: start, calls: new Map(), unreliable: false, ...(tracker === undefined ? {} : { tracker }) };
|
|
1168
|
+
facts.set(fileKey, f);
|
|
1169
|
+
}
|
|
1170
|
+
// C65 (1): the observer's position for this read. A read that begins below `start` is judged by the persisted state up to `start`, so the observer begins there.
|
|
1171
|
+
const p = Math.max(from, f.start);
|
|
1172
|
+
const snap = f.snap;
|
|
1173
|
+
if (snap !== undefined && snap.at === p) {
|
|
1174
|
+
// The same bytes again (a frame split or a 413 re-reads `[from, to)` from the same `from`; a read that failed half way leaves the tracker ahead of
|
|
1175
|
+
// `coveredTo`): the facts go back to what they were when that read began, and the snapshot stays for the next re-read.
|
|
1176
|
+
if (snap.tracker !== undefined)
|
|
1177
|
+
f.tracker = snap.tracker.clone();
|
|
1178
|
+
f.calls = new Map(snap.calls);
|
|
1179
|
+
f.unreliable = snap.unreliable;
|
|
1180
|
+
f.coveredTo = p;
|
|
1181
|
+
return true;
|
|
1182
|
+
}
|
|
1183
|
+
if (p < f.coveredTo) {
|
|
1184
|
+
// Bytes already observed, read again from a point no snapshot was taken at: nothing can vouch for the state there.
|
|
1185
|
+
failClosed(f, fileKey, p);
|
|
1186
|
+
return true;
|
|
1187
|
+
}
|
|
1188
|
+
if (p > f.coveredTo) {
|
|
1189
|
+
// The bytes between what was observed and where this read starts (an earlier firing delivered past the cursor, so this span starts beyond it).
|
|
1190
|
+
// They are read ONCE here, in order, and fed to the same observer.
|
|
1191
|
+
if (p - f.coveredTo > maxGapBytes) {
|
|
1192
|
+
failClosed(f, fileKey, p);
|
|
1193
|
+
snapshotAt(f, p);
|
|
1194
|
+
return true;
|
|
1195
|
+
}
|
|
1196
|
+
const needles = needlesFor(spec);
|
|
1197
|
+
// C44 (6): a line longer than one gap line bound is never parsed (the stream fails closed for the firing), and the deadline is checked before
|
|
1198
|
+
// parsing any line over the check size: a parse and walk of one big line cannot be preempted.
|
|
1199
|
+
let stop = false;
|
|
1200
|
+
// The bounds are BYTES, measured on the accumulated buffer before it is decoded (C45 (3)): a multi-byte line is far longer in bytes than in characters.
|
|
1201
|
+
const feed = (raw, at) => {
|
|
1202
|
+
if (raw.length > maxGapLineBytes) {
|
|
1203
|
+
stop = true;
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
if (raw.length > PAST_GAP_CHECK_LINE_BYTES && options.deadlineAtMs !== undefined && clock() + PAST_GAP_CHUNK_COST_MS > options.deadlineAtMs) {
|
|
1207
|
+
stop = true;
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
const line = raw.toString("utf8");
|
|
1211
|
+
// A Codex stream parses EVERY line, as the resolver's scan does: a record that names no folder is placed by the turn folder, so no line can be skipped on its text.
|
|
1212
|
+
if (spec.codexFolders !== true && !needles.some((needle) => line.includes(needle)))
|
|
1213
|
+
return;
|
|
1214
|
+
try {
|
|
1215
|
+
const parsed = JSON.parse(line);
|
|
1216
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed))
|
|
1217
|
+
observe(fileKey, parsed, at);
|
|
1218
|
+
}
|
|
1219
|
+
catch {
|
|
1220
|
+
// A torn line: nothing to read.
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
let fd;
|
|
1224
|
+
try {
|
|
1225
|
+
fd = openSync(path, "r");
|
|
1226
|
+
let position = f.coveredTo;
|
|
1227
|
+
// The bytes of a line that a chunk ended inside, and the offset it began at.
|
|
1228
|
+
let carry = Buffer.alloc(0);
|
|
1229
|
+
let carryAt = position;
|
|
1230
|
+
let expired = false;
|
|
1231
|
+
while (position < from && !stop) {
|
|
1232
|
+
if (options.deadlineAtMs !== undefined && clock() + PAST_GAP_CHUNK_COST_MS > options.deadlineAtMs) {
|
|
1233
|
+
expired = true;
|
|
1234
|
+
break;
|
|
1235
|
+
}
|
|
1236
|
+
const chunk = Buffer.alloc(Math.min(chunkBytes, from - position));
|
|
1237
|
+
const read = readSync(fd, chunk, 0, chunk.length, position);
|
|
1238
|
+
if (read <= 0)
|
|
1239
|
+
throw new Error("short read");
|
|
1240
|
+
const data = carry.length === 0 ? chunk.subarray(0, read) : Buffer.concat([carry, chunk.subarray(0, read)]);
|
|
1241
|
+
position += read;
|
|
1242
|
+
let lineStart = 0;
|
|
1243
|
+
for (let newline = data.indexOf(0x0a, lineStart); newline !== -1 && !stop; newline = data.indexOf(0x0a, lineStart)) {
|
|
1244
|
+
feed(data.subarray(lineStart, newline), carryAt + lineStart);
|
|
1245
|
+
if (!stop)
|
|
1246
|
+
lineStart = newline + 1;
|
|
1247
|
+
}
|
|
1248
|
+
if (stop)
|
|
1249
|
+
break;
|
|
1250
|
+
carry = data.subarray(lineStart);
|
|
1251
|
+
carryAt += lineStart;
|
|
1252
|
+
f.coveredTo = carryAt;
|
|
1253
|
+
// A line still unfinished past the bound is not accumulated further.
|
|
1254
|
+
if (carry.length > maxGapLineBytes)
|
|
1255
|
+
stop = true;
|
|
1256
|
+
}
|
|
1257
|
+
if (expired || stop)
|
|
1258
|
+
failClosed(f, fileKey, from);
|
|
1259
|
+
else {
|
|
1260
|
+
// The span starts on a record boundary, so what is left is one whole last line.
|
|
1261
|
+
if (carry.length > 0)
|
|
1262
|
+
feed(carry, carryAt);
|
|
1263
|
+
if (stop)
|
|
1264
|
+
failClosed(f, fileKey, from);
|
|
1265
|
+
else
|
|
1266
|
+
f.coveredTo = from;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
catch {
|
|
1270
|
+
failClosed(f, fileKey, from);
|
|
1271
|
+
}
|
|
1272
|
+
finally {
|
|
1273
|
+
if (fd !== undefined) {
|
|
1274
|
+
try {
|
|
1275
|
+
closeSync(fd);
|
|
1276
|
+
}
|
|
1277
|
+
catch {
|
|
1278
|
+
// Nothing useful to do.
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
snapshotAt(f, p);
|
|
1284
|
+
return true;
|
|
1285
|
+
},
|
|
1286
|
+
end(fileKey, to) {
|
|
1287
|
+
const f = facts.get(fileKey);
|
|
1288
|
+
if (f !== undefined && to > f.coveredTo)
|
|
1289
|
+
f.coveredTo = to;
|
|
1290
|
+
},
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
/** `record`: the session's target record. `undefined`: no facts (legacy callers, tests). `null`: unreadable, so the copy fails closed. */
|
|
1294
|
+
export function isolationOf(target, record) {
|
|
1295
|
+
const base = target.kind === "folder" ? { kind: "folder", ownRoots: [] } : { kind: "repo", ownRoots: target.redactionRoots };
|
|
1296
|
+
if (record === undefined)
|
|
1297
|
+
return base;
|
|
1298
|
+
if (record === null)
|
|
1299
|
+
return { ...base, multi: true, calls: null };
|
|
1300
|
+
return { ...base, multi: target.multi === true, calls: record.calls, folders: record.folders };
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* The redaction scope for one stream (A-T4). Pure `stat` walks, memoised for the span (`workTreeRootOf` caches per directory);
|
|
1304
|
+
* no git spawn, so it is free against a hook's budget. A cwd that is gone, or outside every work tree, is NOT another work tree:
|
|
1305
|
+
* the record is kept (the structured-path rule still covers a file read from there).
|
|
1306
|
+
*
|
|
1307
|
+
* `stream` (C40 (1)): the file key of the stream being cut, and whether the dialect tracks calls; with it the scope judges each tool result by the
|
|
1308
|
+
* checkpoint's record of its call, by the position the span reports (`at`).
|
|
1309
|
+
*/
|
|
1310
|
+
export function redactionScopeFor(isolation, cwdKeys, stream) {
|
|
1311
|
+
const cache = new Map();
|
|
1312
|
+
const unresolvableCache = new Map();
|
|
1313
|
+
let position = 0;
|
|
1314
|
+
const past = stream !== undefined ? isolation.past : undefined;
|
|
1315
|
+
const spanFacts = () => (stream === undefined ? undefined : past?.facts.get(stream.fileKey));
|
|
1316
|
+
// C42: a stream with no persisted log and a cursor of 0 has nothing before the cursor: every call is observed in-span, so the log is a fresh one.
|
|
1317
|
+
const logOf = () => {
|
|
1318
|
+
if (stream === undefined || isolation.calls === null)
|
|
1319
|
+
return undefined;
|
|
1320
|
+
const persisted = isolation.calls?.[stream.fileKey];
|
|
1321
|
+
return persisted ?? (past !== undefined && (past.cursors[stream.fileKey] ?? 0) === 0 ? FRESH_CALL_LOG : undefined);
|
|
1322
|
+
};
|
|
1323
|
+
const codex = stream !== undefined && stream.codex === true;
|
|
1324
|
+
const callsOf = stream !== undefined && stream.trackCalls && !codex && isolation.calls !== undefined
|
|
1325
|
+
? {
|
|
1326
|
+
calls: (rawId) => {
|
|
1327
|
+
// C75 (4): the checkpoint keys a call by its BOUNDED id (an id over 128 bytes is its digest); a lookup asks the same way.
|
|
1328
|
+
const id = boundedId(rawId);
|
|
1329
|
+
const persisted = lookupCall(logOf(), id, position);
|
|
1330
|
+
if (typeof persisted !== "string")
|
|
1331
|
+
return persisted;
|
|
1332
|
+
const f = spanFacts();
|
|
1333
|
+
if (f === undefined)
|
|
1334
|
+
return persisted;
|
|
1335
|
+
// A call opened in the bytes this delivery has already passed counts for its later result; only a call in NEITHER place is unknown.
|
|
1336
|
+
const seen = f.calls.get(id);
|
|
1337
|
+
if (seen !== undefined && seen.at < position)
|
|
1338
|
+
return seen.incomplete === true ? "unknown" : seen.paths.length === 0 ? "none" : seen.paths;
|
|
1339
|
+
if (position < f.start)
|
|
1340
|
+
return persisted;
|
|
1341
|
+
if (f.unreliable)
|
|
1342
|
+
return "unknown";
|
|
1343
|
+
// The observer saw every byte of the stream before this result: a call it did not see named no file (C43).
|
|
1344
|
+
return f.start === 0 ? "none" : persisted;
|
|
1345
|
+
},
|
|
1346
|
+
}
|
|
1347
|
+
: {};
|
|
1348
|
+
const base = {
|
|
1349
|
+
...(stream !== undefined && (isolation.calls !== undefined || past !== undefined) ? { at: (offset) => void (position = offset) } : {}),
|
|
1350
|
+
...callsOf,
|
|
1351
|
+
};
|
|
1352
|
+
if (past !== undefined && stream !== undefined)
|
|
1353
|
+
base.observe = (record) => past.observe(stream.fileKey, record, position);
|
|
1354
|
+
if (isolation.multi !== undefined) {
|
|
1355
|
+
const persisted = isolation.multi;
|
|
1356
|
+
// C42: from the first record past the cursor that named another work tree, the rest of the span is multi (that record included).
|
|
1357
|
+
Object.defineProperty(base, "multi", {
|
|
1358
|
+
enumerable: true,
|
|
1359
|
+
get: () => {
|
|
1360
|
+
if (persisted)
|
|
1361
|
+
return true;
|
|
1362
|
+
return stream !== undefined && past !== undefined && past.multi(stream.fileKey, position);
|
|
1363
|
+
},
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
// C48 (11): the files a Codex record's call names (and its output's call), judged by redact.ts like a Claude path. A record whose folders cannot be told
|
|
1367
|
+
// names none here (it is UNKNOWN, decided by the one unknown rule).
|
|
1368
|
+
const codexEnvNow = () => (past !== undefined && stream !== undefined ? past.codexEnv(stream.fileKey, position) : persistedCodexEnv(isolation, stream?.fileKey ?? "", position));
|
|
1369
|
+
const namedFiles = codex && stream !== undefined
|
|
1370
|
+
? {
|
|
1371
|
+
namedFiles: (record) => {
|
|
1372
|
+
const f = spanFacts();
|
|
1373
|
+
// A repo copy withholds such a record through `foreignRecord` (the span is multi from where the facts were lost). A folder copy has no multi flag, so the record
|
|
1374
|
+
// itself carries the mark that withholds it from every copy (C66 (1)).
|
|
1375
|
+
if (f !== undefined && f.unreliable && position >= f.start)
|
|
1376
|
+
return isolation.kind === "folder" ? [UNJUDGED_FILE] : [];
|
|
1377
|
+
const effective = recordFolders(record, codexEnvNow());
|
|
1378
|
+
// A file the turn deleted or moved is judged by its nearest existing ancestor (the Claude rule's `isInside` calls a missing path "outside").
|
|
1379
|
+
// C52 (1): an UNKNOWN placement still carries the files the record names.
|
|
1380
|
+
return [...(effective.files ?? []), ...(effective.inputFiles ?? [])].map((file) => (file === UNJUDGED_FILE ? file : (nearestExisting(file) ?? file)));
|
|
1381
|
+
},
|
|
1382
|
+
}
|
|
1383
|
+
: {};
|
|
1384
|
+
if (isolation.kind === "folder") {
|
|
1385
|
+
return Object.defineProperties({ hole: (candidate) => workTreeRootOf(candidate, cache) !== null, ...namedFiles }, Object.getOwnPropertyDescriptors(base));
|
|
1386
|
+
}
|
|
1387
|
+
const own = new Set(isolation.ownRoots.map((root) => nearestExisting(root) ?? root));
|
|
1388
|
+
const verdicts = new Map();
|
|
1389
|
+
const foreign = (cwd) => {
|
|
1390
|
+
const known = verdicts.get(cwd);
|
|
1391
|
+
if (known !== undefined)
|
|
1392
|
+
return known;
|
|
1393
|
+
const root = workTreeRootOf(cwd, cache);
|
|
1394
|
+
const verdict = root !== null && !own.has(root);
|
|
1395
|
+
verdicts.set(cwd, verdict);
|
|
1396
|
+
return verdict;
|
|
1397
|
+
};
|
|
1398
|
+
if (codex && stream !== undefined) {
|
|
1399
|
+
// Part (b): Codex records name a folder on few types; every record's folder is its EFFECTIVE folder (its own cwd, its call's workdir or patched files,
|
|
1400
|
+
// its call's folder for an output, else the turn folder in force). A record that cannot be placed is UNKNOWN: withheld in a multi-repo copy, kept in a
|
|
1401
|
+
// single-repo one. A record whose folders span two work trees (a patch across two repos) is withheld from both copies.
|
|
1402
|
+
const fileKey = stream.fileKey;
|
|
1403
|
+
const multiNow = () => isolation.multi === true || (past !== undefined && past.multi(fileKey, position));
|
|
1404
|
+
const foreignRecord = (record) => {
|
|
1405
|
+
const f = spanFacts();
|
|
1406
|
+
if (f !== undefined && f.unreliable && position >= f.start)
|
|
1407
|
+
return multiNow();
|
|
1408
|
+
const effective = recordFolders(record, past !== undefined ? past.codexEnv(fileKey, position) : persistedCodexEnv(isolation, fileKey, position));
|
|
1409
|
+
if (!("dirs" in effective))
|
|
1410
|
+
return multiNow();
|
|
1411
|
+
const roots = new Set();
|
|
1412
|
+
for (const dir of effective.dirs) {
|
|
1413
|
+
// C50 (3): a place that exists but cannot be resolved is not "no work tree": the record is UNKNOWN.
|
|
1414
|
+
if (unresolvable(dir, unresolvableCache))
|
|
1415
|
+
return multiNow();
|
|
1416
|
+
const root = workTreeRootOf(dir, cache);
|
|
1417
|
+
if (root !== null)
|
|
1418
|
+
roots.add(root);
|
|
1419
|
+
}
|
|
1420
|
+
if (roots.size === 0)
|
|
1421
|
+
return false;
|
|
1422
|
+
if (roots.size > 1)
|
|
1423
|
+
return true;
|
|
1424
|
+
return !own.has([...roots][0]);
|
|
1425
|
+
};
|
|
1426
|
+
return Object.defineProperties({ foreignRecord, ...namedFiles }, Object.getOwnPropertyDescriptors(base));
|
|
1427
|
+
}
|
|
1428
|
+
return Object.defineProperties({ foreignRecord: (record) => cwdsOfRecord(record, cwdKeys).some(foreign) }, Object.getOwnPropertyDescriptors(base));
|
|
1429
|
+
}
|
|
1430
|
+
function sessionFiles(home, sessionId) {
|
|
1431
|
+
const sessions = sessionsRoot(home);
|
|
1432
|
+
const stem = sessionFileStem(sessionId);
|
|
1433
|
+
const spoolSuffixes = [".spool.jsonl", ".pending.jsonl", ".rewrites.jsonl", ".inferred.jsonl"];
|
|
1434
|
+
let digests;
|
|
1435
|
+
try {
|
|
1436
|
+
digests = readdirSync(sessions);
|
|
1437
|
+
}
|
|
1438
|
+
catch (error) {
|
|
1439
|
+
return { listFailed: error.code !== "ENOENT", spoolKeys: [], ledgerKeys: [], unmappedSpool: false, ledgerDigests: [] };
|
|
1440
|
+
}
|
|
1441
|
+
const noted = (digest) => {
|
|
1442
|
+
try {
|
|
1443
|
+
const key = readFileSync(join(targetsDir(home), `${digest}.repo`), "utf8").trim();
|
|
1444
|
+
const path = key.startsWith(FOLDER_LEDGER_PREFIX) ? key.slice(FOLDER_LEDGER_PREFIX.length) : key;
|
|
1445
|
+
return isAbsolute(path) && repoKeyDigest(key) === digest ? key : null;
|
|
1446
|
+
}
|
|
1447
|
+
catch {
|
|
1448
|
+
return null;
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
const spoolKeys = [];
|
|
1452
|
+
const ledgerKeys = [];
|
|
1453
|
+
const ledgerDigests = [];
|
|
1454
|
+
let unmappedSpool = false;
|
|
1455
|
+
for (const digest of digests) {
|
|
1456
|
+
const hasSpool = spoolSuffixes.some((suffix) => existsSync(join(sessions, digest, `${stem}${suffix}`)));
|
|
1457
|
+
const hasLedger = existsSync(join(sessions, digest, `${stem}.json`));
|
|
1458
|
+
if (!hasSpool && !hasLedger)
|
|
1459
|
+
continue;
|
|
1460
|
+
const key = noted(digest);
|
|
1461
|
+
// A folder ledger is a ledger (its digest is counted, so it is never "unaccounted" by accident) but it is not a work tree: it
|
|
1462
|
+
// is never a candidate root.
|
|
1463
|
+
const isFolder = key !== null && key.startsWith(FOLDER_LEDGER_PREFIX);
|
|
1464
|
+
if (hasLedger) {
|
|
1465
|
+
ledgerDigests.push(digest);
|
|
1466
|
+
if (key !== null && !isFolder)
|
|
1467
|
+
ledgerKeys.push(key);
|
|
1468
|
+
}
|
|
1469
|
+
if (hasSpool) {
|
|
1470
|
+
if (key === null)
|
|
1471
|
+
unmappedSpool = true;
|
|
1472
|
+
else if (!isFolder)
|
|
1473
|
+
spoolKeys.push(key);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
return { listFailed: false, spoolKeys, ledgerKeys, unmappedSpool, ledgerDigests };
|
|
1477
|
+
}
|
|
1478
|
+
function defaultSpawnOk() {
|
|
1479
|
+
return spawnTimeoutMs(MIN_SPAWN_TIMEOUT_MS) !== null;
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Name one candidate root: its keys, slug and redaction roots, or `deferred` (null) when the spawn window ran out.
|
|
1483
|
+
*
|
|
1484
|
+
* ⛔ **A REFUSED SPAWN IS NEVER A FALLBACK IDENTITY** (R16 · D18). `resolveProjectKeys` returns null both for "not a work
|
|
1485
|
+
* tree" and for "no budget left", and `resolveRepoSlug` falls back to `local:<hash of this path>` when its remote lookup is
|
|
1486
|
+
* refused. Both are indistinguishable from their real answers by value, so the window is checked before AND after, and a
|
|
1487
|
+
* result that could have been a refusal is dropped for next firing instead of stored.
|
|
1488
|
+
*/
|
|
1489
|
+
function nameRoot(dir, ctx) {
|
|
1490
|
+
const spawnOk = ctx.seams?.spawnOk ?? defaultSpawnOk;
|
|
1491
|
+
const known = ctx.known !== undefined && (dir === ctx.known.keys.worktree || realOrNull(ctx.known.keys.worktree) === dir) ? ctx.known : null;
|
|
1492
|
+
if (known === null && !spawnOk())
|
|
1493
|
+
return null;
|
|
1494
|
+
const keys = known !== null ? known.keys : (ctx.seams?.resolveKeys ?? resolveProjectKeys)(dir);
|
|
1495
|
+
// After EVERY spawn step: a window that died during it may have turned a real answer into null (or a missing second
|
|
1496
|
+
// redaction root), and none of those is distinguishable by value. One check covers all.
|
|
1497
|
+
if (!spawnOk())
|
|
1498
|
+
return null;
|
|
1499
|
+
// A directory holding `.git` that git will not name: NOT "no repo" (P2 F1). The caller retries it a bounded number of times.
|
|
1500
|
+
if (keys === null)
|
|
1501
|
+
return "unnamed";
|
|
1502
|
+
// `null` is git failing or timing out (`strictRepoSlug`), never an absent remote: defer rather than store `local:<path hash>`.
|
|
1503
|
+
const slug = known !== null ? known.slugOf() : (ctx.seams?.slugOf ?? strictRepoSlug)(keys.worktree);
|
|
1504
|
+
// The window dying DURING the lookup is a deferral that costs nothing; git failing with budget to spare is a FAILURE (C17 #11): it is
|
|
1505
|
+
// counted, so roots that can never be named stop holding the per-firing slots, and the failed ones go to the back of the queue.
|
|
1506
|
+
if (!spawnOk())
|
|
1507
|
+
return null;
|
|
1508
|
+
if (slug === null)
|
|
1509
|
+
return "unnamed";
|
|
1510
|
+
const roots = (ctx.seams?.rootsOf ?? confinementRoots)(keys);
|
|
1511
|
+
if (!spawnOk())
|
|
1512
|
+
return null;
|
|
1513
|
+
return { state: "repo", toplevel: keys.worktree, commonDir: keys.consent, slug, roots: [...roots] };
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* What makes two work trees ONE repository for a target (R5). A remote repo is its normalised slug. A repo with NO remote has a
|
|
1517
|
+
* `local:<hash of its path>` slug that differs per work tree, yet its work trees share one git common dir and are one
|
|
1518
|
+
* repository; grouping by slug alone would split a main clone and its worktrees into two targets and send a session twice.
|
|
1519
|
+
* The slug SENT is the preferred member's (the turn-end work tree first), exactly as 0.8.0 sent the turn-end tree's.
|
|
1520
|
+
*/
|
|
1521
|
+
export function groupKey(info) {
|
|
1522
|
+
return info.slug.startsWith("local:") ? `common:${info.commonDir}` : normalizeSlug(info.slug);
|
|
1523
|
+
}
|
|
1524
|
+
function ledgerExists(home, ledgerKey, sessionId) {
|
|
1525
|
+
const path = sessionStatePath(home, { repoKey: ledgerKey, sessionId });
|
|
1526
|
+
return path !== null && existsSync(path);
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* R5 · O6: which worktree's ledger a (slug, session) uses. An existing ledger beats a new one (nothing re-sends at upgrade);
|
|
1530
|
+
* among several, the latest successful send ACROSS EVERY STREAM (main and each sub-agent's `lastSentAt`), then `seq`, is adopted
|
|
1531
|
+
* and the rest are set aside; with none, the first worktree seen. `candidates` arrive in first-seen order, turn-end first.
|
|
1532
|
+
*
|
|
1533
|
+
* "Set aside" means the other ledgers' offsets and seq NEVER reach the target and are never merged: they are loaded only to
|
|
1534
|
+
* RANK, which is the one read the rule needs.
|
|
1535
|
+
*/
|
|
1536
|
+
function chooseLedger(home, sessionId, candidates) {
|
|
1537
|
+
const holding = candidates.filter((worktree) => ledgerExists(home, worktree, sessionId));
|
|
1538
|
+
if (holding.length === 0)
|
|
1539
|
+
return { ledgerKey: candidates[0], setAside: [], adopted: false };
|
|
1540
|
+
let best = holding[0];
|
|
1541
|
+
let bestRank = [-1, -1];
|
|
1542
|
+
for (const worktree of holding) {
|
|
1543
|
+
const state = loadSessionState(home, { repoKey: worktree, sessionId });
|
|
1544
|
+
const lastSent = Math.max(0, ...Object.values(state.files).map((file) => file.lastSentAt));
|
|
1545
|
+
const rank = [lastSent, state.seq];
|
|
1546
|
+
if (rank[0] > bestRank[0] || (rank[0] === bestRank[0] && rank[1] > bestRank[1])) {
|
|
1547
|
+
best = worktree;
|
|
1548
|
+
bestRank = rank;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
return { ledgerKey: best, setAside: holding.filter((worktree) => worktree !== best), adopted: true };
|
|
1552
|
+
}
|
|
1553
|
+
/** A stream scanned from its first byte: nothing is unknown yet. */
|
|
1554
|
+
const FRESH_CALL_LOG = { entries: [], horizon: 0, overflowed: false };
|
|
1555
|
+
/**
|
|
1556
|
+
* C56 (1) / C57 G4: the ids of entries that LEFT the table (evicted for the cap, dropped for the closed cap, or pruned once every copy had passed them) and carried
|
|
1557
|
+
* names. Bounded at MAX_DROPPED_IDS; past the bound every output whose call is not an entry is unjudged (sticky). C62: a Set (insertion order, O(1) per drop), so a
|
|
1558
|
+
* firing that drops many entries never re-scans the list.
|
|
1559
|
+
*/
|
|
1560
|
+
class NamedDrops {
|
|
1561
|
+
ids;
|
|
1562
|
+
all;
|
|
1563
|
+
constructor(ids, all) {
|
|
1564
|
+
this.ids = new Set(ids ?? []);
|
|
1565
|
+
this.all = all === true;
|
|
1566
|
+
}
|
|
1567
|
+
/** Remember the entry's id when it carried names. Returns what changed: undefined when it carried none, else the id that fell off the end (if any). */
|
|
1568
|
+
note(entry) {
|
|
1569
|
+
if (entry.named !== true && (entry.files?.length ?? 0) === 0)
|
|
1570
|
+
return undefined;
|
|
1571
|
+
this.ids.delete(entry.id);
|
|
1572
|
+
this.ids.add(entry.id);
|
|
1573
|
+
if (this.ids.size > MAX_DROPPED_IDS) {
|
|
1574
|
+
const evicted = this.ids.values().next().value;
|
|
1575
|
+
this.ids.delete(evicted);
|
|
1576
|
+
this.all = true;
|
|
1577
|
+
return { evicted };
|
|
1578
|
+
}
|
|
1579
|
+
return {};
|
|
1580
|
+
}
|
|
1581
|
+
/** The fields of a `CallLog` for this state. */
|
|
1582
|
+
fields() {
|
|
1583
|
+
return { ...(this.ids.size > 0 ? { droppedNamed: [...this.ids] } : {}), ...(this.all ? { droppedNamedAll: true } : {}) };
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
/** C62 (4): remember that `id` LEFT a call table (closed-cap drop or prune). Bounded at MAX_DROPPED_IDS, the oldest is forgotten, not sticky. */
|
|
1587
|
+
function noteDeparted(departed, id) {
|
|
1588
|
+
departed.delete(id);
|
|
1589
|
+
departed.add(id);
|
|
1590
|
+
if (departed.size > MAX_DROPPED_IDS) {
|
|
1591
|
+
const forgotten = departed.values().next().value;
|
|
1592
|
+
departed.delete(forgotten);
|
|
1593
|
+
return forgotten;
|
|
1594
|
+
}
|
|
1595
|
+
return undefined;
|
|
1596
|
+
}
|
|
1597
|
+
/**
|
|
1598
|
+
* C75 (8) / C76: evict the OLDEST entries that store path text from a call log until `free` bytes are freed (or none is left), through the marks the count caps use: an open entry is
|
|
1599
|
+
* remembered in `droppedIds` (its result is unknown), a closed one raises the horizon and leaves a departed id, and each leaves its `named` mark, so its output fails closed.
|
|
1600
|
+
* `measure` "text" counts the path text an entry stores (the table's own budget); "full" counts the whole entry as the session budget does, NET of the ids that join lists (an evicted
|
|
1601
|
+
* id joins one list, two when it is named), and the result carries the EXACT bytes freed. `keepNewest` entries at the end are left alone.
|
|
1602
|
+
*/
|
|
1603
|
+
function shedCallsExact(log, free, includePathless, measure, keepNewest) {
|
|
1604
|
+
let entries = log.entries;
|
|
1605
|
+
let horizon = log.horizon;
|
|
1606
|
+
let overflowed = log.overflowed;
|
|
1607
|
+
const droppedIds = [...(log.droppedIds ?? [])];
|
|
1608
|
+
let droppedFrom = 0;
|
|
1609
|
+
const named = new NamedDrops(log.droppedNamed, log.droppedNamedAll);
|
|
1610
|
+
const departed = new Set(log.prunedIds ?? []);
|
|
1611
|
+
const evict = new Set();
|
|
1612
|
+
let freed = 0;
|
|
1613
|
+
const limit = Math.max(0, entries.length - keepNewest);
|
|
1614
|
+
for (let at = 0; at < limit; at += 1) {
|
|
1615
|
+
const entry = entries[at];
|
|
1616
|
+
if (freed >= free)
|
|
1617
|
+
break;
|
|
1618
|
+
// (The table's own budget leaves an entry that stores no path text alone: its output's fate is already "unknown", and its fixed cost is bounded by the entry-count caps. The SESSION
|
|
1619
|
+
// budget counts everything a stream stores and sheds those too: `includePathless`.)
|
|
1620
|
+
if (pathBytes(entry) === 0 && !includePathless)
|
|
1621
|
+
continue;
|
|
1622
|
+
evict.add(entry.id);
|
|
1623
|
+
freed += measure === "text" ? pathBytes(entry) : callEntryBytes(entry);
|
|
1624
|
+
const item = listItemBytes(entry.id);
|
|
1625
|
+
if (entry.closedAt === null) {
|
|
1626
|
+
droppedIds.push(entry.id);
|
|
1627
|
+
if (measure === "full")
|
|
1628
|
+
freed -= item;
|
|
1629
|
+
// A list at its cap forgets its oldest id for each one it takes: the forgotten id's bytes are credited at once, so the loop stops when the excess is met.
|
|
1630
|
+
if (droppedIds.length - droppedFrom > MAX_DROPPED_IDS) {
|
|
1631
|
+
if (measure === "full")
|
|
1632
|
+
freed += listItemBytes(droppedIds[droppedFrom]);
|
|
1633
|
+
droppedFrom += 1;
|
|
1634
|
+
overflowed = true;
|
|
1635
|
+
named.all = true;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
else {
|
|
1639
|
+
horizon = Math.max(horizon, entry.closedAt);
|
|
1640
|
+
const forgotten = noteDeparted(departed, entry.id);
|
|
1641
|
+
if (measure === "full")
|
|
1642
|
+
freed -= item - (forgotten === undefined ? 0 : listItemBytes(forgotten));
|
|
1643
|
+
}
|
|
1644
|
+
const joined = named.note(entry);
|
|
1645
|
+
if (measure === "full" && joined !== undefined)
|
|
1646
|
+
freed -= item - (joined.evicted === undefined ? 0 : listItemBytes(joined.evicted));
|
|
1647
|
+
}
|
|
1648
|
+
if (evict.size === 0 && droppedIds.length - droppedFrom <= MAX_DROPPED_IDS)
|
|
1649
|
+
return { log, freed: 0 };
|
|
1650
|
+
if (evict.size > 0)
|
|
1651
|
+
entries = entries.filter((entry) => !evict.has(entry.id));
|
|
1652
|
+
// (An incoming list longer than its cap, which the fold never leaves, is cut too.)
|
|
1653
|
+
if (droppedIds.length - droppedFrom > MAX_DROPPED_IDS) {
|
|
1654
|
+
const cut = droppedIds.length - droppedFrom - MAX_DROPPED_IDS;
|
|
1655
|
+
if (measure === "full")
|
|
1656
|
+
for (let i = 0; i < cut; i += 1)
|
|
1657
|
+
freed += listItemBytes(droppedIds[droppedFrom + i]);
|
|
1658
|
+
droppedFrom += cut;
|
|
1659
|
+
overflowed = true;
|
|
1660
|
+
named.all = true;
|
|
1661
|
+
}
|
|
1662
|
+
const ids = droppedFrom > 0 ? droppedIds.slice(droppedFrom) : droppedIds;
|
|
1663
|
+
return { log: { entries, horizon, overflowed, ...(ids.length > 0 ? { droppedIds: ids } : {}), ...named.fields(), ...(departed.size > 0 ? { prunedIds: [...departed] } : {}) }, freed };
|
|
1664
|
+
}
|
|
1665
|
+
/** The log with its oldest path-bearing entries evicted until `free` bytes of path TEXT are freed (the table's own budget). */
|
|
1666
|
+
export function shedCalls(log, free, includePathless = false, measure = "full", keepNewest = 0) {
|
|
1667
|
+
return shedCallsExact(log, free, includePathless, measure, keepNewest).log;
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* C76 (3): ONE budget for a whole SESSION. Every stream (the main transcript and each subagent's) has its own tables, each held to its own bound, and a session can have any number of
|
|
1671
|
+
* streams. After a firing has folded its events, the bytes the session's streams store (call entries, the three id lists, the Codex id maps and the folder timeline) may not pass
|
|
1672
|
+
* `SESSION_BUDGET_BYTES`. C77: they are counted by an UPPER bound of their JSON size (the fixed cost of an entry, the escaped length of every string), so the record on disk is within the
|
|
1673
|
+
* budget, not near it.
|
|
1674
|
+
*/
|
|
1675
|
+
export const SESSION_BUDGET_BYTES = 6 * 1024 * 1024;
|
|
1676
|
+
/** The newest call entries of a stream that the session budget sheds only after everything else (see `shedSession`). */
|
|
1677
|
+
const RECENT_CALLS = 64;
|
|
1678
|
+
/** C78: what a stream's envelope takes in the record beyond its tables: its keys under calls, folders and scan, and their fixed fields (a cursor, a horizon, flags). */
|
|
1679
|
+
const STREAM_ENVELOPE_BYTES = 512;
|
|
1680
|
+
/** The fewest bytes one shedding pass takes from a stream (keeps the passes few when many streams are near equal). */
|
|
1681
|
+
const MIN_SHED_CHUNK = 64 * 1024;
|
|
1682
|
+
// C77 (4): the session budget counts UPPER bounds of the persisted JSON: the escaped length of each string, plus a fixed cost per entry that covers its field names and the longest offsets.
|
|
1683
|
+
const CALL_FIXED_BYTES = 160;
|
|
1684
|
+
const MAP_FIXED_BYTES = 160;
|
|
1685
|
+
const DIR_FIXED_BYTES = 64;
|
|
1686
|
+
const POINT_FIXED_BYTES = 64;
|
|
1687
|
+
/** The bytes of a string as a JSON literal in the record (quotes and escapes included, UTF-8). */
|
|
1688
|
+
const jsonBytes = (text) => Buffer.byteLength(JSON.stringify(text), "utf8");
|
|
1689
|
+
/** One string in a JSON list: its literal and the comma. */
|
|
1690
|
+
const listItemBytes = (text) => jsonBytes(text) + 1;
|
|
1691
|
+
const listBytes = (items) => {
|
|
1692
|
+
let total = 0;
|
|
1693
|
+
for (const item of items ?? [])
|
|
1694
|
+
total += listItemBytes(item);
|
|
1695
|
+
return total;
|
|
1696
|
+
};
|
|
1697
|
+
/** What a call entry takes in the record (an upper bound). */
|
|
1698
|
+
export function callEntryBytes(entry) {
|
|
1699
|
+
return CALL_FIXED_BYTES + jsonBytes(entry.id) + (entry.tool === undefined ? 0 : jsonBytes(entry.tool)) + listBytes(entry.paths) + listBytes(entry.files);
|
|
1700
|
+
}
|
|
1701
|
+
/** What an id-map entry takes in the record (an upper bound). */
|
|
1702
|
+
function mapEntryBytes(key, entry) {
|
|
1703
|
+
let total = MAP_FIXED_BYTES + jsonBytes(key);
|
|
1704
|
+
for (const learned of entry.dirs)
|
|
1705
|
+
total += jsonBytes(learned.dir) + DIR_FIXED_BYTES;
|
|
1706
|
+
return total + listBytes(entry.files) + listBytes(entry.evidence);
|
|
1707
|
+
}
|
|
1708
|
+
const pointBytes = (change) => POINT_FIXED_BYTES + (change.dir === null ? 0 : jsonBytes(change.dir));
|
|
1709
|
+
/** What one stream's tables take in the record (an upper bound), measured once per firing. */
|
|
1710
|
+
function streamBytes(log, folders) {
|
|
1711
|
+
let total = 0;
|
|
1712
|
+
for (const entry of log?.entries ?? [])
|
|
1713
|
+
total += callEntryBytes(entry);
|
|
1714
|
+
total += listBytes(log?.droppedIds) + listBytes(log?.droppedNamed) + listBytes(log?.prunedIds);
|
|
1715
|
+
for (const [key, entry] of folders?.sessions ?? [])
|
|
1716
|
+
total += mapEntryBytes(key, entry);
|
|
1717
|
+
for (const [key, entry] of folders?.cells ?? [])
|
|
1718
|
+
total += mapEntryBytes(key, entry);
|
|
1719
|
+
for (const change of folders?.changes ?? [])
|
|
1720
|
+
total += pointBytes(change);
|
|
1721
|
+
return total;
|
|
1722
|
+
}
|
|
1723
|
+
/** What a whole session's records take by the same measure. Exported for the cells. */
|
|
1724
|
+
export function sessionBytes(calls, folders) {
|
|
1725
|
+
let total = 0;
|
|
1726
|
+
for (const key of new Set([...Object.keys(calls), ...Object.keys(folders)]))
|
|
1727
|
+
total += STREAM_ENVELOPE_BYTES + streamBytes(calls[key], folders[key]);
|
|
1728
|
+
return total;
|
|
1729
|
+
}
|
|
1730
|
+
/** A max-heap of (size, key) with lazy deletion: an item is valid while the stream still has that size. */
|
|
1731
|
+
class SizeHeap {
|
|
1732
|
+
items = [];
|
|
1733
|
+
push(size, key) {
|
|
1734
|
+
this.items.push([size, key]);
|
|
1735
|
+
let at = this.items.length - 1;
|
|
1736
|
+
while (at > 0) {
|
|
1737
|
+
const parent = (at - 1) >> 1;
|
|
1738
|
+
if (this.items[parent][0] >= this.items[at][0])
|
|
1739
|
+
break;
|
|
1740
|
+
[this.items[parent], this.items[at]] = [this.items[at], this.items[parent]];
|
|
1741
|
+
at = parent;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
pop() {
|
|
1745
|
+
const top = this.items[0];
|
|
1746
|
+
const last = this.items.pop();
|
|
1747
|
+
if (top === undefined || last === undefined)
|
|
1748
|
+
return undefined;
|
|
1749
|
+
if (this.items.length > 0) {
|
|
1750
|
+
this.items[0] = last;
|
|
1751
|
+
let at = 0;
|
|
1752
|
+
for (;;) {
|
|
1753
|
+
const left = 2 * at + 1;
|
|
1754
|
+
const right = left + 1;
|
|
1755
|
+
let largest = at;
|
|
1756
|
+
if (left < this.items.length && this.items[left][0] > this.items[largest][0])
|
|
1757
|
+
largest = left;
|
|
1758
|
+
if (right < this.items.length && this.items[right][0] > this.items[largest][0])
|
|
1759
|
+
largest = right;
|
|
1760
|
+
if (largest === at)
|
|
1761
|
+
break;
|
|
1762
|
+
[this.items[largest], this.items[at]] = [this.items[at], this.items[largest]];
|
|
1763
|
+
at = largest;
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
return top;
|
|
1767
|
+
}
|
|
1768
|
+
/** The largest valid stream, removed; and the size of the next valid one (0 if none). */
|
|
1769
|
+
takeTop(valid) {
|
|
1770
|
+
for (;;) {
|
|
1771
|
+
const top = this.pop();
|
|
1772
|
+
if (top === undefined)
|
|
1773
|
+
return undefined;
|
|
1774
|
+
if (!valid(top[0], top[1]))
|
|
1775
|
+
continue;
|
|
1776
|
+
while (this.items.length > 0 && !valid(this.items[0][0], this.items[0][1]))
|
|
1777
|
+
this.pop();
|
|
1778
|
+
return { size: top[0], key: top[1], next: this.items.length > 0 ? this.items[0][0] : 0 };
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
const NOTHING = { freed: 0, changed: false };
|
|
1783
|
+
/**
|
|
1784
|
+
* Shed the session down to `budget`, in place on the two records. Returns true when a Codex id map lost evidence. The stream that holds the most sheds (a heap gives it in O(log streams)),
|
|
1785
|
+
* down to the next largest, or its share of the excess when several tie, never more than the excess; so no stream is emptied while another keeps its oldest calls. Two phases:
|
|
1786
|
+
* A. each pass takes at least `MIN_SHED_CHUNK` and runs, stopping as soon as its chunk is freed, (i) the stream's call entries STRICTLY OLDEST FIRST by position, with or without path
|
|
1787
|
+
* text, each with its departure marks, except its `RECENT_CALLS` newest (a result arrives right after its call: without that floor, a stream whose id lists alone fill its share would
|
|
1788
|
+
* lose its newest calls too); (ii) the oldest ids of its id lists, prunedIds first (not sticky), then droppedNamed, then droppedIds, each list's sticky mark set ONLY when it lost ids;
|
|
1789
|
+
* (iii) the oldest Codex id-map tombstones (the whole map reads UNKNOWN from there); (iv) the oldest text of its id maps (collapse); (v) the oldest timeline points.
|
|
1790
|
+
* B. only when NO stream has anything older left to shed, (vi) the newest call entries, by plain water-filling with no floor: a stream never loses the calls it was just scanned for
|
|
1791
|
+
* while another still keeps older content.
|
|
1792
|
+
* Every step returns the exact bytes it freed and the sizes are kept by subtraction, a growth (an eviction that adds a mark) booked as a negative amount; a stream is left out only when no
|
|
1793
|
+
* step changed anything. A firing costs O(streams + evictions), plus one measure of every stream.
|
|
1794
|
+
*/
|
|
1795
|
+
export function shedSession(calls, folders, budget = SESSION_BUDGET_BYTES) {
|
|
1796
|
+
const keys = [...new Set([...Object.keys(calls), ...Object.keys(folders)])];
|
|
1797
|
+
const sizes = new Map();
|
|
1798
|
+
let total = 0;
|
|
1799
|
+
for (const key of keys) {
|
|
1800
|
+
const size = STREAM_ENVELOPE_BYTES + streamBytes(calls[key], folders[key]);
|
|
1801
|
+
sizes.set(key, size);
|
|
1802
|
+
total += size;
|
|
1803
|
+
}
|
|
1804
|
+
if (total <= budget)
|
|
1805
|
+
return false;
|
|
1806
|
+
let collapsed = false;
|
|
1807
|
+
const asShed = (before, after, freed) => ({ freed, changed: before !== after || freed !== 0 });
|
|
1808
|
+
const older = [
|
|
1809
|
+
(key, free) => shedCallLog(calls, key, free, RECENT_CALLS),
|
|
1810
|
+
(key, free) => {
|
|
1811
|
+
let freed = 0;
|
|
1812
|
+
let changed = false;
|
|
1813
|
+
for (const field of ["prunedIds", "droppedNamed", "droppedIds"]) {
|
|
1814
|
+
const log = calls[key];
|
|
1815
|
+
if (log === undefined || freed >= free)
|
|
1816
|
+
break;
|
|
1817
|
+
const shed = shedList(log, field, free - freed);
|
|
1818
|
+
calls[key] = shed.log;
|
|
1819
|
+
freed += shed.freed;
|
|
1820
|
+
changed ||= shed.log !== log;
|
|
1821
|
+
}
|
|
1822
|
+
return { freed, changed };
|
|
1823
|
+
},
|
|
1824
|
+
(key, free) => {
|
|
1825
|
+
let freed = 0;
|
|
1826
|
+
let changed = false;
|
|
1827
|
+
for (const kind of ["sessions", "cells"]) {
|
|
1828
|
+
const folder = folders[key];
|
|
1829
|
+
if (folder === undefined || freed >= free)
|
|
1830
|
+
break;
|
|
1831
|
+
const shed = shedTombs(folder[kind], free - freed);
|
|
1832
|
+
if (shed.lost)
|
|
1833
|
+
collapsed = true;
|
|
1834
|
+
if (shed.lost)
|
|
1835
|
+
folders[key] = { ...folder, [kind]: shed.entries };
|
|
1836
|
+
freed += shed.freed;
|
|
1837
|
+
changed ||= shed.lost;
|
|
1838
|
+
}
|
|
1839
|
+
return { freed, changed };
|
|
1840
|
+
},
|
|
1841
|
+
(key, free) => {
|
|
1842
|
+
let freed = 0;
|
|
1843
|
+
let changed = false;
|
|
1844
|
+
for (const kind of ["sessions", "cells"]) {
|
|
1845
|
+
const folder = folders[key];
|
|
1846
|
+
if (folder === undefined || freed >= free)
|
|
1847
|
+
break;
|
|
1848
|
+
const shed = shedIdMap(folder[kind], free - freed);
|
|
1849
|
+
if (shed.collapsed)
|
|
1850
|
+
collapsed = true;
|
|
1851
|
+
if (shed.collapsed)
|
|
1852
|
+
folders[key] = { ...folder, [kind]: shed.entries };
|
|
1853
|
+
freed += shed.freed;
|
|
1854
|
+
changed ||= shed.collapsed;
|
|
1855
|
+
}
|
|
1856
|
+
return { freed, changed };
|
|
1857
|
+
},
|
|
1858
|
+
(key, free) => {
|
|
1859
|
+
const folder = folders[key];
|
|
1860
|
+
if (folder === undefined)
|
|
1861
|
+
return NOTHING;
|
|
1862
|
+
const shed = shedTimeline(folder.changes, free);
|
|
1863
|
+
if (shed.changes === folder.changes)
|
|
1864
|
+
return NOTHING;
|
|
1865
|
+
folders[key] = { ...folder, changes: shed.changes };
|
|
1866
|
+
return asShed(folder.changes, shed.changes, shed.freed);
|
|
1867
|
+
},
|
|
1868
|
+
];
|
|
1869
|
+
// Last of all: the newest call entries too.
|
|
1870
|
+
const newest = [(key, free) => shedCallLog(calls, key, free, 0)];
|
|
1871
|
+
const phase = (list, floor) => {
|
|
1872
|
+
const heap = new SizeHeap();
|
|
1873
|
+
const exhausted = new Set();
|
|
1874
|
+
for (const key of keys)
|
|
1875
|
+
heap.push(sizes.get(key), key);
|
|
1876
|
+
let live = keys.length;
|
|
1877
|
+
const valid = (size, key) => !exhausted.has(key) && sizes.get(key) === size;
|
|
1878
|
+
while (total > budget) {
|
|
1879
|
+
const top = heap.takeTop(valid);
|
|
1880
|
+
if (top === undefined)
|
|
1881
|
+
return;
|
|
1882
|
+
const over = total - budget;
|
|
1883
|
+
const target = Math.min(over, Math.max(top.size - top.next, Math.ceil(over / Math.max(1, live)), floor));
|
|
1884
|
+
let freed = 0;
|
|
1885
|
+
let changed = false;
|
|
1886
|
+
for (const step of list) {
|
|
1887
|
+
if (freed >= target)
|
|
1888
|
+
break;
|
|
1889
|
+
const result = step(top.key, target - freed);
|
|
1890
|
+
freed += result.freed;
|
|
1891
|
+
changed ||= result.changed;
|
|
1892
|
+
}
|
|
1893
|
+
if (!changed) {
|
|
1894
|
+
exhausted.add(top.key);
|
|
1895
|
+
live -= 1;
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
// (A pass that grew the record, an eviction whose mark outweighs it, is booked as the negative it is.)
|
|
1899
|
+
total -= freed;
|
|
1900
|
+
sizes.set(top.key, top.size - freed);
|
|
1901
|
+
heap.push(top.size - freed, top.key);
|
|
1902
|
+
}
|
|
1903
|
+
};
|
|
1904
|
+
phase(older, MIN_SHED_CHUNK);
|
|
1905
|
+
if (total > budget)
|
|
1906
|
+
phase(newest, 0);
|
|
1907
|
+
return collapsed;
|
|
1908
|
+
}
|
|
1909
|
+
function shedCallLog(calls, key, free, keepNewest) {
|
|
1910
|
+
const log = calls[key];
|
|
1911
|
+
if (log === undefined)
|
|
1912
|
+
return NOTHING;
|
|
1913
|
+
const shed = shedCallsExact(log, free, true, "full", keepNewest);
|
|
1914
|
+
if (shed.log === log)
|
|
1915
|
+
return NOTHING;
|
|
1916
|
+
calls[key] = shed.log;
|
|
1917
|
+
return { freed: shed.freed, changed: true };
|
|
1918
|
+
}
|
|
1919
|
+
/** Drop the OLDEST ids of one id list until `free` bytes are freed. The list's sticky mark is set ONLY when it actually lost ids (a list that lost nothing has not lost anything to remember). */
|
|
1920
|
+
function shedList(log, field, free) {
|
|
1921
|
+
const ids = log[field] ?? [];
|
|
1922
|
+
let cut = 0;
|
|
1923
|
+
let freed = 0;
|
|
1924
|
+
while (cut < ids.length && freed < free) {
|
|
1925
|
+
freed += listItemBytes(ids[cut]);
|
|
1926
|
+
cut += 1;
|
|
1927
|
+
}
|
|
1928
|
+
if (cut === 0)
|
|
1929
|
+
return { log, freed: 0 };
|
|
1930
|
+
const kept = ids.slice(cut);
|
|
1931
|
+
const { [field]: _gone, ...rest } = log;
|
|
1932
|
+
void _gone;
|
|
1933
|
+
const next = { ...rest, ...(kept.length > 0 ? { [field]: kept } : {}) };
|
|
1934
|
+
// A forgotten dropped id may have been an unmatched result that is now read as "named nothing": the log is overflowed (every unmatched result is unknown) and no forgotten id is trusted
|
|
1935
|
+
// not to have carried files. A forgotten named id turns on the "whole map unjudged" mark. A forgotten pruned id only loses its repeat check (the list is not sticky).
|
|
1936
|
+
if (field === "droppedIds")
|
|
1937
|
+
return { log: { ...next, overflowed: true, droppedNamedAll: true }, freed };
|
|
1938
|
+
if (field === "droppedNamed")
|
|
1939
|
+
return { log: { ...next, droppedNamedAll: true }, freed };
|
|
1940
|
+
return { log: next, freed };
|
|
1941
|
+
}
|
|
1942
|
+
/** Forget the OLDEST tombstones of a persisted id map until `free` bytes are freed NET of the mark that comes with it; the map then reads UNKNOWN from the start (`ALL_UNKNOWN`), which is evidence lost. */
|
|
1943
|
+
function shedTombs(entries, free) {
|
|
1944
|
+
const map = new IdMap(entries ?? []);
|
|
1945
|
+
const had = map.has(ALL_UNKNOWN);
|
|
1946
|
+
// The mark is added once, with the first tombstone forgotten: it is part of what must be won back.
|
|
1947
|
+
const markCost = had ? 0 : mapEntryBytes(ALL_UNKNOWN, { dirs: [], unknownAt: 0 });
|
|
1948
|
+
let freed = 0;
|
|
1949
|
+
let lost = false;
|
|
1950
|
+
while (freed < free + markCost) {
|
|
1951
|
+
const forgotten = map.evictOldestTomb(0);
|
|
1952
|
+
if (forgotten === undefined)
|
|
1953
|
+
break;
|
|
1954
|
+
freed += mapEntryBytes(forgotten[0], forgotten[1]);
|
|
1955
|
+
lost = true;
|
|
1956
|
+
}
|
|
1957
|
+
if (lost)
|
|
1958
|
+
freed -= markCost;
|
|
1959
|
+
return { entries: boundedIdEntries(lost ? [...map] : (entries ?? [])), lost, freed };
|
|
1960
|
+
}
|
|
1961
|
+
/** Collapse the OLDEST text-bearing entries of a persisted id map until `free` bytes are freed. */
|
|
1962
|
+
function shedIdMap(entries, free) {
|
|
1963
|
+
const map = new IdMap(entries ?? []);
|
|
1964
|
+
let freed = 0;
|
|
1965
|
+
for (;;) {
|
|
1966
|
+
if (freed >= free)
|
|
1967
|
+
break;
|
|
1968
|
+
const key = map.oldestText();
|
|
1969
|
+
if (key === undefined)
|
|
1970
|
+
break;
|
|
1971
|
+
const before = map.get(key);
|
|
1972
|
+
map.collapse(key, 0);
|
|
1973
|
+
freed += mapEntryBytes(key, before) - mapEntryBytes(key, map.get(key));
|
|
1974
|
+
}
|
|
1975
|
+
return { entries: boundedIdEntries([...map]), collapsed: map.takeCollapsed(), freed };
|
|
1976
|
+
}
|
|
1977
|
+
/** Drop the OLDEST change points of a timeline until `free` bytes are freed (a position before the first kept point is unknown). */
|
|
1978
|
+
function shedTimeline(changes, free) {
|
|
1979
|
+
let cut = 0;
|
|
1980
|
+
let freed = 0;
|
|
1981
|
+
while (cut < changes.length - 1 && freed < free) {
|
|
1982
|
+
freed += pointBytes(changes[cut]);
|
|
1983
|
+
cut += 1;
|
|
1984
|
+
}
|
|
1985
|
+
return { changes: cut === 0 ? changes : changes.slice(cut), freed };
|
|
1986
|
+
}
|
|
1987
|
+
/** The path text an entry stores (what the budget can free by evicting it). */
|
|
1988
|
+
function pathBytes(entry) {
|
|
1989
|
+
return storedBytes(entry.paths) + storedBytes(entry.files ?? []);
|
|
1990
|
+
}
|
|
1991
|
+
/**
|
|
1992
|
+
* C40 (1): fold the calls one scan read into a stream's checkpoint. Opens first, then closes (a result always follows its call in the file). More than
|
|
1993
|
+
* `MAX_OPEN_CALLS` waiting drops the oldest and marks the log overflowed; more than `MAX_CLOSED_CALLS` closed drops the oldest and raises the horizon.
|
|
1994
|
+
* `unread` (an oversize line, an input too deep to read) marks the log overflowed: a call may have been missed.
|
|
1995
|
+
*/
|
|
1996
|
+
export function applyCallEvents(log, events, unread) {
|
|
1997
|
+
const entries = [...log.entries];
|
|
1998
|
+
const index = new Map(entries.map((entry, at) => [entry.id, at]));
|
|
1999
|
+
const droppedSeen = new Set([...(log.droppedIds ?? []), ...(log.droppedNamed ?? []), ...(log.prunedIds ?? [])]);
|
|
2000
|
+
for (const open of events.opens) {
|
|
2001
|
+
const prior = index.get(open.id);
|
|
2002
|
+
if (prior !== undefined) {
|
|
2003
|
+
// C62 (3): a Codex call that opens an id already in the table poisons it (the entry is reopened); a Claude call keeps the first, as before.
|
|
2004
|
+
if (open.tool !== undefined)
|
|
2005
|
+
entries[prior] = { ...poisonRepeated(entries[prior]), closedAt: null };
|
|
2006
|
+
continue;
|
|
2007
|
+
}
|
|
2008
|
+
index.set(open.id, entries.length);
|
|
2009
|
+
const entry = {
|
|
2010
|
+
id: open.id,
|
|
2011
|
+
at: open.at,
|
|
2012
|
+
paths: open.paths,
|
|
2013
|
+
closedAt: null,
|
|
2014
|
+
...(open.incomplete === true ? { incomplete: true } : {}),
|
|
2015
|
+
...(open.tool === undefined ? {} : { tool: open.tool }),
|
|
2016
|
+
...(open.files === undefined ? {} : { files: open.files }),
|
|
2017
|
+
...(open.named === true ? { named: true } : {}),
|
|
2018
|
+
};
|
|
2019
|
+
// An id the checkpoint dropped earlier is known too.
|
|
2020
|
+
const kept1 = open.tool !== undefined && droppedSeen.has(open.id) ? poisonRepeated(entry) : entry;
|
|
2021
|
+
// C75 (8): an entry whose own stored places pass the budget is never stored: it is UNKNOWN (Codex: UNJUDGED_FILE; Claude: INCOMPLETE). (`placeCall` and `noteCalls` already refuse it; this is the table's own guard.)
|
|
2022
|
+
entries.push(pathBytes(kept1) > CALL_LOG_BUDGET_BYTES ? (kept1.tool !== undefined ? { ...poisonRepeated({ ...kept1, files: [] }), paths: [] } : { ...kept1, paths: [], incomplete: true }) : kept1);
|
|
2023
|
+
}
|
|
2024
|
+
for (const close of events.closes) {
|
|
2025
|
+
const at = index.get(close.id);
|
|
2026
|
+
if (at === undefined)
|
|
2027
|
+
continue;
|
|
2028
|
+
const entry = entries[at];
|
|
2029
|
+
if (entry.closedAt === null)
|
|
2030
|
+
entries[at] = { ...entry, closedAt: close.at };
|
|
2031
|
+
}
|
|
2032
|
+
let overflowed = log.overflowed || unread || events.overflow;
|
|
2033
|
+
let horizon = log.horizon;
|
|
2034
|
+
let kept = entries;
|
|
2035
|
+
let droppedIds = log.droppedIds ?? [];
|
|
2036
|
+
const named = new NamedDrops(log.droppedNamed, log.droppedNamedAll);
|
|
2037
|
+
const departed = new Set(log.prunedIds ?? []);
|
|
2038
|
+
const noteNamed = (entry) => void named.note(entry);
|
|
2039
|
+
const open = kept.filter((entry) => entry.closedAt === null);
|
|
2040
|
+
if (open.length > MAX_OPEN_CALLS) {
|
|
2041
|
+
// C44 (7a): the dropped ids are remembered (bounded) so ONLY their results are unknown; the log stays usable for every other id.
|
|
2042
|
+
const dropping = open.slice(0, open.length - MAX_OPEN_CALLS);
|
|
2043
|
+
const drop = new Set(dropping.map((entry) => entry.id));
|
|
2044
|
+
kept = kept.filter((entry) => !drop.has(entry.id));
|
|
2045
|
+
droppedIds = [...droppedIds, ...dropping.map((entry) => entry.id)];
|
|
2046
|
+
for (const entry of dropping)
|
|
2047
|
+
noteNamed(entry);
|
|
2048
|
+
if (droppedIds.length > MAX_DROPPED_IDS) {
|
|
2049
|
+
droppedIds = droppedIds.slice(droppedIds.length - MAX_DROPPED_IDS);
|
|
2050
|
+
overflowed = true;
|
|
2051
|
+
// C56 (11): the forgotten ids may have carried files: from here every output whose call is not an entry is unjudged.
|
|
2052
|
+
named.all = true;
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
// C75 (8): the TABLE's byte budget. What the entries store (paths and files, open and closed) may not pass CALL_LOG_BUDGET_BYTES per stream: the oldest entries that store anything
|
|
2056
|
+
// are evicted until it fits (`shedCalls`: the same marks as the count caps).
|
|
2057
|
+
// C76: it compares only the path TEXT the entries store (what an eviction can free); the fixed cost of an entry (id, tool, offsets) is bounded by the count caps, and the SESSION budget counts it.
|
|
2058
|
+
let stored = 0;
|
|
2059
|
+
for (const entry of kept)
|
|
2060
|
+
stored += pathBytes(entry);
|
|
2061
|
+
if (stored > CALL_LOG_BUDGET_BYTES) {
|
|
2062
|
+
const shed = shedCalls({ entries: kept, horizon, overflowed, ...(droppedIds.length > 0 ? { droppedIds } : {}), ...named.fields(), ...(departed.size > 0 ? { prunedIds: [...departed] } : {}) }, stored - CALL_LOG_BUDGET_BYTES, false, "text");
|
|
2063
|
+
kept = [...shed.entries];
|
|
2064
|
+
horizon = shed.horizon;
|
|
2065
|
+
overflowed = shed.overflowed;
|
|
2066
|
+
droppedIds = shed.droppedIds ?? [];
|
|
2067
|
+
named.ids.clear();
|
|
2068
|
+
for (const id of shed.droppedNamed ?? [])
|
|
2069
|
+
named.ids.add(id);
|
|
2070
|
+
named.all = shed.droppedNamedAll === true;
|
|
2071
|
+
departed.clear();
|
|
2072
|
+
for (const id of shed.prunedIds ?? [])
|
|
2073
|
+
departed.add(id);
|
|
2074
|
+
}
|
|
2075
|
+
const closed = kept.filter((entry) => entry.closedAt !== null).sort((a, b) => a.closedAt - b.closedAt);
|
|
2076
|
+
if (closed.length > MAX_CLOSED_CALLS) {
|
|
2077
|
+
const dropped = closed.slice(0, closed.length - MAX_CLOSED_CALLS);
|
|
2078
|
+
const drop = new Set(dropped.map((entry) => entry.id));
|
|
2079
|
+
horizon = Math.max(horizon, ...dropped.map((entry) => entry.closedAt));
|
|
2080
|
+
kept = kept.filter((entry) => !drop.has(entry.id));
|
|
2081
|
+
for (const entry of dropped) {
|
|
2082
|
+
noteNamed(entry);
|
|
2083
|
+
noteDeparted(departed, entry.id);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
return {
|
|
2087
|
+
entries: kept,
|
|
2088
|
+
horizon,
|
|
2089
|
+
overflowed,
|
|
2090
|
+
...(droppedIds.length > 0 ? { droppedIds } : {}),
|
|
2091
|
+
...named.fields(),
|
|
2092
|
+
...(departed.size > 0 ? { prunedIds: [...departed] } : {}),
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
/**
|
|
2096
|
+
* C40 (1): remove the entries whose result EVERY copy is already past (`lowWater` is the smallest sent offset of the stream over the session's
|
|
2097
|
+
* targets): the paths are gone as soon as nobody can need them. A later copy that still needs one (a target created late starts at offset 0) finds
|
|
2098
|
+
* the horizon raised and reads the call as unknown: withheld, never kept.
|
|
2099
|
+
*/
|
|
2100
|
+
export function pruneCalls(log, lowWater) {
|
|
2101
|
+
let horizon = log.horizon;
|
|
2102
|
+
const kept = [];
|
|
2103
|
+
const named = new NamedDrops(log.droppedNamed, log.droppedNamedAll);
|
|
2104
|
+
const pruned = new Set(log.prunedIds ?? []);
|
|
2105
|
+
for (const entry of log.entries) {
|
|
2106
|
+
if (entry.closedAt !== null && entry.closedAt < lowWater) {
|
|
2107
|
+
// C62 (4): every pruned id is remembered (bounded, the oldest forgotten): a later reuse is a REPEATED id even when the pruned call named nothing.
|
|
2108
|
+
noteDeparted(pruned, entry.id);
|
|
2109
|
+
horizon = Math.max(horizon, entry.closedAt);
|
|
2110
|
+
// C57 G4: a pruned entry that carried names is remembered like an evicted one: a later output with its id (above the horizon) is unjudged.
|
|
2111
|
+
named.note(entry);
|
|
2112
|
+
continue;
|
|
2113
|
+
}
|
|
2114
|
+
kept.push(entry);
|
|
2115
|
+
}
|
|
2116
|
+
return kept.length === log.entries.length && horizon === log.horizon
|
|
2117
|
+
? log
|
|
2118
|
+
: {
|
|
2119
|
+
entries: kept,
|
|
2120
|
+
horizon,
|
|
2121
|
+
overflowed: log.overflowed,
|
|
2122
|
+
...(log.droppedIds === undefined ? {} : { droppedIds: log.droppedIds }),
|
|
2123
|
+
...named.fields(),
|
|
2124
|
+
...(pruned.size > 0 ? { prunedIds: [...pruned] } : {}),
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
/**
|
|
2128
|
+
* C40 (1): what the checkpoint says about the result of call `id` found at byte `at` of its stream. The entry's paths (judged per copy by the
|
|
2129
|
+
* caller), `none` (the call named no file: kept, a stated residual), or `unknown` (withheld, failing closed).
|
|
2130
|
+
*/
|
|
2131
|
+
export function lookupCall(log, id, at) {
|
|
2132
|
+
if (log === undefined)
|
|
2133
|
+
return "unknown";
|
|
2134
|
+
const entry = log.entries.find((candidate) => candidate.id === id);
|
|
2135
|
+
if (entry !== undefined)
|
|
2136
|
+
return entry.incomplete === true ? "unknown" : entry.paths;
|
|
2137
|
+
if (log.droppedIds?.includes(id) === true)
|
|
2138
|
+
return "unknown";
|
|
2139
|
+
return log.overflowed || at <= log.horizon ? "unknown" : "none";
|
|
2140
|
+
}
|
|
2141
|
+
/**
|
|
2142
|
+
* Turn a session's evidence into targets. ⛔ **The caller holds the one per-session lock** (O5); this reads the record,
|
|
2143
|
+
* scans new bytes, resolves a bounded number of new roots, and saves the record.
|
|
2144
|
+
*
|
|
2145
|
+
* Evidence (R2 · D2 · A): the turn-end folder, each stream's new records' folders and path keys, and repos holding a spooled
|
|
2146
|
+
* commit for this session. Nothing else is read, kept or sent. Granted repos become sticky repo targets; the rest are
|
|
2147
|
+
* recorded by NAME as undecided (R8) and capture nothing. A folder target exists only in `end` mode, after every line was
|
|
2148
|
+
* read, when no repo at all was named and the launch folder is granted (R6 · R18).
|
|
2149
|
+
*/
|
|
2150
|
+
export function resolveTargets(ctx) {
|
|
2151
|
+
// A record describes ONE transcript. The same session id arriving with a DIFFERENT `transcript_path` (D58's shape: one id, two
|
|
2152
|
+
// repos, two files) is not a continuation of that file's scan: its cursors would skip bytes of the new file and its sticky
|
|
2153
|
+
// targets would send the new file's bytes through the old file's ledgers. Start a fresh record for it. Nothing re-sends: the
|
|
2154
|
+
// ledgers outlive the record and are ADOPTED by key, and a C3 stop is about the target, so it is kept.
|
|
2155
|
+
const loaded = loadTargetRecord(ctx.home, ctx.sessionId);
|
|
2156
|
+
const record0 = loaded === null
|
|
2157
|
+
? emptyTargetRecord(ctx.sessionId)
|
|
2158
|
+
: loaded.transcriptPath === null || sameTranscript(loaded.transcriptPath, ctx.transcriptPath)
|
|
2159
|
+
? loaded
|
|
2160
|
+
: { ...emptyTargetRecord(ctx.sessionId), stopped: loaded.stopped };
|
|
2161
|
+
const window = ctx.seams?.scanWindowBytes ?? EVIDENCE_SCAN_WINDOW_BYTES;
|
|
2162
|
+
const spec = evidenceSpecFor(ctx.home, ctx.env, ctx.transcriptPath);
|
|
2163
|
+
// ⛔ C14 / C24 (P1, from W1's and P2's reviews): EVIDENCE BEYOND THE TURN-END FOLDER (record paths, spooled commits, ledger notes, a
|
|
2164
|
+
// launch folder) MAY CREATE A TARGET ONLY FOR A DIALECT THAT CAN ISOLATE RECORDS (`isolatesContentRecords`): one whose every content
|
|
2165
|
+
// record carries or inherits the folder it ran in. A path found in a record names a repo, but without R17 isolation the copy for that
|
|
2166
|
+
// repo would also carry the prompts, outputs and prose of every other repo's records: the multi-repo leak D19 exists to prevent.
|
|
2167
|
+
// `cwdKeys` being non-empty is not enough (Codex names a folder only on two record types), so a dialect that cannot isolate keeps what
|
|
2168
|
+
// 0.7.0 and 0.8.0 did: the repo the turn ended in, never a folder target, and no transcript is read for evidence at all. That is now
|
|
2169
|
+
// Cursor only: Codex isolates its records (part (b), `codex_folders.ts`) and is read for evidence like Claude Code. The gate is HERE,
|
|
2170
|
+
// where hook, drain, finalize and backfill all pass.
|
|
2171
|
+
const evidenced = dialectIsolates(ctx.home, ctx.env, ctx.transcriptPath);
|
|
2172
|
+
const listing = evidenced ? findSubagentTranscriptsChecked(ctx.home, ctx.env, ctx.transcriptPath) : { found: [], listFailed: false };
|
|
2173
|
+
const streams = !evidenced
|
|
2174
|
+
? []
|
|
2175
|
+
: [{ fileKey: "main", path: ctx.transcriptPath }, ...listing.found.map((f) => ({ fileKey: f.fileKey, path: f.path }))];
|
|
2176
|
+
// --- 1. Read new bytes of every stream, bounded in bytes READ. -----------------------------------------------------------
|
|
2177
|
+
const scan = { ...record0.scan };
|
|
2178
|
+
const calls = { ...record0.calls };
|
|
2179
|
+
const folders = { ...record0.folders };
|
|
2180
|
+
let folderUnknown = record0.folderUnknown === true;
|
|
2181
|
+
// ⛔ FINAL for TAIL purposes: the session is over (`end`), or it has been idle long enough for another session's firing to
|
|
2182
|
+
// drain it (P1 C10 (c)). A crashed session never gets a SessionEnd, so its torn last line would otherwise stay "unconsumed"
|
|
2183
|
+
// forever: never `settled`, re-picked by every drain. This decides nothing else: a FOLDER target still needs mode `end` (D21).
|
|
2184
|
+
const finalTail = ctx.mode === "end" || ctx.drain === true;
|
|
2185
|
+
let launchFolder = record0.launchFolder;
|
|
2186
|
+
let oversize = record0.oversize;
|
|
2187
|
+
// An unlistable subagents directory is not an empty one (C17 #2): the delegated streams are unknown, so discovery is not exhausted.
|
|
2188
|
+
let allRead = !listing.listFailed;
|
|
2189
|
+
let anyTorn = false;
|
|
2190
|
+
const dirs = [];
|
|
2191
|
+
if (ctx.turnEndCwd !== null)
|
|
2192
|
+
dirs.push(ctx.turnEndCwd);
|
|
2193
|
+
for (const stream of streams) {
|
|
2194
|
+
// C35 (ii): a beat before each stream's scan, as before each root: a holder that stalled past its bound must not go on to save a
|
|
2195
|
+
// record over its new owner's. `LockLostError` is thrown before anything is written.
|
|
2196
|
+
if (ctx.heartbeat !== undefined && !ctx.heartbeat())
|
|
2197
|
+
throw new LockLostError();
|
|
2198
|
+
const prior = scan[stream.fileKey];
|
|
2199
|
+
const from = prior === undefined ? 0 : prior.offset;
|
|
2200
|
+
const result = scanTranscriptKeys(stream.path, from, from + window, spec, {
|
|
2201
|
+
includeUnterminatedTail: finalTail,
|
|
2202
|
+
maxLineBytes: ctx.seams?.maxLineBytes,
|
|
2203
|
+
maxDistinctHits: ctx.seams?.maxDistinctHits,
|
|
2204
|
+
maxReadBytes: window,
|
|
2205
|
+
continueSkipping: prior?.skipping === true,
|
|
2206
|
+
...(spec.codexFolders === true
|
|
2207
|
+
? {
|
|
2208
|
+
codex: {
|
|
2209
|
+
turn: lastFolderOf(folders[stream.fileKey]),
|
|
2210
|
+
sessions: folders[stream.fileKey]?.sessions ?? [],
|
|
2211
|
+
cells: folders[stream.fileKey]?.cells ?? [],
|
|
2212
|
+
openCalls: openCallsAt(calls[stream.fileKey], from),
|
|
2213
|
+
calls: (id) => persistedCall(calls[stream.fileKey], id, from, false),
|
|
2214
|
+
seen: (id) => seenCallId(calls[stream.fileKey], id),
|
|
2215
|
+
},
|
|
2216
|
+
}
|
|
2217
|
+
: {}),
|
|
2218
|
+
});
|
|
2219
|
+
if (result.kind === "unreadable") {
|
|
2220
|
+
// Q4: no NEW targets from an unreadable stream this firing; sticky targets still deliver. Never complete.
|
|
2221
|
+
allRead = false;
|
|
2222
|
+
continue;
|
|
2223
|
+
}
|
|
2224
|
+
// A file that SHRANK was replaced: restart its scan rather than skip bytes it never read.
|
|
2225
|
+
if (prior !== undefined && result.size < prior.offset) {
|
|
2226
|
+
allRead = false;
|
|
2227
|
+
delete scan[stream.fileKey];
|
|
2228
|
+
delete calls[stream.fileKey];
|
|
2229
|
+
delete folders[stream.fileKey];
|
|
2230
|
+
continue;
|
|
2231
|
+
}
|
|
2232
|
+
// C40 (1): the call checkpoint, from the bytes just read. A stream with a saved cursor but no log (scanned before the checkpoint existed)
|
|
2233
|
+
// begins its log overflowed: the calls before its cursor are unknown, so its unmatched results are withheld.
|
|
2234
|
+
const priorLog = calls[stream.fileKey] ?? (prior === undefined ? FRESH_CALL_LOG : { entries: [], horizon: prior.offset, overflowed: true });
|
|
2235
|
+
// C45 (2): only bytes the scan did not READ (an oversize line) make the call log unreliable. `result.capped` also means a walk was TRUNCATED (or the
|
|
2236
|
+
// distinct-hit cap was reached), which is about the evidence and, for a call, already marks that one entry INCOMPLETE; it must not set the sticky overflow.
|
|
2237
|
+
calls[stream.fileKey] = applyCallEvents(priorLog, result.calls, result.oversizeLines > 0);
|
|
2238
|
+
if (spec.codexFolders === true) {
|
|
2239
|
+
// Part (b): the turn-folder timeline, the session and cell maps, and whether any record's folder could not be told. An output whose call is not
|
|
2240
|
+
// in the table (a dropped one included) is unknown: the call table is what places it.
|
|
2241
|
+
if (result.folders.unknown)
|
|
2242
|
+
folderUnknown = true;
|
|
2243
|
+
const changes = [...(folders[stream.fileKey]?.changes ?? []), ...result.folders.changes];
|
|
2244
|
+
folders[stream.fileKey] = {
|
|
2245
|
+
changes: boundedChanges(changes),
|
|
2246
|
+
// The maps are bounded by the tracker (live entries, tombstones and the whole-map mark); `boundedIdEntries` is only a backstop, and it never cuts
|
|
2247
|
+
// silently: past the bound the whole map is marked UNKNOWN (C56 (12)).
|
|
2248
|
+
sessions: boundedIdEntries(result.folders.sessions),
|
|
2249
|
+
cells: boundedIdEntries(result.folders.cells),
|
|
2250
|
+
...(folders[stream.fileKey]?.skipped !== undefined || result.folders.skipped !== undefined ? { skipped: folders[stream.fileKey]?.skipped ?? result.folders.skipped } : {}),
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
scan[stream.fileKey] = {
|
|
2254
|
+
offset: result.scannedTo,
|
|
2255
|
+
size: result.size,
|
|
2256
|
+
...(result.skipping ? { skipping: true } : {}),
|
|
2257
|
+
};
|
|
2258
|
+
// The furthest point this stream can EVER reach: its end, or, for a FINAL file, a torn tail (nothing more will parse) or an
|
|
2259
|
+
// oversize skip that ran to the end (nothing more will be buffered). Anything short of that, a later firing can still read.
|
|
2260
|
+
const furthest = (!result.skipping && result.scannedTo >= result.size) ||
|
|
2261
|
+
result.torn ||
|
|
2262
|
+
(result.skipping && result.scannedTo >= result.size && finalTail);
|
|
2263
|
+
if (!furthest)
|
|
2264
|
+
allRead = false;
|
|
2265
|
+
if (result.torn)
|
|
2266
|
+
anyTorn = true;
|
|
2267
|
+
if (result.oversizeLines > 0 || result.capped)
|
|
2268
|
+
oversize = true;
|
|
2269
|
+
for (const hit of result.hits) {
|
|
2270
|
+
if (hit.kind === "cwd" && launchFolder === null && stream.fileKey === "main")
|
|
2271
|
+
launchFolder = hit.value;
|
|
2272
|
+
dirs.push(hit.value);
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
// C76 (3): the SESSION's budget over every stream's tables (a session can have any number of subagent streams, each with tables of its own). A Codex id map that lost evidence counts the
|
|
2276
|
+
// session as multi, like any other lost evidence.
|
|
2277
|
+
if (shedSession(calls, folders))
|
|
2278
|
+
oversize = true;
|
|
2279
|
+
// A stream not scanned at all (first sight, or unreadable): unfinished.
|
|
2280
|
+
for (const stream of streams)
|
|
2281
|
+
if (scan[stream.fileKey] === undefined)
|
|
2282
|
+
allRead = false;
|
|
2283
|
+
// ⛔ A stream with a saved UNFINISHED cursor that is no longer listed (the file vanished) keeps the session incomplete (#7).
|
|
2284
|
+
const listed = new Set(streams.map((stream) => stream.fileKey));
|
|
2285
|
+
for (const [fileKey, cursor] of Object.entries(record0.scan)) {
|
|
2286
|
+
if (!listed.has(fileKey) && (cursor.offset < cursor.size || cursor.skipping === true))
|
|
2287
|
+
allRead = false;
|
|
2288
|
+
}
|
|
2289
|
+
// --- 2. Collapse to candidate roots by `stat` (no spawn), then resolve a bounded number. ---------------------------------
|
|
2290
|
+
const files = sessionFiles(ctx.home, ctx.sessionId);
|
|
2291
|
+
// Likewise an unlistable sessions directory: "no spool, no ledger" is unknown, not known.
|
|
2292
|
+
if (files.listFailed)
|
|
2293
|
+
allRead = false;
|
|
2294
|
+
const cache = new Map();
|
|
2295
|
+
const unresolvableCache = new Map();
|
|
2296
|
+
const candidates = [];
|
|
2297
|
+
const evidenceRoots = new Set();
|
|
2298
|
+
const noteCandidate = (dir, viaEvidence) => {
|
|
2299
|
+
const root = workTreeRootOf(dir, cache);
|
|
2300
|
+
// C50 (3): evidence that names a place nobody can resolve cannot be ruled out as another work tree: the status is unknown (multi), like a dropped hit.
|
|
2301
|
+
if (root === null && viaEvidence && unresolvable(dir, unresolvableCache))
|
|
2302
|
+
oversize = true;
|
|
2303
|
+
if (root === null)
|
|
2304
|
+
return;
|
|
2305
|
+
if (viaEvidence)
|
|
2306
|
+
evidenceRoots.add(root);
|
|
2307
|
+
if (!candidates.includes(root))
|
|
2308
|
+
candidates.push(root);
|
|
2309
|
+
};
|
|
2310
|
+
for (const dir of dirs)
|
|
2311
|
+
noteCandidate(dir, true);
|
|
2312
|
+
if (evidenced) {
|
|
2313
|
+
for (const key of files.spoolKeys)
|
|
2314
|
+
noteCandidate(key, true);
|
|
2315
|
+
for (const key of files.ledgerKeys)
|
|
2316
|
+
noteCandidate(key, false);
|
|
2317
|
+
}
|
|
2318
|
+
// ⛔ A root that evidence named but the per-firing cap (or the spawn window) deferred is still evidence-origin on the next firing, even
|
|
2319
|
+
// when only its ledger note supplies it then (C17 #6): only a root that was ledger-only to begin with stays ledger-only.
|
|
2320
|
+
for (const root of record0.unresolved)
|
|
2321
|
+
if (!record0.ledgerOnly.includes(root))
|
|
2322
|
+
evidenceRoots.add(root);
|
|
2323
|
+
// A root is ledger-only until evidence names it, and once evidence has, it never goes back.
|
|
2324
|
+
const ledgerOnly = new Set(record0.ledgerOnly.filter((root) => !evidenceRoots.has(root)));
|
|
2325
|
+
for (const root of candidates)
|
|
2326
|
+
if (!evidenceRoots.has(root) && record0.roots[root] === undefined)
|
|
2327
|
+
ledgerOnly.add(root);
|
|
2328
|
+
const roots = { ...record0.roots };
|
|
2329
|
+
const queue = [...record0.unresolved];
|
|
2330
|
+
for (const root of candidates)
|
|
2331
|
+
if (!queue.includes(root) && roots[root] === undefined)
|
|
2332
|
+
queue.push(root);
|
|
2333
|
+
const cap = ctx.seams?.maxNewRoots ?? MAX_NEW_ROOTS_PER_FIRING;
|
|
2334
|
+
// ⛔ Not reached this firing (cap, spawn window) keep their place; ones that were TRIED and failed rotate to the BACK, so eight roots
|
|
2335
|
+
// that fail for ever cannot hold every slot while a healthy ninth waits (C17 #11).
|
|
2336
|
+
const waiting = [];
|
|
2337
|
+
const failedNow = [];
|
|
2338
|
+
const failures = { ...record0.failures };
|
|
2339
|
+
let spent = 0;
|
|
2340
|
+
for (const root of queue) {
|
|
2341
|
+
if (roots[root] !== undefined)
|
|
2342
|
+
continue;
|
|
2343
|
+
if (spent >= cap) {
|
|
2344
|
+
waiting.push(root);
|
|
2345
|
+
continue;
|
|
2346
|
+
}
|
|
2347
|
+
spent += 1;
|
|
2348
|
+
if (ctx.heartbeat !== undefined && !ctx.heartbeat())
|
|
2349
|
+
throw new LockLostError();
|
|
2350
|
+
const info = nameRoot(root, ctx);
|
|
2351
|
+
if (info === null) {
|
|
2352
|
+
waiting.push(root);
|
|
2353
|
+
continue;
|
|
2354
|
+
}
|
|
2355
|
+
if (info === "unnamed") {
|
|
2356
|
+
if (ctx.countedThisInvocation === undefined || !ctx.countedThisInvocation.has(root)) {
|
|
2357
|
+
failures[root] = (failures[root] ?? 0) + 1;
|
|
2358
|
+
ctx.countedThisInvocation?.add(root);
|
|
2359
|
+
}
|
|
2360
|
+
if ((failures[root] ?? 0) >= MAX_NAMING_FAILURES)
|
|
2361
|
+
roots[root] = { state: "nonrepo" };
|
|
2362
|
+
else
|
|
2363
|
+
failedNow.push(root);
|
|
2364
|
+
continue;
|
|
2365
|
+
}
|
|
2366
|
+
roots[root] = info;
|
|
2367
|
+
}
|
|
2368
|
+
const unresolved = [...waiting, ...failedNow];
|
|
2369
|
+
// EXHAUSTED: nothing a later firing could still discover (P1's definition of `settled`). NOT the same as `complete`: an oversize
|
|
2370
|
+
// line only rules out the FOLDER decision, and repo discovery must still reach the end of the file.
|
|
2371
|
+
const exhausted = allRead && unresolved.length === 0;
|
|
2372
|
+
const discoveryDone = exhausted;
|
|
2373
|
+
// --- 3. Group resolved repos by slug; consent decides what becomes a sticky target. ------------------------------------
|
|
2374
|
+
const turnEndRoot = ctx.turnEndCwd === null ? null : workTreeRootOf(ctx.turnEndCwd, cache);
|
|
2375
|
+
const bySlug = new Map();
|
|
2376
|
+
for (const [root, info] of Object.entries(roots)) {
|
|
2377
|
+
if (info.state !== "repo")
|
|
2378
|
+
continue;
|
|
2379
|
+
const standing = captureStanding(ctx.home, { worktree: info.toplevel, consent: info.commonDir }, info.slug);
|
|
2380
|
+
const key = groupKey(info);
|
|
2381
|
+
const members = bySlug.get(key) ?? [];
|
|
2382
|
+
members.push({ root, info, standing, viaLedgerOnly: ledgerOnly.has(root) });
|
|
2383
|
+
bySlug.set(key, members);
|
|
2384
|
+
}
|
|
2385
|
+
// Ledger digests no resolved repo accounts for yet: an existing 0.8.0 ledger whose work tree discovery has not reached (#3).
|
|
2386
|
+
const accountedDigests = new Set(Object.values(roots).flatMap((info) => (info.state === "repo" ? [repoKeyDigest(info.toplevel)] : [])));
|
|
2387
|
+
const unaccountedLedger = files.ledgerDigests.some((digest) => !accountedDigests.has(digest));
|
|
2388
|
+
const targets = [];
|
|
2389
|
+
const undecided = [];
|
|
2390
|
+
const sticky = new Map(record0.targets.filter((t) => t.kind === "repo").map((t) => [t.groupKey ?? normalizeSlug(t.slug), t]));
|
|
2391
|
+
const keep = [];
|
|
2392
|
+
const newKeys = new Set();
|
|
2393
|
+
for (const [key, members] of bySlug) {
|
|
2394
|
+
// A group no evidence named (only ledger notes) is not this transcript's repo: it makes no target and no undecided name.
|
|
2395
|
+
if (members.every((m) => m.viaLedgerOnly))
|
|
2396
|
+
continue;
|
|
2397
|
+
const granted = members.filter((m) => m.standing === "granted");
|
|
2398
|
+
if (granted.length === 0) {
|
|
2399
|
+
if (members.every((m) => m.standing === "undecided")) {
|
|
2400
|
+
const first = members[0].info;
|
|
2401
|
+
undecided.push({ kind: "repo", name: first.slug.startsWith("local:") ? first.toplevel : first.slug, at: ctx.nowMs });
|
|
2402
|
+
}
|
|
2403
|
+
// A sticky target whose consent was withdrawn is kept in the record but not returned (denied/held).
|
|
2404
|
+
const old = sticky.get(key);
|
|
2405
|
+
if (old !== undefined)
|
|
2406
|
+
keep.push(old);
|
|
2407
|
+
continue;
|
|
2408
|
+
}
|
|
2409
|
+
// ⛔ C28 (Codex's P1 on 47220fa): A DIALECT THAT CANNOT ISOLATE (Cursor; Codex until its own isolation is built) DELIVERS ONLY THE REPO
|
|
2410
|
+
// THE CURRENT TURN ENDED IN, the 0.7.0/0.8.0 rule. A repo an EARLIER turn ended in is a sticky target of this record, and returning
|
|
2411
|
+
// it would send this turn's records (another repo's prompts and output) into ITS copy: turn 1 ends in A, turn 2 ends in B, and B's turn
|
|
2412
|
+
// would reach A. The sticky target stays in the record with its ledger (a later turn that ends in A continues A's offset), it is
|
|
2413
|
+
// just not deliverable now. Every caller passes the turn-end folder it means (hook: its cwd; in-repo backfill: the candidate's
|
|
2414
|
+
// folder) or none (`finalize`, the parent-folder backfill, the idle drain), and none means no target for such a dialect.
|
|
2415
|
+
// C35 (i): the root must belong to a GRANTED member, not merely to the group. Consent can be per clone: of two clones of one remote
|
|
2416
|
+
// only A granted, a turn that ends in B must send nothing (0.8.0 sent nothing), not ride A's ledger and slug.
|
|
2417
|
+
if (!evidenced && !granted.some((m) => m.root === turnEndRoot)) {
|
|
2418
|
+
const stuck = sticky.get(key);
|
|
2419
|
+
if (stuck !== undefined)
|
|
2420
|
+
keep.push(stuck);
|
|
2421
|
+
continue;
|
|
2422
|
+
}
|
|
2423
|
+
// The turn-end work tree first (today's behaviour, R14), then first-seen order.
|
|
2424
|
+
const ordered = [...granted].sort((a, b) => Number(b.root === turnEndRoot) - Number(a.root === turnEndRoot));
|
|
2425
|
+
const worktrees = [...new Set(ordered.map((m) => m.info.toplevel))];
|
|
2426
|
+
const redaction = [...new Set(ordered.flatMap((m) => m.info.roots))];
|
|
2427
|
+
const old = sticky.get(key);
|
|
2428
|
+
let chosen;
|
|
2429
|
+
if (old !== undefined) {
|
|
2430
|
+
chosen = { ledgerKey: old.ledgerKey, setAside: [...old.setAsideLedgers] };
|
|
2431
|
+
}
|
|
2432
|
+
else {
|
|
2433
|
+
const picked = chooseLedger(ctx.home, ctx.sessionId, worktrees);
|
|
2434
|
+
// ⛔ DEFER, never start a second ledger (#3): with no ledger among the worktrees found so far, but a ledger for this
|
|
2435
|
+
// session that discovery has not mapped to any work tree yet, the first-seen choice could rewind the server stream
|
|
2436
|
+
// (P9). Wait for discovery; once it is done with the ledger still unaccounted for, it belongs to something that no
|
|
2437
|
+
// longer exists and a new ledger is the only option left.
|
|
2438
|
+
// C17 #7: also when a ledger WAS adopted. If A's older ledger is found first while B's newer ledger is still an unmapped digest,
|
|
2439
|
+
// making A sticky now could rewind B's server stream: wait until every ledger digest of this session is accounted for.
|
|
2440
|
+
if (unaccountedLedger && !discoveryDone)
|
|
2441
|
+
continue;
|
|
2442
|
+
chosen = picked;
|
|
2443
|
+
newKeys.add(key);
|
|
2444
|
+
}
|
|
2445
|
+
// ⛔ The wire slug is bound to the LEDGER, not to this firing's turn-end (P1 C10): a no-remote group holds work trees with
|
|
2446
|
+
// different `local:<hash>` slugs, so recomputing it per firing would send one ledger's offsets to two server repos (a gapped
|
|
2447
|
+
// stream each), and an adopted 0.8.0 ledger written under work tree X would send X's offsets to local:<Y>. A new target takes
|
|
2448
|
+
// the slug (and common dir) of the member whose work tree IS the chosen ledger key and stores it; a sticky target reuses what
|
|
2449
|
+
// it stored for the session's life. Only worktrees and redaction roots grow. Remote groups share one slug, so nothing changes.
|
|
2450
|
+
const owner = ordered.find((m) => m.info.toplevel === chosen.ledgerKey) ?? ordered[0];
|
|
2451
|
+
// C27: has this session named a work tree OTHER than this group's? Any resolved root outside the group, granted or not (an unnameable `.git`
|
|
2452
|
+
// counts: it is a work tree), any candidate not yet resolved, or evidence dropped (oversize / capped: the status is unknown). Ledger-only
|
|
2453
|
+
// roots are not evidence about this transcript. Monotonic: a target that was multi stays multi.
|
|
2454
|
+
const namedOther = oversize ||
|
|
2455
|
+
unresolved.length > 0 ||
|
|
2456
|
+
Object.entries(roots).some(([root, info]) => !ledgerOnly.has(root) && (info.state !== "repo" || groupKey(info) !== key));
|
|
2457
|
+
const target = {
|
|
2458
|
+
...(old?.multi === true || namedOther ? { multi: true } : {}),
|
|
2459
|
+
kind: "repo",
|
|
2460
|
+
slug: old?.slug ?? owner.info.slug,
|
|
2461
|
+
groupKey: key,
|
|
2462
|
+
ledgerKey: chosen.ledgerKey,
|
|
2463
|
+
redactionRoots: redaction,
|
|
2464
|
+
worktrees,
|
|
2465
|
+
commonDir: old?.commonDir ?? owner.info.commonDir,
|
|
2466
|
+
setAsideLedgers: chosen.setAside,
|
|
2467
|
+
};
|
|
2468
|
+
keep.push(target);
|
|
2469
|
+
targets.push(target);
|
|
2470
|
+
}
|
|
2471
|
+
// --- 4. A folder target: only when the session is OVER, fully read, and named no repo at all (R6 · R18). ----------------
|
|
2472
|
+
const complete = exhausted && !oversize && !files.unmappedSpool && !anyTorn;
|
|
2473
|
+
// Part (b): a Codex session whose records' folders were not all determined has no folder target (every record must be placeable before the whole
|
|
2474
|
+
// session is sent under a folder's name).
|
|
2475
|
+
const foldersKnown = !folderUnknown;
|
|
2476
|
+
// ⛔ ANY candidate root is repo evidence, whatever naming it said (P2 F1): a `.git` git will not name is still a repo, and
|
|
2477
|
+
// R6 gives a folder target only when evidence finds NO repo at all.
|
|
2478
|
+
const namedAnyRepo = Object.keys(roots).length > 0 || unresolved.length > 0 || files.spoolKeys.length > 0 || files.unmappedSpool;
|
|
2479
|
+
const folderStanding = ctx.seams?.folderStanding ?? ((real) => folderStandingOf(ctx.home, real));
|
|
2480
|
+
let folderTarget = null;
|
|
2481
|
+
const stickyFolder = record0.targets.find((t) => t.kind === "folder") ?? null;
|
|
2482
|
+
// The folder target's OWN ledger is not "a repo stream existed": exclude its digest from the unmapped-ledger evidence.
|
|
2483
|
+
const folderDigest = stickyFolder === null ? null : repoKeyDigest(stickyFolder.ledgerKey);
|
|
2484
|
+
const foreignLedger = files.ledgerDigests.some((digest) => digest !== folderDigest && !accountedDigests.has(digest));
|
|
2485
|
+
const noRepoEvidence = !namedAnyRepo && !foreignLedger;
|
|
2486
|
+
if (!evidenced) {
|
|
2487
|
+
// C14: a dialect with no recorded folder has no launch folder, so it never gets a folder target.
|
|
2488
|
+
}
|
|
2489
|
+
else if (stickyFolder !== null) {
|
|
2490
|
+
// ⛔ Re-checked on EVERY return (#5), INCLUDING completeness: a resumed session whose new bytes are not yet scanned gets no
|
|
2491
|
+
// folder target, because it would deliver bytes discovery has not seen. A grant withdrawn, or repo evidence appearing later,
|
|
2492
|
+
// stops it too. A sticky folder does NOT need `end` mode: a Stop or the idle drain may deliver it once the scan is complete.
|
|
2493
|
+
if (complete && foldersKnown && noRepoEvidence && folderStanding(stickyFolder.folder ?? "") === "granted")
|
|
2494
|
+
folderTarget = stickyFolder;
|
|
2495
|
+
}
|
|
2496
|
+
else if (ctx.mode === "end" && complete && foldersKnown && noRepoEvidence && launchFolder !== null) {
|
|
2497
|
+
const real = realOrNull(launchFolder);
|
|
2498
|
+
if (real !== null) {
|
|
2499
|
+
const standing = folderStanding(real);
|
|
2500
|
+
if (standing === "granted") {
|
|
2501
|
+
folderTarget = {
|
|
2502
|
+
kind: "folder",
|
|
2503
|
+
slug: folderSlug(real),
|
|
2504
|
+
ledgerKey: folderLedgerKey(real),
|
|
2505
|
+
redactionRoots: [real],
|
|
2506
|
+
worktrees: [],
|
|
2507
|
+
commonDir: null,
|
|
2508
|
+
folder: real,
|
|
2509
|
+
setAsideLedgers: [],
|
|
2510
|
+
};
|
|
2511
|
+
}
|
|
2512
|
+
else if (standing === "undecided") {
|
|
2513
|
+
undecided.push({ kind: "folder", name: real, at: ctx.nowMs });
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
if (folderTarget !== null) {
|
|
2518
|
+
keep.push(folderTarget);
|
|
2519
|
+
targets.push(folderTarget);
|
|
2520
|
+
}
|
|
2521
|
+
else if (stickyFolder !== null) {
|
|
2522
|
+
// ⛔ A sticky folder that is WITHHELD this firing (incomplete scan, consent withdrawn) stays in the record, exactly as a repo target
|
|
2523
|
+
// whose consent was withdrawn does (C17 #5): dropping it would make its own ledger read as a foreign one and the folder could
|
|
2524
|
+
// never come back.
|
|
2525
|
+
keep.push(stickyFolder);
|
|
2526
|
+
}
|
|
2527
|
+
// C40 (1): an entry is removed once EVERY copy of the session is past its result: the smallest sent offset of the stream over the targets that
|
|
2528
|
+
// can still send. No such target (none yet) prunes nothing.
|
|
2529
|
+
const live = keep.filter((t) => record0.stopped[stopKeyOf(t)] === undefined);
|
|
2530
|
+
if (live.length > 0) {
|
|
2531
|
+
const ledgers = live.map((t) => loadSessionState(ctx.home, { repoKey: t.ledgerKey, sessionId: ctx.sessionId }));
|
|
2532
|
+
for (const [fileKey, log] of Object.entries(calls)) {
|
|
2533
|
+
calls[fileKey] = pruneCalls(log, Math.min(...ledgers.map((ledger) => fileState(ledger, fileKey).sentOffset)));
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
const turnEndInfo = turnEndRoot === null ? undefined : roots[turnEndRoot];
|
|
2537
|
+
const turnEndSlug = turnEndInfo?.state === "repo" ? normalizeSlug(turnEndInfo.slug) : null;
|
|
2538
|
+
const record = {
|
|
2539
|
+
v: 1,
|
|
2540
|
+
sessionId: ctx.sessionId,
|
|
2541
|
+
launchFolder,
|
|
2542
|
+
scan,
|
|
2543
|
+
calls,
|
|
2544
|
+
folders,
|
|
2545
|
+
...(folderUnknown ? { folderUnknown: true } : {}),
|
|
2546
|
+
oversize,
|
|
2547
|
+
failures,
|
|
2548
|
+
roots,
|
|
2549
|
+
unresolved,
|
|
2550
|
+
ledgerOnly: [...ledgerOnly],
|
|
2551
|
+
targets: keep,
|
|
2552
|
+
undecided,
|
|
2553
|
+
stopped: record0.stopped,
|
|
2554
|
+
settled: exhausted,
|
|
2555
|
+
transcriptPath: ctx.transcriptPath,
|
|
2556
|
+
firedAt: ctx.drain === true ? record0.firedAt : ctx.nowMs,
|
|
2557
|
+
updatedAt: ctx.nowMs,
|
|
2558
|
+
};
|
|
2559
|
+
// ⛔ A record that cannot be persisted cannot make a NEW target sticky (#11): its ledger key would be re-chosen next firing,
|
|
2560
|
+
// possibly differently. New targets defer; sticky ones still deliver. The caller is told.
|
|
2561
|
+
// C35 (ii): and one right before the save, the resolver's only write.
|
|
2562
|
+
if (ctx.heartbeat !== undefined && !ctx.heartbeat())
|
|
2563
|
+
throw new LockLostError();
|
|
2564
|
+
const persisted = saveTargetRecord(ctx.home, record);
|
|
2565
|
+
const deliverable = (persisted
|
|
2566
|
+
? targets
|
|
2567
|
+
: targets.filter((t) => (t.kind === "repo" ? !newKeys.has(t.groupKey ?? normalizeSlug(t.slug)) : stickyFolder !== null))).filter((t) => record0.stopped[stopKeyOf(t)] === undefined);
|
|
2568
|
+
return { targets: deliverable, undecided, complete, settled: exhausted, persisted, turnEndSlug, record };
|
|
2569
|
+
}
|
|
2570
|
+
/** The key a target is stopped under (`TargetRecord.stopped`): its repo group, or its folder slug. */
|
|
2571
|
+
export function stopKeyOf(target) {
|
|
2572
|
+
return target.kind === "repo" ? (target.groupKey ?? normalizeSlug(target.slug)) : target.slug;
|
|
2573
|
+
}
|
|
2574
|
+
/**
|
|
2575
|
+
* Stop ONE target for the rest of the session (C3: `folder_repository_forbidden`). Sticky, per target, never credential-wide;
|
|
2576
|
+
* `status` names it. Returns false when the record could not be written (the target then retries and is refused again, which
|
|
2577
|
+
* costs a request, not a byte).
|
|
2578
|
+
*/
|
|
2579
|
+
export function stopTarget(home, sessionId, target, code, nowMs) {
|
|
2580
|
+
const record = loadTargetRecord(home, sessionId);
|
|
2581
|
+
if (record === null)
|
|
2582
|
+
return false;
|
|
2583
|
+
const key = stopKeyOf(target);
|
|
2584
|
+
return saveTargetRecord(home, { ...record, stopped: { ...record.stopped, [key]: { at: nowMs, code } } });
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* Whether two spellings name one transcript file. finalize, backfill and the drain build the path their own way, so a symlinked
|
|
2588
|
+
* home or a trailing component must not read as "a different transcript" (that would reset the record on every switch). Compared
|
|
2589
|
+
* by realpath; a side that is gone falls back to the string compare.
|
|
2590
|
+
*/
|
|
2591
|
+
function sameTranscript(a, b) {
|
|
2592
|
+
if (a === b)
|
|
2593
|
+
return true;
|
|
2594
|
+
const ra = realOrNull(a);
|
|
2595
|
+
const rb = realOrNull(b);
|
|
2596
|
+
return ra !== null && rb !== null && ra === rb;
|
|
2597
|
+
}
|
|
2598
|
+
function realOrNull(path) {
|
|
2599
|
+
// C71 (3): the one resolver (a path that does not exist is null).
|
|
2600
|
+
return physicalPath(path, "fail");
|
|
2601
|
+
}
|
|
2602
|
+
//# sourceMappingURL=session_targets.js.map
|