@vibe-commit/capture 0.4.1 → 0.4.3

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.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `TODOS[177]` — the counts of squash pairs that were DETECTED but never ended up recorded by the server, so the
3
+ * silence is not a non-event.
4
+ *
5
+ * `oversize`/`sha256` are a contract limit of the server's `x-rewrites-inferred` reader, caught before the squash is
6
+ * ever spooled: a fold of more than 64 commits (the server counts anything past 64 per request as dropped, and a
7
+ * partial edge set is worse than none), and a sha256 repository (the server refuses 64-hex shas). Either way the
8
+ * client emits NOTHING for that squash and counts it here instead — one per squash, since neither ever had pairs on
9
+ * disk to count.
10
+ *
11
+ * ⛔⛔ `set_aside`/`pruned` (VL, 2026-10-08, VG's silent-loss finding over a real `finalize` short-ack run) exist
12
+ * because set-aside and the 7-day prune both end by deleting or quietly carrying forward the group line a reader
13
+ * would otherwise count from the live spool file. Without a durable record here, a group that was set aside and
14
+ * then `finalize` deleted the (now fully-resolved) spool for — or one that aged out under the prune — left no
15
+ * trace anywhere: MEASURED, `status` read 0 before the event and 0 after, as if 70 real pairs had never existed.
16
+ * These two count PAIRS, recorded at the moment of the event, before any deletion can happen — which is what
17
+ * makes deleting the spool afterward fine.
18
+ *
19
+ * ⚠ THIS TALLY IS THE ONE SOURCE OF TRUTH FOR `set_aside` (VG's follow-up finding, item (b)): an earlier version
20
+ * of this fix also added a LIVE gauge (`inferredSetAsideForRepo` in `spool.ts`, reading the still-on-disk file
21
+ * directly), which double-counted the SAME pairs once a group aged into the prune — `status` announced 80 pairs
22
+ * for one 40-pair group, 40 from each source. That gauge is gone; `inferredUnacknowledgedForRepo` ("still
23
+ * waiting on an ack," a genuinely different fact) is the only live gauge left in this area.
24
+ *
25
+ * Counts only — never a sha, a path or a branch. One small file per repo beside the session files, named so no
26
+ * scanner matches it (`pruned-pending.tally`'s rule); a tally older than 7 days restarts instead of accumulating.
27
+ * Never throws: this can run inside the user's `git commit`.
28
+ */
29
+ import { readFileSync, writeFileSync } from "node:fs";
30
+ import { join } from "node:path";
31
+ import { repoSessionsDir } from "./paths.js";
32
+ const TALLY_NAME = "inferred-skipped.tally";
33
+ const TTL_MS = 7 * 24 * 3600_000;
34
+ /**
35
+ * ⚠ ADDITIVE: `set_aside`/`pruned` default to 0 when absent, rather than invalidating a tally written by 0.4.2 or
36
+ * earlier (which has `oversize`/`sha256` and no concept of the other two) — a missing field means "before this
37
+ * existed," never "corrupt," same instinct as `SpoolEntry.observedAt?`.
38
+ */
39
+ function read(dir) {
40
+ try {
41
+ const t = JSON.parse(readFileSync(join(dir, TALLY_NAME), "utf8"));
42
+ if (!(Number.isSafeInteger(t.oversize) && Number.isSafeInteger(t.sha256) && typeof t.at === "number" && Number.isFinite(t.at)))
43
+ return null;
44
+ return {
45
+ oversize: t.oversize,
46
+ sha256: t.sha256,
47
+ set_aside: Number.isSafeInteger(t.set_aside) ? t.set_aside : 0,
48
+ pruned: Number.isSafeInteger(t.pruned) ? t.pruned : 0,
49
+ at: t.at,
50
+ };
51
+ }
52
+ catch {
53
+ /* absent or damaged: no tally */
54
+ }
55
+ return null;
56
+ }
57
+ /**
58
+ * Count `count` (default 1) pairs/squashes skipped for one reason. Never throws.
59
+ *
60
+ * ⛔⛔ `set_aside` and `pruned` (VL, 2026-10-08, VG's silent-loss finding) are PAIR counts — unlike `oversize`/
61
+ * `sha256`, which count SQUASHES (an oversize/sha256 squash is never spooled, so there is nothing to count by
62
+ * pair). Both record the ONLY durable trace of pairs that a live gauge (`inferredUnacknowledgedForRepo`) stops
63
+ * counting the moment the file backing it is deleted or pruned. Called BEFORE that deletion — `set_aside` from
64
+ * `post.ts` at the moment it calls `setAsideInferredGroup`, `pruned` from the prune sweep at the moment it
65
+ * discards a group with NEITHER mark (never a delivered or already-tallied-set-aside one, or this would
66
+ * double-count — `spool.ts`'s `settleInferred`) — so "deleting the spool is fine" (VL) because this tally is
67
+ * what makes it fine.
68
+ */
69
+ export function recordInferredSkipped(home, repoKey, reason, nowMs = Date.now(), count = 1) {
70
+ try {
71
+ const dir = repoSessionsDir(home, repoKey);
72
+ if (dir === null || count <= 0)
73
+ return;
74
+ const prev = read(dir);
75
+ const carry = prev !== null && nowMs - prev.at <= TTL_MS ? prev : { oversize: 0, sha256: 0, set_aside: 0, pruned: 0, at: nowMs };
76
+ const next = {
77
+ oversize: carry.oversize + (reason === "oversize" ? count : 0),
78
+ sha256: carry.sha256 + (reason === "sha256" ? count : 0),
79
+ set_aside: carry.set_aside + (reason === "set_aside" ? count : 0),
80
+ pruned: carry.pruned + (reason === "pruned" ? count : 0),
81
+ at: nowMs,
82
+ };
83
+ writeFileSync(join(dir, TALLY_NAME), `${JSON.stringify(next)}\n`, { mode: 0o600 });
84
+ }
85
+ catch {
86
+ /* the event already happened; failing to count it must not throw */
87
+ }
88
+ }
89
+ /** The counts to announce, or null when nothing was skipped in the last 7 days. */
90
+ export function readInferredSkipped(home, repoKey, nowMs) {
91
+ const dir = repoSessionsDir(home, repoKey);
92
+ if (dir === null)
93
+ return null;
94
+ const t = read(dir);
95
+ if (t === null || nowMs - t.at > TTL_MS || (t.oversize === 0 && t.sha256 === 0 && t.set_aside === 0 && t.pruned === 0))
96
+ return null;
97
+ return { oversize: t.oversize, sha256: t.sha256, set_aside: t.set_aside, pruned: t.pruned };
98
+ }
99
+ //# sourceMappingURL=inferred_tally.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inferred_tally.js","sourceRoot":"","sources":["../src/inferred_tally.ts"],"names":[],"mappings":"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"}
@@ -25,9 +25,12 @@
25
25
  * The prompt time is the `timestamp` the AGENT wrote on that record. ⛔ NEVER the committer date: it is settable
26
26
  * (`GIT_COMMITTER_DATE`, `rebase --committer-date-is-author-date`). STRICT `>=`, no tolerance.
27
27
  *
28
- * ## The scan cap (stated): at most 64 complete records and 64 KiB from the sent offset
29
- * Garbled lines and records without a valid timestamp are skipped and count toward the 64. A trailing unterminated line
30
- * is not a record. A prompt beyond either cap is not found.
28
+ * ## The scan cap (stated): at most 64 complete records, and 8 MiB, from the sent offset — read in 64 KiB chunks
29
+ * 0.4.1 read ONE 64 KiB window; a big record (an attachment, a large tool_result) before the prompt pushed it out in ~1.8% of
30
+ * resumes (VG), and the line was dropped. Now the file is read in chunks until a prompt-like record with a timestamp is
31
+ * found, so a big record no longer hides the prompt. The 64-RECORD cap is unchanged, and an 8 MiB ceiling stops one
32
+ * pathological record from making a hook read for ever. Garbled lines and records without a valid timestamp are skipped and
33
+ * count toward the 64. A trailing unterminated line is not a record. A prompt beyond either cap is not found.
31
34
  *
32
35
  * ## What happens when no prompt time is found — the two paths differ ON PURPOSE
33
36
  * - STALE-OWN path (the session's state file already existed): the line is DROPPED. That is exactly what 0.4.0 did with
@@ -42,8 +45,10 @@
42
45
  * A commit whose line is promoted by a hook whose unsent span has no prompt (its tool_result is the first unsent record,
43
46
  * e.g. a hook that delivered the tool_use before the commit without refreshing liveAt) is dropped on the stale-own path.
44
47
  */
45
- /** The scan reads at most this many bytes from the sent offset. */
46
- export declare const SCAN_WINDOW_BYTES: number;
48
+ /** The scan reads this many bytes at a time from the sent offset… */
49
+ export declare const SCAN_CHUNK_BYTES: number;
50
+ /** …and at most this many in all (a whole number of chunks). Past it the answer is `none`. */
51
+ export declare const SCAN_MAX_BYTES: number;
47
52
  /** …and considers at most this many complete records. */
48
53
  export declare const SCAN_MAX_RECORDS = 64;
49
54
  export type TurnStart = {
@@ -25,9 +25,12 @@
25
25
  * The prompt time is the `timestamp` the AGENT wrote on that record. ⛔ NEVER the committer date: it is settable
26
26
  * (`GIT_COMMITTER_DATE`, `rebase --committer-date-is-author-date`). STRICT `>=`, no tolerance.
27
27
  *
28
- * ## The scan cap (stated): at most 64 complete records and 64 KiB from the sent offset
29
- * Garbled lines and records without a valid timestamp are skipped and count toward the 64. A trailing unterminated line
30
- * is not a record. A prompt beyond either cap is not found.
28
+ * ## The scan cap (stated): at most 64 complete records, and 8 MiB, from the sent offset — read in 64 KiB chunks
29
+ * 0.4.1 read ONE 64 KiB window; a big record (an attachment, a large tool_result) before the prompt pushed it out in ~1.8% of
30
+ * resumes (VG), and the line was dropped. Now the file is read in chunks until a prompt-like record with a timestamp is
31
+ * found, so a big record no longer hides the prompt. The 64-RECORD cap is unchanged, and an 8 MiB ceiling stops one
32
+ * pathological record from making a hook read for ever. Garbled lines and records without a valid timestamp are skipped and
33
+ * count toward the 64. A trailing unterminated line is not a record. A prompt beyond either cap is not found.
31
34
  *
32
35
  * ## What happens when no prompt time is found — the two paths differ ON PURPOSE
33
36
  * - STALE-OWN path (the session's state file already existed): the line is DROPPED. That is exactly what 0.4.0 did with
@@ -43,8 +46,10 @@
43
46
  * e.g. a hook that delivered the tool_use before the commit without refreshing liveAt) is dropped on the stale-own path.
44
47
  */
45
48
  import { closeSync, openSync, readSync } from "node:fs";
46
- /** The scan reads at most this many bytes from the sent offset. */
47
- export const SCAN_WINDOW_BYTES = 64 * 1024;
49
+ /** The scan reads this many bytes at a time from the sent offset… */
50
+ export const SCAN_CHUNK_BYTES = 64 * 1024;
51
+ /** …and at most this many in all (a whole number of chunks). Past it the answer is `none`. */
52
+ export const SCAN_MAX_BYTES = 8 * 1024 * 1024;
48
53
  /** …and considers at most this many complete records. */
49
54
  export const SCAN_MAX_RECORDS = 64;
50
55
  const NONE = { kind: "none" };
@@ -78,28 +83,30 @@ export function turnStart(path, offset, nowMs) {
78
83
  let fd = null;
79
84
  try {
80
85
  fd = openSync(path, "r");
81
- const buf = Buffer.alloc(SCAN_WINDOW_BYTES);
82
- const got = readSync(fd, buf, 0, SCAN_WINDOW_BYTES, offset);
83
- const text = buf.subarray(0, got).toString("utf8");
84
- const lines = text.split("\n");
85
- lines.pop(); // the tail after the last newline is not a complete record
86
- for (const line of lines.slice(0, SCAN_MAX_RECORDS)) {
87
- let rec;
88
- try {
89
- rec = JSON.parse(line);
90
- }
91
- catch {
92
- continue;
86
+ const buf = Buffer.alloc(SCAN_CHUNK_BYTES);
87
+ // A record that crosses a chunk boundary is carried in `partial` as BYTES (never decoded early: a multi-byte character
88
+ // may be cut by the boundary) and decoded only once its newline arrives.
89
+ let partial = [];
90
+ let records = 0;
91
+ let read = 0;
92
+ while (read < SCAN_MAX_BYTES) {
93
+ const got = readSync(fd, buf, 0, SCAN_CHUNK_BYTES, offset + read);
94
+ if (got <= 0)
95
+ break;
96
+ read += got;
97
+ let start = 0;
98
+ for (let nl = buf.indexOf(0x0a, 0); nl !== -1 && nl < got; nl = buf.indexOf(0x0a, start)) {
99
+ const text = Buffer.concat([...partial, buf.subarray(start, nl)]).toString("utf8");
100
+ partial = [];
101
+ start = nl + 1;
102
+ records += 1;
103
+ if (records > SCAN_MAX_RECORDS)
104
+ return NONE;
105
+ const found = promptStart(text, nowMs);
106
+ if (found !== null)
107
+ return found;
93
108
  }
94
- if (rec === null || typeof rec !== "object" || Array.isArray(rec))
95
- continue;
96
- const r = rec;
97
- if (!isPromptLike(r) || typeof r.timestamp !== "string")
98
- continue;
99
- const ms = Date.parse(r.timestamp);
100
- if (!Number.isFinite(ms))
101
- continue;
102
- return ms > nowMs ? { kind: "future" } : { kind: "ts", ms };
109
+ partial.push(Buffer.from(buf.subarray(start, got))); // copy: `buf` is reused by the next read
103
110
  }
104
111
  return NONE;
105
112
  }
@@ -117,6 +124,25 @@ export function turnStart(path, offset, nowMs) {
117
124
  }
118
125
  }
119
126
  }
127
+ /** One complete record: its turn-start answer if it is a prompt-like record with a valid timestamp, else null. */
128
+ function promptStart(line, nowMs) {
129
+ let rec;
130
+ try {
131
+ rec = JSON.parse(line);
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ if (rec === null || typeof rec !== "object" || Array.isArray(rec))
137
+ return null;
138
+ const r = rec;
139
+ if (!isPromptLike(r) || typeof r.timestamp !== "string")
140
+ return null;
141
+ const ms = Date.parse(r.timestamp);
142
+ if (!Number.isFinite(ms))
143
+ return null;
144
+ return ms > nowMs ? { kind: "future" } : { kind: "ts", ms };
145
+ }
120
146
  /** Keep (promote) this pending line? See the header for the two paths. */
121
147
  export function keepsPendingLine(line, bound, mode) {
122
148
  if (bound.kind === "future")
@@ -1 +1 @@
1
- {"version":3,"file":"pending_bound.js","sourceRoot":"","sources":["../src/pending_bound.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"pending_bound.js","sourceRoot":"","sources":["../src/pending_bound.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * ORPHAN PENDING FILES — `TODOS[175]`.
3
+ *
4
+ * A `<sid>.pending.jsonl` has ONE consumer: its own session's running hook (D2 / cross-promotion forbid any other). Two
5
+ * producers write them — the cold rung (an env id never seen) and, since 0.4.1, a STALE-OWN id — so an id whose session
6
+ * never runs again leaves its file for ever, and `status` counted it beside the real spool: a permanent "N commits are
7
+ * waiting" with no remedy (`finalize` has no session to close).
8
+ *
9
+ * ## Why deleting one is safe
10
+ * Since 0.4.1 a pending line is promotable only if it was observed no earlier than the start of the turn being delivered
11
+ * (`pending_bound.ts`), i.e. only by a turn that was ALREADY RUNNING when the commit was observed. A line older than any
12
+ * plausible turn can never pass that bound: it is dead weight, not a recoverable edge. (A turn longer than the TTL loses
13
+ * its edge — the safe direction: a lost edge, never a wrong one.)
14
+ *
15
+ * ## The rule
16
+ * - TTL 7 days, STRICTLY older. A file is EXPIRED when its mtime AND the `observedAt` of every line are past it. A legacy
17
+ * 0.4.0 line has no `observedAt`; the mtime ages it. ⛔ NEVER the line's `at` (the committer date — settable).
18
+ * - WHOLE FILES ONLY, and only `*.pending.jsonl`. Survivors are never rewritten (`appendPending` is an unlocked
19
+ * read-modify-write, so a rewrite could race it). `.spool.jsonl` (real, attributed commits), rewrites and state files are
20
+ * never touched.
21
+ * - At most 50 files per call. Called from the Stop and SessionEnd hooks only — not `post-commit` (it runs under git) and
22
+ * not `status` (readers stay read-only).
23
+ *
24
+ * ## The race, narrowed: rename to a tombstone, re-read, put back anything fresh
25
+ * Check-then-delete leaves a window in which an append lands in a file we then delete. So the file is first RENAMED to
26
+ * `<name>.expired-<ts>` — an atomic step after which `appendPending` creates a NEW file at the old path — and the tombstone
27
+ * is re-read: if it is no longer expired (a line landed before the rename) it goes back, MERGED into any new file by sha
28
+ * rather than clobbering it; otherwise it is deleted. The lost-append window is the rename itself. A crashed prune leaves a
29
+ * tombstone; one older than an hour (judged by the timestamp in its NAME — a rename keeps the old mtime) is settled the
30
+ * same way on the next pass.
31
+ *
32
+ * ## Self-announcing, counts only
33
+ * A silent delete would turn a loss into a non-event. What was discarded is tallied in `pruned-pending.tally` (a name no
34
+ * other scan of the sessions directory matches) as `{files, lines, at}`: no sha, no session id. `status` shows one sentence
35
+ * for up to 7 days after the last discard; an older tally restarts instead of accumulating.
36
+ *
37
+ * Nothing here throws: a hook must exit 0.
38
+ */
39
+ export declare const PENDING_TTL_MS: number;
40
+ export declare const PRUNE_MAX_FILES = 50;
41
+ /** A tombstone younger than this may belong to a prune that is still running in another process. */
42
+ export declare const TOMBSTONE_GRACE_MS = 3600000;
43
+ export interface PruneResult {
44
+ readonly files: number;
45
+ readonly lines: number;
46
+ }
47
+ /** Test seam: runs between the rename and the re-read — exactly where a concurrent append would land. */
48
+ export interface PruneSeams {
49
+ readonly afterRename?: (tombstone: string, original: string) => void;
50
+ }
51
+ /** Pure. Expired = strictly older than the TTL on the file's mtime AND on every line's `observedAt`. */
52
+ export declare function pendingExpired(raw: string, mtimeMs: number, nowMs: number): boolean;
53
+ /** Reads the file; an unreadable one is NOT expired (never count or delete what cannot be examined). */
54
+ export declare function pendingFileExpired(path: string, nowMs: number): boolean;
55
+ export declare function pruneOrphanPending(home: string, repoKey: string, nowMs: number, seams?: PruneSeams): PruneResult;
56
+ /** The counts to announce, or null when nothing was discarded in the last 7 days. */
57
+ export declare function readPruneTally(home: string, repoKey: string, nowMs: number): {
58
+ files: number;
59
+ lines: number;
60
+ } | null;
@@ -0,0 +1,207 @@
1
+ /**
2
+ * ORPHAN PENDING FILES — `TODOS[175]`.
3
+ *
4
+ * A `<sid>.pending.jsonl` has ONE consumer: its own session's running hook (D2 / cross-promotion forbid any other). Two
5
+ * producers write them — the cold rung (an env id never seen) and, since 0.4.1, a STALE-OWN id — so an id whose session
6
+ * never runs again leaves its file for ever, and `status` counted it beside the real spool: a permanent "N commits are
7
+ * waiting" with no remedy (`finalize` has no session to close).
8
+ *
9
+ * ## Why deleting one is safe
10
+ * Since 0.4.1 a pending line is promotable only if it was observed no earlier than the start of the turn being delivered
11
+ * (`pending_bound.ts`), i.e. only by a turn that was ALREADY RUNNING when the commit was observed. A line older than any
12
+ * plausible turn can never pass that bound: it is dead weight, not a recoverable edge. (A turn longer than the TTL loses
13
+ * its edge — the safe direction: a lost edge, never a wrong one.)
14
+ *
15
+ * ## The rule
16
+ * - TTL 7 days, STRICTLY older. A file is EXPIRED when its mtime AND the `observedAt` of every line are past it. A legacy
17
+ * 0.4.0 line has no `observedAt`; the mtime ages it. ⛔ NEVER the line's `at` (the committer date — settable).
18
+ * - WHOLE FILES ONLY, and only `*.pending.jsonl`. Survivors are never rewritten (`appendPending` is an unlocked
19
+ * read-modify-write, so a rewrite could race it). `.spool.jsonl` (real, attributed commits), rewrites and state files are
20
+ * never touched.
21
+ * - At most 50 files per call. Called from the Stop and SessionEnd hooks only — not `post-commit` (it runs under git) and
22
+ * not `status` (readers stay read-only).
23
+ *
24
+ * ## The race, narrowed: rename to a tombstone, re-read, put back anything fresh
25
+ * Check-then-delete leaves a window in which an append lands in a file we then delete. So the file is first RENAMED to
26
+ * `<name>.expired-<ts>` — an atomic step after which `appendPending` creates a NEW file at the old path — and the tombstone
27
+ * is re-read: if it is no longer expired (a line landed before the rename) it goes back, MERGED into any new file by sha
28
+ * rather than clobbering it; otherwise it is deleted. The lost-append window is the rename itself. A crashed prune leaves a
29
+ * tombstone; one older than an hour (judged by the timestamp in its NAME — a rename keeps the old mtime) is settled the
30
+ * same way on the next pass.
31
+ *
32
+ * ## Self-announcing, counts only
33
+ * A silent delete would turn a loss into a non-event. What was discarded is tallied in `pruned-pending.tally` (a name no
34
+ * other scan of the sessions directory matches) as `{files, lines, at}`: no sha, no session id. `status` shows one sentence
35
+ * for up to 7 days after the last discard; an older tally restarts instead of accumulating.
36
+ *
37
+ * Nothing here throws: a hook must exit 0.
38
+ */
39
+ import { appendFileSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
40
+ import { join } from "node:path";
41
+ import { repoSessionsDir } from "./paths.js";
42
+ export const PENDING_TTL_MS = 7 * 24 * 3600_000;
43
+ export const PRUNE_MAX_FILES = 50;
44
+ /** A tombstone younger than this may belong to a prune that is still running in another process. */
45
+ export const TOMBSTONE_GRACE_MS = 3600_000;
46
+ const PENDING_SUFFIX = ".pending.jsonl";
47
+ const TOMBSTONE = /^(.+\.pending\.jsonl)\.expired-(\d+)$/;
48
+ const TALLY_NAME = "pruned-pending.tally";
49
+ const nonBlank = (raw) => raw.split("\n").filter((l) => l.trim() !== "");
50
+ /** Pure. Expired = strictly older than the TTL on the file's mtime AND on every line's `observedAt`. */
51
+ export function pendingExpired(raw, mtimeMs, nowMs) {
52
+ if (!(nowMs - mtimeMs > PENDING_TTL_MS))
53
+ return false;
54
+ for (const l of nonBlank(raw)) {
55
+ let rec;
56
+ try {
57
+ rec = JSON.parse(l);
58
+ }
59
+ catch {
60
+ continue;
61
+ }
62
+ const at = rec !== null && typeof rec === "object" ? rec.observedAt : undefined;
63
+ if (typeof at === "number" && Number.isFinite(at) && !(nowMs - at > PENDING_TTL_MS))
64
+ return false;
65
+ }
66
+ return true;
67
+ }
68
+ /** Reads the file; an unreadable one is NOT expired (never count or delete what cannot be examined). */
69
+ export function pendingFileExpired(path, nowMs) {
70
+ try {
71
+ return pendingExpired(readFileSync(path, "utf8"), statSync(path).mtimeMs, nowMs);
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ function shaOf(line) {
78
+ try {
79
+ const s = JSON.parse(line).sha;
80
+ return typeof s === "string" ? s : null;
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ /** Put a tombstone's lines back under its original name, merging into a file that appeared meanwhile; then remove it. */
87
+ function restore(tomb, original, raw) {
88
+ let live = null;
89
+ try {
90
+ live = readFileSync(original, "utf8");
91
+ }
92
+ catch {
93
+ /* nothing there */
94
+ }
95
+ if (live === null) {
96
+ renameSync(tomb, original);
97
+ return;
98
+ }
99
+ const have = new Set(nonBlank(live).map(shaOf));
100
+ for (const l of nonBlank(raw))
101
+ if (!have.has(shaOf(l)))
102
+ appendFileSync(original, `${l}\n`);
103
+ rmSync(tomb, { force: true });
104
+ }
105
+ /** Examine one tombstone (just made, or left by a crash): delete it if still expired, else restore it. Returns the lines discarded. */
106
+ function settle(tomb, original, nowMs) {
107
+ let raw;
108
+ let mtimeMs;
109
+ try {
110
+ raw = readFileSync(tomb, "utf8");
111
+ mtimeMs = statSync(tomb).mtimeMs;
112
+ }
113
+ catch {
114
+ return 0;
115
+ }
116
+ if (!pendingExpired(raw, mtimeMs, nowMs)) {
117
+ restore(tomb, original, raw);
118
+ return 0;
119
+ }
120
+ rmSync(tomb, { force: true });
121
+ return nonBlank(raw).length;
122
+ }
123
+ export function pruneOrphanPending(home, repoKey, nowMs, seams = {}) {
124
+ let files = 0;
125
+ let lines = 0;
126
+ try {
127
+ const dir = repoSessionsDir(home, repoKey);
128
+ if (dir === null)
129
+ return { files, lines };
130
+ const entries = readdirSync(dir).sort();
131
+ let budget = PRUNE_MAX_FILES;
132
+ for (const entry of entries) {
133
+ if (budget <= 0)
134
+ break;
135
+ try {
136
+ const t = TOMBSTONE.exec(entry);
137
+ if (t !== null) {
138
+ if (nowMs - Number(t[2]) <= TOMBSTONE_GRACE_MS)
139
+ continue; // maybe mid-flight in another process
140
+ budget -= 1;
141
+ const n = settle(join(dir, entry), join(dir, t[1]), nowMs);
142
+ if (n > 0) {
143
+ files += 1;
144
+ lines += n;
145
+ }
146
+ continue;
147
+ }
148
+ if (!entry.endsWith(PENDING_SUFFIX))
149
+ continue;
150
+ const path = join(dir, entry);
151
+ if (!pendingFileExpired(path, nowMs))
152
+ continue;
153
+ budget -= 1;
154
+ const tomb = `${path}.expired-${nowMs}`;
155
+ renameSync(path, tomb);
156
+ seams.afterRename?.(tomb, path);
157
+ const n = settle(tomb, path, nowMs);
158
+ if (n > 0) {
159
+ files += 1;
160
+ lines += n;
161
+ }
162
+ }
163
+ catch {
164
+ /* one unreadable entry must not stop the pass */
165
+ }
166
+ }
167
+ if (files > 0 || lines > 0)
168
+ recordTally(dir, files, lines, nowMs);
169
+ }
170
+ catch {
171
+ /* never throw from a hook */
172
+ }
173
+ return { files, lines };
174
+ }
175
+ function readTally(dir) {
176
+ try {
177
+ const t = JSON.parse(readFileSync(join(dir, TALLY_NAME), "utf8"));
178
+ if (Number.isSafeInteger(t.files) && Number.isSafeInteger(t.lines) && typeof t.at === "number" && Number.isFinite(t.at))
179
+ return t;
180
+ }
181
+ catch {
182
+ /* absent or damaged: no tally */
183
+ }
184
+ return null;
185
+ }
186
+ function recordTally(dir, files, lines, nowMs) {
187
+ try {
188
+ const prev = readTally(dir);
189
+ const carry = prev !== null && nowMs - prev.at <= PENDING_TTL_MS ? prev : { files: 0, lines: 0, at: nowMs };
190
+ const next = { files: carry.files + files, lines: carry.lines + lines, at: nowMs };
191
+ writeFileSync(join(dir, TALLY_NAME), `${JSON.stringify(next)}\n`, { mode: 0o600 });
192
+ }
193
+ catch {
194
+ /* the discard already happened; failing to announce it must not undo or throw */
195
+ }
196
+ }
197
+ /** The counts to announce, or null when nothing was discarded in the last 7 days. */
198
+ export function readPruneTally(home, repoKey, nowMs) {
199
+ const dir = repoSessionsDir(home, repoKey);
200
+ if (dir === null)
201
+ return null;
202
+ const t = readTally(dir);
203
+ if (t === null || nowMs - t.at > PENDING_TTL_MS || (t.files === 0 && t.lines === 0))
204
+ return null;
205
+ return { files: t.files, lines: t.lines };
206
+ }
207
+ //# sourceMappingURL=pending_prune.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pending_prune.js","sourceRoot":"","sources":["../src/pending_prune.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACjH,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAE7C,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,GAAG,EAAE,GAAG,QAAQ,CAAC;AAChD,MAAM,CAAC,MAAM,eAAe,GAAG,EAAE,CAAC;AAClC,oGAAoG;AACpG,MAAM,CAAC,MAAM,kBAAkB,GAAG,QAAQ,CAAC;AAE3C,MAAM,cAAc,GAAG,gBAAgB,CAAC;AACxC,MAAM,SAAS,GAAG,uCAAuC,CAAC;AAC1D,MAAM,UAAU,GAAG,sBAAsB,CAAC;AAY1C,MAAM,QAAQ,GAAG,CAAC,GAAW,EAAY,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;AAE3F,wGAAwG;AACxG,MAAM,UAAU,cAAc,CAAC,GAAW,EAAE,OAAe,EAAE,KAAa;IACxE,IAAI,CAAC,CAAC,KAAK,GAAG,OAAO,GAAG,cAAc,CAAC;QAAE,OAAO,KAAK,CAAC;IACtD,KAAK,MAAM,CAAC,IAAI,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;QAC9B,IAAI,GAAY,CAAC;QACjB,IAAI,CAAC;YACH,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QACtB,CAAC;QAAC,MAAM,CAAC;YACP,SAAS;QACX,CAAC;QACD,MAAM,EAAE,GAAG,GAAG,KAAK,IAAI,IAAI,OAAO,GAAG,KAAK,QAAQ,CAAC,CAAC,CAAE,GAAgC,CAAC,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC;QAC9G,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,KAAK,GAAG,EAAE,GAAG,cAAc,CAAC;YAAE,OAAO,KAAK,CAAC;IACpG,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,wGAAwG;AACxG,MAAM,UAAU,kBAAkB,CAAC,IAAY,EAAE,KAAa;IAC5D,IAAI,CAAC;QACH,OAAO,cAAc,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;IACnF,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,SAAS,KAAK,CAAC,IAAY;IACzB,IAAI,CAAC;QACH,MAAM,CAAC,GAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAuB,CAAC,GAAG,CAAC;QACtD,OAAO,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAC1C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,yHAAyH;AACzH,SAAS,OAAO,CAAC,IAAY,EAAE,QAAgB,EAAE,GAAW;IAC1D,IAAI,IAAI,GAAkB,IAAI,CAAC;IAC/B,IAAI,CAAC;QACH,IAAI,GAAG,YAAY,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IACxC,CAAC;IAAC,MAAM,CAAC;QACP,mBAAmB;IACrB,CAAC;IACD,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;QAClB,UAAU,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;QAC3B,OAAO;IACT,CAAC;IACD,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC;IAChD,KAAK,MAAM,CAAC,IAAI,QAAQ,CAAC,GAAG,CAAC;QAAE,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;YAAE,cAAc,CAAC,QAAQ,EAAE,GAAG,CAAC,IAAI,CAAC,CAAC;IAC3F,MAAM,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;AAChC,CAAC;AAED,uIAAuI;AACvI,SAAS,MAAM,CAAC,IAAY,EAAE,QAAgB,EAAE,KAAa;IAC3D,IAAI,GAAW,CAAC;IAChB,IAAI,OAAe,CAAC;IACpB,IAAI,CAAC;QACH,GAAG,GAAG,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QACjC,OAAO,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC;IACnC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,CAAC,CAAC;IACX,CAAC;IACD,IAAI,CAAC,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,CAAC;QACzC,OAAO,CAAC,IAAI,EAAE,QAAQ,EAAE,GAAG,CAAC,CAAC;QAC7B,OAAO,CAAC,CAAC;IACX,CAAC;IACD,MAAM,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IAC9B,OAAO,QAAQ,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC;AAC9B,CAAC;AAED,MAAM,UAAU,kBAAkB,CAAC,IAAY,EAAE,OAAe,EAAE,KAAa,EAAE,QAAoB,EAAE;IACrG,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,eAAe,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QAC3C,IAAI,GAAG,KAAK,IAAI;YAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;QAC1C,MAAM,OAAO,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;QACxC,IAAI,MAAM,GAAG,eAAe,CAAC;QAC7B,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;YAC5B,IAAI,MAAM,IAAI,CAAC;gBAAE,MAAM;YACvB,IAAI,CAAC;gBACH,MAAM,CAAC,GAAG,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;gBAChC,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;oBACf,IAAI,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,kBAAkB;wBAAE,SAAS,CAAG,sCAAsC;oBAClG,MAAM,IAAI,CAAC,CAAC;oBACZ,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;oBAC3D,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;wBAAC,KAAK,IAAI,CAAC,CAAC;wBAAC,KAAK,IAAI,CAAC,CAAC;oBAAC,CAAC;oBACtC,SAAS;gBACX,CAAC;gBACD,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,cAAc,CAAC;oBAAE,SAAS;gBAC9C,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;gBAC9B,IAAI,CAAC,kBAAkB,CAAC,IAAI,EAAE,KAAK,CAAC;oBAAE,SAAS;gBAC/C,MAAM,IAAI,CAAC,CAAC;gBACZ,MAAM,IAAI,GAAG,GAAG,IAAI,YAAY,KAAK,EAAE,CAAC;gBACxC,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;gBACvB,KAAK,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;gBAChC,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;gBACpC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;oBAAC,KAAK,IAAI,CAAC,CAAC;oBAAC,KAAK,IAAI,CAAC,CAAC;gBAAC,CAAC;YACxC,CAAC;YAAC,MAAM,CAAC;gBACP,iDAAiD;YACnD,CAAC;QACH,CAAC;QACD,IAAI,KAAK,GAAG,CAAC,IAAI,KAAK,GAAG,CAAC;YAAE,WAAW,CAAC,GAAG,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC;IACpE,CAAC;IAAC,MAAM,CAAC;QACP,6BAA6B;IAC/B,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;AAC1B,CAAC;AAQD,SAAS,SAAS,CAAC,GAAW;IAC5B,IAAI,CAAC;QACH,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,CAAC,GAAG,EAAE,UAAU,CAAC,EAAE,MAAM,CAAC,CAAmB,CAAC;QACpF,IAAI,MAAM,CAAC,aAAa,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC,aAAa,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,OAAO,CAAC,CAAC,EAAE,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;YAAE,OAAO,CAAU,CAAC;IAC7I,CAAC;IAAC,MAAM,CAAC;QACP,iCAAiC;IACnC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,WAAW,CAAC,GAAW,EAAE,KAAa,EAAE,KAAa,EAAE,KAAa;IAC3E,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC;QAC5B,MAAM,KAAK,GAAG,IAAI,KAAK,IAAI,IAAI,KAAK,GAAG,IAAI,CAAC,EAAE,IAAI,cAAc,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC;QAC5G,MAAM,IAAI,GAAU,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,GAAG,KAAK,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,GAAG,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC;QAC1F,aAAa,CAAC,IAAI,CAAC,GAAG,EAAE,UAAU,CAAC,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IACrF,CAAC;IAAC,MAAM,CAAC;QACP,iFAAiF;IACnF,CAAC;AACH,CAAC;AAED,qFAAqF;AACrF,MAAM,UAAU,cAAc,CAAC,IAAY,EAAE,OAAe,EAAE,KAAa;IACzE,MAAM,GAAG,GAAG,eAAe,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IAC3C,IAAI,GAAG,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAC9B,MAAM,CAAC,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC;IACzB,IAAI,CAAC,KAAK,IAAI,IAAI,KAAK,GAAG,CAAC,CAAC,EAAE,GAAG,cAAc,IAAI,CAAC,CAAC,CAAC,KAAK,KAAK,CAAC,IAAI,CAAC,CAAC,KAAK,KAAK,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IACjG,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC;AAC5C,CAAC"}
package/dist/post.d.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  import { type SelectedAgentId } from "./agents/types.js";
21
21
  import type { IngestCredential } from "./credential.js";
22
22
  import { type Disposition } from "./policy.js";
23
- import { type Observed } from "./spool.js";
23
+ import { type InferredGroupId, type Observed } from "./spool.js";
24
24
  /** Production data plane. Overridable for tests and for a self-hosted server. */
25
25
  export declare const DEFAULT_INGEST_URL = "https://api.vibecommit.ai/ingest/v1/session";
26
26
  /**
@@ -122,6 +122,15 @@ export interface IngestDelta {
122
122
  * be wrong about.
123
123
  */
124
124
  readonly rewrites?: readonly string[];
125
+ /**
126
+ * INFERRED squash pairs — `ancestor:successor:patchid`, already in wire spelling (`capInferred`). `TODOS[177]`.
127
+ *
128
+ * ⛔ **THEIR OWN HEADER, `x-rewrites-inferred`, AND NEVER `x-rewrites`.** `x-rewrites` is what GIT REPORTED (`post-rewrite`) and the
129
+ * server stores it as exact; a pair this client INFERRED from a reflog and a patch-id is a different claim and must be stored as
130
+ * one. Folding the two into one header would launder an inference into a fact, which is the one error this wire cannot take back
131
+ * (`match_kind` is in the primary key and the history is append-only).
132
+ */
133
+ readonly inferredRewrites?: readonly string[];
125
134
  }
126
135
  /**
127
136
  * Build the request headers.
@@ -232,6 +241,15 @@ export type SendOutcome = {
232
241
  * verb sends, so `null` after asking for finality means "not done", never "done".
233
242
  */
234
243
  readonly finalAck: "sealed" | "already" | "refused" | null;
244
+ /**
245
+ * ⭐ `X-Rewrites-Inferred-Stored` (VG pre-review, 2026-10-08) — how many of the pairs we SENT on
246
+ * `x-rewrites-inferred` the server actually recorded. `null` when the header is absent (an older
247
+ * server, including the study servers before this deploys) or malformed: "not determinable" is not
248
+ * "zero". A group is only marked delivered when this equals the exact count we sent — anything less,
249
+ * including `null`, keeps every group for the next hook. `ON CONFLICT DO NOTHING` on the server makes
250
+ * a resend idempotent, so keeping on doubt costs a duplicate request, never a duplicate row.
251
+ */
252
+ readonly inferredStored: number | null;
235
253
  }
236
254
  /** No response at all — DNS, refused, TLS, timeout. Not an HTTP status. */
237
255
  | {
@@ -289,6 +307,19 @@ export type Delivery =
289
307
  * behaves exactly as before — which is what the hook path does.
290
308
  */
291
309
  readonly detail: ErrorDetail | null;
310
+ /**
311
+ * `TODOS[177]` follow-up (VL, 2026-10-08) — the server's `x-rewrites-inferred-stored` ack, pass-through from
312
+ * `SendOutcome` (so `null` whenever the response didn't carry it, including a request that sent no inferred
313
+ * pairs at all — the caller already knows whether it asked). `finalize`'s drain loop reads this to decide
314
+ * whether to keep looping: an ABSENT ack (old server) means stop immediately, a SHORT one means move on.
315
+ */
316
+ readonly inferredStored: number | null;
317
+ /**
318
+ * `TODOS[177]` follow-up (VL, 2026-10-08, VG's silent-loss finding) — how many pairs THIS call just set aside
319
+ * (0 when it didn't). `finalize`'s drain loop sums this across the main delivery and every drain request to
320
+ * print ONE stderr line for the whole run, rather than one per request.
321
+ */
322
+ readonly inferredSetAsidePairs: number;
292
323
  };
293
324
  export interface DeliveryContext {
294
325
  readonly home: string;
@@ -344,6 +375,14 @@ export interface DeliveryContext {
344
375
  readonly commits?: Observed;
345
376
  /** Rewrite pairs, wire-spelled. Main delivery only, for the same reason. */
346
377
  readonly rewrites?: readonly string[];
378
+ /** `TODOS[177]` — the inferred pairs this delivery carries (`capInferred`), wire-spelled. */
379
+ readonly inferredRewrites?: readonly string[];
380
+ /**
381
+ * The IDENTITIES of the groups behind `inferredRewrites` (`capInferred`'s `groups`) — what `dropInferredGroups` marks
382
+ * delivered, and ONLY when the server's `x-rewrites-inferred-stored` ack equals `inferredRewrites.length` exactly.
383
+ * `undefined` here means the same as an empty `inferredRewrites`: nothing to drop.
384
+ */
385
+ readonly inferredGroups?: readonly InferredGroupId[];
347
386
  /**
348
387
  * ⭐ `TODOS[146]` — assert the session is over (`X-Session-Final: 1`) and send EVEN WHEN THE
349
388
  * OFFSET IS AT EOF, as an empty delta: the common one-shot case is a session whose bytes all