@vincemakes/kiso-runtime 0.1.37 → 0.2.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/agent.d.ts +1 -0
- package/dist/agent.js +2 -0
- package/dist/index.d.ts +32 -10
- package/dist/index.js +41 -10
- package/dist/internal.d.ts +25 -0
- package/dist/internal.js +25 -0
- package/dist/lock-adapter.d.ts +92 -0
- package/dist/lock-adapter.js +384 -0
- package/dist/run.js +21 -1
- package/dist/session.d.ts +9 -0
- package/dist/session.js +8 -0
- package/dist/store.d.ts +41 -33
- package/dist/store.js +69 -236
- package/dist/trace/analyze.d.ts +35 -0
- package/dist/trace/analyze.js +51 -0
- package/dist/trace/guard.d.ts +46 -0
- package/dist/trace/guard.js +208 -0
- package/dist/trace/hash.d.ts +22 -0
- package/dist/trace/hash.js +34 -0
- package/dist/trace/manifest.d.ts +36 -0
- package/dist/trace/manifest.js +100 -0
- package/dist/trace/record.d.ts +129 -0
- package/dist/trace/record.js +237 -0
- package/dist/trace/writer.d.ts +43 -0
- package/dist/trace/writer.js +154 -0
- package/dist/usage/canonical.d.ts +123 -0
- package/dist/usage/canonical.js +152 -0
- package/package.json +9 -5
package/dist/run.js
CHANGED
|
@@ -8,6 +8,8 @@ import { ABORTED, MergedSignal, abortable, openRunId } from "./recovery.js";
|
|
|
8
8
|
import { deriveRecoveryPlan, invocationSeqOf } from "./recovery-plan.js";
|
|
9
9
|
import { composeApprovalChain, composeSystemPrompt, composeToolTable, microcompactFor } from "./compose.js";
|
|
10
10
|
import { truncationGuard } from "./truncation-guard.js";
|
|
11
|
+
import { RequestTracer, traceGuard } from "./trace/guard.js";
|
|
12
|
+
import { runtimeVersion } from "./trace/writer.js";
|
|
11
13
|
import { ResumeBlockedError } from "./session.js";
|
|
12
14
|
/**
|
|
13
15
|
* A single turn. Async-iterable, so `for await (const ev of session.run(x))`
|
|
@@ -47,6 +49,7 @@ export class Run {
|
|
|
47
49
|
// The WHOLE body is one try/finally: a consumer that abandons the
|
|
48
50
|
// run at ANY yield (even the user_input one) must release the
|
|
49
51
|
// session's single-run slot and its approval resolvers.
|
|
52
|
+
let tracer = null;
|
|
50
53
|
try {
|
|
51
54
|
// round 4: health is re-checked when the iterator ACTUALLY starts —
|
|
52
55
|
// a run constructed before the session was poisoned must fail
|
|
@@ -54,6 +57,20 @@ export class Run {
|
|
|
54
57
|
this.#session.ensureHealthy();
|
|
55
58
|
this.#session.beginRun(this);
|
|
56
59
|
const log = this.#session.log;
|
|
60
|
+
// E1 (1.2.0): the request tracer — the observation ledger. It
|
|
61
|
+
// sits at the adapter boundary; the model-visible byte stream is
|
|
62
|
+
// untouched (I6, trace-bytes.test.ts). Soft-fail: a degraded
|
|
63
|
+
// writer costs one stderr line and the run goes on.
|
|
64
|
+
tracer = new RequestTracer({
|
|
65
|
+
root: this.#store.root,
|
|
66
|
+
sessionId: this.#session.id,
|
|
67
|
+
runId: this.runId,
|
|
68
|
+
provider: this.#config.provider ?? "adapter",
|
|
69
|
+
model: this.#config.model,
|
|
70
|
+
adapterVersion: runtimeVersion(),
|
|
71
|
+
log: log.all,
|
|
72
|
+
});
|
|
73
|
+
tracer.init();
|
|
57
74
|
const signal = this.#externalSignal ? new MergedSignal(this.#abort.signal, this.#externalSignal) : this.#abort.signal;
|
|
58
75
|
// E2: the session's own microcompact wins; otherwise the FIRST
|
|
59
76
|
// extension providing a compaction config supplies it.
|
|
@@ -75,7 +92,7 @@ export class Run {
|
|
|
75
92
|
const loopConfig = () => ({
|
|
76
93
|
// 0.1.40 (R-C item 3): the truncation guard gates the model
|
|
77
94
|
// stream — a truncated turn's tool batch never executes.
|
|
78
|
-
adapter: truncationGuard(this.#adapter),
|
|
95
|
+
adapter: traceGuard(tracer, truncationGuard(this.#adapter)), // tracer assigned above, before loopConfig
|
|
79
96
|
model: this.#config.model,
|
|
80
97
|
sessionId: this.#session.id, // P3: tools see their session (ToolContext.sessionId)
|
|
81
98
|
...(systemPrompt !== undefined ? { systemPrompt } : {}),
|
|
@@ -230,6 +247,9 @@ export class Run {
|
|
|
230
247
|
for (const executionId of this.#uncertaintyIds) {
|
|
231
248
|
this.#session.dropUncertaintyResolver(executionId);
|
|
232
249
|
}
|
|
250
|
+
// E1: the run's ledger story — the run_end lands synchronously
|
|
251
|
+
// (a killed run leaves no run_end, and the next init marks it).
|
|
252
|
+
tracer?.finishRun();
|
|
233
253
|
this.#session.endRun(this);
|
|
234
254
|
}
|
|
235
255
|
}
|
package/dist/session.d.ts
CHANGED
|
@@ -71,6 +71,7 @@ export interface CompactInfo {
|
|
|
71
71
|
/** The covered content's estimated tokens (the chars/4 proxy). */
|
|
72
72
|
readonly tokens: number;
|
|
73
73
|
}
|
|
74
|
+
/** @deprecated the canonical name is `Session` (root export, 1.1.0); this alias is removed in the next major. */
|
|
74
75
|
export declare class AgentSession {
|
|
75
76
|
#private;
|
|
76
77
|
readonly id: string;
|
|
@@ -97,6 +98,11 @@ export declare class AgentSession {
|
|
|
97
98
|
* turns (dispatch's /model), never mid-run.
|
|
98
99
|
*/
|
|
99
100
|
setAdapter(adapter: Adapter): void;
|
|
101
|
+
/** E2: the adapter identity ("anthropic" | "openai-compat") — the route
|
|
102
|
+
* key the canonical consumer (CLI usage, the trace block) keys on. The
|
|
103
|
+
* per-run tracer reads the SAME #config.provider; one source, one
|
|
104
|
+
* route — the CLI and the trace can never disagree. */
|
|
105
|
+
get provider(): "anthropic" | "openai-compat" | undefined;
|
|
100
106
|
/** Run one user turn. Iterate to consume; `run.abort()` cancels. */
|
|
101
107
|
run(input: string, options?: {
|
|
102
108
|
signal?: AbortSignalLike;
|
|
@@ -178,6 +184,9 @@ export declare class AgentSession {
|
|
|
178
184
|
}
|
|
179
185
|
export interface SessionConfig {
|
|
180
186
|
readonly model: string;
|
|
187
|
+
/** E1: the adapter identity ("anthropic" | "openai-compat") — trace
|
|
188
|
+
* provenance, additive (S1 surface untouched: type-only, optional). */
|
|
189
|
+
readonly provider?: "anthropic" | "openai-compat";
|
|
181
190
|
readonly systemPrompt?: string;
|
|
182
191
|
readonly tools?: readonly Tool<any>[];
|
|
183
192
|
readonly registry: import("@vincemakes/kiso-core").ToolRegistry;
|
package/dist/session.js
CHANGED
|
@@ -54,6 +54,7 @@ export class ResumeBlockedError extends Error {
|
|
|
54
54
|
this.uncertain = uncertain;
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
+
/** @deprecated the canonical name is `Session` (root export, 1.1.0); this alias is removed in the next major. */
|
|
57
58
|
export class AgentSession {
|
|
58
59
|
id;
|
|
59
60
|
log;
|
|
@@ -154,6 +155,13 @@ export class AgentSession {
|
|
|
154
155
|
setAdapter(adapter) {
|
|
155
156
|
this.#adapter = adapter;
|
|
156
157
|
}
|
|
158
|
+
/** E2: the adapter identity ("anthropic" | "openai-compat") — the route
|
|
159
|
+
* key the canonical consumer (CLI usage, the trace block) keys on. The
|
|
160
|
+
* per-run tracer reads the SAME #config.provider; one source, one
|
|
161
|
+
* route — the CLI and the trace can never disagree. */
|
|
162
|
+
get provider() {
|
|
163
|
+
return this.#config.provider;
|
|
164
|
+
}
|
|
157
165
|
/** Run one user turn. Iterate to consume; `run.abort()` cancels. */
|
|
158
166
|
run(input, options) {
|
|
159
167
|
this.ensureHealthy();
|
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 (
|
|
7
|
-
*
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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 (
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
|
88
|
+
/** round 5(P1-2): true only while THIS instance holds its handle. */
|
|
80
89
|
private lockHeld;
|
|
81
90
|
/**
|
|
82
|
-
* Release OUR lock only:
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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 (
|
|
7
|
-
*
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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 {
|
|
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
|
|
61
|
-
#
|
|
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
|
|
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 (
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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):
|
|
100
|
-
//
|
|
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
|
+
// held — a 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
|
|
118
|
+
/** round 5(P1-2): true only while THIS instance holds its handle. */
|
|
112
119
|
lockHeld(sessionId) {
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
187
|
-
*
|
|
188
|
-
*
|
|
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
|
|
193
|
-
if (
|
|
143
|
+
const handle = this.#lockHandles.get(sessionId);
|
|
144
|
+
if (handle === undefined)
|
|
194
145
|
return;
|
|
195
|
-
this.#
|
|
196
|
-
|
|
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.#
|
|
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,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E1 (1.2.0) — slice 4, the cache-break derivation (proposal §4, ruling
|
|
3
|
+
* R4b: per-segment hashes + prefix fingerprint; the BREAK COUNT is an
|
|
4
|
+
* analysis-side derivation, never a recorded field).
|
|
5
|
+
*
|
|
6
|
+
* The cacheable prefix is every segment that is NOT the current turn —
|
|
7
|
+
* freshness "fresh" is the boundary (manifest.ts). `cacheableHashes`
|
|
8
|
+
* pairs the manifest segments 1:1 with the per-segment hashes and
|
|
9
|
+
* drops the fresh tail, so the fingerprint and the break derivation
|
|
10
|
+
* share one boundary by construction: a current-turn change alone
|
|
11
|
+
* never moves the fingerprint and never counts a break.
|
|
12
|
+
*
|
|
13
|
+
* `prefixBreak` compares two adjacent requests' cacheable prefixes:
|
|
14
|
+
* the first differing segment is the break, at its (0-based) depth;
|
|
15
|
+
* a prefix that merely GREW (a new turn joined) breaks at the old
|
|
16
|
+
* length. Unchanged prefixes → null (0 breaks). This is what
|
|
17
|
+
* bench/trace-report.mjs and the bench render per-request (slice 5).
|
|
18
|
+
*/
|
|
19
|
+
import type { TraceSegment } from "./record.js";
|
|
20
|
+
/** The cacheable-prefix hashes: segments[i] ↔ hashes[i], dropping every
|
|
21
|
+
* freshness "fresh" segment (the current turn). */
|
|
22
|
+
export declare function cacheableHashes(segments: readonly TraceSegment[], hashes: readonly string[]): string[];
|
|
23
|
+
export interface PrefixBreak {
|
|
24
|
+
/** 0-based segment index within the cacheable prefix where the
|
|
25
|
+
* prefix first diverges (depth 0 = the system prompt). */
|
|
26
|
+
readonly depth: number;
|
|
27
|
+
}
|
|
28
|
+
/** R4b: compare two adjacent requests' cacheable prefixes. null = the
|
|
29
|
+
* prefix is unchanged (0 breaks). A prefix that grew breaks at the old
|
|
30
|
+
* length — the new segment is where caching can no longer attach. */
|
|
31
|
+
export declare function prefixBreak(prev: readonly string[], next: readonly string[]): PrefixBreak | null;
|
|
32
|
+
/** Per-request breaks across a run's request sequence: request k's
|
|
33
|
+
* break is relative to request k−1; the first request has no
|
|
34
|
+
* predecessor (null). */
|
|
35
|
+
export declare function deriveBreaks(requests: readonly (readonly string[])[]): (PrefixBreak | null)[];
|