@north-light/crouter 0.3.250 → 0.3.251
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/core/__tests__/canvas-inbox-watcher-hold.test.js +95 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +9 -2
- package/dist/core/__tests__/seam/held-deferred-human-prompt.test.d.ts +1 -0
- package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +63 -0
- package/dist/core/runtime/broker/engine-drive.js +14 -3
- package/dist/core/runtime/broker/held-deferred-inbox.d.ts +9 -0
- package/dist/core/runtime/broker/held-deferred-inbox.js +8 -0
- package/dist/pi-extensions/canvas-inbox-watcher.js +105 -4
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -13,6 +13,9 @@ import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher
|
|
|
13
13
|
import { appendInbox, readCursor } from '../feed/inbox.js';
|
|
14
14
|
import { drainBearings } from '../runtime/kickoff.js';
|
|
15
15
|
import { appendSituationalContext } from '../runtime/situational-context.js';
|
|
16
|
+
import { createEngineDriver } from '../runtime/broker/engine-drive.js';
|
|
17
|
+
import { createTurnAdmissionGate } from '../runtime/broker/turn-admission.js';
|
|
18
|
+
import { heldDeferredInboxPromptJoin } from '../runtime/broker/held-deferred-inbox.js';
|
|
16
19
|
import { createNode, getNode, recordPid, setIntent } from '../canvas/canvas.js';
|
|
17
20
|
import { createProfile, pauseProfile, resumeProfile } from '../profiles/manifest.js';
|
|
18
21
|
import { closeDb } from '../canvas/db.js';
|
|
@@ -275,6 +278,98 @@ describe('canvas inbox watcher — hold + idle delivery', () => {
|
|
|
275
278
|
assert.equal(pi.injected.length, 1);
|
|
276
279
|
assert.equal(pi.injected[0].deliverAs, undefined, 'idle → sendUserMessage triggers a turn, no deliverAs');
|
|
277
280
|
});
|
|
281
|
+
test('the broker prompt boundary prepends held deferred mail before the human body', async () => {
|
|
282
|
+
freshNode('node-deferred-engine-prompt');
|
|
283
|
+
const pi = makeFakePi();
|
|
284
|
+
disposers.push(registerWatcher(pi));
|
|
285
|
+
await wait(TICK_MS + 100);
|
|
286
|
+
const entry = appendInbox('node-deferred-engine-prompt', {
|
|
287
|
+
from: 'child-4',
|
|
288
|
+
tier: 'deferred',
|
|
289
|
+
kind: 'update',
|
|
290
|
+
label: 'held update',
|
|
291
|
+
});
|
|
292
|
+
await wait(SETTLE_MS);
|
|
293
|
+
const order = [];
|
|
294
|
+
const session = {
|
|
295
|
+
isStreaming: false,
|
|
296
|
+
sendCustomMessage: async ({ content }) => { order.push(content); },
|
|
297
|
+
prompt: async (text, options) => {
|
|
298
|
+
order.push(text);
|
|
299
|
+
options.preflightResult?.(true);
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
const drive = createEngineDriver({
|
|
303
|
+
registry: { sendFrame: () => undefined },
|
|
304
|
+
replies: {},
|
|
305
|
+
session: () => session,
|
|
306
|
+
dispatches: { track: (promise) => promise },
|
|
307
|
+
memoryRefs: { guidanceFor: () => null },
|
|
308
|
+
authReloadGate: { inFlight: () => undefined },
|
|
309
|
+
rebind: { installedGeneration: () => 0 },
|
|
310
|
+
projection: {},
|
|
311
|
+
toolGroups: {},
|
|
312
|
+
turnAdmission: createTurnAdmissionGate(),
|
|
313
|
+
});
|
|
314
|
+
await drive({}, { type: 'prompt', text: 'where are we?' });
|
|
315
|
+
assert.equal(order.length, 2, 'one held card and the one human prompt share the admitted operation');
|
|
316
|
+
assert.match(order[0], /held update/, 'the held inbox card is first');
|
|
317
|
+
assert.equal(order[1], 'where are we?', 'the human body follows the held card');
|
|
318
|
+
assert.equal(readCursor('node-deferred-engine-prompt'), undefined, 'the joined handoff waits for settlement');
|
|
319
|
+
await pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
|
|
320
|
+
await settle(pi);
|
|
321
|
+
assert.equal(readCursor('node-deferred-engine-prompt'), entry.entry_id, 'agent_settled commits the broker-joined handoff');
|
|
322
|
+
const staleFollowUpEntry = appendInbox('node-deferred-engine-prompt', {
|
|
323
|
+
from: 'child-4',
|
|
324
|
+
tier: 'deferred',
|
|
325
|
+
kind: 'update',
|
|
326
|
+
label: 'held after a stale client snapshot',
|
|
327
|
+
});
|
|
328
|
+
await wait(SETTLE_MS);
|
|
329
|
+
const beforeFollowUp = order.length;
|
|
330
|
+
await drive({}, { type: 'follow_up', text: 'continue from there' });
|
|
331
|
+
assert.equal(order.length, beforeFollowUp + 2, 'an idle-routed follow_up shares the same prompt boundary');
|
|
332
|
+
assert.match(order.at(-2), /held after a stale client snapshot/, 'the held card still precedes the stale follow_up body');
|
|
333
|
+
assert.equal(order.at(-1), 'continue from there');
|
|
334
|
+
await pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
|
|
335
|
+
await settle(pi);
|
|
336
|
+
assert.equal(readCursor('node-deferred-engine-prompt'), staleFollowUpEntry.entry_id, 'the m-C prompt settles its joined handoff too');
|
|
337
|
+
});
|
|
338
|
+
test('a held deferred batch is prepended to an admitted human prompt and replays if that prompt reloads before settlement', async () => {
|
|
339
|
+
freshNode('node-deferred-human-prompt');
|
|
340
|
+
const pi = makeFakePi();
|
|
341
|
+
disposers.push(registerWatcher(pi));
|
|
342
|
+
await wait(TICK_MS + 100);
|
|
343
|
+
const entry = appendInbox('node-deferred-human-prompt', {
|
|
344
|
+
from: 'child-4',
|
|
345
|
+
tier: 'deferred',
|
|
346
|
+
kind: 'update',
|
|
347
|
+
label: 'held update',
|
|
348
|
+
});
|
|
349
|
+
await wait(SETTLE_MS);
|
|
350
|
+
assert.equal(pi.injected.length, 0, 'deferred-only mail remains held while no turn starts');
|
|
351
|
+
const firstPromptPrefix = [];
|
|
352
|
+
await heldDeferredInboxPromptJoin()?.({
|
|
353
|
+
sendCustomMessage: async ({ content }) => { firstPromptPrefix.push(content); },
|
|
354
|
+
});
|
|
355
|
+
assert.equal(firstPromptPrefix.length, 1, 'the held card is sent before the human prompt starts');
|
|
356
|
+
assert.match(firstPromptPrefix[0], /held update/);
|
|
357
|
+
assert.equal(readCursor('node-deferred-human-prompt'), undefined, 'the pre-prompt handoff is not committed');
|
|
358
|
+
await shutdown(pi);
|
|
359
|
+
assert.equal(readCursor('node-deferred-human-prompt'), undefined, 'a reload before agent_settled leaves the held entry replayable');
|
|
360
|
+
const replacement = makeFakePi();
|
|
361
|
+
disposers.push(registerWatcher(replacement));
|
|
362
|
+
await wait(SETTLE_MS);
|
|
363
|
+
const replayedPrefix = [];
|
|
364
|
+
await heldDeferredInboxPromptJoin()?.({
|
|
365
|
+
sendCustomMessage: async ({ content }) => { replayedPrefix.push(content); },
|
|
366
|
+
});
|
|
367
|
+
assert.equal(replayedPrefix.length, 1, 'the replacement prompt receives the uncommitted held card again');
|
|
368
|
+
assert.match(replayedPrefix[0], /held update/);
|
|
369
|
+
await replacement.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
|
|
370
|
+
await settle(replacement);
|
|
371
|
+
assert.equal(readCursor('node-deferred-human-prompt'), entry.entry_id, 'only agent_settled commits the prompt-joined handoff');
|
|
372
|
+
});
|
|
278
373
|
// Pause makes the profile inert while its broker is still alive: the entry
|
|
279
374
|
// must be RETAINED, not consumed. A critical entry is the sharpest case —
|
|
280
375
|
// its route aborts the live turn, exactly the punch-through pause forbids.
|
|
@@ -494,12 +494,19 @@ class FakeSession {
|
|
|
494
494
|
* a resolvable `/name` token must not crash on a missing method. */
|
|
495
495
|
customMessages = [];
|
|
496
496
|
sendCustomMessage(message, options) {
|
|
497
|
-
|
|
497
|
+
const customMessage = {
|
|
498
498
|
customType: message.customType,
|
|
499
499
|
content: message.content,
|
|
500
500
|
display: message.display,
|
|
501
501
|
deliverAs: options?.deliverAs,
|
|
502
|
-
}
|
|
502
|
+
};
|
|
503
|
+
this.customMessages.push(customMessage);
|
|
504
|
+
try {
|
|
505
|
+
appendFileSync(join(this.dir, 'fake-pi.custom-messages.jsonl'), JSON.stringify(customMessage) + '\n');
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
/* best effort test proof */
|
|
509
|
+
}
|
|
503
510
|
return Promise.resolve();
|
|
504
511
|
}
|
|
505
512
|
abort() {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Run with: npm run build && node --test --test-timeout=180000 dist/core/__tests__/seam/held-deferred-human-prompt.test.js
|
|
2
|
+
//
|
|
3
|
+
// Regression: Pi loads canvas extensions through Jiti with moduleCache:false,
|
|
4
|
+
// while broker frame dispatch is native ESM. Deferred mail held by the Jiti
|
|
5
|
+
// watcher must still join the next human prompt driven by native broker code.
|
|
6
|
+
import { after, before, test } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { appendInbox, readCursor } from '../../feed/inbox.js';
|
|
11
|
+
import { updateNode } from '../../canvas/index.js';
|
|
12
|
+
import { reviveNode } from '../../runtime/revive.js';
|
|
13
|
+
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
14
|
+
import { createAttachKit, delay, frameHas, tok } from '../helpers/broker-clients.js';
|
|
15
|
+
let h;
|
|
16
|
+
let root;
|
|
17
|
+
const kit = createAttachKit(() => h);
|
|
18
|
+
before(async () => {
|
|
19
|
+
h = await createHeadlessHarness({ sessionPrefix: 'crtr-held-deferred-prompt' });
|
|
20
|
+
root = h.spawnRoot('held deferred prompt seam root');
|
|
21
|
+
});
|
|
22
|
+
after(async () => {
|
|
23
|
+
kit.closeAll();
|
|
24
|
+
if (h !== undefined)
|
|
25
|
+
await h.dispose();
|
|
26
|
+
});
|
|
27
|
+
test('a Jiti-loaded watcher prepends its held deferred batch to a native broker human prompt', { timeout: 30_000 }, async () => {
|
|
28
|
+
const nodeId = h.fabricateBrokerNode({
|
|
29
|
+
id: 'held-deferred-human-prompt',
|
|
30
|
+
parent: root,
|
|
31
|
+
lifecycle: 'resident',
|
|
32
|
+
status: 'idle',
|
|
33
|
+
});
|
|
34
|
+
updateNode(nodeId, {
|
|
35
|
+
launch: {
|
|
36
|
+
extensions: [],
|
|
37
|
+
tools: [],
|
|
38
|
+
systemPrompt: '',
|
|
39
|
+
env: {
|
|
40
|
+
CRTR_WATCHER_TICK_MS: '20',
|
|
41
|
+
CRTR_WATCHER_DEBOUNCE_MS: '25',
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
46
|
+
await h.awaitBoot(nodeId);
|
|
47
|
+
const entry = appendInbox(nodeId, {
|
|
48
|
+
from: 'child',
|
|
49
|
+
tier: 'deferred',
|
|
50
|
+
kind: 'update',
|
|
51
|
+
label: 'held Jiti deferred update',
|
|
52
|
+
});
|
|
53
|
+
await delay(150);
|
|
54
|
+
const customPath = join(h.home, 'nodes', nodeId, 'fake-pi.custom-messages.jsonl');
|
|
55
|
+
assert.equal(existsSync(customPath), false, 'deferred-only mail stays held and does not wake the broker');
|
|
56
|
+
const controller = await kit.attach(nodeId, 'controller', 'held-deferred-prompt-controller');
|
|
57
|
+
const prompt = tok('HELD-DEFERRED-HUMAN-PROMPT');
|
|
58
|
+
controller.send({ type: 'prompt', text: prompt });
|
|
59
|
+
await controller.waitFrame((frame) => frame.type === 'agent_end' && frameHas(frame, prompt), 'the human prompt turn');
|
|
60
|
+
const customMessages = readFileSync(customPath, 'utf8');
|
|
61
|
+
assert.match(customMessages, /held Jiti deferred update/, 'the Jiti extension instance joined its held batch before the native broker prompt');
|
|
62
|
+
assert.equal(readCursor(nodeId), entry.entry_id, 'agent_settled commits the joined handoff');
|
|
63
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { emitEvent } from '../../events/emit.js';
|
|
2
2
|
import { operationIdContext } from '../../events/operation-id.js';
|
|
3
3
|
import { promptWithAdvertisedCommandInvocation } from '../advertised-command-invocation.js';
|
|
4
|
+
import { heldDeferredInboxPromptJoin } from './held-deferred-inbox.js';
|
|
4
5
|
import { chooseGuidanceDeliveryMode, promptWithNextTurnGuidance, resolveEngineRoute, resolveSteerCall, } from './engine-routing.js';
|
|
5
6
|
export function createEngineDriver(deps) {
|
|
6
7
|
const { registry, replies, session, dispatches, memoryRefs, authReloadGate, rebind, projection, toolGroups, turnAdmission, } = deps;
|
|
@@ -44,14 +45,24 @@ export function createEngineDriver(deps) {
|
|
|
44
45
|
// false. Admission waits for that preflight, then routes against the
|
|
45
46
|
// state it actually established instead of its stale snapshot.
|
|
46
47
|
const route = resolveEngineRoute(frame, session().isStreaming);
|
|
48
|
+
const promptOptions = route.call === 'prompt' ? route.options : undefined;
|
|
47
49
|
const guidance = memoryRefs.guidanceFor(frame.text);
|
|
50
|
+
const prompt = async () => {
|
|
51
|
+
// Both human surfaces converge at this admitted boundary. The watcher
|
|
52
|
+
// claims held deferred mail before the prompt body, but its cursor stays
|
|
53
|
+
// uncommitted until the resulting turn's agent_settled event.
|
|
54
|
+
if (route.call === 'prompt' && !session().isStreaming) {
|
|
55
|
+
await heldDeferredInboxPromptJoin()?.(session());
|
|
56
|
+
}
|
|
57
|
+
await session().prompt(frame.text, { ...promptOptions, preflightResult: release });
|
|
58
|
+
};
|
|
48
59
|
if (guidance === null) {
|
|
49
60
|
if (route.call === 'prompt') {
|
|
50
61
|
// A routed prompt STARTS a turn — including an idle follow_up
|
|
51
62
|
// escalated by m-C — so it gets a fresh operation id. A true
|
|
52
63
|
// (mid-stream) followUp rides the current turn's operation, like
|
|
53
64
|
// steer.
|
|
54
|
-
return settle(dispatches.track(operationIdContext.fresh(() => promptWithAdvertisedCommandInvocation(session(), frame.text,
|
|
65
|
+
return settle(dispatches.track(operationIdContext.fresh(() => promptWithAdvertisedCommandInvocation(session(), frame.text, prompt).catch(relayError))));
|
|
55
66
|
}
|
|
56
67
|
// C1: pi's followUp() takes images as a POSITIONAL 2nd arg (`followUp(text, images?)`).
|
|
57
68
|
release();
|
|
@@ -67,9 +78,9 @@ export function createEngineDriver(deps) {
|
|
|
67
78
|
// ATOMICALLY clears its own pending entry if prompt() settles
|
|
68
79
|
// without ever reaching that drain (an early throw/return leaves
|
|
69
80
|
// nothing stranded to leak into a later, unrelated turn).
|
|
70
|
-
return settle(dispatches.track(promptWithNextTurnGuidance(session(), guidance, () => operationIdContext.fresh(() => promptWithAdvertisedCommandInvocation(session(), frame.text,
|
|
81
|
+
return settle(dispatches.track(promptWithNextTurnGuidance(session(), guidance, () => operationIdContext.fresh(() => promptWithAdvertisedCommandInvocation(session(), frame.text, prompt))).catch(relayError)));
|
|
71
82
|
}
|
|
72
|
-
return settle(dispatches.track(operationIdContext.fresh(() => promptWithAdvertisedCommandInvocation(session(), frame.text,
|
|
83
|
+
return settle(dispatches.track(operationIdContext.fresh(() => promptWithAdvertisedCommandInvocation(session(), frame.text, prompt)
|
|
73
84
|
.catch(relayError)
|
|
74
85
|
.then(() => sendGuidance(guidance, deliverAs)))));
|
|
75
86
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const HELD_DEFERRED_INBOX_PROMPT_JOIN: unique symbol;
|
|
2
|
+
export type HeldDeferredInboxPromptJoin = (session: {
|
|
3
|
+
sendCustomMessage: (message: {
|
|
4
|
+
customType: string;
|
|
5
|
+
content: string;
|
|
6
|
+
display: boolean;
|
|
7
|
+
}) => Promise<void>;
|
|
8
|
+
}) => Promise<void>;
|
|
9
|
+
export declare function heldDeferredInboxPromptJoin(): HeldDeferredInboxPromptJoin | undefined;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The inbox watcher is a Pi extension loaded through Jiti, while the broker is
|
|
2
|
+
// native ESM. They therefore cannot share a module singleton. This process-global
|
|
3
|
+
// symbol names the watcher-owned join function across that loader boundary.
|
|
4
|
+
export const HELD_DEFERRED_INBOX_PROMPT_JOIN = Symbol.for('@crouton-kit/crtr:held-deferred-inbox-prompt-join');
|
|
5
|
+
export function heldDeferredInboxPromptJoin() {
|
|
6
|
+
const join = globalThis[HELD_DEFERRED_INBOX_PROMPT_JOIN];
|
|
7
|
+
return typeof join === 'function' ? join : undefined;
|
|
8
|
+
}
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
// Context sends never trigger a turn: the visible human delivery is the sole
|
|
11
11
|
// trigger, so the agent cannot run on context alone before hearing the person.
|
|
12
12
|
// A context-only batch keeps its wake by triggering on its LAST card while idle,
|
|
13
|
-
// and rides the running turn while streaming.
|
|
13
|
+
// and rides the running turn while streaming. A deferred-only idle batch stays
|
|
14
|
+
// held until either durable mail wakes a turn or the broker prepends it to an
|
|
15
|
+
// already-admitted live human prompt; that bridge still commits only at
|
|
16
|
+
// agent_settled.
|
|
14
17
|
import { envNodeId, envProfileId, envWatcherDebounceMs, envWatcherTickMs } from '../shared/env.js';
|
|
15
18
|
import { coalesceBrokerInbox, entryCards, readBrokerCanceledEntryIds, readBrokerCursor, readBrokerInboxSince, reportRefNodeId, } from '../core/runtime/broker/inbox.js';
|
|
16
19
|
import { advanceBrokerInboxCursor, brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
|
|
@@ -20,6 +23,7 @@ import { errorClassFromError } from '../core/events/errors.js';
|
|
|
20
23
|
import { operationIdContext } from '../core/events/operation-id.js';
|
|
21
24
|
import { situationalContextEnvelope, SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../core/runtime/situational-context.js';
|
|
22
25
|
import { isProfilePaused } from '../core/profiles/manifest.js';
|
|
26
|
+
import { HELD_DEFERRED_INBOX_PROMPT_JOIN, } from '../core/runtime/broker/held-deferred-inbox.js';
|
|
23
27
|
const MAX_PENDING_HANDOFFS = 64;
|
|
24
28
|
const MAX_SOURCE_OPERATION_IDS = 16;
|
|
25
29
|
let currentWatcher;
|
|
@@ -188,6 +192,7 @@ class CanvasInboxWatcher {
|
|
|
188
192
|
lastArrival = 0;
|
|
189
193
|
pendingHandoffs = [];
|
|
190
194
|
pendingCommit;
|
|
195
|
+
promptJoinInFlight = false;
|
|
191
196
|
seeded = false;
|
|
192
197
|
disposed = false;
|
|
193
198
|
disposalReason;
|
|
@@ -250,7 +255,7 @@ class CanvasInboxWatcher {
|
|
|
250
255
|
// every queued continuation has drained, so hasPendingMessages() must
|
|
251
256
|
// not veto the commit here. Await crtrd before changing the in-memory
|
|
252
257
|
// durable cursor; a failed commit remains replayable after process exit.
|
|
253
|
-
const result = await this.settle(nodeId, ctx, false);
|
|
258
|
+
const result = await this.settle(nodeId, ctx, false, true);
|
|
254
259
|
if (result === 'refresh_held')
|
|
255
260
|
this.dispose('refresh', false);
|
|
256
261
|
}
|
|
@@ -383,6 +388,7 @@ class CanvasInboxWatcher {
|
|
|
383
388
|
this.reportNodes.clear();
|
|
384
389
|
this.reportNodesAsked.clear();
|
|
385
390
|
this.pendingCommit = undefined;
|
|
391
|
+
this.promptJoinInFlight = false;
|
|
386
392
|
this.nodeId = undefined;
|
|
387
393
|
this.lastCtx = undefined;
|
|
388
394
|
this.lastArrival = 0;
|
|
@@ -531,6 +537,8 @@ class CanvasInboxWatcher {
|
|
|
531
537
|
this.retryPendingCommitOnTick(nodeId);
|
|
532
538
|
return;
|
|
533
539
|
}
|
|
540
|
+
if (this.promptJoinInFlight)
|
|
541
|
+
return;
|
|
534
542
|
// A paused profile is inert: read nothing, send nothing, leave the cursor
|
|
535
543
|
// where it is, so every queued entry — critical included, since aborting the
|
|
536
544
|
// live turn is exactly the punch-through pause forbids — is still there to
|
|
@@ -599,6 +607,9 @@ class CanvasInboxWatcher {
|
|
|
599
607
|
const hiddenIds = new Set(hiddenEntries.map((entry) => entry.entry_id));
|
|
600
608
|
const visibleIds = new Set(visibleEntries.map((entry) => entry.entry_id));
|
|
601
609
|
const routedEntries = entries.filter((entry) => hiddenIds.has(entry.entry_id) || visibleIds.has(entry.entry_id));
|
|
610
|
+
// Deferred-only idle mail remains in this buffer until a non-deferred inbox
|
|
611
|
+
// entry starts delivery, or a live human prompt claims it through
|
|
612
|
+
// prependHeldDeferredInbox(). Neither path wakes the node on its own.
|
|
602
613
|
const deferredHold = this.isIdle()
|
|
603
614
|
&& routedEntries.length > 0
|
|
604
615
|
&& routedEntries.every((entry) => entry.tier === 'deferred');
|
|
@@ -826,6 +837,83 @@ class CanvasInboxWatcher {
|
|
|
826
837
|
this.requeueFromBatch(batch, execution.requeueEntryIds);
|
|
827
838
|
return 'attempted';
|
|
828
839
|
}
|
|
840
|
+
/** Claim the deferred-only idle batch for the human prompt already admitted
|
|
841
|
+
* by the broker. Each rendered delivery is persisted before that prompt, but
|
|
842
|
+
* no cursor may advance until the prompt's agent_settled event proves the
|
|
843
|
+
* turn consumed it. */
|
|
844
|
+
async prependHeldDeferredInbox(session) {
|
|
845
|
+
if (!this.canMutate() || this.nodeId === undefined || profilePaused())
|
|
846
|
+
return;
|
|
847
|
+
const plan = this.ordinaryPlan();
|
|
848
|
+
if (!plan.deferredHold)
|
|
849
|
+
return;
|
|
850
|
+
const deliveries = plan.visibleEntries.length === 0
|
|
851
|
+
? []
|
|
852
|
+
: this.digest(plan.visibleEntries, 'poll');
|
|
853
|
+
const handoffCount = Number(plan.hiddenEntries.length > 0) + deliveries.length;
|
|
854
|
+
if (this.pendingHandoffs.length + handoffCount > MAX_PENDING_HANDOFFS)
|
|
855
|
+
return;
|
|
856
|
+
const batch = [...this.buffer];
|
|
857
|
+
this.promptJoinInFlight = true;
|
|
858
|
+
try {
|
|
859
|
+
if (plan.hiddenEntries.length > 0) {
|
|
860
|
+
await this.sendPromptJoinDelivery(session, 'hidden', plan.hiddenEntries, this.contextCards(this.nodeId, plan.hiddenEntries));
|
|
861
|
+
}
|
|
862
|
+
for (const delivery of deliveries) {
|
|
863
|
+
await this.sendPromptJoinDelivery(session, 'visible', delivery.entries, [...delivery.cards, delivery.text]);
|
|
864
|
+
}
|
|
865
|
+
if (!this.canMutate())
|
|
866
|
+
return;
|
|
867
|
+
this.buffer.splice(0, batch.length);
|
|
868
|
+
}
|
|
869
|
+
finally {
|
|
870
|
+
this.promptJoinInFlight = false;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
async sendPromptJoinDelivery(session, channel, entries, messages) {
|
|
874
|
+
let facts;
|
|
875
|
+
try {
|
|
876
|
+
facts = deliveryFacts(entries, channel);
|
|
877
|
+
}
|
|
878
|
+
catch (error) {
|
|
879
|
+
this.raiseReadFatal('poll', error);
|
|
880
|
+
}
|
|
881
|
+
let operationId;
|
|
882
|
+
try {
|
|
883
|
+
await operationIdContext.fresh(async (freshOperationId) => {
|
|
884
|
+
operationId = freshOperationId;
|
|
885
|
+
emitBestEffort({
|
|
886
|
+
level: 'info',
|
|
887
|
+
event: 'inbox.delivery.started',
|
|
888
|
+
operation_id: freshOperationId,
|
|
889
|
+
fields: deliveryFields(facts),
|
|
890
|
+
});
|
|
891
|
+
for (const content of messages) {
|
|
892
|
+
await session.sendCustomMessage({
|
|
893
|
+
customType: SITUATIONAL_CONTEXT_CUSTOM_TYPE,
|
|
894
|
+
content,
|
|
895
|
+
display: true,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
catch (error) {
|
|
901
|
+
if (operationId === undefined)
|
|
902
|
+
this.raiseReadFatal('poll', error);
|
|
903
|
+
emitDeliveryFailure(operationId, facts, 'send', error);
|
|
904
|
+
throw error;
|
|
905
|
+
}
|
|
906
|
+
if (operationId === undefined) {
|
|
907
|
+
this.raiseReadFatal('poll', new Error(`${channel} prompt-join delivery returned without an operation id`));
|
|
908
|
+
}
|
|
909
|
+
const handoff = { operationId, ...facts, settleAtAgentSettled: true };
|
|
910
|
+
if (!this.canMutate()) {
|
|
911
|
+
if (this.disposed && this.disposalReason !== undefined)
|
|
912
|
+
emitDeliveryAbandoned(handoff, this.disposalReason);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
this.pendingHandoffs.push(handoff);
|
|
916
|
+
}
|
|
829
917
|
coveredEntryIds(channel) {
|
|
830
918
|
const covered = new Set();
|
|
831
919
|
for (const handoff of this.pendingHandoffs) {
|
|
@@ -883,7 +971,7 @@ class CanvasInboxWatcher {
|
|
|
883
971
|
emitDeliveryCompleted(handoff);
|
|
884
972
|
return 'settled';
|
|
885
973
|
}
|
|
886
|
-
async settle(nodeId, ctx, holdPendingMessages) {
|
|
974
|
+
async settle(nodeId, ctx, holdPendingMessages, atAgentSettled = false) {
|
|
887
975
|
if (holdPendingMessages) {
|
|
888
976
|
try {
|
|
889
977
|
if (ctx?.hasPendingMessages?.() === true)
|
|
@@ -903,9 +991,17 @@ class CanvasInboxWatcher {
|
|
|
903
991
|
for (const handoff of alreadyDurable)
|
|
904
992
|
emitDeliveryCompleted(handoff);
|
|
905
993
|
}
|
|
994
|
+
const agentSettledBlock = atAgentSettled
|
|
995
|
+
? undefined
|
|
996
|
+
: this.pendingHandoffs
|
|
997
|
+
.filter((handoff) => handoff.settleAtAgentSettled === true)
|
|
998
|
+
.map((handoff) => physical.throughIndexes.get(handoff))
|
|
999
|
+
.reduce((first, index) => first === undefined ? index : Math.min(first, index), undefined);
|
|
906
1000
|
const eligible = this.pendingHandoffs.filter((handoff) => {
|
|
907
1001
|
const throughIndex = physical.throughIndexes.get(handoff);
|
|
908
|
-
return
|
|
1002
|
+
return (handoff.settleAtAgentSettled !== true || atAgentSettled)
|
|
1003
|
+
&& (physical.blockedIndex === undefined || throughIndex < physical.blockedIndex)
|
|
1004
|
+
&& (agentSettledBlock === undefined || throughIndex < agentSettledBlock);
|
|
909
1005
|
});
|
|
910
1006
|
if (eligible.length === 0)
|
|
911
1007
|
return 'settled';
|
|
@@ -1006,6 +1102,11 @@ function installWatcher(pi, cadence) {
|
|
|
1006
1102
|
currentWatcher?.dispose('reload', false);
|
|
1007
1103
|
const watcher = new CanvasInboxWatcher(pi, cadence, ++nextGeneration);
|
|
1008
1104
|
currentWatcher = watcher;
|
|
1105
|
+
// Pi loads this extension through Jiti while the broker driver is native ESM,
|
|
1106
|
+
// so their module-level singletons differ. Register the bound watcher on the
|
|
1107
|
+
// process-global key that the broker reads immediately before a human prompt.
|
|
1108
|
+
const promptJoin = (session) => watcher.prependHeldDeferredInbox(session);
|
|
1109
|
+
globalThis[HELD_DEFERRED_INBOX_PROMPT_JOIN] = promptJoin;
|
|
1009
1110
|
try {
|
|
1010
1111
|
watcher.start();
|
|
1011
1112
|
}
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.251",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.251",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|