@vibe-commit/capture 0.8.0 → 0.9.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 +124 -0
- 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 +450 -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/consent.d.ts +22 -0
- package/dist/consent.js +38 -1
- package/dist/consent.js.map +1 -1
- package/dist/copy/strings.d.ts +43 -1
- package/dist/copy/strings.js +48 -4
- 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 +593 -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/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 +14 -0
- package/dist/post.js +32 -10
- 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 +12 -0
- package/dist/spool.js +29 -1
- 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/package.json +3 -3
|
@@ -36,17 +36,21 @@ import { join } from "node:path";
|
|
|
36
36
|
import { DEFAULT_AGENT_ID, admitsTranscript, transcriptRoots } from "../agents/registry.js";
|
|
37
37
|
import { BACKFILL, COMMANDS, ERRORS, FINALIZE } from "../copy/index.js";
|
|
38
38
|
import { renderVerbHelp } from "../verb_help.js";
|
|
39
|
-
import { isCaptureAllowed } from "../consent.js";
|
|
39
|
+
import { hasAnyConsent, isCaptureAllowed } from "../consent.js";
|
|
40
40
|
import { authCommandFor, channelCommand, connectCommandFor } from "../install.js";
|
|
41
41
|
import { loadCredential } from "../credential.js";
|
|
42
42
|
import { EXIT } from "../exit.js";
|
|
43
|
-
import { headRef, onceRepoSlug } from "../git.js";
|
|
43
|
+
import { headRef, onceRepoSlug, onceStrictRepoSlug } from "../git.js";
|
|
44
44
|
import { confinementRoots, deliverSubagents, fileSize, readSpan, recordCutter, sendTimeoutMs, wait } from "../hooks/entry.js";
|
|
45
|
+
import { STALE_MARGIN_MS, claimSessionWriter } from "../hooks/fire_lock.js";
|
|
46
|
+
import { FOLDER_REPOSITORY_FORBIDDEN } from "../policy.js";
|
|
47
|
+
import { LockLostError, dialectIsolates, isolationOf, pastCursorOf, resolveTargets, stopKeyOf, stopTarget } from "../session_targets.js";
|
|
45
48
|
import { repoSessionsDir, sessionStatePath } from "../paths.js";
|
|
46
49
|
import { deliver, ingestUrl } from "../post.js";
|
|
47
50
|
import { resolveProjectKeys } from "../project.js";
|
|
48
|
-
import {
|
|
49
|
-
import { fileState, loadSessionState,
|
|
51
|
+
import { deleteInferredSpool, inferredFullyResolved } from "../spool.js";
|
|
52
|
+
import { fileState, loadSessionState, pruneDeliveredEndHold } from "../state.js";
|
|
53
|
+
import { readSpoolUnion } from "../spool_union.js";
|
|
50
54
|
import { renderErrorBlock, wrap } from "../term.js";
|
|
51
55
|
import { notAGitRepoBlock, writeFailure, writeLines } from "./context.js";
|
|
52
56
|
import { credentialProblem } from "./credential_problem.js";
|
|
@@ -96,7 +100,141 @@ function parseArgs(argv) {
|
|
|
96
100
|
return null; // exactly one of the two
|
|
97
101
|
return { session, latest, force };
|
|
98
102
|
}
|
|
103
|
+
/** How long the verb waits for another writer of the same session (a hook firing) before it says so and stops. */
|
|
104
|
+
const WRITER_WAIT_MS = 30_000;
|
|
105
|
+
/**
|
|
106
|
+
* ⛔ HOW LONG THE VERB'S WRITER LOCK MAY SIT UNTOUCHED before another writer may take it from a holder presumed dead (C25).
|
|
107
|
+
*
|
|
108
|
+
* The verb HEARTBEATS the lock (`FireClaim.refresh`, same nonce) before every request, every delegated file, every target and every
|
|
109
|
+
* root the resolver names, so what matters is the LONGEST GAP between two heartbeats, not the length of the job. That gap is one
|
|
110
|
+
* request (`sendTimeoutMs(SEND_BUDGET_MS, 0)` = 36 s at most) or one root's naming (a few git calls at 5 s each, one ssh at 2 s),
|
|
111
|
+
* plus the local read, redaction and compression of one delta: well under a minute. The bound is TWICE the request gap plus the
|
|
112
|
+
* hook margin (76 s): slack for a slow disk without leaving a dead holder's lock in the way for long. It is stamped into the lock
|
|
113
|
+
* (C18 #2), so a hook contender honours it instead of its own seven seconds, and `fire_lock.ts` caps it at ten minutes whatever
|
|
114
|
+
* a file claims. It used to be the whole send budget plus the margin (62 s) with NO heartbeat, so a session that took longer than
|
|
115
|
+
* that, a long backfill of a large transcript, was reclaimed while its holder was still sending, and two writers ran at once.
|
|
116
|
+
*/
|
|
117
|
+
function holderStaleMs() {
|
|
118
|
+
return 2 * (sendTimeoutMs(SEND_BUDGET_MS, 0) + STALE_MARGIN_MS);
|
|
119
|
+
}
|
|
120
|
+
/** Resolver calls one `finalize` makes while discovery is still moving (each reads a bounded window). */
|
|
121
|
+
/**
|
|
122
|
+
* A SAFETY bound on the interactive resolver loop, not a limit anyone should meet (C18 #11). The loop ends when discovery is settled or
|
|
123
|
+
* a round changes nothing the resolver keeps; every round either advances a scan cursor, resolves a root, or counts a bounded failure,
|
|
124
|
+
* so it is finite. A fixed 16 meant a transcript naming more than 128 repositories returned unsettled and uploaded only some targets.
|
|
125
|
+
*/
|
|
126
|
+
const RESOLVE_ROUNDS_MAX = 100_000;
|
|
127
|
+
/**
|
|
128
|
+
* Take the session's one writer lock (O5) and resolve every target the session names, as a session that is OVER (`end` mode, the
|
|
129
|
+
* only mode that may decide a folder target). The lock is released by the caller's `releases`, after the sends. Shared by
|
|
130
|
+
* `finalize` and `backfill`: one resolver loop, so a session is judged the same whichever verb closes it.
|
|
131
|
+
* `busy`: another writer held the session for the whole wait.
|
|
132
|
+
*/
|
|
133
|
+
export async function resolveSessionTargets(env, sessionId, transcriptPath, known, releases) {
|
|
134
|
+
const claim = await claimSessionWriter(env.home, sessionId, {
|
|
135
|
+
staleAfterMs: holderStaleMs(),
|
|
136
|
+
deadlineAtMs: Date.now() + WRITER_WAIT_MS,
|
|
137
|
+
wait,
|
|
138
|
+
});
|
|
139
|
+
if (claim === null)
|
|
140
|
+
return { kind: "busy" };
|
|
141
|
+
releases.push(() => claim.release());
|
|
142
|
+
const heartbeat = () => claim.refresh();
|
|
143
|
+
const base = {
|
|
144
|
+
home: env.home,
|
|
145
|
+
env: env.env,
|
|
146
|
+
sessionId,
|
|
147
|
+
transcriptPath,
|
|
148
|
+
turnEndCwd: env.turnEndCwd,
|
|
149
|
+
mode: "end",
|
|
150
|
+
nowMs: env.nowMs,
|
|
151
|
+
heartbeat,
|
|
152
|
+
...(known === null ? {} : { known }),
|
|
153
|
+
};
|
|
154
|
+
// Each call reads one bounded window AND resolves at most a bounded number of roots, so a fully-scanned transcript can still have
|
|
155
|
+
// a queue of unresolved roots (the granted repo may be the last of twenty). Keep going until discovery is SETTLED, and stop
|
|
156
|
+
// only when a call changed nothing the resolver keeps: not the cursors, not the queue, not the roots, not the failure counts.
|
|
157
|
+
//
|
|
158
|
+
// ⛔ The queue's ORDER is not progress (C21 iv): failed roots rotate to the back, so a round in which every tried root fails again
|
|
159
|
+
// changes only the order. `unresolved` is compared as a SET, and a root's failure counts once per invocation (`counted`), so each
|
|
160
|
+
// round either moves a cursor, resolves a root, counts a NEW failure, or the loop stops. The record keeps its rotated order.
|
|
161
|
+
const progress = (r) => JSON.stringify([
|
|
162
|
+
r.record.scan,
|
|
163
|
+
[...r.record.unresolved].sort(),
|
|
164
|
+
Object.keys(r.record.roots),
|
|
165
|
+
r.record.failures,
|
|
166
|
+
r.record.oversize,
|
|
167
|
+
r.targets.length,
|
|
168
|
+
]);
|
|
169
|
+
const counted = new Set();
|
|
170
|
+
const round0 = { ...base, countedThisInvocation: counted };
|
|
171
|
+
let res;
|
|
172
|
+
try {
|
|
173
|
+
res = resolveTargets(round0);
|
|
174
|
+
for (let round = 1; round < RESOLVE_ROUNDS_MAX && !res.settled; round += 1) {
|
|
175
|
+
if (!heartbeat())
|
|
176
|
+
return { kind: "busy" };
|
|
177
|
+
const next = resolveTargets(round0);
|
|
178
|
+
const same = progress(next) === progress(res);
|
|
179
|
+
res = next;
|
|
180
|
+
if (same)
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
// C25: the lock was lost while naming a root, before the resolver wrote anything. Another writer has the session: stop.
|
|
186
|
+
if (error instanceof LockLostError)
|
|
187
|
+
return { kind: "busy" };
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
const record = res.record;
|
|
191
|
+
const targets = res.targets.filter((target) => record.stopped[stopKeyOf(target)] === undefined);
|
|
192
|
+
return {
|
|
193
|
+
kind: "ok",
|
|
194
|
+
targets,
|
|
195
|
+
scan: Object.fromEntries(Object.entries(record.scan).map(([fileKey, cursor]) => [fileKey, cursor.offset])),
|
|
196
|
+
record,
|
|
197
|
+
capRepos: dialectIsolates(env.home, env.env, transcriptPath),
|
|
198
|
+
heartbeat,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/** What `closeSession` is told for one resolved target: the repo (or folder) and everything `closeSession` needs about it. */
|
|
202
|
+
export function closeSpecOf(target, scan, facts) {
|
|
203
|
+
return specOf(target, scan, facts);
|
|
204
|
+
}
|
|
205
|
+
function specOf(target, scan, facts) {
|
|
206
|
+
// C43: a copy delivered from the start of a stream whose call checkpoint has been pruned (a repo granted after the others were sent) cannot judge a
|
|
207
|
+
// result by the log, so a repo copy of an isolating dialect observes its own delivered bytes in order (the same observer the hook uses past the cursor).
|
|
208
|
+
const base = isolationOf(target, facts?.record);
|
|
209
|
+
const isolation = facts?.capRepos === true && facts.record !== undefined ? { ...base, past: pastCursorOf(base, scan) } : base;
|
|
210
|
+
return {
|
|
211
|
+
projectKey: target.ledgerKey,
|
|
212
|
+
redactionRoots: target.redactionRoots,
|
|
213
|
+
repoSlug: target.slug,
|
|
214
|
+
worktrees: target.worktrees.length > 0 ? target.worktrees : [target.ledgerKey],
|
|
215
|
+
allowNeverCaptured: true,
|
|
216
|
+
...(target.kind === "folder" || facts?.capRepos === true ? { sizeCap: scan } : {}),
|
|
217
|
+
isolation,
|
|
218
|
+
target,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
99
221
|
export async function finalize(ctx, argv) {
|
|
222
|
+
const releases = [];
|
|
223
|
+
try {
|
|
224
|
+
return await finalizeBody(ctx, argv, releases);
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
for (const release of releases) {
|
|
228
|
+
try {
|
|
229
|
+
release();
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// A lock that cannot be released ages out by itself.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
async function finalizeBody(ctx, argv, releases) {
|
|
100
238
|
// `TODOS[184]`. Check-first, before `parseArgs`/anything else — the ONE change this ticket makes to
|
|
101
239
|
// `finalize`. Reads and writes nothing; every other line below (`parseArgs`, session resolution, the
|
|
102
240
|
// seal call, the drain loop, every exit code and receipt string) is byte-identical to 0.4.4 for any
|
|
@@ -118,24 +256,106 @@ export async function finalize(ctx, argv) {
|
|
|
118
256
|
}, ctx.colour));
|
|
119
257
|
return EXIT.usage;
|
|
120
258
|
}
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
writeFailure(ctx, notAGitRepoBlock(ctx));
|
|
124
|
-
return EXIT.failure;
|
|
125
|
-
}
|
|
126
|
-
const projectKey = keys.worktree;
|
|
127
|
-
const slugOf = onceRepoSlug(projectKey);
|
|
128
|
-
if (!isCaptureAllowed(ctx.home, keys, slugOf)) {
|
|
129
|
-
// "This repository is not allowed to be captured": the repo's own consent, not the machine's credential.
|
|
259
|
+
const nowMs = ctx.now().getTime();
|
|
260
|
+
const stillWriting = (sessionId) => {
|
|
130
261
|
writeLines(ctx.stderr, renderErrorBlock({
|
|
131
262
|
kind: "bad",
|
|
132
|
-
code: "
|
|
133
|
-
what:
|
|
263
|
+
code: "FINALIZE_STILL_WRITING",
|
|
264
|
+
what: FINALIZE.stillWriting,
|
|
134
265
|
why: [],
|
|
135
|
-
fixLabel:
|
|
136
|
-
fixes: [
|
|
266
|
+
fixLabel: FINALIZE.stillWritingFix,
|
|
267
|
+
fixes: [channelCommand(ctx.env, ctx.selfPath, FINALIZE.commandSessionForce(sessionId))],
|
|
137
268
|
}, ctx.colour));
|
|
138
|
-
return EXIT.
|
|
269
|
+
return EXIT.usage;
|
|
270
|
+
};
|
|
271
|
+
// ANY-FOLDER (A-T3): at the three places this verb used to refuse because the folder it ran in is not the session's repo, a
|
|
272
|
+
// `--session <id>` whose transcript exists is resolved by the SAME resolver the hook uses. Only `--session`: `--latest` names a
|
|
273
|
+
// session through THIS repo's ledgers, so it has nothing to look up elsewhere. No targets (or no transcript) leaves the
|
|
274
|
+
// ORIGINAL refusal and its exit code exactly as they were.
|
|
275
|
+
// C25: the heartbeat of the session lock `resolveSessionTargets` took, handed to every `closeSession`. Absent only when no resolver
|
|
276
|
+
// ran (a plain in-repo close of a session picked by `--latest`), which holds no session lock to keep.
|
|
277
|
+
let heartbeat;
|
|
278
|
+
const prepareAnyFolder = async () => {
|
|
279
|
+
if (args.session === null || !SESSION_ID.test(args.session))
|
|
280
|
+
return { kind: "none" };
|
|
281
|
+
const sessionId = args.session;
|
|
282
|
+
// ⛔ NOTHING IS READ FOR A MACHINE THAT HAS GRANTED NOTHING (C18 #5): the resolver reads the transcript for evidence, so the same
|
|
283
|
+
// gate the hook has comes first, before any transcript is looked for or opened and before any state is written.
|
|
284
|
+
if (!hasAnyConsent(ctx.home))
|
|
285
|
+
return { kind: "none" };
|
|
286
|
+
const transcriptPath = findTranscript(ctx.home, ctx.env, sessionId);
|
|
287
|
+
if (transcriptPath === null)
|
|
288
|
+
return { kind: "none" };
|
|
289
|
+
try {
|
|
290
|
+
if (!args.force && nowMs - statSync(transcriptPath).mtimeMs < STILL_WRITING_MS)
|
|
291
|
+
return { kind: "still-writing", sessionId };
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return { kind: "none" };
|
|
295
|
+
}
|
|
296
|
+
const eof = fileSize(transcriptPath);
|
|
297
|
+
if (eof === null)
|
|
298
|
+
return { kind: "none" };
|
|
299
|
+
// ⛔ `turnEndCwd` is NULL (C18 #1): the CLI's cwd is where the person TYPED this, not where the session ended. Passing it made
|
|
300
|
+
// `finalize --session S` run inside granted repo B create a B target for a session that never touched B. Which repos a session
|
|
301
|
+
// belongs to comes from the session's own records only; the cwd repo's own close is the ledger-gated branch below.
|
|
302
|
+
const resolved = await resolveSessionTargets({ home: ctx.home, env: ctx.env, nowMs: ctx.now().getTime(), turnEndCwd: null }, sessionId, transcriptPath, null, releases);
|
|
303
|
+
if (resolved.kind === "busy")
|
|
304
|
+
return { kind: "busy" };
|
|
305
|
+
heartbeat = resolved.heartbeat;
|
|
306
|
+
if (resolved.targets.length === 0)
|
|
307
|
+
return { kind: "none" };
|
|
308
|
+
return { kind: "ready", sessionId, transcriptPath, eof, specs: resolved.targets.map((target) => specOf(target, resolved.scan, resolved)) };
|
|
309
|
+
};
|
|
310
|
+
const busyBlock = () => {
|
|
311
|
+
writeLines(ctx.stderr, renderErrorBlock({
|
|
312
|
+
kind: "bad",
|
|
313
|
+
code: "DELIVERY_FAILED",
|
|
314
|
+
what: FINALIZE.notDelivered,
|
|
315
|
+
why: [],
|
|
316
|
+
fixLabel: FINALIZE.retryFix,
|
|
317
|
+
fixes: [channelCommand(ctx.env, ctx.selfPath, args.session === null ? FINALIZE.commandSessionAny : FINALIZE.commandSession(args.session))],
|
|
318
|
+
}, ctx.colour));
|
|
319
|
+
return EXIT.failure;
|
|
320
|
+
};
|
|
321
|
+
const refuseAnyFolder = (prepared) => {
|
|
322
|
+
if (prepared.kind === "still-writing")
|
|
323
|
+
return stillWriting(prepared.sessionId);
|
|
324
|
+
if (prepared.kind === "busy")
|
|
325
|
+
return busyBlock();
|
|
326
|
+
return null;
|
|
327
|
+
};
|
|
328
|
+
let anyFolder = { kind: "none" };
|
|
329
|
+
const keys = resolveProjectKeys(ctx.cwd);
|
|
330
|
+
if (keys === null) {
|
|
331
|
+
anyFolder = await prepareAnyFolder();
|
|
332
|
+
const refused = refuseAnyFolder(anyFolder);
|
|
333
|
+
if (refused !== null)
|
|
334
|
+
return refused;
|
|
335
|
+
if (anyFolder.kind !== "ready") {
|
|
336
|
+
writeFailure(ctx, notAGitRepoBlock(ctx));
|
|
337
|
+
return EXIT.failure;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const projectKey = keys === null ? null : keys.worktree;
|
|
341
|
+
const slugOf = projectKey === null ? null : onceRepoSlug(projectKey);
|
|
342
|
+
if (keys !== null && slugOf !== null && !isCaptureAllowed(ctx.home, keys, slugOf)) {
|
|
343
|
+
anyFolder = await prepareAnyFolder();
|
|
344
|
+
const refused = refuseAnyFolder(anyFolder);
|
|
345
|
+
if (refused !== null)
|
|
346
|
+
return refused;
|
|
347
|
+
if (anyFolder.kind !== "ready") {
|
|
348
|
+
// "This repository is not allowed to be captured": the repo's own consent, not the machine's credential.
|
|
349
|
+
writeLines(ctx.stderr, renderErrorBlock({
|
|
350
|
+
kind: "bad",
|
|
351
|
+
code: "NOT_CONNECTED",
|
|
352
|
+
what: ERRORS.notConnected,
|
|
353
|
+
why: [],
|
|
354
|
+
fixLabel: ERRORS.notConnectedFix,
|
|
355
|
+
fixes: [connectCommandFor(ctx.env, ctx.selfPath)],
|
|
356
|
+
}, ctx.colour));
|
|
357
|
+
return EXIT.notConnected;
|
|
358
|
+
}
|
|
139
359
|
}
|
|
140
360
|
const load = loadCredential({ env: ctx.env, home: ctx.home });
|
|
141
361
|
if (load.kind !== "ok") {
|
|
@@ -160,94 +380,173 @@ export async function finalize(ctx, argv) {
|
|
|
160
380
|
return EXIT.failure;
|
|
161
381
|
}
|
|
162
382
|
// ── Which session. ──
|
|
163
|
-
const nowMs = ctx.now().getTime();
|
|
164
383
|
let sessionId;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
sessionId = args.session;
|
|
384
|
+
let transcriptPath;
|
|
385
|
+
let eof;
|
|
386
|
+
let specs;
|
|
387
|
+
if (anyFolder.kind === "ready") {
|
|
388
|
+
({ sessionId, transcriptPath, eof, specs } = anyFolder);
|
|
171
389
|
}
|
|
172
390
|
else {
|
|
173
|
-
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
what: FINALIZE.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}, ctx.colour));
|
|
183
|
-
return EXIT.usage;
|
|
391
|
+
// Here the cwd IS an allowed repo (the two gates above passed), so `keys`, `projectKey` and `slugOf` exist.
|
|
392
|
+
if (keys === null || projectKey === null || slugOf === null)
|
|
393
|
+
return EXIT.failure;
|
|
394
|
+
if (args.session !== null) {
|
|
395
|
+
if (!SESSION_ID.test(args.session)) {
|
|
396
|
+
writeLines(ctx.stderr, renderErrorBlock({ kind: "bad", code: "FINALIZE_BAD_SESSION_ID", what: FINALIZE.badSessionId, why: [] }, ctx.colour));
|
|
397
|
+
return EXIT.usage;
|
|
398
|
+
}
|
|
399
|
+
sessionId = args.session;
|
|
184
400
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
401
|
+
else {
|
|
402
|
+
const recent = recentSessions(ctx.home, projectKey, nowMs);
|
|
403
|
+
if (recent.length === 0) {
|
|
404
|
+
writeLines(ctx.stderr, renderErrorBlock({
|
|
405
|
+
kind: "bad",
|
|
406
|
+
code: "FINALIZE_NO_RECENT_SESSION",
|
|
407
|
+
what: FINALIZE.noRecentSession,
|
|
408
|
+
why: [],
|
|
409
|
+
fixLabel: FINALIZE.noRecentSessionFix,
|
|
410
|
+
fixes: [channelCommand(ctx.env, ctx.selfPath, FINALIZE.commandSessionAny)],
|
|
411
|
+
}, ctx.colour));
|
|
412
|
+
return EXIT.usage;
|
|
413
|
+
}
|
|
414
|
+
if (recent.length > 1) {
|
|
415
|
+
writeLines(ctx.stderr, renderErrorBlock({
|
|
416
|
+
kind: "bad",
|
|
417
|
+
code: "FINALIZE_SEVERAL_RECENT",
|
|
418
|
+
what: FINALIZE.severalRecent,
|
|
419
|
+
why: recent.map((r) => FINALIZE.recentRow(r.id, Math.round((nowMs - r.at) / 60000))),
|
|
420
|
+
fixLabel: FINALIZE.severalRecentFix,
|
|
421
|
+
fixes: [channelCommand(ctx.env, ctx.selfPath, FINALIZE.commandSessionAny)],
|
|
422
|
+
}, ctx.colour));
|
|
423
|
+
return EXIT.usage;
|
|
424
|
+
}
|
|
425
|
+
sessionId = recent[0].id;
|
|
195
426
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
why: [],
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
427
|
+
// ── The session must be one THIS repo captured (it has a state file here) and its transcript must be findable inside the agent's own root. ──
|
|
428
|
+
const state = sessionStatePath(ctx.home, { repoKey: projectKey, sessionId });
|
|
429
|
+
if (state === null || !existsSync(state)) {
|
|
430
|
+
// A session this folder's repo never captured may still belong to other repos it named (any-folder capture).
|
|
431
|
+
anyFolder = await prepareAnyFolder();
|
|
432
|
+
const refused = refuseAnyFolder(anyFolder);
|
|
433
|
+
if (refused !== null)
|
|
434
|
+
return refused;
|
|
435
|
+
if (anyFolder.kind !== "ready") {
|
|
436
|
+
writeLines(ctx.stderr, renderErrorBlock({ kind: "bad", code: "FINALIZE_UNKNOWN_SESSION", what: FINALIZE.unknownSession(sessionId), why: [] }, ctx.colour));
|
|
437
|
+
return EXIT.usage;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (anyFolder.kind === "ready") {
|
|
441
|
+
({ transcriptPath, eof, specs } = anyFolder);
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
const found = findTranscript(ctx.home, ctx.env, sessionId);
|
|
445
|
+
if (found === null) {
|
|
446
|
+
writeLines(ctx.stderr, renderErrorBlock({ kind: "bad", code: "FINALIZE_TRANSCRIPT_MISSING", what: FINALIZE.transcriptMissing(sessionId), why: [] }, ctx.colour));
|
|
447
|
+
return EXIT.failure;
|
|
448
|
+
}
|
|
449
|
+
transcriptPath = found;
|
|
450
|
+
try {
|
|
451
|
+
if (!args.force && nowMs - statSync(transcriptPath).mtimeMs < STILL_WRITING_MS)
|
|
452
|
+
return stillWriting(sessionId);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
return EXIT.failure;
|
|
456
|
+
}
|
|
457
|
+
const size = fileSize(transcriptPath);
|
|
458
|
+
if (size === null)
|
|
459
|
+
return EXIT.failure;
|
|
460
|
+
eof = size;
|
|
461
|
+
// The session's other repos (parent-folder turns touched them too): resolved by the one resolver, under the one session lock.
|
|
462
|
+
// This repo's own ledger target is closed exactly as before; a resolved target for the same repo REPLACES it (the hook's own
|
|
463
|
+
// ledger choice), and a resolver that finds nothing leaves exactly today's single target.
|
|
464
|
+
const resolved = await resolveSessionTargets({ home: ctx.home, env: ctx.env, nowMs: ctx.now().getTime(), turnEndCwd: null }, sessionId, transcriptPath, { keys, slugOf: onceStrictRepoSlug(keys.worktree) }, releases);
|
|
465
|
+
if (resolved.kind === "busy")
|
|
466
|
+
return busyBlock();
|
|
467
|
+
heartbeat = resolved.heartbeat;
|
|
468
|
+
const own = resolved.targets.find((target) => target.worktrees.includes(projectKey) || target.ledgerKey === projectKey);
|
|
469
|
+
const manual = {
|
|
470
|
+
projectKey,
|
|
471
|
+
redactionRoots: confinementRoots(keys),
|
|
472
|
+
repoSlug: slugOf(),
|
|
473
|
+
worktrees: [projectKey],
|
|
474
|
+
allowNeverCaptured: false,
|
|
475
|
+
target: null,
|
|
476
|
+
};
|
|
477
|
+
specs = [
|
|
478
|
+
own === undefined ? manual : specOf(own, resolved.scan, resolved),
|
|
479
|
+
...resolved.targets.filter((target) => target !== own).map((target) => specOf(target, resolved.scan, resolved)),
|
|
480
|
+
];
|
|
220
481
|
}
|
|
221
482
|
}
|
|
222
|
-
catch {
|
|
223
|
-
return EXIT.failure;
|
|
224
|
-
}
|
|
225
|
-
const eof = fileSize(transcriptPath);
|
|
226
|
-
if (eof === null)
|
|
227
|
-
return EXIT.failure;
|
|
228
483
|
// ── Deliver: promote what was pending, then sub-agents FIRST, then the main stream with the assertion. ──
|
|
229
484
|
// ⛔ NO PENDING-LINE PROMOTION HERE, deliberately. Every hook fire of this session already ran it (`hooks/entry.ts`), and
|
|
230
485
|
// the session necessarily has a state file (required above), so a first-turn commit was promoted by the first
|
|
231
486
|
// `Stop` that wrote one. Promoting from this verb would add a second call site to a function whose only call site
|
|
232
487
|
// is a pinned property (`post-commit-spool.test.ts`, D208): a dead session must never reach it.
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
488
|
+
const outcomes = [];
|
|
489
|
+
// ⛔ LEAST RECENTLY SENT FIRST (C18 #8), as the hook orders its targets: resolver order is discovery order, and a session with many
|
|
490
|
+
// targets would always serve the same first ones if the credential or the clock ran out part-way. Never-sent targets (0) go first.
|
|
491
|
+
const lastSentAt = (spec) => {
|
|
492
|
+
const state = loadSessionState(ctx.home, { repoKey: spec.projectKey, sessionId });
|
|
493
|
+
return Math.max(0, ...Object.values(state.files).map((file) => file.lastSentAt));
|
|
494
|
+
};
|
|
495
|
+
specs = specs
|
|
496
|
+
.map((spec, index) => ({ spec, index, at: lastSentAt(spec) }))
|
|
497
|
+
.sort((a, b) => a.at - b.at || a.index - b.index)
|
|
498
|
+
.map((entry) => entry.spec);
|
|
499
|
+
for (const spec of specs) {
|
|
500
|
+
const closedOne = await closeSession({
|
|
501
|
+
home: ctx.home,
|
|
502
|
+
env: ctx.env,
|
|
503
|
+
nodeVersion: ctx.nodeVersion,
|
|
504
|
+
agentId: ctx.agentId ?? DEFAULT_AGENT_ID,
|
|
505
|
+
cwd: ctx.cwd,
|
|
506
|
+
url,
|
|
507
|
+
credential: load.credential,
|
|
508
|
+
projectKey: spec.projectKey,
|
|
509
|
+
redactionRoots: spec.redactionRoots,
|
|
510
|
+
repoSlug: spec.repoSlug,
|
|
511
|
+
sessionId,
|
|
512
|
+
transcriptPath,
|
|
513
|
+
eof: spec.sizeCap === undefined ? eof : Math.min(eof, spec.sizeCap.main ?? 0),
|
|
514
|
+
seal: true,
|
|
515
|
+
allowNeverCaptured: spec.allowNeverCaptured,
|
|
516
|
+
completeSubagents: false,
|
|
517
|
+
worktrees: spec.worktrees,
|
|
518
|
+
...(spec.sizeCap === undefined ? {} : { sizeCap: spec.sizeCap }),
|
|
519
|
+
...(spec.isolation === undefined ? {} : { isolation: spec.isolation }),
|
|
520
|
+
...(heartbeat === undefined ? {} : { heartbeat }),
|
|
521
|
+
});
|
|
522
|
+
// C25: the session's lock was taken from us. Stop BEFORE the next target's write; this outcome is the verb's answer.
|
|
523
|
+
if (closedOne.kind === "lock-lost") {
|
|
524
|
+
outcomes.push(closedOne);
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
// C3: the server refused THIS target for good. It is stopped (recorded, never retried), and it does not fail the others.
|
|
528
|
+
if (spec.target !== null &&
|
|
529
|
+
"delivery" in closedOne &&
|
|
530
|
+
closedOne.delivery.kind === "attempted" &&
|
|
531
|
+
closedOne.delivery.detail?.code === FOLDER_REPOSITORY_FORBIDDEN) {
|
|
532
|
+
// C35 (iii): recording the stop is a WRITE, so only while the session is still ours. A lost lock reports the refusal and leaves it
|
|
533
|
+
// for the next holder, who meets the same 403.
|
|
534
|
+
if (heartbeat === undefined || heartbeat())
|
|
535
|
+
stopTarget(ctx.home, sessionId, spec.target, FOLDER_REPOSITORY_FORBIDDEN, Date.now());
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
outcomes.push(closedOne);
|
|
539
|
+
if (closedOne.kind === "stopped")
|
|
540
|
+
break; // a fatal class for the credential: no other target can send either
|
|
541
|
+
}
|
|
542
|
+
// ONE answer: the first target that did not close cleanly, else one line for all of them.
|
|
543
|
+
const closed = outcomes.find((outcome) => outcome.kind !== "sealed" && outcome.kind !== "already") ??
|
|
544
|
+
(outcomes.length === 0
|
|
545
|
+
? { kind: "never-captured" }
|
|
546
|
+
: {
|
|
547
|
+
...(outcomes.find((outcome) => outcome.kind === "sealed") ?? outcomes[0]),
|
|
548
|
+
setAsidePairs: outcomes.reduce((sum, outcome) => sum + ("setAsidePairs" in outcome ? outcome.setAsidePairs : 0), 0),
|
|
549
|
+
});
|
|
251
550
|
// ── What the server said. ──
|
|
252
551
|
if (closed.kind === "stopped") {
|
|
253
552
|
writeLines(ctx.stderr, renderErrorBlock({
|
|
@@ -282,7 +581,8 @@ export async function finalize(ctx, argv) {
|
|
|
282
581
|
writeLines(ctx.stderr, renderErrorBlock({ kind: "warn", what: FINALIZE.notAcknowledged, why: [] }, ctx.colour));
|
|
283
582
|
return EXIT.unacknowledged;
|
|
284
583
|
}
|
|
285
|
-
// Anything else did not deliver (`not-delivered
|
|
584
|
+
// Anything else did not deliver (`not-delivered`, or `lock-lost` (C25: another writer took the session; the retry command is the
|
|
585
|
+
// right advice and no new line is needed); `finalize` never asks for the outcomes only a backfill can get).
|
|
286
586
|
writeLines(ctx.stderr, renderErrorBlock({
|
|
287
587
|
kind: "bad",
|
|
288
588
|
code: "DELIVERY_FAILED",
|
|
@@ -332,9 +632,14 @@ export async function closeSession(input) {
|
|
|
332
632
|
if (state === null || !existsSync(state))
|
|
333
633
|
return { kind: "never-captured" };
|
|
334
634
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const
|
|
635
|
+
// ⛔ THE SPOOL UNION (any-folder capture, O7): the commit, rewrite and inferred spools of every work tree of this repo for the
|
|
636
|
+
// session, acknowledged per source. With one work tree this is exactly the three reads it always was.
|
|
637
|
+
const worktrees = input.worktrees ?? [projectKey];
|
|
638
|
+
const isolation = input.isolation ?? { kind: "repo", ownRoots: redactionRoots };
|
|
639
|
+
const union = readSpoolUnion(home, sessionId, projectKey, worktrees);
|
|
640
|
+
const spooled = union.commits;
|
|
641
|
+
const rewritten = union.rewrites;
|
|
642
|
+
const inferred = union.inferred; // `TODOS[177]`
|
|
338
643
|
const hookEnv = {
|
|
339
644
|
env,
|
|
340
645
|
home,
|
|
@@ -344,7 +649,11 @@ export async function closeSession(input) {
|
|
|
344
649
|
};
|
|
345
650
|
const hookInput = { sessionId, transcriptPath, cwd: input.cwd, eventName: "Stop" };
|
|
346
651
|
const budget = input.sendBudgetMs ?? SEND_BUDGET_MS;
|
|
652
|
+
const beat = input.heartbeat;
|
|
653
|
+
const holding = () => beat === undefined || beat();
|
|
347
654
|
let startedAt = Date.now();
|
|
655
|
+
if (!holding())
|
|
656
|
+
return { kind: "lock-lost" };
|
|
348
657
|
if (!input.completeSubagents) {
|
|
349
658
|
await deliverSubagents({
|
|
350
659
|
ctx: hookEnv,
|
|
@@ -357,10 +666,17 @@ export async function closeSession(input) {
|
|
|
357
666
|
startedAt,
|
|
358
667
|
sendBudget: budget,
|
|
359
668
|
byHook: false,
|
|
669
|
+
...(input.sizeCap === undefined ? {} : { sizeCap: input.sizeCap }),
|
|
670
|
+
isolation,
|
|
671
|
+
...(beat === undefined ? {} : { heartbeat: beat }),
|
|
360
672
|
});
|
|
673
|
+
if (!holding())
|
|
674
|
+
return { kind: "lock-lost" };
|
|
361
675
|
}
|
|
362
676
|
else {
|
|
363
677
|
for (let round = 0; round < SUBAGENT_ROUNDS_MAX; round += 1) {
|
|
678
|
+
if (!holding())
|
|
679
|
+
return { kind: "lock-lost" };
|
|
364
680
|
const sent = await deliverSubagents({
|
|
365
681
|
ctx: hookEnv,
|
|
366
682
|
input: hookInput,
|
|
@@ -373,7 +689,12 @@ export async function closeSession(input) {
|
|
|
373
689
|
startedAt: Date.now(),
|
|
374
690
|
sendBudget: budget,
|
|
375
691
|
byHook: false,
|
|
692
|
+
...(input.sizeCap === undefined ? {} : { sizeCap: input.sizeCap }),
|
|
693
|
+
isolation,
|
|
694
|
+
...(beat === undefined ? {} : { heartbeat: beat }),
|
|
376
695
|
});
|
|
696
|
+
if (!holding())
|
|
697
|
+
return { kind: "lock-lost" };
|
|
377
698
|
const blocked = sent.deliveries.find(blocksMain);
|
|
378
699
|
if (blocked !== undefined)
|
|
379
700
|
return { kind: "subagents-held", delivery: blocked };
|
|
@@ -386,6 +707,8 @@ export async function closeSession(input) {
|
|
|
386
707
|
// the clock it always had.
|
|
387
708
|
startedAt = Date.now();
|
|
388
709
|
}
|
|
710
|
+
if (!holding())
|
|
711
|
+
return { kind: "lock-lost" };
|
|
389
712
|
const delivery = await deliver({
|
|
390
713
|
home,
|
|
391
714
|
env,
|
|
@@ -397,17 +720,28 @@ export async function closeSession(input) {
|
|
|
397
720
|
fileKey: "main",
|
|
398
721
|
transcriptPath,
|
|
399
722
|
timeoutMs: sendTimeoutMs(budget, Date.now() - startedAt),
|
|
723
|
+
// ⛔ NO `deadlineAtMs` HERE (C21 #2, retracting C18 #8's deadline): `finalize` and `backfill` are interactive and "set no deadline
|
|
724
|
+
// and run on" (post.ts). A deadline would stop a main stream that needs longer part-way, never send the final request, and
|
|
725
|
+
// report a session 0.8.0 completed as failed.
|
|
400
726
|
nowMs: Date.now(),
|
|
401
727
|
head: headRef(projectKey),
|
|
402
728
|
commits: spooled,
|
|
403
729
|
rewrites: rewritten.pairs,
|
|
404
730
|
inferredRewrites: inferred.pairs,
|
|
405
731
|
inferredGroups: inferred.groups,
|
|
732
|
+
spoolSources: union.sources,
|
|
406
733
|
...(input.seal ? { final: true } : {}),
|
|
407
734
|
byHook: false,
|
|
408
|
-
|
|
735
|
+
...(beat === undefined ? {} : { beforeRequest: beat }),
|
|
736
|
+
}, eof, (from, to) => readSpan(transcriptPath, from, to, redactionRoots, home, env, isolation), recordCutter(transcriptPath));
|
|
409
737
|
if (delivery.kind === "stopped")
|
|
410
738
|
return { kind: "stopped" };
|
|
739
|
+
// C25: `deliver` makes no request once the lock is gone. It answers `nothing-to-send` (which a close that does not seal reads as
|
|
740
|
+
// "already delivered") or, after some deltas went, the last good one. Ask again: a lock that is not ours, with bytes still unsent,
|
|
741
|
+
// is not a delivery. (Everything sent and acknowledged is reported as it happened, whoever holds the session now.)
|
|
742
|
+
if (!holding() && (delivery.kind === "nothing-to-send" || fileState(loadSessionState(home, spoolKey), "main").sentOffset < eof)) {
|
|
743
|
+
return { kind: "lock-lost" };
|
|
744
|
+
}
|
|
411
745
|
if (!input.seal) {
|
|
412
746
|
return delivery.kind === "nothing-to-send" || (delivery.kind === "attempted" && delivery.disposition === "ok")
|
|
413
747
|
? { kind: "delivered", delivery }
|
|
@@ -416,7 +750,9 @@ export async function closeSession(input) {
|
|
|
416
750
|
if (delivery.kind !== "attempted" || delivery.disposition !== "ok")
|
|
417
751
|
return { kind: "not-delivered", delivery };
|
|
418
752
|
if (delivery.finalAck === "sealed" || delivery.finalAck === "already") {
|
|
419
|
-
|
|
753
|
+
// C25: a write, so only while the session is still ours. The close itself DID happen (the server said so), and is reported.
|
|
754
|
+
if (holding())
|
|
755
|
+
clearDeliveredHold(home, spoolKey);
|
|
420
756
|
// ⭐ `TODOS[177]` follow-up (VL, 2026-10-08) — DRAIN what the first delivery's caps left spooled. A session
|
|
421
757
|
// over 16 exact pairs or 64 inferred pairs (one squash of 64 is already the server's own cap; several
|
|
422
758
|
// squashes in one session is what overflows this) would otherwise leave the rest spooled FOREVER: nobody
|
|
@@ -439,8 +775,11 @@ export async function closeSession(input) {
|
|
|
439
775
|
// existing "absent ack, stop asking" guard below never fires either).
|
|
440
776
|
const lean = fileState(loadSessionState(home, spoolKey), "main").lean;
|
|
441
777
|
for (let i = 0; !lean && i < FINALIZE_DRAIN_MAX_ITERATIONS; i += 1) {
|
|
442
|
-
|
|
443
|
-
|
|
778
|
+
if (!holding())
|
|
779
|
+
break;
|
|
780
|
+
const more = readSpoolUnion(home, sessionId, projectKey, worktrees);
|
|
781
|
+
const moreRewritten = more.rewrites;
|
|
782
|
+
const moreInferred = more.inferred;
|
|
444
783
|
if (moreRewritten.pairs.length === 0 && moreInferred.pairs.length === 0)
|
|
445
784
|
break;
|
|
446
785
|
const groupId = moreInferred.groups.length > 0 ? `${moreInferred.groups[0].successor}:${moreInferred.groups[0].patchId}` : null;
|
|
@@ -463,9 +802,11 @@ export async function closeSession(input) {
|
|
|
463
802
|
rewrites: moreRewritten.pairs,
|
|
464
803
|
inferredRewrites: moreInferred.pairs,
|
|
465
804
|
inferredGroups: moreInferred.groups,
|
|
805
|
+
spoolSources: more.sources,
|
|
466
806
|
final: true,
|
|
467
807
|
byHook: false,
|
|
468
|
-
|
|
808
|
+
...(beat === undefined ? {} : { beforeRequest: beat }),
|
|
809
|
+
}, eof, (from, to) => readSpan(transcriptPath, from, to, redactionRoots, home, env, isolation), recordCutter(transcriptPath));
|
|
469
810
|
if (drain.kind !== "attempted" || drain.disposition !== "ok")
|
|
470
811
|
break;
|
|
471
812
|
setAsidePairs += drain.inferredSetAsidePairs;
|
|
@@ -498,8 +839,11 @@ export async function closeSession(input) {
|
|
|
498
839
|
// ⛔⛔ Fine to delete AFTER `setAsidePairs` is computed above, NEVER before (VL, 2026-10-08, VG's silent-loss
|
|
499
840
|
// finding B1): `setAsidePairs` is read from `deliver()`'s own return values — which were captured before this
|
|
500
841
|
// point — not re-derived from the spool afterward, which is the file this line is about to delete.
|
|
501
|
-
|
|
502
|
-
|
|
842
|
+
for (const source of holding() ? [projectKey, ...worktrees.filter((worktree) => worktree !== projectKey)] : []) {
|
|
843
|
+
const sourceKey = { repoKey: source, sessionId };
|
|
844
|
+
if (inferredFullyResolved(home, sourceKey))
|
|
845
|
+
deleteInferredSpool(home, sourceKey);
|
|
846
|
+
}
|
|
503
847
|
return { kind: delivery.finalAck, delivery, setAsidePairs };
|
|
504
848
|
}
|
|
505
849
|
if (delivery.finalAck === "refused")
|
|
@@ -553,14 +897,6 @@ function findTranscript(home, env, sessionId) {
|
|
|
553
897
|
}
|
|
554
898
|
/** After a successful close, forget end-of-session holds whose bytes have now been delivered. */
|
|
555
899
|
function clearDeliveredHold(home, key) {
|
|
556
|
-
|
|
557
|
-
if (session.endHold === null)
|
|
558
|
-
return;
|
|
559
|
-
const eof = {};
|
|
560
|
-
for (const [fileKey, size] of Object.entries(session.endHold.eof)) {
|
|
561
|
-
if (fileState(session, fileKey).sentOffset < size)
|
|
562
|
-
eof[fileKey] = size;
|
|
563
|
-
}
|
|
564
|
-
saveSessionState(home, key, { ...session, endHold: Object.keys(eof).length > 0 ? { at: session.endHold.at, eof } : null });
|
|
900
|
+
pruneDeliveredEndHold(home, key);
|
|
565
901
|
}
|
|
566
902
|
//# sourceMappingURL=finalize.js.map
|