@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.
- package/dist/adapters/openrouter.d.ts +1 -0
- package/dist/adapters/openrouter.js +1 -0
- package/dist/builders/channel-builder.d.ts +18 -0
- package/dist/builders/control-flow-builders.d.ts +17 -30
- package/dist/builders/decide-builders.d.ts +5 -5
- package/dist/builders/decide-builders.js +4 -0
- package/dist/builders/every.d.ts +2 -2
- package/dist/builders/every.js +2 -0
- package/dist/builders/loop-builder.d.ts +1 -1
- package/dist/builders/loop-builder.js +1 -1
- package/dist/builders/provide-builder.d.ts +3 -2
- package/dist/builders/provide-builder.js +3 -0
- package/dist/builders/spawn-builder.d.ts +2 -3
- package/dist/builders/spawn-builder.js +2 -1
- package/dist/builders/step-builders.d.ts +8 -9
- package/dist/builders/step-builders.js +0 -1
- package/dist/builders/tool-builder.d.ts +2 -0
- package/dist/builders/tool-builder.js +2 -0
- package/dist/builders/workflow-hydrator.js +21 -14
- package/dist/builders/workflow-replay-key.d.ts +15 -0
- package/dist/builders/workflow-replay-key.js +46 -0
- package/dist/harness/agent-harness.d.ts +43 -11
- package/dist/harness/agent-harness.js +121 -40
- package/dist/harness/deps/interpreter.d.ts +1 -0
- package/dist/harness/deps/interpreter.js +1 -0
- package/dist/harness/deps/runtime.d.ts +3 -2
- package/dist/harness/deps/runtime.js +2 -2
- package/dist/harness/model-call.d.ts +3 -0
- package/dist/harness/model-call.js +19 -5
- package/dist/index.d.ts +7 -5
- package/dist/index.js +3 -3
- package/dist/interpreter/action-deps.d.ts +1 -1
- package/dist/interpreter/action-deps.js +1 -1
- package/dist/interpreter/child-span.d.ts +17 -0
- package/dist/interpreter/child-span.js +51 -0
- package/dist/interpreter/collect-tools.js +19 -54
- package/dist/interpreter/execute-action.js +30 -17
- package/dist/interpreter/execute-control.js +67 -9
- package/dist/interpreter/execute-decide.js +9 -27
- package/dist/interpreter/execute.d.ts +10 -0
- package/dist/interpreter/execute.js +69 -46
- package/dist/interpreter/model-error.d.ts +9 -0
- package/dist/interpreter/model-error.js +32 -0
- package/dist/interpreter/step-walk.d.ts +11 -0
- package/dist/interpreter/step-walk.js +41 -0
- package/dist/observability/genai-attributes.d.ts +52 -29
- package/dist/observability/genai-attributes.js +27 -1
- package/dist/runtime/channel-store.d.ts +73 -1
- package/dist/runtime/channel-store.js +179 -63
- package/dist/runtime/context-impl.d.ts +36 -32
- package/dist/runtime/context-impl.js +80 -45
- package/dist/runtime/durable/harness-checkpoints.d.ts +33 -16
- package/dist/runtime/durable/harness-checkpoints.js +74 -52
- package/dist/runtime/durable/step-ledger.d.ts +15 -5
- package/dist/runtime/durable/step-ledger.js +23 -6
- package/dist/schemas/workflow.d.ts +1 -1
- package/dist/types/checkpoint.d.ts +31 -0
- package/dist/types/checkpoint.js +29 -0
- package/dist/until/combinators.d.ts +10 -0
- package/dist/until/combinators.js +37 -13
- package/dist/until/predicates.js +4 -2
- package/package.json +3 -3
- 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
|
-
|
|
26
|
-
if (
|
|
27
|
-
|
|
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
|
|
110
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
|
78
|
+
private readonly _inFlight;
|
|
74
79
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
|
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
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
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):
|
|
182
|
+
enterStep(frame: FrontierFrame): ContextImpl;
|
|
181
183
|
/**
|
|
182
184
|
* @internal
|
|
183
|
-
* The execution path of the step
|
|
184
|
-
*
|
|
185
|
-
*
|
|
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
|
-
*
|
|
192
|
-
*
|
|
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(
|
|
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
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
-
|
|
108
|
+
_inFlight = new Set();
|
|
98
109
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
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
|
|
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.
|
|
298
|
-
this
|
|
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
|
|
303
|
-
*
|
|
304
|
-
*
|
|
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.
|
|
317
|
+
return this._path;
|
|
309
318
|
}
|
|
310
319
|
/**
|
|
311
320
|
* @internal
|
|
312
|
-
*
|
|
313
|
-
*
|
|
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(
|
|
319
|
-
|
|
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
|
|
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
|