@vibe-commit/capture 0.10.0 → 0.11.1
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 +36 -1
- package/dist/agent_hooks.js +2 -2
- package/dist/cli.d.ts +35 -0
- package/dist/cli.js +412 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/backfill.js +19 -2
- package/dist/commands/backfill.js.map +1 -1
- package/dist/commands/finalize.d.ts +37 -1
- package/dist/commands/finalize.js +129 -29
- package/dist/commands/finalize.js.map +1 -1
- package/dist/commands/status.js +7 -0
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/status_machine.js +5 -0
- package/dist/commands/status_machine.js.map +1 -1
- package/dist/commands/upload_worker.d.ts +65 -0
- package/dist/commands/upload_worker.js +337 -0
- package/dist/commands/upload_worker.js.map +1 -0
- package/dist/copy/strings.d.ts +25 -8
- package/dist/copy/strings.js +43 -9
- package/dist/copy/strings.js.map +1 -1
- package/dist/drain.d.ts +68 -0
- package/dist/drain.js +234 -0
- package/dist/drain.js.map +1 -0
- package/dist/firing.d.ts +169 -0
- package/dist/firing.js +334 -0
- package/dist/firing.js.map +1 -0
- package/dist/hooks/entry.d.ts +171 -26
- package/dist/hooks/entry.js +337 -508
- package/dist/hooks/entry.js.map +1 -1
- package/dist/hooks/fire_lock.d.ts +36 -87
- package/dist/hooks/fire_lock.js +40 -99
- package/dist/hooks/fire_lock.js.map +1 -1
- package/dist/hooks/post_commit.d.ts +2 -0
- package/dist/hooks/post_commit.js +7 -3
- package/dist/hooks/post_commit.js.map +1 -1
- package/dist/hooks/session_start.d.ts +18 -0
- package/dist/hooks/session_start.js +29 -0
- package/dist/hooks/session_start.js.map +1 -1
- package/dist/identity.d.ts +32 -0
- package/dist/identity.js +38 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +3 -17
- package/dist/index.js +59 -450
- package/dist/index.js.map +1 -1
- package/dist/paths.d.ts +18 -0
- package/dist/paths.js +31 -0
- package/dist/paths.js.map +1 -1
- package/dist/pending_prune.d.ts +2 -2
- package/dist/pending_prune.js +2 -2
- package/dist/policy.d.ts +17 -0
- package/dist/policy.js +7 -0
- package/dist/policy.js.map +1 -1
- package/dist/post.d.ts +52 -4
- package/dist/post.js +59 -21
- package/dist/post.js.map +1 -1
- package/dist/replay.d.ts +172 -0
- package/dist/replay.js +302 -0
- package/dist/replay.js.map +1 -0
- package/dist/session_targets.d.ts +12 -0
- package/dist/session_targets.js +6 -1
- package/dist/session_targets.js.map +1 -1
- package/dist/spool.d.ts +44 -16
- package/dist/spool.js +174 -33
- package/dist/spool.js.map +1 -1
- package/dist/spool_union.d.ts +18 -2
- package/dist/spool_union.js +23 -2
- package/dist/spool_union.js.map +1 -1
- package/dist/state.js +21 -14
- package/dist/state.js.map +1 -1
- package/dist/stdin.d.ts +2 -0
- package/dist/stdin.js +13 -0
- package/dist/stdin.js.map +1 -0
- package/dist/uploader_status.d.ts +34 -0
- package/dist/uploader_status.js +25 -0
- package/dist/uploader_status.js.map +1 -0
- package/dist/uploader_view.d.ts +39 -0
- package/dist/uploader_view.js +106 -0
- package/dist/uploader_view.js.map +1 -0
- package/dist/verbs.d.ts +11 -0
- package/dist/verbs.js +13 -0
- package/dist/verbs.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/hooks/entry.js
CHANGED
|
@@ -33,33 +33,36 @@
|
|
|
33
33
|
* own red-proof in `test/credential.test.ts`. Do not read one as evidence for
|
|
34
34
|
* the other.
|
|
35
35
|
*/
|
|
36
|
-
import { closeSync, existsSync, openSync, readSync,
|
|
36
|
+
import { closeSync, existsSync, openSync, readSync, statSync, writeSync, } from "node:fs";
|
|
37
|
+
import { spawn } from "node:child_process";
|
|
37
38
|
import { basename, dirname } from "node:path";
|
|
38
39
|
import { zstdCompressSync } from "node:zlib";
|
|
39
40
|
import { grantsUnreadable, hasAnyConsent, isCaptureAllowed, isLocalSlug, normalizeSlug } from "../consent.js";
|
|
40
|
-
import { endingSoon,
|
|
41
|
+
import { endingSoon, metaFor } from "../credential_meta.js";
|
|
41
42
|
import { loadCredential } from "../credential.js";
|
|
42
43
|
import { EXIT } from "../exit.js";
|
|
43
|
-
import { headRef, onceRepoSlug
|
|
44
|
+
import { headRef, onceRepoSlug } from "../git.js";
|
|
44
45
|
import { redactSpan, rulesForTranscript } from "../redact.js";
|
|
45
46
|
import { authCommandFor, connectCommandFor } from "../install.js";
|
|
46
|
-
import { installOnce, sessionStartPayload } from "./session_start.js";
|
|
47
|
-
import { credentialFingerprint, deliver, ingestUrl } from "../post.js";
|
|
48
|
-
import { FOLDER_REPOSITORY_FORBIDDEN, markSkipped } from "../policy.js";
|
|
49
|
-
import { dialectIsolates, evidenceSpecFor, findSubagentTranscripts, isolationOf,
|
|
47
|
+
import { installForWorkerTarget, installOnce, sessionStartPayload } from "./session_start.js";
|
|
48
|
+
import { CAPTURE_NOT_APPROVED, credentialFingerprint, deliver, ingestUrl } from "../post.js";
|
|
49
|
+
import { FOLDER_REPOSITORY_FORBIDDEN, markSkipped, replayDecision } from "../policy.js";
|
|
50
|
+
import { dialectIsolates, evidenceSpecFor, findSubagentTranscripts, isolationOf, loadTargetRecord, pastCursorOf, redactionScopeFor, stopTarget, } from "../session_targets.js";
|
|
50
51
|
import { readSpoolUnion } from "../spool_union.js";
|
|
51
|
-
import { fileState, loadSessionState,
|
|
52
|
-
import { SESSION_END_EVENT, settleDelayMs, settledSize, } from "./session_end.js";
|
|
52
|
+
import { fileState, loadSessionState, saveSessionState, withFileState } from "../state.js";
|
|
53
53
|
import { admitsTranscript, agentForTranscript, canonicalHookEvent, dialectFor, } from "../agents/registry.js";
|
|
54
|
-
import {
|
|
55
|
-
import {
|
|
54
|
+
import { FIRING_VERSION, MAX_FIRING_SUB_FILES, recordFiring, liveAtOf, liveStampKeys, stampLive } from "../firing.js";
|
|
55
|
+
import { identityDigest } from "../identity.js";
|
|
56
|
+
import { readUploaderStatus } from "../uploader_status.js";
|
|
57
|
+
import { uploaderAlive } from "./fire_lock.js";
|
|
58
|
+
import { announcedSubagentFileKey, sessionFileStem, sessionLivePath, sessionStatePath, subagentFileKey, } from "../paths.js";
|
|
56
59
|
import { resolveProjectKeys } from "../project.js";
|
|
57
60
|
export { confinementRoots } from "../project.js";
|
|
58
61
|
import { startSpawnBudget } from "../spawn_budget.js";
|
|
59
62
|
import { turnStart } from "../pending_bound.js";
|
|
60
63
|
import { recordInferredSkipped } from "../inferred_tally.js";
|
|
61
64
|
import { pruneOrphanPending } from "../pending_prune.js";
|
|
62
|
-
import { pendingModeFor, promotePending, pruneExpiredInferred, readPending } from "../spool.js";
|
|
65
|
+
import { SESSION_LIVE_WINDOW_MS, pendingModeFor, promotePending, pruneExpiredInferred, readPending } from "../spool.js";
|
|
63
66
|
import { meetsNodeFloor } from "../runtime.js";
|
|
64
67
|
import { renderNotice } from "../system_message.js";
|
|
65
68
|
/**
|
|
@@ -128,107 +131,6 @@ const MIN_SUBAGENT_BUDGET_MS = 400;
|
|
|
128
131
|
* and a session that spawned 27 delegated agents will fire far more than four.
|
|
129
132
|
*/
|
|
130
133
|
const MAX_SUBAGENT_FILES = 8;
|
|
131
|
-
/**
|
|
132
|
-
* What `stampGaps` gets to keep, carved OUT of the send budget on `SessionEnd`
|
|
133
|
-
* — `CR-182`.
|
|
134
|
-
*
|
|
135
|
-
* ## The defect, and why it is "starved" rather than "never runs"
|
|
136
|
-
*
|
|
137
|
-
* `stampGaps` is called AFTER the send loop, and the watchdog fires at
|
|
138
|
-
* `budgetMs` regardless. The loop's own arithmetic does leave it something: each
|
|
139
|
-
* attempt takes 0.6 of what REMAINS, so the last one starts with at least
|
|
140
|
-
* `MIN_SUBAGENT_BUDGET_MS` and returns with 0.4 of that — 160 ms — still on the
|
|
141
|
-
* clock. But that 160 ms is measured from `startedAt`, which is the settle, and
|
|
142
|
-
* **the watchdog is measured from `runHook`**. Everything before the settle —
|
|
143
|
-
* the stdin read, the consent gate's `git rev-parse`, the credential load — is
|
|
144
|
-
* time the loop never sees and the watchdog already spent. `CR-126` measured a
|
|
145
|
-
* comparable pre-entry interval at p99 392 ms idle and 184 ms under load, so the
|
|
146
|
-
* 160 ms cushion is routinely already gone.
|
|
147
|
-
*
|
|
148
|
-
* MEASURED against the unmodified client (12 delegated files, hung ingest
|
|
149
|
-
* server, `SessionEnd`): idle, every run stamped. Under CPU saturation, a
|
|
150
|
-
* 2200 ms budget produced **one state cell and zero gaps** — the loop's last
|
|
151
|
-
* send overshot and the process was cut off before it could record anything.
|
|
152
|
-
* That is D56 §D8's quietly-short record, which is the one outcome this client
|
|
153
|
-
* may not produce.
|
|
154
|
-
*
|
|
155
|
-
* ## Why 150 ms, and why a fixed number is honest here
|
|
156
|
-
*
|
|
157
|
-
* `stampGaps` is pure synchronous fs — one `loadSessionState` + `saveSessionState`
|
|
158
|
-
* pair per undelivered stream, bounded by `MAX_SUBAGENT_FILES + 1` (9) — with no
|
|
159
|
-
* network, no spawn and no timer in it. So unlike a send, its cost does not
|
|
160
|
-
* depend on anything outside this process, and a fixed reserve is a real bound
|
|
161
|
-
* rather than a guess dressed as one. 150 ms is ~17 ms per stream at the cap on
|
|
162
|
-
* a machine where the whole 9-file loop measures under 5 ms, i.e. an order of
|
|
163
|
-
* magnitude of margin over the effect — the same ratio `SESSION_END_SETTLE_DELAY_MS`
|
|
164
|
-
* is set at, and small enough that it costs the send 90 ms of 2850 on the
|
|
165
|
-
* default budget.
|
|
166
|
-
*
|
|
167
|
-
* ## ⛔ THE RESERVE WAS NOT ACTUALLY RESERVED UNTIL `CR-195/U2` — the two clocks
|
|
168
|
-
* started at different instants
|
|
169
|
-
*
|
|
170
|
-
* Everything above this paragraph diagnosed the defect correctly and then
|
|
171
|
-
* subtracted the reserve from the WRONG ORIGIN. `budgetMs - 150` is a bound on
|
|
172
|
-
* the loop's own clock, which starts at `settleStart`; the watchdog's clock
|
|
173
|
-
* starts in `runHook`, before stdin is read. So the loop was permitted to spend
|
|
174
|
-
* until `budgetMs` *measured from the settle* — already past the watchdog's
|
|
175
|
-
* deadline by exactly the pre-entry interval this docblock names — and the
|
|
176
|
-
* watchdog then fired MID-SEND. `stampGaps` runs after the loop, so it did not
|
|
177
|
-
* run at all, and the held spans stayed held.
|
|
178
|
-
*
|
|
179
|
-
* ⛔ **That is the G6 flake, and it was a real product defect rather than a
|
|
180
|
-
* timing-sensitive assertion.** `test/session-end-gaps.test.ts` caught it as
|
|
181
|
-
* `held: [[0, 39]]` where a stamped gap belonged: on `SessionEnd` — which has no
|
|
182
|
-
* next invocation — a hole that is held rather than stamped is the final turn
|
|
183
|
-
* recorded as delivered-so-far instead of as short. `sendBudgetMs` now takes the
|
|
184
|
-
* elapsed time since `runHook` armed the watchdog and charges it, so the reserve
|
|
185
|
-
* is measured against the deadline that can actually end the process.
|
|
186
|
-
*
|
|
187
|
-
* ⚠ **Since `CR-228` the reserve pays for a HOLD record too.** A held stream is
|
|
188
|
-
* no longer stamped, but `SessionState.endHold` is written for it — and a hook
|
|
189
|
-
* cut off before that write leaves the bytes held with nothing saying the
|
|
190
|
-
* session ended owing them, which `status` cannot see. Same reserve, same cost.
|
|
191
|
-
*
|
|
192
|
-
* ⚠ **Still a mitigation, not a guarantee.** Enough scheduler pressure defeats
|
|
193
|
-
* any fixed reserve, exactly as it defeats the watchdog itself — a timer cannot
|
|
194
|
-
* fire while the event loop is blocked. What changed is that the reserve now
|
|
195
|
-
* survives the ordinary case it was written for; before, the ordinary case is
|
|
196
|
-
* precisely what consumed it.
|
|
197
|
-
*
|
|
198
|
-
* ⛔ Zero on every other event: `stampGaps` runs on `SessionEnd` alone, so
|
|
199
|
-
* reserving elsewhere would shrink `Stop`'s send for a stamp that never happens.
|
|
200
|
-
*/
|
|
201
|
-
const GAP_STAMP_RESERVE_MS = 150;
|
|
202
|
-
/**
|
|
203
|
-
* The budget the SENDS may spend — the whole budget, less the stamp's share.
|
|
204
|
-
*
|
|
205
|
-
* A function, and exported, for the reason `sendTimeoutMs` is: the carve-out is
|
|
206
|
-
* the thing this task changes, and a wall-clock test cannot pin it. Every
|
|
207
|
-
* discriminator that distinguishes reserve from no-reserve through a real
|
|
208
|
-
* process turned out to slide with per-send overhead — a 149 ms window that
|
|
209
|
-
* moves by 25 ms for every 10 ms of connect-and-compress cost — so pinning it
|
|
210
|
-
* there would be tuning a constant to one machine. Here it is arithmetic.
|
|
211
|
-
*
|
|
212
|
-
* ⛔ `budgetMs` itself still governs the SETTLE and the WATCHDOG. Only the sends
|
|
213
|
-
* are carved.
|
|
214
|
-
*/
|
|
215
|
-
/**
|
|
216
|
-
* ⛔ **TAKES THE RESOLVED EVENT, NEVER THE RAW `hook_event_name` (`CR-195`).**
|
|
217
|
-
*
|
|
218
|
-
* It used to take a `string`, and that is precisely how Cursor's `sessionEnd`
|
|
219
|
-
* bought a full-budget send with no reserve: the comparison was against
|
|
220
|
-
* `"SessionEnd"`, so an unrecognised spelling was `false` rather than an error.
|
|
221
|
-
* `HookEventName | null` makes the resolution a thing the CALLER must already
|
|
222
|
-
* have done — `null` is the honest "none of the three", and an unresolved wire
|
|
223
|
-
* string is no longer expressible here at all.
|
|
224
|
-
*/
|
|
225
|
-
export function sendBudgetMs(budgetMs, event, sinceHookStartMs = 0) {
|
|
226
|
-
const reserve = event === SESSION_END_EVENT ? GAP_STAMP_RESERVE_MS : 0;
|
|
227
|
-
// ⛔ FLOORED AT ZERO. A negative would reach `sendTimeoutMs`, whose own
|
|
228
|
-
// `Math.max(0, budgetMs - consumedMs)` turns a negative budget into a POSITIVE
|
|
229
|
-
// remaining — a blown clock reading as fresh time.
|
|
230
|
-
return Math.max(0, budgetMs - sinceHookStartMs - reserve);
|
|
231
|
-
}
|
|
232
134
|
/**
|
|
233
135
|
* Parse hook stdin. Claude Code supplies `{ session_id, transcript_path, cwd,
|
|
234
136
|
* hook_event_name }` on stdin — NOT in the environment.
|
|
@@ -302,14 +204,6 @@ export function silenceStderr() {
|
|
|
302
204
|
* truncated write is the silent-unconfigured failure DX3 exists to kill.
|
|
303
205
|
*/
|
|
304
206
|
function finish(payload) {
|
|
305
|
-
// ⛔ RELEASED HERE, AND HERE IS THE ONLY PLACE THAT COVERS EVERY EXIT
|
|
306
|
-
// (`CR-195`/U4a). A `try/finally` around `hookBody` would look tidier and
|
|
307
|
-
// would leak the lock on the one path that matters most: the WATCHDOG calls
|
|
308
|
-
// this function directly, and the signal handlers do too, so neither unwinds
|
|
309
|
-
// `hookBody` at all. A lock leaked there survives until it goes stale, and
|
|
310
|
-
// until then it suppresses the next firing of that event — turning a guard
|
|
311
|
-
// against duplicates into a guard against captures.
|
|
312
|
-
releaseFiring();
|
|
313
207
|
if (payload !== null) {
|
|
314
208
|
try {
|
|
315
209
|
writeSync(1, `${payload}\n`);
|
|
@@ -320,29 +214,6 @@ function finish(payload) {
|
|
|
320
214
|
}
|
|
321
215
|
process.exit(EXIT.ok);
|
|
322
216
|
}
|
|
323
|
-
/**
|
|
324
|
-
* The firing this process claimed, if it claimed one.
|
|
325
|
-
*
|
|
326
|
-
* ⚠ **Module state, deliberately, and it is the narrow case that justifies it.**
|
|
327
|
-
* This process handles exactly ONE hook invocation and then exits; `finish` is
|
|
328
|
-
* its single exit point and is reached from paths that never return through
|
|
329
|
-
* `hookBody`. Threading a claim out to those paths is not possible — the
|
|
330
|
-
* watchdog holds no reference to the body's locals.
|
|
331
|
-
*/
|
|
332
|
-
const heldClaims = [];
|
|
333
|
-
/** Idempotent, and never throws: the hook contract forbids saying anything. Releases EVERY lock this process holds. */
|
|
334
|
-
function releaseFiring() {
|
|
335
|
-
const claims = heldClaims.splice(0, heldClaims.length);
|
|
336
|
-
for (const claim of claims) {
|
|
337
|
-
try {
|
|
338
|
-
claim.release();
|
|
339
|
-
}
|
|
340
|
-
catch {
|
|
341
|
-
// A lock we could not release goes stale on its own; the bound is derived
|
|
342
|
-
// from the budget precisely so a lost release costs one invocation, once.
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
217
|
/**
|
|
347
218
|
* Install every contract guard, then run the body.
|
|
348
219
|
*
|
|
@@ -524,199 +395,216 @@ async function hookBody(ctx, budgetMs, hookStartedAt) {
|
|
|
524
395
|
// `/cso` finding 2: a rejected override is a silent no-send, not a fallback to the default.
|
|
525
396
|
if (url === null)
|
|
526
397
|
return null;
|
|
527
|
-
//
|
|
528
|
-
// not. Resolved through the dialect (`CR-195`, D205): Cursor sends `sessionEnd`.
|
|
529
|
-
const isSessionEnd = event === SESSION_END_EVENT;
|
|
530
|
-
// --- TWO LOCKS, and they are different questions (O5). ---
|
|
398
|
+
// --- U6: THE HOOK RECORDS A FIRING AND RETURNS (plan §3.1). It never waits for the network again. ---
|
|
531
399
|
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
event: event ?? input.eventName,
|
|
545
|
-
};
|
|
546
|
-
const lockable = fireLockPath(ctx.home, fireKey) !== null;
|
|
547
|
-
const firing = claimFiring(ctx.home, fireKey, budgetMs);
|
|
548
|
-
if (firing === null && lockable) {
|
|
549
|
-
// Another process holds this exact firing. ⛔ Silent and exit 0: a `systemMessage` here would put a line in the user's turn
|
|
550
|
-
// for a duplicate they did not cause. `finish` releases nothing because we hold nothing.
|
|
551
|
-
return null;
|
|
400
|
+
// What used to happen here (claim the firing, wait for the session's writer lock, settle, resolve targets, promote, read the spool, deliver, stamp
|
|
401
|
+
// gaps, drain idle sessions) is the upload worker's now, replayed from the line this appends (`replay.ts`, `drain.ts`). The motivating incident: the
|
|
402
|
+
// hook's 3 s attempt timeout abandoned requests the server then applied, which forked versions and starved the VM.
|
|
403
|
+
//
|
|
404
|
+
// ⛔ The hook holds NO lock. Duplicate firings (Cursor fires one event twice) are two lines, and the second is a no-op by `replayDecision`.
|
|
405
|
+
const load = loadCredential({ env: ctx.env, home: ctx.home });
|
|
406
|
+
if (load.kind !== "ok") {
|
|
407
|
+
// Absent or unusable: write nothing and spawn nothing. The notice's old condition was `resolution.targets.length > 0`, which the hook no longer
|
|
408
|
+
// computes; it is now "the turn-end repo is allowed, or the session's own record already names a target". A parent-folder session with no record
|
|
409
|
+
// stays silent on its first turn, which is the quiet direction.
|
|
410
|
+
const named = turnEndAllowed || (loadTargetRecord(ctx.home, input.sessionId)?.targets.length ?? 0) > 0;
|
|
411
|
+
return named ? notice("not_connected") : null;
|
|
552
412
|
}
|
|
553
|
-
|
|
554
|
-
heldClaims.push(firing);
|
|
555
|
-
const writer = await claimSessionWriter(ctx.home, input.sessionId, {
|
|
556
|
-
staleAfterMs: budgetMs + STALE_MARGIN_MS,
|
|
557
|
-
deadlineAtMs: hookStartedAt + Math.floor(budgetMs * WRITER_WAIT_FRACTION),
|
|
558
|
-
wait,
|
|
559
|
-
});
|
|
560
|
-
if (writer === null)
|
|
561
|
-
return null;
|
|
562
|
-
heldClaims.push(writer);
|
|
563
|
-
const settleFor = isSessionEnd ? settleDelayMs(ctx.env, budgetMs) : 0;
|
|
564
|
-
// `CR-182`. Carved OUT of the budget, never added on top. ⛔ AND THE PRE-ENTRY COST IS CHARGED HERE (`CR-195/U2`, the G6
|
|
565
|
-
// defect): the stdin read, the gate's spawn, the credential load AND NOW the writer-lock wait all happened between the
|
|
566
|
-
// watchdog starting and this line, and the reserve must be measured against the clock that can actually end the process.
|
|
567
|
-
const settleStart = Date.now();
|
|
568
|
-
const sendBudget = sendBudgetMs(budgetMs, event, settleStart - hookStartedAt);
|
|
569
|
-
const eof = settleFor > 0
|
|
570
|
-
? await settledSize(() => fileSize(input.transcriptPath), wait, settleFor)
|
|
571
|
-
: fileSize(input.transcriptPath);
|
|
413
|
+
const eof = fileSize(input.transcriptPath);
|
|
572
414
|
if (eof === null)
|
|
573
415
|
return null;
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
turnEndCwd: input.cwd,
|
|
582
|
-
mode: isSessionEnd ? "end" : "turn",
|
|
583
|
-
nowMs: Date.now(),
|
|
584
|
-
// ⛔ The RESOLVER gets a STRICT thunk (C17 #13b): `slugOf` above falls back to `local:<hash>` on a timeout, and what the resolver is given
|
|
585
|
-
// becomes the target's sticky identity. The consent gate keeps `slugOf`.
|
|
586
|
-
...(keys !== null && slugOf !== null ? { known: { keys, slugOf: onceStrictRepoSlug(keys.worktree) } } : {}),
|
|
416
|
+
const identity = identityDigest({ url, env: ctx.env });
|
|
417
|
+
// Liveness (plan §3.5): READ BEFORE STAMPING, so the worker can judge `pendingModeFor` as the hook saw it; then stamp `.live`, hook-only.
|
|
418
|
+
const record = loadTargetRecord(ctx.home, input.sessionId);
|
|
419
|
+
const stampKeys = liveStampKeys({
|
|
420
|
+
isolates: dialectIsolates(ctx.home, ctx.env, input.transcriptPath),
|
|
421
|
+
turnEnd: turnEndAllowed && keys !== null ? keys.worktree : null,
|
|
422
|
+
recordLedgerKeys: record === null ? [] : record.targets.map((target) => target.ledgerKey),
|
|
587
423
|
});
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
424
|
+
const prior = {};
|
|
425
|
+
for (const key of stampKeys) {
|
|
426
|
+
const before = priorLivenessOf(ctx.home, { repoKey: key, sessionId: input.sessionId });
|
|
427
|
+
if (before !== undefined)
|
|
428
|
+
prior[key] = before;
|
|
429
|
+
}
|
|
430
|
+
const firedAt = Date.now();
|
|
431
|
+
// `slug` only when step 3 got a REAL one, never the `local:` fallback: what the worker passes the resolver becomes a target's sticky identity.
|
|
432
|
+
const turnEndSlug = keys !== null && slugOf !== null ? slugOf() : null;
|
|
433
|
+
const firing = {
|
|
434
|
+
v: FIRING_VERSION,
|
|
435
|
+
at: firedAt,
|
|
436
|
+
sid: input.sessionId,
|
|
437
|
+
event: input.eventName,
|
|
438
|
+
eof,
|
|
439
|
+
transcript: input.transcriptPath,
|
|
440
|
+
cwd: input.cwd,
|
|
441
|
+
...(keys === null ? {} : { keys: { worktree: keys.worktree, consent: keys.consent } }),
|
|
442
|
+
...(turnEndSlug === null || isLocalSlug(turnEndSlug) ? {} : { slug: turnEndSlug }),
|
|
443
|
+
agent: ctx.agentId,
|
|
444
|
+
id: identity,
|
|
445
|
+
prior,
|
|
446
|
+
...(input.agentTranscriptPath === undefined ? {} : { announced: input.agentTranscriptPath }),
|
|
447
|
+
...subSizesOf(ctx, input),
|
|
448
|
+
};
|
|
449
|
+
recordFiring(ctx.home, input.sessionId, firing);
|
|
450
|
+
// Appended FIRST, stamped second, lock read third: the worker releases THEN looks at the queue, so one side always sees the other.
|
|
451
|
+
for (const key of stampKeys)
|
|
452
|
+
stampLive(ctx.home, { repoKey: key, sessionId: input.sessionId }, firedAt);
|
|
453
|
+
// --- NOTICES come from the worker's status file (plan §3.7): the hook cannot see this firing's outcome, only earlier ones. ---
|
|
454
|
+
const status = readUploaderStatus(ctx.home, identity);
|
|
455
|
+
const payload = (() => {
|
|
456
|
+
// `fatal`: a hook that stays silent about a revoked credential captures nothing forever. QUIET DURING ROTATION (`11A`): only about the credential this
|
|
457
|
+
// hook is about to use; if `auth` has replaced the one the worker was rejected with, there is nothing to tell the user.
|
|
458
|
+
if (status?.rejected !== undefined && status.rejected.fingerprint === credentialFingerprint(load.credential)) {
|
|
459
|
+
return notice("credential_revoked", null, load.credential);
|
|
460
|
+
}
|
|
461
|
+
// `CR-234d` (12A): the worker remembered where the credential stands in the SIDECAR; from 14 days before the end, say so once per machine per day.
|
|
462
|
+
// It outranks the positive message: that one repeats daily, this one is the only way a person learns capture will stop.
|
|
463
|
+
const endsAt = endingSoon(metaFor(ctx.home, credentialFingerprint(load.credential)), firedAt);
|
|
464
|
+
if (endsAt !== null) {
|
|
465
|
+
const ending = notice("credential_ending", null, load.credential, endsAt);
|
|
466
|
+
if (ending !== null)
|
|
467
|
+
return ending;
|
|
468
|
+
}
|
|
469
|
+
if (firstStop?.state != null) {
|
|
470
|
+
const install = notice(firstStop.state, null, undefined, undefined, firstStop.cloneKey);
|
|
471
|
+
if (install !== null)
|
|
472
|
+
return install;
|
|
473
|
+
}
|
|
474
|
+
// `capture_confirmed`: one turn later, and only on a hook of the session the outcome NAMES, still claimed once per (repo, day). The copy says
|
|
475
|
+
// "captured this session", so showing it in another session, possibly in a declined repo, would be false and would use up the claim.
|
|
476
|
+
const stem = sessionFileStem(input.sessionId);
|
|
477
|
+
if (turnEndAllowed && keys !== null && status !== null && status.outcomes.some((o) => o.session === stem && o.class === "done:delivered")) {
|
|
478
|
+
return notice("capture_confirmed", keys.worktree);
|
|
611
479
|
}
|
|
612
480
|
return null;
|
|
481
|
+
})();
|
|
482
|
+
// Spawn the worker if none is alive for this identity. A failed spawn is silent and leaves the firing for the next hook.
|
|
483
|
+
if (!uploaderAlive(ctx.home, identity))
|
|
484
|
+
spawnWorker(ctx);
|
|
485
|
+
return payload;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* U6b (F5, C33) — each sub-agent file's size NOW, for `Firing.sub`: what the worker may send of it when it replays this firing. The same two
|
|
489
|
+
* discoveries `deliverSubagents` makes (the dialect's glob and the announced path), so a stream the delivery would send has a ceiling here. Empty
|
|
490
|
+
* map when there are none (a file that appears later is the NEXT firing's). `{}` (no key) when the list is too long to record: uncapped, as before.
|
|
491
|
+
* Never throws: a hook must not.
|
|
492
|
+
*/
|
|
493
|
+
function subSizesOf(ctx, input) {
|
|
494
|
+
try {
|
|
495
|
+
const sub = {};
|
|
496
|
+
for (const file of [...findSubagentFiles(ctx, input.transcriptPath), ...announcedSubagentFiles(ctx, input)])
|
|
497
|
+
sub[file.fileKey] = file.size;
|
|
498
|
+
return Object.keys(sub).length > MAX_FIRING_SUB_FILES ? {} : { sub };
|
|
613
499
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
500
|
+
catch {
|
|
501
|
+
return {};
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* U6 — the liveness the hook read BEFORE it stamped (plan §3.4, P2 point 4), in `Firing.prior`'s three states: a number (the later of the state
|
|
506
|
+
* file's `liveAt` and the `.live` stamp), `null` (a state file exists with NO `liveAt`: what `finalize` and `backfill` leave), or `undefined` (no
|
|
507
|
+
* state file at all, which reads as `first-turn`). A plain number-or-omitted map would read a resumed session as `first-turn`.
|
|
508
|
+
*/
|
|
509
|
+
function priorLivenessOf(home, key) {
|
|
510
|
+
const statePath = sessionStatePath(home, key);
|
|
511
|
+
if (statePath === null)
|
|
512
|
+
return undefined;
|
|
513
|
+
const stamped = liveAtOf(null, sessionLivePath(home, key));
|
|
514
|
+
if (!existsSync(statePath))
|
|
515
|
+
return stamped ?? undefined;
|
|
516
|
+
return liveAtOf(loadSessionState(home, key).liveAt, sessionLivePath(home, key));
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* U6 — spawn the detached worker (plan §3.9): `stdio: "ignore"` so the hook's pipes are not held open, `cwd: home` so it pins no worktree or temp dir, the
|
|
520
|
+
* hook's own environment so `VIBECOMMIT_TOKEN` and the ingest override reach it. Node's documented background pattern. `selfPath` is `process.argv[1]`,
|
|
521
|
+
* so both channels (npm and the plugin's vendored bin) respawn themselves. Never throws, never waits.
|
|
522
|
+
*/
|
|
523
|
+
function spawnWorker(ctx) {
|
|
524
|
+
const self = ctx.selfPath;
|
|
525
|
+
if (self === undefined || self === "")
|
|
526
|
+
return;
|
|
527
|
+
try {
|
|
528
|
+
const child = spawn(process.execPath, [self, "__upload-worker"], { detached: true, stdio: "ignore", windowsHide: true, cwd: ctx.home, env: ctx.env });
|
|
529
|
+
child.on("error", () => {
|
|
530
|
+
// A worker that could not start leaves the firing for the next hook.
|
|
531
|
+
});
|
|
532
|
+
child.unref();
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
// Silent, per the hook contract.
|
|
626
536
|
}
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
537
|
+
}
|
|
538
|
+
/** Pure. The reason for one target from its delivery and where its main ledger stands against the `eof` it was asked to reach. */
|
|
539
|
+
export function targetReason(delivery, sentOffset, eof, stopped) {
|
|
540
|
+
if (stopped)
|
|
541
|
+
return { kind: "held", why: "folder-forbidden" };
|
|
542
|
+
switch (delivery.kind) {
|
|
543
|
+
case "stopped":
|
|
544
|
+
return { kind: "held", why: "stopped" };
|
|
545
|
+
case "no-repo":
|
|
546
|
+
return { kind: "never" };
|
|
547
|
+
case "nothing-to-send":
|
|
548
|
+
if (delivery.why === "lock-lost")
|
|
549
|
+
return { kind: "lock-lost" };
|
|
550
|
+
if (delivery.why === "read-failed" || delivery.why === "unwritable")
|
|
551
|
+
return { kind: "not-progressed", why: delivery.why };
|
|
552
|
+
if (delivery.why === "unterminated")
|
|
553
|
+
return { kind: "held", why: "unterminated" };
|
|
554
|
+
return sentOffset >= eof ? { kind: "caught-up" } : { kind: "not-progressed", why: "read-failed" };
|
|
555
|
+
case "attempted":
|
|
556
|
+
switch (delivery.disposition) {
|
|
557
|
+
case "fatal":
|
|
558
|
+
return { kind: "fatal" };
|
|
559
|
+
case "never":
|
|
560
|
+
return { kind: "never" };
|
|
561
|
+
case "later":
|
|
562
|
+
return delivery.detail?.code === CAPTURE_NOT_APPROVED ? { kind: "held", why: "capture-not-approved" } : { kind: "later" };
|
|
563
|
+
case "ok":
|
|
564
|
+
return sentOffset >= eof ? { kind: "caught-up" } : { kind: "more-owed" };
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* U4 — DELIVER ONE FIRING TO ITS RESOLVED TARGETS. The loop `hookBody` always ran inline, callable by the upload worker with a replay's
|
|
570
|
+
* as-of inputs and no wall clock. Behaviour for the inline hook is unchanged: `pace.kind === "hook"` and no `asOf` is the old code.
|
|
571
|
+
*
|
|
572
|
+
* Order and fairness are as before: least-recently-sent target first, so a 20 MB backlog on one cannot starve the rest.
|
|
573
|
+
*/
|
|
574
|
+
export async function deliverFiring(opts) {
|
|
575
|
+
const { ctx, input, resolution, pace } = opts;
|
|
631
576
|
const ordered = leastRecentlySentFirst(ctx.home, input.sessionId, resolution.targets);
|
|
632
577
|
// ⛔ D95 STAYS, exactly as written, for the one case R14 protects: a session whose turns end in ONE granted work tree and
|
|
633
578
|
// which names nothing else granted. Every other session (several targets, a target found only by evidence, a folder target)
|
|
634
579
|
// has later firings that can finish it, so a shortfall at SessionEnd is OWED there, not written off (R19 · D22).
|
|
635
580
|
const writeOffAtSessionEnd = ordered.length === 1 && ordered[0].kind === "repo" && resolution.turnEndSlug === normalizeSlug(ordered[0].slug);
|
|
636
|
-
const deadline = settleStart + sendBudget;
|
|
581
|
+
const deadline = pace.kind === "hook" ? pace.settleStart + pace.sendBudget : Infinity;
|
|
637
582
|
const results = [];
|
|
638
583
|
for (const target of ordered) {
|
|
639
584
|
const result = await deliverTarget({
|
|
640
585
|
ctx,
|
|
641
586
|
input,
|
|
642
|
-
url,
|
|
643
|
-
credential:
|
|
587
|
+
url: opts.url,
|
|
588
|
+
credential: opts.credential,
|
|
644
589
|
target,
|
|
645
|
-
eof,
|
|
646
|
-
event,
|
|
647
|
-
isSessionEnd,
|
|
648
|
-
settleStart,
|
|
649
|
-
sendBudget,
|
|
650
|
-
consumedMs: results.length === 0 ? consumedMs : Date.now() - settleStart,
|
|
590
|
+
eof: opts.eof,
|
|
591
|
+
event: opts.event,
|
|
592
|
+
isSessionEnd: opts.isSessionEnd,
|
|
593
|
+
pace: pace.kind === "hook" ? { ...pace, consumedMs: results.length === 0 ? pace.consumedMs : Date.now() - pace.settleStart } : pace,
|
|
651
594
|
// A target reached with the deadline already spent sends nothing this firing but is still STAMPED at SessionEnd.
|
|
652
595
|
skipSend: results.length > 0 && Date.now() >= deadline,
|
|
653
596
|
owedAtSessionEnd: !writeOffAtSessionEnd,
|
|
654
597
|
scanned: scannedOffsets(resolution.record.scan),
|
|
655
598
|
record: resolution.record,
|
|
599
|
+
...(opts.asOf === undefined ? {} : { asOf: opts.asOf }),
|
|
600
|
+
...(opts.heartbeat === undefined ? {} : { heartbeat: opts.heartbeat }),
|
|
656
601
|
});
|
|
657
602
|
results.push(result);
|
|
603
|
+
if (result.reason.kind === "lock-lost")
|
|
604
|
+
break;
|
|
658
605
|
}
|
|
659
|
-
|
|
660
|
-
//
|
|
661
|
-
// `fatal`: a hook that stays silent about a revoked credential captures nothing forever. `later` and `never` say nothing: a
|
|
662
|
-
// hook has one line per session and spending it on a condition that resolves itself next turn is the tool noise D57 §DX3
|
|
663
|
-
// exists to avoid. `ok`: the fourth state (`CR-023d`), and its `(repo, day)` claim is what keeps it to once.
|
|
664
|
-
//
|
|
665
|
-
// With N targets the answer is ONE: any `fatal` wins (it is credential-wide by definition), else the first target that
|
|
666
|
-
// delivered speaks. A per-target stop (C3) is never `fatal` and is never a notice: `status` names it.
|
|
667
|
-
const fatal = results.find((r) => r.stopped === false && r.delivery.kind === "attempted" && r.delivery.disposition === "fatal");
|
|
668
|
-
const payload = (() => {
|
|
669
|
-
if (fatal !== undefined && fatal.delivery.kind === "attempted") {
|
|
670
|
-
// ⛔ QUIET DURING ROTATION (`11A`). This hook loaded its credential before an `auth` replaced the file; its 401 is about a
|
|
671
|
-
// credential that is already gone. If what is on disk NOW is a different credential, there is nothing to tell the user.
|
|
672
|
-
const current = loadCredential({ env: ctx.env, home: ctx.home });
|
|
673
|
-
if (current.kind === "ok" && credentialFingerprint(current.credential) !== credentialFingerprint(load.credential)) {
|
|
674
|
-
return null;
|
|
675
|
-
}
|
|
676
|
-
return notice("credential_revoked", null, load.credential);
|
|
677
|
-
}
|
|
678
|
-
const ok = results.find((r) => r.delivery.kind === "attempted" && r.delivery.disposition === "ok");
|
|
679
|
-
if (ok === undefined || ok.delivery.kind !== "attempted")
|
|
680
|
-
return null;
|
|
681
|
-
// `CR-234d` (12A): a 2xx carries where the credential stands (`X-Credential-Expires`). Remember it in the SIDECAR (never in
|
|
682
|
-
// `credentials.json`, which a hook must not rewrite, R10) and, from 14 days before the end, say so once per machine per day.
|
|
683
|
-
// It outranks the positive message: that one repeats daily, this one is the only way a person learns capture will stop.
|
|
684
|
-
if (ok.delivery.credentialExpires != null) {
|
|
685
|
-
const meta = noteCredentialExpiry(ctx.home, ctx.env, credentialFingerprint(load.credential), ok.delivery.credentialExpires, new Date());
|
|
686
|
-
const endsAt = endingSoon(meta, Date.now());
|
|
687
|
-
if (endsAt !== null) {
|
|
688
|
-
const ending = notice("credential_ending", null, load.credential, endsAt);
|
|
689
|
-
if (ending !== null)
|
|
690
|
-
return ending;
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
return ((firstStop?.state == null ? null : notice(firstStop.state, null, undefined, undefined, firstStop.cloneKey)) ??
|
|
694
|
-
notice("capture_confirmed", ok.target.ledgerKey));
|
|
695
|
-
})();
|
|
696
|
-
// The leftover of the one deadline goes to sessions that have had no firing for 10 minutes (R19 · D22): never throws.
|
|
697
|
-
try {
|
|
698
|
-
await drainIdleSessions({
|
|
699
|
-
ctx,
|
|
700
|
-
url,
|
|
701
|
-
credential: load.credential,
|
|
702
|
-
currentSessionId: input.sessionId,
|
|
703
|
-
settleStart,
|
|
704
|
-
sendBudget,
|
|
705
|
-
staleAfterMs: budgetMs + STALE_MARGIN_MS,
|
|
706
|
-
});
|
|
707
|
-
}
|
|
708
|
-
catch {
|
|
709
|
-
// A hook cannot report an error, and a drain that failed leaves every session exactly as it was.
|
|
710
|
-
}
|
|
711
|
-
return payload;
|
|
606
|
+
return results;
|
|
712
607
|
}
|
|
713
|
-
/**
|
|
714
|
-
* How much of the hook's budget a firing may spend WAITING for another writer of the same session before it gives up. Most of it (0.8): a different event is serialised behind the holder, and the holder's own send can run to its timeout before it lets go, so a shorter
|
|
715
|
-
* window would give up on exactly the overlapping Stop/SessionEnd case (`session-end-gaps` U4a). The
|
|
716
|
-
* rest must still cover the settle, the send and (`SessionEnd`) the gap stamp. A waiter that gives up loses nothing: its bytes
|
|
717
|
-
* ride the next firing, or the idle drain.
|
|
718
|
-
*/
|
|
719
|
-
const WRITER_WAIT_FRACTION = 0.8;
|
|
720
608
|
/**
|
|
721
609
|
* Targets in the order they are served: the one that sent LEAST recently first (A2). `lastSentAt` is the latest successful send
|
|
722
610
|
* across the target's ledger's streams, 0 for "never", so a target that has never been served goes first and a target that just
|
|
@@ -732,6 +620,23 @@ function leastRecentlySentFirst(home, sessionId, targets) {
|
|
|
732
620
|
.sort((a, b) => a.at - b.at || a.index - b.index)
|
|
733
621
|
.map((entry) => entry.target);
|
|
734
622
|
}
|
|
623
|
+
/**
|
|
624
|
+
* U4 — `pendingModeFor`, answered from `firing.prior` (plan §3.4, P2 point 4). `prior` keeps `pendingModeFor`'s three outcomes:
|
|
625
|
+
* - a NUMBER is the stamp the hook read before it stamped: live within the window of `firing.at` is `first-turn`, older is `stale-own`;
|
|
626
|
+
* - an explicit `null` is a state file with NO `liveAt` (what `finalize` and `backfill` leave): `stale-own`;
|
|
627
|
+
* - an ABSENT key is no state file at hook time: `first-turn`. A key the hook did not record at all (a second work tree, a parent-folder
|
|
628
|
+
* repo on its first firing) falls back to `pendingModeFor` judged at `firing.at`, never `Date.now()`, which is safe because stamps arrive in
|
|
629
|
+
* firing order.
|
|
630
|
+
* A plain number-or-omitted map would read a resumed session as `first-turn` and keep a lingering-id line.
|
|
631
|
+
*/
|
|
632
|
+
export function modeFromPrior(home, key, asOf) {
|
|
633
|
+
const recorded = asOf.prior[key.repoKey];
|
|
634
|
+
if (recorded === null)
|
|
635
|
+
return "stale-own";
|
|
636
|
+
if (recorded !== undefined)
|
|
637
|
+
return asOf.at - recorded <= SESSION_LIVE_WINDOW_MS ? "first-turn" : "stale-own";
|
|
638
|
+
return pendingModeFor(home, key, asOf.at);
|
|
639
|
+
}
|
|
735
640
|
/**
|
|
736
641
|
* Deliver ONE target of ONE session: what `hookBody` used to do inline for its single repo, now per target.
|
|
737
642
|
*
|
|
@@ -744,17 +649,44 @@ function leastRecentlySentFirst(home, sessionId, targets) {
|
|
|
744
649
|
* stream's offset can rewind the other's.
|
|
745
650
|
*/
|
|
746
651
|
async function deliverTarget(opts) {
|
|
747
|
-
const { ctx, input, url, credential, target, event, isSessionEnd,
|
|
652
|
+
const { ctx, input, url, credential, target, event, isSessionEnd, pace, asOf, heartbeat } = opts;
|
|
653
|
+
// U5: a holder that lost its lock writes nothing, not even a promotion.
|
|
654
|
+
if (heartbeat !== undefined && !heartbeat()) {
|
|
655
|
+
return { target, delivery: { kind: "nothing-to-send", why: "lock-lost" }, subagents: { files: [], held: false, deliveries: [] }, stopped: false, reason: { kind: "lock-lost" } };
|
|
656
|
+
}
|
|
657
|
+
// U10: the worker (the only caller that passes `selfPath`) gives this repo its git hooks before the first send, the same once-per-clone plain write
|
|
658
|
+
// the hook's own `installOnce` makes. A commit made before it is missed either way; the next one is seen.
|
|
659
|
+
installForWorkerTarget({ env: ctx.env, home: ctx.home, selfPath: ctx.selfPath ?? "" }, target);
|
|
660
|
+
// The hook's clock, when it has one. The worker has none: `undefined` all the way down, and a fixed timeout in its place.
|
|
661
|
+
const hookClock = pace.kind === "hook" ? pace : null;
|
|
662
|
+
const deadlineAtMs = hookClock === null ? undefined : hookClock.settleStart + hookClock.sendBudget;
|
|
663
|
+
const requestTimeoutMs = hookClock === null ? pace.timeoutMs : sendTimeoutMs(hookClock.sendBudget, hookClock.consumedMs);
|
|
748
664
|
// ⛔ A FOLDER stream delivers only up to what the resolver has SCANNED (P1, A-T3 requirement b): bytes appended between the scan
|
|
749
665
|
// and this read were never checked for repo evidence, and a repo they name would end the folder rule. A repo target has no such
|
|
750
666
|
// limit: its bytes are its own whatever else the session named.
|
|
751
667
|
// C42: a REPO copy of a dialect that isolates records may deliver past the scan cursor. Every per-record decision there is exact (the record's own
|
|
752
668
|
// cwd, the path rule, the always-withheld types); the two facts the checkpoint cannot hold are learned IN-SPAN, in order (`pastCursorOf`).
|
|
753
669
|
const folderCap = target.kind === "folder" ? (opts.scanned ?? {}) : undefined;
|
|
754
|
-
|
|
670
|
+
let eof = folderCap === undefined ? opts.eof : Math.min(opts.eof, folderCap.main ?? 0);
|
|
671
|
+
// ⛔ U4, TRAP 1 — A REPLAYED FIRING NEVER GOES TO `nextSpan` WITH AN `eof` BEHIND `sentOffset`: that reads as "the file was replaced" and
|
|
672
|
+
// resyncs the whole session from byte 0. Compare with the file's REAL size first (`replayDecision`): a rotation delivers to the real size,
|
|
673
|
+
// an already-delivered firing (a duplicate, or one older than a `finalize`) is a no-op.
|
|
674
|
+
let replayNoop = false;
|
|
675
|
+
if (asOf !== undefined) {
|
|
676
|
+
const real = fileSize(input.transcriptPath);
|
|
677
|
+
if (real === null) {
|
|
678
|
+
return { target, delivery: { kind: "nothing-to-send", why: "read-failed" }, subagents: { files: [], held: false, deliveries: [] }, stopped: false, reason: { kind: "not-progressed", why: "read-failed" } };
|
|
679
|
+
}
|
|
680
|
+
const sent = fileState(loadSessionState(ctx.home, { repoKey: target.ledgerKey, sessionId: input.sessionId }), "main").sentOffset;
|
|
681
|
+
const decision = replayDecision(sent, eof, real);
|
|
682
|
+
if (decision.kind === "noop")
|
|
683
|
+
replayNoop = true;
|
|
684
|
+
else
|
|
685
|
+
eof = decision.eof;
|
|
686
|
+
}
|
|
755
687
|
const baseIsolation = isolationOf(target, opts.record);
|
|
756
688
|
const isolation = opts.record !== undefined && opts.scanned !== undefined && dialectIsolates(ctx.home, ctx.env, input.transcriptPath)
|
|
757
|
-
? { ...baseIsolation, past: pastCursorOf(baseIsolation, opts.scanned, {
|
|
689
|
+
? { ...baseIsolation, past: pastCursorOf(baseIsolation, opts.scanned, deadlineAtMs === undefined ? {} : { deadlineAtMs }) }
|
|
758
690
|
: baseIsolation;
|
|
759
691
|
const sessionId = input.sessionId;
|
|
760
692
|
const ledgerKey = target.ledgerKey;
|
|
@@ -767,9 +699,15 @@ async function deliverTarget(opts) {
|
|
|
767
699
|
// return value is NOT discarded, it feeds the durable `set_aside` tally.
|
|
768
700
|
for (const projectKey of sourceKeys) {
|
|
769
701
|
const spoolKey = { repoKey: projectKey, sessionId: input.sessionId };
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
702
|
+
// U4: a NO-OP firing (already delivered, or a duplicate) promotes nothing: its pending lines are judged by the firing that carries them.
|
|
703
|
+
if (!replayNoop) {
|
|
704
|
+
// A replay judges the session's liveness as the HOOK saw it before it stamped (`firing.prior`), not by the state file the worker
|
|
705
|
+
// may have since written: stamping first would flip `stale-own` to `first-turn` and keep a lingering-id line (P2 point 4).
|
|
706
|
+
const pendingMode = asOf === undefined ? pendingModeFor(ctx.home, spoolKey) : modeFromPrior(ctx.home, spoolKey, asOf);
|
|
707
|
+
// The skew guard stays on `Date.now()`: on `firing.at`, a prompt written after the firing would read as `future` and keep any line.
|
|
708
|
+
const bound = promotionBound(ctx.home, spoolKey, input.transcriptPath, Date.now());
|
|
709
|
+
promotePending(ctx.home, spoolKey, "this-session-is-running", bound, pendingMode, asOf?.at);
|
|
710
|
+
}
|
|
773
711
|
if (event === "Stop" || isSessionEnd) {
|
|
774
712
|
pruneOrphanPending(ctx.home, projectKey, Date.now());
|
|
775
713
|
const prunedInferred = pruneExpiredInferred(ctx.home, projectKey, Date.now());
|
|
@@ -783,11 +721,12 @@ async function deliverTarget(opts) {
|
|
|
783
721
|
if (!opts.skipSend) {
|
|
784
722
|
// ⛔ THE UNION, acknowledged per source (O7): commits, rewrites and inferred squashes of every work tree of this repo for this
|
|
785
723
|
// session. A single-worktree target reads exactly the three calls the hook always made.
|
|
786
|
-
|
|
724
|
+
// U4: a replay carries only the commits that existed when its hook fired (the pin). A no-op firing carries none.
|
|
725
|
+
const union = readSpoolUnion(ctx.home, sessionId, ledgerKey, target.worktrees, asOf?.at);
|
|
787
726
|
// ⛔ THE ONE NEW SPAWN `CR-170` ADDED, for REF MOVES, not commits: a direct look at `HEAD` is the only record of a `pull`,
|
|
788
727
|
// `checkout` or `reset --hard`. Null on an empty repository. A FOLDER has no HEAD: no spawn.
|
|
789
728
|
const head = target.kind === "repo" ? headRef(ledgerKey) : null;
|
|
790
|
-
delivery = await deliver({
|
|
729
|
+
delivery = replayNoop ? { kind: "nothing-to-send" } : await deliver({
|
|
791
730
|
home: ctx.home,
|
|
792
731
|
env: ctx.env,
|
|
793
732
|
url,
|
|
@@ -799,11 +738,16 @@ async function deliverTarget(opts) {
|
|
|
799
738
|
// ⛔ THE PATH, so `deliver()` can derive `X-Agent` from the root that contains it (`CR-204`, D177 §7).
|
|
800
739
|
transcriptPath: input.transcriptPath,
|
|
801
740
|
// ⛔ `sendBudget`, not `budgetMs`: the main send is the single largest consumer on this path (`CR-182`).
|
|
802
|
-
timeoutMs:
|
|
803
|
-
// PR 4a: WHEN THE SENDS MUST STOP. Shared by every target of the firing, so the rest waits for the next event.
|
|
804
|
-
deadlineAtMs:
|
|
741
|
+
timeoutMs: requestTimeoutMs,
|
|
742
|
+
// PR 4a: WHEN THE SENDS MUST STOP. Shared by every target of the firing, so the rest waits for the next event. U4: the worker has none.
|
|
743
|
+
...(deadlineAtMs === undefined ? {} : { deadlineAtMs }),
|
|
805
744
|
nowMs: Date.now(),
|
|
806
|
-
|
|
745
|
+
...(heartbeat === undefined ? {} : { beforeRequest: heartbeat }),
|
|
746
|
+
// U5: the worker sends one delta and comes back (fairness and pacing); a hook loops to its budget.
|
|
747
|
+
...(hookClock === null ? { oneDelta: true } : {}),
|
|
748
|
+
// U4: a replay is not a hook. It never stamps "now"; it stamps the moment its hook fired (`liveAtMs`, merged by `max`).
|
|
749
|
+
byHook: asOf === undefined && hookClock !== null,
|
|
750
|
+
...(asOf === undefined ? {} : { liveAtMs: asOf.at }),
|
|
807
751
|
// ⛔ THE MAIN DELIVERY ONLY. Sub-agent deliveries leave all three unset: each is its own `capture_id`, and
|
|
808
752
|
// `capture_commits`' PK is `(org_id, capture_id, commit_sha)`, so attaching these to all of them would admit several
|
|
809
753
|
// legal, permanent rows for one commit (D154).
|
|
@@ -825,7 +769,7 @@ async function deliverTarget(opts) {
|
|
|
825
769
|
// a per-target stop so the next firing does not ask again. Never credential-wide, never `fatal`.
|
|
826
770
|
if (delivery.kind === "attempted" && delivery.detail?.code === FOLDER_REPOSITORY_FORBIDDEN) {
|
|
827
771
|
stopTarget(ctx.home, sessionId, target, FOLDER_REPOSITORY_FORBIDDEN, Date.now());
|
|
828
|
-
return { target, delivery, subagents, stopped: true };
|
|
772
|
+
return { target, delivery, subagents, stopped: true, reason: targetReason(delivery, 0, eof, true) };
|
|
829
773
|
}
|
|
830
774
|
// --- DELEGATED WORK. `CR-124`, D76. ---
|
|
831
775
|
// Claude Code writes sub-agent transcripts to `<session>/subagents/`, which `transcript_path` never names: ~16% of
|
|
@@ -838,10 +782,16 @@ async function deliverTarget(opts) {
|
|
|
838
782
|
projectKey: ledgerKey,
|
|
839
783
|
redactionRoots: target.redactionRoots,
|
|
840
784
|
repoSlug: target.slug,
|
|
841
|
-
startedAt: settleStart,
|
|
842
|
-
sendBudget,
|
|
843
|
-
byHook:
|
|
785
|
+
startedAt: hookClock?.settleStart ?? Date.now(),
|
|
786
|
+
sendBudget: hookClock?.sendBudget ?? 0,
|
|
787
|
+
byHook: asOf === undefined && hookClock !== null,
|
|
788
|
+
...(asOf === undefined ? {} : { liveAtMs: asOf.at }),
|
|
789
|
+
...(heartbeat === undefined ? {} : { heartbeat }),
|
|
790
|
+
...(hookClock === null ? { unboundedTimeoutMs: requestTimeoutMs } : {}),
|
|
791
|
+
// F2d: one request at a time per machine. The main delta just went; the first sub-agent request waits the floor.
|
|
792
|
+
...(hookClock === null && delivery.kind === "attempted" ? { pauseFirstMs: UNBOUNDED_PACE_FLOOR_MS } : {}),
|
|
844
793
|
...(folderCap === undefined ? {} : { sizeCap: folderCap }),
|
|
794
|
+
...(asOf?.subCap === undefined ? {} : { firingCap: asOf.subCap }),
|
|
845
795
|
isolation,
|
|
846
796
|
});
|
|
847
797
|
}
|
|
@@ -853,28 +803,51 @@ async function deliverTarget(opts) {
|
|
|
853
803
|
// nothing to return. ⛔ And `CR-228` (`TODOS[142]`): it writes off only what nothing explains. `held` is whether ANY delivery
|
|
854
804
|
// of this target ended `later`/`fatal`, or was refused by a standing stop. R19 · D22 adds the other case that is HELD, not
|
|
855
805
|
// written off: `owedAtSessionEnd`, a session other than the one-target in-repo case, whose later firings can finish it.
|
|
806
|
+
//
|
|
807
|
+
// ⛔ U5, D6 — THE WORKER HAS NO BUDGET TO RUN OUT, so it never writes off what is merely BEHIND:
|
|
808
|
+
// - a step that sent one delta and has more owed (`more`) stamps NOTHING: the next step continues, and stamping here would `markSkipped` the rest
|
|
809
|
+
// of a transcript over the delta cap while the server was answering (found by cell R18: the first step reported `done` with 2 MiB written off);
|
|
810
|
+
// - the final step records what is still behind as a HOLD (`endHold`), never as a gap: behind with an answering server just keeps sending, and
|
|
811
|
+
// what the server refused (`never`), an oversize record, a deleted transcript and the backlog caps stamp their gaps where they happen.
|
|
856
812
|
if (isSessionEnd) {
|
|
857
|
-
|
|
813
|
+
const moreOwed = hookClock === null && [delivery, ...subagents.deliveries].some((d) => d.kind === "attempted" && d.more === true);
|
|
814
|
+
if (!moreOwed) {
|
|
815
|
+
stampGaps(ctx, input, ledgerKey, [{ fileKey: "main", size: eof }, ...subagents.files], endedHeld(delivery) || subagents.held || opts.owedAtSessionEnd || hookClock === null, Date.now(), asOf?.at);
|
|
816
|
+
}
|
|
858
817
|
}
|
|
859
|
-
return {
|
|
818
|
+
return {
|
|
819
|
+
target,
|
|
820
|
+
delivery,
|
|
821
|
+
subagents,
|
|
822
|
+
stopped,
|
|
823
|
+
reason: targetReason(delivery, fileState(loadSessionState(ctx.home, { repoKey: ledgerKey, sessionId }), "main").sentOffset, eof, stopped),
|
|
824
|
+
};
|
|
860
825
|
}
|
|
861
826
|
/** The resolver's scanned offset per stream, for the folder cap. */
|
|
862
827
|
function scannedOffsets(scan) {
|
|
863
828
|
return Object.fromEntries(Object.entries(scan).map(([fileKey, cursor]) => [fileKey, cursor.offset]));
|
|
864
829
|
}
|
|
830
|
+
/**
|
|
831
|
+
* U6b (F2d): THE WORKER'S PACE BETWEEN REQUESTS (D9): sleep `max(250 ms, the request's own elapsed)`, capped at 30 s. It lives here, beside the delivery loops
|
|
832
|
+
* that must honour it, because ONE worker step can put several requests on the wire (the main delta, then one per sub-agent file) and pacing only
|
|
833
|
+
* after the whole step let a fast server take thirteen of them back to back.
|
|
834
|
+
*/
|
|
835
|
+
export const UNBOUNDED_PACE_FLOOR_MS = 250;
|
|
836
|
+
export const UNBOUNDED_PACE_CAP_MS = 30_000;
|
|
837
|
+
export function unboundedPaceMs(elapsedMs) {
|
|
838
|
+
return Math.min(UNBOUNDED_PACE_CAP_MS, Math.max(UNBOUNDED_PACE_FLOOR_MS, elapsedMs));
|
|
839
|
+
}
|
|
865
840
|
/** A session with no firing of its own for this long is IDLE: another session's firing may finish its owed bytes (R19 · D22). */
|
|
866
|
-
const DRAIN_IDLE_MS = 10 * 60_000;
|
|
841
|
+
export const DRAIN_IDLE_MS = 10 * 60_000;
|
|
867
842
|
/** Session records one firing looks at. Examined ones move to the back of the line (their mtime moves), so none starves. */
|
|
868
|
-
const DRAIN_EXAMINE_MAX = 40;
|
|
843
|
+
export const DRAIN_EXAMINE_MAX = 40;
|
|
869
844
|
/** A record whose transcript is gone and that owes nothing is forgotten after this long. */
|
|
870
|
-
const DRAIN_FORGET_MS = 30 * 24 * 3600_000;
|
|
871
|
-
/** Do not START another session with less than this left of the shared deadline: its first request could not finish. */
|
|
872
|
-
const DRAIN_MIN_LEFT_MS = 250;
|
|
845
|
+
export const DRAIN_FORGET_MS = 30 * 24 * 3600_000;
|
|
873
846
|
/**
|
|
874
847
|
* An owed target whose transcript is GONE (Claude Code deletes transcripts ~30 days on) can never be sent: its hold becomes the
|
|
875
848
|
* honest gap now (R19 · D22), and the hold is cleared. Whatever was already delivered is untouched.
|
|
876
849
|
*/
|
|
877
|
-
function writeOffOwed(home, record, nowMs) {
|
|
850
|
+
export function writeOffOwed(home, record, nowMs) {
|
|
878
851
|
for (const target of record.targets) {
|
|
879
852
|
const key = { repoKey: target.ledgerKey, sessionId: record.sessionId };
|
|
880
853
|
let session = loadSessionState(home, key);
|
|
@@ -889,172 +862,6 @@ function writeOffOwed(home, record, nowMs) {
|
|
|
889
862
|
void nowMs;
|
|
890
863
|
}
|
|
891
864
|
}
|
|
892
|
-
/**
|
|
893
|
-
* ⛔ THE IDLE-SESSION DRAIN (R19 · D22 · A). What finishes a session's unsent bytes after its LAST hook.
|
|
894
|
-
*
|
|
895
|
-
* Each hook run can send a few megabytes, and a session whose last hook ran with bytes still behind (a multi-repo session at
|
|
896
|
-
* `SessionEnd`, a repo found late in a long one) has no next hook of its own. So any later `Stop`, `PreCompact` or `SessionEnd`
|
|
897
|
-
* on this machine, AFTER its own session's targets and inside the SAME one deadline, finishes sessions that have had no firing
|
|
898
|
-
* for 10 minutes: oldest first, each under THAT session's writer lock (never waited for: a busy session is skipped), consent
|
|
899
|
-
* re-checked by the resolver, and its discovery continued (≤8 MiB and ≤8 roots per firing, R16), which can add repo targets.
|
|
900
|
-
*
|
|
901
|
-
* - A session is RETIRED from the drain once its discovery is settled and nothing is behind: it costs a stat and moves to the
|
|
902
|
-
* back of the line. Discovery stopping never stops a repo target delivering owed bytes.
|
|
903
|
-
* - A folder decision is exactly R18's (`unknown` stays unknown here; backfill offers it); only an ALREADY-sticky folder target
|
|
904
|
-
* delivers, and only up to what was scanned.
|
|
905
|
-
* - An owed target whose transcript is gone becomes a gap then (`writeOffOwed`).
|
|
906
|
-
*
|
|
907
|
-
* ⛔ It spends only the LEFTOVER of the one deadline (`settleStart + sendBudget`): the deliveries below honour the same
|
|
908
|
-
* `deadlineAtMs`, and no new session starts with under `DRAIN_MIN_LEFT_MS` left. Never throws: a hook cannot say anything.
|
|
909
|
-
*/
|
|
910
|
-
async function drainIdleSessions(opts) {
|
|
911
|
-
const { ctx, url, credential, settleStart, sendBudget } = opts;
|
|
912
|
-
const deadline = settleStart + sendBudget;
|
|
913
|
-
const candidates = listTargetRecords(ctx.home)
|
|
914
|
-
.filter(({ record }) => record.sessionId !== opts.currentSessionId)
|
|
915
|
-
.sort((a, b) => a.mtimeMs - b.mtimeMs)
|
|
916
|
-
.slice(0, DRAIN_EXAMINE_MAX);
|
|
917
|
-
for (const { path, record } of candidates) {
|
|
918
|
-
if (deadline - Date.now() < DRAIN_MIN_LEFT_MS)
|
|
919
|
-
return;
|
|
920
|
-
const now = Date.now();
|
|
921
|
-
if (now - record.firedAt < DRAIN_IDLE_MS)
|
|
922
|
-
continue;
|
|
923
|
-
// ⛔ A record the drain cannot act on (no transcript path, a transcript gone, a path confinement refuses) must still MOVE to the
|
|
924
|
-
// back of the line (C18 #7): the candidates are the oldest records by mtime, and forty of these at the front would otherwise be
|
|
925
|
-
// re-examined first on every firing and starve a live owed session behind them.
|
|
926
|
-
const rotate = () => {
|
|
927
|
-
try {
|
|
928
|
-
utimesSync(path, new Date(), new Date());
|
|
929
|
-
}
|
|
930
|
-
catch {
|
|
931
|
-
// Ordering only.
|
|
932
|
-
}
|
|
933
|
-
};
|
|
934
|
-
if (record.transcriptPath === null) {
|
|
935
|
-
rotate();
|
|
936
|
-
continue;
|
|
937
|
-
}
|
|
938
|
-
const transcript = record.transcriptPath;
|
|
939
|
-
// ⛔ C28: A SESSION OF A DIALECT THAT CANNOT ISOLATE IS NEVER OWED (Cursor; Codex until its own isolation is built). It delivers only
|
|
940
|
-
// the repo each turn ends in, on that turn's own firing; there is no later firing to finish for it and no discovery to continue, so
|
|
941
|
-
// the drain neither delivers nor writes off for it. It still moves to the back of the line, and the record is forgotten on the
|
|
942
|
-
// usual schedule (a session that has not fired for that long is over).
|
|
943
|
-
if (!dialectIsolates(ctx.home, ctx.env, transcript)) {
|
|
944
|
-
if (now - record.firedAt > DRAIN_FORGET_MS) {
|
|
945
|
-
try {
|
|
946
|
-
rmSync(path);
|
|
947
|
-
}
|
|
948
|
-
catch {
|
|
949
|
-
// A record we could not delete is just examined again.
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
else {
|
|
953
|
-
rotate();
|
|
954
|
-
}
|
|
955
|
-
continue;
|
|
956
|
-
}
|
|
957
|
-
// ⛔ CONFINEMENT, AGAIN (`/cso` finding 1): this path was read from a state file, not handed over by a gated hook input, and it
|
|
958
|
-
// reaches a read whose result is UPLOADED. A path admitted weeks ago can since have become a symlink out of the agent roots,
|
|
959
|
-
// and the record is only as trustworthy as the state directory. A file that is not there is not read, so it needs no gate
|
|
960
|
-
// (the write-off below touches no bytes).
|
|
961
|
-
if (existsSync(transcript) && !admitsTranscript(ctx.home, ctx.env, transcript)) {
|
|
962
|
-
rotate();
|
|
963
|
-
continue;
|
|
964
|
-
}
|
|
965
|
-
const size = fileSize(transcript);
|
|
966
|
-
if (size === null) {
|
|
967
|
-
// ⛔ THE WRITE-OFF IS A WRITE TO THE SESSION'S LEDGERS, so it takes the session's one writer lock like every other writer
|
|
968
|
-
// (C18 #6), never waiting: a session a delivery holds right now is skipped and examined on a later firing.
|
|
969
|
-
const writeOff = await claimSessionWriter(ctx.home, record.sessionId, { staleAfterMs: opts.staleAfterMs, deadlineAtMs: 0, wait });
|
|
970
|
-
if (writeOff === null) {
|
|
971
|
-
rotate();
|
|
972
|
-
continue;
|
|
973
|
-
}
|
|
974
|
-
try {
|
|
975
|
-
writeOffOwed(ctx.home, record, now);
|
|
976
|
-
}
|
|
977
|
-
finally {
|
|
978
|
-
writeOff.release();
|
|
979
|
-
}
|
|
980
|
-
if (now - record.firedAt > DRAIN_FORGET_MS) {
|
|
981
|
-
try {
|
|
982
|
-
rmSync(path);
|
|
983
|
-
}
|
|
984
|
-
catch {
|
|
985
|
-
// A record we could not delete is just examined again.
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
else {
|
|
989
|
-
rotate();
|
|
990
|
-
}
|
|
991
|
-
continue;
|
|
992
|
-
}
|
|
993
|
-
const live = record.targets.filter((target) => record.stopped[stopKeyOf(target)] === undefined);
|
|
994
|
-
const subFiles = findSubagentFiles(ctx, transcript);
|
|
995
|
-
const behind = live.some((target) => {
|
|
996
|
-
const state = loadSessionState(ctx.home, { repoKey: target.ledgerKey, sessionId: record.sessionId });
|
|
997
|
-
return (state.endHold !== null ||
|
|
998
|
-
fileState(state, "main").sentOffset < size ||
|
|
999
|
-
subFiles.some((file) => fileState(state, file.fileKey).sentOffset < file.size));
|
|
1000
|
-
});
|
|
1001
|
-
if (!behind && record.settled) {
|
|
1002
|
-
// Retired: nothing to send and nothing left to discover. Move it to the back of the line.
|
|
1003
|
-
try {
|
|
1004
|
-
utimesSync(path, new Date(), new Date());
|
|
1005
|
-
}
|
|
1006
|
-
catch {
|
|
1007
|
-
// Ordering only.
|
|
1008
|
-
}
|
|
1009
|
-
continue;
|
|
1010
|
-
}
|
|
1011
|
-
const claim = await claimSessionWriter(ctx.home, record.sessionId, { staleAfterMs: opts.staleAfterMs, deadlineAtMs: 0, wait });
|
|
1012
|
-
// ⛔ C29 (iii): a writer is on it right now: not ours to wait for, but it MOVES to the back of the line like every other record the
|
|
1013
|
-
// drain cannot act on (C18 #7). Left in place, forty busy records at the front would be re-examined first on every firing.
|
|
1014
|
-
if (claim === null) {
|
|
1015
|
-
rotate();
|
|
1016
|
-
continue;
|
|
1017
|
-
}
|
|
1018
|
-
try {
|
|
1019
|
-
const resolution = resolveTargets({
|
|
1020
|
-
home: ctx.home,
|
|
1021
|
-
env: ctx.env,
|
|
1022
|
-
sessionId: record.sessionId,
|
|
1023
|
-
transcriptPath: transcript,
|
|
1024
|
-
turnEndCwd: null,
|
|
1025
|
-
mode: "turn",
|
|
1026
|
-
nowMs: Date.now(),
|
|
1027
|
-
drain: true,
|
|
1028
|
-
});
|
|
1029
|
-
const input = { sessionId: record.sessionId, transcriptPath: transcript, cwd: "", eventName: "Stop" };
|
|
1030
|
-
for (const target of leastRecentlySentFirst(ctx.home, record.sessionId, resolution.targets)) {
|
|
1031
|
-
if (deadline - Date.now() < DRAIN_MIN_LEFT_MS)
|
|
1032
|
-
break;
|
|
1033
|
-
await deliverTarget({
|
|
1034
|
-
ctx,
|
|
1035
|
-
input,
|
|
1036
|
-
url,
|
|
1037
|
-
credential,
|
|
1038
|
-
target,
|
|
1039
|
-
eof: size,
|
|
1040
|
-
event: null,
|
|
1041
|
-
isSessionEnd: false,
|
|
1042
|
-
settleStart,
|
|
1043
|
-
sendBudget,
|
|
1044
|
-
consumedMs: Date.now() - settleStart,
|
|
1045
|
-
skipSend: false,
|
|
1046
|
-
owedAtSessionEnd: false,
|
|
1047
|
-
scanned: scannedOffsets(resolution.record.scan),
|
|
1048
|
-
record: resolution.record,
|
|
1049
|
-
});
|
|
1050
|
-
pruneDeliveredEndHold(ctx.home, { repoKey: target.ledgerKey, sessionId: record.sessionId });
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
finally {
|
|
1054
|
-
claim.release();
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
865
|
/**
|
|
1059
866
|
* Send whatever delegated transcripts fit in what is left of the budget.
|
|
1060
867
|
*
|
|
@@ -1104,7 +911,13 @@ export async function deliverSubagents(opts) {
|
|
|
1104
911
|
...announcedSubagentFiles(ctx, input),
|
|
1105
912
|
];
|
|
1106
913
|
const cap = opts.sizeCap;
|
|
1107
|
-
const
|
|
914
|
+
const fireCap = opts.firingCap;
|
|
915
|
+
const files = cap === undefined && fireCap === undefined
|
|
916
|
+
? discovered
|
|
917
|
+
: discovered.map((f) => ({
|
|
918
|
+
...f,
|
|
919
|
+
size: Math.min(f.size, cap === undefined ? f.size : (cap[f.fileKey] ?? 0), fireCap === undefined ? f.size : (fireCap[f.fileKey] ?? 0)),
|
|
920
|
+
}));
|
|
1108
921
|
if (files.length === 0)
|
|
1109
922
|
return { files, held: false, deliveries: [] };
|
|
1110
923
|
// ⚠ SKIP WHAT HAS NOTHING PENDING, BEFORE THE CAP APPLIES — and this is not an
|
|
@@ -1125,9 +938,13 @@ export async function deliverSubagents(opts) {
|
|
|
1125
938
|
return { files, held: false, deliveries: [] };
|
|
1126
939
|
let held = false;
|
|
1127
940
|
const deliveries = [];
|
|
1128
|
-
|
|
941
|
+
let pauseNextMs = opts.unboundedTimeoutMs === undefined ? null : (opts.pauseFirstMs ?? null);
|
|
942
|
+
for (const file of opts.unboundedTimeoutMs === undefined ? pending.slice(0, MAX_SUBAGENT_FILES) : pending) {
|
|
943
|
+
if (pauseNextMs !== null)
|
|
944
|
+
await wait(pauseNextMs);
|
|
945
|
+
const requestStartedAt = Date.now();
|
|
1129
946
|
const consumedMs = Date.now() - startedAt;
|
|
1130
|
-
if (sendBudget - consumedMs < MIN_SUBAGENT_BUDGET_MS)
|
|
947
|
+
if (opts.unboundedTimeoutMs === undefined && sendBudget - consumedMs < MIN_SUBAGENT_BUDGET_MS)
|
|
1131
948
|
break;
|
|
1132
949
|
if (opts.heartbeat !== undefined && !opts.heartbeat())
|
|
1133
950
|
break;
|
|
@@ -1145,12 +962,14 @@ export async function deliverSubagents(opts) {
|
|
|
1145
962
|
// that contains IT is what names its producer.
|
|
1146
963
|
transcriptPath: file.path,
|
|
1147
964
|
// The remainder, MEASURED. This is what keeps N files inside one budget.
|
|
1148
|
-
timeoutMs: sendTimeoutMs(sendBudget, consumedMs),
|
|
965
|
+
timeoutMs: opts.unboundedTimeoutMs ?? sendTimeoutMs(sendBudget, consumedMs),
|
|
1149
966
|
// PR 4a: the same origin `consumedMs` is measured from (`startedAt` is the main delivery's `settleStart`).
|
|
1150
|
-
deadlineAtMs: startedAt + sendBudget,
|
|
967
|
+
...(opts.unboundedTimeoutMs === undefined ? { deadlineAtMs: startedAt + sendBudget } : {}),
|
|
1151
968
|
nowMs: Date.now(),
|
|
1152
969
|
byHook,
|
|
970
|
+
...(opts.liveAtMs === undefined ? {} : { liveAtMs: opts.liveAtMs }),
|
|
1153
971
|
...(opts.heartbeat === undefined ? {} : { beforeRequest: opts.heartbeat }),
|
|
972
|
+
...(opts.unboundedTimeoutMs === undefined ? {} : { oneDelta: true }),
|
|
1154
973
|
}, file.size,
|
|
1155
974
|
// The SAME reader as the main transcript, so `CR-024d`'s redaction and
|
|
1156
975
|
// `/cso` finding 1's confinement both apply. A delegated agent reading a
|
|
@@ -1159,6 +978,7 @@ export async function deliverSubagents(opts) {
|
|
|
1159
978
|
deliveries.push(delivery);
|
|
1160
979
|
if (endedHeld(delivery))
|
|
1161
980
|
held = true;
|
|
981
|
+
pauseNextMs = opts.unboundedTimeoutMs !== undefined && delivery.kind === "attempted" ? unboundedPaceMs(Date.now() - requestStartedAt) : null;
|
|
1162
982
|
}
|
|
1163
983
|
return { files, held, deliveries };
|
|
1164
984
|
}
|
|
@@ -1236,8 +1056,17 @@ function endedHeld(delivery) {
|
|
|
1236
1056
|
*/
|
|
1237
1057
|
function stampGaps(ctx, input, projectKey, files,
|
|
1238
1058
|
/** A delivery in THIS invocation ended held — see `endedHeld`. */
|
|
1239
|
-
heldThisInvocation, nowMs
|
|
1059
|
+
heldThisInvocation, nowMs,
|
|
1060
|
+
/**
|
|
1061
|
+
* ⛔ U6b (F1): the time a REPLAY is for (`firing.at`). A worker replays a SessionEnd hours late after an outage or a backlog, and `Date.now()` there
|
|
1062
|
+
* stamped an ended session LIVE now (`liveAt`) and ended now (`endedAt`), so a human's commit (no env session id) became `sole_live_session` for
|
|
1063
|
+
* a session that ended hours ago, and every `later` retry re-stamped it. A replay stamps the hook's time: `endedAt = at`, `liveAt = max(stored, at)`.
|
|
1064
|
+
* Absent for an inline caller, which IS now.
|
|
1065
|
+
*/
|
|
1066
|
+
replayAt) {
|
|
1240
1067
|
const key = { repoKey: projectKey, sessionId: input.sessionId };
|
|
1068
|
+
const at = replayAt ?? nowMs;
|
|
1069
|
+
const liveAtOf = (stored) => (replayAt === undefined ? nowMs : Math.max(stored ?? 0, replayAt));
|
|
1241
1070
|
const owed = {};
|
|
1242
1071
|
// Re-read per file rather than once: each `deliver()` above saved, so a
|
|
1243
1072
|
// snapshot taken before the loop would stamp gaps over work that succeeded.
|
|
@@ -1256,19 +1085,19 @@ heldThisInvocation, nowMs) {
|
|
|
1256
1085
|
continue;
|
|
1257
1086
|
}
|
|
1258
1087
|
// D95's case, unchanged: the server was answering and the BUDGET ran out.
|
|
1259
|
-
saveSessionState(ctx.home, key, { ...withFileState(session, file.fileKey, markSkipped(current, current.sentOffset, file.size)), liveAt:
|
|
1088
|
+
saveSessionState(ctx.home, key, { ...withFileState(session, file.fileKey, markSkipped(current, current.sentOffset, file.size)), liveAt: liveAtOf(session.liveAt) });
|
|
1260
1089
|
}
|
|
1261
1090
|
// The positive record. Written when there is a hold to state OR an old one to
|
|
1262
1091
|
// clear — a `SessionEnd` that owes nothing must not leave a previous one's
|
|
1263
1092
|
// claim standing, and one that never held anything need not touch the file.
|
|
1264
1093
|
const session = loadSessionState(ctx.home, key);
|
|
1265
|
-
const endHold = Object.keys(owed).length > 0 ? { at
|
|
1094
|
+
const endHold = Object.keys(owed).length > 0 ? { at, eof: owed } : null;
|
|
1266
1095
|
// ⭐ `TODOS[146]`: also record THAT the session ended, so `status` can tell a turn that is open because the
|
|
1267
1096
|
// session is live from one that is open because it is over. Only for a session that has done something
|
|
1268
1097
|
// (`seq > 0`): a SessionEnd for a session that never delivered must not mint a state file, which would
|
|
1269
1098
|
// make it "live" to the attribution ladder.
|
|
1270
1099
|
if (endHold !== null || session.endHold !== null || session.seq > 0) {
|
|
1271
|
-
saveSessionState(ctx.home, key, { ...session, endHold, endedAt:
|
|
1100
|
+
saveSessionState(ctx.home, key, { ...session, endHold, endedAt: at, liveAt: liveAtOf(session.liveAt) });
|
|
1272
1101
|
}
|
|
1273
1102
|
}
|
|
1274
1103
|
/**
|
|
@@ -1290,7 +1119,7 @@ heldThisInvocation, nowMs) {
|
|
|
1290
1119
|
* A missing or unreadable directory is simply "no sub-agents" — silent, per the
|
|
1291
1120
|
* hook contract.
|
|
1292
1121
|
*/
|
|
1293
|
-
function findSubagentFiles(ctx, transcriptPath) {
|
|
1122
|
+
export function findSubagentFiles(ctx, transcriptPath) {
|
|
1294
1123
|
// ⛔ THE ONE COPY lives in `session_targets.ts` (`findSubagentTranscripts`), shared with the evidence reader so discovery and
|
|
1295
1124
|
// delivery can never disagree about which files are a session's. Every rule above this line (the dialect's mechanism, the root
|
|
1296
1125
|
// that selects it, `agent-*.jsonl` and never `agent-*`, confinement of every entry) is enforced there.
|