@noetic-tools/core 0.3.0 → 1.1.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/harness/agent-harness.d.ts +2 -2
- package/dist/harness/agent-harness.js +17 -5
- package/dist/harness/model-call.js +9 -8
- package/dist/harness/model-schema.d.ts +8 -0
- package/dist/harness/model-schema.js +8 -0
- package/dist/index.d.ts +1 -1
- package/dist/interpreter/execute-action.js +19 -2
- package/dist/interpreter/execute-control.js +59 -4
- package/dist/runtime/channel-store.d.ts +42 -2
- package/dist/runtime/channel-store.js +232 -35
- package/dist/runtime/context-impl.d.ts +17 -1
- package/dist/runtime/context-impl.js +30 -3
- package/dist/runtime/durable/stall-nudge.d.ts +16 -9
- package/dist/runtime/durable/stall-nudge.js +29 -10
- package/dist/runtime/event-broadcaster.d.ts +9 -1
- package/dist/runtime/event-broadcaster.js +43 -19
- package/dist/schemas/workflow.js +21 -5
- package/package.json +7 -4
- package/schema/noetic-workflow.schema.json +523 -0
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { frameworkCast, NoeticErrorImpl } from '@noetic-tools/types';
|
|
2
|
-
function isExternalChannel(ch) {
|
|
3
|
-
return 'external' in ch && ch.external === true;
|
|
4
|
-
}
|
|
5
2
|
const MAX_TOPIC_TIMEOUT = 300_000; // 5 minutes
|
|
3
|
+
const DEFAULT_SEND_TIMEOUT = 30_000;
|
|
4
|
+
const RESOLVED = Promise.resolve();
|
|
5
|
+
/** Build the `cancelled` error a blocked channel operation rejects with on abort. */
|
|
6
|
+
function cancelledError(signal) {
|
|
7
|
+
return new NoeticErrorImpl({
|
|
8
|
+
kind: 'cancelled',
|
|
9
|
+
reason: typeof signal.reason === 'string' ? signal.reason : undefined,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
6
12
|
export class ChannelStore {
|
|
7
13
|
channels = new Map();
|
|
8
14
|
closedExecutions = new Set();
|
|
@@ -16,6 +22,7 @@ export class ChannelStore {
|
|
|
16
22
|
queue: [],
|
|
17
23
|
capacity: channel.capacity ?? 1_000,
|
|
18
24
|
queueWaiters: [],
|
|
25
|
+
pendingSenders: [],
|
|
19
26
|
topicSubscribers: new Set(),
|
|
20
27
|
wakeSubscribers: new Set(),
|
|
21
28
|
};
|
|
@@ -39,14 +46,38 @@ export class ChannelStore {
|
|
|
39
46
|
subscribers.delete(callback);
|
|
40
47
|
};
|
|
41
48
|
}
|
|
42
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Internal-sender write with back-pressure (spec 06). Delivery is
|
|
51
|
+
* synchronous when possible; the returned promise only parks when a queue
|
|
52
|
+
* channel is at capacity:
|
|
53
|
+
*
|
|
54
|
+
* - **value**: store last-write-wins value, drain ALL parked recv waiters,
|
|
55
|
+
* resolve immediately.
|
|
56
|
+
* - **queue**: hand off to a parked recv waiter or push below capacity —
|
|
57
|
+
* resolve immediately. At capacity, park the sender FIFO until a consumer
|
|
58
|
+
* dequeues an item; reject `channel_timeout` after `opts.timeout`
|
|
59
|
+
* (default 30s) or `cancelled` when `opts.signal` aborts.
|
|
60
|
+
* - **topic**: deliver to current subscribers, resolve immediately.
|
|
61
|
+
*
|
|
62
|
+
* External callers use `ChannelHandle.send` (sync, drop-oldest) instead.
|
|
63
|
+
*/
|
|
64
|
+
send(channel, value, opts) {
|
|
43
65
|
const state = this.getOrCreate(channel);
|
|
66
|
+
let result = RESOLVED;
|
|
44
67
|
switch (state.mode) {
|
|
45
68
|
case 'value':
|
|
46
69
|
state.currentValue = value;
|
|
47
70
|
state.hasValue = true;
|
|
71
|
+
// Value-mode reads are non-consuming (last-write-wins), so once
|
|
72
|
+
// hasValue flips true EVERY parked waiter must be drained — waking
|
|
73
|
+
// only one would strand the rest until channel_timeout while new
|
|
74
|
+
// recv calls succeed instantly. Splice in place: timeout timers and
|
|
75
|
+
// abort listeners close over the array identity.
|
|
48
76
|
if (state.valueWaiters.length > 0) {
|
|
49
|
-
state.valueWaiters.
|
|
77
|
+
const drained = state.valueWaiters.splice(0, state.valueWaiters.length);
|
|
78
|
+
for (const waiter of drained) {
|
|
79
|
+
waiter.resolve(value);
|
|
80
|
+
}
|
|
50
81
|
}
|
|
51
82
|
break;
|
|
52
83
|
case 'queue':
|
|
@@ -57,13 +88,8 @@ export class ChannelStore {
|
|
|
57
88
|
state.queue.push(value);
|
|
58
89
|
}
|
|
59
90
|
else {
|
|
60
|
-
// At capacity
|
|
61
|
-
|
|
62
|
-
if (isExternalChannel(channel)) {
|
|
63
|
-
state.queue.shift();
|
|
64
|
-
state.queue.push(value);
|
|
65
|
-
}
|
|
66
|
-
// For internal, drop the new value
|
|
91
|
+
// At capacity — park the sender (back-pressure).
|
|
92
|
+
result = this.parkSender(state, channel.name, value, opts);
|
|
67
93
|
}
|
|
68
94
|
break;
|
|
69
95
|
case 'topic':
|
|
@@ -72,30 +98,128 @@ export class ChannelStore {
|
|
|
72
98
|
}
|
|
73
99
|
break;
|
|
74
100
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
101
|
+
this.fireWakes(state);
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
/** Park an internal sender on a full queue until a slot frees (FIFO). */
|
|
105
|
+
parkSender(state, channelName, value, opts) {
|
|
106
|
+
const timeout = opts?.timeout ?? DEFAULT_SEND_TIMEOUT;
|
|
107
|
+
const signal = opts?.signal;
|
|
108
|
+
if (signal?.aborted) {
|
|
109
|
+
return Promise.reject(cancelledError(signal));
|
|
110
|
+
}
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
let timer = null;
|
|
113
|
+
let removeAbortListener = null;
|
|
114
|
+
const cleanup = () => {
|
|
115
|
+
if (timer) {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
}
|
|
118
|
+
removeAbortListener?.();
|
|
119
|
+
};
|
|
120
|
+
const entry = {
|
|
121
|
+
value,
|
|
122
|
+
resolve: () => {
|
|
123
|
+
cleanup();
|
|
124
|
+
resolve();
|
|
125
|
+
},
|
|
126
|
+
reject: (e) => {
|
|
127
|
+
cleanup();
|
|
128
|
+
reject(e);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
state.pendingSenders.push(entry);
|
|
132
|
+
const removeEntry = () => {
|
|
133
|
+
const idx = state.pendingSenders.indexOf(entry);
|
|
134
|
+
if (idx < 0) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
state.pendingSenders.splice(idx, 1);
|
|
138
|
+
return true;
|
|
139
|
+
};
|
|
140
|
+
if (timeout > 0) {
|
|
141
|
+
timer = setTimeout(() => {
|
|
142
|
+
if (removeEntry()) {
|
|
143
|
+
entry.reject(new NoeticErrorImpl({
|
|
144
|
+
kind: 'channel_timeout',
|
|
145
|
+
channelName,
|
|
146
|
+
timeout,
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
}, timeout);
|
|
83
150
|
}
|
|
151
|
+
if (signal) {
|
|
152
|
+
const onAbort = () => {
|
|
153
|
+
if (removeEntry()) {
|
|
154
|
+
entry.reject(cancelledError(signal));
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
signal.addEventListener('abort', onAbort, {
|
|
158
|
+
once: true,
|
|
159
|
+
});
|
|
160
|
+
removeAbortListener = () => {
|
|
161
|
+
signal.removeEventListener('abort', onAbort);
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* A dequeue freed a queue slot — shift the oldest parked sender's value
|
|
168
|
+
* into it (FIFO) and resolve that sender's promise.
|
|
169
|
+
*/
|
|
170
|
+
promotePendingSender(state) {
|
|
171
|
+
if (state.pendingSenders.length === 0) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (state.queue.length >= state.capacity) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const sender = state.pendingSenders.shift();
|
|
178
|
+
state.queue.push(sender.value);
|
|
179
|
+
sender.resolve();
|
|
180
|
+
}
|
|
181
|
+
/** Fire-and-reset the one-shot wake subscribers for a channel state. */
|
|
182
|
+
fireWakes(state) {
|
|
183
|
+
if (state.wakeSubscribers.size === 0) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// Swap in a fresh set before firing so a callback that re-subscribes
|
|
187
|
+
// (typical for `every` re-arming on its next iteration) lands on the
|
|
188
|
+
// new set and survives until the next send.
|
|
189
|
+
const wakers = state.wakeSubscribers;
|
|
190
|
+
state.wakeSubscribers = new Set();
|
|
191
|
+
for (const wake of wakers) {
|
|
192
|
+
wake();
|
|
84
193
|
}
|
|
85
194
|
}
|
|
86
|
-
async recv(channel, timeout = 30_000) {
|
|
195
|
+
async recv(channel, timeout = 30_000, signal) {
|
|
196
|
+
// An already-aborted context must reject promptly rather than park a
|
|
197
|
+
// waiter that nothing will ever wake (spec 09, Cancellation item 2).
|
|
198
|
+
if (signal?.aborted) {
|
|
199
|
+
throw cancelledError(signal);
|
|
200
|
+
}
|
|
87
201
|
const state = this.getOrCreate(channel);
|
|
88
202
|
switch (state.mode) {
|
|
89
203
|
case 'value':
|
|
90
204
|
if (state.hasValue) {
|
|
91
205
|
return state.currentValue;
|
|
92
206
|
}
|
|
93
|
-
return this.waitWithTimeout(state.valueWaiters, channel.name, timeout);
|
|
94
|
-
case 'queue':
|
|
207
|
+
return this.waitWithTimeout(state.valueWaiters, channel.name, timeout, signal);
|
|
208
|
+
case 'queue': {
|
|
95
209
|
if (state.queue.length > 0) {
|
|
96
|
-
|
|
210
|
+
const head = state.queue.shift();
|
|
211
|
+
this.promotePendingSender(state);
|
|
212
|
+
return head;
|
|
97
213
|
}
|
|
98
|
-
|
|
214
|
+
// capacity-0 edge: senders can be parked while the queue is empty —
|
|
215
|
+
// hand the oldest parked value straight to this receiver.
|
|
216
|
+
if (state.pendingSenders.length > 0) {
|
|
217
|
+
const sender = state.pendingSenders.shift();
|
|
218
|
+
sender.resolve();
|
|
219
|
+
return sender.value;
|
|
220
|
+
}
|
|
221
|
+
return this.waitWithTimeout(state.queueWaiters, channel.name, timeout, signal);
|
|
222
|
+
}
|
|
99
223
|
case 'topic': {
|
|
100
224
|
// Clamp timeout to prevent indefinite subscriber leaks
|
|
101
225
|
let effectiveTimeout = timeout;
|
|
@@ -104,8 +228,10 @@ export class ChannelStore {
|
|
|
104
228
|
effectiveTimeout = MAX_TOPIC_TIMEOUT;
|
|
105
229
|
}
|
|
106
230
|
return new Promise((resolve, reject) => {
|
|
231
|
+
let removeAbortListener = null;
|
|
107
232
|
const timer = setTimeout(() => {
|
|
108
233
|
state.topicSubscribers.delete(handler);
|
|
234
|
+
removeAbortListener?.();
|
|
109
235
|
reject(new NoeticErrorImpl({
|
|
110
236
|
kind: 'channel_timeout',
|
|
111
237
|
channelName: channel.name,
|
|
@@ -115,9 +241,23 @@ export class ChannelStore {
|
|
|
115
241
|
const handler = (value) => {
|
|
116
242
|
clearTimeout(timer);
|
|
117
243
|
state.topicSubscribers.delete(handler);
|
|
244
|
+
removeAbortListener?.();
|
|
118
245
|
resolve(value);
|
|
119
246
|
};
|
|
120
247
|
state.topicSubscribers.add(handler);
|
|
248
|
+
if (signal) {
|
|
249
|
+
const onAbort = () => {
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
state.topicSubscribers.delete(handler);
|
|
252
|
+
reject(cancelledError(signal));
|
|
253
|
+
};
|
|
254
|
+
signal.addEventListener('abort', onAbort, {
|
|
255
|
+
once: true,
|
|
256
|
+
});
|
|
257
|
+
removeAbortListener = () => {
|
|
258
|
+
signal.removeEventListener('abort', onAbort);
|
|
259
|
+
};
|
|
260
|
+
}
|
|
121
261
|
});
|
|
122
262
|
}
|
|
123
263
|
}
|
|
@@ -127,32 +267,55 @@ export class ChannelStore {
|
|
|
127
267
|
switch (state.mode) {
|
|
128
268
|
case 'value':
|
|
129
269
|
return state.hasValue ? state.currentValue : null;
|
|
130
|
-
case 'queue':
|
|
131
|
-
|
|
270
|
+
case 'queue': {
|
|
271
|
+
if (state.queue.length > 0) {
|
|
272
|
+
const head = state.queue.shift();
|
|
273
|
+
this.promotePendingSender(state);
|
|
274
|
+
return head;
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
132
278
|
case 'topic':
|
|
133
279
|
return null;
|
|
134
280
|
}
|
|
135
281
|
}
|
|
136
|
-
waitWithTimeout(waiters, channelName, timeout) {
|
|
282
|
+
waitWithTimeout(waiters, channelName, timeout, signal) {
|
|
137
283
|
return new Promise((resolve, reject) => {
|
|
138
284
|
let timer = null;
|
|
139
|
-
|
|
285
|
+
let removeAbortListener = null;
|
|
286
|
+
// Symmetric cleanup: whichever path settles the waiter (delivery,
|
|
287
|
+
// timeout, abort) must release both the timer and the abort listener.
|
|
288
|
+
const cleanup = () => {
|
|
140
289
|
if (timer) {
|
|
141
290
|
clearTimeout(timer);
|
|
142
291
|
}
|
|
292
|
+
removeAbortListener?.();
|
|
293
|
+
};
|
|
294
|
+
const wrappedResolve = (v) => {
|
|
295
|
+
cleanup();
|
|
143
296
|
resolve(v);
|
|
144
297
|
};
|
|
298
|
+
const wrappedReject = (e) => {
|
|
299
|
+
cleanup();
|
|
300
|
+
reject(e);
|
|
301
|
+
};
|
|
145
302
|
const entry = {
|
|
146
303
|
resolve: wrappedResolve,
|
|
147
|
-
reject,
|
|
304
|
+
reject: wrappedReject,
|
|
148
305
|
};
|
|
149
306
|
waiters.push(entry);
|
|
307
|
+
const removeEntry = () => {
|
|
308
|
+
const idx = waiters.indexOf(entry);
|
|
309
|
+
if (idx < 0) {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
waiters.splice(idx, 1);
|
|
313
|
+
return true;
|
|
314
|
+
};
|
|
150
315
|
if (timeout > 0) {
|
|
151
316
|
timer = setTimeout(() => {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
waiters.splice(idx, 1);
|
|
155
|
-
reject(new NoeticErrorImpl({
|
|
317
|
+
if (removeEntry()) {
|
|
318
|
+
wrappedReject(new NoeticErrorImpl({
|
|
156
319
|
kind: 'channel_timeout',
|
|
157
320
|
channelName,
|
|
158
321
|
timeout,
|
|
@@ -160,8 +323,42 @@ export class ChannelStore {
|
|
|
160
323
|
}
|
|
161
324
|
}, timeout);
|
|
162
325
|
}
|
|
326
|
+
if (signal) {
|
|
327
|
+
const onAbort = () => {
|
|
328
|
+
if (removeEntry()) {
|
|
329
|
+
wrappedReject(cancelledError(signal));
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
signal.addEventListener('abort', onAbort, {
|
|
333
|
+
once: true,
|
|
334
|
+
});
|
|
335
|
+
removeAbortListener = () => {
|
|
336
|
+
signal.removeEventListener('abort', onAbort);
|
|
337
|
+
};
|
|
338
|
+
}
|
|
163
339
|
});
|
|
164
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* External-sender write: synchronous, never back-pressured (spec 06,
|
|
343
|
+
* External Sender Back-Pressure). At capacity the OLDEST queued item is
|
|
344
|
+
* dropped (with a warning) so external callers never block.
|
|
345
|
+
*/
|
|
346
|
+
sendExternal(channel, value) {
|
|
347
|
+
const state = this.getOrCreate(channel);
|
|
348
|
+
const isFullQueue = state.mode === 'queue' &&
|
|
349
|
+
state.queueWaiters.length === 0 &&
|
|
350
|
+
state.queue.length >= state.capacity;
|
|
351
|
+
if (isFullQueue) {
|
|
352
|
+
console.warn(`[noetic] Channel '${channel.name}': queue at capacity (${state.capacity}), dropping oldest message (external sender).`);
|
|
353
|
+
state.queue.shift();
|
|
354
|
+
state.queue.push(value);
|
|
355
|
+
this.fireWakes(state);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// Below capacity (or non-queue mode) the internal path resolves
|
|
359
|
+
// synchronously — nothing can park here.
|
|
360
|
+
void this.send(channel, value);
|
|
361
|
+
}
|
|
165
362
|
getHandle(channel, executionId) {
|
|
166
363
|
const store = this;
|
|
167
364
|
return {
|
|
@@ -176,7 +373,7 @@ export class ChannelStore {
|
|
|
176
373
|
channelName: channel.name,
|
|
177
374
|
});
|
|
178
375
|
}
|
|
179
|
-
store.
|
|
376
|
+
store.sendExternal(channel, value);
|
|
180
377
|
},
|
|
181
378
|
};
|
|
182
379
|
}
|
|
@@ -34,6 +34,15 @@ export declare class ContextImpl implements Context<ContextMemory> {
|
|
|
34
34
|
private _completed;
|
|
35
35
|
private _aborted;
|
|
36
36
|
private _abortReason?;
|
|
37
|
+
/**
|
|
38
|
+
* Per-context abort fan-out. `abort()` fires it so operations blocked on
|
|
39
|
+
* this context (channel `recv` waiters, parked back-pressure senders)
|
|
40
|
+
* reject promptly with `{ kind: 'cancelled' }` instead of hanging until
|
|
41
|
+
* their timeout (spec 09, Cancellation item 2). Each fork/spawn child
|
|
42
|
+
* constructs its own ContextImpl and therefore its own controller —
|
|
43
|
+
* aborting a child never rejects the parent's waiters.
|
|
44
|
+
*/
|
|
45
|
+
private readonly _abortController;
|
|
37
46
|
private _memory?;
|
|
38
47
|
/**
|
|
39
48
|
* Stack of steps currently in flight on this context, most-recent last.
|
|
@@ -77,7 +86,7 @@ export declare class ContextImpl implements Context<ContextMemory> {
|
|
|
77
86
|
recv<T>(ch: Channel<T>, opts?: {
|
|
78
87
|
timeout?: number;
|
|
79
88
|
}): Promise<T>;
|
|
80
|
-
send<T>(ch: Channel<T>, value: T): void
|
|
89
|
+
send<T>(ch: Channel<T>, value: T): Promise<void>;
|
|
81
90
|
tryRecv<T>(ch: Channel<T>): T | null;
|
|
82
91
|
checkpoint(): Promise<void>;
|
|
83
92
|
complete<T>(value: T): void;
|
|
@@ -85,6 +94,13 @@ export declare class ContextImpl implements Context<ContextMemory> {
|
|
|
85
94
|
get completionValue(): unknown;
|
|
86
95
|
get abortReason(): string | undefined;
|
|
87
96
|
abort(reason?: string): void;
|
|
97
|
+
/**
|
|
98
|
+
* @internal
|
|
99
|
+
* Abort signal scoped to this context. Channel operations register on it
|
|
100
|
+
* so `abort()` rejects them promptly. Not part of the public Context
|
|
101
|
+
* interface.
|
|
102
|
+
*/
|
|
103
|
+
get abortSignal(): AbortSignal;
|
|
88
104
|
/**
|
|
89
105
|
* @internal
|
|
90
106
|
* Push a frame onto the execution frontier. Called by `execute()` at the
|
|
@@ -48,6 +48,15 @@ export class ContextImpl {
|
|
|
48
48
|
_completed = false;
|
|
49
49
|
_aborted = false;
|
|
50
50
|
_abortReason;
|
|
51
|
+
/**
|
|
52
|
+
* Per-context abort fan-out. `abort()` fires it so operations blocked on
|
|
53
|
+
* this context (channel `recv` waiters, parked back-pressure senders)
|
|
54
|
+
* reject promptly with `{ kind: 'cancelled' }` instead of hanging until
|
|
55
|
+
* their timeout (spec 09, Cancellation item 2). Each fork/spawn child
|
|
56
|
+
* constructs its own ContextImpl and therefore its own controller —
|
|
57
|
+
* aborting a child never rejects the parent's waiters.
|
|
58
|
+
*/
|
|
59
|
+
_abortController = new AbortController();
|
|
51
60
|
_memory;
|
|
52
61
|
/**
|
|
53
62
|
* Stack of steps currently in flight on this context, most-recent last.
|
|
@@ -110,13 +119,18 @@ export class ContextImpl {
|
|
|
110
119
|
if (!this.channelStore) {
|
|
111
120
|
return Promise.reject(new Error('No channel store configured'));
|
|
112
121
|
}
|
|
113
|
-
return this.channelStore.recv(ch, opts?.timeout);
|
|
122
|
+
return this.channelStore.recv(ch, opts?.timeout, this._abortController.signal);
|
|
114
123
|
}
|
|
115
124
|
send(ch, value) {
|
|
116
125
|
if (!this.channelStore) {
|
|
117
|
-
|
|
126
|
+
return Promise.reject(new Error('No channel store configured'));
|
|
118
127
|
}
|
|
119
|
-
|
|
128
|
+
// Internal sender: back-pressured on full queue channels (default 30s →
|
|
129
|
+
// channel_timeout); aborting this context rejects a parked send with
|
|
130
|
+
// 'cancelled' via the shared abort signal.
|
|
131
|
+
return this.channelStore.send(ch, value, {
|
|
132
|
+
signal: this._abortController.signal,
|
|
133
|
+
});
|
|
120
134
|
}
|
|
121
135
|
tryRecv(ch) {
|
|
122
136
|
if (!this.channelStore) {
|
|
@@ -145,6 +159,19 @@ export class ContextImpl {
|
|
|
145
159
|
abort(reason) {
|
|
146
160
|
this._aborted = true;
|
|
147
161
|
this._abortReason = reason;
|
|
162
|
+
// Reject everything blocked on this context (channel recv waiters,
|
|
163
|
+
// parked back-pressure senders) with { kind: 'cancelled' }. Idempotent —
|
|
164
|
+
// a second abort() leaves the already-aborted signal untouched.
|
|
165
|
+
this._abortController.abort(reason ?? 'context aborted');
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* @internal
|
|
169
|
+
* Abort signal scoped to this context. Channel operations register on it
|
|
170
|
+
* so `abort()` rejects them promptly. Not part of the public Context
|
|
171
|
+
* interface.
|
|
172
|
+
*/
|
|
173
|
+
get abortSignal() {
|
|
174
|
+
return this._abortController.signal;
|
|
148
175
|
}
|
|
149
176
|
/**
|
|
150
177
|
* @internal
|
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
* and without the agent asking for external input — i.e. the agent
|
|
6
6
|
* stopped without calling either a terminal tool or an ask-user-style
|
|
7
7
|
* tool. That's a soft failure: rather than wait forever, we nudge the
|
|
8
|
-
* agent once to remind it of its options
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* agent once to remind it of its options and wait for the nudged turn
|
|
9
|
+
* to actually complete (`harness.execute` is enqueue-only, so the hook
|
|
10
|
+
* awaits `getAgentResponse` — raced against the signal — before
|
|
11
|
+
* re-checking). If the second turn also ends in the same stalled shape
|
|
12
|
+
* we escalate: the caller-supplied `onStall` callback records the
|
|
13
|
+
* escalation (task pause, metrics, whatever the domain demands) and we
|
|
14
|
+
* settle the signal ourselves with a canonical "stalled" outcome so
|
|
15
|
+
* the parent unwinds cleanly.
|
|
13
16
|
*
|
|
14
17
|
* The helper factors the state machine out of the loop so callers can
|
|
15
18
|
* opt in per-runner. Task-specific concerns (how "pending external
|
|
@@ -74,9 +77,13 @@ export declare function createNudgeMessage(opts: CreateNudgeMessageOpts): InputM
|
|
|
74
77
|
* - the caller reports pending external input (`hasPendingExternal`
|
|
75
78
|
* returns true — e.g. an outstanding ask-user request).
|
|
76
79
|
*
|
|
77
|
-
* Otherwise it sends the nudge message via `harness.execute
|
|
78
|
-
* for
|
|
79
|
-
* the
|
|
80
|
-
*
|
|
80
|
+
* Otherwise it sends the nudge message via `harness.execute` and waits
|
|
81
|
+
* for the nudged turn to actually complete. `execute()` only enqueues
|
|
82
|
+
* the message — the turn runs asynchronously through the session
|
|
83
|
+
* runner — so the hook awaits `harness.getAgentResponse(threadId)`
|
|
84
|
+
* (raced against `signal.done` so a terminal tool that settles the
|
|
85
|
+
* signal mid-turn short-circuits the wait) before re-checking the same
|
|
86
|
+
* two conditions. If the agent still hasn't progressed, the hook fires
|
|
87
|
+
* `onStall` and resolves the signal with `buildStalledOutcome()`.
|
|
81
88
|
*/
|
|
82
89
|
export declare function createStallNudgeHook<TOutcome>(opts: StallNudgeOpts<TOutcome>): (ctx: AfterFirstTurnContext) => Promise<void>;
|
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
* and without the agent asking for external input — i.e. the agent
|
|
6
6
|
* stopped without calling either a terminal tool or an ask-user-style
|
|
7
7
|
* tool. That's a soft failure: rather than wait forever, we nudge the
|
|
8
|
-
* agent once to remind it of its options
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* agent once to remind it of its options and wait for the nudged turn
|
|
9
|
+
* to actually complete (`harness.execute` is enqueue-only, so the hook
|
|
10
|
+
* awaits `getAgentResponse` — raced against the signal — before
|
|
11
|
+
* re-checking). If the second turn also ends in the same stalled shape
|
|
12
|
+
* we escalate: the caller-supplied `onStall` callback records the
|
|
13
|
+
* escalation (task pause, metrics, whatever the domain demands) and we
|
|
14
|
+
* settle the signal ourselves with a canonical "stalled" outcome so
|
|
15
|
+
* the parent unwinds cleanly.
|
|
13
16
|
*
|
|
14
17
|
* The helper factors the state machine out of the loop so callers can
|
|
15
18
|
* opt in per-runner. Task-specific concerns (how "pending external
|
|
@@ -55,10 +58,14 @@ export function createNudgeMessage(opts) {
|
|
|
55
58
|
* - the caller reports pending external input (`hasPendingExternal`
|
|
56
59
|
* returns true — e.g. an outstanding ask-user request).
|
|
57
60
|
*
|
|
58
|
-
* Otherwise it sends the nudge message via `harness.execute
|
|
59
|
-
* for
|
|
60
|
-
* the
|
|
61
|
-
*
|
|
61
|
+
* Otherwise it sends the nudge message via `harness.execute` and waits
|
|
62
|
+
* for the nudged turn to actually complete. `execute()` only enqueues
|
|
63
|
+
* the message — the turn runs asynchronously through the session
|
|
64
|
+
* runner — so the hook awaits `harness.getAgentResponse(threadId)`
|
|
65
|
+
* (raced against `signal.done` so a terminal tool that settles the
|
|
66
|
+
* signal mid-turn short-circuits the wait) before re-checking the same
|
|
67
|
+
* two conditions. If the agent still hasn't progressed, the hook fires
|
|
68
|
+
* `onStall` and resolves the signal with `buildStalledOutcome()`.
|
|
62
69
|
*/
|
|
63
70
|
export function createStallNudgeHook(opts) {
|
|
64
71
|
return async (ctx) => {
|
|
@@ -72,7 +79,19 @@ export function createStallNudgeHook(opts) {
|
|
|
72
79
|
await opts.harness.execute(opts.nudgeMessage, {
|
|
73
80
|
threadId: opts.threadId,
|
|
74
81
|
});
|
|
75
|
-
|
|
82
|
+
// Wait for the nudged turn to genuinely finish. A one-microtask wait
|
|
83
|
+
// would escalate before the agent's LLM round trip even starts —
|
|
84
|
+
// `execute()` resolves as soon as the message is enqueued. Errors from
|
|
85
|
+
// `getAgentResponse` are swallowed: a failed response fetch must not
|
|
86
|
+
// crash the hook, it just means we proceed to the stall re-check.
|
|
87
|
+
await Promise.race([
|
|
88
|
+
opts.harness
|
|
89
|
+
.getAgentResponse({
|
|
90
|
+
threadId: opts.threadId,
|
|
91
|
+
})
|
|
92
|
+
.catch(() => undefined),
|
|
93
|
+
opts.signal.done.then(() => undefined, () => undefined),
|
|
94
|
+
]);
|
|
76
95
|
if (ctx.signalSettled()) {
|
|
77
96
|
return;
|
|
78
97
|
}
|
|
@@ -45,7 +45,13 @@ export declare class EventBroadcaster {
|
|
|
45
45
|
}
|
|
46
46
|
declare class BroadcastIterator implements AsyncIterator<StreamEvent> {
|
|
47
47
|
private cursor;
|
|
48
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Parked `next()` calls, FIFO. The AsyncIterator protocol allows pipelined
|
|
50
|
+
* `next()` calls (calling again before the previous promise settles), so a
|
|
51
|
+
* single waiter slot would orphan all but the latest call — the overwritten
|
|
52
|
+
* promises would never settle. `notify()` drains in arrival order.
|
|
53
|
+
*/
|
|
54
|
+
private readonly waiters;
|
|
49
55
|
private returned;
|
|
50
56
|
private readonly broadcaster;
|
|
51
57
|
constructor(broadcaster: EventBroadcaster);
|
|
@@ -54,6 +60,8 @@ declare class BroadcastIterator implements AsyncIterator<StreamEvent> {
|
|
|
54
60
|
/** Called by the broadcaster when new data or completion is available. */
|
|
55
61
|
notify(): void;
|
|
56
62
|
next(): Promise<IteratorResult<StreamEvent>>;
|
|
63
|
+
/** Settle every parked waiter with `{ done: true }` — used on early exit. */
|
|
64
|
+
private settleWaiters;
|
|
57
65
|
throw(err: Error): Promise<IteratorResult<StreamEvent>>;
|
|
58
66
|
return(): Promise<IteratorResult<StreamEvent>>;
|
|
59
67
|
private tryRead;
|