@vibe-commit/capture 0.4.3 → 0.4.4
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 +11 -0
- package/dist/commands/finalize.js +9 -1
- package/dist/commands/finalize.js.map +1 -1
- package/dist/commands/status.js +19 -3
- package/dist/commands/status.js.map +1 -1
- package/dist/copy/strings.d.ts +15 -0
- package/dist/copy/strings.js +28 -0
- package/dist/copy/strings.js.map +1 -1
- package/dist/credential.js +14 -2
- package/dist/credential.js.map +1 -1
- package/dist/header_safety.d.ts +41 -0
- package/dist/header_safety.js +48 -0
- package/dist/header_safety.js.map +1 -0
- package/dist/policy.d.ts +17 -0
- package/dist/policy.js +7 -1
- package/dist/policy.js.map +1 -1
- package/dist/post.d.ts +20 -4
- package/dist/post.js +354 -87
- package/dist/post.js.map +1 -1
- package/dist/spool.d.ts +0 -7
- package/dist/spool.js +3 -31
- package/dist/spool.js.map +1 -1
- package/dist/state.d.ts +46 -0
- package/dist/state.js +114 -0
- package/dist/state.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/post.d.ts
CHANGED
|
@@ -149,6 +149,19 @@ export interface IngestDelta {
|
|
|
149
149
|
* at all is not. So the wire carries the irreplaceable half.
|
|
150
150
|
*/
|
|
151
151
|
export declare function buildIngestHeaders(credential: IngestCredential, delta: IngestDelta): Record<string, string>;
|
|
152
|
+
/**
|
|
153
|
+
* ⛔⛔ `TODOS[182]` — THE FIVE, AND EXACTLY THE FIVE. `lean` is defined by
|
|
154
|
+
* SUBTRACTION from this set, never by an allow-list: an earlier draft of this
|
|
155
|
+
* fix hand-picked which headers to KEEP and silently dropped `content-type` /
|
|
156
|
+
* `content-encoding` (the server could no longer parse the zstd body) and
|
|
157
|
+
* `x-agent` (a lean FIRST delta would pin `unknown` forever under STICKY
|
|
158
|
+
* `lean` — `UNKNOWN_AGENT_ID`'s own docblock above). A deny-list fails SAFE:
|
|
159
|
+
* anything not named here defaults to "stays", and the only headers worth
|
|
160
|
+
* dropping are the ones whose SIZE is unbounded by a count cap — a branch name
|
|
161
|
+
* and the four list headers. `leanHeaders` is tested by SET EQUALITY against
|
|
162
|
+
* `buildIngestHeaders`'s full output, never by eyeballing an allow-list.
|
|
163
|
+
*/
|
|
164
|
+
export declare const LEAN_OMIT_HEADERS: ReadonlySet<string>;
|
|
152
165
|
/**
|
|
153
166
|
* The wire's error code for org-approval-pending — `CR-128`, D81.
|
|
154
167
|
*
|
|
@@ -400,10 +413,13 @@ export interface DeliveryContext {
|
|
|
400
413
|
/**
|
|
401
414
|
* Send the undelivered span of one transcript file and apply the failure policy.
|
|
402
415
|
*
|
|
403
|
-
* **
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
416
|
+
* **At most one NETWORK attempt per hook, still — exactly D56 §D8's rule.** A
|
|
417
|
+
* `finalize` lean retry (below) is the one exception D56 §D8 itself does not
|
|
418
|
+
* cover: it governs HOOKS, and `finalize` is a synchronous, user-invoked verb
|
|
419
|
+
* that already makes 1 + up to `FINALIZE_DRAIN_MAX_ITERATIONS` requests by
|
|
420
|
+
* design (VL's explicit ruling, `TODOS[182]`). On the hook path, `later` still
|
|
421
|
+
* does not retry here; it leaves the offset where it is so the hook that fires
|
|
422
|
+
* on the NEXT turn picks the same bytes up.
|
|
407
423
|
*
|
|
408
424
|
* Never throws: the caller is a hook, and an exception on this path is the
|
|
409
425
|
* contract breaking. Every failure resolves to a `Delivery`.
|
package/dist/post.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { createHash } from "node:crypto";
|
|
21
21
|
import { agentForTranscript } from "./agents/registry.js";
|
|
22
22
|
import { UNKNOWN_AGENT_ID } from "./agents/types.js";
|
|
23
|
+
import { headerSafe, sessionIdSafe } from "./header_safety.js";
|
|
23
24
|
import { recordInferredSkipped } from "./inferred_tally.js";
|
|
24
25
|
import { classify, markDelivered, markHeld, markSkipped, nextSpan, resolveCaps, } from "./policy.js";
|
|
25
26
|
import { fileState, isStopped, loadSessionState, saveSessionState, withFileState, } from "./state.js";
|
|
@@ -59,6 +60,19 @@ export function ingestUrl(env) {
|
|
|
59
60
|
const candidate = override.trim();
|
|
60
61
|
return isAllowedIngestUrl(candidate) ? candidate : null;
|
|
61
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* `parseObservedHead`'s upper bound on `X-Head-Branch`, mirrored here — `TODOS[182]`.
|
|
65
|
+
*
|
|
66
|
+
* Duplicating the server's constant is deliberate, the same reason `git.ts`'s
|
|
67
|
+
* `SLUG_MAX` duplicates `REPO_SLUG_MAX`: this module cannot import across the
|
|
68
|
+
* repo boundary, and the alternative is a 431 in the field instead of an
|
|
69
|
+
* omission here. The server nulls, never truncates, a branch over this bound
|
|
70
|
+
* — it has a matching check of its own one layer further down, is why.
|
|
71
|
+
*
|
|
72
|
+
* @provenance vibecommit-mcp src/conversation/ingest_session.ts — `parseObservedHead`,
|
|
73
|
+
* `trimmed.length > 255 ? null : trimmed`, read
|
|
74
|
+
*/
|
|
75
|
+
const MAX_HEAD_BRANCH_LENGTH = 255;
|
|
62
76
|
/**
|
|
63
77
|
* Build the request headers.
|
|
64
78
|
*
|
|
@@ -126,10 +140,25 @@ export function buildIngestHeaders(credential, delta) {
|
|
|
126
140
|
// ⚠ OMITTED, never sent empty. A blank header is a value the server has to
|
|
127
141
|
// have an opinion about; an absent one is unambiguously "nothing to say", and
|
|
128
142
|
// an empty repository genuinely has nothing to say here.
|
|
143
|
+
//
|
|
144
|
+
// ⛔⛔ `TODOS[182]` — OMITTED, NEVER TRUNCATED, above `MAX_HEAD_BRANCH_LENGTH`
|
|
145
|
+
// or on an unsendable byte. The server already nulls a branch over its own
|
|
146
|
+
// 255-char limit (`parseObservedHead`, mirrored below) and takes the existing
|
|
147
|
+
// detached-HEAD path for it — no ref move, `last_head_sha` untouched — so
|
|
148
|
+
// omitting here changes nothing downstream; a TRUNCATED prefix could
|
|
149
|
+
// coincidentally equal a different, real, SHORTER branch and mint a wrong,
|
|
150
|
+
// PERMANENT `repository_ref_moves` row (`reject_history_mutation` refuses an
|
|
151
|
+
// UPDATE). And a branch is the one field in this function built from
|
|
152
|
+
// arbitrary git ref bytes rather than a wire-fixed shape, so it is the one
|
|
153
|
+
// that needs `headerSafe`: a character above U+00FF or a bare LF/CR here
|
|
154
|
+
// throws the SAME `TypeError` a real network outage does (`header_safety.ts`),
|
|
155
|
+
// and the 255 cap alone does not catch `功能/登录` (13 bytes).
|
|
129
156
|
if (delta.head != null) {
|
|
130
157
|
headers["x-head-sha"] = delta.head.sha;
|
|
131
|
-
|
|
132
|
-
|
|
158
|
+
const branch = delta.head.branch;
|
|
159
|
+
if (branch !== null && branch.length <= MAX_HEAD_BRANCH_LENGTH && headerSafe(branch)) {
|
|
160
|
+
headers["x-head-branch"] = branch;
|
|
161
|
+
}
|
|
133
162
|
}
|
|
134
163
|
// ⛔⛔ THE WIRE CONTRACT, AND EVERY WAY OF GETTING IT WRONG IS SILENT IN BOTH
|
|
135
164
|
// DIRECTIONS (`D190`). Three header names and two separators, agreed between
|
|
@@ -173,6 +202,73 @@ export function buildIngestHeaders(credential, delta) {
|
|
|
173
202
|
}
|
|
174
203
|
return headers;
|
|
175
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* ⛔⛔ `TODOS[182]` — THE FIVE, AND EXACTLY THE FIVE. `lean` is defined by
|
|
207
|
+
* SUBTRACTION from this set, never by an allow-list: an earlier draft of this
|
|
208
|
+
* fix hand-picked which headers to KEEP and silently dropped `content-type` /
|
|
209
|
+
* `content-encoding` (the server could no longer parse the zstd body) and
|
|
210
|
+
* `x-agent` (a lean FIRST delta would pin `unknown` forever under STICKY
|
|
211
|
+
* `lean` — `UNKNOWN_AGENT_ID`'s own docblock above). A deny-list fails SAFE:
|
|
212
|
+
* anything not named here defaults to "stays", and the only headers worth
|
|
213
|
+
* dropping are the ones whose SIZE is unbounded by a count cap — a branch name
|
|
214
|
+
* and the four list headers. `leanHeaders` is tested by SET EQUALITY against
|
|
215
|
+
* `buildIngestHeaders`'s full output, never by eyeballing an allow-list.
|
|
216
|
+
*/
|
|
217
|
+
export const LEAN_OMIT_HEADERS = new Set([
|
|
218
|
+
"x-head-branch",
|
|
219
|
+
"x-commits",
|
|
220
|
+
"x-commit-attributions",
|
|
221
|
+
"x-rewrites",
|
|
222
|
+
"x-rewrites-inferred",
|
|
223
|
+
]);
|
|
224
|
+
/**
|
|
225
|
+
* The lean (deny-list) reduction of a full header set — `TODOS[182]`.
|
|
226
|
+
*
|
|
227
|
+
* Takes the FULL object `buildIngestHeaders` would already build from the real
|
|
228
|
+
* delta — real branch, real commits, real rewrites — and removes exactly
|
|
229
|
+
* `LEAN_OMIT_HEADERS`. Never built the other way around (a delta with its
|
|
230
|
+
* list fields pre-blanked): that would make "lean" whatever
|
|
231
|
+
* `buildIngestHeaders`'s conditionals happen to gate on today, which is the
|
|
232
|
+
* allow-list failure mode this function exists to avoid repeating.
|
|
233
|
+
*/
|
|
234
|
+
function leanHeaders(full) {
|
|
235
|
+
const reduced = {};
|
|
236
|
+
for (const [name, value] of Object.entries(full)) {
|
|
237
|
+
if (!LEAN_OMIT_HEADERS.has(name))
|
|
238
|
+
reduced[name] = value;
|
|
239
|
+
}
|
|
240
|
+
return reduced;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* The first header whose value fails the shared predicate, or `null` if every
|
|
244
|
+
* one is sendable — `TODOS[182]`.
|
|
245
|
+
*
|
|
246
|
+
* `x-head-branch` can never be the header this returns: `buildIngestHeaders`
|
|
247
|
+
* already omits it unless it is both within `MAX_HEAD_BRANCH_LENGTH` and
|
|
248
|
+
* `headerSafe`. So anything this function still finds, by elimination, is one
|
|
249
|
+
* of the REQUIRED headers — there is no second optional one to drop.
|
|
250
|
+
*/
|
|
251
|
+
function firstUnsafeHeader(headers) {
|
|
252
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
253
|
+
if (!headerSafe(value))
|
|
254
|
+
return name;
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The backstop (`TODOS[182]`, step 4 of the design): `new Headers()` in its
|
|
260
|
+
* own try, after the explicit predicate already passed, before `fetch` ever
|
|
261
|
+
* sees the object. Never the primary check — see `header_safety.ts`.
|
|
262
|
+
*/
|
|
263
|
+
function headersConstructible(headers) {
|
|
264
|
+
try {
|
|
265
|
+
new Headers(headers);
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
176
272
|
/**
|
|
177
273
|
* The wire's error code for org-approval-pending — `CR-128`, D81.
|
|
178
274
|
*
|
|
@@ -320,13 +416,105 @@ function parseFinalAck(raw) {
|
|
|
320
416
|
const v = raw?.trim().toLowerCase();
|
|
321
417
|
return v === "sealed" || v === "already" || v === "refused" ? v : null;
|
|
322
418
|
}
|
|
419
|
+
/**
|
|
420
|
+
* Build the delta, reduce it to the deny-list set when `lean`, validate every
|
|
421
|
+
* resulting header value, and only then call `send()` — `TODOS[182]`.
|
|
422
|
+
*
|
|
423
|
+
* `x-head-branch` can never be the `header` an "unsafe" result names:
|
|
424
|
+
* `buildIngestHeaders` already omits it unless it is both within
|
|
425
|
+
* `MAX_HEAD_BRANCH_LENGTH` and `headerSafe`. So a `header-unsafe` result here,
|
|
426
|
+
* by elimination, always names a REQUIRED header — there is no second
|
|
427
|
+
* optional one for the caller to drop.
|
|
428
|
+
*/
|
|
429
|
+
async function attemptSend(ctx, agent, seq, span, body, lean) {
|
|
430
|
+
const full = buildIngestHeaders(ctx.credential, {
|
|
431
|
+
sessionId: ctx.sessionId,
|
|
432
|
+
seq,
|
|
433
|
+
byteOffset: span.from,
|
|
434
|
+
fileKey: ctx.fileKey,
|
|
435
|
+
repoSlug: ctx.repoSlug,
|
|
436
|
+
agent,
|
|
437
|
+
final: ctx.final === true,
|
|
438
|
+
head: ctx.head,
|
|
439
|
+
commits: ctx.commits,
|
|
440
|
+
rewrites: ctx.rewrites,
|
|
441
|
+
inferredRewrites: ctx.inferredRewrites,
|
|
442
|
+
});
|
|
443
|
+
const headers = lean ? leanHeaders(full) : full;
|
|
444
|
+
const badHeader = firstUnsafeHeader(headers);
|
|
445
|
+
if (badHeader !== null)
|
|
446
|
+
return { kind: "unsafe", header: badHeader };
|
|
447
|
+
// The backstop: nothing named it, but `Headers()` still refuses. Cannot be
|
|
448
|
+
// attributed to a specific header from here, so it is reported the same way
|
|
449
|
+
// a non-`authorization` failure is below — a general refusal, never `fatal`
|
|
450
|
+
// on a guess.
|
|
451
|
+
if (!headersConstructible(headers))
|
|
452
|
+
return { kind: "unsafe", header: "" };
|
|
453
|
+
return { kind: "sent", outcome: await send(ctx.url, headers, body, ctx.timeoutMs) };
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* A pre-send refusal: the header that failed never reached `fetch`, so there
|
|
457
|
+
* is no `SendOutcome` to classify — `TODOS[182]`.
|
|
458
|
+
*
|
|
459
|
+
* `disposition: "fatal"` is for `authorization` specifically (a byte-invalid
|
|
460
|
+
* credential is a credential-class problem, not a payload-class one, the
|
|
461
|
+
* exact reasoning a 401 already gets): `markHeld`, never `markSkipped`,
|
|
462
|
+
* because these bytes are fine and a reconnected user should still get them.
|
|
463
|
+
* Every other required header takes `"never"` — the loud-refusal path a 400
|
|
464
|
+
* or 413 already gets, so a bad session id is exactly as visible in `status`
|
|
465
|
+
* as those are, never a new, differently-silent no-op.
|
|
466
|
+
*
|
|
467
|
+
* ⚠ **This `authorization` branch is unreachable via any real CLI path today, verified rather than
|
|
468
|
+
* assumed.** `credential.ts`'s `loadCredential` already rejects anything that would fail `headerSafe` (the
|
|
469
|
+
* same predicate `attemptSend` runs here), and `"Bearer " + secret` cannot become unsafe from a safe secret —
|
|
470
|
+
* a safe ASCII prefix added to a safe string is still safe. So `headerSafe(secret)` at load time and
|
|
471
|
+
* `headerSafe(authorization)` here always agree, and the real first line of defense is `credential.ts`'s,
|
|
472
|
+
* never this one — which is kept anyway as the backstop for a FUTURE credential source that might bypass
|
|
473
|
+
* that loader.
|
|
474
|
+
*
|
|
475
|
+
* `lean` is an explicit parameter, never read off `current`, because the ONE
|
|
476
|
+
* caller that can reach this mid-transition (a `finalize` lean retry whose
|
|
477
|
+
* OWN headers somehow still failed — unreachable in practice, since lean
|
|
478
|
+
* headers are already proven safe, but stated rather than assumed) must
|
|
479
|
+
* persist `lean: true` going forward, which `current.lean` would not yet say.
|
|
480
|
+
*/
|
|
481
|
+
function refuse(ctx, key, session, current, span, caps, disposition, lean) {
|
|
482
|
+
const next = disposition === "fatal"
|
|
483
|
+
? {
|
|
484
|
+
...withFileState(session, ctx.fileKey, { ...markHeld(current, span, ctx.nowMs, caps), lean }),
|
|
485
|
+
stop: { at: new Date(ctx.nowMs).toISOString(), fingerprint: credentialFingerprint(ctx.credential) },
|
|
486
|
+
}
|
|
487
|
+
: withFileState(session, ctx.fileKey, { ...markSkipped(current, span.from, span.to), lean });
|
|
488
|
+
saveSessionState(ctx.home, key, next);
|
|
489
|
+
return { kind: "attempted", disposition, detail: null, finalAck: null, inferredStored: null, inferredSetAsidePairs: 0 };
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* The first 431 on a HOOK's attempt: hold the span (not `never` yet — the
|
|
493
|
+
* bytes may still be sendable, just not with these headers) and set `lean`
|
|
494
|
+
* STICKY for every attempt after this one, on this file, for the rest of the
|
|
495
|
+
* session — `TODOS[182]`.
|
|
496
|
+
*
|
|
497
|
+
* D56 §D8 forbids a synchronous retry inside a hook, so the lean retry itself
|
|
498
|
+
* waits for the NEXT hook invocation, which re-enters `deliver()` and reads
|
|
499
|
+
* `current.lean === true` from the top. `deliver()`'s own 431 branch below
|
|
500
|
+
* explains why `finalize` (no "next hook" to defer to) instead retries
|
|
501
|
+
* in-process, within the same call.
|
|
502
|
+
*/
|
|
503
|
+
function holdLean(ctx, key, session, current, span, caps) {
|
|
504
|
+
const next = withFileState(session, ctx.fileKey, { ...markHeld(current, span, ctx.nowMs, caps), lean: true });
|
|
505
|
+
saveSessionState(ctx.home, key, next);
|
|
506
|
+
return { kind: "attempted", disposition: "later", detail: null, finalAck: null, inferredStored: null, inferredSetAsidePairs: 0 };
|
|
507
|
+
}
|
|
323
508
|
/**
|
|
324
509
|
* Send the undelivered span of one transcript file and apply the failure policy.
|
|
325
510
|
*
|
|
326
|
-
* **
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
511
|
+
* **At most one NETWORK attempt per hook, still — exactly D56 §D8's rule.** A
|
|
512
|
+
* `finalize` lean retry (below) is the one exception D56 §D8 itself does not
|
|
513
|
+
* cover: it governs HOOKS, and `finalize` is a synchronous, user-invoked verb
|
|
514
|
+
* that already makes 1 + up to `FINALIZE_DRAIN_MAX_ITERATIONS` requests by
|
|
515
|
+
* design (VL's explicit ruling, `TODOS[182]`). On the hook path, `later` still
|
|
516
|
+
* does not retry here; it leaves the offset where it is so the hook that fires
|
|
517
|
+
* on the NEXT turn picks the same bytes up.
|
|
330
518
|
*
|
|
331
519
|
* Never throws: the caller is a hook, and an exception on this path is the
|
|
332
520
|
* contract breaking. Every failure resolves to a `Delivery`.
|
|
@@ -360,34 +548,101 @@ export async function deliver(ctx, eof, readBody) {
|
|
|
360
548
|
const body = readBody(span.from, span.to);
|
|
361
549
|
if (body === null)
|
|
362
550
|
return { kind: "nothing-to-send" };
|
|
363
|
-
|
|
364
|
-
//
|
|
365
|
-
//
|
|
551
|
+
const caps = resolveCaps(ctx.env);
|
|
552
|
+
// `seq` is claimed BEFORE the attempt and persisted by the SWITCH below (every ordinary disposition —
|
|
553
|
+
// "ok"/"later"/"never"/"fatal" — builds `next` from `{ ...session, seq, … }`), so it is monotonic per
|
|
554
|
+
// session across retries once a request was actually classified. A seq reused after a failure would read
|
|
555
|
+
// server-side as a replay of a delta that is not the same bytes.
|
|
556
|
+
//
|
|
557
|
+
// ⚠ `refuse()` and `holdLean()` below do NOT persist it — they build `next` from `session` alone, so a
|
|
558
|
+
// pre-send refusal or a hook's first hold leaves `session.seq` exactly where it was. That is harmless: a
|
|
559
|
+
// request classified `refuse`/`holdLean` never reached `send()` at all (a bad session id, an unsafe
|
|
560
|
+
// credential, or the first 431 a hook defers on), so the server never saw THIS seq either — reusing it on
|
|
561
|
+
// the next real attempt is not a replay of anything, because nothing was sent under it.
|
|
562
|
+
//
|
|
563
|
+
// Claimed ONCE even across a `finalize` lean retry below: both attempts, if there are two, carry the same
|
|
564
|
+
// bytes at the same offset, so they are one logical delivery that happened to need its headers shed, not two.
|
|
366
565
|
const seq = session.seq + 1;
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
566
|
+
// ⛔ THE ONE PLACE THE WIRE AGENT IS DECIDED — from the containing registry
|
|
567
|
+
// root, never from the flag (D177 §7). Computed once per `deliver()` call,
|
|
568
|
+
// reused across a lean retry, so a retry never pays a second filesystem
|
|
569
|
+
// lookup for an answer that cannot have changed between two attempts
|
|
570
|
+
// milliseconds apart.
|
|
571
|
+
const agent = agentForTranscript(ctx.home, ctx.env, ctx.transcriptPath);
|
|
572
|
+
// ⛔⛔ `TODOS[182]` — `X-Session-Id` HAS NO BOUND ANYWHERE ELSE ON THE HOOK
|
|
573
|
+
// PATH. The server 400s an id over its own `SESSION_ID_MAX` (200), and 400 is
|
|
574
|
+
// already `never` — so an over-LONG id was never silently retried. The GAP is
|
|
575
|
+
// charset: a byte above `\x7E` here throws the SAME `TypeError` a real
|
|
576
|
+
// network outage does (`header_safety.ts`), which `classify`'s `unreachable`
|
|
577
|
+
// branch would fold into `later` and retry FOREVER. Checked before anything
|
|
578
|
+
// else: a bad id can never be sent no matter what the rest of the delta looks
|
|
579
|
+
// like, so there is nothing to gain by building headers around it first.
|
|
580
|
+
if (!sessionIdSafe(ctx.sessionId)) {
|
|
581
|
+
return refuse(ctx, key, session, current, span, caps, "never", current.lean);
|
|
582
|
+
}
|
|
583
|
+
// `lean` is STICKY (VL's ruling, `TODOS[182]`): once `current.lean` is true,
|
|
584
|
+
// THIS attempt — and every attempt after it, for this file, for the rest of
|
|
585
|
+
// the session — builds the deny-list header set from the start. Not
|
|
586
|
+
// re-evaluated size or charset attempt by attempt: a flag that cleared
|
|
587
|
+
// itself whenever a smaller delta happened to fit would alternate between
|
|
588
|
+
// full and lean requests depending on what the NEXT turn happens to contain,
|
|
589
|
+
// which nobody can debug.
|
|
590
|
+
let lean = current.lean;
|
|
591
|
+
let attempt = await attemptSend(ctx, agent, seq, span, body, lean);
|
|
592
|
+
if (attempt.kind === "unsafe") {
|
|
593
|
+
return refuse(ctx, key, session, current, span, caps, attempt.header === "authorization" ? "fatal" : "never", lean);
|
|
594
|
+
}
|
|
595
|
+
let outcome = attempt.outcome;
|
|
596
|
+
// ⛔⛔ `TODOS[182]` — 431 IS NOT IN `STATUS_CLASSES` AND NEVER WILL BE: it is
|
|
597
|
+
// handled entirely by this state machine, never by the generic table.
|
|
598
|
+
// `later`'s own definition is "retry the SAME bytes next hook" — for a 431,
|
|
599
|
+
// "the same bytes" means the same HEADERS, and nothing about a later attempt
|
|
600
|
+
// changes the branch, the spooled commits or the session id that caused it.
|
|
601
|
+
// A table-driven `later` for 431 retries the identical oversized/rejected
|
|
602
|
+
// request forever: the exact stall this task exists to close.
|
|
603
|
+
if (outcome.kind === "response" && outcome.status === 431) {
|
|
604
|
+
if (lean) {
|
|
605
|
+
// Already lean and STILL 431: nothing client-side can shrink this
|
|
606
|
+
// further. `never`, exactly as 413 — the one case `never` is correct
|
|
607
|
+
// here, because it really is per-body now.
|
|
608
|
+
return refuse(ctx, key, session, current, span, caps, "never", true);
|
|
609
|
+
}
|
|
610
|
+
if (ctx.byHook) {
|
|
611
|
+
// A hook defers rather than retries — see `holdLean`'s own docblock.
|
|
612
|
+
return holdLean(ctx, key, session, current, span, caps);
|
|
613
|
+
}
|
|
614
|
+
// `finalize`: no "next hook" to defer to, and VL's explicit ruling that one
|
|
615
|
+
// in-process retry here does not reopen D56 §D8, which governs hooks. Go
|
|
616
|
+
// lean and retry ONCE, within this same invocation.
|
|
617
|
+
lean = true;
|
|
618
|
+
attempt = await attemptSend(ctx, agent, seq, span, body, lean);
|
|
619
|
+
if (attempt.kind === "unsafe") {
|
|
620
|
+
// Unreachable in practice — every lean header is already proven safe, or
|
|
621
|
+
// it would never have reached the full build either — but stated rather
|
|
622
|
+
// than assumed: refuse exactly as the first attempt would have.
|
|
623
|
+
return refuse(ctx, key, session, current, span, caps, attempt.header === "authorization" ? "fatal" : "never", lean);
|
|
624
|
+
}
|
|
625
|
+
outcome = attempt.outcome;
|
|
626
|
+
if (outcome.kind === "response" && outcome.status === 431) {
|
|
627
|
+
// The lean retry ALSO 431'd, within the SAME `finalize` invocation:
|
|
628
|
+
// `never` for the span, discovered now rather than on a next hook that
|
|
629
|
+
// does not exist for this verb.
|
|
630
|
+
return refuse(ctx, key, session, current, span, caps, "never", true);
|
|
631
|
+
}
|
|
632
|
+
// Falls through to the ordinary classification below, now with `lean`
|
|
633
|
+
// true — the `"ok"` case's accounting reads it to skip settling data this
|
|
634
|
+
// attempt never put on the wire.
|
|
635
|
+
}
|
|
384
636
|
const disposition = classify(outcome);
|
|
385
|
-
const caps = resolveCaps(ctx.env);
|
|
386
637
|
let next = ctx.byHook ? { ...session, seq, liveAt: ctx.nowMs } : { ...session, seq };
|
|
387
638
|
let inferredSetAsidePairs = 0;
|
|
388
639
|
switch (disposition) {
|
|
389
640
|
case "ok":
|
|
390
|
-
|
|
641
|
+
// `{ ...markDelivered(...), lean }` rather than relying on `markDelivered`'s own spread of `current`:
|
|
642
|
+
// `current.lean` is this file's state from BEFORE this call, so on the one path where a `finalize`
|
|
643
|
+
// retry just went lean mid-call and then succeeded, `current.lean` would still read `false` — `lean`
|
|
644
|
+
// (the local variable, updated above) is the value that must persist going forward.
|
|
645
|
+
next = withFileState(next, ctx.fileKey, { ...markDelivered(current, span.to, ctx.nowMs), lean });
|
|
391
646
|
// ⭐ `TODOS[145]` — KEEP THE RECEIPT. `captureId` is non-empty when this delta sealed a capture
|
|
392
647
|
// and EMPTY when it sealed nothing; the client used to stamp `lastSentAt` identically for both and
|
|
393
648
|
// surface neither. MAIN stream only (a sub-agent stream seals nothing by construction and would
|
|
@@ -447,79 +702,91 @@ export async function deliver(ctx, eof, readBody) {
|
|
|
447
702
|
// above reads as "drop". `X-Commits-Retry` is the per-sha answer, so it
|
|
448
703
|
// decides line by line and the prefix drop is only the fallback for a
|
|
449
704
|
// server that predates it.
|
|
450
|
-
const retry = outcome.kind === "response" ? outcome.commitsRetry : null;
|
|
451
|
-
const nothingLanded = outcome.kind === "response" && outcome.captureId === "";
|
|
452
|
-
const holdForNextDelta = nothingLanded && (ctx.commits?.shas.length ?? 0) > 0;
|
|
453
|
-
if (ctx.commits !== undefined && ctx.commits.count > 0) {
|
|
454
|
-
if (retry !== null) {
|
|
455
|
-
settleSpooled(ctx.home, key, ctx.commits.count, new Set(retry));
|
|
456
|
-
}
|
|
457
|
-
else if (!holdForNextDelta) {
|
|
458
|
-
dropSpooled(ctx.home, key, ctx.commits.count);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
// The rewrite spool is its own file with its own cap, so its own drop.
|
|
462
|
-
//
|
|
463
|
-
// ⛔ AND IT IS UNCONDITIONAL, UNLIKE THE COMMITS ABOVE — do not "fix" this
|
|
464
|
-
// to match. `tryRecordRewrites` on the server sits OUTSIDE the capture
|
|
465
|
-
// write and its comment says why: "Unconditional on the capture: a delta
|
|
466
|
-
// that sealed no turns still saw the rewrite, and the mapping is what
|
|
467
|
-
// keeps a squash-merged commit reachable." A `commit_sha_successors` row
|
|
468
|
-
// names no turn, so `X-Capture-Id` says nothing about whether it landed.
|
|
469
|
-
if (ctx.rewrites !== undefined && ctx.rewrites.length > 0) {
|
|
470
|
-
dropRewrites(ctx.home, key, ctx.rewrites.length);
|
|
471
|
-
}
|
|
472
|
-
// `TODOS[177]` — the INFERRED pairs have their own file and their own drop. ⛔ UNLIKE `rewrites` above, this one is
|
|
473
|
-
// CONDITIONAL on the server's per-request ack (`x-rewrites-inferred-stored`): a 2xx alone is "the delta was
|
|
474
|
-
// accepted", not "every inferred pair in it was recorded" (VG pre-review, 2026-10-08). Drop by GROUP IDENTITY,
|
|
475
|
-
// never by count — see `dropInferredGroups`'s own comment for why a count-based drop lost pairs under concurrency.
|
|
476
705
|
//
|
|
477
|
-
// ⛔⛔
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
706
|
+
// ⛔⛔ `TODOS[182]` — EVERYTHING BELOW IS GATED ON `!lean`. A lean request's `ctx.commits` /
|
|
707
|
+
// `ctx.rewrites` / `ctx.inferredRewrites` / `ctx.inferredGroups` describe data THIS delivery never put
|
|
708
|
+
// on the wire (the deny-list dropped their headers) — dropping, settling or counting an attempt
|
|
709
|
+
// against them would discard real, unsent data. VG's measured finding: a copy of this client whose
|
|
710
|
+
// header BUILDER omitted the list headers while `ctx` was left unchanged lost a real commit (3→0) and
|
|
711
|
+
// a real rewrite (1→0) in one Stop hook, neither of which the server had ever seen. Held (never
|
|
712
|
+
// sendable, for a reason unrelated to `lean`) entries are UNAFFECTED by this gate: they are consumed
|
|
713
|
+
// only via a NORMAL, non-lean call's `capSpool`/`capSuccessors`/`capInferred`, which this gate does not
|
|
714
|
+
// touch, so a lean session's held lines still drain exactly as soon as a non-lean request reads them.
|
|
715
|
+
if (!lean) {
|
|
716
|
+
const retry = outcome.kind === "response" ? outcome.commitsRetry : null;
|
|
717
|
+
const nothingLanded = outcome.kind === "response" && outcome.captureId === "";
|
|
718
|
+
const holdForNextDelta = nothingLanded && (ctx.commits?.shas.length ?? 0) > 0;
|
|
719
|
+
if (ctx.commits !== undefined && ctx.commits.count > 0) {
|
|
720
|
+
if (retry !== null) {
|
|
721
|
+
settleSpooled(ctx.home, key, ctx.commits.count, new Set(retry));
|
|
722
|
+
}
|
|
723
|
+
else if (!holdForNextDelta) {
|
|
724
|
+
dropSpooled(ctx.home, key, ctx.commits.count);
|
|
725
|
+
}
|
|
490
726
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
727
|
+
// The rewrite spool is its own file with its own cap, so its own drop.
|
|
728
|
+
//
|
|
729
|
+
// ⛔ AND IT IS UNCONDITIONAL, UNLIKE THE COMMITS ABOVE — do not "fix" this
|
|
730
|
+
// to match. `tryRecordRewrites` on the server sits OUTSIDE the capture
|
|
731
|
+
// write and its comment says why: "Unconditional on the capture: a delta
|
|
732
|
+
// that sealed no turns still saw the rewrite, and the mapping is what
|
|
733
|
+
// keeps a squash-merged commit reachable." A `commit_sha_successors` row
|
|
734
|
+
// names no turn, so `X-Capture-Id` says nothing about whether it landed.
|
|
735
|
+
if (ctx.rewrites !== undefined && ctx.rewrites.length > 0) {
|
|
736
|
+
dropRewrites(ctx.home, key, ctx.rewrites.length);
|
|
737
|
+
}
|
|
738
|
+
// `TODOS[177]` — the INFERRED pairs have their own file and their own drop. ⛔ UNLIKE `rewrites` above, this one is
|
|
739
|
+
// CONDITIONAL on the server's per-request ack (`x-rewrites-inferred-stored`): a 2xx alone is "the delta was
|
|
740
|
+
// accepted", not "every inferred pair in it was recorded" (VG pre-review, 2026-10-08). Drop by GROUP IDENTITY,
|
|
741
|
+
// never by count — see `dropInferredGroups`'s own comment for why a count-based drop lost pairs under concurrency.
|
|
742
|
+
//
|
|
743
|
+
// ⛔⛔ PER-GROUP BOUND (VL, 2026-10-08, VG's head-of-line finding): `capInferred` sends exactly ONE group per
|
|
744
|
+
// request now, so `ctx.inferredGroups` has at most one member and the ack names it unambiguously.
|
|
745
|
+
// stored === sent → delivered, drop it.
|
|
746
|
+
// stored !== null, short → a real signal from a server that saw the group and recorded fewer than all of it:
|
|
747
|
+
// count an attempt; after `INFERRED_SET_ASIDE_AFTER` attempts, give up on it so it
|
|
748
|
+
// stops blocking the group behind it.
|
|
749
|
+
// stored === null → an old/absent-ack server said nothing at all: "keeping is right there" — no
|
|
750
|
+
// attempt is counted (an attempt means a signal was RECEIVED), nothing is set aside.
|
|
751
|
+
if (ctx.inferredRewrites !== undefined && ctx.inferredRewrites.length > 0 && ctx.inferredGroups !== undefined && ctx.inferredGroups.length > 0) {
|
|
752
|
+
const group = ctx.inferredGroups[0];
|
|
753
|
+
const stored = outcome.kind === "response" ? outcome.inferredStored : null;
|
|
754
|
+
if (stored === ctx.inferredRewrites.length) {
|
|
755
|
+
dropInferredGroups(ctx.home, key, [group]);
|
|
756
|
+
}
|
|
757
|
+
else if (stored !== null) {
|
|
758
|
+
// ⛔⛔ `stored !== null` ALONE, never `stored !== null && stored > 0` (VG's mutant G5, 2026-10-08): the
|
|
759
|
+
// real server (`ff67ef9`) writes a group in one atomic upsert, so a CARRIED group gets back either `n`
|
|
760
|
+
// (all stored, handled above) or exactly `0` (the write failed, the header was voided, or a replay) — it
|
|
761
|
+
// never answers `n-1`. An ack of `0` is a real signal ("the server saw this and stored none of it") and
|
|
762
|
+
// must count an attempt exactly like any other short ack, or a persistently-refused group is never set
|
|
763
|
+
// aside and head-of-line blocking comes back on the realistic path.
|
|
764
|
+
const attempts = recordInferredAttempt(ctx.home, key, group);
|
|
765
|
+
if (attempts >= INFERRED_SET_ASIDE_AFTER) {
|
|
766
|
+
setAsideInferredGroup(ctx.home, key, group, "short_ack");
|
|
767
|
+
// `TODOS[177]` follow-up (VL, 2026-10-08, VG's silent-loss finding): tally BEFORE any deletion can
|
|
768
|
+
// happen — `finalize` may delete this session's `.inferred.jsonl` outright once every group in it is
|
|
769
|
+
// resolved, and this tally is the ONLY record of `set_aside` left once that file is gone (VG's
|
|
770
|
+
// follow-up finding: an earlier version of this fix also kept a live gauge reading that same file,
|
|
771
|
+
// which double-counted these pairs once the group aged into the 7-day prune — removed).
|
|
772
|
+
recordInferredSkipped(ctx.home, key.repoKey, "set_aside", ctx.nowMs, ctx.inferredRewrites.length);
|
|
773
|
+
inferredSetAsidePairs = ctx.inferredRewrites.length;
|
|
774
|
+
}
|
|
508
775
|
}
|
|
509
776
|
}
|
|
510
777
|
}
|
|
511
778
|
break;
|
|
512
779
|
case "later":
|
|
513
|
-
next = withFileState(next, ctx.fileKey, markHeld(current, span, ctx.nowMs, caps));
|
|
780
|
+
next = withFileState(next, ctx.fileKey, { ...markHeld(current, span, ctx.nowMs, caps), lean });
|
|
514
781
|
break;
|
|
515
782
|
case "never":
|
|
516
|
-
next = withFileState(next, ctx.fileKey, markSkipped(current, span.from, span.to));
|
|
783
|
+
next = withFileState(next, ctx.fileKey, { ...markSkipped(current, span.from, span.to), lean });
|
|
517
784
|
break;
|
|
518
785
|
case "fatal":
|
|
519
786
|
// Credential-level, not payload-level: the offset does NOT advance, because
|
|
520
787
|
// these bytes are fine and a reconnected user should still get them.
|
|
521
788
|
next = {
|
|
522
|
-
...withFileState(next, ctx.fileKey, markHeld(current, span, ctx.nowMs, caps)),
|
|
789
|
+
...withFileState(next, ctx.fileKey, { ...markHeld(current, span, ctx.nowMs, caps), lean }),
|
|
523
790
|
stop: {
|
|
524
791
|
at: new Date(ctx.nowMs).toISOString(),
|
|
525
792
|
fingerprint: credentialFingerprint(ctx.credential),
|