@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.
- 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 +2 -2
- package/dist/builders/provide-builder.js +2 -0
- package/dist/builders/spawn-builder.d.ts +2 -2
- package/dist/builders/spawn-builder.js +2 -0
- package/dist/builders/step-builders.d.ts +8 -8
- package/dist/builders/workflow-hydrator.js +6 -1
- package/dist/harness/agent-harness.d.ts +20 -5
- package/dist/harness/agent-harness.js +49 -14
- package/dist/harness/deps/runtime.d.ts +3 -2
- package/dist/harness/deps/runtime.js +2 -2
- package/dist/harness/model-call.js +10 -0
- package/dist/index.d.ts +9 -7
- package/dist/index.js +4 -4
- package/dist/interpreter/child-span.d.ts +17 -0
- package/dist/interpreter/child-span.js +51 -0
- package/dist/interpreter/execute-action.js +21 -33
- package/dist/interpreter/execute-control.js +64 -8
- package/dist/interpreter/execute.d.ts +10 -0
- package/dist/interpreter/execute.js +41 -12
- package/dist/observability/genai-attributes.d.ts +45 -29
- package/dist/observability/genai-attributes.js +19 -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 +10 -1
- package/dist/runtime/context-impl.js +17 -0
- package/dist/runtime/durable/harness-checkpoints.d.ts +5 -1
- package/dist/runtime/durable/harness-checkpoints.js +8 -2
- package/dist/runtime/durable/step-ledger.d.ts +14 -4
- package/dist/runtime/durable/step-ledger.js +22 -5
- 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 +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
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
/**
|
|
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
|
|
157
|
-
*
|
|
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;
|