@noetic-tools/core 5.7.0 → 5.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.
Files changed (63) hide show
  1. package/dist/adapters/openrouter.d.ts +1 -0
  2. package/dist/adapters/openrouter.js +1 -0
  3. package/dist/builders/channel-builder.d.ts +18 -0
  4. package/dist/builders/control-flow-builders.d.ts +17 -30
  5. package/dist/builders/decide-builders.d.ts +5 -5
  6. package/dist/builders/decide-builders.js +4 -0
  7. package/dist/builders/every.d.ts +2 -2
  8. package/dist/builders/every.js +2 -0
  9. package/dist/builders/loop-builder.d.ts +1 -1
  10. package/dist/builders/loop-builder.js +1 -1
  11. package/dist/builders/provide-builder.d.ts +3 -2
  12. package/dist/builders/provide-builder.js +3 -0
  13. package/dist/builders/spawn-builder.d.ts +2 -3
  14. package/dist/builders/spawn-builder.js +2 -1
  15. package/dist/builders/step-builders.d.ts +8 -9
  16. package/dist/builders/step-builders.js +0 -1
  17. package/dist/builders/tool-builder.d.ts +2 -0
  18. package/dist/builders/tool-builder.js +2 -0
  19. package/dist/builders/workflow-hydrator.js +21 -14
  20. package/dist/builders/workflow-replay-key.d.ts +15 -0
  21. package/dist/builders/workflow-replay-key.js +46 -0
  22. package/dist/harness/agent-harness.d.ts +43 -11
  23. package/dist/harness/agent-harness.js +121 -40
  24. package/dist/harness/deps/interpreter.d.ts +1 -0
  25. package/dist/harness/deps/interpreter.js +1 -0
  26. package/dist/harness/deps/runtime.d.ts +3 -2
  27. package/dist/harness/deps/runtime.js +2 -2
  28. package/dist/harness/model-call.d.ts +3 -0
  29. package/dist/harness/model-call.js +19 -5
  30. package/dist/index.d.ts +7 -5
  31. package/dist/index.js +3 -3
  32. package/dist/interpreter/action-deps.d.ts +1 -1
  33. package/dist/interpreter/action-deps.js +1 -1
  34. package/dist/interpreter/child-span.d.ts +17 -0
  35. package/dist/interpreter/child-span.js +51 -0
  36. package/dist/interpreter/collect-tools.js +19 -54
  37. package/dist/interpreter/execute-action.js +30 -17
  38. package/dist/interpreter/execute-control.js +67 -9
  39. package/dist/interpreter/execute-decide.js +9 -27
  40. package/dist/interpreter/execute.d.ts +10 -0
  41. package/dist/interpreter/execute.js +69 -46
  42. package/dist/interpreter/model-error.d.ts +9 -0
  43. package/dist/interpreter/model-error.js +32 -0
  44. package/dist/interpreter/step-walk.d.ts +11 -0
  45. package/dist/interpreter/step-walk.js +41 -0
  46. package/dist/observability/genai-attributes.d.ts +52 -29
  47. package/dist/observability/genai-attributes.js +27 -1
  48. package/dist/runtime/channel-store.d.ts +73 -1
  49. package/dist/runtime/channel-store.js +179 -63
  50. package/dist/runtime/context-impl.d.ts +36 -32
  51. package/dist/runtime/context-impl.js +80 -45
  52. package/dist/runtime/durable/harness-checkpoints.d.ts +33 -16
  53. package/dist/runtime/durable/harness-checkpoints.js +74 -52
  54. package/dist/runtime/durable/step-ledger.d.ts +15 -5
  55. package/dist/runtime/durable/step-ledger.js +23 -6
  56. package/dist/schemas/workflow.d.ts +1 -1
  57. package/dist/types/checkpoint.d.ts +31 -0
  58. package/dist/types/checkpoint.js +29 -0
  59. package/dist/until/combinators.d.ts +10 -0
  60. package/dist/until/combinators.js +37 -13
  61. package/dist/until/predicates.js +4 -2
  62. package/package.json +3 -3
  63. package/schema/noetic-workflow.schema.json +432 -0
@@ -1,7 +1,20 @@
1
- import { frameworkCast, NoeticErrorImpl } from '@noetic-tools/types';
2
- const MAX_TOPIC_TIMEOUT = 300_000; // 5 minutes
1
+ import { frameworkCast, NoeticConfigError, NoeticErrorImpl } from '@noetic-tools/types';
3
2
  const DEFAULT_SEND_TIMEOUT = 30_000;
4
3
  const RESOLVED = Promise.resolve();
4
+ /**
5
+ * Reject a negative (or NaN) channel timeout. `0` is legal and disables the
6
+ * timeout; anything below it is a caller bug, not a shorter wait.
7
+ */
8
+ function invalidTimeout(channelName, timeout) {
9
+ if (timeout >= 0) {
10
+ return null;
11
+ }
12
+ return new NoeticConfigError({
13
+ code: 'INVALID_CHANNEL_TIMEOUT',
14
+ message: `Channel '${channelName}': timeout must be a non-negative number of milliseconds, got ${timeout}.`,
15
+ hint: 'Pass a positive timeout in milliseconds, or 0 to wait with no timeout.',
16
+ });
17
+ }
5
18
  /** Build the `cancelled` error a blocked channel operation rejects with on abort. */
6
19
  function cancelledError(signal) {
7
20
  return new NoeticErrorImpl({
@@ -9,6 +22,10 @@ function cancelledError(signal) {
9
22
  reason: typeof signal.reason === 'string' ? signal.reason : undefined,
10
23
  });
11
24
  }
25
+ /** Print a channel warning with the `[noetic]` prefix. */
26
+ export function warnToConsole(warning) {
27
+ console.warn(`[noetic] ${warning.message}`);
28
+ }
12
29
  export class ChannelStore {
13
30
  channels = new Map();
14
31
  closedExecutions = new Set();
@@ -21,25 +38,87 @@ export class ChannelStore {
21
38
  */
22
39
  handleObserved = new Set();
23
40
  subscribersByExecution = new Map();
41
+ /**
42
+ * Channel name → ids of the root executions that used it, which decides
43
+ * the channels an execution's checkpoint carries. Ids stay after their
44
+ * execution closes: a host may checkpoint a finished run, and channel
45
+ * state itself lives for the harness lifetime (spec 06, Lifecycle).
46
+ */
47
+ users = new Map();
48
+ warn;
49
+ constructor(opts = {}) {
50
+ this.warn = opts.onWarning ?? warnToConsole;
51
+ }
24
52
  getOrCreate(channel) {
25
- let state = frameworkCast(this.channels.get(channel.name));
26
- if (!state) {
27
- state = {
28
- mode: channel.mode,
29
- hasValue: false,
30
- valueWaiters: [],
31
- queue: [],
32
- capacity: channel.capacity ?? 1_000,
33
- queueWaiters: [],
34
- pendingSenders: [],
35
- topicSubscribers: new Set(),
36
- wakeSubscribers: new Set(),
37
- externalSubscribers: new Set(),
38
- };
39
- this.channels.set(channel.name, frameworkCast(state));
53
+ const existing = frameworkCast(this.channels.get(channel.name));
54
+ if (existing) {
55
+ return existing;
40
56
  }
57
+ const state = emptyState(channel.mode, channel.capacity ?? 1_000);
58
+ this.channels.set(channel.name, frameworkCast(state));
41
59
  return state;
42
60
  }
61
+ /**
62
+ * Record that `executionId` (a root execution) uses `channel`, so the
63
+ * channel rides along in its checkpoints.
64
+ */
65
+ claim(channel, executionId) {
66
+ this.addUser(channel.name, executionId);
67
+ }
68
+ addUser(channelName, executionId) {
69
+ let ids = this.users.get(channelName);
70
+ if (!ids) {
71
+ ids = new Set();
72
+ this.users.set(channelName, ids);
73
+ }
74
+ ids.add(executionId);
75
+ }
76
+ /**
77
+ * Stored state (values, queued items) of every channel `executionId` used.
78
+ * Parked receivers, parked senders, and subscribers are live promises and
79
+ * stay out; topic channels store nothing and are skipped.
80
+ */
81
+ snapshot(executionId) {
82
+ const out = [];
83
+ for (const [name, ids] of this.users) {
84
+ const state = this.channels.get(name);
85
+ if (!state || !ids.has(executionId)) {
86
+ continue;
87
+ }
88
+ if (state.mode === 'value') {
89
+ out.push({
90
+ name,
91
+ mode: 'value',
92
+ value: state.currentValue,
93
+ hasValue: state.hasValue,
94
+ });
95
+ }
96
+ if (state.mode === 'queue') {
97
+ out.push({
98
+ name,
99
+ mode: 'queue',
100
+ capacity: state.capacity,
101
+ queue: [
102
+ ...state.queue,
103
+ ],
104
+ });
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+ /**
110
+ * Refill channel state from a checkpoint and mark `executionId` as a user.
111
+ * A channel that already has live state keeps it — live state is never
112
+ * older than a snapshot.
113
+ */
114
+ restore(executionId, snapshots) {
115
+ for (const snap of snapshots) {
116
+ if (!this.channels.has(snap.name)) {
117
+ this.channels.set(snap.name, restoredState(snap));
118
+ }
119
+ this.addUser(snap.name, executionId);
120
+ }
121
+ }
43
122
  /**
44
123
  * Subscribe to wake notifications on a channel without consuming any value.
45
124
  * Fired once on the next `send()` for any mode, then auto-removed. Returns
@@ -70,8 +149,16 @@ export class ChannelStore {
70
149
  * - **topic**: deliver to current subscribers, resolve immediately.
71
150
  *
72
151
  * External callers use `ChannelHandle.send` (sync, drop-oldest) instead.
152
+ * `opts.timeout: 0` parks with no timeout and warns `unbounded_wait`.
153
+ *
154
+ * @throws `NoeticConfigError` with code `INVALID_CHANNEL_TIMEOUT` (as a
155
+ * rejection) when `opts.timeout` is negative.
73
156
  */
74
157
  send(channel, value, opts) {
158
+ const invalid = invalidTimeout(channel.name, opts?.timeout ?? DEFAULT_SEND_TIMEOUT);
159
+ if (invalid) {
160
+ return Promise.reject(invalid);
161
+ }
75
162
  const state = this.getOrCreate(channel);
76
163
  let result = RESOLVED;
77
164
  switch (state.mode) {
@@ -106,8 +193,8 @@ export class ChannelStore {
106
193
  }
107
194
  break;
108
195
  case 'topic':
109
- for (const sub of state.topicSubscribers) {
110
- sub(value);
196
+ for (const waiter of state.topicWaiters.splice(0)) {
197
+ waiter.resolve(value);
111
198
  }
112
199
  for (const sub of state.externalSubscribers) {
113
200
  sub.push(value);
@@ -153,7 +240,15 @@ export class ChannelStore {
153
240
  state.pendingSenders.splice(idx, 1);
154
241
  return true;
155
242
  };
156
- if (timeout > 0) {
243
+ if (timeout === 0) {
244
+ this.warn({
245
+ kind: 'unbounded_wait',
246
+ channelName,
247
+ operation: 'send',
248
+ message: `Channel '${channelName}': send parked on a full queue with timeout 0 — it waits until a consumer frees a slot or the context aborts.`,
249
+ });
250
+ }
251
+ else {
157
252
  timer = setTimeout(() => {
158
253
  if (removeEntry()) {
159
254
  entry.reject(new NoeticErrorImpl({
@@ -208,7 +303,20 @@ export class ChannelStore {
208
303
  wake();
209
304
  }
210
305
  }
306
+ /**
307
+ * Receive by mode: value returns the current value, queue dequeues the
308
+ * head, topic waits for the next send. A receiver that has to wait gets
309
+ * `channel_timeout` after `timeout` ms; `0` waits with no timeout in every
310
+ * mode and warns `unbounded_wait`.
311
+ *
312
+ * @throws `NoeticConfigError` with code `INVALID_CHANNEL_TIMEOUT` (as a
313
+ * rejection) when `timeout` is negative.
314
+ */
211
315
  async recv(channel, timeout = 30_000, signal) {
316
+ const invalid = invalidTimeout(channel.name, timeout);
317
+ if (invalid) {
318
+ throw invalid;
319
+ }
212
320
  // An already-aborted context must reject promptly rather than park a
213
321
  // waiter that nothing will ever wake (spec 09, Cancellation item 2).
214
322
  if (signal?.aborted) {
@@ -235,46 +343,8 @@ export class ChannelStore {
235
343
  }
236
344
  return this.waitWithTimeout(state.queueWaiters, channel.name, timeout, signal);
237
345
  }
238
- case 'topic': {
239
- // Clamp timeout to prevent indefinite subscriber leaks
240
- let effectiveTimeout = timeout;
241
- if (effectiveTimeout <= 0) {
242
- console.warn(`[noetic] Channel '${channel.name}': topic recv with non-positive timeout, clamping to ${MAX_TOPIC_TIMEOUT}ms`);
243
- effectiveTimeout = MAX_TOPIC_TIMEOUT;
244
- }
245
- return new Promise((resolve, reject) => {
246
- let removeAbortListener = null;
247
- const timer = setTimeout(() => {
248
- state.topicSubscribers.delete(handler);
249
- removeAbortListener?.();
250
- reject(new NoeticErrorImpl({
251
- kind: 'channel_timeout',
252
- channelName: channel.name,
253
- timeout: effectiveTimeout,
254
- }));
255
- }, effectiveTimeout);
256
- const handler = (value) => {
257
- clearTimeout(timer);
258
- state.topicSubscribers.delete(handler);
259
- removeAbortListener?.();
260
- resolve(value);
261
- };
262
- state.topicSubscribers.add(handler);
263
- if (signal) {
264
- const onAbort = () => {
265
- clearTimeout(timer);
266
- state.topicSubscribers.delete(handler);
267
- reject(cancelledError(signal));
268
- };
269
- signal.addEventListener('abort', onAbort, {
270
- once: true,
271
- });
272
- removeAbortListener = () => {
273
- signal.removeEventListener('abort', onAbort);
274
- };
275
- }
276
- });
277
- }
346
+ case 'topic':
347
+ return this.waitWithTimeout(state.topicWaiters, channel.name, timeout, signal);
278
348
  }
279
349
  }
280
350
  tryRecv(channel) {
@@ -325,7 +395,15 @@ export class ChannelStore {
325
395
  waiters.splice(idx, 1);
326
396
  return true;
327
397
  };
328
- if (timeout > 0) {
398
+ if (timeout === 0) {
399
+ this.warn({
400
+ kind: 'unbounded_wait',
401
+ channelName,
402
+ operation: 'recv',
403
+ message: `Channel '${channelName}': recv waiting with timeout 0 — it waits until a value arrives or the context aborts.`,
404
+ });
405
+ }
406
+ else {
329
407
  timer = setTimeout(() => {
330
408
  if (removeEntry()) {
331
409
  wrappedReject(new NoeticErrorImpl({
@@ -362,7 +440,12 @@ export class ChannelStore {
362
440
  state.queueWaiters.length === 0 &&
363
441
  state.queue.length >= state.capacity;
364
442
  if (isFullQueue) {
365
- console.warn(`[noetic] Channel '${channel.name}': queue at capacity (${state.capacity}), dropping oldest message (external sender).`);
443
+ this.warn({
444
+ kind: 'overflow_drop',
445
+ channelName: channel.name,
446
+ capacity: state.capacity,
447
+ message: `Channel '${channel.name}': queue at capacity (${state.capacity}), dropping oldest message (external sender).`,
448
+ });
366
449
  state.queue.shift();
367
450
  state.queue.push(value);
368
451
  this.fireWakes(state);
@@ -391,10 +474,12 @@ export class ChannelStore {
391
474
  if (this.closedExecutions.has(executionId)) {
392
475
  return CLOSED_ITERABLE;
393
476
  }
477
+ this.claim(channel, executionId);
394
478
  const state = this.getOrCreate(channel);
395
479
  const subscriber = new ExternalSubscriber({
396
480
  channelName: channel.name,
397
481
  conflate: state.mode === 'value',
482
+ warn: this.warn,
398
483
  takeShared: state.mode === 'queue'
399
484
  ? () => {
400
485
  const head = this.dequeueHead(state);
@@ -462,6 +547,7 @@ export class ChannelStore {
462
547
  }
463
548
  getHandle(channel, executionId) {
464
549
  this.handleObserved.add(executionId);
550
+ this.claim(channel, executionId);
465
551
  const store = this;
466
552
  return {
467
553
  get closed() {
@@ -506,6 +592,31 @@ export class ChannelStore {
506
592
  this.closedExecutions.delete(executionId);
507
593
  }
508
594
  }
595
+ function emptyState(mode, capacity) {
596
+ return {
597
+ mode,
598
+ hasValue: false,
599
+ valueWaiters: [],
600
+ queue: [],
601
+ capacity,
602
+ queueWaiters: [],
603
+ pendingSenders: [],
604
+ topicWaiters: [],
605
+ wakeSubscribers: new Set(),
606
+ externalSubscribers: new Set(),
607
+ };
608
+ }
609
+ function restoredState(snap) {
610
+ if (snap.mode === 'value') {
611
+ const state = emptyState('value', 1_000);
612
+ state.currentValue = snap.value;
613
+ state.hasValue = snap.hasValue;
614
+ return state;
615
+ }
616
+ const state = emptyState('queue', snap.capacity);
617
+ state.queue.push(...snap.queue);
618
+ return state;
619
+ }
509
620
  const MAX_EXTERNAL_BUFFER = 1_000;
510
621
  const CLOSED_ITERABLE = {
511
622
  [Symbol.asyncIterator]() {
@@ -559,7 +670,12 @@ class ExternalSubscriber {
559
670
  return;
560
671
  }
561
672
  if (this.buffer.length >= MAX_EXTERNAL_BUFFER) {
562
- console.warn(`[noetic] Channel '${this.opts.channelName}': external subscriber buffer at capacity (${MAX_EXTERNAL_BUFFER}), dropping oldest value.`);
673
+ this.opts.warn({
674
+ kind: 'subscriber_drop',
675
+ channelName: this.opts.channelName,
676
+ capacity: MAX_EXTERNAL_BUFFER,
677
+ message: `Channel '${this.opts.channelName}': external subscriber buffer at capacity (${MAX_EXTERNAL_BUFFER}), dropping oldest value.`,
678
+ });
563
679
  this.buffer.shift();
564
680
  }
565
681
  this.buffer.push(value);
@@ -1,5 +1,11 @@
1
1
  import type { AgentHarnessContract, Channel, ChannelStore, Context, ContextData, ContextLayer, CwdState, EventBroadcaster, FrontierFrame, FsAdapter, Item, ItemLog, ItemSchemaRegistry, LastLayerUsage, Span, StepMeta, TokenUsage, Tool } from './context-deps.js';
2
2
  import type { StepLedger } from './durable/step-ledger.js';
3
+ /**
4
+ * @internal
5
+ * Id of the root execution `ctx` runs under — the id `run()` opens and
6
+ * closes channel scopes with (spec 06).
7
+ */
8
+ export declare function rootIdOf(ctx: Context): string;
3
9
  /**
4
10
  * @internal
5
11
  * The cancellation tree rooted at `ctx`, in pre-order (a parent before its
@@ -63,30 +69,24 @@ export declare class ContextImpl implements Context<ContextData> {
63
69
  private readonly _children;
64
70
  private _contextData?;
65
71
  /**
66
- * Stack of steps currently in flight on this context, most-recent last.
67
- * Pushed by `enterStep` at the top of `execute()` and popped by
68
- * `leaveStep` when the step resolves (success or failure). The harness'
69
- * checkpoint writer serialises this stack as the execution frontier so
70
- * a restart can identify which step the context was paused inside.
72
+ * Steps in flight on this context, in dispatch order. `enterStep` adds a frame and
73
+ * `leaveStep` removes that same frame, so concurrent dispatches can settle in any
74
+ * order without disturbing each other. The checkpoint writer serialises this set as
75
+ * the execution frontier.
71
76
  * @internal
72
77
  */
73
- private readonly _frontier;
78
+ private readonly _inFlight;
74
79
  /**
75
- * Path segment for each in-flight frame, parallel to `_frontier`. Kept separate so
76
- * `serialiseFrontier()` keeps emitting the published `FrontierFrame` shape.
80
+ * Execution path of the step whose lane this is. On the context itself it is the
81
+ * prefix its steps hang under: empty on a root context, the parent's path plus a
82
+ * discriminator on an inParallel/spawn child, so a path stays unique across the whole
83
+ * step tree. A lane (see `enterStep`) reads its own step's path here instead.
77
84
  * @internal
78
85
  */
79
- private readonly _segments;
80
- /**
81
- * Path this context's steps hang under. Empty on a root context; an inParallel/spawn child
82
- * inherits its parent's current path plus a discriminator, so a path key stays unique
83
- * across the whole step tree rather than restarting per child context.
84
- * @internal
85
- */
86
- private readonly _pathPrefix;
86
+ private readonly _path;
87
87
  /**
88
88
  * Dispatch counts per `<parent path>/<step id>`, so a loop body re-entered on each
89
- * iteration gets `#0`, `#1`, … instead of colliding on one key.
89
+ * iteration gets `#0`, `#1`, … instead of colliding on one key. Shared by every lane.
90
90
  * @internal
91
91
  */
92
92
  private readonly _occurrences;
@@ -156,7 +156,7 @@ export declare class ContextImpl implements Context<ContextData> {
156
156
  recv<T>(ch: Channel<T>, opts?: {
157
157
  timeout?: number;
158
158
  }): Promise<T>;
159
- send<T>(ch: Channel<T>, value: T): Promise<void>;
159
+ send<T>(ch: Channel<T>, value: NoInfer<T>): Promise<void>;
160
160
  tryRecv<T>(ch: Channel<T>): T | null;
161
161
  checkpoint(): Promise<void>;
162
162
  complete<T>(value: T): void;
@@ -173,28 +173,26 @@ export declare class ContextImpl implements Context<ContextData> {
173
173
  get abortSignal(): AbortSignal;
174
174
  /**
175
175
  * @internal
176
- * Push a frame onto the execution frontier. Called by `execute()` at the
177
- * top of every step dispatch so that the frontier reflects exactly the
178
- * stack of steps currently in-flight on this context.
176
+ * Register `frame` as in flight and return the lane its step runs on: a view of
177
+ * this context that shares everything except its execution path. `execute()` hands
178
+ * the lane to the step, so steps the step dispatches hang under its path no matter
179
+ * how other dispatches on this context interleave. The occurrence ordinal is taken
180
+ * here, at dispatch, never at settle.
179
181
  */
180
- enterStep(frame: FrontierFrame): void;
182
+ enterStep(frame: FrontierFrame): ContextImpl;
181
183
  /**
182
184
  * @internal
183
- * The execution path of the step currently on top of the frontier — the ledger key
184
- * identifying this exact dispatch. Stable across a replay given the same control
185
- * flow, which is what lets a resumed run line recorded outputs up with the steps
186
- * that produced them.
185
+ * The execution path of the step this lane belongs to — the ledger key identifying
186
+ * this exact dispatch. Stable across a replay given the same control flow, which is
187
+ * what lets a resumed run line recorded outputs up with the steps that produced them.
187
188
  */
188
189
  currentPath(): string;
189
190
  /**
190
191
  * @internal
191
- * Pop the top frame. Called when a step resolves (success or failure)
192
- * so that the frontier unwinds cleanly. The value of `expectedStepId`
193
- * is used as a consistency check — if it does not match the top frame
194
- * the pop is still performed, but a best-effort warning is surfaced to
195
- * stderr rather than swallowed silently.
192
+ * Drop `frame` from the in-flight set. Called when its step resolves, success or
193
+ * failure.
196
194
  */
197
- leaveStep(expectedStepId: string): void;
195
+ leaveStep(frame: FrontierFrame): void;
198
196
  /**
199
197
  * @internal
200
198
  * Return a defensive copy of the current frontier. Consumed by the
@@ -202,3 +200,9 @@ export declare class ContextImpl implements Context<ContextData> {
202
200
  */
203
201
  serialiseFrontier(): FrontierFrame[];
204
202
  }
203
+ /**
204
+ * @internal
205
+ * The context a lane views, or `ctx` itself when it is not a lane. For per-execution
206
+ * caches keyed by context identity, which every lane of one context must share.
207
+ */
208
+ export declare function laneBase(ctx: Context<ContextData>): Context<ContextData>;
@@ -9,6 +9,18 @@ class NoopSpan {
9
9
  addEvent(_name, _attributes) { }
10
10
  end() { }
11
11
  }
12
+ /**
13
+ * @internal
14
+ * Id of the root execution `ctx` runs under — the id `run()` opens and
15
+ * closes channel scopes with (spec 06).
16
+ */
17
+ export function rootIdOf(ctx) {
18
+ let node = ctx;
19
+ while (node.parent) {
20
+ node = node.parent;
21
+ }
22
+ return node.id;
23
+ }
12
24
  /**
13
25
  * @internal
14
26
  * The cancellation tree rooted at `ctx`, in pre-order (a parent before its
@@ -87,30 +99,24 @@ export class ContextImpl {
87
99
  _children = new Set();
88
100
  _contextData;
89
101
  /**
90
- * Stack of steps currently in flight on this context, most-recent last.
91
- * Pushed by `enterStep` at the top of `execute()` and popped by
92
- * `leaveStep` when the step resolves (success or failure). The harness'
93
- * checkpoint writer serialises this stack as the execution frontier so
94
- * a restart can identify which step the context was paused inside.
102
+ * Steps in flight on this context, in dispatch order. `enterStep` adds a frame and
103
+ * `leaveStep` removes that same frame, so concurrent dispatches can settle in any
104
+ * order without disturbing each other. The checkpoint writer serialises this set as
105
+ * the execution frontier.
95
106
  * @internal
96
107
  */
97
- _frontier = [];
108
+ _inFlight = new Set();
98
109
  /**
99
- * Path segment for each in-flight frame, parallel to `_frontier`. Kept separate so
100
- * `serialiseFrontier()` keeps emitting the published `FrontierFrame` shape.
110
+ * Execution path of the step whose lane this is. On the context itself it is the
111
+ * prefix its steps hang under: empty on a root context, the parent's path plus a
112
+ * discriminator on an inParallel/spawn child, so a path stays unique across the whole
113
+ * step tree. A lane (see `enterStep`) reads its own step's path here instead.
101
114
  * @internal
102
115
  */
103
- _segments = [];
104
- /**
105
- * Path this context's steps hang under. Empty on a root context; an inParallel/spawn child
106
- * inherits its parent's current path plus a discriminator, so a path key stays unique
107
- * across the whole step tree rather than restarting per child context.
108
- * @internal
109
- */
110
- _pathPrefix;
116
+ _path;
111
117
  /**
112
118
  * Dispatch counts per `<parent path>/<step id>`, so a loop body re-entered on each
113
- * iteration gets `#0`, `#1`, … instead of colliding on one key.
119
+ * iteration gets `#0`, `#1`, … instead of colliding on one key. Shared by every lane.
114
120
  * @internal
115
121
  */
116
122
  _occurrences = new Map();
@@ -133,7 +139,7 @@ export class ContextImpl {
133
139
  this.resourceId = opts.resourceId;
134
140
  this.channelStore = opts.channelStore;
135
141
  this._checkpointFn = opts.checkpointFn;
136
- this._pathPrefix = opts.pathPrefix ?? '';
142
+ this._path = opts.pathPrefix ?? '';
137
143
  this.ledger = opts.ledger;
138
144
  this.layers = opts.layers;
139
145
  this.unifiedTools = opts.unifiedTools;
@@ -215,6 +221,7 @@ export class ContextImpl {
215
221
  if (!this.channelStore) {
216
222
  return Promise.reject(new Error('No channel store configured'));
217
223
  }
224
+ this.channelStore.claim(ch, rootIdOf(this));
218
225
  return this.channelStore.recv(ch, opts?.timeout, this._abortController.signal);
219
226
  }
220
227
  send(ch, value) {
@@ -224,6 +231,7 @@ export class ContextImpl {
224
231
  // Internal sender: back-pressured on full queue channels (default 30s →
225
232
  // channel_timeout); aborting this context rejects a parked send with
226
233
  // 'cancelled' via the shared abort signal.
234
+ this.channelStore.claim(ch, rootIdOf(this));
227
235
  return this.channelStore.send(ch, value, {
228
236
  signal: this._abortController.signal,
229
237
  });
@@ -232,6 +240,7 @@ export class ContextImpl {
232
240
  if (!this.channelStore) {
233
241
  throw new Error('No channel store configured');
234
242
  }
243
+ this.channelStore.claim(ch, rootIdOf(this));
235
244
  return this.channelStore.tryRecv(ch);
236
245
  }
237
246
  async checkpoint() {
@@ -285,45 +294,35 @@ export class ContextImpl {
285
294
  }
286
295
  /**
287
296
  * @internal
288
- * Push a frame onto the execution frontier. Called by `execute()` at the
289
- * top of every step dispatch so that the frontier reflects exactly the
290
- * stack of steps currently in-flight on this context.
297
+ * Register `frame` as in flight and return the lane its step runs on: a view of
298
+ * this context that shares everything except its execution path. `execute()` hands
299
+ * the lane to the step, so steps the step dispatches hang under its path no matter
300
+ * how other dispatches on this context interleave. The occurrence ordinal is taken
301
+ * here, at dispatch, never at settle.
291
302
  */
292
303
  enterStep(frame) {
293
- const parent = this.currentPath();
294
- const key = `${parent}/${frame.stepId}`;
304
+ const key = `${this._path}/${frame.stepId}`;
295
305
  const occurrence = this._occurrences.get(key) ?? 0;
296
306
  this._occurrences.set(key, occurrence + 1);
297
- this._segments.push(`/${frame.stepId}#${occurrence}`);
298
- this._frontier.push(frame);
307
+ this._inFlight.add(frame);
308
+ return laneOf(this, `${key}#${occurrence}`);
299
309
  }
300
310
  /**
301
311
  * @internal
302
- * The execution path of the step currently on top of the frontier — the ledger key
303
- * identifying this exact dispatch. Stable across a replay given the same control
304
- * flow, which is what lets a resumed run line recorded outputs up with the steps
305
- * that produced them.
312
+ * The execution path of the step this lane belongs to — the ledger key identifying
313
+ * this exact dispatch. Stable across a replay given the same control flow, which is
314
+ * what lets a resumed run line recorded outputs up with the steps that produced them.
306
315
  */
307
316
  currentPath() {
308
- return this._pathPrefix + this._segments.join('');
317
+ return this._path;
309
318
  }
310
319
  /**
311
320
  * @internal
312
- * Pop the top frame. Called when a step resolves (success or failure)
313
- * so that the frontier unwinds cleanly. The value of `expectedStepId`
314
- * is used as a consistency check — if it does not match the top frame
315
- * the pop is still performed, but a best-effort warning is surfaced to
316
- * stderr rather than swallowed silently.
321
+ * Drop `frame` from the in-flight set. Called when its step resolves, success or
322
+ * failure.
317
323
  */
318
- leaveStep(expectedStepId) {
319
- const top = this._frontier[this._frontier.length - 1];
320
- if (top && top.stepId !== expectedStepId) {
321
- // A mismatch indicates bookkeeping drift. We unwind best-effort and
322
- // let the caller observe via `serialiseFrontier()` if needed.
323
- console.warn(`ContextImpl.leaveStep: expected "${expectedStepId}" on top of frontier but saw "${top.stepId}".`);
324
- }
325
- this._frontier.pop();
326
- this._segments.pop();
324
+ leaveStep(frame) {
325
+ this._inFlight.delete(frame);
327
326
  }
328
327
  /**
329
328
  * @internal
@@ -331,10 +330,46 @@ export class ContextImpl {
331
330
  * checkpoint writer — see `AgentHarness.checkpoint`.
332
331
  */
333
332
  serialiseFrontier() {
334
- return this._frontier.map((frame) => ({
333
+ return [
334
+ ...this._inFlight,
335
+ ].map((frame) => ({
335
336
  stepId: frame.stepId,
336
337
  input: frame.input,
337
338
  state: frame.state,
338
339
  }));
339
340
  }
340
341
  }
342
+ //#region Lanes
343
+ /** The context each lane views, so `laneOf` never stacks a proxy on a proxy. */
344
+ const laneTargets = new WeakMap();
345
+ /**
346
+ * A view of `ctx` that reads `path` as its execution path and forwards every other
347
+ * read and write to the context itself, so state, usage, abort, and the in-flight set
348
+ * stay shared. A Proxy rather than a subclass: steps assign `ctx.state` and friends
349
+ * directly, and a copy would keep those writes to itself.
350
+ */
351
+ function laneOf(ctx, path) {
352
+ const target = laneTargets.get(ctx) ?? ctx;
353
+ const lane = new Proxy(target, {
354
+ get(base, prop, receiver) {
355
+ if (prop === '_path') {
356
+ return path;
357
+ }
358
+ return Reflect.get(base, prop, receiver);
359
+ },
360
+ set(base, prop, value) {
361
+ return Reflect.set(base, prop, value);
362
+ },
363
+ });
364
+ laneTargets.set(lane, target);
365
+ return lane;
366
+ }
367
+ /**
368
+ * @internal
369
+ * The context a lane views, or `ctx` itself when it is not a lane. For per-execution
370
+ * caches keyed by context identity, which every lane of one context must share.
371
+ */
372
+ export function laneBase(ctx) {
373
+ return ctx instanceof ContextImpl ? (laneTargets.get(ctx) ?? ctx) : ctx;
374
+ }
375
+ //#endregion