@vincemakes/kiso-runtime 0.1.37 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export * from "./recovery.js";
5
5
  export * from "./compose.js";
6
6
  export * from "./summarize.js";
7
7
  export * from "./store.js";
8
+ export * from "./lock-adapter.js";
8
9
  export * from "./ledger.js";
9
10
  export * from "./extensions.js";
10
11
  export * from "./trust.js";
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export * from "./recovery.js";
5
5
  export * from "./compose.js";
6
6
  export * from "./summarize.js";
7
7
  export * from "./store.js";
8
+ export * from "./lock-adapter.js";
8
9
  export * from "./ledger.js";
9
10
  export * from "./extensions.js";
10
11
  export * from "./trust.js";
@@ -0,0 +1,92 @@
1
+ /**
2
+ * LockAdapter — the cross-process single-writer lock contract (R-G 0.1.47,
3
+ * ADR-0050).
4
+ *
5
+ * The default adapter is the identity-confirmed link lock — a pure Node
6
+ * mechanism. Node's stdlib has no advisory-lock primitive (the round-4
7
+ * python3 flock helper is the dependency this round retires), so possession
8
+ * of `<id>.lock` is decided by atomic filesystem operations, and the file
9
+ * itself carries the holder's identity.
10
+ *
11
+ * The protocol (the decision path is synchronous — no awaits between the
12
+ * read and the act):
13
+ *
14
+ * acquire — read the identity; if it is absent or names a dead pid (fresh
15
+ * path / dead holder / stale residue): rename the path away (atomic —
16
+ * exactly one contender wins), re-read what was moved, and if it differs
17
+ * from what was judged (a rival's LIVE file got moved) abort the takeover
18
+ * and restore-or-keep. Then write MY identity to a temp file, fsync it,
19
+ * and LINK it at the final path (atomic create-if-absent). The link is
20
+ * the only way the final path ever exists — a kill can never leave an
21
+ * empty or half-written lock (see the fsync note below). A live foreign
22
+ * identity refuses immediately; a live identity naming OUR OWN process is
23
+ * a same-process writer's residue (round 5) and is retried until its
24
+ * release or the cap.
25
+ *
26
+ * verify — possession is re-checked at every append: the file must still
27
+ * name this handle's pid AND token. Failure is a STRICT refusal — no
28
+ * retry, no wait heuristic (ADR-0050: the guard must be reason-able; a
29
+ * displaced holder fails honestly and the session resumes from a fresh
30
+ * store — never two writers).
31
+ *
32
+ * release — rename my file away, confirm it is mine, leave the EMPTY
33
+ * released marker (the path is never deleted — a contender must be able
34
+ * to read it), remove the tombstone. A moved rival's file is
35
+ * restored-or-kept, never clobbered.
36
+ *
37
+ * The fsync-before-link order is LOAD-BEARING: the temp file is fully
38
+ * written AND fsynced before the final name is linked, so a power loss can
39
+ * never produce an empty or half-written file at the final path — the
40
+ * inode's data is durable before the name exists. The final path itself
41
+ * needs no directory fsync: if the name is lost in a crash, the holder
42
+ * died with it, and the residue is acquirable (ADR-0050 §crash-durability).
43
+ *
44
+ * The identity FILE format is the cross-version channel (unchanged from
45
+ * round 4): modern `{"pid": number, "token": string}`, legacy bare-pid
46
+ * (string or JSON number), empty (the released marker / a legacy-format
47
+ * writer's create window), half-written (crash residue). A legacy-format
48
+ * writer sees a live modern identity and refuses to take over; we refuse a
49
+ * live foreign legacy pid. Empty and half-written files are taken over as
50
+ * residue — under the documented QUARANTINE upgrade contract (round 5
51
+ * P1-4), no live legacy holder exists to be split (ADR-0050 §migration).
52
+ *
53
+ * Hard-link dependence: linkSync requires a link-capable filesystem
54
+ * (macOS/Linux/Windows NTFS). EPERM/ENOTSUP is an honest
55
+ * LockUnavailableError carrying the errno — never a silent degradation to
56
+ * a weaker scheme (that would re-open the empty-file window, ADR-0050).
57
+ *
58
+ * Test-only affordances (KISO_LOCK_TEST_* env, default off): the race
59
+ * gates (native-lock-race.test.ts) freeze a contender between the read and
60
+ * the rename-away, and between the rename-away and the verify, via fixed
61
+ * pauses plus SIGSTOP/SIGCONT, and locate the freeze points via
62
+ * ready-marker files (ADR-0050 §test affordances).
63
+ */
64
+ /** A live foreign writer owns the lock — never taken over. */
65
+ export declare class LockedError extends Error {
66
+ constructor(message: string);
67
+ }
68
+ /** The mechanism cannot operate (fs/link failure) — never a lock conflict. */
69
+ export declare class LockUnavailableError extends Error {
70
+ constructor(reason: string);
71
+ }
72
+ export interface LockHandle {
73
+ readonly pid: number;
74
+ readonly token: string;
75
+ /** True iff the lock file at the path still names THIS identity. */
76
+ verify(): boolean;
77
+ /** Idempotent release; the path is left as the empty released marker. */
78
+ release(): void;
79
+ }
80
+ export interface LockAdapter {
81
+ readonly name: string;
82
+ /**
83
+ * Take (or take over) the lock at lockPath. Rejects with LockedError
84
+ * when a live foreign writer owns it (or the same-process-residue retry
85
+ * cap is hit), LockUnavailableError when the mechanism cannot operate.
86
+ * cancelled() is invoked at each retry decision and may throw to abort
87
+ * the acquisition (the store's lifecycle barrier).
88
+ */
89
+ acquire(lockPath: string, sessionId: string, cancelled: () => void): Promise<LockHandle>;
90
+ }
91
+ /** The default adapter: the identity-confirmed link lock (ADR-0050). */
92
+ export declare const nativeLockAdapter: LockAdapter;
@@ -0,0 +1,339 @@
1
+ /**
2
+ * LockAdapter — the cross-process single-writer lock contract (R-G 0.1.47,
3
+ * ADR-0050).
4
+ *
5
+ * The default adapter is the identity-confirmed link lock — a pure Node
6
+ * mechanism. Node's stdlib has no advisory-lock primitive (the round-4
7
+ * python3 flock helper is the dependency this round retires), so possession
8
+ * of `<id>.lock` is decided by atomic filesystem operations, and the file
9
+ * itself carries the holder's identity.
10
+ *
11
+ * The protocol (the decision path is synchronous — no awaits between the
12
+ * read and the act):
13
+ *
14
+ * acquire — read the identity; if it is absent or names a dead pid (fresh
15
+ * path / dead holder / stale residue): rename the path away (atomic —
16
+ * exactly one contender wins), re-read what was moved, and if it differs
17
+ * from what was judged (a rival's LIVE file got moved) abort the takeover
18
+ * and restore-or-keep. Then write MY identity to a temp file, fsync it,
19
+ * and LINK it at the final path (atomic create-if-absent). The link is
20
+ * the only way the final path ever exists — a kill can never leave an
21
+ * empty or half-written lock (see the fsync note below). A live foreign
22
+ * identity refuses immediately; a live identity naming OUR OWN process is
23
+ * a same-process writer's residue (round 5) and is retried until its
24
+ * release or the cap.
25
+ *
26
+ * verify — possession is re-checked at every append: the file must still
27
+ * name this handle's pid AND token. Failure is a STRICT refusal — no
28
+ * retry, no wait heuristic (ADR-0050: the guard must be reason-able; a
29
+ * displaced holder fails honestly and the session resumes from a fresh
30
+ * store — never two writers).
31
+ *
32
+ * release — rename my file away, confirm it is mine, leave the EMPTY
33
+ * released marker (the path is never deleted — a contender must be able
34
+ * to read it), remove the tombstone. A moved rival's file is
35
+ * restored-or-kept, never clobbered.
36
+ *
37
+ * The fsync-before-link order is LOAD-BEARING: the temp file is fully
38
+ * written AND fsynced before the final name is linked, so a power loss can
39
+ * never produce an empty or half-written file at the final path — the
40
+ * inode's data is durable before the name exists. The final path itself
41
+ * needs no directory fsync: if the name is lost in a crash, the holder
42
+ * died with it, and the residue is acquirable (ADR-0050 §crash-durability).
43
+ *
44
+ * The identity FILE format is the cross-version channel (unchanged from
45
+ * round 4): modern `{"pid": number, "token": string}`, legacy bare-pid
46
+ * (string or JSON number), empty (the released marker / a legacy-format
47
+ * writer's create window), half-written (crash residue). A legacy-format
48
+ * writer sees a live modern identity and refuses to take over; we refuse a
49
+ * live foreign legacy pid. Empty and half-written files are taken over as
50
+ * residue — under the documented QUARANTINE upgrade contract (round 5
51
+ * P1-4), no live legacy holder exists to be split (ADR-0050 §migration).
52
+ *
53
+ * Hard-link dependence: linkSync requires a link-capable filesystem
54
+ * (macOS/Linux/Windows NTFS). EPERM/ENOTSUP is an honest
55
+ * LockUnavailableError carrying the errno — never a silent degradation to
56
+ * a weaker scheme (that would re-open the empty-file window, ADR-0050).
57
+ *
58
+ * Test-only affordances (KISO_LOCK_TEST_* env, default off): the race
59
+ * gates (native-lock-race.test.ts) freeze a contender between the read and
60
+ * the rename-away, and between the rename-away and the verify, via fixed
61
+ * pauses plus SIGSTOP/SIGCONT, and locate the freeze points via
62
+ * ready-marker files (ADR-0050 §test affordances).
63
+ */
64
+ import { closeSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
65
+ import { randomUUID } from "node:crypto";
66
+ import { join } from "node:path";
67
+ /** A live foreign writer owns the lock — never taken over. */
68
+ export class LockedError extends Error {
69
+ constructor(message) {
70
+ super(message);
71
+ this.name = "LockedError";
72
+ }
73
+ }
74
+ /** The mechanism cannot operate (fs/link failure) — never a lock conflict. */
75
+ export class LockUnavailableError extends Error {
76
+ constructor(reason) {
77
+ super(`session locking unavailable: ${reason}`);
78
+ this.name = "LockUnavailableError";
79
+ }
80
+ }
81
+ function isAlive(pid) {
82
+ try {
83
+ process.kill(pid, 0);
84
+ return true;
85
+ }
86
+ catch (err) {
87
+ return err.code === "EPERM";
88
+ }
89
+ }
90
+ /**
91
+ * Read a lock file's holder identity (round 4 formats, unchanged — the
92
+ * cross-version channel). Empty, unreadable, or half-written locks have no
93
+ * identity: they are residue, taken over (ADR-0050 §migration).
94
+ */
95
+ function readLockIdentity(lockPath) {
96
+ let raw;
97
+ try {
98
+ raw = readFileSync(lockPath, "utf8");
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ const trimmed = raw.trim();
104
+ if (trimmed === "")
105
+ return null;
106
+ let parsed;
107
+ try {
108
+ parsed = JSON.parse(trimmed);
109
+ }
110
+ catch {
111
+ parsed = trimmed; // half-written JSON — try as a bare pid
112
+ }
113
+ if (typeof parsed === "number" && Number.isInteger(parsed)) {
114
+ return { pid: parsed }; // JSON.parse("123") — a legacy bare pid
115
+ }
116
+ if (typeof parsed === "string") {
117
+ const pid = Number.parseInt(parsed, 10);
118
+ return Number.isFinite(pid) ? { pid } : null;
119
+ }
120
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
121
+ const v = parsed;
122
+ return {
123
+ ...(typeof v.pid === "number" ? { pid: v.pid } : {}),
124
+ ...(typeof v.token === "string" ? { token: v.token } : {}),
125
+ };
126
+ }
127
+ return null;
128
+ }
129
+ function sameIdentity(a, b) {
130
+ if (a === null && b === null)
131
+ return true;
132
+ if (a === null || b === null)
133
+ return false;
134
+ return a.pid === b.pid && (a.token ?? null) === (b.token ?? null);
135
+ }
136
+ function unavailable(err) {
137
+ const e = err;
138
+ return new LockUnavailableError(`${e.code ?? "?"}: ${e.message}`);
139
+ }
140
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
141
+ class NativeLockHandle {
142
+ pid;
143
+ token;
144
+ #lockPath;
145
+ #tombstone;
146
+ #released = false;
147
+ constructor(lockPath, tombstone, identity) {
148
+ this.#lockPath = lockPath;
149
+ this.#tombstone = tombstone;
150
+ this.pid = identity.pid;
151
+ this.token = identity.token;
152
+ }
153
+ verify() {
154
+ if (this.#released)
155
+ return false;
156
+ const now = readLockIdentity(this.#lockPath);
157
+ return now !== null && now.pid === this.pid && now.token === this.token;
158
+ }
159
+ release() {
160
+ if (this.#released)
161
+ return;
162
+ this.#released = true;
163
+ try {
164
+ renameSync(this.#lockPath, this.#tombstone);
165
+ }
166
+ catch (err) {
167
+ if (err.code === "ENOENT")
168
+ return; // nothing of ours at the path
169
+ // A fs-level failure: fall back to clearing in place (advisory
170
+ // best effort — a missing marker can only wedge same-process
171
+ // residue, never split a writer).
172
+ try {
173
+ writeFileSync(this.#lockPath, "");
174
+ }
175
+ catch {
176
+ // advisory only
177
+ }
178
+ return;
179
+ }
180
+ const moved = readLockIdentity(this.#tombstone);
181
+ if (moved !== null && moved.pid === this.pid && moved.token === this.token) {
182
+ // Mine. Leave the EMPTY released marker — the path is never
183
+ // deleted (a contender must be able to read it; the
184
+ // storage-identity suite pins it) — created ONLY-if-absent, so
185
+ // a rival that linked in the window keeps its lock untouched.
186
+ try {
187
+ const fd = openSync(this.#lockPath, "wx");
188
+ closeSync(fd);
189
+ }
190
+ catch (err) {
191
+ // EEXIST → a rival's lock stands; anything else → advisory.
192
+ }
193
+ try {
194
+ unlinkSync(this.#tombstone);
195
+ }
196
+ catch {
197
+ // the stray tombstone is inert
198
+ }
199
+ return;
200
+ }
201
+ // A rival's file was moved (the displacement cascade, ADR-0050
202
+ // §residual) — restore-or-keep, never clobber: link it back
203
+ // only-if-absent; on EEXIST the path already carries a newer lock
204
+ // and the moved file stays at its distinct name (inert residue).
205
+ try {
206
+ linkSync(this.#tombstone, this.#lockPath);
207
+ }
208
+ catch {
209
+ // EEXIST or fs failure — inert
210
+ }
211
+ }
212
+ }
213
+ class NativeLock {
214
+ name = "link-lock";
215
+ async acquire(lockPath, sessionId, cancelled) {
216
+ const pid = process.pid;
217
+ const token = randomUUID();
218
+ // The staging name is PER-ATTEMPT: a later rename-away in the same
219
+ // acquire must never atomically overwrite an earlier attempt's
220
+ // abandoned staging (that would clobber the displacement residue,
221
+ // ADR-0050 §residual). tmp/tombstone are unique per acquire and are
222
+ // always cleaned or inert.
223
+ const tmp = `${lockPath}.tmp-${pid}-${token}`;
224
+ const tombstone = `${lockPath}.tomb-${pid}-${token}`;
225
+ // Test-only affordances (ADR-0050 §test affordances): the cascade
226
+ // gate freezes a contender at the two decision points.
227
+ const readyDir = process.env.KISO_LOCK_TEST_READY_DIR;
228
+ const readPause = Number(process.env.KISO_LOCK_TEST_PAUSE_READ_MS ?? 0) || 0;
229
+ const takeoverPause = Number(process.env.KISO_LOCK_TEST_PAUSE_TAKEOVER_MS ?? 0) || 0;
230
+ for (let attempt = 0;; attempt++) {
231
+ cancelled();
232
+ const staging = `${lockPath}.staging-${pid}-${token}-${attempt}`;
233
+ const seen = readLockIdentity(lockPath);
234
+ if (seen === null || seen.pid === undefined || !isAlive(seen.pid)) {
235
+ // Fresh path, dead holder, or stale residue — take it over
236
+ // by identity confirmation: rename-away → verify → link.
237
+ if (readyDir !== undefined)
238
+ writeFileSync(join(readyDir, `read-${pid}`), "");
239
+ if (readPause > 0)
240
+ await sleep(readPause);
241
+ let moved = false;
242
+ try {
243
+ renameSync(lockPath, staging);
244
+ moved = true;
245
+ }
246
+ catch (err) {
247
+ if (err.code !== "ENOENT")
248
+ throw unavailable(err);
249
+ // ENOENT — the path is already absent; proceed to the link.
250
+ }
251
+ if (moved) {
252
+ if (readyDir !== undefined)
253
+ writeFileSync(join(readyDir, `takeover-${pid}`), "");
254
+ if (takeoverPause > 0)
255
+ await sleep(takeoverPause);
256
+ const s = readLockIdentity(staging);
257
+ if (!sameIdentity(s, seen)) {
258
+ // The path was replaced between my read and my rename
259
+ // — a RIVAL'S LIVE file was moved. Abort the takeover:
260
+ // restore-or-keep (link back only-if-absent; on EEXIST
261
+ // the moved file is abandoned — inert residue at its
262
+ // distinct name, ADR-0050 §residual).
263
+ try {
264
+ linkSync(staging, lockPath);
265
+ }
266
+ catch (err) {
267
+ if (err.code !== "EEXIST")
268
+ throw unavailable(err);
269
+ }
270
+ continue;
271
+ }
272
+ }
273
+ // Write my identity fully, fsync it, THEN link — the
274
+ // load-bearing order (ADR-0050 §crash-durability): the final
275
+ // path can never exist empty or half-written, even across a
276
+ // power loss.
277
+ try {
278
+ const fd = openSync(tmp, "w");
279
+ try {
280
+ writeFileSync(fd, JSON.stringify({ pid, token }), "utf8");
281
+ fsyncSync(fd);
282
+ }
283
+ finally {
284
+ closeSync(fd);
285
+ }
286
+ linkSync(tmp, lockPath);
287
+ }
288
+ catch (err) {
289
+ const code = err.code;
290
+ if (code === "EEXIST") {
291
+ // A rival linked first — retry from a fresh read.
292
+ try {
293
+ unlinkSync(tmp);
294
+ }
295
+ catch {
296
+ // the stray is inert
297
+ }
298
+ continue;
299
+ }
300
+ throw unavailable(err);
301
+ }
302
+ try {
303
+ unlinkSync(tmp);
304
+ }
305
+ catch {
306
+ // the stray is inert
307
+ }
308
+ // The takeover succeeded — our identity owns the path, and the
309
+ // verified-dead file we moved away may be cleaned up (its
310
+ // name is distinct; unlinking it can never touch the lock
311
+ // path). Only the EEXIST-abandoned staging of a FAILED
312
+ // takeover lingers — the displacement fingerprint.
313
+ if (moved) {
314
+ try {
315
+ unlinkSync(staging);
316
+ }
317
+ catch {
318
+ // the stray is inert
319
+ }
320
+ }
321
+ return new NativeLockHandle(lockPath, tombstone, { pid, token });
322
+ }
323
+ if (seen.pid === pid && seen.token !== undefined) {
324
+ // Same-process writer's residue (round 5): another store in
325
+ // THIS process holds the lock — it will release; retry until
326
+ // it does (never a spurious self-conflict). A legacy
327
+ // bare-pid lock naming our own process is refused like any
328
+ // live foreign owner.
329
+ if (attempt >= 25)
330
+ throw new LockedError(`session ${sessionId} is locked by another writer`);
331
+ await sleep(20);
332
+ continue;
333
+ }
334
+ throw new LockedError(`session ${sessionId} is locked by another writer (pid ${seen.pid})`);
335
+ }
336
+ }
337
+ }
338
+ /** The default adapter: the identity-confirmed link lock (ADR-0050). */
339
+ export const nativeLockAdapter = new NativeLock();
package/dist/store.d.ts CHANGED
@@ -3,24 +3,32 @@
3
3
  *
4
4
  * One file per session: `<root>/<id>.jsonl`, lines of
5
5
  * `{"runId": string, "ts": number, "event": Event}`. The single-writer
6
- * lock (round 4) is an EXCLUSIVE KERNEL flock on `<id>.lock`, held by a
7
- * dedicated helper process:
6
+ * lock (R-G 0.1.47, ADR-0050) is the native identity-confirmed LINK LOCK
7
+ * on `<id>.lock` — no helper process, no python3. Possession is decided by
8
+ * atomic filesystem operations (see lock-adapter.ts for the full protocol
9
+ * and the residual family):
8
10
  *
9
- * - the kernel arbitrates every race a contender can never remove or
10
- * overwrite a live holder's lock, because there is nothing to remove;
11
- * the lock simply exists while the helper lives and vanishes with it;
12
- * - the lock file ALSO carries `{"pid": number, "token": string}` written
13
- * by the holder, as a best-effort guard for OLD-format writers (whose
14
- * O_EXCL pidfile scheme does not honor flock). round 5(P1-4): this guard
15
- * is NOT a seamless rolling upgrade an old writer that created an
16
- * empty lock file before writing its pid creates a split-brain window
17
- * that a pidfile read cannot close. The documented upgrade contract is
18
- * QUARANTINE: stop every old-format process, THEN start the new
19
- * version. A dead/empty/unreadable legacy lock is otherwise harmless
20
- * flock ignores content, and the kernel lock is what matters;
21
- * - `close()` releases only THIS instance's helper; `closeAll()` every
22
- * held helper — a foreign close can never release another writer's
23
- * kernel lock (flock is tied to the helper's open file description).
11
+ * - the final path exists ONLY by linking a fully-written and fsynced
12
+ * identity file (atomic create-if-absent) a kill can never leave an
13
+ * empty or half-written lock; dead holders are taken over by identity
14
+ * confirmation (rename-away verify link), never by deletion;
15
+ * - the file carries `{"pid": number, "token": string}`; the holder's
16
+ * possession is RE-CHECKED at every append (the file must still name its
17
+ * pid AND token) and a failure is a strict refusal no retry, no wait
18
+ * heuristic: a displaced holder fails honestly and the session resumes
19
+ * from a fresh store, never two writers (ADR-0050 §residual);
20
+ * - the identity format is the cross-version channel (round 4 formats
21
+ * unchanged). A dead/empty/unreadable legacy lock is residue and is
22
+ * taken over the documented upgrade contract is QUARANTINE (round 5
23
+ * P1-4): stop every old-format process, THEN start the new version
24
+ * (ADR-0050 §migration);
25
+ * - the mechanism is an injection point: `new SessionStore(root, {
26
+ * lockAdapter })` — the interface is the extension point, and the
27
+ * default adapter is `nativeLockAdapter` (ADR-0050);
28
+ * - `close()` releases only THIS instance's handle; `closeAll()` every
29
+ * held handle — a foreign close can never release another writer's
30
+ * lock. Release leaves the EMPTY released marker; the path is never
31
+ * deleted.
24
32
  *
25
33
  * Consistency contract (A group):
26
34
  * - every id is validated BEFORE any file side effect (append, close,
@@ -35,6 +43,7 @@
35
43
  * tolerated damage; everything else throws StoreCorruptionError.
36
44
  */
37
45
  import { type Event } from "@vincemakes/kiso-core";
46
+ import { type LockAdapter } from "./lock-adapter.js";
38
47
  /** History that does not parse as a contiguous kiso trajectory. */
39
48
  export declare class StoreCorruptionError extends Error {
40
49
  constructor(message: string);
@@ -60,29 +69,28 @@ export interface SessionMeta {
60
69
  export declare class SessionStore {
61
70
  #private;
62
71
  readonly root: string;
63
- constructor(root: string);
72
+ constructor(root: string, opts?: {
73
+ lockAdapter?: LockAdapter;
74
+ });
64
75
  private pathFor;
65
76
  private lockPathFor;
66
77
  /**
67
- * Take the single-writer lock (round 4): an EXCLUSIVE kernel flock held
68
- * by a dedicated helper process. The KERNEL arbitrates every race —
69
- * there is no stale lock to delete and no takeover to race: a
70
- * contender either gets the flock (the previous holder is gone) or it
71
- * fails. The lock file also carries the holder's identity so an OLD-format
72
- * writer (which does not honor flock) still sees a live owner and
73
- * refuses to take over a best-effort guard, NOT a seamless rolling
74
- * upgrade (round 5 P1-4): the documented upgrade contract is quarantine —
75
- * stop every old-format process, then start the new version.
76
- * No recursion, no deletion, no window between NEW-format writers.
78
+ * Take the single-writer lock (R-G 0.1.47, ADR-0050): the identity-
79
+ * confirmed link lock (see lock-adapter.ts). The adapter decides
80
+ * possession by atomic filesystem operations; a dead holder is taken
81
+ * over by identity confirmation (rename-away verify link), a live
82
+ * foreign writer refuses. No recursion, no deletion, no window between
83
+ * writers. The adapter's `cancelled` callback throws the store's closed
84
+ * message so a close() that landed mid-acquisition aborts it
85
+ * immediately (round 5 P1-3).
77
86
  */
78
87
  private acquireLock;
79
- /** round 5(P1-2): true only while the helper process is alive. */
88
+ /** round 5(P1-2): true only while THIS instance holds its handle. */
80
89
  private lockHeld;
81
90
  /**
82
- * Release OUR lock only: kill OUR helper. The kernel releases the
83
- * flock with the helper's death; the identity file is CLEARED so a
84
- * same-process successor is never mistaken for a live legacy owner —
85
- * the flock is the authority, the file is advisory (round 4).
91
+ * Release OUR lock only: release OUR handle (the adapter leaves the
92
+ * empty released marker at the path never a deletion). A foreign
93
+ * close can never release another writer's lock (ADR-0050).
86
94
  */
87
95
  private releaseLock;
88
96
  /** Write-ahead: durable (written + fsynced) before returning. */
package/dist/store.js CHANGED
@@ -3,24 +3,32 @@
3
3
  *
4
4
  * One file per session: `<root>/<id>.jsonl`, lines of
5
5
  * `{"runId": string, "ts": number, "event": Event}`. The single-writer
6
- * lock (round 4) is an EXCLUSIVE KERNEL flock on `<id>.lock`, held by a
7
- * dedicated helper process:
6
+ * lock (R-G 0.1.47, ADR-0050) is the native identity-confirmed LINK LOCK
7
+ * on `<id>.lock` — no helper process, no python3. Possession is decided by
8
+ * atomic filesystem operations (see lock-adapter.ts for the full protocol
9
+ * and the residual family):
8
10
  *
9
- * - the kernel arbitrates every race a contender can never remove or
10
- * overwrite a live holder's lock, because there is nothing to remove;
11
- * the lock simply exists while the helper lives and vanishes with it;
12
- * - the lock file ALSO carries `{"pid": number, "token": string}` written
13
- * by the holder, as a best-effort guard for OLD-format writers (whose
14
- * O_EXCL pidfile scheme does not honor flock). round 5(P1-4): this guard
15
- * is NOT a seamless rolling upgrade an old writer that created an
16
- * empty lock file before writing its pid creates a split-brain window
17
- * that a pidfile read cannot close. The documented upgrade contract is
18
- * QUARANTINE: stop every old-format process, THEN start the new
19
- * version. A dead/empty/unreadable legacy lock is otherwise harmless
20
- * flock ignores content, and the kernel lock is what matters;
21
- * - `close()` releases only THIS instance's helper; `closeAll()` every
22
- * held helper — a foreign close can never release another writer's
23
- * kernel lock (flock is tied to the helper's open file description).
11
+ * - the final path exists ONLY by linking a fully-written and fsynced
12
+ * identity file (atomic create-if-absent) a kill can never leave an
13
+ * empty or half-written lock; dead holders are taken over by identity
14
+ * confirmation (rename-away verify link), never by deletion;
15
+ * - the file carries `{"pid": number, "token": string}`; the holder's
16
+ * possession is RE-CHECKED at every append (the file must still name its
17
+ * pid AND token) and a failure is a strict refusal no retry, no wait
18
+ * heuristic: a displaced holder fails honestly and the session resumes
19
+ * from a fresh store, never two writers (ADR-0050 §residual);
20
+ * - the identity format is the cross-version channel (round 4 formats
21
+ * unchanged). A dead/empty/unreadable legacy lock is residue and is
22
+ * taken over the documented upgrade contract is QUARANTINE (round 5
23
+ * P1-4): stop every old-format process, THEN start the new version
24
+ * (ADR-0050 §migration);
25
+ * - the mechanism is an injection point: `new SessionStore(root, {
26
+ * lockAdapter })` — the interface is the extension point, and the
27
+ * default adapter is `nativeLockAdapter` (ADR-0050);
28
+ * - `close()` releases only THIS instance's handle; `closeAll()` every
29
+ * held handle — a foreign close can never release another writer's
30
+ * lock. Release leaves the EMPTY released marker; the path is never
31
+ * deleted.
24
32
  *
25
33
  * Consistency contract (A group):
26
34
  * - every id is validated BEFORE any file side effect (append, close,
@@ -34,10 +42,10 @@
34
42
  * - load is strict (A group round 1): a partial final line is the only
35
43
  * tolerated damage; everything else throws StoreCorruptionError.
36
44
  */
37
- import { spawn } from "node:child_process";
38
- import { appendFileSync, closeSync, existsSync, fsyncSync, fstatSync, ftruncateSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, readSync, unlinkSync, writeFileSync, } from "node:fs";
45
+ import { appendFileSync, closeSync, existsSync, fsyncSync, fstatSync, ftruncateSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, } from "node:fs";
39
46
  import { dirname, join } from "node:path";
40
47
  import { isKisoEvent } from "@vincemakes/kiso-core";
48
+ import { LockedError, nativeLockAdapter, } from "./lock-adapter.js";
41
49
  /** History that does not parse as a contiguous kiso trajectory. */
42
50
  export class StoreCorruptionError extends Error {
43
51
  constructor(message) {
@@ -57,10 +65,11 @@ const ID_PATTERN = /^[A-Za-z0-9._-]+$/;
57
65
  export class SessionStore {
58
66
  root;
59
67
  #fds = new Map();
60
- /** sessionId → the lock helper process THIS instance spawned. */
61
- #lockHelpers = new Map();
68
+ /** sessionId → the lock handle THIS instance holds (ADR-0050). */
69
+ #lockHandles = new Map();
70
+ #lockAdapter;
62
71
  /** round 4 (adversarial): serialize concurrent acquireLock calls ON this instance —
63
- * two racing appends must not spawn two helpers and fight each other. */
72
+ * two racing appends must not issue two acquisitions and fight each other. */
64
73
  #lockAcquiring = new Map();
65
74
  /** round 5(P1-1): serialize the WHOLE append critical section per session on
66
75
  * this instance — lock check → CAS → write → fsync. A rejected write
@@ -68,8 +77,9 @@ export class SessionStore {
68
77
  * never land after a stale failure (which would fork memory and disk). */
69
78
  #appendQueues = new Map();
70
79
  #closed = new Set();
71
- constructor(root) {
80
+ constructor(root, opts) {
72
81
  this.root = root;
82
+ this.#lockAdapter = opts?.lockAdapter ?? nativeLockAdapter;
73
83
  mkdirSync(root, { recursive: true });
74
84
  fsyncDir(root);
75
85
  }
@@ -84,21 +94,18 @@ export class SessionStore {
84
94
  return join(this.root, `${sessionId}.lock`);
85
95
  }
86
96
  /**
87
- * Take the single-writer lock (round 4): an EXCLUSIVE kernel flock held
88
- * by a dedicated helper process. The KERNEL arbitrates every race —
89
- * there is no stale lock to delete and no takeover to race: a
90
- * contender either gets the flock (the previous holder is gone) or it
91
- * fails. The lock file also carries the holder's identity so an OLD-format
92
- * writer (which does not honor flock) still sees a live owner and
93
- * refuses to take over a best-effort guard, NOT a seamless rolling
94
- * upgrade (round 5 P1-4): the documented upgrade contract is quarantine —
95
- * stop every old-format process, then start the new version.
96
- * No recursion, no deletion, no window between NEW-format writers.
97
+ * Take the single-writer lock (R-G 0.1.47, ADR-0050): the identity-
98
+ * confirmed link lock (see lock-adapter.ts). The adapter decides
99
+ * possession by atomic filesystem operations; a dead holder is taken
100
+ * over by identity confirmation (rename-away verify link), a live
101
+ * foreign writer refuses. No recursion, no deletion, no window between
102
+ * writers. The adapter's `cancelled` callback throws the store's closed
103
+ * message so a close() that landed mid-acquisition aborts it
104
+ * immediately (round 5 P1-3).
97
105
  */
98
106
  async acquireLock(sessionId) {
99
- // round 5(P1-2): the lock is held only while the helper PROCESS is
100
- // aliveflock is bound to the helper's lifetime. A dead helper's
101
- // entry must never be trusted as "locked".
107
+ // round 5(P1-2): a lock is held only while THIS instance's handle is
108
+ // helda displaced/dead lock must never be trusted as "locked".
102
109
  if (this.lockHeld(sessionId))
103
110
  return;
104
111
  const inFlight = this.#lockAcquiring.get(sessionId);
@@ -108,102 +115,36 @@ export class SessionStore {
108
115
  this.#lockAcquiring.set(sessionId, attempt);
109
116
  return attempt;
110
117
  }
111
- /** round 5(P1-2): true only while the helper process is alive. */
118
+ /** round 5(P1-2): true only while THIS instance holds its handle. */
112
119
  lockHeld(sessionId) {
113
- const child = this.#lockHelpers.get(sessionId);
114
- if (child === undefined || child.pid === undefined || child.pid <= 0)
115
- return false;
116
- return isAlive(child.pid);
120
+ return this.#lockHandles.has(sessionId);
117
121
  }
118
122
  async #acquireLockOnce(sessionId) {
119
123
  const lockPath = this.lockPathFor(sessionId);
120
- for (let attempt = 0;; attempt++) {
121
- const child = spawn("python3", ["-c", LOCK_HELPER_SCRIPT, lockPath], {
122
- stdio: ["pipe", "pipe", "ignore"],
123
- });
124
- const verdict = await helperVerdict(child);
125
- if (verdict === "LOCKED") {
126
- // The kernel flock is ours. One last compatibility gate: an
127
- // OLD-format writer (which does not honor flock) may still
128
- // be alive — its lock file names it. Refuse, and release
129
- // the flock (the helper dies). A MODERN lock (with a token)
130
- // naming OUR OWN process is a same-process writer's residue
131
- // (round 4: the file is advisory; the flock is the authority).
132
- const legacy = readLockIdentity(lockPath);
133
- if (legacy?.pid !== undefined && isAlive(legacy.pid) && (legacy.token === undefined || legacy.pid !== process.pid)) {
134
- child.kill();
135
- throw new Error(`session ${sessionId} is locked by another writer (pid ${legacy.pid})`);
136
- }
137
- // Record our identity in the file: irrelevant to flock, but
138
- // an OLD-format contender reads it and refuses to take over
139
- // a live writer's lock.
140
- try {
141
- writeFileSync(lockPath, JSON.stringify({ pid: process.pid, token: crypto.randomUUID() }));
142
- }
143
- catch {
144
- // the file itself is advisory — the kernel lock holds
145
- }
146
- this.#lockHelpers.set(sessionId, child);
147
- // round 5(P1-2): the helper's death removes the entry — the
148
- // flock dies with the process; a later append re-acquires
149
- // (and fails honestly if a rival holds the flock now).
150
- child.on("exit", () => {
151
- if (this.#lockHelpers.get(sessionId) === child) {
152
- this.#lockHelpers.delete(sessionId);
153
- }
154
- });
155
- return;
156
- }
157
- child.kill();
158
- if (verdict === "SPAWN_FAILED") {
159
- // round 4 (adversarial): the helper could not start (python3 missing) —
160
- // an HONEST error, never a fake lock conflict.
161
- throw new Error(`session locking unavailable: the flock helper (python3) failed to start for ${sessionId}`);
162
- }
163
- // BUSY: either a live modern writer, or a holder that is just
164
- // exiting (its helper is dying). A FOREIGN live writer's identity
165
- // is in the file — refuse at once. A MODERN lock (with a token)
166
- // naming OUR OWN process is a same-process writer — it will
167
- // release its helper; retry until it does (round 4: never a
168
- // spurious self-conflict). A legacy bare-pid lock naming our own
169
- // process is still a live foreign owner and is refused.
170
- const legacy = readLockIdentity(lockPath);
171
- if (legacy?.pid !== undefined && isAlive(legacy.pid) && (legacy.token === undefined || legacy.pid !== process.pid)) {
172
- throw new Error(`session ${sessionId} is locked by another writer (pid ${legacy.pid})`);
173
- }
174
- if (attempt >= 25) {
175
- throw new Error(`session ${sessionId} is locked by another writer`);
176
- }
177
- // round 5(P1-3): a close() that landed while we waited ends the
178
- // acquisition immediately — no 500ms wait, no lock at all.
124
+ const handle = await this.#lockAdapter.acquire(lockPath, sessionId, () => {
179
125
  if (this.#closed.has(sessionId)) {
180
126
  throw new Error(`session store is closed for ${sessionId}`);
181
127
  }
182
- await new Promise((resolve) => setTimeout(resolve, 20));
128
+ });
129
+ // round 5(P1-3): a close() that landed while we waited ends the
130
+ // acquisition immediately — no lock outlives the instance.
131
+ if (this.#closed.has(sessionId)) {
132
+ handle.release();
133
+ throw new Error(`session store is closed for ${sessionId}`);
183
134
  }
135
+ this.#lockHandles.set(sessionId, handle);
184
136
  }
185
137
  /**
186
- * Release OUR lock only: kill OUR helper. The kernel releases the
187
- * flock with the helper's death; the identity file is CLEARED so a
188
- * same-process successor is never mistaken for a live legacy owner —
189
- * the flock is the authority, the file is advisory (round 4).
138
+ * Release OUR lock only: release OUR handle (the adapter leaves the
139
+ * empty released marker at the path never a deletion). A foreign
140
+ * close can never release another writer's lock (ADR-0050).
190
141
  */
191
142
  releaseLock(sessionId) {
192
- const child = this.#lockHelpers.get(sessionId);
193
- if (child === undefined)
143
+ const handle = this.#lockHandles.get(sessionId);
144
+ if (handle === undefined)
194
145
  return;
195
- this.#lockHelpers.delete(sessionId);
196
- // round 4 (adversarial): the identity is cleared BEFORE the helper dies — a
197
- // contender that acquires the flock in the release gap writes its
198
- // own identity AFTER our clear, so it is never wiped by us (the
199
- // file is advisory; the kernel flock is the authority).
200
- try {
201
- writeFileSync(this.lockPathFor(sessionId), "");
202
- }
203
- catch {
204
- // advisory only
205
- }
206
- child.kill();
146
+ this.#lockHandles.delete(sessionId);
147
+ handle.release();
207
148
  }
208
149
  // ── append: lock, open, repair, CAS, write, fsync ────────────────────
209
150
  /** Write-ahead: durable (written + fsynced) before returning. */
@@ -241,6 +182,14 @@ export class SessionStore {
241
182
  this.releaseLock(sessionId);
242
183
  throw new Error(`session store is closed for ${sessionId}`);
243
184
  }
185
+ // R-G 0.1.47 (ADR-0050): possession is RE-CHECKED at every append —
186
+ // the file must still name this handle's pid AND token. A displaced
187
+ // holder's next append SELF-REFUSES honestly (strict refusal, no
188
+ // retry): never a lockless write, never a second writer.
189
+ const handle = this.#lockHandles.get(sessionId);
190
+ if (handle === undefined || !handle.verify()) {
191
+ throw new LockedError(`session ${sessionId} is locked by another writer`);
192
+ }
244
193
  let fd;
245
194
  try {
246
195
  fd = this.fd(sessionId);
@@ -372,7 +321,7 @@ export class SessionStore {
372
321
  }
373
322
  /** Release every held fd and lock, including locks whose JSONL open failed. */
374
323
  closeAll() {
375
- for (const id of new Set([...this.#fds.keys(), ...this.#lockHelpers.keys()])) {
324
+ for (const id of new Set([...this.#fds.keys(), ...this.#lockHandles.keys()])) {
376
325
  this.close(id);
377
326
  }
378
327
  }
@@ -383,122 +332,6 @@ function isRecord(value) {
383
332
  const v = value;
384
333
  return typeof v.runId === "string" && typeof v.ts === "number" && isKisoEvent(v.event);
385
334
  }
386
- /**
387
- * Read a lock file's holder identity (round 4). Formats:
388
- * modern: {"pid": 123, "token": "..."}
389
- * legacy: a bare pid — either the STRING "123" or, because
390
- * JSON.parse("123") yields the NUMBER 123, the number itself.
391
- * Neither may be mistaken for an object without a pid.
392
- * Empty, unreadable, or half-written locks have no identity — the kernel
393
- * flock supersedes them (there is nothing to refuse, and nothing to
394
- * delete).
395
- */
396
- function readLockIdentity(lockPath) {
397
- let raw;
398
- try {
399
- raw = readFileSync(lockPath, "utf8");
400
- }
401
- catch {
402
- return null;
403
- }
404
- const trimmed = raw.trim();
405
- if (trimmed === "")
406
- return null;
407
- let parsed;
408
- try {
409
- parsed = JSON.parse(trimmed);
410
- }
411
- catch {
412
- parsed = trimmed; // half-written JSON — try as a bare pid
413
- }
414
- if (typeof parsed === "number" && Number.isInteger(parsed)) {
415
- return { pid: parsed }; // JSON.parse("123") — a legacy bare pid
416
- }
417
- if (typeof parsed === "string") {
418
- const pid = Number.parseInt(parsed, 10);
419
- return Number.isFinite(pid) ? { pid } : null;
420
- }
421
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
422
- const v = parsed;
423
- return {
424
- ...(typeof v.pid === "number" ? { pid: v.pid } : {}),
425
- ...(typeof v.token === "string" ? { token: v.token } : {}),
426
- };
427
- }
428
- return null;
429
- }
430
- /**
431
- * The lock helper: a python3 process that takes an EXCLUSIVE flock on the
432
- * lock path and HOLDS it until it dies (its stdin is closed / it is
433
- * killed). The kernel releases the flock with the helper — the lock is
434
- * tied to the open file description, so a dead helper can never leave a
435
- * stale lock behind, and no contender can ever remove a live one.
436
- * python3's `fcntl` module provides flock on both macOS and Linux.
437
- */
438
- const LOCK_HELPER_SCRIPT = [
439
- "import fcntl, os, sys",
440
- "fd = os.open(sys.argv[1], os.O_RDWR | os.O_CREAT, 0o644)",
441
- "try:",
442
- " fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)",
443
- "except OSError:",
444
- " print('BUSY', flush=True)",
445
- " sys.exit(0)",
446
- "print('LOCKED', flush=True)",
447
- "try:",
448
- " while sys.stdin.buffer.read(1):",
449
- " pass",
450
- "except Exception:",
451
- " pass",
452
- ].join("\n");
453
- /** The helper's first stdout line: "LOCKED" or anything else = busy/dead. */
454
- function helperVerdict(child) {
455
- return new Promise((resolve) => {
456
- let buf = "";
457
- let settled = false;
458
- const done = (verdict) => {
459
- if (settled)
460
- return;
461
- settled = true;
462
- child.stdout?.removeAllListeners();
463
- // The helper is a LOCK DAEMON: it must never keep the parent's
464
- // event loop alive (a finished store exits cleanly), and when the
465
- // parent DOES exit the pipes close, the helper's read hits EOF,
466
- // the helper exits, and the kernel releases the flock. The child
467
- // process handle, its stdin hold, and its verdict channel are all
468
- // unref'd — the lock outlives nothing the parent does not.
469
- const unref = (s) => s?.unref?.();
470
- unref(child);
471
- unref(child.stdin);
472
- unref(child.stdout);
473
- resolve(verdict);
474
- };
475
- child.stdout?.on("data", (d) => {
476
- buf += d.toString();
477
- const nl = buf.indexOf("\n");
478
- if (nl !== -1)
479
- done(buf.slice(0, nl).trim());
480
- });
481
- child.stdout?.on("end", () => done(buf.trim()));
482
- child.stdout?.on("error", () => done("FAILED"));
483
- // round 5(P2-1): a spawn failure (python3 missing, exec denied) is
484
- // DISTINCT from a busy lock — the caller must not report "locked by
485
- // another writer" for a missing helper. The verdict is SPAWN_FAILED
486
- // and the acquire path checks exactly that string.
487
- child.on("error", (err) => {
488
- void err;
489
- done("SPAWN_FAILED");
490
- });
491
- });
492
- }
493
- function isAlive(pid) {
494
- try {
495
- process.kill(pid, 0);
496
- return true;
497
- }
498
- catch (err) {
499
- return err.code === "EPERM";
500
- }
501
- }
502
335
  /**
503
336
  * If the file does not end with a newline, truncate to the last complete
504
337
  * line (or 0) — the torn-tail repair. Runs on open AND before every append,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-runtime",
3
- "version": "0.1.37",
3
+ "version": "1.0.0",
4
4
  "description": "kiso runtime — durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,11 +21,11 @@
21
21
  "test": "vitest run"
22
22
  },
23
23
  "dependencies": {
24
- "@vincemakes/kiso-core": "0.1.35"
24
+ "@vincemakes/kiso-core": "1.0.0"
25
25
  },
26
26
  "peerDependencies": {
27
- "@vincemakes/kiso-provider-anthropic": "0.1.36",
28
- "@vincemakes/kiso-provider-openai": "0.1.36"
27
+ "@vincemakes/kiso-provider-anthropic": "1.0.0",
28
+ "@vincemakes/kiso-provider-openai": "1.0.0"
29
29
  },
30
30
  "peerDependenciesMeta": {
31
31
  "@vincemakes/kiso-provider-anthropic": {
@@ -36,7 +36,7 @@
36
36
  }
37
37
  },
38
38
  "devDependencies": {
39
- "@vincemakes/kiso-evals": "0.1.36",
39
+ "@vincemakes/kiso-evals": "1.0.0",
40
40
  "@types/node": "^26.1.2",
41
41
  "typescript": "^5.7.2",
42
42
  "vitest": "^3.0.0"