@particle-academy/prism-harness 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +179 -0
- package/dist/doctor.d.ts +35 -0
- package/dist/doctor.js +110 -0
- package/dist/errors.d.ts +199 -0
- package/dist/errors.js +201 -0
- package/dist/events.d.ts +67 -0
- package/dist/events.js +40 -0
- package/dist/harness.d.ts +51 -0
- package/dist/harness.js +70 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +20 -0
- package/dist/json.d.ts +15 -0
- package/dist/json.js +15 -0
- package/dist/modes.d.ts +63 -0
- package/dist/modes.js +109 -0
- package/dist/runtime.d.ts +104 -0
- package/dist/runtime.js +244 -0
- package/dist/session.d.ts +114 -0
- package/dist/session.js +198 -0
- package/dist/skills.d.ts +37 -0
- package/dist/skills.js +73 -0
- package/dist/store-task-source.d.ts +169 -0
- package/dist/store-task-source.js +513 -0
- package/dist/stores/file-session-store.d.ts +34 -0
- package/dist/stores/file-session-store.js +284 -0
- package/dist/stores/memory-session-store.d.ts +22 -0
- package/dist/stores/memory-session-store.js +97 -0
- package/dist/stores/session-store.d.ts +65 -0
- package/dist/stores/session-store.js +3 -0
- package/dist/stores/store-manager.d.ts +32 -0
- package/dist/stores/store-manager.js +57 -0
- package/dist/subagent-runner.d.ts +45 -0
- package/dist/subagent-runner.js +89 -0
- package/dist/subagents.d.ts +182 -0
- package/dist/subagents.js +286 -0
- package/dist/task-tools.d.ts +55 -0
- package/dist/task-tools.js +136 -0
- package/dist/tasks.d.ts +257 -0
- package/dist/tasks.js +162 -0
- package/dist/thread.d.ts +49 -0
- package/dist/thread.js +88 -0
- package/dist/tools.d.ts +89 -0
- package/dist/tools.js +148 -0
- package/package.json +34 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { mkdir, open, readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { isJsonObject } from '../json.js';
|
|
6
|
+
import { HarnessError } from '../errors.js';
|
|
7
|
+
/**
|
|
8
|
+
* State on disk, as one JSON file per key. DURABLE, and it says so.
|
|
9
|
+
*
|
|
10
|
+
* The default durable driver for a port with no database and no dependencies.
|
|
11
|
+
* A real deployment points the durable slot at a database instead; this exists
|
|
12
|
+
* so the package WORKS ON INSTALL rather than requiring infrastructure before
|
|
13
|
+
* the first session can be opened — the same reason the PHP reference defaults
|
|
14
|
+
* both slots to `database`.
|
|
15
|
+
*
|
|
16
|
+
* ## Two properties that matter more than speed
|
|
17
|
+
*
|
|
18
|
+
* **Writes are atomic.** A payload is written to a temporary file and renamed
|
|
19
|
+
* over the target, because `rename` is atomic on both POSIX and Windows. A
|
|
20
|
+
* partial write here is a corrupted thread, and a process killed mid-write is
|
|
21
|
+
* ordinary rather than exotic.
|
|
22
|
+
*
|
|
23
|
+
* **The lock is cross-process.** It is an exclusive-create lockfile (`wx`),
|
|
24
|
+
* which is the one primitive that is atomic on every filesystem worth
|
|
25
|
+
* supporting — unlike an "is it there?" check followed by a create, which has a
|
|
26
|
+
* window between the two. Two workers on one machine genuinely exclude each
|
|
27
|
+
* other. Two workers on different machines sharing a network filesystem do NOT
|
|
28
|
+
* reliably, and no file lock can promise that; use a database or Redis there.
|
|
29
|
+
*/
|
|
30
|
+
export class FileSessionStore {
|
|
31
|
+
#directory;
|
|
32
|
+
constructor(directory) {
|
|
33
|
+
this.#directory = directory;
|
|
34
|
+
mkdirSync(directory, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
durability() {
|
|
37
|
+
return 'durable';
|
|
38
|
+
}
|
|
39
|
+
async get(key) {
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
raw = await readFile(this.#pathFor(key), 'utf8');
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (isMissing(error))
|
|
46
|
+
return null;
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
let document;
|
|
50
|
+
try {
|
|
51
|
+
document = JSON.parse(raw);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw HarnessError.unmappableContent(`the stored payload for [${key}] is not valid JSON`, {
|
|
55
|
+
cause: error,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (!isJsonObject(document)) {
|
|
59
|
+
throw HarnessError.unmappableContent(`the stored payload for [${key}] is not an object`);
|
|
60
|
+
}
|
|
61
|
+
const expiresAt = document.expires_at;
|
|
62
|
+
if (typeof expiresAt === 'number' && expiresAt <= Date.now()) {
|
|
63
|
+
await this.forget(key);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return isJsonObject(document.payload) ? document.payload : null;
|
|
67
|
+
}
|
|
68
|
+
async put(key, payload, ttlSeconds = null) {
|
|
69
|
+
const target = this.#pathFor(key);
|
|
70
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
71
|
+
const document = JSON.stringify({
|
|
72
|
+
key,
|
|
73
|
+
payload,
|
|
74
|
+
expires_at: ttlSeconds === null ? null : Date.now() + ttlSeconds * 1000,
|
|
75
|
+
});
|
|
76
|
+
await mkdir(this.#directory, { recursive: true });
|
|
77
|
+
await writeFile(temporary, document, 'utf8');
|
|
78
|
+
try {
|
|
79
|
+
// Atomic on both POSIX and Windows. A reader either sees the whole old
|
|
80
|
+
// payload or the whole new one, never half of either.
|
|
81
|
+
await rename(temporary, target);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
await rm(temporary, { force: true });
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async forget(key) {
|
|
89
|
+
await rm(this.#pathFor(key), { force: true });
|
|
90
|
+
}
|
|
91
|
+
async withLock(key, callback, ttlSeconds = 10, waitSeconds = 5) {
|
|
92
|
+
const lockPath = `${this.#pathFor(key)}.lock`;
|
|
93
|
+
const deadline = Date.now() + waitSeconds * 1000;
|
|
94
|
+
for (;;) {
|
|
95
|
+
try {
|
|
96
|
+
// `wx` fails if the file exists. Exclusive CREATION is atomic; a
|
|
97
|
+
// check-then-create is not, and the window between the two is exactly
|
|
98
|
+
// where two workers both decide they hold the lock.
|
|
99
|
+
const handle = await open(lockPath, 'wx');
|
|
100
|
+
// ONE write, and the terminator goes in it. The newline is what lets a
|
|
101
|
+
// reader tell a complete stamp from a torn one, so it must never be a
|
|
102
|
+
// second write that could be the half that did not land. See
|
|
103
|
+
// `#expired`, and note the format is shared with the other two ports.
|
|
104
|
+
await handle.writeFile(stamp(Date.now() + ttlSeconds * 1000), 'utf8');
|
|
105
|
+
await handle.close();
|
|
106
|
+
try {
|
|
107
|
+
return await callback();
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await this.#release(lockPath);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (!isHeld(error))
|
|
115
|
+
throw error;
|
|
116
|
+
// A lock whose TTL has passed belonged to a process that died holding
|
|
117
|
+
// it. Left alone it would wedge the key forever, which is worse than
|
|
118
|
+
// the small race in reclaiming it — and the reclaim is itself a
|
|
119
|
+
// create-exclusive, so only one waiter can win.
|
|
120
|
+
if (await this.#expired(lockPath, ttlSeconds)) {
|
|
121
|
+
await unlink(lockPath).catch(() => undefined);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (Date.now() >= deadline) {
|
|
125
|
+
throw HarnessError.sessionLocked(key, waitSeconds);
|
|
126
|
+
}
|
|
127
|
+
await sleep(25);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Give the lock up, and NEVER leave it held on the way out.
|
|
133
|
+
*
|
|
134
|
+
* Deleting is the normal path, but on Windows it can fail: another waiter
|
|
135
|
+
* attempting to create the same path holds a transient handle, and the unlink
|
|
136
|
+
* then raises EPERM. Swallowing that — which is what this did first — LEAKS
|
|
137
|
+
* THE LOCK. The waiter then sees a lockfile whose TTL is still in the future,
|
|
138
|
+
* has no way to know its holder is gone, and blocks for the whole wait before
|
|
139
|
+
* failing. Not theoretical: `prism-harness-py` hit exactly this the first
|
|
140
|
+
* time two threads recorded a message concurrently, and this port had the
|
|
141
|
+
* same defect with no test that happened to reach it.
|
|
142
|
+
*
|
|
143
|
+
* So if the file cannot be removed, it is rewritten with an ALREADY-PAST
|
|
144
|
+
* expiry. Any waiter reclaims it on its next attempt, which is the same path
|
|
145
|
+
* a genuinely dead holder takes.
|
|
146
|
+
*/
|
|
147
|
+
async #release(lockPath) {
|
|
148
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
149
|
+
try {
|
|
150
|
+
await unlink(lockPath);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (isMissing(error))
|
|
155
|
+
return;
|
|
156
|
+
await sleep(5);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Nothing left to try but mark it dead. The TTL is the backstop, and it is
|
|
160
|
+
// why the lockfile carries one at all. TERMINATED like any other stamp: an
|
|
161
|
+
// unterminated `0` reads as a torn write, and a waiter would then sit on
|
|
162
|
+
// the mtime bound for a whole ttl instead of reclaiming it at once — the
|
|
163
|
+
// exact delay this path exists to avoid.
|
|
164
|
+
await writeFile(lockPath, stamp(0), 'utf8').catch(() => undefined);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Whether this lock belonged to a holder that is gone.
|
|
168
|
+
*
|
|
169
|
+
* ## An UNFINISHED stamp is HELD, not expired in 1970
|
|
170
|
+
*
|
|
171
|
+
* Taking the lock is an exclusive create and THEN a write of the expiry —
|
|
172
|
+
* two operations, with a moment in between where the file exists and is
|
|
173
|
+
* incomplete. There are two ways to read it in that moment and both used to
|
|
174
|
+
* steal the lock:
|
|
175
|
+
*
|
|
176
|
+
* - **Empty.** `Number('')` is `0`, which is finite and certainly
|
|
177
|
+
* `<= Date.now()`.
|
|
178
|
+
* - **Torn.** A partial write leaves a PREFIX of the timestamp —
|
|
179
|
+
* `17356899` where `1735689900000` was meant — and every prefix of a
|
|
180
|
+
* number is a SMALLER number. It parses, it is finite, and it is decades
|
|
181
|
+
* in the past. This one is worse than the empty case precisely because it
|
|
182
|
+
* does not look broken.
|
|
183
|
+
*
|
|
184
|
+
* Either way a waiter concluded the holder was long dead, deleted the lock,
|
|
185
|
+
* and took one somebody was actively holding. Two callers, one key: the
|
|
186
|
+
* failure this lock exists to prevent, produced BY the mechanism meant to
|
|
187
|
+
* recover from a dead holder. The empty case surfaced as a flaky test — about
|
|
188
|
+
* one run in eight — which is how it survived; the torn case would not have
|
|
189
|
+
* surfaced at all.
|
|
190
|
+
*
|
|
191
|
+
* The fix is the TERMINATOR. A stamp is digits followed by a newline, written
|
|
192
|
+
* in one write, so anything else is an unfinished write rather than a small
|
|
193
|
+
* number. Unreadable then means WAIT — `session_locked` after the wait
|
|
194
|
+
* period, which is loud and recoverable — instead of deleting a live holder's
|
|
195
|
+
* lock. The format is shared with the PHP and Python ports, because a worker
|
|
196
|
+
* in any of the three may be pointed at the same store directory.
|
|
197
|
+
*
|
|
198
|
+
* ## …but only until it is older than the ttl
|
|
199
|
+
*
|
|
200
|
+
* Treating an empty lockfile as held forever would let a process that died
|
|
201
|
+
* inside that window wedge the key permanently, which is the worse failure
|
|
202
|
+
* of the two. The file's own mtime bounds it: the window is microseconds in a
|
|
203
|
+
* live process, so a file still empty a whole ttl later belonged to one that
|
|
204
|
+
* did not survive it. Nothing extra has to be written for that to work.
|
|
205
|
+
*/
|
|
206
|
+
async #expired(lockPath, ttlSeconds) {
|
|
207
|
+
let raw;
|
|
208
|
+
try {
|
|
209
|
+
raw = await readFile(lockPath, 'utf8');
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// Gone between the failed create and this read: treat it as not expired
|
|
213
|
+
// and let the next attempt take it cleanly.
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
// Digits AND a terminator, or this is not a finished stamp. Checked as a
|
|
217
|
+
// whole rather than by trying to parse and hoping a bad value fails to:
|
|
218
|
+
// the torn case parses perfectly and is the reason this check exists.
|
|
219
|
+
if (!STAMP.test(raw)) {
|
|
220
|
+
try {
|
|
221
|
+
const { mtimeMs } = await stat(lockPath);
|
|
222
|
+
return mtimeMs + ttlSeconds * 1000 <= Date.now();
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const expiresAt = Number(raw.slice(0, -1));
|
|
229
|
+
return Number.isFinite(expiresAt) && expiresAt <= Date.now();
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* One file per key, named by a digest.
|
|
233
|
+
*
|
|
234
|
+
* A session key contains colons, which are legal on POSIX and not on Windows,
|
|
235
|
+
* and it is long enough to run into path ceilings once a scope is appended.
|
|
236
|
+
* The digest sidesteps both, and the key itself is stored INSIDE the file so
|
|
237
|
+
* the mapping stays inspectable.
|
|
238
|
+
*/
|
|
239
|
+
#pathFor(key) {
|
|
240
|
+
return join(this.#directory, `${createHash('sha256').update(key).digest('hex').slice(0, 32)}.json`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* A finished lock stamp: digits, then a newline. NOTHING ELSE.
|
|
245
|
+
*
|
|
246
|
+
* The terminator is the only thing that distinguishes a complete timestamp from
|
|
247
|
+
* a torn one, because a prefix of a timestamp is a perfectly valid smaller
|
|
248
|
+
* timestamp. Shared with the PHP and Python ports — a worker in any of the
|
|
249
|
+
* three may be pointed at the same store directory, and one that does not
|
|
250
|
+
* emit the newline has its locks waited out rather than reclaimed everywhere
|
|
251
|
+
* else.
|
|
252
|
+
*/
|
|
253
|
+
const STAMP = /^\d+\n$/;
|
|
254
|
+
function stamp(expiresAt) {
|
|
255
|
+
return `${String(Math.trunc(expiresAt))}\n`;
|
|
256
|
+
}
|
|
257
|
+
function isMissing(error) {
|
|
258
|
+
return typeof error === 'object' && error !== null && error.code === 'ENOENT';
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* "The lock is already held" — under four different names.
|
|
262
|
+
*
|
|
263
|
+
* POSIX says EEXIST. WINDOWS DOES NOT, reliably: deleting a file that another
|
|
264
|
+
* handle still has open leaves it in a pending-delete state, and a create
|
|
265
|
+
* attempt against that returns EPERM, EACCES or EBUSY instead. That window is
|
|
266
|
+
* not rare — it is exactly the moment one caller releases the lock while the
|
|
267
|
+
* next is trying to take it, which is the common case under contention rather
|
|
268
|
+
* than an edge.
|
|
269
|
+
*
|
|
270
|
+
* Treating those as "held, retry" is also the safe direction to be wrong in: a
|
|
271
|
+
* genuine permission problem retries until the wait expires and then reports
|
|
272
|
+
* `session_locked`, which is a survivable failure. Treating them as "not held"
|
|
273
|
+
* would hand the same lock to two callers.
|
|
274
|
+
*/
|
|
275
|
+
function isHeld(error) {
|
|
276
|
+
const code = typeof error === 'object' && error !== null ? error.code : undefined;
|
|
277
|
+
return code === 'EEXIST' || code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
|
|
278
|
+
}
|
|
279
|
+
function sleep(ms) {
|
|
280
|
+
return new Promise((resolve) => {
|
|
281
|
+
const timer = setTimeout(resolve, ms);
|
|
282
|
+
timer.unref?.();
|
|
283
|
+
});
|
|
284
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { JsonObject } from '../json.js';
|
|
2
|
+
import type { Durability, SessionStore } from './session-store.js';
|
|
3
|
+
/**
|
|
4
|
+
* State in this process's memory. VOLATILE, and it says so.
|
|
5
|
+
*
|
|
6
|
+
* The right home for the ephemeral slot in a test or a single-process tool, and
|
|
7
|
+
* REFUSED for the durable slot by the store manager — not as a technicality:
|
|
8
|
+
* the contents do not outlive the process, and the durable slot holds approvals
|
|
9
|
+
* a human has not answered yet.
|
|
10
|
+
*
|
|
11
|
+
* The lock is real but PROCESS-LOCAL. Two workers cannot see each other's
|
|
12
|
+
* locks, which is the whole reason a deployment uses something else; this
|
|
13
|
+
* serialises callers within one process and claims nothing beyond that.
|
|
14
|
+
*/
|
|
15
|
+
export declare class MemorySessionStore implements SessionStore {
|
|
16
|
+
#private;
|
|
17
|
+
durability(): Durability;
|
|
18
|
+
get(key: string): Promise<JsonObject | null>;
|
|
19
|
+
put(key: string, payload: JsonObject, ttlSeconds?: number | null): Promise<void>;
|
|
20
|
+
forget(key: string): Promise<void>;
|
|
21
|
+
withLock<T>(key: string, callback: () => T | Promise<T>, _ttlSeconds?: number, waitSeconds?: number): Promise<T>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { clone } from '../json.js';
|
|
2
|
+
import { HarnessError } from '../errors.js';
|
|
3
|
+
/**
|
|
4
|
+
* State in this process's memory. VOLATILE, and it says so.
|
|
5
|
+
*
|
|
6
|
+
* The right home for the ephemeral slot in a test or a single-process tool, and
|
|
7
|
+
* REFUSED for the durable slot by the store manager — not as a technicality:
|
|
8
|
+
* the contents do not outlive the process, and the durable slot holds approvals
|
|
9
|
+
* a human has not answered yet.
|
|
10
|
+
*
|
|
11
|
+
* The lock is real but PROCESS-LOCAL. Two workers cannot see each other's
|
|
12
|
+
* locks, which is the whole reason a deployment uses something else; this
|
|
13
|
+
* serialises callers within one process and claims nothing beyond that.
|
|
14
|
+
*/
|
|
15
|
+
export class MemorySessionStore {
|
|
16
|
+
#entries = new Map();
|
|
17
|
+
/** Key → the promise chain currently serialising work on it. */
|
|
18
|
+
#locks = new Map();
|
|
19
|
+
durability() {
|
|
20
|
+
return 'volatile';
|
|
21
|
+
}
|
|
22
|
+
async get(key) {
|
|
23
|
+
const entry = this.#entries.get(key);
|
|
24
|
+
if (entry === undefined) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
28
|
+
this.#entries.delete(key);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
// A copy, so a caller mutating what it read cannot reach into the store.
|
|
32
|
+
return clone(entry.payload);
|
|
33
|
+
}
|
|
34
|
+
async put(key, payload, ttlSeconds = null) {
|
|
35
|
+
this.#entries.set(key, {
|
|
36
|
+
payload: clone(payload),
|
|
37
|
+
expiresAt: ttlSeconds === null ? null : Date.now() + ttlSeconds * 1000,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
async forget(key) {
|
|
41
|
+
this.#entries.delete(key);
|
|
42
|
+
}
|
|
43
|
+
async withLock(key, callback, _ttlSeconds = 10, waitSeconds = 5) {
|
|
44
|
+
const previous = this.#locks.get(key) ?? Promise.resolve();
|
|
45
|
+
let release;
|
|
46
|
+
const held = new Promise((resolve) => {
|
|
47
|
+
release = resolve;
|
|
48
|
+
});
|
|
49
|
+
// CLAIMED SYNCHRONOUSLY, before the first `await`.
|
|
50
|
+
//
|
|
51
|
+
// Registering after awaiting is a check-then-act race: two callers arriving
|
|
52
|
+
// in the same tick both read an empty slot, both decide they hold the lock,
|
|
53
|
+
// and both run. That is the same shape as an `exists?`-then-`create` on the
|
|
54
|
+
// file driver, which is why that one uses exclusive creation instead — and
|
|
55
|
+
// it is not theoretical, it is what the serialisation test caught here.
|
|
56
|
+
//
|
|
57
|
+
// The chain resolves only once the previous holder has finished AND this
|
|
58
|
+
// one has released, so waiters queue in arrival order.
|
|
59
|
+
const chained = previous.then(() => held, () => held);
|
|
60
|
+
this.#locks.set(key, chained);
|
|
61
|
+
// The wait is bounded. A callback that never returns would otherwise hold
|
|
62
|
+
// every later caller forever, and "the lock timed out" is a far better
|
|
63
|
+
// failure than a request that hangs until something upstream gives up.
|
|
64
|
+
const acquired = await Promise.race([
|
|
65
|
+
previous.then(() => true, () => true),
|
|
66
|
+
sleep(waitSeconds * 1000).then(() => false),
|
|
67
|
+
]);
|
|
68
|
+
if (!acquired) {
|
|
69
|
+
// Release before throwing, or everyone queued behind this caller waits on
|
|
70
|
+
// a lock that was never actually taken.
|
|
71
|
+
this.#releaseSlot(key, chained, release);
|
|
72
|
+
throw HarnessError.sessionLocked(key, waitSeconds);
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
return await callback();
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
this.#releaseSlot(key, chained, release);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
#releaseSlot(key, chained, release) {
|
|
82
|
+
release();
|
|
83
|
+
// Only clear the slot if nobody queued behind us. Deleting a slot another
|
|
84
|
+
// caller has already chained onto would let a third caller start while the
|
|
85
|
+
// second is still running.
|
|
86
|
+
if (this.#locks.get(key) === chained) {
|
|
87
|
+
this.#locks.delete(key);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function sleep(ms) {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
const timer = setTimeout(resolve, ms);
|
|
94
|
+
// Do not hold the event loop open just to fail a lock later.
|
|
95
|
+
timer.unref?.();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { JsonObject } from '../json.js';
|
|
2
|
+
/**
|
|
3
|
+
* Whether a store's contents survive a deploy.
|
|
4
|
+
*
|
|
5
|
+
* This is the distinction the whole state layer turns on. An in-memory map or a
|
|
6
|
+
* Redis used as a cache is the natural home for live session state — and a
|
|
7
|
+
* cache is disposable by definition. Something has to say which of the two a
|
|
8
|
+
* configured store actually is, because the package cannot detect it and
|
|
9
|
+
* guessing wrong loses a half-executed agent action rather than a cheap value.
|
|
10
|
+
*/
|
|
11
|
+
export type Durability =
|
|
12
|
+
/**
|
|
13
|
+
* Contents may vanish at any time — a flush, an eviction, a deploy.
|
|
14
|
+
*
|
|
15
|
+
* Only safe for state whose loss degrades to a default: the active mode, the
|
|
16
|
+
* selected model, run bookkeeping. Ask again and you get a sensible answer.
|
|
17
|
+
*/
|
|
18
|
+
'volatile'
|
|
19
|
+
/**
|
|
20
|
+
* Contents survive until deliberately removed.
|
|
21
|
+
*
|
|
22
|
+
* Required for anything whose loss is a correctness failure rather than an
|
|
23
|
+
* inconvenience — a pending tool approval is a half-executed action waiting
|
|
24
|
+
* on a human, and it has to outlive the request, the worker, and a deploy.
|
|
25
|
+
*/
|
|
26
|
+
| 'durable';
|
|
27
|
+
export declare function isDurable(durability: Durability): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Where session state lives between turns.
|
|
30
|
+
*
|
|
31
|
+
* A server handles a request and moves on, so a session cannot be an object
|
|
32
|
+
* held in memory the way a single-process agent's is — it has to be
|
|
33
|
+
* reconstructed from a store every time. This is that store.
|
|
34
|
+
*
|
|
35
|
+
* Implementations declare their OWN durability rather than having it inferred.
|
|
36
|
+
* Only the application knows whether its Redis is persistent or a disposable
|
|
37
|
+
* cache, and the difference decides whether losing the contents is a shrug or a
|
|
38
|
+
* lost agent action.
|
|
39
|
+
*/
|
|
40
|
+
export interface SessionStore {
|
|
41
|
+
/** Null when nothing is stored. */
|
|
42
|
+
get(key: string): Promise<JsonObject | null>;
|
|
43
|
+
/** `ttlSeconds` of null keeps the payload until it is removed. */
|
|
44
|
+
put(key: string, payload: JsonObject, ttlSeconds?: number | null): Promise<void>;
|
|
45
|
+
forget(key: string): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Run the callback while holding an EXCLUSIVE lock on the key.
|
|
48
|
+
*
|
|
49
|
+
* Two workers can resolve the same session at the same moment — a queued job
|
|
50
|
+
* finishing a run while the user sends another message is ordinary, not
|
|
51
|
+
* exotic. Whatever must not happen twice goes in here.
|
|
52
|
+
*
|
|
53
|
+
* Returns the callback's value. Throws `HarnessError` code `session_locked`
|
|
54
|
+
* if the lock cannot be acquired within `waitSeconds`, rather than running
|
|
55
|
+
* the callback anyway.
|
|
56
|
+
*/
|
|
57
|
+
withLock<T>(key: string, callback: () => T | Promise<T>, ttlSeconds?: number, waitSeconds?: number): Promise<T>;
|
|
58
|
+
/**
|
|
59
|
+
* Whether this store's contents survive a deploy.
|
|
60
|
+
*
|
|
61
|
+
* Read by the manager when a slot is resolved: a store that reports itself
|
|
62
|
+
* volatile is refused for durable state instead of silently accepting it.
|
|
63
|
+
*/
|
|
64
|
+
durability(): Durability;
|
|
65
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SessionStore } from './session-store.js';
|
|
2
|
+
export declare const SLOT_EPHEMERAL = "ephemeral";
|
|
3
|
+
export declare const SLOT_DURABLE = "durable";
|
|
4
|
+
export type StoreFactory = () => SessionStore;
|
|
5
|
+
export interface StoreManagerOptions {
|
|
6
|
+
/** Driver name per slot. Both default to whatever is registered as `default`. */
|
|
7
|
+
stores?: Partial<Record<'ephemeral' | 'durable', string>>;
|
|
8
|
+
/** Driver name → factory. Resolved lazily, and each driver is built at most once. */
|
|
9
|
+
drivers: Record<string, StoreFactory>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Resolves the two state slots, and REFUSES a configuration that would lose work.
|
|
13
|
+
*
|
|
14
|
+
* State is split into two named slots rather than one store, because the halves
|
|
15
|
+
* have genuinely different requirements:
|
|
16
|
+
*
|
|
17
|
+
* - `ephemeral` — active mode, selected model, run bookkeeping. Losing it
|
|
18
|
+
* degrades to a default.
|
|
19
|
+
* - `durable` — threads and pending tool approvals. Losing it is a correctness
|
|
20
|
+
* failure, not a cache miss.
|
|
21
|
+
*
|
|
22
|
+
* The guard is the point of this class: a driver that reports itself volatile
|
|
23
|
+
* is refused for the durable slot AT RESOLVE TIME. Accepting it and finding out
|
|
24
|
+
* later is exactly the failure the package was written to avoid.
|
|
25
|
+
*/
|
|
26
|
+
export declare class SessionStoreManager {
|
|
27
|
+
#private;
|
|
28
|
+
constructor(options: StoreManagerOptions);
|
|
29
|
+
ephemeral(): SessionStore;
|
|
30
|
+
durable(): SessionStore;
|
|
31
|
+
slot(slot: string): SessionStore;
|
|
32
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { HarnessError } from '../errors.js';
|
|
2
|
+
import { isDurable } from './session-store.js';
|
|
3
|
+
export const SLOT_EPHEMERAL = 'ephemeral';
|
|
4
|
+
export const SLOT_DURABLE = 'durable';
|
|
5
|
+
/**
|
|
6
|
+
* Resolves the two state slots, and REFUSES a configuration that would lose work.
|
|
7
|
+
*
|
|
8
|
+
* State is split into two named slots rather than one store, because the halves
|
|
9
|
+
* have genuinely different requirements:
|
|
10
|
+
*
|
|
11
|
+
* - `ephemeral` — active mode, selected model, run bookkeeping. Losing it
|
|
12
|
+
* degrades to a default.
|
|
13
|
+
* - `durable` — threads and pending tool approvals. Losing it is a correctness
|
|
14
|
+
* failure, not a cache miss.
|
|
15
|
+
*
|
|
16
|
+
* The guard is the point of this class: a driver that reports itself volatile
|
|
17
|
+
* is refused for the durable slot AT RESOLVE TIME. Accepting it and finding out
|
|
18
|
+
* later is exactly the failure the package was written to avoid.
|
|
19
|
+
*/
|
|
20
|
+
export class SessionStoreManager {
|
|
21
|
+
#drivers;
|
|
22
|
+
#slots;
|
|
23
|
+
#resolved = new Map();
|
|
24
|
+
constructor(options) {
|
|
25
|
+
this.#drivers = options.drivers;
|
|
26
|
+
this.#slots = {
|
|
27
|
+
[SLOT_EPHEMERAL]: options.stores?.ephemeral ?? 'default',
|
|
28
|
+
[SLOT_DURABLE]: options.stores?.durable ?? 'default',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
ephemeral() {
|
|
32
|
+
return this.slot(SLOT_EPHEMERAL);
|
|
33
|
+
}
|
|
34
|
+
durable() {
|
|
35
|
+
return this.slot(SLOT_DURABLE);
|
|
36
|
+
}
|
|
37
|
+
slot(slot) {
|
|
38
|
+
const cached = this.#resolved.get(slot);
|
|
39
|
+
if (cached !== undefined)
|
|
40
|
+
return cached;
|
|
41
|
+
const name = this.#slots[slot] ?? 'default';
|
|
42
|
+
const factory = this.#drivers[name];
|
|
43
|
+
if (factory === undefined) {
|
|
44
|
+
throw HarnessError.unknownStoreDriver(slot, name);
|
|
45
|
+
}
|
|
46
|
+
const store = factory();
|
|
47
|
+
// Checked HERE rather than at construction, so it fires in the same place
|
|
48
|
+
// whether the store is configured up front, swapped in a test, or changed
|
|
49
|
+
// at runtime — and so a misconfiguration cannot lie dormant until the first
|
|
50
|
+
// approval needs saving.
|
|
51
|
+
if (slot === SLOT_DURABLE && !isDurable(store.durability())) {
|
|
52
|
+
throw HarnessError.volatileDurableStore(slot, name);
|
|
53
|
+
}
|
|
54
|
+
this.#resolved.set(slot, store);
|
|
55
|
+
return store;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { PrismHarness } from './harness.js';
|
|
2
|
+
import type { AgentRuntime } from './runtime.js';
|
|
3
|
+
import type { Session } from './session.js';
|
|
4
|
+
import { RunContext, type Subagent } from './subagents.js';
|
|
5
|
+
import type { HarnessTool } from './tools.js';
|
|
6
|
+
export type SubagentOutcome =
|
|
7
|
+
/** It ran and returned. */
|
|
8
|
+
'completed'
|
|
9
|
+
/** The tree had nothing left to spend. */
|
|
10
|
+
| 'exhausted'
|
|
11
|
+
/** The tree was stopped. */
|
|
12
|
+
| 'cancelled'
|
|
13
|
+
/** Refused before it began — depth, most often. */
|
|
14
|
+
| 'denied'
|
|
15
|
+
/** It ran and threw. */
|
|
16
|
+
| 'failed';
|
|
17
|
+
export interface SubagentResult {
|
|
18
|
+
subagent: string;
|
|
19
|
+
runId: string;
|
|
20
|
+
parentRunId: string;
|
|
21
|
+
outcome: SubagentOutcome;
|
|
22
|
+
text: string;
|
|
23
|
+
/** What to hand back to the model as the tool's result. */
|
|
24
|
+
toToolResult(): string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Turns a declared `Subagent` into a TOOL the parent run can call.
|
|
28
|
+
*
|
|
29
|
+
* The parent is mid-run and holding its own session lock when this executes.
|
|
30
|
+
* Everything here is arranged so that fact stays harmless:
|
|
31
|
+
*
|
|
32
|
+
* - the child resolves its OWN session address, so it takes a different lock;
|
|
33
|
+
* - the child's authority comes from its declared mode, never the parent's;
|
|
34
|
+
* - the child's budget is drawn from the tree's remaining allowance;
|
|
35
|
+
* - EVERY way the child can end returns a framed result rather than throwing,
|
|
36
|
+
* because the parent is a legitimate audience for "that did not work" and
|
|
37
|
+
* tearing down the parent run would discard work it had already done.
|
|
38
|
+
*/
|
|
39
|
+
export declare class SubagentRunner {
|
|
40
|
+
private readonly harness;
|
|
41
|
+
private readonly runtime;
|
|
42
|
+
constructor(harness: PrismHarness, runtime: AgentRuntime);
|
|
43
|
+
tool(subagent: Subagent, parent: Session, context: RunContext, parentRunId: string): HarnessTool;
|
|
44
|
+
run(subagent: Subagent, parent: Session, context: RunContext, parentRunId: string, task: string): Promise<SubagentResult>;
|
|
45
|
+
}
|