@north-light/crouter 0.3.269 → 0.3.270
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/api/dto/broker.d.ts +11 -2
- package/dist/api/dto/broker.js +2 -2
- package/dist/api/dto/nodes.d.ts +4 -0
- package/dist/clients/attach/viewer.js +406 -406
- package/dist/commands/pkg/plugin-inspect.js +2 -2
- package/dist/commands/pkg/plugin-manage.js +3 -3
- package/dist/commands/sys/doctor.js +4 -3
- package/dist/core/__tests__/human-deliver.test.js +1 -1
- package/dist/core/__tests__/integration/lifecycle-hooks.test.d.ts +1 -0
- package/dist/core/__tests__/integration/lifecycle-hooks.test.js +182 -0
- package/dist/core/__tests__/integration/revive.test.js +15 -15
- package/dist/core/__tests__/push-final-guard.test.js +1 -1
- package/dist/core/__tests__/revive-capacity.test.js +20 -20
- package/dist/core/__tests__/revive-parked-fresh.test.js +14 -14
- package/dist/core/__tests__/seam/broker-cap-freeze.test.js +2 -2
- package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
- package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +1 -1
- package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +2 -2
- package/dist/core/command-hooks/discovery.d.ts +27 -1
- package/dist/core/command-hooks/discovery.js +52 -0
- package/dist/core/command-hooks/index.d.ts +4 -3
- package/dist/core/command-hooks/index.js +2 -1
- package/dist/core/command-hooks/lifecycle-catalog.d.ts +3 -0
- package/dist/core/command-hooks/lifecycle-catalog.js +4 -0
- package/dist/core/command-hooks/report.d.ts +11 -2
- package/dist/core/command-hooks/report.js +10 -1
- package/dist/core/command-hooks/schema.d.ts +16 -1
- package/dist/core/command-hooks/schema.js +128 -40
- package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +12 -0
- package/dist/core/command-hooks/transport/exec-lifecycle.js +152 -0
- package/dist/core/human/feedback-companion.js +1 -1
- package/dist/core/review/realize.js +1 -1
- package/dist/core/runtime/broker/engine-drive.d.ts +14 -3
- package/dist/core/runtime/broker/engine-drive.js +29 -1
- package/dist/core/runtime/broker/frame-client.js +3 -4
- package/dist/core/runtime/broker/rebind.js +7 -0
- package/dist/core/runtime/broker-protocol.d.ts +11 -2
- package/dist/core/runtime/broker.d.ts +1 -1
- package/dist/core/runtime/broker.js +13 -3
- package/dist/core/runtime/node-read.d.ts +2 -0
- package/dist/core/runtime/node-read.js +18 -9
- package/dist/core/runtime/revive-all.d.ts +1 -1
- package/dist/core/runtime/revive-all.js +2 -2
- package/dist/core/runtime/revive.d.ts +2 -2
- package/dist/core/runtime/revive.js +49 -13
- package/dist/core/runtime/session-visibility.d.ts +10 -14
- package/dist/core/runtime/session-visibility.js +43 -30
- package/dist/core/runtime/stamp/channel.d.ts +10 -6
- package/dist/core/runtime/stamp/channel.js +16 -7
- package/dist/core/runtime/stamp/protocol.d.ts +4 -0
- package/dist/core/runtime/turn-visibility.d.ts +10 -0
- package/dist/core/runtime/turn-visibility.js +31 -0
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
- package/dist/daemon/api/bridge.js +1 -1
- package/dist/daemon/api/handlers/attach.js +4 -4
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/messages.js +2 -2
- package/dist/daemon/api/handlers/nodes.js +7 -4
- package/dist/daemon/cron/sinks.js +3 -3
- package/dist/daemon/fleet.js +1 -1
- package/dist/daemon/messaging/node-message.js +1 -1
- package/dist/daemon/profile-delete.js +1 -1
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +6 -6
- package/dist/hook-authoring.d.ts +31 -1
- package/dist/hook-authoring.js +42 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/pi-extensions/canvas-stamp.js +3 -3
- package/dist/shared/generated-context.d.ts +1 -2
- package/dist/shared/generated-context.js +3 -4
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { PROCESS_GROUP_KILL_GRACE_MS, posixProcessGroupSpawnOptions, terminateProcessGroup } from '../../../hook-process.js';
|
|
4
|
+
import { isRecord } from '../../../shared/predicates.js';
|
|
5
|
+
import { diag } from '../../io.js';
|
|
6
|
+
const MAX_STDOUT = 10 * 1024 * 1024;
|
|
7
|
+
export const LIFECYCLE_HOOK_TIMEOUT_MS = 5_000;
|
|
8
|
+
export const LIFECYCLE_EVENT_BUDGET_MS = 10_000;
|
|
9
|
+
/** Runs one lifecycle hook. The node launch remains authoritative on every failure. */
|
|
10
|
+
export async function invokeLifecycleHook(hook, invocation, timeoutMs) {
|
|
11
|
+
try {
|
|
12
|
+
const request = {
|
|
13
|
+
protocolVersion: 1,
|
|
14
|
+
op: hook.op,
|
|
15
|
+
event: hook.event,
|
|
16
|
+
phase: hook.phase,
|
|
17
|
+
operationId: randomBytes(16).toString('hex'),
|
|
18
|
+
node: invocation.node,
|
|
19
|
+
runtime: invocation.runtime,
|
|
20
|
+
context: { cwd: invocation.node.cwd },
|
|
21
|
+
};
|
|
22
|
+
const result = await runLifecycleProcess(hook, JSON.stringify(request), timeoutMs);
|
|
23
|
+
if (result.signal !== null)
|
|
24
|
+
throw new Error(`executable was killed by signal ${result.signal}`);
|
|
25
|
+
const envelope = parseEnvelope(result.stdout);
|
|
26
|
+
if (envelope.ok) {
|
|
27
|
+
if (result.status !== 0)
|
|
28
|
+
diag(`crtr: ${label(hook)} returned ok:true but exited ${result.status} (honoring the envelope)`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
diag(`crtr: ${label(hook)} failed: ${envelope.error.code}: ${envelope.error.message}`);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
diag(`crtr: ${label(hook)} failed: ${describeError(error)}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Executes lifecycle hooks in discovery order, within one bounded start-event budget. */
|
|
38
|
+
export async function invokeLifecycleHooks(hooks, invocation) {
|
|
39
|
+
const deadline = Date.now() + LIFECYCLE_EVENT_BUDGET_MS;
|
|
40
|
+
for (const hook of hooks) {
|
|
41
|
+
const remaining = deadline - Date.now();
|
|
42
|
+
// A timed-out hook must be reaped before launch can proceed. Reserve the
|
|
43
|
+
// process-group termination grace inside the event budget rather than
|
|
44
|
+
// letting the final cleanup extend the node-start critical path.
|
|
45
|
+
const timeoutMs = Math.min(LIFECYCLE_HOOK_TIMEOUT_MS, remaining - PROCESS_GROUP_KILL_GRACE_MS);
|
|
46
|
+
if (timeoutMs <= 0) {
|
|
47
|
+
diag(`crtr: lifecycle hook event ${hook.event} exceeded its ${LIFECYCLE_EVENT_BUDGET_MS}ms aggregate budget; skipped ${label(hook)}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
await invokeLifecycleHook(hook, invocation, timeoutMs);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function runLifecycleProcess(hook, input, timeoutMs) {
|
|
54
|
+
let child;
|
|
55
|
+
try {
|
|
56
|
+
child = spawn(hook.executable, ['--crtr-hook-protocol', '1'], {
|
|
57
|
+
cwd: hook.plugin.root,
|
|
58
|
+
env: process.env,
|
|
59
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
60
|
+
...posixProcessGroupSpawnOptions(),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw new Error(`executable could not be run (${describeError(error)})`);
|
|
65
|
+
}
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
let stdoutBytes = 0;
|
|
68
|
+
const stdout = [];
|
|
69
|
+
let failure;
|
|
70
|
+
let closed = false;
|
|
71
|
+
let cleaned = false;
|
|
72
|
+
let status = null;
|
|
73
|
+
let signal = null;
|
|
74
|
+
let timeout;
|
|
75
|
+
const finish = () => {
|
|
76
|
+
if (!closed || (failure !== undefined && !cleaned))
|
|
77
|
+
return;
|
|
78
|
+
if (timeout !== undefined)
|
|
79
|
+
clearTimeout(timeout);
|
|
80
|
+
if (failure?.kind === 'timeout')
|
|
81
|
+
return reject(new Error(`executable timed out after ${timeoutMs}ms`));
|
|
82
|
+
if (failure?.kind === 'output_limit')
|
|
83
|
+
return reject(new Error('stdout exceeded the 10 MiB cap'));
|
|
84
|
+
if (failure?.kind === 'spawn')
|
|
85
|
+
return reject(new Error(`executable could not be run (${describeError(failure.error)})`));
|
|
86
|
+
if (failure?.kind === 'cleanup')
|
|
87
|
+
return reject(new Error(`executable cleanup failed (${describeError(failure.error)})`));
|
|
88
|
+
resolve({ stdout: Buffer.concat(stdout).toString('utf8'), status, signal });
|
|
89
|
+
};
|
|
90
|
+
const terminate = (reason) => {
|
|
91
|
+
if (failure !== undefined)
|
|
92
|
+
return;
|
|
93
|
+
failure = reason;
|
|
94
|
+
if (timeout !== undefined)
|
|
95
|
+
clearTimeout(timeout);
|
|
96
|
+
void terminateProcessGroup(child).then(() => { cleaned = true; finish(); }, (error) => { failure = { kind: 'cleanup', error }; cleaned = true; finish(); });
|
|
97
|
+
};
|
|
98
|
+
child.stdout.on('data', (chunk) => {
|
|
99
|
+
stdoutBytes += chunk.byteLength;
|
|
100
|
+
if (stdoutBytes > MAX_STDOUT)
|
|
101
|
+
return terminate({ kind: 'output_limit' });
|
|
102
|
+
stdout.push(chunk);
|
|
103
|
+
});
|
|
104
|
+
child.once('error', (error) => {
|
|
105
|
+
if (child.pid === undefined) {
|
|
106
|
+
failure = { kind: 'spawn', error };
|
|
107
|
+
closed = true;
|
|
108
|
+
cleaned = true;
|
|
109
|
+
finish();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
terminate({ kind: 'spawn', error });
|
|
113
|
+
});
|
|
114
|
+
child.once('close', (nextStatus, nextSignal) => {
|
|
115
|
+
status = nextStatus;
|
|
116
|
+
signal = nextSignal;
|
|
117
|
+
closed = true;
|
|
118
|
+
finish();
|
|
119
|
+
});
|
|
120
|
+
timeout = setTimeout(() => terminate({ kind: 'timeout' }), timeoutMs);
|
|
121
|
+
child.stdin.end(input);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function parseEnvelope(stdout) {
|
|
125
|
+
let value;
|
|
126
|
+
try {
|
|
127
|
+
value = JSON.parse(stdout.trim());
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
throw new Error('stdout was not a single JSON envelope');
|
|
131
|
+
}
|
|
132
|
+
if (!isRecord(value) || value['protocolVersion'] !== 1)
|
|
133
|
+
throw new Error('stdout was not a protocolVersion: 1 envelope');
|
|
134
|
+
if (value['ok'] === true && exactKeys(value, ['protocolVersion', 'ok']))
|
|
135
|
+
return { protocolVersion: 1, ok: true };
|
|
136
|
+
if (value['ok'] !== false || !exactKeys(value, ['protocolVersion', 'ok', 'error']))
|
|
137
|
+
throw new Error('response envelope had missing or extra fields');
|
|
138
|
+
const error = value['error'];
|
|
139
|
+
if (!isRecord(error) || !exactKeys(error, ['code', 'message'], ['next']) || typeof error['code'] !== 'string' || error['code'] === '' || typeof error['message'] !== 'string' || error['message'] === '' || (error['next'] !== undefined && typeof error['next'] !== 'string')) {
|
|
140
|
+
throw new Error('failure envelope had invalid error fields');
|
|
141
|
+
}
|
|
142
|
+
return { protocolVersion: 1, ok: false, error: { code: error['code'], message: error['message'], ...(error['next'] === undefined ? {} : { next: error['next'] }) } };
|
|
143
|
+
}
|
|
144
|
+
function exactKeys(value, required, optional = []) {
|
|
145
|
+
return required.every((key) => Object.hasOwn(value, key)) && Object.keys(value).every((key) => required.includes(key) || optional.includes(key));
|
|
146
|
+
}
|
|
147
|
+
function label(hook) {
|
|
148
|
+
return `plugin "${hook.plugin.name}" lifecycle hook "${hook.op}" for ${hook.event}`;
|
|
149
|
+
}
|
|
150
|
+
function describeError(error) {
|
|
151
|
+
return error instanceof Error ? error.message : String(error);
|
|
152
|
+
}
|
|
@@ -92,7 +92,7 @@ export async function realizeFeedbackCompanion(args) {
|
|
|
92
92
|
// The bind deadline below only a live broker can meet, so a capacity
|
|
93
93
|
// refusal must surface here and discard the companion — freezing would
|
|
94
94
|
// burn the whole deadline waiting for a launch that is not coming.
|
|
95
|
-
reviveNode(node.node_id, { resume: false, capacity: 'refuse' });
|
|
95
|
+
await reviveNode(node.node_id, { resume: false, capacity: 'refuse' });
|
|
96
96
|
if (!await awaitCompanionBind(node.node_id, COMPANION_BIND_DEADLINE_MS)) {
|
|
97
97
|
throw new Error(`feedback companion did not bind within ${COMPANION_BIND_DEADLINE_MS}ms`);
|
|
98
98
|
}
|
|
@@ -172,7 +172,7 @@ export async function realizeReview(reviewId) {
|
|
|
172
172
|
// Realization is bounded by the bind deadline below, which only a live broker
|
|
173
173
|
// can meet: refuse at the cap rather than freeze into a certain timeout.
|
|
174
174
|
if (companion.pi_session_id === null)
|
|
175
|
-
reviveNode(companion.node_id, { resume: false, capacity: 'refuse' });
|
|
175
|
+
await reviveNode(companion.node_id, { resume: false, capacity: 'refuse' });
|
|
176
176
|
// R4: session_start is the durable bind boundary.
|
|
177
177
|
if (!await awaitCompanionBind(review.companion_node_id, COMPANION_BIND_DEADLINE_MS)) {
|
|
178
178
|
throw companionUnbound(review.review_id, COMPANION_BIND_DEADLINE_MS);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { MessageOrigin } from '../stamp/protocol.js';
|
|
1
2
|
import type { ClientToBroker } from '../broker-protocol.js';
|
|
2
3
|
import type { BrokerClient, BrokerClientRegistry } from './client-registry.js';
|
|
3
4
|
import type { EventProjection } from './event-projection.js';
|
|
@@ -9,9 +10,18 @@ import type { RebindDriver } from './rebind.js';
|
|
|
9
10
|
import type { ToolGroupTracker } from './tool-groups.js';
|
|
10
11
|
import type { PromptDispatchTracker } from './turn-ignition.js';
|
|
11
12
|
import type { TurnAdmissionGate } from './turn-admission.js';
|
|
12
|
-
/** The frames that drive the live engine
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/** The frames that drive the live engine. A daemon delivery carries its
|
|
14
|
+
* provenance through admission because only the resolved route can decide
|
|
15
|
+
* whether the resulting run is internal. */
|
|
16
|
+
type DeliveredInput = {
|
|
17
|
+
delivery?: {
|
|
18
|
+
origin?: MessageOrigin;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
export type DriveFrame = (Extract<ClientToBroker, {
|
|
22
|
+
type: 'prompt' | 'follow_up';
|
|
23
|
+
}> & DeliveredInput) | Extract<ClientToBroker, {
|
|
24
|
+
type: 'steer' | 'abort' | 'bash';
|
|
15
25
|
}>;
|
|
16
26
|
export type EngineDriver = (client: BrokerClient, frame: DriveFrame) => Promise<unknown> | undefined;
|
|
17
27
|
export declare function createEngineDriver(deps: {
|
|
@@ -26,3 +36,4 @@ export declare function createEngineDriver(deps: {
|
|
|
26
36
|
toolGroups: ToolGroupTracker;
|
|
27
37
|
turnAdmission: TurnAdmissionGate;
|
|
28
38
|
}): EngineDriver;
|
|
39
|
+
export {};
|
|
@@ -2,6 +2,8 @@ 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
4
|
import { heldDeferredInboxPromptJoin } from './held-deferred-inbox.js';
|
|
5
|
+
import { announceMessageOrigin } from '../stamp/channel.js';
|
|
6
|
+
import { clearTurnVisibility, currentTurnVisibility, setTurnVisibility, } from '../turn-visibility.js';
|
|
5
7
|
import { chooseGuidanceDeliveryMode, promptWithNextTurnGuidance, resolveEngineRoute, resolveSteerCall, } from './engine-routing.js';
|
|
6
8
|
export function createEngineDriver(deps) {
|
|
7
9
|
const { registry, replies, session, dispatches, memoryRefs, authReloadGate, rebind, projection, toolGroups, turnAdmission, } = deps;
|
|
@@ -47,6 +49,20 @@ export function createEngineDriver(deps) {
|
|
|
47
49
|
const route = resolveEngineRoute(frame, session().isStreaming);
|
|
48
50
|
const promptOptions = route.call === 'prompt' ? route.options : undefined;
|
|
49
51
|
const guidance = memoryRefs.guidanceFor(frame.text);
|
|
52
|
+
const deliveryIsInternal = frame.delivery?.origin?.type === 'runtime'
|
|
53
|
+
&& frame.delivery.origin.kind === 'park'
|
|
54
|
+
&& route.call === 'prompt'
|
|
55
|
+
&& route.options.streamingBehavior === undefined;
|
|
56
|
+
const announceDelivery = () => {
|
|
57
|
+
if (frame.delivery === undefined)
|
|
58
|
+
return;
|
|
59
|
+
const visibility = deliveryIsInternal ? 'internal' : undefined;
|
|
60
|
+
if (frame.delivery.origin === undefined) {
|
|
61
|
+
setTurnVisibility('visible');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
announceMessageOrigin(frame.text, frame.delivery.origin, visibility);
|
|
65
|
+
};
|
|
50
66
|
const prompt = async () => {
|
|
51
67
|
// Both human surfaces converge at this admitted boundary. The watcher
|
|
52
68
|
// claims held deferred mail before the prompt body, but its cursor stays
|
|
@@ -60,7 +76,19 @@ export function createEngineDriver(deps) {
|
|
|
60
76
|
// replay. A person's live prompt is not replayable, so it must run.
|
|
61
77
|
}
|
|
62
78
|
}
|
|
63
|
-
|
|
79
|
+
announceDelivery();
|
|
80
|
+
registry.broadcast({
|
|
81
|
+
type: 'turn_visibility',
|
|
82
|
+
visibility: currentTurnVisibility(),
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
await session().prompt(frame.text, { ...promptOptions, preflightResult: release });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (deliveryIsInternal)
|
|
89
|
+
clearTurnVisibility();
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
64
92
|
};
|
|
65
93
|
if (guidance === null) {
|
|
66
94
|
if (route.call === 'prompt') {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../../../shared/generated-context.js';
|
|
2
2
|
import { emitEvent } from '../../events/emit.js';
|
|
3
3
|
import { normalizeSnapshotTail } from '../broker-protocol.js';
|
|
4
|
-
import { announceMessageOrigin } from '../stamp/channel.js';
|
|
5
4
|
export function createClientHandlers(deps) {
|
|
6
5
|
const { registry, replies, session, driveEngine, turnAcceptance, pendingDialogs, sendWelcome, replayExtraPendingDialogsTo, disposeAndExit, } = deps;
|
|
7
6
|
const { notWritable, ackTo } = replies;
|
|
@@ -83,13 +82,13 @@ export function createClientHandlers(deps) {
|
|
|
83
82
|
};
|
|
84
83
|
if (via === 'prompt')
|
|
85
84
|
turnAcceptance.waitFor(ackAccepted);
|
|
86
|
-
// The daemon knows who authored this body; the stamp writer lives here.
|
|
87
|
-
if (frame.origin !== undefined)
|
|
88
|
-
announceMessageOrigin(frame.text, frame.origin);
|
|
89
85
|
const outcome = driveEngine(client, {
|
|
90
86
|
type: 'prompt',
|
|
91
87
|
text: frame.text,
|
|
92
88
|
...(frame.images !== undefined ? { images: frame.images } : {}),
|
|
89
|
+
delivery: {
|
|
90
|
+
...(frame.origin === undefined ? {} : { origin: frame.origin }),
|
|
91
|
+
},
|
|
93
92
|
});
|
|
94
93
|
if (via === 'steer' || outcome === undefined) {
|
|
95
94
|
turnAcceptance.cancel(ackAccepted);
|
|
@@ -6,6 +6,7 @@ import { operationIdContext } from '../../events/operation-id.js';
|
|
|
6
6
|
import { installPersonaTransitionGate } from '../broker-persona-guidance.js';
|
|
7
7
|
import { abortSessionFromExtension } from './extension-abort.js';
|
|
8
8
|
import { clearFault, invalidateProviderRetryEpisode, readFault } from '../fault.js';
|
|
9
|
+
import { clearTurnVisibility } from '../turn-visibility.js';
|
|
9
10
|
const MAX_ADMITTED_REPLACEMENTS = 2;
|
|
10
11
|
const REPLACEMENT_DEADLINE_MS = 30_000;
|
|
11
12
|
const REPLACEMENT_DEADLINE_EXCEEDED = Symbol('replacement-deadline-exceeded');
|
|
@@ -302,6 +303,12 @@ export class RebindDriver {
|
|
|
302
303
|
catch (err) {
|
|
303
304
|
emitEvent({ level: 'debug', event: 'broker.runtime.diagnostic', fields: { message: `event relay threw: ${String(err)}` } });
|
|
304
305
|
}
|
|
306
|
+
finally {
|
|
307
|
+
// Projection sees the settlement while the run is still internal; every
|
|
308
|
+
// later input and output fails open to visible after this boundary.
|
|
309
|
+
if (event.type === 'agent_settled')
|
|
310
|
+
clearTurnVisibility();
|
|
311
|
+
}
|
|
305
312
|
}
|
|
306
313
|
ackTo(client, op, ok = true, detail, id) {
|
|
307
314
|
this.deps.registry.sendFrame(client, { type: 'ack', for: op, ok, ...(detail !== undefined ? { detail } : {}), ...(id !== undefined ? { id } : {}) });
|
|
@@ -2,7 +2,7 @@ import type { AgentSession, AgentSessionEvent, RpcExtensionUIRequest, RpcExtensi
|
|
|
2
2
|
import type { Api, ImageContent, Model } from '@earendil-works/pi-ai';
|
|
3
3
|
import type { BrokerErrorFrame, BrokerNodeNamedFrame } from '../../api/dto/broker.js';
|
|
4
4
|
import type { ToolGroupSummary } from '../../shared/tool-groups.js';
|
|
5
|
-
import type { MessageOrigin } from './stamp/protocol.js';
|
|
5
|
+
import type { MessageOrigin, MessageVisibility } from './stamp/protocol.js';
|
|
6
6
|
/** pi's own RPC types, re-exported unchanged. `broker-protocol` stays the one
|
|
7
7
|
* import site for the wire union; the shapes themselves are pi's, versioned by
|
|
8
8
|
* pi, so a pi change is a compile error here instead of silent drift. */
|
|
@@ -22,6 +22,10 @@ export interface BrokerSnapshot {
|
|
|
22
22
|
/** Stable session-entry ids aligned 1:1 with `messages`. Absent when the
|
|
23
23
|
* session manager cannot expose its entry tree. */
|
|
24
24
|
messageIds?: string[];
|
|
25
|
+
/** Presentation visibility aligned 1:1 with `messages`. */
|
|
26
|
+
messageVisibility: MessageVisibility[];
|
|
27
|
+
/** Presentation visibility of the run currently owned by the engine. */
|
|
28
|
+
turnVisibility: MessageVisibility;
|
|
25
29
|
stats: SessionStats;
|
|
26
30
|
/** pi's OWN `get_state` payload, verbatim — not a mirror. `buildSnapshot`
|
|
27
31
|
* fills it exactly as pi's `rpc-mode.js` does, from the same getters, so a
|
|
@@ -398,6 +402,11 @@ export interface WelcomeFrame {
|
|
|
398
402
|
* the other four learn nothing); the PAYLOAD is pi's — `Model`, exactly what
|
|
399
403
|
* pi's own `set_model` response carries and what `snapshot.state.model` holds.
|
|
400
404
|
* Undefined when the engine has none. */
|
|
405
|
+
export interface TurnVisibilityFrame {
|
|
406
|
+
type: 'turn_visibility';
|
|
407
|
+
/** Presentation visibility of the run whose native events follow. */
|
|
408
|
+
visibility: MessageVisibility;
|
|
409
|
+
}
|
|
401
410
|
export interface WorkingActivityFrame {
|
|
402
411
|
type: 'working_activity';
|
|
403
412
|
/** Full display label selected from the user's configured gerunds. */
|
|
@@ -643,7 +652,7 @@ export interface ToolGroupSummaryFrame {
|
|
|
643
652
|
/** Everything the broker can send. Live `AgentSessionEvent`s are relayed
|
|
644
653
|
* verbatim (the broker adds nothing); the broker's own control frames carry
|
|
645
654
|
* non-colliding `type` discriminants. */
|
|
646
|
-
export type BrokerToClient = WelcomeFrame | WorkingActivityFrame | ModelChangedFrame | ResourcesReloadedFrame | ErrorFrame | NodeNamedFrame | AckFrame | BrokerDataFrame | BashStartFrame | BashOutputFrame | BashEndFrame | ExtensionUIRequestFrame | ExtensionUIDismissFrame | ToolGroupSummaryFrame | AgentSessionEvent;
|
|
655
|
+
export type BrokerToClient = WelcomeFrame | TurnVisibilityFrame | WorkingActivityFrame | ModelChangedFrame | ResourcesReloadedFrame | ErrorFrame | NodeNamedFrame | AckFrame | BrokerDataFrame | BashStartFrame | BashOutputFrame | BashEndFrame | ExtensionUIRequestFrame | ExtensionUIDismissFrame | ToolGroupSummaryFrame | AgentSessionEvent;
|
|
647
656
|
/** Encode one frame as a single newline-terminated JSON line. */
|
|
648
657
|
export declare function encodeFrame(frame: ClientToBroker | BrokerToClient): string;
|
|
649
658
|
/** Byte bounds for a {@link FrameDecoder} (C5). */
|
|
@@ -29,7 +29,7 @@ interface PendingDialog {
|
|
|
29
29
|
* bash children. No-op before the session is built or after a clean dispose. */
|
|
30
30
|
export declare function disposeActiveSession(): void;
|
|
31
31
|
export declare function runBroker(nodeId: string, startupAt?: bigint, startupTs?: string): Promise<void>;
|
|
32
|
-
export declare function snapshotMessages(session: CreateAgentSessionResult['session'], boundaryReviewId?: string): import("./session-
|
|
32
|
+
export declare function snapshotMessages(session: CreateAgentSessionResult['session'], boundaryReviewId?: string): import("./session-visibility.js").ProjectedMessage[] | undefined;
|
|
33
33
|
export declare function buildBrokerSession(engine: BrokerEngine, cfg: BrokerSdkConfig): Promise<{
|
|
34
34
|
session: CreateAgentSessionResult['session'];
|
|
35
35
|
services: AgentSessionServices;
|
|
@@ -40,7 +40,8 @@ import { modelRequestFromConfig, selectInitialModelRoute, unregisteredConcreteMo
|
|
|
40
40
|
import { KNOWN_STREAM_WAIT_UI } from './stream-watchdog.js';
|
|
41
41
|
import { probeOnline as probeOnlineReal } from './connectivity.js';
|
|
42
42
|
import { CRTR_CYCLE_CUSTOM_TYPE, cycleAwareMessages } from './session-cycles.js';
|
|
43
|
-
import {
|
|
43
|
+
import { projectTranscript } from './session-visibility.js';
|
|
44
|
+
import { currentTurnVisibility } from './turn-visibility.js';
|
|
44
45
|
import { isUnknownModel } from './model-selection.js';
|
|
45
46
|
import { BrokerClientRegistry } from './broker/client-registry.js';
|
|
46
47
|
import { ToolGroupTracker } from './broker/tool-groups.js';
|
|
@@ -397,6 +398,9 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
|
|
|
397
398
|
return ({
|
|
398
399
|
messages,
|
|
399
400
|
...(retained === undefined ? {} : { messageIds: retained.map(({ id }) => id) }),
|
|
401
|
+
messageVisibility: retained?.map(({ visibility }) => visibility)
|
|
402
|
+
?? messages.map(() => 'visible'),
|
|
403
|
+
turnVisibility: currentTurnVisibility(),
|
|
400
404
|
stats: liveSession.getSessionStats(),
|
|
401
405
|
// pi's `get_state` (rpc-mode.js), field for field, from the same getters.
|
|
402
406
|
// The ONE divergence: a model-less session (undefined OR the SDK
|
|
@@ -754,11 +758,17 @@ export function snapshotMessages(session, boundaryReviewId) {
|
|
|
754
758
|
// payload), narrower than `CycleSessionManagerLike`'s structural read of the
|
|
755
759
|
// `crtr-cycle` marker's `{cycle, fromLeaf}` shape — the fields we read off it
|
|
756
760
|
// are ones ONLY this module's own `appendCustomEntry` call (above) ever writes.
|
|
757
|
-
const
|
|
761
|
+
const manager = session.sessionManager;
|
|
762
|
+
const messages = cycleAwareMessages(manager);
|
|
758
763
|
if (messages !== undefined) {
|
|
759
764
|
hydratePersistedAdvertisedCommandMessages(messages.map(({ message }) => message));
|
|
760
765
|
}
|
|
761
|
-
return messages === undefined
|
|
766
|
+
return messages === undefined
|
|
767
|
+
? undefined
|
|
768
|
+
: projectTranscript(messages, {
|
|
769
|
+
boundaryReviewId,
|
|
770
|
+
sessionEntries: manager.getEntries?.(),
|
|
771
|
+
});
|
|
762
772
|
}
|
|
763
773
|
// resolveLaunchModel — the SOLE launch model-selection policy, module-scoped
|
|
764
774
|
// so both buildForManager (initial boot, inside buildBrokerSession below) and
|
|
@@ -32,6 +32,8 @@ export interface NodeMessagesPageRead {
|
|
|
32
32
|
messages: unknown[];
|
|
33
33
|
/** Stable session-entry ids aligned 1:1 with `messages`. */
|
|
34
34
|
messageIds?: string[];
|
|
35
|
+
/** Presentation visibility aligned 1:1 with `messages`. */
|
|
36
|
+
messageVisibility: Array<'visible' | 'internal'>;
|
|
35
37
|
nextCursor: string | null;
|
|
36
38
|
}
|
|
37
39
|
export interface NodeMessagesPageOptions {
|
|
@@ -18,7 +18,7 @@ import { SessionManager } from './broker-sdk.js';
|
|
|
18
18
|
import { openModelRegistry } from './model-registry.js';
|
|
19
19
|
import { BUILTIN_SLASH_COMMANDS } from './pi-vendored.js';
|
|
20
20
|
import { cycleAwareMessages } from './session-cycles.js';
|
|
21
|
-
import {
|
|
21
|
+
import { projectTranscript } from './session-visibility.js';
|
|
22
22
|
import { normalizeToolGroupSummaryRecord } from '../../shared/tool-groups.js';
|
|
23
23
|
/** A session `.jsonl` records only `{ provider, modelId }` — pi never persists a
|
|
24
24
|
* whole `Model`. Resolve it back through pi's own registry so a dormant
|
|
@@ -149,10 +149,10 @@ function readToolGroupSummaries(nodeId) {
|
|
|
149
149
|
return {};
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
|
-
/** Reconstruct
|
|
152
|
+
/** Reconstruct transcript messages from a stable at-rest session copy.
|
|
153
153
|
* Both snapshot and paged reads use this one path so their histories cannot
|
|
154
154
|
* diverge. */
|
|
155
|
-
async function
|
|
155
|
+
async function reconstructTranscript(nodeId) {
|
|
156
156
|
const node = getNode(nodeId);
|
|
157
157
|
if (node === null) {
|
|
158
158
|
throw new InputError({ error: 'not_found', message: `no node: ${nodeId}`, field: 'id', next: 'List nodes with `crtr node inspect list`.' });
|
|
@@ -174,7 +174,10 @@ async function reconstructVisibleMessages(nodeId) {
|
|
|
174
174
|
}
|
|
175
175
|
const identified = reconstructed === undefined
|
|
176
176
|
? undefined
|
|
177
|
-
:
|
|
177
|
+
: projectTranscript(reconstructed, {
|
|
178
|
+
boundaryReviewId: node.review_binding?.review_id,
|
|
179
|
+
sessionEntries: manager.getEntries(),
|
|
180
|
+
});
|
|
178
181
|
const messages = identified?.map(({ message }) => message) ?? context.messages;
|
|
179
182
|
return {
|
|
180
183
|
sessionFile,
|
|
@@ -228,10 +231,13 @@ function encodeMessagesCursor(before) {
|
|
|
228
231
|
/** Reconstruct a persisted session without launching its broker. Backs the
|
|
229
232
|
* machine-readable snapshot endpoint and the human transcript render. */
|
|
230
233
|
export async function readNodeSnapshot(nodeId) {
|
|
231
|
-
const read = await
|
|
234
|
+
const read = await reconstructTranscript(nodeId);
|
|
232
235
|
const snapshot = {
|
|
233
236
|
messages: read.messages,
|
|
234
237
|
...(read.identified === undefined ? {} : { messageIds: read.identified.map(({ id }) => id) }),
|
|
238
|
+
messageVisibility: read.identified?.map(({ visibility }) => visibility)
|
|
239
|
+
?? read.messages.map(() => 'visible'),
|
|
240
|
+
turnVisibility: 'visible',
|
|
235
241
|
stats: statsFromMessages(read.messages, read.sessionId, read.sessionFile),
|
|
236
242
|
// pi's `get_state` shape (RpcSessionState), reconstructed offline: the
|
|
237
243
|
// live-only fields report their at-rest values (nothing is streaming or
|
|
@@ -284,15 +290,16 @@ export async function readNodeMessagesPage(nodeId, { cursor, limit = 200 }) {
|
|
|
284
290
|
// transcript read is how a UI opens a new conversation. Only this paged read
|
|
285
291
|
// can answer honestly — `/snapshot` would have to invent the engine state a
|
|
286
292
|
// never-run node does not have, so it and the raw `/session` export still
|
|
287
|
-
// reject the same state through `
|
|
293
|
+
// reject the same state through `reconstructTranscript`.
|
|
288
294
|
// Same shape a session-file-backed empty read produces: `messageIds` stays
|
|
289
295
|
// aligned 1:1 with `messages` (an absent `message_ids` would claim this read
|
|
290
296
|
// cannot identify entries, which is a different statement than "there are
|
|
291
297
|
// none"), and `nextCursor: null` is the same end-of-history marker an
|
|
292
298
|
// exhausted backward walk returns.
|
|
293
|
-
if (readableSessionFile(nodeId) === null)
|
|
294
|
-
return { messages: [], messageIds: [], nextCursor: null };
|
|
295
|
-
|
|
299
|
+
if (readableSessionFile(nodeId) === null) {
|
|
300
|
+
return { messages: [], messageIds: [], messageVisibility: [], nextCursor: null };
|
|
301
|
+
}
|
|
302
|
+
const { messages, identified } = await reconstructTranscript(nodeId);
|
|
296
303
|
const before = cursor === undefined ? messages.length : decodeMessagesCursor(cursor, messages.length);
|
|
297
304
|
let start = Math.max(0, before - limit);
|
|
298
305
|
while (start > 0
|
|
@@ -305,6 +312,8 @@ export async function readNodeMessagesPage(nodeId, { cursor, limit = 200 }) {
|
|
|
305
312
|
return {
|
|
306
313
|
messages: page?.map(({ message }) => message) ?? messages.slice(start, before),
|
|
307
314
|
...(page === undefined ? {} : { messageIds: page.map(({ id }) => id) }),
|
|
315
|
+
messageVisibility: page?.map(({ visibility }) => visibility)
|
|
316
|
+
?? messages.slice(start, before).map(() => 'visible'),
|
|
308
317
|
nextCursor: start === 0 ? null : encodeMessagesCursor(start),
|
|
309
318
|
};
|
|
310
319
|
}
|
|
@@ -54,4 +54,4 @@ export interface ReviveAllResult {
|
|
|
54
54
|
* no-op), so this is a thin orchestrator over `listDisconnected`: revive each
|
|
55
55
|
* with resume:true. One failing node never aborts the sweep — its error is
|
|
56
56
|
* collected and the rest proceed. */
|
|
57
|
-
export declare function reviveAll(): ReviveAllResult
|
|
57
|
+
export declare function reviveAll(): Promise<ReviveAllResult>;
|
|
@@ -84,7 +84,7 @@ export function listDisconnected() {
|
|
|
84
84
|
* no-op), so this is a thin orchestrator over `listDisconnected`: revive each
|
|
85
85
|
* with resume:true. One failing node never aborts the sweep — its error is
|
|
86
86
|
* collected and the rest proceed. */
|
|
87
|
-
export function reviveAll() {
|
|
87
|
+
export async function reviveAll() {
|
|
88
88
|
const result = { revived: [], failed: [], frozen: [] };
|
|
89
89
|
for (const meta of listDisconnected()) {
|
|
90
90
|
try {
|
|
@@ -93,7 +93,7 @@ export function reviveAll() {
|
|
|
93
93
|
// whole canvas is the likeliest way to meet the cap, and every node in it
|
|
94
94
|
// is one the daemon wants back: freeze the overflow and let the tick
|
|
95
95
|
// bring them up as slots free.
|
|
96
|
-
const outcome = reviveNode(meta.node_id, { resume: true, recovery: true, capacity: 'freeze' }).outcome;
|
|
96
|
+
const outcome = (await reviveNode(meta.node_id, { resume: true, recovery: true, capacity: 'freeze' })).outcome;
|
|
97
97
|
if (outcome === 'frozen')
|
|
98
98
|
result.frozen.push(meta.node_id);
|
|
99
99
|
else
|
|
@@ -87,10 +87,10 @@ export type ReviveCapacityPolicy = 'freeze' | 'refuse';
|
|
|
87
87
|
* Throws if the node does not exist. All other failures propagate as-is —
|
|
88
88
|
* callers (daemon, command) decide how to handle.
|
|
89
89
|
*/
|
|
90
|
-
export declare function reviveNode(nodeId: string, opts: ReviveOptions): ReviveResult
|
|
90
|
+
export declare function reviveNode(nodeId: string, opts: ReviveOptions): Promise<ReviveResult>;
|
|
91
91
|
/** The attach-specific entry to the revive boundary. A focus viewer may decline
|
|
92
92
|
* to wake an exact parked resident while preserving the ordinary activating
|
|
93
93
|
* attach default. The fleet check and parked decision are synchronous with the
|
|
94
94
|
* launch below, so an already-running broker wins before dormancy is observed. */
|
|
95
|
-
export declare function decideAttachRevive(nodeId: string, opts: ReviveOptions, wakeParked: boolean): AttachReviveDecision
|
|
95
|
+
export declare function decideAttachRevive(nodeId: string, opts: ReviveOptions, wakeParked: boolean): Promise<AttachReviveDecision>;
|
|
96
96
|
export {};
|