@vincemakes/kiso-runtime 0.1.36 → 0.1.38

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/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.36",
3
+ "version": "0.1.38",
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",