@noetic-tools/core 5.6.0 → 5.8.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 (44) hide show
  1. package/dist/builders/channel-builder.d.ts +18 -0
  2. package/dist/builders/control-flow-builders.d.ts +17 -30
  3. package/dist/builders/decide-builders.d.ts +5 -5
  4. package/dist/builders/decide-builders.js +4 -0
  5. package/dist/builders/every.d.ts +2 -2
  6. package/dist/builders/every.js +2 -0
  7. package/dist/builders/loop-builder.d.ts +1 -1
  8. package/dist/builders/loop-builder.js +1 -1
  9. package/dist/builders/provide-builder.d.ts +2 -2
  10. package/dist/builders/provide-builder.js +2 -0
  11. package/dist/builders/spawn-builder.d.ts +2 -2
  12. package/dist/builders/spawn-builder.js +2 -0
  13. package/dist/builders/step-builders.d.ts +8 -8
  14. package/dist/builders/workflow-hydrator.js +6 -1
  15. package/dist/harness/agent-harness.d.ts +20 -5
  16. package/dist/harness/agent-harness.js +49 -14
  17. package/dist/harness/deps/runtime.d.ts +3 -2
  18. package/dist/harness/deps/runtime.js +2 -2
  19. package/dist/harness/model-call.js +10 -0
  20. package/dist/index.d.ts +9 -7
  21. package/dist/index.js +4 -4
  22. package/dist/interpreter/child-span.d.ts +17 -0
  23. package/dist/interpreter/child-span.js +51 -0
  24. package/dist/interpreter/execute-action.js +21 -33
  25. package/dist/interpreter/execute-control.js +64 -8
  26. package/dist/interpreter/execute.d.ts +10 -0
  27. package/dist/interpreter/execute.js +41 -12
  28. package/dist/observability/genai-attributes.d.ts +45 -29
  29. package/dist/observability/genai-attributes.js +19 -1
  30. package/dist/runtime/channel-store.d.ts +73 -1
  31. package/dist/runtime/channel-store.js +179 -63
  32. package/dist/runtime/context-impl.d.ts +10 -1
  33. package/dist/runtime/context-impl.js +17 -0
  34. package/dist/runtime/durable/harness-checkpoints.d.ts +5 -1
  35. package/dist/runtime/durable/harness-checkpoints.js +8 -2
  36. package/dist/runtime/durable/step-ledger.d.ts +14 -4
  37. package/dist/runtime/durable/step-ledger.js +22 -5
  38. package/dist/types/checkpoint.d.ts +31 -0
  39. package/dist/types/checkpoint.js +29 -0
  40. package/dist/until/combinators.d.ts +10 -0
  41. package/dist/until/combinators.js +37 -13
  42. package/dist/until/predicates.js +4 -2
  43. package/package.json +3 -3
  44. package/schema/noetic-workflow.schema.json +57 -0
@@ -1,4 +1,36 @@
1
1
  import type { Channel, ChannelHandle, ExternalChannel } from '@noetic-tools/types';
2
+ import type { ChannelSnapshot } from '../types/checkpoint.js';
3
+ /**
4
+ * A channel condition worth surfacing but not worth failing on. `AgentHarness`
5
+ * records these as `channel.warning` span events (spec 10); a bare store
6
+ * prints them with `console.warn`.
7
+ */
8
+ export type ChannelWarning = {
9
+ /** An external sender hit a full queue and the oldest item was dropped. */
10
+ kind: 'overflow_drop';
11
+ channelName: string;
12
+ capacity: number;
13
+ message: string;
14
+ } | {
15
+ /** An external subscriber's private buffer filled and its oldest value was dropped. */
16
+ kind: 'subscriber_drop';
17
+ channelName: string;
18
+ capacity: number;
19
+ message: string;
20
+ } | {
21
+ /** A `recv` or parked `send` is waiting with `timeout: 0`, so nothing will time it out. */
22
+ kind: 'unbounded_wait';
23
+ channelName: string;
24
+ operation: 'recv' | 'send';
25
+ message: string;
26
+ };
27
+ /** Options accepted by the `ChannelStore` constructor. */
28
+ export interface ChannelStoreOpts {
29
+ /** Receives every channel warning. Defaults to `warnToConsole`. */
30
+ onWarning?: (warning: ChannelWarning) => void;
31
+ }
32
+ /** Print a channel warning with the `[noetic]` prefix. */
33
+ export declare function warnToConsole(warning: ChannelWarning): void;
2
34
  /** Options accepted by internal (back-pressured) `send`. */
3
35
  export interface ChannelSendOpts {
4
36
  /**
@@ -21,7 +53,34 @@ export declare class ChannelStore {
21
53
  */
22
54
  private handleObserved;
23
55
  private subscribersByExecution;
56
+ /**
57
+ * Channel name → ids of the root executions that used it, which decides
58
+ * the channels an execution's checkpoint carries. Ids stay after their
59
+ * execution closes: a host may checkpoint a finished run, and channel
60
+ * state itself lives for the harness lifetime (spec 06, Lifecycle).
61
+ */
62
+ private users;
63
+ private readonly warn;
64
+ constructor(opts?: ChannelStoreOpts);
24
65
  private getOrCreate;
66
+ /**
67
+ * Record that `executionId` (a root execution) uses `channel`, so the
68
+ * channel rides along in its checkpoints.
69
+ */
70
+ claim<T>(channel: Channel<T>, executionId: string): void;
71
+ private addUser;
72
+ /**
73
+ * Stored state (values, queued items) of every channel `executionId` used.
74
+ * Parked receivers, parked senders, and subscribers are live promises and
75
+ * stay out; topic channels store nothing and are skipped.
76
+ */
77
+ snapshot(executionId: string): ChannelSnapshot[];
78
+ /**
79
+ * Refill channel state from a checkpoint and mark `executionId` as a user.
80
+ * A channel that already has live state keeps it — live state is never
81
+ * older than a snapshot.
82
+ */
83
+ restore(executionId: string, snapshots: readonly ChannelSnapshot[]): void;
25
84
  /**
26
85
  * Subscribe to wake notifications on a channel without consuming any value.
27
86
  * Fired once on the next `send()` for any mode, then auto-removed. Returns
@@ -42,8 +101,12 @@ export declare class ChannelStore {
42
101
  * - **topic**: deliver to current subscribers, resolve immediately.
43
102
  *
44
103
  * External callers use `ChannelHandle.send` (sync, drop-oldest) instead.
104
+ * `opts.timeout: 0` parks with no timeout and warns `unbounded_wait`.
105
+ *
106
+ * @throws `NoeticConfigError` with code `INVALID_CHANNEL_TIMEOUT` (as a
107
+ * rejection) when `opts.timeout` is negative.
45
108
  */
46
- send<T>(channel: Channel<T>, value: T, opts?: ChannelSendOpts): Promise<void>;
109
+ send<T>(channel: Channel<T>, value: NoInfer<T>, opts?: ChannelSendOpts): Promise<void>;
47
110
  /** Park an internal sender on a full queue until a slot frees (FIFO). */
48
111
  private parkSender;
49
112
  /**
@@ -53,6 +116,15 @@ export declare class ChannelStore {
53
116
  private promotePendingSender;
54
117
  /** Fire-and-reset the one-shot wake subscribers for a channel state. */
55
118
  private fireWakes;
119
+ /**
120
+ * Receive by mode: value returns the current value, queue dequeues the
121
+ * head, topic waits for the next send. A receiver that has to wait gets
122
+ * `channel_timeout` after `timeout` ms; `0` waits with no timeout in every
123
+ * mode and warns `unbounded_wait`.
124
+ *
125
+ * @throws `NoeticConfigError` with code `INVALID_CHANNEL_TIMEOUT` (as a
126
+ * rejection) when `timeout` is negative.
127
+ */
56
128
  recv<T>(channel: Channel<T>, timeout?: number, signal?: AbortSignal): Promise<T>;
57
129
  tryRecv<T>(channel: Channel<T>): T | null;
58
130
  private waitWithTimeout;
@@ -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
@@ -14,6 +20,7 @@ export declare class ContextImpl implements Context<ContextData> {
14
20
  cost: number;
15
21
  state: unknown;
16
22
  readonly parent: Context<ContextData> | null;
23
+ readonly usageParent: Context<ContextData> | null;
17
24
  readonly depth: number;
18
25
  readonly span: Span;
19
26
  readonly threadId: string;
@@ -98,6 +105,8 @@ export declare class ContextImpl implements Context<ContextData> {
98
105
  constructor(opts: {
99
106
  harness: AgentHarnessContract;
100
107
  parent?: Context;
108
+ /** Charge this context's usage to `parent` as it is spent. Default `true`. */
109
+ rollUpUsage?: boolean;
101
110
  items?: Item[];
102
111
  state?: unknown;
103
112
  threadId?: string;
@@ -153,7 +162,7 @@ export declare class ContextImpl implements Context<ContextData> {
153
162
  recv<T>(ch: Channel<T>, opts?: {
154
163
  timeout?: number;
155
164
  }): Promise<T>;
156
- send<T>(ch: Channel<T>, value: T): Promise<void>;
165
+ send<T>(ch: Channel<T>, value: NoInfer<T>): Promise<void>;
157
166
  tryRecv<T>(ch: Channel<T>): T | null;
158
167
  checkpoint(): Promise<void>;
159
168
  complete<T>(value: T): void;
@@ -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
@@ -38,6 +50,7 @@ export class ContextImpl {
38
50
  cost = 0;
39
51
  state;
40
52
  parent;
53
+ usageParent;
41
54
  depth;
42
55
  span;
43
56
  threadId;
@@ -125,6 +138,7 @@ export class ContextImpl {
125
138
  this.harness = opts.harness;
126
139
  this.state = opts.state ?? {};
127
140
  this.parent = opts.parent ?? null;
141
+ this.usageParent = opts.rollUpUsage === false ? null : this.parent;
128
142
  this.depth = this.parent ? this.parent.depth + 1 : 0;
129
143
  this.span = opts.span ?? new NoopSpan();
130
144
  this.threadId = opts.threadId ?? crypto.randomUUID();
@@ -213,6 +227,7 @@ export class ContextImpl {
213
227
  if (!this.channelStore) {
214
228
  return Promise.reject(new Error('No channel store configured'));
215
229
  }
230
+ this.channelStore.claim(ch, rootIdOf(this));
216
231
  return this.channelStore.recv(ch, opts?.timeout, this._abortController.signal);
217
232
  }
218
233
  send(ch, value) {
@@ -222,6 +237,7 @@ export class ContextImpl {
222
237
  // Internal sender: back-pressured on full queue channels (default 30s →
223
238
  // channel_timeout); aborting this context rejects a parked send with
224
239
  // 'cancelled' via the shared abort signal.
240
+ this.channelStore.claim(ch, rootIdOf(this));
225
241
  return this.channelStore.send(ch, value, {
226
242
  signal: this._abortController.signal,
227
243
  });
@@ -230,6 +246,7 @@ export class ContextImpl {
230
246
  if (!this.channelStore) {
231
247
  throw new Error('No channel store configured');
232
248
  }
249
+ this.channelStore.claim(ch, rootIdOf(this));
233
250
  return this.channelStore.tryRecv(ch);
234
251
  }
235
252
  async checkpoint() {
@@ -1,5 +1,6 @@
1
1
  import type { LayerStateStore } from '@noetic-tools/context';
2
2
  import type { Context, Item, ItemSchemaRegistry, RestoreContextOptions } from '@noetic-tools/types';
3
+ import type { ChannelStore } from '../channel-store.js';
3
4
  import type { EventBroadcaster } from '../event-broadcaster.js';
4
5
  import type { CheckpointStore } from './checkpoint-store.js';
5
6
  import type { StepLedgerRetention, StepLedgerStore } from './step-ledger.js';
@@ -28,6 +29,7 @@ export interface CheckpointHarnessHandle {
28
29
  readonly stepLedgerStore?: StepLedgerStore;
29
30
  readonly stepLedgerRetention?: StepLedgerRetention;
30
31
  readonly layerStateStore: LayerStateStore;
32
+ readonly channelStore: Pick<ChannelStore, 'snapshot' | 'restore'>;
31
33
  readonly itemSchemas: ItemSchemaRegistry;
32
34
  createContext(opts?: RestoreCheckpointOptions & {
33
35
  items?: Item[];
@@ -51,7 +53,9 @@ export declare function captureCheckpoint(h: CheckpointHarnessHandle, ctx: Conte
51
53
  * `null` if no snapshot is recorded for `executionId`. Layer state is
52
54
  * replayed into `layerStateStore` keyed by the original executionId so
53
55
  * the restored context observes the pre-crash state through
54
- * `readLayerState` and the context projectors.
56
+ * `readLayerState` and the context projectors. Channel values and queued
57
+ * items are refilled into the channel store, for channels with no live
58
+ * state.
55
59
  *
56
60
  * `opts` carries the host's own context wiring (broadcaster, parent, state,
57
61
  * layer overrides) into the rebuilt context. A snapshot cannot round-trip live
@@ -1,5 +1,5 @@
1
1
  import { CheckpointSchemaVersion } from '../../types/checkpoint.js';
2
- import { ContextImpl } from '../context-impl.js';
2
+ import { ContextImpl, rootIdOf } from '../context-impl.js';
3
3
  import { StepLedger } from './step-ledger.js';
4
4
  //#endregion
5
5
  //#region captureCheckpoint
@@ -47,6 +47,9 @@ export async function captureCheckpoint(h, ctx) {
47
47
  ...ctx.itemLog.items,
48
48
  ],
49
49
  },
50
+ // Channel state belongs to the root execution, so a child checkpoint
51
+ // carries the channels its root used.
52
+ channels: h.channelStore.snapshot(rootIdOf(ctx)),
50
53
  capturedAt: new Date().toISOString(),
51
54
  };
52
55
  try {
@@ -63,7 +66,9 @@ export async function captureCheckpoint(h, ctx) {
63
66
  * `null` if no snapshot is recorded for `executionId`. Layer state is
64
67
  * replayed into `layerStateStore` keyed by the original executionId so
65
68
  * the restored context observes the pre-crash state through
66
- * `readLayerState` and the context projectors.
69
+ * `readLayerState` and the context projectors. Channel values and queued
70
+ * items are refilled into the channel store, for channels with no live
71
+ * state.
67
72
  *
68
73
  * `opts` carries the host's own context wiring (broadcaster, parent, state,
69
74
  * layer overrides) into the rebuilt context. A snapshot cannot round-trip live
@@ -91,6 +96,7 @@ export async function restoreFromCheckpoint(h, executionId, opts) {
91
96
  for (const [layerId, state] of Object.entries(snapshot.layers)) {
92
97
  h.layerStateStore.set(executionId, layerId, state);
93
98
  }
99
+ h.channelStore.restore(executionId, snapshot.channels ?? []);
94
100
  const items = h.itemSchemas.parseMany(snapshot.itemLog.items);
95
101
  const cwdInit = snapshot.cwd?.current ?? undefined;
96
102
  /* Caller wiring first, snapshot second: a host may legitimately swap the context
@@ -11,6 +11,12 @@
11
11
  * into the single `execution:<id>:snapshot` key would rewrite a growing blob on every
12
12
  * step — O(n²) bytes over a run.
13
13
  *
14
+ * An entry's key is one of two forms (spec 23a § Identity). By default it is the step's
15
+ * execution path (`ContextImpl.currentPath()`): positional, so a resumed run lines up
16
+ * recorded outputs with steps by where they ran. A step with a `replayKey` is keyed
17
+ * `key:<replayKey>` instead: content-addressed, so it finds its output wherever it
18
+ * runs. A `durable: false` step has no key and is never recorded.
19
+ *
14
20
  * Sharding bounds the cost of one append but not the total, so retention is bounded on
15
21
  * two axes (`StepLedgerRetention`): an oversized entry is dropped rather than written,
16
22
  * and past `maxEntries` the oldest entry is evicted. Both degradations are safe by
@@ -19,7 +25,9 @@
19
25
  */
20
26
  import type { StorageAdapter } from '@noetic-tools/context';
21
27
  import { z } from 'zod';
22
- /** @public One completed step, keyed by its execution path. */
28
+ /** Marks a ledger key as a step's `replayKey` rather than its execution path. */
29
+ export declare const REPLAY_KEY_PREFIX = "key:";
30
+ /** @public One completed step, keyed by its execution path or its `key:` replay key. */
23
31
  export declare const StepLedgerEntrySchema: z.ZodObject<{
24
32
  path: z.ZodString;
25
33
  stepId: z.ZodString;
@@ -153,9 +161,11 @@ export declare class StepLedger {
153
161
  get stats(): StepLedgerStats;
154
162
  /**
155
163
  * The recorded output for `path`, when a previous run completed the same step there.
156
- * A divergence (different step id or kind at this path) discards the entry and every
157
- * entry recorded beneath it, then returns undefined so the step runs fresh — the
158
- * subtree's recorded outputs belong to a branch that no longer exists.
164
+ * A divergence (different step id or kind at this key) returns undefined so the step
165
+ * runs fresh. At a positional path it also discards every entry recorded beneath it —
166
+ * the subtree's recorded outputs belong to a branch that no longer exists. A `key:`
167
+ * entry discards only itself: its children are keyed by their own paths or keys, not
168
+ * nested under it.
159
169
  */
160
170
  take(path: string, step: {
161
171
  id: string;