@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/agent.d.ts
CHANGED
|
@@ -62,6 +62,7 @@ export interface AgentDefinition {
|
|
|
62
62
|
* loop's policy chain. */
|
|
63
63
|
readonly extensions?: readonly KisoExtension[];
|
|
64
64
|
}
|
|
65
|
+
/** @deprecated the canonical name is `Agent` (root export, 1.1.0); this alias is removed in the next major. */
|
|
65
66
|
export declare class AgentRuntime {
|
|
66
67
|
#private;
|
|
67
68
|
constructor(definition: AgentDefinition);
|
package/dist/agent.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { EventLog, ToolRegistry } from "@vincemakes/kiso-core";
|
|
15
15
|
import { AgentSession } from "./session.js";
|
|
16
|
+
/** @deprecated the canonical name is `Agent` (root export, 1.1.0); this alias is removed in the next major. */
|
|
16
17
|
export class AgentRuntime {
|
|
17
18
|
#definition;
|
|
18
19
|
#registry;
|
|
@@ -54,6 +55,7 @@ export class AgentRuntime {
|
|
|
54
55
|
const adapter = await this.#adapterPromise;
|
|
55
56
|
const config = {
|
|
56
57
|
model: this.#definition.model,
|
|
58
|
+
...(this.#definition.provider !== undefined ? { provider: this.#definition.provider } : {}),
|
|
57
59
|
...(this.#definition.systemPrompt !== undefined ? { systemPrompt: this.#definition.systemPrompt } : {}),
|
|
58
60
|
registry: this.#registry,
|
|
59
61
|
...(this.#definition.permissionPolicy !== undefined || this.#definition.hooks !== undefined
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The SDK surface — the curated root manifest (S1, 2026-08-12).
|
|
3
|
+
*
|
|
4
|
+
* Every name here is a review-approved contract: adding or removing one
|
|
5
|
+
* WITHOUT the ritual (the review's sign-off + a public-surface.json
|
|
6
|
+
* refresh) turns the public-surface gate red. See docs/sdk.md for the
|
|
7
|
+
* contract, the aliases, and what deliberately lives behind the
|
|
8
|
+
* first-party door.
|
|
9
|
+
*
|
|
10
|
+
* What is NOT here is not the SDK. The recovery / compose / summarize /
|
|
11
|
+
* lock-adapter machinery and buildAdapter live behind "./internal"
|
|
12
|
+
* (unstable, first-party only — see internal.ts).
|
|
13
|
+
*
|
|
14
|
+
* Canonical names: Agent = AgentRuntime, Session = AgentSession. The old
|
|
15
|
+
* names are deprecated aliases, removed in the next major (additive,
|
|
16
|
+
* ADR-0051 Amendment 1 — release rounds move the cli minor).
|
|
17
|
+
*/
|
|
18
|
+
export { AgentRuntime, AgentRuntime as Agent, createAgent } from "./agent.js";
|
|
19
|
+
export type { AgentDefinition, PermissionPolicy, PermissionRule } from "./agent.js";
|
|
20
|
+
export { AgentSession, AgentSession as Session } from "./session.js";
|
|
21
|
+
export { PoisonedSessionError, ResumeBlockedError } from "./session.js";
|
|
22
|
+
export type { ApprovalRequest, CompactInfo, SessionConfig, SummarizeResult } from "./session.js";
|
|
23
|
+
export { Run } from "./run.js";
|
|
24
|
+
export { SessionStore, StaleWriterError, StoreCorruptionError } from "./store.js";
|
|
25
|
+
export type { Event, SessionMeta, StoreRecord } from "./store.js";
|
|
26
|
+
export { disposeExtensions, loadExtensions, loadProjectExtensions } from "./extensions.js";
|
|
27
|
+
export type { KisoExtension } from "./extensions.js";
|
|
28
|
+
export { executionForCallId, executionLedger } from "./ledger.js";
|
|
29
|
+
export type { ExecutionRecord, ExecutionStatus } from "./ledger.js";
|
|
30
|
+
export { kisoHome, projectArtifacts, recordTrust, trustFor } from "./trust.js";
|
|
31
|
+
export type { ProjectArtifact, ProjectArtifacts, TrustDecision, TrustRecord } from "./trust.js";
|
|
32
|
+
export { canonicalizeUsage } from "./usage/canonical.js";
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,41 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The SDK surface — the curated root manifest (S1, 2026-08-12).
|
|
3
|
+
*
|
|
4
|
+
* Every name here is a review-approved contract: adding or removing one
|
|
5
|
+
* WITHOUT the ritual (the review's sign-off + a public-surface.json
|
|
6
|
+
* refresh) turns the public-surface gate red. See docs/sdk.md for the
|
|
7
|
+
* contract, the aliases, and what deliberately lives behind the
|
|
8
|
+
* first-party door.
|
|
9
|
+
*
|
|
10
|
+
* What is NOT here is not the SDK. The recovery / compose / summarize /
|
|
11
|
+
* lock-adapter machinery and buildAdapter live behind "./internal"
|
|
12
|
+
* (unstable, first-party only — see internal.ts).
|
|
13
|
+
*
|
|
14
|
+
* Canonical names: Agent = AgentRuntime, Session = AgentSession. The old
|
|
15
|
+
* names are deprecated aliases, removed in the next major (additive,
|
|
16
|
+
* ADR-0051 Amendment 1 — release rounds move the cli minor).
|
|
17
|
+
*/
|
|
18
|
+
// agent
|
|
19
|
+
export { AgentRuntime, AgentRuntime as Agent, createAgent } from "./agent.js";
|
|
20
|
+
// session
|
|
21
|
+
export { AgentSession, AgentSession as Session } from "./session.js";
|
|
22
|
+
export { PoisonedSessionError, ResumeBlockedError } from "./session.js";
|
|
23
|
+
// run
|
|
24
|
+
export { Run } from "./run.js";
|
|
25
|
+
// store
|
|
26
|
+
export { SessionStore, StaleWriterError, StoreCorruptionError } from "./store.js";
|
|
27
|
+
// extensions
|
|
28
|
+
export { disposeExtensions, loadExtensions, loadProjectExtensions } from "./extensions.js";
|
|
29
|
+
// ledger
|
|
30
|
+
export { executionForCallId, executionLedger } from "./ledger.js";
|
|
31
|
+
// trust
|
|
32
|
+
export { kisoHome, projectArtifacts, recordTrust, trustFor } from "./trust.js";
|
|
33
|
+
// usage — the canonical accounting schema (E2/1.3.0, R4b-1 ruling:
|
|
34
|
+
// additive minor, one function; the signature is frozen the moment this
|
|
35
|
+
// line lands — changing it is a MAJOR ritual). R5b-④a set the final
|
|
36
|
+
// shape at freeze time: the trailing `table?: PricingTable` injection
|
|
37
|
+
// slot defaults to the builtin v1 table, so the R5a-1-commercial table
|
|
38
|
+
// rides day one without ever widening this signature. Raw stays
|
|
39
|
+
// provider observation; canonicalizeUsage derives at the accounting
|
|
40
|
+
// boundary (R4 Case B — the frozen usage union does not move).
|
|
41
|
+
export { canonicalizeUsage } from "./usage/canonical.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The FIRST-PARTY door — NOT part of the public SDK contract (S1,
|
|
3
|
+
* 2026-08-12, adjudicated).
|
|
4
|
+
*
|
|
5
|
+
* Unstable: anything here can move or vanish in any release without a
|
|
6
|
+
* major bump. External consumers must import the curated root
|
|
7
|
+
* (index.ts); this file exists for the in-repo consumers (apps/cli,
|
|
8
|
+
* extensions, tests) that need machinery the SDK deliberately does not
|
|
9
|
+
* expose — the recovery / compose / summarize / lock-adapter internals
|
|
10
|
+
* and buildAdapter.
|
|
11
|
+
*
|
|
12
|
+
* This is today's pre-S1 index.ts verbatim: removing the door would be a
|
|
13
|
+
* breaking change for the first-party consumers mid-migration.
|
|
14
|
+
*/
|
|
15
|
+
export * from "./agent.js";
|
|
16
|
+
export * from "./session.js";
|
|
17
|
+
export * from "./run.js";
|
|
18
|
+
export * from "./recovery.js";
|
|
19
|
+
export * from "./compose.js";
|
|
20
|
+
export * from "./summarize.js";
|
|
21
|
+
export * from "./store.js";
|
|
22
|
+
export * from "./lock-adapter.js";
|
|
23
|
+
export * from "./ledger.js";
|
|
24
|
+
export * from "./extensions.js";
|
|
25
|
+
export * from "./trust.js";
|
package/dist/internal.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The FIRST-PARTY door — NOT part of the public SDK contract (S1,
|
|
3
|
+
* 2026-08-12, adjudicated).
|
|
4
|
+
*
|
|
5
|
+
* Unstable: anything here can move or vanish in any release without a
|
|
6
|
+
* major bump. External consumers must import the curated root
|
|
7
|
+
* (index.ts); this file exists for the in-repo consumers (apps/cli,
|
|
8
|
+
* extensions, tests) that need machinery the SDK deliberately does not
|
|
9
|
+
* expose — the recovery / compose / summarize / lock-adapter internals
|
|
10
|
+
* and buildAdapter.
|
|
11
|
+
*
|
|
12
|
+
* This is today's pre-S1 index.ts verbatim: removing the door would be a
|
|
13
|
+
* breaking change for the first-party consumers mid-migration.
|
|
14
|
+
*/
|
|
15
|
+
export * from "./agent.js";
|
|
16
|
+
export * from "./session.js";
|
|
17
|
+
export * from "./run.js";
|
|
18
|
+
export * from "./recovery.js";
|
|
19
|
+
export * from "./compose.js";
|
|
20
|
+
export * from "./summarize.js";
|
|
21
|
+
export * from "./store.js";
|
|
22
|
+
export * from "./lock-adapter.js";
|
|
23
|
+
export * from "./ledger.js";
|
|
24
|
+
export * from "./extensions.js";
|
|
25
|
+
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,384 @@
|
|
|
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 { execFileSync } from "node:child_process";
|
|
65
|
+
import { closeSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
66
|
+
import { randomUUID } from "node:crypto";
|
|
67
|
+
import { join } from "node:path";
|
|
68
|
+
/** A live foreign writer owns the lock — never taken over. */
|
|
69
|
+
export class LockedError extends Error {
|
|
70
|
+
constructor(message) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.name = "LockedError";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** The mechanism cannot operate (fs/link failure) — never a lock conflict. */
|
|
76
|
+
export class LockUnavailableError extends Error {
|
|
77
|
+
constructor(reason) {
|
|
78
|
+
super(`session locking unavailable: ${reason}`);
|
|
79
|
+
this.name = "LockUnavailableError";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* ADR-0050 Amendment 1 (Finding R-I-1): the liveness probe is
|
|
84
|
+
* state-aware. A dead holder can linger in the process table after a
|
|
85
|
+
* kill — the exiting state (STAT E on macOS: the kill landing while a
|
|
86
|
+
* pty syscall is blocked, the dead session's terminal left open) or an
|
|
87
|
+
* un-reaped zombie (STAT Z). POSIX reports BOTH alive to kill(pid, 0)
|
|
88
|
+
* (they exist until reaped), so the takeover refused a holder that can
|
|
89
|
+
* never execute another session write. Both states are probed and
|
|
90
|
+
* judged DEAD. A probe failure (unreadable state) maintains the
|
|
91
|
+
* pre-amendment behavior — alive, the fail-safe refusal (prefer a false
|
|
92
|
+
* refusal over a double-write). Live-process semantics and the PID-reuse
|
|
93
|
+
* rules do not move: a live foreign writer is still refused.
|
|
94
|
+
*
|
|
95
|
+
* ADR-0050 Amendment 2 (Finding R-I-p-3): the state letters are matched
|
|
96
|
+
* ANYWHERE in the state string, not as the first character. The
|
|
97
|
+
* exit-path linger's ps output is "?E" — the first character is the
|
|
98
|
+
* no-controlling-terminal marker "?", with the E sitting AFTER it — so
|
|
99
|
+
* first-character matching judged the finding's own documented shape
|
|
100
|
+
* alive. The ps state alphabet (macOS + Linux) has no flag letters
|
|
101
|
+
* "E"/"Z": an occurrence anywhere is the process-state code, and the
|
|
102
|
+
* holder is dead.
|
|
103
|
+
*/
|
|
104
|
+
function isAlive(pid) {
|
|
105
|
+
try {
|
|
106
|
+
process.kill(pid, 0);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
return err.code === "EPERM";
|
|
110
|
+
}
|
|
111
|
+
const state = processState(pid);
|
|
112
|
+
// A null state (probe failure) keeps the pre-amendment behavior —
|
|
113
|
+
// judged alive, the fail-safe refusal.
|
|
114
|
+
return state === null || (!state.includes("E") && !state.includes("Z"));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The process state via `ps -o state= -p <pid>` (macOS/Linux) — the
|
|
118
|
+
* whole state string (a multi-char string like "Ss+" or "?E" is a
|
|
119
|
+
* combined flag set; the E/Z process-state codes may sit after the "?"
|
|
120
|
+
* no-tty marker or other flag letters, so the matching in isAlive scans
|
|
121
|
+
* the whole string). An empty or unreadable state — the process
|
|
122
|
+
* vanished between the kill and the probe, or ps itself failed —
|
|
123
|
+
* returns null; isAlive's fail-safe branch then judges the holder alive
|
|
124
|
+
* (the pre-amendment behavior).
|
|
125
|
+
*/
|
|
126
|
+
function processState(pid) {
|
|
127
|
+
try {
|
|
128
|
+
const state = execFileSync("ps", ["-o", "state=", "-p", String(pid)], { encoding: "utf8" }).trim();
|
|
129
|
+
return state.length > 0 ? state : null;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Read a lock file's holder identity (round 4 formats, unchanged — the
|
|
137
|
+
* cross-version channel). Empty, unreadable, or half-written locks have no
|
|
138
|
+
* identity: they are residue, taken over (ADR-0050 §migration).
|
|
139
|
+
*/
|
|
140
|
+
function readLockIdentity(lockPath) {
|
|
141
|
+
let raw;
|
|
142
|
+
try {
|
|
143
|
+
raw = readFileSync(lockPath, "utf8");
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const trimmed = raw.trim();
|
|
149
|
+
if (trimmed === "")
|
|
150
|
+
return null;
|
|
151
|
+
let parsed;
|
|
152
|
+
try {
|
|
153
|
+
parsed = JSON.parse(trimmed);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
parsed = trimmed; // half-written JSON — try as a bare pid
|
|
157
|
+
}
|
|
158
|
+
if (typeof parsed === "number" && Number.isInteger(parsed)) {
|
|
159
|
+
return { pid: parsed }; // JSON.parse("123") — a legacy bare pid
|
|
160
|
+
}
|
|
161
|
+
if (typeof parsed === "string") {
|
|
162
|
+
const pid = Number.parseInt(parsed, 10);
|
|
163
|
+
return Number.isFinite(pid) ? { pid } : null;
|
|
164
|
+
}
|
|
165
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
166
|
+
const v = parsed;
|
|
167
|
+
return {
|
|
168
|
+
...(typeof v.pid === "number" ? { pid: v.pid } : {}),
|
|
169
|
+
...(typeof v.token === "string" ? { token: v.token } : {}),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
function sameIdentity(a, b) {
|
|
175
|
+
if (a === null && b === null)
|
|
176
|
+
return true;
|
|
177
|
+
if (a === null || b === null)
|
|
178
|
+
return false;
|
|
179
|
+
return a.pid === b.pid && (a.token ?? null) === (b.token ?? null);
|
|
180
|
+
}
|
|
181
|
+
function unavailable(err) {
|
|
182
|
+
const e = err;
|
|
183
|
+
return new LockUnavailableError(`${e.code ?? "?"}: ${e.message}`);
|
|
184
|
+
}
|
|
185
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
186
|
+
class NativeLockHandle {
|
|
187
|
+
pid;
|
|
188
|
+
token;
|
|
189
|
+
#lockPath;
|
|
190
|
+
#tombstone;
|
|
191
|
+
#released = false;
|
|
192
|
+
constructor(lockPath, tombstone, identity) {
|
|
193
|
+
this.#lockPath = lockPath;
|
|
194
|
+
this.#tombstone = tombstone;
|
|
195
|
+
this.pid = identity.pid;
|
|
196
|
+
this.token = identity.token;
|
|
197
|
+
}
|
|
198
|
+
verify() {
|
|
199
|
+
if (this.#released)
|
|
200
|
+
return false;
|
|
201
|
+
const now = readLockIdentity(this.#lockPath);
|
|
202
|
+
return now !== null && now.pid === this.pid && now.token === this.token;
|
|
203
|
+
}
|
|
204
|
+
release() {
|
|
205
|
+
if (this.#released)
|
|
206
|
+
return;
|
|
207
|
+
this.#released = true;
|
|
208
|
+
try {
|
|
209
|
+
renameSync(this.#lockPath, this.#tombstone);
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
if (err.code === "ENOENT")
|
|
213
|
+
return; // nothing of ours at the path
|
|
214
|
+
// A fs-level failure: fall back to clearing in place (advisory
|
|
215
|
+
// best effort — a missing marker can only wedge same-process
|
|
216
|
+
// residue, never split a writer).
|
|
217
|
+
try {
|
|
218
|
+
writeFileSync(this.#lockPath, "");
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// advisory only
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const moved = readLockIdentity(this.#tombstone);
|
|
226
|
+
if (moved !== null && moved.pid === this.pid && moved.token === this.token) {
|
|
227
|
+
// Mine. Leave the EMPTY released marker — the path is never
|
|
228
|
+
// deleted (a contender must be able to read it; the
|
|
229
|
+
// storage-identity suite pins it) — created ONLY-if-absent, so
|
|
230
|
+
// a rival that linked in the window keeps its lock untouched.
|
|
231
|
+
try {
|
|
232
|
+
const fd = openSync(this.#lockPath, "wx");
|
|
233
|
+
closeSync(fd);
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
// EEXIST → a rival's lock stands; anything else → advisory.
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
unlinkSync(this.#tombstone);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// the stray tombstone is inert
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// A rival's file was moved (the displacement cascade, ADR-0050
|
|
247
|
+
// §residual) — restore-or-keep, never clobber: link it back
|
|
248
|
+
// only-if-absent; on EEXIST the path already carries a newer lock
|
|
249
|
+
// and the moved file stays at its distinct name (inert residue).
|
|
250
|
+
try {
|
|
251
|
+
linkSync(this.#tombstone, this.#lockPath);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// EEXIST or fs failure — inert
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
class NativeLock {
|
|
259
|
+
name = "link-lock";
|
|
260
|
+
async acquire(lockPath, sessionId, cancelled) {
|
|
261
|
+
const pid = process.pid;
|
|
262
|
+
const token = randomUUID();
|
|
263
|
+
// The staging name is PER-ATTEMPT: a later rename-away in the same
|
|
264
|
+
// acquire must never atomically overwrite an earlier attempt's
|
|
265
|
+
// abandoned staging (that would clobber the displacement residue,
|
|
266
|
+
// ADR-0050 §residual). tmp/tombstone are unique per acquire and are
|
|
267
|
+
// always cleaned or inert.
|
|
268
|
+
const tmp = `${lockPath}.tmp-${pid}-${token}`;
|
|
269
|
+
const tombstone = `${lockPath}.tomb-${pid}-${token}`;
|
|
270
|
+
// Test-only affordances (ADR-0050 §test affordances): the cascade
|
|
271
|
+
// gate freezes a contender at the two decision points.
|
|
272
|
+
const readyDir = process.env.KISO_LOCK_TEST_READY_DIR;
|
|
273
|
+
const readPause = Number(process.env.KISO_LOCK_TEST_PAUSE_READ_MS ?? 0) || 0;
|
|
274
|
+
const takeoverPause = Number(process.env.KISO_LOCK_TEST_PAUSE_TAKEOVER_MS ?? 0) || 0;
|
|
275
|
+
for (let attempt = 0;; attempt++) {
|
|
276
|
+
cancelled();
|
|
277
|
+
const staging = `${lockPath}.staging-${pid}-${token}-${attempt}`;
|
|
278
|
+
const seen = readLockIdentity(lockPath);
|
|
279
|
+
if (seen === null || seen.pid === undefined || !isAlive(seen.pid)) {
|
|
280
|
+
// Fresh path, dead holder, or stale residue — take it over
|
|
281
|
+
// by identity confirmation: rename-away → verify → link.
|
|
282
|
+
if (readyDir !== undefined)
|
|
283
|
+
writeFileSync(join(readyDir, `read-${pid}`), "");
|
|
284
|
+
if (readPause > 0)
|
|
285
|
+
await sleep(readPause);
|
|
286
|
+
let moved = false;
|
|
287
|
+
try {
|
|
288
|
+
renameSync(lockPath, staging);
|
|
289
|
+
moved = true;
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
if (err.code !== "ENOENT")
|
|
293
|
+
throw unavailable(err);
|
|
294
|
+
// ENOENT — the path is already absent; proceed to the link.
|
|
295
|
+
}
|
|
296
|
+
if (moved) {
|
|
297
|
+
if (readyDir !== undefined)
|
|
298
|
+
writeFileSync(join(readyDir, `takeover-${pid}`), "");
|
|
299
|
+
if (takeoverPause > 0)
|
|
300
|
+
await sleep(takeoverPause);
|
|
301
|
+
const s = readLockIdentity(staging);
|
|
302
|
+
if (!sameIdentity(s, seen)) {
|
|
303
|
+
// The path was replaced between my read and my rename
|
|
304
|
+
// — a RIVAL'S LIVE file was moved. Abort the takeover:
|
|
305
|
+
// restore-or-keep (link back only-if-absent; on EEXIST
|
|
306
|
+
// the moved file is abandoned — inert residue at its
|
|
307
|
+
// distinct name, ADR-0050 §residual).
|
|
308
|
+
try {
|
|
309
|
+
linkSync(staging, lockPath);
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
if (err.code !== "EEXIST")
|
|
313
|
+
throw unavailable(err);
|
|
314
|
+
}
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// Write my identity fully, fsync it, THEN link — the
|
|
319
|
+
// load-bearing order (ADR-0050 §crash-durability): the final
|
|
320
|
+
// path can never exist empty or half-written, even across a
|
|
321
|
+
// power loss.
|
|
322
|
+
try {
|
|
323
|
+
const fd = openSync(tmp, "w");
|
|
324
|
+
try {
|
|
325
|
+
writeFileSync(fd, JSON.stringify({ pid, token }), "utf8");
|
|
326
|
+
fsyncSync(fd);
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
closeSync(fd);
|
|
330
|
+
}
|
|
331
|
+
linkSync(tmp, lockPath);
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
const code = err.code;
|
|
335
|
+
if (code === "EEXIST") {
|
|
336
|
+
// A rival linked first — retry from a fresh read.
|
|
337
|
+
try {
|
|
338
|
+
unlinkSync(tmp);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// the stray is inert
|
|
342
|
+
}
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
throw unavailable(err);
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
unlinkSync(tmp);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// the stray is inert
|
|
352
|
+
}
|
|
353
|
+
// The takeover succeeded — our identity owns the path, and the
|
|
354
|
+
// verified-dead file we moved away may be cleaned up (its
|
|
355
|
+
// name is distinct; unlinking it can never touch the lock
|
|
356
|
+
// path). Only the EEXIST-abandoned staging of a FAILED
|
|
357
|
+
// takeover lingers — the displacement fingerprint.
|
|
358
|
+
if (moved) {
|
|
359
|
+
try {
|
|
360
|
+
unlinkSync(staging);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
// the stray is inert
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return new NativeLockHandle(lockPath, tombstone, { pid, token });
|
|
367
|
+
}
|
|
368
|
+
if (seen.pid === pid && seen.token !== undefined) {
|
|
369
|
+
// Same-process writer's residue (round 5): another store in
|
|
370
|
+
// THIS process holds the lock — it will release; retry until
|
|
371
|
+
// it does (never a spurious self-conflict). A legacy
|
|
372
|
+
// bare-pid lock naming our own process is refused like any
|
|
373
|
+
// live foreign owner.
|
|
374
|
+
if (attempt >= 25)
|
|
375
|
+
throw new LockedError(`session ${sessionId} is locked by another writer`);
|
|
376
|
+
await sleep(20);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
throw new LockedError(`session ${sessionId} is locked by another writer (pid ${seen.pid})`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/** The default adapter: the identity-confirmed link lock (ADR-0050). */
|
|
384
|
+
export const nativeLockAdapter = new NativeLock();
|