@vincemakes/kiso-runtime 0.1.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/LICENSE +21 -0
- package/README.md +8 -0
- package/dist/agent.d.ts +64 -0
- package/dist/agent.js +119 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/session.d.ts +155 -0
- package/dist/session.js +920 -0
- package/dist/store.d.ts +111 -0
- package/dist/store.js +586 -0
- package/package.json +53 -0
package/dist/store.js
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionStore — append-only JSONL durability, identity-safe (A 组).
|
|
3
|
+
*
|
|
4
|
+
* One file per session: `<root>/<id>.jsonl`, lines of
|
|
5
|
+
* `{"runId": string, "ts": number, "event": Event}`. The single-writer
|
|
6
|
+
* lock (第四轮) is an EXCLUSIVE KERNEL flock on `<id>.lock`, held by a
|
|
7
|
+
* dedicated helper process:
|
|
8
|
+
*
|
|
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). 第五轮(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).
|
|
24
|
+
*
|
|
25
|
+
* Consistency contract (A 组):
|
|
26
|
+
* - every id is validated BEFORE any file side effect (append, close,
|
|
27
|
+
* load, lock paths);
|
|
28
|
+
* - append runs an expected-last-seq CAS against the file's REAL last
|
|
29
|
+
* committed seq: a stale preloaded handle writing a duplicate seq is
|
|
30
|
+
* refused with StaleWriterError — and the run that fed it terminates,
|
|
31
|
+
* so the in-memory EventLog never continues past a rejected write;
|
|
32
|
+
* - the torn tail is repaired before EVERY append, and committed records
|
|
33
|
+
* (newline-terminated) are never truncated;
|
|
34
|
+
* - load is strict (A 组 round 1): a partial final line is the only
|
|
35
|
+
* tolerated damage; everything else throws StoreCorruptionError.
|
|
36
|
+
*/
|
|
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";
|
|
39
|
+
import { dirname, join } from "node:path";
|
|
40
|
+
import { isKisoEvent } from "@vincemakes/kiso-core";
|
|
41
|
+
/** History that does not parse as a contiguous kiso trajectory. */
|
|
42
|
+
export class StoreCorruptionError extends Error {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(`session store corruption: ${message}`);
|
|
45
|
+
this.name = "StoreCorruptionError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** A write that would duplicate or skip a seq — the handle is stale. */
|
|
49
|
+
export class StaleWriterError extends Error {
|
|
50
|
+
constructor(expected, got) {
|
|
51
|
+
super(`stale session handle: expected seq ${expected}, got ${got} — reload the session`);
|
|
52
|
+
this.name = "StaleWriterError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Session ids become file names — keep them host-safe. */
|
|
56
|
+
const ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
57
|
+
export class SessionStore {
|
|
58
|
+
root;
|
|
59
|
+
#fds = new Map();
|
|
60
|
+
/** sessionId → the lock helper process THIS instance spawned. */
|
|
61
|
+
#lockHelpers = new Map();
|
|
62
|
+
/** 第四轮(对抗): serialize concurrent acquireLock calls ON this instance —
|
|
63
|
+
* two racing appends must not spawn two helpers and fight each other. */
|
|
64
|
+
#lockAcquiring = new Map();
|
|
65
|
+
/** 第五轮(P1-1): serialize the WHOLE append critical section per session on
|
|
66
|
+
* this instance — lock check → CAS → write → fsync. A rejected write
|
|
67
|
+
* propagates to every append queued behind it, so a concurrent write can
|
|
68
|
+
* never land after a stale failure (which would fork memory and disk). */
|
|
69
|
+
#appendQueues = new Map();
|
|
70
|
+
#closed = new Set();
|
|
71
|
+
constructor(root) {
|
|
72
|
+
this.root = root;
|
|
73
|
+
mkdirSync(root, { recursive: true });
|
|
74
|
+
fsyncDir(root);
|
|
75
|
+
}
|
|
76
|
+
// ── paths and the cross-process lock ─────────────────────────────────
|
|
77
|
+
pathFor(sessionId) {
|
|
78
|
+
if (!ID_PATTERN.test(sessionId)) {
|
|
79
|
+
throw new Error(`invalid session id: ${sessionId}`);
|
|
80
|
+
}
|
|
81
|
+
return join(this.root, `${sessionId}.jsonl`);
|
|
82
|
+
}
|
|
83
|
+
lockPathFor(sessionId) {
|
|
84
|
+
return join(this.root, `${sessionId}.lock`);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Take the single-writer lock (第四轮): 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 (第五轮 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
|
+
*/
|
|
98
|
+
async acquireLock(sessionId) {
|
|
99
|
+
// 第五轮(P1-2): the lock is held only while the helper PROCESS is
|
|
100
|
+
// alive — flock is bound to the helper's lifetime. A dead helper's
|
|
101
|
+
// entry must never be trusted as "locked".
|
|
102
|
+
if (this.lockHeld(sessionId))
|
|
103
|
+
return;
|
|
104
|
+
const inFlight = this.#lockAcquiring.get(sessionId);
|
|
105
|
+
if (inFlight !== undefined)
|
|
106
|
+
return inFlight;
|
|
107
|
+
const attempt = this.#acquireLockOnce(sessionId).finally(() => this.#lockAcquiring.delete(sessionId));
|
|
108
|
+
this.#lockAcquiring.set(sessionId, attempt);
|
|
109
|
+
return attempt;
|
|
110
|
+
}
|
|
111
|
+
/** 第五轮(P1-2): true only while the helper process is alive. */
|
|
112
|
+
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);
|
|
117
|
+
}
|
|
118
|
+
async #acquireLockOnce(sessionId) {
|
|
119
|
+
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
|
+
// (第四轮: 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
|
+
// 第五轮(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
|
+
// 第四轮(对抗): 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 (第四轮: 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
|
+
// 第五轮(P1-3): a close() that landed while we waited ends the
|
|
178
|
+
// acquisition immediately — no 500ms wait, no lock at all.
|
|
179
|
+
if (this.#closed.has(sessionId)) {
|
|
180
|
+
throw new Error(`session store is closed for ${sessionId}`);
|
|
181
|
+
}
|
|
182
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
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 (第四轮).
|
|
190
|
+
*/
|
|
191
|
+
releaseLock(sessionId) {
|
|
192
|
+
const child = this.#lockHelpers.get(sessionId);
|
|
193
|
+
if (child === undefined)
|
|
194
|
+
return;
|
|
195
|
+
this.#lockHelpers.delete(sessionId);
|
|
196
|
+
// 第四轮(对抗): 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();
|
|
207
|
+
}
|
|
208
|
+
// ── append: lock, open, repair, CAS, write, fsync ────────────────────
|
|
209
|
+
/** Write-ahead: durable (written + fsynced) before returning. */
|
|
210
|
+
async append(sessionId, runId, event) {
|
|
211
|
+
if (this.#closed.has(sessionId)) {
|
|
212
|
+
throw new Error(`session store is closed for ${sessionId}`);
|
|
213
|
+
}
|
|
214
|
+
this.pathFor(sessionId); // id validated before ANY file side effect
|
|
215
|
+
// 第五轮(P1-1): the WHOLE critical section is serialized per session
|
|
216
|
+
// on this instance — and a rejection PROPAGATES to every append
|
|
217
|
+
// queued behind it: a concurrent write can never land after a
|
|
218
|
+
// stale failure that poisoned the session.
|
|
219
|
+
const previous = this.#appendQueues.get(sessionId) ?? Promise.resolve();
|
|
220
|
+
const run = previous.then(() => this.#appendOnce(sessionId, runId, event));
|
|
221
|
+
this.#appendQueues.set(sessionId, run);
|
|
222
|
+
try {
|
|
223
|
+
await run;
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
if (this.#appendQueues.get(sessionId) === run) {
|
|
227
|
+
this.#appendQueues.delete(sessionId);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async #appendOnce(sessionId, runId, event) {
|
|
232
|
+
// 第五轮(P1-3): close() may have returned while we waited — the
|
|
233
|
+
// lifecycle barrier is re-checked after the lock acquisition.
|
|
234
|
+
if (this.#closed.has(sessionId)) {
|
|
235
|
+
throw new Error(`session store is closed for ${sessionId}`);
|
|
236
|
+
}
|
|
237
|
+
await this.acquireLock(sessionId);
|
|
238
|
+
if (this.#closed.has(sessionId)) {
|
|
239
|
+
// The lock was acquired AFTER close() returned — release it and
|
|
240
|
+
// fail; nothing of this instance may outlive close().
|
|
241
|
+
this.releaseLock(sessionId);
|
|
242
|
+
throw new Error(`session store is closed for ${sessionId}`);
|
|
243
|
+
}
|
|
244
|
+
let fd;
|
|
245
|
+
try {
|
|
246
|
+
fd = this.fd(sessionId);
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
// The lock was acquired but the JSONL could not be opened:
|
|
250
|
+
// release the lock — it must not leak (A 组).
|
|
251
|
+
this.releaseLock(sessionId);
|
|
252
|
+
throw err;
|
|
253
|
+
}
|
|
254
|
+
repairTornTail(fd);
|
|
255
|
+
// Expected-last-seq CAS against the file's REAL last committed seq
|
|
256
|
+
// (A 组): a stale preloaded handle cannot write a duplicate seq.
|
|
257
|
+
const last = lastCommittedSeq(fd);
|
|
258
|
+
const expected = (last ?? -1) + 1;
|
|
259
|
+
if (event.seq !== expected) {
|
|
260
|
+
throw new StaleWriterError(expected, event.seq);
|
|
261
|
+
}
|
|
262
|
+
appendFileSync(fd, `${JSON.stringify({ runId, ts: Date.now(), event })}\n`);
|
|
263
|
+
fsyncSync(fd);
|
|
264
|
+
// 第五轮(P1-3): a close() that landed during the write must not
|
|
265
|
+
// leave our helper behind.
|
|
266
|
+
if (this.#closed.has(sessionId)) {
|
|
267
|
+
this.releaseLock(sessionId);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Open (creating if needed) the session file. The torn tail is repaired
|
|
272
|
+
* here AND before every append — an in-process append failure cannot
|
|
273
|
+
* poison the next one. The parent directory is fsynced so the file's
|
|
274
|
+
* existence survives a crash.
|
|
275
|
+
*/
|
|
276
|
+
fd(sessionId) {
|
|
277
|
+
const existing = this.#fds.get(sessionId);
|
|
278
|
+
if (existing !== undefined)
|
|
279
|
+
return existing;
|
|
280
|
+
const path = this.pathFor(sessionId);
|
|
281
|
+
const fd = openSync(path, "a+");
|
|
282
|
+
repairTornTail(fd);
|
|
283
|
+
fsyncDir(dirname(path));
|
|
284
|
+
this.#fds.set(sessionId, fd);
|
|
285
|
+
return fd;
|
|
286
|
+
}
|
|
287
|
+
// ── load: strict replay ──────────────────────────────────────────────
|
|
288
|
+
/**
|
|
289
|
+
* Replay a session's log. The ONLY tolerated damage is a partial final
|
|
290
|
+
* line (a crash mid-write): it is dropped and the contiguous prefix is
|
|
291
|
+
* returned. Anything else — mid-file garbage, valid JSON that is not a
|
|
292
|
+
* kiso record, a seq that is not 0..N — throws StoreCorruptionError.
|
|
293
|
+
*/
|
|
294
|
+
load(sessionId) {
|
|
295
|
+
const path = this.pathFor(sessionId);
|
|
296
|
+
if (!existsSync(path))
|
|
297
|
+
return [];
|
|
298
|
+
const raw = readFileSync(path, "utf8");
|
|
299
|
+
const lines = raw.split("\n");
|
|
300
|
+
const nonEmpty = [];
|
|
301
|
+
for (let i = 0; i < lines.length; i++) {
|
|
302
|
+
if (lines[i] !== "")
|
|
303
|
+
nonEmpty.push(i);
|
|
304
|
+
}
|
|
305
|
+
// 二: a line WITHOUT a trailing newline is NOT committed — whether
|
|
306
|
+
// or not it happens to parse. load and append must agree: append's
|
|
307
|
+
// torn-tail repair truncates exactly what load refuses to return.
|
|
308
|
+
const tolerantTail = !raw.endsWith("\n");
|
|
309
|
+
const records = [];
|
|
310
|
+
for (let k = 0; k < nonEmpty.length; k++) {
|
|
311
|
+
const line = lines[nonEmpty[k]];
|
|
312
|
+
const isLast = k === nonEmpty.length - 1;
|
|
313
|
+
if (isLast && tolerantTail)
|
|
314
|
+
break; // uncommitted — drop it
|
|
315
|
+
let parsed;
|
|
316
|
+
try {
|
|
317
|
+
parsed = JSON.parse(line);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
throw new StoreCorruptionError(`line ${nonEmpty[k] + 1} is not JSON`);
|
|
321
|
+
}
|
|
322
|
+
if (!isRecord(parsed)) {
|
|
323
|
+
throw new StoreCorruptionError(`line ${nonEmpty[k] + 1} is not a session record`);
|
|
324
|
+
}
|
|
325
|
+
records.push(parsed);
|
|
326
|
+
}
|
|
327
|
+
for (let i = 0; i < records.length; i++) {
|
|
328
|
+
const seq = records[i].event.seq;
|
|
329
|
+
if (seq !== i) {
|
|
330
|
+
throw new StoreCorruptionError(`seq discontinuity: expected ${i}, got ${seq}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return records;
|
|
334
|
+
}
|
|
335
|
+
has(sessionId) {
|
|
336
|
+
return existsSync(this.pathFor(sessionId));
|
|
337
|
+
}
|
|
338
|
+
list() {
|
|
339
|
+
const metas = [];
|
|
340
|
+
for (const entry of readdirSync(this.root)) {
|
|
341
|
+
if (!entry.endsWith(".jsonl"))
|
|
342
|
+
continue;
|
|
343
|
+
const id = entry.slice(0, -".jsonl".length);
|
|
344
|
+
const records = this.load(id);
|
|
345
|
+
if (records.length === 0)
|
|
346
|
+
continue;
|
|
347
|
+
const first = records[0].event;
|
|
348
|
+
metas.push({
|
|
349
|
+
id,
|
|
350
|
+
title: first.type === "user_input" && typeof first.content === "string"
|
|
351
|
+
? first.content.slice(0, 60)
|
|
352
|
+
: "(no prompt)",
|
|
353
|
+
events: records.length,
|
|
354
|
+
runs: new Set(records.map((r) => r.runId)).size,
|
|
355
|
+
createdAt: records[0]?.ts ?? 0,
|
|
356
|
+
updatedAt: records.at(-1)?.ts ?? 0,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return metas.sort((a, b) => a.id.localeCompare(b.id));
|
|
360
|
+
}
|
|
361
|
+
// ── lifecycle ────────────────────────────────────────────────────────
|
|
362
|
+
/** Release a session's fd and OUR writer lock. Idempotent. */
|
|
363
|
+
close(sessionId) {
|
|
364
|
+
this.pathFor(sessionId); // id validated before ANY file side effect
|
|
365
|
+
const fd = this.#fds.get(sessionId);
|
|
366
|
+
if (fd !== undefined) {
|
|
367
|
+
closeSync(fd);
|
|
368
|
+
this.#fds.delete(sessionId);
|
|
369
|
+
}
|
|
370
|
+
this.releaseLock(sessionId);
|
|
371
|
+
this.#closed.add(sessionId);
|
|
372
|
+
}
|
|
373
|
+
/** Release every held fd and lock, including locks whose JSONL open failed. */
|
|
374
|
+
closeAll() {
|
|
375
|
+
for (const id of new Set([...this.#fds.keys(), ...this.#lockHelpers.keys()])) {
|
|
376
|
+
this.close(id);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function isRecord(value) {
|
|
381
|
+
if (typeof value !== "object" || value === null)
|
|
382
|
+
return false;
|
|
383
|
+
const v = value;
|
|
384
|
+
return typeof v.runId === "string" && typeof v.ts === "number" && isKisoEvent(v.event);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Read a lock file's holder identity (第四轮). 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
|
+
// 第五轮(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
|
+
/**
|
|
503
|
+
* If the file does not end with a newline, truncate to the last complete
|
|
504
|
+
* line (or 0) — the torn-tail repair. Runs on open AND before every append,
|
|
505
|
+
* so an in-process append failure cannot poison the next one.
|
|
506
|
+
*/
|
|
507
|
+
function repairTornTail(fd) {
|
|
508
|
+
const size = fstatSync(fd).size;
|
|
509
|
+
if (size === 0)
|
|
510
|
+
return;
|
|
511
|
+
const last = Buffer.alloc(1);
|
|
512
|
+
readSync(fd, last, 0, 1, size - 1);
|
|
513
|
+
if (last[0] === 0x0a)
|
|
514
|
+
return; // ends cleanly
|
|
515
|
+
const lastNewline = lastNewlineOffset(fd, size);
|
|
516
|
+
ftruncateSync(fd, lastNewline + 1);
|
|
517
|
+
fsyncSync(fd);
|
|
518
|
+
}
|
|
519
|
+
/** Offset of the last '\n' in the file, or -1 when none exists. */
|
|
520
|
+
function lastNewlineOffset(fd, size) {
|
|
521
|
+
const chunk = 64 * 1024;
|
|
522
|
+
let offset = size;
|
|
523
|
+
while (offset > 0) {
|
|
524
|
+
const readLen = Math.min(chunk, offset);
|
|
525
|
+
const buf = Buffer.alloc(readLen);
|
|
526
|
+
offset -= readLen;
|
|
527
|
+
readSync(fd, buf, 0, readLen, offset);
|
|
528
|
+
const idx = buf.lastIndexOf(0x0a);
|
|
529
|
+
if (idx !== -1)
|
|
530
|
+
return offset + idx;
|
|
531
|
+
}
|
|
532
|
+
return -1;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* The seq of the file's last COMMITTED (newline-terminated) record —
|
|
536
|
+
* the CAS anchor. Grows the tail read until the last record parses.
|
|
537
|
+
*/
|
|
538
|
+
function lastCommittedSeq(fd) {
|
|
539
|
+
const size = fstatSync(fd).size;
|
|
540
|
+
if (size === 0)
|
|
541
|
+
return undefined;
|
|
542
|
+
let chunk = 4096;
|
|
543
|
+
while (true) {
|
|
544
|
+
const readLen = Math.min(chunk, size);
|
|
545
|
+
const buf = Buffer.alloc(readLen);
|
|
546
|
+
readSync(fd, buf, 0, readLen, size - readLen);
|
|
547
|
+
const lines = buf
|
|
548
|
+
.toString("utf8")
|
|
549
|
+
.split("\n")
|
|
550
|
+
.filter((l) => l.trim() !== "");
|
|
551
|
+
if (lines.length > 0) {
|
|
552
|
+
try {
|
|
553
|
+
const parsed = JSON.parse(lines[lines.length - 1]);
|
|
554
|
+
if (typeof parsed?.event?.seq !== "number") {
|
|
555
|
+
throw new Error("not a record");
|
|
556
|
+
}
|
|
557
|
+
return parsed.event.seq;
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
if (readLen >= size) {
|
|
561
|
+
throw new StoreCorruptionError("cannot determine the last committed seq — the tail is not a record");
|
|
562
|
+
}
|
|
563
|
+
chunk *= 2;
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (readLen >= size)
|
|
568
|
+
return undefined;
|
|
569
|
+
chunk *= 2;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/** Durability of directory entries: fsync the directory itself. */
|
|
573
|
+
function fsyncDir(dir) {
|
|
574
|
+
let fd;
|
|
575
|
+
try {
|
|
576
|
+
fd = openSync(dir, "r");
|
|
577
|
+
fsyncSync(fd);
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
// Some platforms refuse dir fsync; the file-level fsync still holds.
|
|
581
|
+
}
|
|
582
|
+
finally {
|
|
583
|
+
if (fd !== undefined)
|
|
584
|
+
closeSync(fd);
|
|
585
|
+
}
|
|
586
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vincemakes/kiso-runtime",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "kiso runtime \u2014 durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.build.json",
|
|
20
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
21
|
+
"test": "vitest run"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@vincemakes/kiso-core": "0.1.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@vincemakes/kiso-provider-anthropic": "0.1.0",
|
|
28
|
+
"@vincemakes/kiso-provider-openai": "0.1.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependenciesMeta": {
|
|
31
|
+
"@vincemakes/kiso-provider-anthropic": {
|
|
32
|
+
"optional": true
|
|
33
|
+
},
|
|
34
|
+
"@vincemakes/kiso-provider-openai": {
|
|
35
|
+
"optional": true
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@vincemakes/kiso-evals": "0.1.0",
|
|
40
|
+
"@types/node": "^26.1.2",
|
|
41
|
+
"typescript": "^5.7.2",
|
|
42
|
+
"vitest": "^3.0.0"
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "https://github.com/vincemakes/kiso.git",
|
|
47
|
+
"directory": "packages/runtime"
|
|
48
|
+
},
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/vincemakes/kiso/issues"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://github.com/vincemakes/kiso/tree/main/packages/runtime#readme"
|
|
53
|
+
}
|