@particle-academy/prism-harness 0.8.0 → 0.9.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.
@@ -1,9 +1,11 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { mkdirSync } from 'node:fs';
3
- import { mkdir, open, readFile, rename, rm, stat, truncate, unlink, writeFile } from 'node:fs/promises';
3
+ import { mkdir, open, readFile, rename, rm, stat, truncate, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { isJsonObject } from '../json.js';
6
6
  import { HarnessError } from '../errors.js';
7
+ import { withFileOwnerLock, pendingFileOwnerReleases } from './file-owner-lock.js';
8
+ import { fileLockIdentityStatus } from './file-lock-identity.js';
7
9
  /**
8
10
  * State on disk, as one JSON file per key. DURABLE, and it says so.
9
11
  *
@@ -20,12 +22,10 @@ import { HarnessError } from '../errors.js';
20
22
  * partial write here is a corrupted thread, and a process killed mid-write is
21
23
  * ordinary rather than exotic.
22
24
  *
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.
25
+ * **The lock is cross-process.** Decision 0022 uses an atomically published
26
+ * owner directory. TTL never proves death. Local filesystems on one host only;
27
+ * SMB/NFS are unsupported. Stop all old workers before upgrading and inspect
28
+ * legacy timestamp lockfiles explicitly. Use a database or Redis across hosts.
29
29
  */
30
30
  export class FileSessionStore {
31
31
  #directory;
@@ -44,6 +44,11 @@ export class FileSessionStore {
44
44
  durability() {
45
45
  return 'durable';
46
46
  }
47
+ /** Local filesystem on one host; SMB/NFS sharing is unsupported. */
48
+ sharingScope() { return 'host'; }
49
+ lockScope() { return 'host'; }
50
+ /** Process discovery snapshot; this observer never starts metadata discovery. */
51
+ identityStatus() { return fileLockIdentityStatus(); }
47
52
  async get(key) {
48
53
  let raw;
49
54
  try {
@@ -221,144 +226,11 @@ export class FileSessionStore {
221
226
  }
222
227
  }
223
228
  async withLock(key, callback, ttlSeconds = 10, waitSeconds = 5) {
224
- const lockPath = `${this.#pathFor(key)}.lock`;
225
- const deadline = Date.now() + waitSeconds * 1000;
226
- for (;;) {
227
- try {
228
- // `wx` fails if the file exists. Exclusive CREATION is atomic; a
229
- // check-then-create is not, and the window between the two is exactly
230
- // where two workers both decide they hold the lock.
231
- const handle = await open(lockPath, 'wx');
232
- // ONE write, and the terminator goes in it. The newline is what lets a
233
- // reader tell a complete stamp from a torn one, so it must never be a
234
- // second write that could be the half that did not land. See
235
- // `#expired`, and note the format is shared with the other two ports.
236
- await handle.writeFile(stamp(Date.now() + ttlSeconds * 1000), 'utf8');
237
- await handle.close();
238
- try {
239
- return await callback();
240
- }
241
- finally {
242
- await this.#release(lockPath);
243
- }
244
- }
245
- catch (error) {
246
- if (!isHeld(error))
247
- throw error;
248
- // A lock whose TTL has passed belonged to a process that died holding
249
- // it. Left alone it would wedge the key forever, which is worse than
250
- // the small race in reclaiming it — and the reclaim is itself a
251
- // create-exclusive, so only one waiter can win.
252
- if (await this.#expired(lockPath, ttlSeconds)) {
253
- await unlink(lockPath).catch(() => undefined);
254
- continue;
255
- }
256
- if (Date.now() >= deadline) {
257
- throw HarnessError.sessionLocked(key, waitSeconds);
258
- }
259
- await sleep(25);
260
- }
261
- }
229
+ return withFileOwnerLock(this.#pathFor(key) + '.lock', key, callback, ttlSeconds, waitSeconds);
262
230
  }
263
- /**
264
- * Give the lock up, and NEVER leave it held on the way out.
265
- *
266
- * Deleting is the normal path, but on Windows it can fail: another waiter
267
- * attempting to create the same path holds a transient handle, and the unlink
268
- * then raises EPERM. Swallowing that — which is what this did first — LEAKS
269
- * THE LOCK. The waiter then sees a lockfile whose TTL is still in the future,
270
- * has no way to know its holder is gone, and blocks for the whole wait before
271
- * failing. Not theoretical: `prism-harness-py` hit exactly this the first
272
- * time two threads recorded a message concurrently, and this port had the
273
- * same defect with no test that happened to reach it.
274
- *
275
- * So if the file cannot be removed, it is rewritten with an ALREADY-PAST
276
- * expiry. Any waiter reclaims it on its next attempt, which is the same path
277
- * a genuinely dead holder takes.
278
- */
279
- async #release(lockPath) {
280
- for (let attempt = 0; attempt < 5; attempt += 1) {
281
- try {
282
- await unlink(lockPath);
283
- return;
284
- }
285
- catch (error) {
286
- if (isMissing(error))
287
- return;
288
- await sleep(5);
289
- }
290
- }
291
- // Nothing left to try but mark it dead. The TTL is the backstop, and it is
292
- // why the lockfile carries one at all. TERMINATED like any other stamp: an
293
- // unterminated `0` reads as a torn write, and a waiter would then sit on
294
- // the mtime bound for a whole ttl instead of reclaiming it at once — the
295
- // exact delay this path exists to avoid.
296
- await writeFile(lockPath, stamp(0), 'utf8').catch(() => undefined);
297
- }
298
- /**
299
- * Whether this lock belonged to a holder that is gone.
300
- *
301
- * ## An UNFINISHED stamp is HELD, not expired in 1970
302
- *
303
- * Taking the lock is an exclusive create and THEN a write of the expiry —
304
- * two operations, with a moment in between where the file exists and is
305
- * incomplete. There are two ways to read it in that moment and both used to
306
- * steal the lock:
307
- *
308
- * - **Empty.** `Number('')` is `0`, which is finite and certainly
309
- * `<= Date.now()`.
310
- * - **Torn.** A partial write leaves a PREFIX of the timestamp —
311
- * `17356899` where `1735689900000` was meant — and every prefix of a
312
- * number is a SMALLER number. It parses, it is finite, and it is decades
313
- * in the past. This one is worse than the empty case precisely because it
314
- * does not look broken.
315
- *
316
- * Either way a waiter concluded the holder was long dead, deleted the lock,
317
- * and took one somebody was actively holding. Two callers, one key: the
318
- * failure this lock exists to prevent, produced BY the mechanism meant to
319
- * recover from a dead holder. The empty case surfaced as a flaky test — about
320
- * one run in eight — which is how it survived; the torn case would not have
321
- * surfaced at all.
322
- *
323
- * The fix is the TERMINATOR. A stamp is digits followed by a newline, written
324
- * in one write, so anything else is an unfinished write rather than a small
325
- * number. Unreadable then means WAIT — `session_locked` after the wait
326
- * period, which is loud and recoverable — instead of deleting a live holder's
327
- * lock. The format is shared with the PHP and Python ports, because a worker
328
- * in any of the three may be pointed at the same store directory.
329
- *
330
- * ## …but only until it is older than the ttl
331
- *
332
- * Treating an empty lockfile as held forever would let a process that died
333
- * inside that window wedge the key permanently, which is the worse failure
334
- * of the two. The file's own mtime bounds it: the window is microseconds in a
335
- * live process, so a file still empty a whole ttl later belonged to one that
336
- * did not survive it. Nothing extra has to be written for that to work.
337
- */
338
- async #expired(lockPath, ttlSeconds) {
339
- let raw;
340
- try {
341
- raw = await readFile(lockPath, 'utf8');
342
- }
343
- catch {
344
- // Gone between the failed create and this read: treat it as not expired
345
- // and let the next attempt take it cleanly.
346
- return false;
347
- }
348
- // Digits AND a terminator, or this is not a finished stamp. Checked as a
349
- // whole rather than by trying to parse and hoping a bad value fails to:
350
- // the torn case parses perfectly and is the reason this check exists.
351
- if (!STAMP.test(raw)) {
352
- try {
353
- const { mtimeMs } = await stat(lockPath);
354
- return mtimeMs + ttlSeconds * 1000 <= Date.now();
355
- }
356
- catch {
357
- return false;
358
- }
359
- }
360
- const expiresAt = Number(raw.slice(0, -1));
361
- return Number.isFinite(expiresAt) && expiresAt <= Date.now();
231
+ /** Pending release diagnostics for this directory; length is the pending count. */
232
+ pendingReleases() {
233
+ return pendingFileOwnerReleases(this.#directory);
362
234
  }
363
235
  /**
364
236
  * One file per key, named by a digest.
@@ -376,49 +248,10 @@ export class FileSessionStore {
376
248
  return join(this.#directory, `${digestOf(key)}.jsonl`);
377
249
  }
378
250
  }
379
- /**
380
- * A finished lock stamp: digits, then a newline. NOTHING ELSE.
381
- *
382
- * The terminator is the only thing that distinguishes a complete timestamp from
383
- * a torn one, because a prefix of a timestamp is a perfectly valid smaller
384
- * timestamp. Shared with the PHP and Python ports — a worker in any of the
385
- * three may be pointed at the same store directory, and one that does not
386
- * emit the newline has its locks waited out rather than reclaimed everywhere
387
- * else.
388
- */
389
- const STAMP = /^\d+\n$/;
390
251
  /** A key's file name. Colons are illegal on Windows and keys run long, so it is digested. */
391
252
  function digestOf(key) {
392
253
  return createHash('sha256').update(key).digest('hex').slice(0, 32);
393
254
  }
394
- function stamp(expiresAt) {
395
- return `${String(Math.trunc(expiresAt))}\n`;
396
- }
397
255
  function isMissing(error) {
398
256
  return typeof error === 'object' && error !== null && error.code === 'ENOENT';
399
257
  }
400
- /**
401
- * "The lock is already held" — under four different names.
402
- *
403
- * POSIX says EEXIST. WINDOWS DOES NOT, reliably: deleting a file that another
404
- * handle still has open leaves it in a pending-delete state, and a create
405
- * attempt against that returns EPERM, EACCES or EBUSY instead. That window is
406
- * not rare — it is exactly the moment one caller releases the lock while the
407
- * next is trying to take it, which is the common case under contention rather
408
- * than an edge.
409
- *
410
- * Treating those as "held, retry" is also the safe direction to be wrong in: a
411
- * genuine permission problem retries until the wait expires and then reports
412
- * `session_locked`, which is a survivable failure. Treating them as "not held"
413
- * would hand the same lock to two callers.
414
- */
415
- function isHeld(error) {
416
- const code = typeof error === 'object' && error !== null ? error.code : undefined;
417
- return code === 'EEXIST' || code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
418
- }
419
- function sleep(ms) {
420
- return new Promise((resolve) => {
421
- const timer = setTimeout(resolve, ms);
422
- timer.unref?.();
423
- });
424
- }
@@ -15,6 +15,8 @@ import type { Durability, SessionStore } from './session-store.js';
15
15
  export declare class MemorySessionStore implements SessionStore {
16
16
  #private;
17
17
  durability(): Durability;
18
+ sharingScope(): 'process';
19
+ lockScope(): 'process' | 'host' | 'distributed' | 'unsupported';
18
20
  get(key: string): Promise<JsonObject | null>;
19
21
  put(key: string, payload: JsonObject, ttlSeconds?: number | null): Promise<void>;
20
22
  forget(key: string): Promise<void>;
@@ -19,6 +19,8 @@ export class MemorySessionStore {
19
19
  durability() {
20
20
  return 'volatile';
21
21
  }
22
+ sharingScope() { return 'process'; }
23
+ lockScope() { return 'process'; }
22
24
  async get(key) {
23
25
  const entry = this.#entries.get(key);
24
26
  if (entry === undefined) {
@@ -25,6 +25,7 @@ export type Durability =
25
25
  */
26
26
  | 'durable';
27
27
  export declare function isDurable(durability: Durability): boolean;
28
+ export type LockScope = 'process' | 'host' | 'distributed' | 'unsupported';
28
29
  /**
29
30
  * Where session state lives between turns.
30
31
  *
@@ -38,6 +39,9 @@ export declare function isDurable(durability: Durability): boolean;
38
39
  * lost agent action.
39
40
  */
40
41
  export interface SessionStore {
42
+ /** Required to opt into indexed namespaces; absent declarations are refused there. */
43
+ lockScope?(): LockScope;
44
+ sharingScope?(): LockScope;
41
45
  /** Null when nothing is stored. */
42
46
  get(key: string): Promise<JsonObject | null>;
43
47
  /** `ttlSeconds` of null keeps the payload until it is removed. */
@@ -1,4 +1,5 @@
1
1
  import { MAX_DEPTH } from './subagents.js';
2
+ import { HarnessError } from './errors.js';
2
3
  /**
3
4
  * Turns a declared `Subagent` into a TOOL the parent run can call.
4
5
  *
@@ -24,6 +25,9 @@ export class SubagentRunner {
24
25
  name: subagent.name,
25
26
  description: subagent.description,
26
27
  handle: async (args) => {
28
+ if (parent.agent !== null && Object.keys(args).some(key => key !== 'task')) {
29
+ throw HarnessError.runNotPermitted('Named subagent input may contain only task. Identity changes require an explicit host-owned delegation binding.');
30
+ }
27
31
  const task = typeof args.task === 'string' ? args.task : '';
28
32
  return (await this.run(subagent, parent, context, parentRunId, task)).toToolResult();
29
33
  },
@@ -47,12 +51,15 @@ export class SubagentRunner {
47
51
  return refused(subagent.name, runId, parentRunId, 'denied', `subagent nesting reached the maximum depth of ${MAX_DEPTH}`);
48
52
  }
49
53
  const child = this.harness
50
- .for(parent.participant)
54
+ .pending(parent.participant, parent.agent, parent.actor)
51
55
  .session(subagent.scopeUnder(parent.scope));
52
56
  // The child's authority comes from ITS mode, not the parent's.
53
57
  await child.usingMode(subagent.mode);
54
- await child.usingProvider((await parent.provider()) ?? 'unknown');
55
- await child.usingModel((await parent.model()) ?? 'unknown');
58
+ const provider = await parent.provider(), model = await parent.model();
59
+ if (parent.agent === null || provider !== null)
60
+ await child.usingProvider(provider ?? 'unknown');
61
+ if (parent.agent === null || model !== null)
62
+ await child.usingModel(model ?? 'unknown');
56
63
  try {
57
64
  const response = await this.runtime.send(child, task, undefined, childContext);
58
65
  return {
@@ -25,7 +25,8 @@ export declare class RunBudget {
25
25
  readonly maxSteps: number;
26
26
  readonly maxCostUsd: number | null;
27
27
  readonly maxSeconds: number | null;
28
- constructor(maxSteps: number, maxCostUsd?: number | null, maxSeconds?: number | null);
28
+ readonly maxOutputTokensPerCall: number | null;
29
+ constructor(maxSteps: number, maxCostUsd?: number | null, maxSeconds?: number | null, maxOutputTokensPerCall?: number | null);
29
30
  static fromConfig(config: Record<string, unknown>, defaultSteps?: number): RunBudget;
30
31
  /**
31
32
  * The budget a CHILD actually gets, as a TREE-ABSOLUTE ceiling.
package/dist/subagents.js CHANGED
@@ -25,13 +25,20 @@ export class RunBudget {
25
25
  maxSteps;
26
26
  maxCostUsd;
27
27
  maxSeconds;
28
- constructor(maxSteps, maxCostUsd = null, maxSeconds = null) {
28
+ maxOutputTokensPerCall;
29
+ constructor(maxSteps, maxCostUsd = null, maxSeconds = null, maxOutputTokensPerCall = null) {
29
30
  this.maxSteps = maxSteps;
30
31
  this.maxCostUsd = maxCostUsd;
31
32
  this.maxSeconds = maxSeconds;
33
+ this.maxOutputTokensPerCall = maxOutputTokensPerCall;
34
+ if (maxOutputTokensPerCall !== null && (!Number.isSafeInteger(maxOutputTokensPerCall) || maxOutputTokensPerCall < 1))
35
+ throw new TypeError('Output ceilings must be positive integers or null.');
32
36
  }
33
37
  static fromConfig(config, defaultSteps = 8) {
34
- return new RunBudget(numberOr(config.max_steps, defaultSteps), numberOrNull(config.max_cost_usd), numberOrNull(config.max_seconds));
38
+ const output = config.max_output_tokens_per_call;
39
+ if (output !== undefined && output !== null && (typeof output !== 'number' || !Number.isSafeInteger(output) || output < 1))
40
+ throw new TypeError('Output ceilings must be positive integers or null.');
41
+ return new RunBudget(numberOr(config.max_steps, defaultSteps), numberOrNull(config.max_cost_usd), numberOrNull(config.max_seconds), output ?? null);
35
42
  }
36
43
  /**
37
44
  * The budget a CHILD actually gets, as a TREE-ABSOLUTE ceiling.
@@ -61,7 +68,7 @@ export class RunBudget {
61
68
  * should adopt.
62
69
  */
63
70
  nestedWithin(parent, ledger) {
64
- return new RunBudget(Math.min(parent.maxSteps, ledger.steps + this.maxSteps), lesser(parent.maxCostUsd, this.maxCostUsd === null ? null : ledger.costUsd + this.maxCostUsd), lesser(parent.maxSeconds, this.maxSeconds === null ? null : Math.trunc(ledger.elapsedSeconds()) + this.maxSeconds));
71
+ return new RunBudget(Math.min(parent.maxSteps, ledger.steps + this.maxSteps), lesser(parent.maxCostUsd, this.maxCostUsd === null ? null : ledger.costUsd + this.maxCostUsd), lesser(parent.maxSeconds, this.maxSeconds === null ? null : Math.trunc(ledger.elapsedSeconds()) + this.maxSeconds), lesser(parent.maxOutputTokensPerCall, this.maxOutputTokensPerCall));
65
72
  }
66
73
  }
67
74
  /**
@@ -103,7 +103,12 @@ export function agentCompletionTool(options) {
103
103
  return { id: task.id(), state: outcome };
104
104
  },
105
105
  };
106
- return (session) => authorizedTool(tool, session, authorizer);
106
+ return (session) => {
107
+ const scoped = session.agent === null ? authorizer : authorizer.forAgent(session.agent);
108
+ if (session.agent !== null && (!scoped.enabled || !scoped.hasCallPolicy()))
109
+ throw HarnessError.completionToolWithoutAuthorizer(name);
110
+ return authorizedTool(tool, session, scoped);
111
+ };
107
112
  }
108
113
  /**
109
114
  * A holder that MATCHES NO WORKER, for a task whose holder cannot be read.
package/dist/thread.d.ts CHANGED
@@ -29,6 +29,8 @@ export declare class Thread {
29
29
  private readonly store;
30
30
  private readonly key;
31
31
  constructor(store: SessionStore, key: string);
32
+ /** Existence probe only: never starts a log, migrates data or acquires a lock. */
33
+ exists(): Promise<boolean>;
32
34
  messages(): Promise<ThreadMessage[]>;
33
35
  /** How many messages the thread holds. */
34
36
  count(): Promise<number>;
package/dist/thread.js CHANGED
@@ -23,6 +23,12 @@ export class Thread {
23
23
  this.store = store;
24
24
  this.key = key;
25
25
  }
26
+ /** Existence probe only: never starts a log, migrates data or acquires a lock. */
27
+ async exists() {
28
+ if (supportsAppendLog(this.store) && await this.store.logLength(this.key) !== null)
29
+ return true;
30
+ return await this.store.get(this.key) !== null;
31
+ }
26
32
  async messages() {
27
33
  if (supportsAppendLog(this.store)) {
28
34
  const log = await this.store.readLog(this.key);
package/dist/tools.d.ts CHANGED
@@ -23,12 +23,28 @@ export type ToolProvider = (session: Session) => Iterable<HarnessTool> | Promise
23
23
  * (`registerFactory`), and a set discovered at resolve time — an MCP server's
24
24
  * catalogue, say (`registerProvider`).
25
25
  */
26
+ export interface ResolvedTool {
27
+ tool: HarnessTool;
28
+ owner: string;
29
+ source: 'static' | 'factory' | 'provider';
30
+ }
31
+ export interface RegistryOptions {
32
+ refuseDuplicates?: boolean;
33
+ }
26
34
  export declare class ToolRegistry {
27
35
  #private;
28
- register(tool: HarnessTool): this;
29
- registerMany(tools: Iterable<HarnessTool>): this;
30
- registerFactory(name: string, factory: ToolFactory): this;
31
- registerProvider(provider: ToolProvider): this;
36
+ private readonly namespace;
37
+ private readonly root?;
38
+ constructor(options?: RegistryOptions | string | null, root?: ToolRegistry);
39
+ /** Immutable namespace views share registrations, never a current-agent flag. */
40
+ forAgent(agent: string | null): ToolRegistry;
41
+ register(tool: HarnessTool, owner?: string): this;
42
+ registerMany(tools: Iterable<HarnessTool>, owner?: string): this;
43
+ registerFactory(name: string, factory: ToolFactory, owner?: string): this;
44
+ registerProvider(provider: ToolProvider, owner?: string, name?: string): this;
45
+ replace(tool: HarnessTool, predecessorOwner: string, owner?: string): this;
46
+ replaceFactory(name: string, factory: ToolFactory, predecessorOwner: string, owner?: string): this;
47
+ replaceProvider(name: string, provider: ToolProvider, predecessorOwner: string, owner?: string): this;
32
48
  /** Every name this registry can produce without a session. */
33
49
  names(): string[];
34
50
  /**
@@ -40,12 +56,15 @@ export declare class ToolRegistry {
40
56
  * run wondering why the model never called it.
41
57
  */
42
58
  resolve(names: readonly string[], session?: Session): Promise<Map<string, HarnessTool>>;
59
+ /** The offer path and inspection path consume the same ownership result. */
60
+ resolveDetailed(names: readonly string[], session?: Session): Promise<Map<string, ResolvedTool>>;
43
61
  }
44
62
  /** Whether a tool may be OFFERED to a run. */
45
63
  export type OfferPolicy = (session: Session, tool: HarnessTool) => boolean | Promise<boolean>;
46
64
  /** Whether THIS call, with THESE arguments, may proceed. */
47
65
  export type CallPolicy = (session: Session, tool: HarnessTool, args: JsonObject) => boolean | Promise<boolean>;
48
66
  export interface ToolAuthorizerOptions {
67
+ agents?: Record<string, ToolAuthorizerOptions>;
49
68
  enabled?: boolean;
50
69
  offer?: OfferPolicy;
51
70
  call?: CallPolicy;
@@ -65,8 +84,12 @@ export interface ToolAuthorizerOptions {
65
84
  */
66
85
  export declare class ToolAuthorizer {
67
86
  #private;
87
+ private readonly namespace;
88
+ private readonly root?;
68
89
  readonly enabled: boolean;
69
- constructor(options?: ToolAuthorizerOptions);
90
+ constructor(options?: ToolAuthorizerOptions, namespace?: string | null, root?: ToolAuthorizer | undefined);
91
+ forAgent(agent: string | null): ToolAuthorizer;
92
+ hasCallPolicy(): boolean;
70
93
  /** The tools a run may be offered, each wrapped so the call policy is asked again. */
71
94
  allowed(session: Session, tools: Map<string, HarnessTool>): Promise<HarnessTool[]>;
72
95
  /**