@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.
- package/README.md +20 -0
- package/dist/commands/finalize.js +94 -2
- package/dist/commands/finalize.js.map +1 -1
- package/dist/commands/status.js +33 -2
- package/dist/commands/status.js.map +1 -1
- package/dist/copy/strings.d.ts +29 -1
- package/dist/copy/strings.js +43 -1
- package/dist/copy/strings.js.map +1 -1
- package/dist/git.d.ts +10 -0
- package/dist/git.js +16 -0
- package/dist/git.js.map +1 -1
- package/dist/hooks/entry.d.ts +10 -0
- package/dist/hooks/entry.js +35 -3
- package/dist/hooks/entry.js.map +1 -1
- package/dist/hooks/post_commit.js +23 -4
- package/dist/hooks/post_commit.js.map +1 -1
- package/dist/inferred_squash.d.ts +67 -0
- package/dist/inferred_squash.js +151 -0
- package/dist/inferred_squash.js.map +1 -0
- package/dist/inferred_tally.d.ts +50 -0
- package/dist/inferred_tally.js +99 -0
- package/dist/inferred_tally.js.map +1 -0
- package/dist/pending_bound.d.ts +10 -5
- package/dist/pending_bound.js +52 -26
- package/dist/pending_bound.js.map +1 -1
- package/dist/pending_prune.d.ts +60 -0
- package/dist/pending_prune.js +207 -0
- package/dist/pending_prune.js.map +1 -0
- package/dist/post.d.ts +40 -1
- package/dist/post.js +57 -1
- package/dist/post.js.map +1 -1
- package/dist/spool.d.ts +120 -0
- package/dist/spool.js +494 -1
- package/dist/spool.js.map +1 -1
- package/dist/state.d.ts +2 -2
- package/dist/state.js +10 -4
- 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
|
@@ -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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"}
|
package/dist/pending_bound.d.ts
CHANGED
|
@@ -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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
46
|
-
export declare const
|
|
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 = {
|
package/dist/pending_bound.js
CHANGED
|
@@ -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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
47
|
-
export const
|
|
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(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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":"AAAA
|
|
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":"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"}
|
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
|