@vibe-commit/capture 0.9.0 → 0.11.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 +49 -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 +131 -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 +63 -0
- package/dist/commands/upload_worker.js +336 -0
- package/dist/commands/upload_worker.js.map +1 -0
- package/dist/commands/why.d.ts +19 -1
- package/dist/commands/why.js +35 -3
- package/dist/commands/why.js.map +1 -1
- package/dist/commit_files.d.ts +64 -0
- package/dist/commit_files.js +83 -0
- package/dist/commit_files.js.map +1 -0
- package/dist/copy/strings.d.ts +34 -14
- package/dist/copy/strings.js +51 -14
- 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 +335 -507
- 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 +11 -0
- package/dist/hooks/post_commit.js +16 -8
- package/dist/hooks/post_commit.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 +63 -4
- package/dist/post.js +76 -24
- package/dist/post.js.map +1 -1
- package/dist/replay.d.ts +167 -0
- package/dist/replay.js +301 -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 +55 -21
- package/dist/spool.js +182 -38
- 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/dist/version.js.map +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
47
|
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,
|
|
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,41 @@ 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
|
+
// The hook's clock, when it has one. The worker has none: `undefined` all the way down, and a fixed timeout in its place.
|
|
658
|
+
const hookClock = pace.kind === "hook" ? pace : null;
|
|
659
|
+
const deadlineAtMs = hookClock === null ? undefined : hookClock.settleStart + hookClock.sendBudget;
|
|
660
|
+
const requestTimeoutMs = hookClock === null ? pace.timeoutMs : sendTimeoutMs(hookClock.sendBudget, hookClock.consumedMs);
|
|
748
661
|
// ⛔ A FOLDER stream delivers only up to what the resolver has SCANNED (P1, A-T3 requirement b): bytes appended between the scan
|
|
749
662
|
// 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
663
|
// limit: its bytes are its own whatever else the session named.
|
|
751
664
|
// 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
665
|
// cwd, the path rule, the always-withheld types); the two facts the checkpoint cannot hold are learned IN-SPAN, in order (`pastCursorOf`).
|
|
753
666
|
const folderCap = target.kind === "folder" ? (opts.scanned ?? {}) : undefined;
|
|
754
|
-
|
|
667
|
+
let eof = folderCap === undefined ? opts.eof : Math.min(opts.eof, folderCap.main ?? 0);
|
|
668
|
+
// ⛔ U4, TRAP 1 — A REPLAYED FIRING NEVER GOES TO `nextSpan` WITH AN `eof` BEHIND `sentOffset`: that reads as "the file was replaced" and
|
|
669
|
+
// resyncs the whole session from byte 0. Compare with the file's REAL size first (`replayDecision`): a rotation delivers to the real size,
|
|
670
|
+
// an already-delivered firing (a duplicate, or one older than a `finalize`) is a no-op.
|
|
671
|
+
let replayNoop = false;
|
|
672
|
+
if (asOf !== undefined) {
|
|
673
|
+
const real = fileSize(input.transcriptPath);
|
|
674
|
+
if (real === null) {
|
|
675
|
+
return { target, delivery: { kind: "nothing-to-send", why: "read-failed" }, subagents: { files: [], held: false, deliveries: [] }, stopped: false, reason: { kind: "not-progressed", why: "read-failed" } };
|
|
676
|
+
}
|
|
677
|
+
const sent = fileState(loadSessionState(ctx.home, { repoKey: target.ledgerKey, sessionId: input.sessionId }), "main").sentOffset;
|
|
678
|
+
const decision = replayDecision(sent, eof, real);
|
|
679
|
+
if (decision.kind === "noop")
|
|
680
|
+
replayNoop = true;
|
|
681
|
+
else
|
|
682
|
+
eof = decision.eof;
|
|
683
|
+
}
|
|
755
684
|
const baseIsolation = isolationOf(target, opts.record);
|
|
756
685
|
const isolation = opts.record !== undefined && opts.scanned !== undefined && dialectIsolates(ctx.home, ctx.env, input.transcriptPath)
|
|
757
|
-
? { ...baseIsolation, past: pastCursorOf(baseIsolation, opts.scanned, {
|
|
686
|
+
? { ...baseIsolation, past: pastCursorOf(baseIsolation, opts.scanned, deadlineAtMs === undefined ? {} : { deadlineAtMs }) }
|
|
758
687
|
: baseIsolation;
|
|
759
688
|
const sessionId = input.sessionId;
|
|
760
689
|
const ledgerKey = target.ledgerKey;
|
|
@@ -767,9 +696,15 @@ async function deliverTarget(opts) {
|
|
|
767
696
|
// return value is NOT discarded, it feeds the durable `set_aside` tally.
|
|
768
697
|
for (const projectKey of sourceKeys) {
|
|
769
698
|
const spoolKey = { repoKey: projectKey, sessionId: input.sessionId };
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
699
|
+
// U4: a NO-OP firing (already delivered, or a duplicate) promotes nothing: its pending lines are judged by the firing that carries them.
|
|
700
|
+
if (!replayNoop) {
|
|
701
|
+
// A replay judges the session's liveness as the HOOK saw it before it stamped (`firing.prior`), not by the state file the worker
|
|
702
|
+
// may have since written: stamping first would flip `stale-own` to `first-turn` and keep a lingering-id line (P2 point 4).
|
|
703
|
+
const pendingMode = asOf === undefined ? pendingModeFor(ctx.home, spoolKey) : modeFromPrior(ctx.home, spoolKey, asOf);
|
|
704
|
+
// The skew guard stays on `Date.now()`: on `firing.at`, a prompt written after the firing would read as `future` and keep any line.
|
|
705
|
+
const bound = promotionBound(ctx.home, spoolKey, input.transcriptPath, Date.now());
|
|
706
|
+
promotePending(ctx.home, spoolKey, "this-session-is-running", bound, pendingMode, asOf?.at);
|
|
707
|
+
}
|
|
773
708
|
if (event === "Stop" || isSessionEnd) {
|
|
774
709
|
pruneOrphanPending(ctx.home, projectKey, Date.now());
|
|
775
710
|
const prunedInferred = pruneExpiredInferred(ctx.home, projectKey, Date.now());
|
|
@@ -783,11 +718,12 @@ async function deliverTarget(opts) {
|
|
|
783
718
|
if (!opts.skipSend) {
|
|
784
719
|
// ⛔ THE UNION, acknowledged per source (O7): commits, rewrites and inferred squashes of every work tree of this repo for this
|
|
785
720
|
// session. A single-worktree target reads exactly the three calls the hook always made.
|
|
786
|
-
|
|
721
|
+
// U4: a replay carries only the commits that existed when its hook fired (the pin). A no-op firing carries none.
|
|
722
|
+
const union = readSpoolUnion(ctx.home, sessionId, ledgerKey, target.worktrees, asOf?.at);
|
|
787
723
|
// ⛔ 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
724
|
// `checkout` or `reset --hard`. Null on an empty repository. A FOLDER has no HEAD: no spawn.
|
|
789
725
|
const head = target.kind === "repo" ? headRef(ledgerKey) : null;
|
|
790
|
-
delivery = await deliver({
|
|
726
|
+
delivery = replayNoop ? { kind: "nothing-to-send" } : await deliver({
|
|
791
727
|
home: ctx.home,
|
|
792
728
|
env: ctx.env,
|
|
793
729
|
url,
|
|
@@ -799,11 +735,16 @@ async function deliverTarget(opts) {
|
|
|
799
735
|
// ⛔ THE PATH, so `deliver()` can derive `X-Agent` from the root that contains it (`CR-204`, D177 §7).
|
|
800
736
|
transcriptPath: input.transcriptPath,
|
|
801
737
|
// ⛔ `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:
|
|
738
|
+
timeoutMs: requestTimeoutMs,
|
|
739
|
+
// 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.
|
|
740
|
+
...(deadlineAtMs === undefined ? {} : { deadlineAtMs }),
|
|
805
741
|
nowMs: Date.now(),
|
|
806
|
-
|
|
742
|
+
...(heartbeat === undefined ? {} : { beforeRequest: heartbeat }),
|
|
743
|
+
// U5: the worker sends one delta and comes back (fairness and pacing); a hook loops to its budget.
|
|
744
|
+
...(hookClock === null ? { oneDelta: true } : {}),
|
|
745
|
+
// U4: a replay is not a hook. It never stamps "now"; it stamps the moment its hook fired (`liveAtMs`, merged by `max`).
|
|
746
|
+
byHook: asOf === undefined && hookClock !== null,
|
|
747
|
+
...(asOf === undefined ? {} : { liveAtMs: asOf.at }),
|
|
807
748
|
// ⛔ THE MAIN DELIVERY ONLY. Sub-agent deliveries leave all three unset: each is its own `capture_id`, and
|
|
808
749
|
// `capture_commits`' PK is `(org_id, capture_id, commit_sha)`, so attaching these to all of them would admit several
|
|
809
750
|
// legal, permanent rows for one commit (D154).
|
|
@@ -811,6 +752,8 @@ async function deliverTarget(opts) {
|
|
|
811
752
|
// ⛔ THE WHOLE OBJECT, NOT `spooled.shas` (`T5`, `D190 §1`): shas and rungs are only safe as a pair, and `count` is the
|
|
812
753
|
// number of spool LINES consumed, which is what a 2xx truncates by.
|
|
813
754
|
commits: union.commits,
|
|
755
|
+
// E8: this target IS a granted repo (the resolver only returns granted targets); a folder has no commits to describe.
|
|
756
|
+
commitFiles: target.kind === "repo",
|
|
814
757
|
rewrites: union.rewrites.pairs,
|
|
815
758
|
inferredRewrites: union.inferred.pairs,
|
|
816
759
|
inferredGroups: union.inferred.groups,
|
|
@@ -823,7 +766,7 @@ async function deliverTarget(opts) {
|
|
|
823
766
|
// a per-target stop so the next firing does not ask again. Never credential-wide, never `fatal`.
|
|
824
767
|
if (delivery.kind === "attempted" && delivery.detail?.code === FOLDER_REPOSITORY_FORBIDDEN) {
|
|
825
768
|
stopTarget(ctx.home, sessionId, target, FOLDER_REPOSITORY_FORBIDDEN, Date.now());
|
|
826
|
-
return { target, delivery, subagents, stopped: true };
|
|
769
|
+
return { target, delivery, subagents, stopped: true, reason: targetReason(delivery, 0, eof, true) };
|
|
827
770
|
}
|
|
828
771
|
// --- DELEGATED WORK. `CR-124`, D76. ---
|
|
829
772
|
// Claude Code writes sub-agent transcripts to `<session>/subagents/`, which `transcript_path` never names: ~16% of
|
|
@@ -836,10 +779,16 @@ async function deliverTarget(opts) {
|
|
|
836
779
|
projectKey: ledgerKey,
|
|
837
780
|
redactionRoots: target.redactionRoots,
|
|
838
781
|
repoSlug: target.slug,
|
|
839
|
-
startedAt: settleStart,
|
|
840
|
-
sendBudget,
|
|
841
|
-
byHook:
|
|
782
|
+
startedAt: hookClock?.settleStart ?? Date.now(),
|
|
783
|
+
sendBudget: hookClock?.sendBudget ?? 0,
|
|
784
|
+
byHook: asOf === undefined && hookClock !== null,
|
|
785
|
+
...(asOf === undefined ? {} : { liveAtMs: asOf.at }),
|
|
786
|
+
...(heartbeat === undefined ? {} : { heartbeat }),
|
|
787
|
+
...(hookClock === null ? { unboundedTimeoutMs: requestTimeoutMs } : {}),
|
|
788
|
+
// F2d: one request at a time per machine. The main delta just went; the first sub-agent request waits the floor.
|
|
789
|
+
...(hookClock === null && delivery.kind === "attempted" ? { pauseFirstMs: UNBOUNDED_PACE_FLOOR_MS } : {}),
|
|
842
790
|
...(folderCap === undefined ? {} : { sizeCap: folderCap }),
|
|
791
|
+
...(asOf?.subCap === undefined ? {} : { firingCap: asOf.subCap }),
|
|
843
792
|
isolation,
|
|
844
793
|
});
|
|
845
794
|
}
|
|
@@ -851,28 +800,51 @@ async function deliverTarget(opts) {
|
|
|
851
800
|
// nothing to return. ⛔ And `CR-228` (`TODOS[142]`): it writes off only what nothing explains. `held` is whether ANY delivery
|
|
852
801
|
// of this target ended `later`/`fatal`, or was refused by a standing stop. R19 · D22 adds the other case that is HELD, not
|
|
853
802
|
// written off: `owedAtSessionEnd`, a session other than the one-target in-repo case, whose later firings can finish it.
|
|
803
|
+
//
|
|
804
|
+
// ⛔ U5, D6 — THE WORKER HAS NO BUDGET TO RUN OUT, so it never writes off what is merely BEHIND:
|
|
805
|
+
// - a step that sent one delta and has more owed (`more`) stamps NOTHING: the next step continues, and stamping here would `markSkipped` the rest
|
|
806
|
+
// 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);
|
|
807
|
+
// - 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
|
|
808
|
+
// what the server refused (`never`), an oversize record, a deleted transcript and the backlog caps stamp their gaps where they happen.
|
|
854
809
|
if (isSessionEnd) {
|
|
855
|
-
|
|
810
|
+
const moreOwed = hookClock === null && [delivery, ...subagents.deliveries].some((d) => d.kind === "attempted" && d.more === true);
|
|
811
|
+
if (!moreOwed) {
|
|
812
|
+
stampGaps(ctx, input, ledgerKey, [{ fileKey: "main", size: eof }, ...subagents.files], endedHeld(delivery) || subagents.held || opts.owedAtSessionEnd || hookClock === null, Date.now(), asOf?.at);
|
|
813
|
+
}
|
|
856
814
|
}
|
|
857
|
-
return {
|
|
815
|
+
return {
|
|
816
|
+
target,
|
|
817
|
+
delivery,
|
|
818
|
+
subagents,
|
|
819
|
+
stopped,
|
|
820
|
+
reason: targetReason(delivery, fileState(loadSessionState(ctx.home, { repoKey: ledgerKey, sessionId }), "main").sentOffset, eof, stopped),
|
|
821
|
+
};
|
|
858
822
|
}
|
|
859
823
|
/** The resolver's scanned offset per stream, for the folder cap. */
|
|
860
824
|
function scannedOffsets(scan) {
|
|
861
825
|
return Object.fromEntries(Object.entries(scan).map(([fileKey, cursor]) => [fileKey, cursor.offset]));
|
|
862
826
|
}
|
|
827
|
+
/**
|
|
828
|
+
* 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
|
|
829
|
+
* 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
|
|
830
|
+
* after the whole step let a fast server take thirteen of them back to back.
|
|
831
|
+
*/
|
|
832
|
+
export const UNBOUNDED_PACE_FLOOR_MS = 250;
|
|
833
|
+
export const UNBOUNDED_PACE_CAP_MS = 30_000;
|
|
834
|
+
export function unboundedPaceMs(elapsedMs) {
|
|
835
|
+
return Math.min(UNBOUNDED_PACE_CAP_MS, Math.max(UNBOUNDED_PACE_FLOOR_MS, elapsedMs));
|
|
836
|
+
}
|
|
863
837
|
/** A session with no firing of its own for this long is IDLE: another session's firing may finish its owed bytes (R19 · D22). */
|
|
864
|
-
const DRAIN_IDLE_MS = 10 * 60_000;
|
|
838
|
+
export const DRAIN_IDLE_MS = 10 * 60_000;
|
|
865
839
|
/** Session records one firing looks at. Examined ones move to the back of the line (their mtime moves), so none starves. */
|
|
866
|
-
const DRAIN_EXAMINE_MAX = 40;
|
|
840
|
+
export const DRAIN_EXAMINE_MAX = 40;
|
|
867
841
|
/** A record whose transcript is gone and that owes nothing is forgotten after this long. */
|
|
868
|
-
const DRAIN_FORGET_MS = 30 * 24 * 3600_000;
|
|
869
|
-
/** Do not START another session with less than this left of the shared deadline: its first request could not finish. */
|
|
870
|
-
const DRAIN_MIN_LEFT_MS = 250;
|
|
842
|
+
export const DRAIN_FORGET_MS = 30 * 24 * 3600_000;
|
|
871
843
|
/**
|
|
872
844
|
* An owed target whose transcript is GONE (Claude Code deletes transcripts ~30 days on) can never be sent: its hold becomes the
|
|
873
845
|
* honest gap now (R19 · D22), and the hold is cleared. Whatever was already delivered is untouched.
|
|
874
846
|
*/
|
|
875
|
-
function writeOffOwed(home, record, nowMs) {
|
|
847
|
+
export function writeOffOwed(home, record, nowMs) {
|
|
876
848
|
for (const target of record.targets) {
|
|
877
849
|
const key = { repoKey: target.ledgerKey, sessionId: record.sessionId };
|
|
878
850
|
let session = loadSessionState(home, key);
|
|
@@ -887,172 +859,6 @@ function writeOffOwed(home, record, nowMs) {
|
|
|
887
859
|
void nowMs;
|
|
888
860
|
}
|
|
889
861
|
}
|
|
890
|
-
/**
|
|
891
|
-
* ⛔ THE IDLE-SESSION DRAIN (R19 · D22 · A). What finishes a session's unsent bytes after its LAST hook.
|
|
892
|
-
*
|
|
893
|
-
* Each hook run can send a few megabytes, and a session whose last hook ran with bytes still behind (a multi-repo session at
|
|
894
|
-
* `SessionEnd`, a repo found late in a long one) has no next hook of its own. So any later `Stop`, `PreCompact` or `SessionEnd`
|
|
895
|
-
* on this machine, AFTER its own session's targets and inside the SAME one deadline, finishes sessions that have had no firing
|
|
896
|
-
* for 10 minutes: oldest first, each under THAT session's writer lock (never waited for: a busy session is skipped), consent
|
|
897
|
-
* re-checked by the resolver, and its discovery continued (≤8 MiB and ≤8 roots per firing, R16), which can add repo targets.
|
|
898
|
-
*
|
|
899
|
-
* - A session is RETIRED from the drain once its discovery is settled and nothing is behind: it costs a stat and moves to the
|
|
900
|
-
* back of the line. Discovery stopping never stops a repo target delivering owed bytes.
|
|
901
|
-
* - A folder decision is exactly R18's (`unknown` stays unknown here; backfill offers it); only an ALREADY-sticky folder target
|
|
902
|
-
* delivers, and only up to what was scanned.
|
|
903
|
-
* - An owed target whose transcript is gone becomes a gap then (`writeOffOwed`).
|
|
904
|
-
*
|
|
905
|
-
* ⛔ It spends only the LEFTOVER of the one deadline (`settleStart + sendBudget`): the deliveries below honour the same
|
|
906
|
-
* `deadlineAtMs`, and no new session starts with under `DRAIN_MIN_LEFT_MS` left. Never throws: a hook cannot say anything.
|
|
907
|
-
*/
|
|
908
|
-
async function drainIdleSessions(opts) {
|
|
909
|
-
const { ctx, url, credential, settleStart, sendBudget } = opts;
|
|
910
|
-
const deadline = settleStart + sendBudget;
|
|
911
|
-
const candidates = listTargetRecords(ctx.home)
|
|
912
|
-
.filter(({ record }) => record.sessionId !== opts.currentSessionId)
|
|
913
|
-
.sort((a, b) => a.mtimeMs - b.mtimeMs)
|
|
914
|
-
.slice(0, DRAIN_EXAMINE_MAX);
|
|
915
|
-
for (const { path, record } of candidates) {
|
|
916
|
-
if (deadline - Date.now() < DRAIN_MIN_LEFT_MS)
|
|
917
|
-
return;
|
|
918
|
-
const now = Date.now();
|
|
919
|
-
if (now - record.firedAt < DRAIN_IDLE_MS)
|
|
920
|
-
continue;
|
|
921
|
-
// ⛔ A record the drain cannot act on (no transcript path, a transcript gone, a path confinement refuses) must still MOVE to the
|
|
922
|
-
// back of the line (C18 #7): the candidates are the oldest records by mtime, and forty of these at the front would otherwise be
|
|
923
|
-
// re-examined first on every firing and starve a live owed session behind them.
|
|
924
|
-
const rotate = () => {
|
|
925
|
-
try {
|
|
926
|
-
utimesSync(path, new Date(), new Date());
|
|
927
|
-
}
|
|
928
|
-
catch {
|
|
929
|
-
// Ordering only.
|
|
930
|
-
}
|
|
931
|
-
};
|
|
932
|
-
if (record.transcriptPath === null) {
|
|
933
|
-
rotate();
|
|
934
|
-
continue;
|
|
935
|
-
}
|
|
936
|
-
const transcript = record.transcriptPath;
|
|
937
|
-
// ⛔ C28: A SESSION OF A DIALECT THAT CANNOT ISOLATE IS NEVER OWED (Cursor; Codex until its own isolation is built). It delivers only
|
|
938
|
-
// 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
|
|
939
|
-
// 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
|
|
940
|
-
// usual schedule (a session that has not fired for that long is over).
|
|
941
|
-
if (!dialectIsolates(ctx.home, ctx.env, transcript)) {
|
|
942
|
-
if (now - record.firedAt > DRAIN_FORGET_MS) {
|
|
943
|
-
try {
|
|
944
|
-
rmSync(path);
|
|
945
|
-
}
|
|
946
|
-
catch {
|
|
947
|
-
// A record we could not delete is just examined again.
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
else {
|
|
951
|
-
rotate();
|
|
952
|
-
}
|
|
953
|
-
continue;
|
|
954
|
-
}
|
|
955
|
-
// ⛔ CONFINEMENT, AGAIN (`/cso` finding 1): this path was read from a state file, not handed over by a gated hook input, and it
|
|
956
|
-
// reaches a read whose result is UPLOADED. A path admitted weeks ago can since have become a symlink out of the agent roots,
|
|
957
|
-
// 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
|
|
958
|
-
// (the write-off below touches no bytes).
|
|
959
|
-
if (existsSync(transcript) && !admitsTranscript(ctx.home, ctx.env, transcript)) {
|
|
960
|
-
rotate();
|
|
961
|
-
continue;
|
|
962
|
-
}
|
|
963
|
-
const size = fileSize(transcript);
|
|
964
|
-
if (size === null) {
|
|
965
|
-
// ⛔ THE WRITE-OFF IS A WRITE TO THE SESSION'S LEDGERS, so it takes the session's one writer lock like every other writer
|
|
966
|
-
// (C18 #6), never waiting: a session a delivery holds right now is skipped and examined on a later firing.
|
|
967
|
-
const writeOff = await claimSessionWriter(ctx.home, record.sessionId, { staleAfterMs: opts.staleAfterMs, deadlineAtMs: 0, wait });
|
|
968
|
-
if (writeOff === null) {
|
|
969
|
-
rotate();
|
|
970
|
-
continue;
|
|
971
|
-
}
|
|
972
|
-
try {
|
|
973
|
-
writeOffOwed(ctx.home, record, now);
|
|
974
|
-
}
|
|
975
|
-
finally {
|
|
976
|
-
writeOff.release();
|
|
977
|
-
}
|
|
978
|
-
if (now - record.firedAt > DRAIN_FORGET_MS) {
|
|
979
|
-
try {
|
|
980
|
-
rmSync(path);
|
|
981
|
-
}
|
|
982
|
-
catch {
|
|
983
|
-
// A record we could not delete is just examined again.
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
else {
|
|
987
|
-
rotate();
|
|
988
|
-
}
|
|
989
|
-
continue;
|
|
990
|
-
}
|
|
991
|
-
const live = record.targets.filter((target) => record.stopped[stopKeyOf(target)] === undefined);
|
|
992
|
-
const subFiles = findSubagentFiles(ctx, transcript);
|
|
993
|
-
const behind = live.some((target) => {
|
|
994
|
-
const state = loadSessionState(ctx.home, { repoKey: target.ledgerKey, sessionId: record.sessionId });
|
|
995
|
-
return (state.endHold !== null ||
|
|
996
|
-
fileState(state, "main").sentOffset < size ||
|
|
997
|
-
subFiles.some((file) => fileState(state, file.fileKey).sentOffset < file.size));
|
|
998
|
-
});
|
|
999
|
-
if (!behind && record.settled) {
|
|
1000
|
-
// Retired: nothing to send and nothing left to discover. Move it to the back of the line.
|
|
1001
|
-
try {
|
|
1002
|
-
utimesSync(path, new Date(), new Date());
|
|
1003
|
-
}
|
|
1004
|
-
catch {
|
|
1005
|
-
// Ordering only.
|
|
1006
|
-
}
|
|
1007
|
-
continue;
|
|
1008
|
-
}
|
|
1009
|
-
const claim = await claimSessionWriter(ctx.home, record.sessionId, { staleAfterMs: opts.staleAfterMs, deadlineAtMs: 0, wait });
|
|
1010
|
-
// ⛔ 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
|
|
1011
|
-
// drain cannot act on (C18 #7). Left in place, forty busy records at the front would be re-examined first on every firing.
|
|
1012
|
-
if (claim === null) {
|
|
1013
|
-
rotate();
|
|
1014
|
-
continue;
|
|
1015
|
-
}
|
|
1016
|
-
try {
|
|
1017
|
-
const resolution = resolveTargets({
|
|
1018
|
-
home: ctx.home,
|
|
1019
|
-
env: ctx.env,
|
|
1020
|
-
sessionId: record.sessionId,
|
|
1021
|
-
transcriptPath: transcript,
|
|
1022
|
-
turnEndCwd: null,
|
|
1023
|
-
mode: "turn",
|
|
1024
|
-
nowMs: Date.now(),
|
|
1025
|
-
drain: true,
|
|
1026
|
-
});
|
|
1027
|
-
const input = { sessionId: record.sessionId, transcriptPath: transcript, cwd: "", eventName: "Stop" };
|
|
1028
|
-
for (const target of leastRecentlySentFirst(ctx.home, record.sessionId, resolution.targets)) {
|
|
1029
|
-
if (deadline - Date.now() < DRAIN_MIN_LEFT_MS)
|
|
1030
|
-
break;
|
|
1031
|
-
await deliverTarget({
|
|
1032
|
-
ctx,
|
|
1033
|
-
input,
|
|
1034
|
-
url,
|
|
1035
|
-
credential,
|
|
1036
|
-
target,
|
|
1037
|
-
eof: size,
|
|
1038
|
-
event: null,
|
|
1039
|
-
isSessionEnd: false,
|
|
1040
|
-
settleStart,
|
|
1041
|
-
sendBudget,
|
|
1042
|
-
consumedMs: Date.now() - settleStart,
|
|
1043
|
-
skipSend: false,
|
|
1044
|
-
owedAtSessionEnd: false,
|
|
1045
|
-
scanned: scannedOffsets(resolution.record.scan),
|
|
1046
|
-
record: resolution.record,
|
|
1047
|
-
});
|
|
1048
|
-
pruneDeliveredEndHold(ctx.home, { repoKey: target.ledgerKey, sessionId: record.sessionId });
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
finally {
|
|
1052
|
-
claim.release();
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
862
|
/**
|
|
1057
863
|
* Send whatever delegated transcripts fit in what is left of the budget.
|
|
1058
864
|
*
|
|
@@ -1102,7 +908,13 @@ export async function deliverSubagents(opts) {
|
|
|
1102
908
|
...announcedSubagentFiles(ctx, input),
|
|
1103
909
|
];
|
|
1104
910
|
const cap = opts.sizeCap;
|
|
1105
|
-
const
|
|
911
|
+
const fireCap = opts.firingCap;
|
|
912
|
+
const files = cap === undefined && fireCap === undefined
|
|
913
|
+
? discovered
|
|
914
|
+
: discovered.map((f) => ({
|
|
915
|
+
...f,
|
|
916
|
+
size: Math.min(f.size, cap === undefined ? f.size : (cap[f.fileKey] ?? 0), fireCap === undefined ? f.size : (fireCap[f.fileKey] ?? 0)),
|
|
917
|
+
}));
|
|
1106
918
|
if (files.length === 0)
|
|
1107
919
|
return { files, held: false, deliveries: [] };
|
|
1108
920
|
// ⚠ SKIP WHAT HAS NOTHING PENDING, BEFORE THE CAP APPLIES — and this is not an
|
|
@@ -1123,9 +935,13 @@ export async function deliverSubagents(opts) {
|
|
|
1123
935
|
return { files, held: false, deliveries: [] };
|
|
1124
936
|
let held = false;
|
|
1125
937
|
const deliveries = [];
|
|
1126
|
-
|
|
938
|
+
let pauseNextMs = opts.unboundedTimeoutMs === undefined ? null : (opts.pauseFirstMs ?? null);
|
|
939
|
+
for (const file of opts.unboundedTimeoutMs === undefined ? pending.slice(0, MAX_SUBAGENT_FILES) : pending) {
|
|
940
|
+
if (pauseNextMs !== null)
|
|
941
|
+
await wait(pauseNextMs);
|
|
942
|
+
const requestStartedAt = Date.now();
|
|
1127
943
|
const consumedMs = Date.now() - startedAt;
|
|
1128
|
-
if (sendBudget - consumedMs < MIN_SUBAGENT_BUDGET_MS)
|
|
944
|
+
if (opts.unboundedTimeoutMs === undefined && sendBudget - consumedMs < MIN_SUBAGENT_BUDGET_MS)
|
|
1129
945
|
break;
|
|
1130
946
|
if (opts.heartbeat !== undefined && !opts.heartbeat())
|
|
1131
947
|
break;
|
|
@@ -1143,12 +959,14 @@ export async function deliverSubagents(opts) {
|
|
|
1143
959
|
// that contains IT is what names its producer.
|
|
1144
960
|
transcriptPath: file.path,
|
|
1145
961
|
// The remainder, MEASURED. This is what keeps N files inside one budget.
|
|
1146
|
-
timeoutMs: sendTimeoutMs(sendBudget, consumedMs),
|
|
962
|
+
timeoutMs: opts.unboundedTimeoutMs ?? sendTimeoutMs(sendBudget, consumedMs),
|
|
1147
963
|
// PR 4a: the same origin `consumedMs` is measured from (`startedAt` is the main delivery's `settleStart`).
|
|
1148
|
-
deadlineAtMs: startedAt + sendBudget,
|
|
964
|
+
...(opts.unboundedTimeoutMs === undefined ? { deadlineAtMs: startedAt + sendBudget } : {}),
|
|
1149
965
|
nowMs: Date.now(),
|
|
1150
966
|
byHook,
|
|
967
|
+
...(opts.liveAtMs === undefined ? {} : { liveAtMs: opts.liveAtMs }),
|
|
1151
968
|
...(opts.heartbeat === undefined ? {} : { beforeRequest: opts.heartbeat }),
|
|
969
|
+
...(opts.unboundedTimeoutMs === undefined ? {} : { oneDelta: true }),
|
|
1152
970
|
}, file.size,
|
|
1153
971
|
// The SAME reader as the main transcript, so `CR-024d`'s redaction and
|
|
1154
972
|
// `/cso` finding 1's confinement both apply. A delegated agent reading a
|
|
@@ -1157,6 +975,7 @@ export async function deliverSubagents(opts) {
|
|
|
1157
975
|
deliveries.push(delivery);
|
|
1158
976
|
if (endedHeld(delivery))
|
|
1159
977
|
held = true;
|
|
978
|
+
pauseNextMs = opts.unboundedTimeoutMs !== undefined && delivery.kind === "attempted" ? unboundedPaceMs(Date.now() - requestStartedAt) : null;
|
|
1160
979
|
}
|
|
1161
980
|
return { files, held, deliveries };
|
|
1162
981
|
}
|
|
@@ -1234,8 +1053,17 @@ function endedHeld(delivery) {
|
|
|
1234
1053
|
*/
|
|
1235
1054
|
function stampGaps(ctx, input, projectKey, files,
|
|
1236
1055
|
/** A delivery in THIS invocation ended held — see `endedHeld`. */
|
|
1237
|
-
heldThisInvocation, nowMs
|
|
1056
|
+
heldThisInvocation, nowMs,
|
|
1057
|
+
/**
|
|
1058
|
+
* ⛔ 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
|
|
1059
|
+
* 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
|
|
1060
|
+
* 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)`.
|
|
1061
|
+
* Absent for an inline caller, which IS now.
|
|
1062
|
+
*/
|
|
1063
|
+
replayAt) {
|
|
1238
1064
|
const key = { repoKey: projectKey, sessionId: input.sessionId };
|
|
1065
|
+
const at = replayAt ?? nowMs;
|
|
1066
|
+
const liveAtOf = (stored) => (replayAt === undefined ? nowMs : Math.max(stored ?? 0, replayAt));
|
|
1239
1067
|
const owed = {};
|
|
1240
1068
|
// Re-read per file rather than once: each `deliver()` above saved, so a
|
|
1241
1069
|
// snapshot taken before the loop would stamp gaps over work that succeeded.
|
|
@@ -1254,19 +1082,19 @@ heldThisInvocation, nowMs) {
|
|
|
1254
1082
|
continue;
|
|
1255
1083
|
}
|
|
1256
1084
|
// D95's case, unchanged: the server was answering and the BUDGET ran out.
|
|
1257
|
-
saveSessionState(ctx.home, key, { ...withFileState(session, file.fileKey, markSkipped(current, current.sentOffset, file.size)), liveAt:
|
|
1085
|
+
saveSessionState(ctx.home, key, { ...withFileState(session, file.fileKey, markSkipped(current, current.sentOffset, file.size)), liveAt: liveAtOf(session.liveAt) });
|
|
1258
1086
|
}
|
|
1259
1087
|
// The positive record. Written when there is a hold to state OR an old one to
|
|
1260
1088
|
// clear — a `SessionEnd` that owes nothing must not leave a previous one's
|
|
1261
1089
|
// claim standing, and one that never held anything need not touch the file.
|
|
1262
1090
|
const session = loadSessionState(ctx.home, key);
|
|
1263
|
-
const endHold = Object.keys(owed).length > 0 ? { at
|
|
1091
|
+
const endHold = Object.keys(owed).length > 0 ? { at, eof: owed } : null;
|
|
1264
1092
|
// ⭐ `TODOS[146]`: also record THAT the session ended, so `status` can tell a turn that is open because the
|
|
1265
1093
|
// session is live from one that is open because it is over. Only for a session that has done something
|
|
1266
1094
|
// (`seq > 0`): a SessionEnd for a session that never delivered must not mint a state file, which would
|
|
1267
1095
|
// make it "live" to the attribution ladder.
|
|
1268
1096
|
if (endHold !== null || session.endHold !== null || session.seq > 0) {
|
|
1269
|
-
saveSessionState(ctx.home, key, { ...session, endHold, endedAt:
|
|
1097
|
+
saveSessionState(ctx.home, key, { ...session, endHold, endedAt: at, liveAt: liveAtOf(session.liveAt) });
|
|
1270
1098
|
}
|
|
1271
1099
|
}
|
|
1272
1100
|
/**
|
|
@@ -1288,7 +1116,7 @@ heldThisInvocation, nowMs) {
|
|
|
1288
1116
|
* A missing or unreadable directory is simply "no sub-agents" — silent, per the
|
|
1289
1117
|
* hook contract.
|
|
1290
1118
|
*/
|
|
1291
|
-
function findSubagentFiles(ctx, transcriptPath) {
|
|
1119
|
+
export function findSubagentFiles(ctx, transcriptPath) {
|
|
1292
1120
|
// ⛔ THE ONE COPY lives in `session_targets.ts` (`findSubagentTranscripts`), shared with the evidence reader so discovery and
|
|
1293
1121
|
// delivery can never disagree about which files are a session's. Every rule above this line (the dialect's mechanism, the root
|
|
1294
1122
|
// that selects it, `agent-*.jsonl` and never `agent-*`, confinement of every entry) is enforced there.
|