@vibe-commit/capture 0.4.0 → 0.4.2
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 +10 -0
- package/dist/commands/status.js +10 -2
- package/dist/commands/status.js.map +1 -1
- package/dist/copy/strings.d.ts +5 -0
- package/dist/copy/strings.js +6 -0
- package/dist/copy/strings.js.map +1 -1
- package/dist/hooks/entry.d.ts +8 -0
- package/dist/hooks/entry.js +27 -2
- package/dist/hooks/entry.js.map +1 -1
- package/dist/hooks/post_commit.js +3 -0
- package/dist/hooks/post_commit.js.map +1 -1
- package/dist/pending_bound.d.ts +72 -0
- package/dist/pending_bound.js +156 -0
- package/dist/pending_bound.js.map +1 -0
- 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/spool.d.ts +22 -1
- package/dist/spool.js +55 -31
- 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 +3 -2
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PROMOTION BOUND — `TODOS[176]`; revised after VG's review of 68eb3a7 (option A, VL ruling 2026-10-05).
|
|
3
|
+
*
|
|
4
|
+
* A pending line is promoted into the wire spool by its own session's running hook. Since the cold rung also holds the
|
|
5
|
+
* lines of a RESUMED session (state file present but not live), a pending line can be days old when that session's hook
|
|
6
|
+
* finally runs: a terminal that still exports the session id, committing by hand, would otherwise have those commits
|
|
7
|
+
* adopted as the session's own the moment it resumed. So a line is promoted only if it was OBSERVED no earlier than the
|
|
8
|
+
* START OF THE TURN the hook is delivering: `observedAt >= turnStart`.
|
|
9
|
+
*
|
|
10
|
+
* ## What "the start of the turn" is — and what 68eb3a7 got wrong
|
|
11
|
+
* 68eb3a7 compared against the FIRST unsent record. Measured with real `claude -p` (VG), that is not the turn start:
|
|
12
|
+
* 1. A session left OPEN and idle (no SessionEnd flush): after a Stop, the first unsent record is the previous Stop's
|
|
13
|
+
* TRAILING record (attachment / stop_hook_summary), stamped at the OLD Stop time, so a lingering-id commit anywhere in
|
|
14
|
+
* the idle gap passed `>=` and was adopted.
|
|
15
|
+
* 2. Many real records carry NO timestamp (mode, agent-name, custom-title, last-prompt, permission-mode,
|
|
16
|
+
* file-history-snapshot, cost-state); "no bound can be computed" then meant "keep", which adopted lines 0.4.0 refused.
|
|
17
|
+
* So the turn start is the timestamp of the first unsent PROMPT-LIKE record, found by SKIPPING everything else:
|
|
18
|
+
* - `type:"user"` whose `message.content` is a STRING (a tool_result is an array; an `isMeta` record is skipped), or
|
|
19
|
+
* - `type:"queue-operation"` with `operation:"enqueue"` (the resume-time record `claude -p --resume` writes first).
|
|
20
|
+
* Local slash-command / `!` records are skipped (`LOCAL_RECORD`).
|
|
21
|
+
* A prompt always precedes the turn's tool calls, so it is never later than a commit made in the turn.
|
|
22
|
+
*
|
|
23
|
+
* ## Which clock is compared to which
|
|
24
|
+
* `observedAt` is the WALL CLOCK of the `post-commit` process (our process clock, written when the line was spooled).
|
|
25
|
+
* The prompt time is the `timestamp` the AGENT wrote on that record. ⛔ NEVER the committer date: it is settable
|
|
26
|
+
* (`GIT_COMMITTER_DATE`, `rebase --committer-date-is-author-date`). STRICT `>=`, no tolerance.
|
|
27
|
+
*
|
|
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.
|
|
34
|
+
*
|
|
35
|
+
* ## What happens when no prompt time is found — the two paths differ ON PURPOSE
|
|
36
|
+
* - STALE-OWN path (the session's state file already existed): the line is DROPPED. That is exactly what 0.4.0 did with
|
|
37
|
+
* such a line (it never wrote one), so dropping adopts nothing 0.4.0 refused. A line with no `observedAt` is dropped
|
|
38
|
+
* too: no 0.4.0 client wrote a stale-own pending line, so it cannot be ours.
|
|
39
|
+
* - FIRST-TURN path (no state file yet): the line is KEPT. There the first record IS the prompt, and keeping is today's
|
|
40
|
+
* behaviour. A legacy 0.4.0 line (no `observedAt`) is kept on this path only.
|
|
41
|
+
* A prompt dated AFTER the hook's own clock (skew: a record cannot be written ahead of now) disables the bound on both
|
|
42
|
+
* paths (kept): a bound that is demonstrably wrong must not drop a legitimate line.
|
|
43
|
+
*
|
|
44
|
+
* ## The cost, accepted (the safe direction: a lost edge, never a wrong one)
|
|
45
|
+
* A commit whose line is promoted by a hook whose unsent span has no prompt (its tool_result is the first unsent record,
|
|
46
|
+
* e.g. a hook that delivered the tool_use before the commit without refreshing liveAt) is dropped on the stale-own path.
|
|
47
|
+
*/
|
|
48
|
+
import { closeSync, openSync, readSync } from "node:fs";
|
|
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;
|
|
53
|
+
/** …and considers at most this many complete records. */
|
|
54
|
+
export const SCAN_MAX_RECORDS = 64;
|
|
55
|
+
const NONE = { kind: "none" };
|
|
56
|
+
function isPromptLike(rec) {
|
|
57
|
+
if (rec.type === "queue-operation")
|
|
58
|
+
return rec.operation === "enqueue";
|
|
59
|
+
if (rec.type !== "user" || rec.isMeta === true)
|
|
60
|
+
return false;
|
|
61
|
+
const message = rec.message;
|
|
62
|
+
if (message === null || typeof message !== "object")
|
|
63
|
+
return false;
|
|
64
|
+
const content = message.content;
|
|
65
|
+
return typeof content === "string" && !LOCAL_RECORD.test(content);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A LOCAL record the CLI writes for a slash command or a `!` shell escape (`<command-name>`, `<command-message>`,
|
|
69
|
+
* `<local-command-…>`, `<bash-…>`) is `type:"user"` with STRING content and not `isMeta`, but it starts no model turn and
|
|
70
|
+
* triggers no Stop. Run as the last thing before an idle spell it is still unsent at the next turn's hook, stamped HOURS
|
|
71
|
+
* before the real prompt, so a lingering-id line observed in between would pass `>=` (VG, cell 12). Skipped. The price is
|
|
72
|
+
* the safe direction: a turn started ONLY by such a record has no turn start, so a stale-own line is dropped. Anchored at
|
|
73
|
+
* the start of the trimmed text: a real prompt that merely MENTIONS a tag still counts.
|
|
74
|
+
*/
|
|
75
|
+
const LOCAL_RECORD = /^\s*<(?:command-name>|command-message>|local-command|bash-)/;
|
|
76
|
+
/**
|
|
77
|
+
* The turn start of the unsent span of `path` beginning at byte `offset`, as the timestamp of the first prompt-like
|
|
78
|
+
* record with a valid timestamp (see the header). `nowMs` is the hook's own clock (the skew guard).
|
|
79
|
+
*/
|
|
80
|
+
export function turnStart(path, offset, nowMs) {
|
|
81
|
+
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
82
|
+
return NONE;
|
|
83
|
+
let fd = null;
|
|
84
|
+
try {
|
|
85
|
+
fd = openSync(path, "r");
|
|
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;
|
|
108
|
+
}
|
|
109
|
+
partial.push(Buffer.from(buf.subarray(start, got))); // copy: `buf` is reused by the next read
|
|
110
|
+
}
|
|
111
|
+
return NONE;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return NONE;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
if (fd !== null) {
|
|
118
|
+
try {
|
|
119
|
+
closeSync(fd);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* nothing to do */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
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
|
+
}
|
|
146
|
+
/** Keep (promote) this pending line? See the header for the two paths. */
|
|
147
|
+
export function keepsPendingLine(line, bound, mode) {
|
|
148
|
+
if (bound.kind === "future")
|
|
149
|
+
return true;
|
|
150
|
+
if (bound.kind === "none")
|
|
151
|
+
return mode === "first-turn";
|
|
152
|
+
if (line.observedAt === undefined)
|
|
153
|
+
return mode === "first-turn";
|
|
154
|
+
return line.observedAt >= bound.ms;
|
|
155
|
+
}
|
|
156
|
+
//# sourceMappingURL=pending_bound.js.map
|
|
@@ -0,0 +1 @@
|
|
|
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/spool.d.ts
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
* the sha; the observation is not. `files` is carried for wave 2 and never put
|
|
41
41
|
* in a header.
|
|
42
42
|
*/
|
|
43
|
+
import { type PendingMode, type TurnStart } from "./pending_bound.js";
|
|
43
44
|
import { type SessionKey } from "./paths.js";
|
|
44
45
|
/**
|
|
45
46
|
* ⛔ **WHICH RUNG OF THE LADDER ANSWERED — the difference between an observation,
|
|
@@ -139,6 +140,12 @@ export interface SpoolEntry {
|
|
|
139
140
|
* variable belonged to the committing process, and the mtimes have moved on.
|
|
140
141
|
*/
|
|
141
142
|
readonly attribution: AttributionRung;
|
|
143
|
+
/**
|
|
144
|
+
* `TODOS[176]` — the WALL CLOCK (ms since the epoch) of the `post-commit` process when it spooled this line. Written
|
|
145
|
+
* on PENDING lines only; `pending_bound.ts` compares it with the start of the turn being delivered (the first unsent prompt-like record). Additive: a
|
|
146
|
+
* 0.4.0 line has none and is promoted as before. Never the committer date (settable).
|
|
147
|
+
*/
|
|
148
|
+
readonly observedAt?: number;
|
|
142
149
|
}
|
|
143
150
|
/**
|
|
144
151
|
* How many SHAs one hook may put on the wire.
|
|
@@ -311,6 +318,13 @@ export interface ActiveSession {
|
|
|
311
318
|
* forgot the env path, and this seam has no other check.
|
|
312
319
|
*/
|
|
313
320
|
export declare function activeSessionFor(home: string, repoKey: string, envSessionId: string | null, nowMs?: number, windowMs?: number): ActiveSession | null;
|
|
321
|
+
/**
|
|
322
|
+
* `TODOS[176]` — which promotion path a pending line is on, decided by the session's OWN state at the moment its hook runs:
|
|
323
|
+
* `stale-own` = its state file EXISTS and is not live (a RESUMED session: the cold rung put the line here because rung 1 failed),
|
|
324
|
+
* `first-turn` = no state file yet, or one that is live (the original cold case — the first Stop promotes BEFORE it saves state).
|
|
325
|
+
* Reads only; writes nothing (D2).
|
|
326
|
+
*/
|
|
327
|
+
export declare function pendingModeFor(home: string, key: SessionKey, nowMs?: number, windowMs?: number): PendingMode;
|
|
314
328
|
/**
|
|
315
329
|
* ⛔ **THE TWO RUNGS THAT MAY GO ON `x-commit-attributions` (`D190`).**
|
|
316
330
|
*
|
|
@@ -506,4 +520,11 @@ export declare function promotePending(home: string, key: SessionKey,
|
|
|
506
520
|
* a lingering exported `CLAUDE_CODE_SESSION_ID` accumulates pending lines that
|
|
507
521
|
* are never promoted and age out under `MAX_PENDING_SHAS`.
|
|
508
522
|
*/
|
|
509
|
-
corroboration: "this-session-is-running"
|
|
523
|
+
corroboration: "this-session-is-running",
|
|
524
|
+
/**
|
|
525
|
+
* ⛔ `TODOS[176]` — THE PROMOTION BOUND AND ITS PATH, BOTH REQUIRED SO A CALLER CANNOT FORGET THEM. `bound` is the start
|
|
526
|
+
* of the turn this hook is delivering (`turnStart`: the first unsent PROMPT-LIKE record's timestamp); `mode` says whether the
|
|
527
|
+
* session's state file already existed (`stale-own`) or not (`first-turn`). A line observed before the turn start is DROPPED;
|
|
528
|
+
* when no turn start is found a stale-own line is dropped and a first-turn line kept. See `pending_bound.ts`.
|
|
529
|
+
*/
|
|
530
|
+
bound: TurnStart, mode: PendingMode): number;
|
package/dist/spool.js
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
*/
|
|
43
43
|
import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
44
44
|
import { dirname, join } from "node:path";
|
|
45
|
+
import { keepsPendingLine } from "./pending_bound.js";
|
|
45
46
|
import { repoSessionsDir, sessionStatePath } from "./paths.js";
|
|
46
47
|
/**
|
|
47
48
|
* How many SHAs one hook may put on the wire.
|
|
@@ -306,36 +307,33 @@ export function activeSessionFor(home, repoKey, envSessionId, nowMs = Date.now()
|
|
|
306
307
|
// forever. The refusal's premise — *we were TOLD who committed, and it is none of these* —
|
|
307
308
|
// is false for a session that has merely not written its state file YET.
|
|
308
309
|
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
// ancient session, and promotion would then mint permanent edges for it.
|
|
310
|
+
// ⭐ **`TODOS[176]` — AND IT IS THE SAME FOR A SESSION WHOSE STATE FILE EXISTS BUT HAS GONE STALE.**
|
|
311
|
+
// A RESUMED session (idle past the 30-minute window) is exactly as "not yet corroborated" as a brand-new one: the
|
|
312
|
+
// resumed turn's first commit is made BEFORE any hook of that turn has fired, and `liveAt` is refreshed only by a
|
|
313
|
+
// hook. Measured on the registry 0.4.0 binary: alone, that commit left no spool line anywhere (null); with another
|
|
314
|
+
// session live it was filed under the OTHER session as `env_session_unmatched` and then discarded. Either way the
|
|
315
|
+
// edge was lost for good. So rung 1 failing (X is not live) with X named by the environment now ALWAYS takes the cold
|
|
316
|
+
// rung, keyed to X's own id.
|
|
317
317
|
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
318
|
+
// ⛔ WHAT MAKES THAT SAFE (the old comment here forbade it, for a good reason): the hazard was a shell that exported a
|
|
319
|
+
// session id once and kept committing, with the commits then adopted by a long-dead session and PROMOTED to
|
|
320
|
+
// edge-minting rows. Three things close it, and none of them is this function:
|
|
321
|
+
// 1. this rung writes ONE `.pending.jsonl` line (post_commit.ts picks the sidecar for this rung): no state file, no
|
|
322
|
+
// `liveAt`, no spool line. A non-hook writer cannot make X live (D2; asserted as bytes in
|
|
323
|
+
// `test/stale-own-pending.test.ts`).
|
|
324
|
+
// 2. only X's OWN running hook promotes X's bucket (`promotePending`'s call site is keyed by the executing hook's
|
|
325
|
+
// session, never by the environment): a dead session never runs a hook, and another session's hook cannot touch it.
|
|
326
|
+
// 3. the promotion bound (`pending_bound.ts`): a line observed before the START OF THE TURN X is delivering (its first unsent prompt-like record) is
|
|
327
|
+
// DROPPED, so a lingering-id commit from days ago is not adopted when X is finally resumed.
|
|
328
|
+
// Residual, stated: a stale exported id committing DURING the resumed turn looks exactly like X's own commit.
|
|
329
|
+
// (Worded to avoid `from` followed by a quoted string: `provenance.test.ts` walls RAW TEXT and reads that shape as an
|
|
330
|
+
// escaping import specifier.)
|
|
325
331
|
//
|
|
326
|
-
// ⚠ WHAT THIS COSTS, stated rather than discovered: an environment naming a session this
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
// ages out under `MAX_PENDING_SHAS`.
|
|
332
|
-
const envHasNoStateFile = (() => {
|
|
333
|
-
if (envSessionId === null)
|
|
334
|
-
return false;
|
|
335
|
-
const state = sessionStatePath(home, { repoKey, sessionId: envSessionId });
|
|
336
|
-
return state !== null && !existsSync(state);
|
|
337
|
-
})();
|
|
338
|
-
if (envSessionId !== null && envHasNoStateFile) {
|
|
332
|
+
// ⚠ WHAT THIS COSTS, stated rather than discovered: an environment naming a session this clone has never captured, or
|
|
333
|
+
// captured long ago, opens a `.pending.jsonl` bucket even when others are live (Codex under an outer Claude session is
|
|
334
|
+
// the case `agents/registry.ts:11-13` describes). The line is `pending`, not sendable: it promotes only if THAT
|
|
335
|
+
// session's own hook later corroborates it, and otherwise ages out under `MAX_PENDING_SHAS`.
|
|
336
|
+
if (envSessionId !== null && sessionStatePath(home, { repoKey, sessionId: envSessionId }) !== null) {
|
|
339
337
|
return { sessionId: envSessionId, attribution: "env_session_uncorroborated" };
|
|
340
338
|
}
|
|
341
339
|
if (live.length === 0)
|
|
@@ -373,6 +371,19 @@ function readLiveAt(path) {
|
|
|
373
371
|
return null;
|
|
374
372
|
}
|
|
375
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* `TODOS[176]` — which promotion path a pending line is on, decided by the session's OWN state at the moment its hook runs:
|
|
376
|
+
* `stale-own` = its state file EXISTS and is not live (a RESUMED session: the cold rung put the line here because rung 1 failed),
|
|
377
|
+
* `first-turn` = no state file yet, or one that is live (the original cold case — the first Stop promotes BEFORE it saves state).
|
|
378
|
+
* Reads only; writes nothing (D2).
|
|
379
|
+
*/
|
|
380
|
+
export function pendingModeFor(home, key, nowMs = Date.now(), windowMs = SESSION_LIVE_WINDOW_MS) {
|
|
381
|
+
const state = sessionStatePath(home, key);
|
|
382
|
+
if (state === null || !existsSync(state))
|
|
383
|
+
return "first-turn";
|
|
384
|
+
const at = readLiveAt(state);
|
|
385
|
+
return at !== null && nowMs - at <= windowMs ? "first-turn" : "stale-own";
|
|
386
|
+
}
|
|
376
387
|
/** Every session whose hook last ran inside the window, most recent first. */
|
|
377
388
|
function liveSessions(home, repoKey, nowMs, windowMs) {
|
|
378
389
|
const dir = repoSessionsDir(home, repoKey);
|
|
@@ -764,7 +775,8 @@ function parseEntry(line) {
|
|
|
764
775
|
const at = typeof o.at === "string" ? o.at : "";
|
|
765
776
|
const branch = typeof o.branch === "string" && o.branch !== "" ? o.branch : null;
|
|
766
777
|
const files = Array.isArray(o.files) ? o.files.filter((f) => typeof f === "string") : [];
|
|
767
|
-
|
|
778
|
+
const observedAt = typeof o.observedAt === "number" && Number.isFinite(o.observedAt) && o.observedAt > 0 ? o.observedAt : undefined;
|
|
779
|
+
return { sha, branch, at, files, attribution: parseRung(o.attribution), ...(observedAt === undefined ? {} : { observedAt }) };
|
|
768
780
|
}
|
|
769
781
|
/**
|
|
770
782
|
* ⛔ **AN UNLABELLED LINE IS `recency_heuristic`, AND THAT IS NOT A DEFAULT — IT
|
|
@@ -938,14 +950,26 @@ export function promotePending(home, key,
|
|
|
938
950
|
* a lingering exported `CLAUDE_CODE_SESSION_ID` accumulates pending lines that
|
|
939
951
|
* are never promoted and age out under `MAX_PENDING_SHAS`.
|
|
940
952
|
*/
|
|
941
|
-
corroboration
|
|
953
|
+
corroboration,
|
|
954
|
+
/**
|
|
955
|
+
* ⛔ `TODOS[176]` — THE PROMOTION BOUND AND ITS PATH, BOTH REQUIRED SO A CALLER CANNOT FORGET THEM. `bound` is the start
|
|
956
|
+
* of the turn this hook is delivering (`turnStart`: the first unsent PROMPT-LIKE record's timestamp); `mode` says whether the
|
|
957
|
+
* session's state file already existed (`stale-own`) or not (`first-turn`). A line observed before the turn start is DROPPED;
|
|
958
|
+
* when no turn start is found a stale-own line is dropped and a first-turn line kept. See `pending_bound.ts`.
|
|
959
|
+
*/
|
|
960
|
+
bound, mode) {
|
|
942
961
|
void corroboration;
|
|
943
962
|
const pending = readPending(home, key);
|
|
944
963
|
if (pending.length === 0)
|
|
945
964
|
return 0;
|
|
946
965
|
let promoted = 0;
|
|
947
966
|
for (const entry of pending) {
|
|
948
|
-
if (
|
|
967
|
+
if (!keepsPendingLine(entry, bound, mode))
|
|
968
|
+
continue;
|
|
969
|
+
// The pending-only observation clock stays behind: a promoted line is an ordinary spool line.
|
|
970
|
+
const { observedAt: _observedAt, ...rest } = entry;
|
|
971
|
+
void _observedAt;
|
|
972
|
+
if (appendSpool(home, key, { ...rest, attribution: "env_session_id" }))
|
|
949
973
|
promoted += 1;
|
|
950
974
|
}
|
|
951
975
|
clearPending(home, key);
|