@north-light/crouter 0.3.218 → 0.3.220
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/clients/attach/viewer.js +368 -368
- package/dist/commands/sys/panels/broker-limits-panel.js +3 -3
- package/dist/core/__tests__/helpers/broker-clients.d.ts +1 -0
- package/dist/core/__tests__/helpers/broker-clients.js +1 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +37 -1
- package/dist/core/runtime/broker/client-registry.d.ts +6 -3
- package/dist/core/runtime/broker/client-registry.js +6 -4
- package/dist/core/runtime/broker/frame-dispatch.js +1 -0
- package/dist/core/runtime/broker-protocol.d.ts +4 -0
- package/dist/core/runtime/launch-config-worker.d.ts +1 -0
- package/dist/core/runtime/launch-config-worker.js +12 -0
- package/dist/core/runtime/launch.d.ts +16 -2
- package/dist/core/runtime/launch.js +55 -21
- package/dist/core/runtime/promote.d.ts +20 -2
- package/dist/core/runtime/promote.js +63 -59
- package/dist/core/runtime/recycle.js +2 -2
- package/dist/core/runtime/reset.js +2 -2
- package/dist/core/runtime/spawn.js +3 -3
- package/dist/core/runtime/warm-pool.js +9 -7
- package/dist/daemon/api/__tests__/bridge-heartbeat.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/bridge-heartbeat.test.js +30 -0
- package/dist/daemon/api/__tests__/seam/api-server.test.js +30 -0
- package/dist/daemon/api/bridge.d.ts +6 -0
- package/dist/daemon/api/bridge.js +39 -2
- package/dist/daemon/api/handlers/nodes.js +16 -17
- package/dist/types.js +5 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -82,7 +82,7 @@ export class BrokerLimitsPanel {
|
|
|
82
82
|
}
|
|
83
83
|
render(width) {
|
|
84
84
|
const out = [
|
|
85
|
-
truncateToWidth(`${theme.fg("accent", theme.bold("Broker limits"))}${theme.fg("dim", " user scope · defaults
|
|
85
|
+
truncateToWidth(`${theme.fg("accent", theme.bold("Broker limits"))}${theme.fg("dim", " user scope · defaults 16 / 32")}`, width),
|
|
86
86
|
truncateToWidth(theme.fg("dim", "These bounds govern daemon automatic revive; explicit node revives remain available."), width),
|
|
87
87
|
"",
|
|
88
88
|
];
|
|
@@ -91,8 +91,8 @@ export class BrokerLimitsPanel {
|
|
|
91
91
|
out.push(truncateToWidth(`${selected ? theme.fg("accent", "▶") : " "} ${padAnsi(selected ? theme.fg("accent", label) : label, 24)} ${input.render(Math.max(8, width - 28))[0] ?? ""}`, width));
|
|
92
92
|
out.push(truncateToWidth(theme.fg("dim", ` ${description}`), width));
|
|
93
93
|
};
|
|
94
|
-
line(0, "Warning threshold", "Warn when live brokers reach this count. Default:
|
|
95
|
-
line(1, "Automatic-revive hard cap", "Defer automatic revives at this count. Default:
|
|
94
|
+
line(0, "Warning threshold", "Warn when live brokers reach this count. Default: 16.", this.warningInput);
|
|
95
|
+
line(1, "Automatic-revive hard cap", "Defer automatic revives at this count. Default: 32.", this.hardCapInput);
|
|
96
96
|
out.push("");
|
|
97
97
|
for (const text of wrapText(`Configured limits apply next tick. ${BROKER_LIMIT_OVERRIDE_PRECEDENCE}`, width))
|
|
98
98
|
out.push(theme.fg("dim", text));
|
|
@@ -27,6 +27,7 @@ export interface AttachKit {
|
|
|
27
27
|
/** Connect a BrokerClient to a node's running broker, hello, await welcome. */
|
|
28
28
|
attach(id: string, role: ClientRole, clientId: string, opts?: {
|
|
29
29
|
snapshotTail?: number;
|
|
30
|
+
attends?: boolean;
|
|
30
31
|
}): Promise<Attached>;
|
|
31
32
|
/** Attach (as `role`) and retry until the welcome satisfies `pred`. */
|
|
32
33
|
attachUntil(id: string, role: ClientRole, clientId: string, pred: (a: Attached) => boolean, label: string): Promise<Attached>;
|
|
@@ -19,20 +19,23 @@
|
|
|
19
19
|
// they belong to the compiled seam tier; neither needs tmux.
|
|
20
20
|
import { after, before, test } from 'node:test';
|
|
21
21
|
import assert from 'node:assert/strict';
|
|
22
|
-
import { writeFileSync } from 'node:fs';
|
|
22
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { appendInbox, readCursor, readInboxSince } from '../../feed/inbox.js';
|
|
25
25
|
import { registerWarmSpare, subscribe, updateNode } from '../../canvas/index.js';
|
|
26
26
|
import { isPidAlive } from '../../canvas/pid.js';
|
|
27
27
|
import { reviveNode } from '../../runtime/revive.js';
|
|
28
28
|
import { createHarness } from '../helpers/harness.js';
|
|
29
|
+
import { createAttachKit } from '../helpers/broker-clients.js';
|
|
29
30
|
let h;
|
|
30
31
|
let root;
|
|
32
|
+
const attach = createAttachKit(() => h);
|
|
31
33
|
before(async () => {
|
|
32
34
|
h = await createHarness({ headless: true, sessionPrefix: 'crtr-dormancy' });
|
|
33
35
|
root = h.spawnRoot('dormancy-release suite root');
|
|
34
36
|
});
|
|
35
37
|
after(async () => {
|
|
38
|
+
attach.closeAll();
|
|
36
39
|
if (h !== undefined)
|
|
37
40
|
await h.dispose();
|
|
38
41
|
});
|
|
@@ -187,6 +190,39 @@ test('an unattended resident with no live obligation completes on the daemon clo
|
|
|
187
190
|
process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
|
|
188
191
|
}
|
|
189
192
|
});
|
|
193
|
+
test('a non-attending observer does not prevent unattended resident parking', { timeout: 30_000 }, async () => {
|
|
194
|
+
const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
|
|
195
|
+
process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
|
|
196
|
+
const nodeId = h.fabricateBrokerNode({
|
|
197
|
+
id: 'settled-with-system-observer',
|
|
198
|
+
parent: root,
|
|
199
|
+
lifecycle: 'resident',
|
|
200
|
+
status: 'idle',
|
|
201
|
+
intent: 'idle-release',
|
|
202
|
+
});
|
|
203
|
+
updateNode(nodeId, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
|
|
204
|
+
reviveNode(nodeId, { resume: true });
|
|
205
|
+
const boot = await h.awaitBoot(nodeId);
|
|
206
|
+
await attach.attach(nodeId, 'observer', 'system-observer-test', { attends: false });
|
|
207
|
+
assert.equal(JSON.parse(readFileSync(join(h.home, 'nodes', nodeId, 'job', 'attach.json'), 'utf8')).viewers, 0, 'a non-attending hello is not persisted as human attendance');
|
|
208
|
+
try {
|
|
209
|
+
const seenAt = 12_000;
|
|
210
|
+
await h.tick(seenAt);
|
|
211
|
+
await h.tick(seenAt + 1500);
|
|
212
|
+
await h.waitFor(() => h.node(nodeId)?.status === 'done' && !isPidAlive(boot.pid), {
|
|
213
|
+
timeoutMs: 10_000,
|
|
214
|
+
intervalMs: 20,
|
|
215
|
+
label: 'system-observed unattended completion and broker exit',
|
|
216
|
+
});
|
|
217
|
+
await h.awaitFleetExit(nodeId);
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
if (priorParkMs === undefined)
|
|
221
|
+
delete process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
|
|
222
|
+
else
|
|
223
|
+
process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
|
|
224
|
+
}
|
|
225
|
+
});
|
|
190
226
|
test('a connected broker viewer keeps an obligation-free resident live', { timeout: 30_000 }, async () => {
|
|
191
227
|
const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
|
|
192
228
|
process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { Socket } from 'node:net';
|
|
2
2
|
import { FrameDecoder, type BrokerSnapshot, type BrokerToClient, type ClientRole } from '../broker-protocol.js';
|
|
3
|
-
/** True when the live broker has at least one
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
/** True when the live broker has at least one client that declared itself to
|
|
4
|
+
* be attending the conversation. A system observer may read the same stream
|
|
5
|
+
* without keeping an otherwise idle broker alive. Callers establish broker
|
|
6
|
+
* liveness separately because a crash can leave the last persisted count behind. */
|
|
6
7
|
export declare function hasAttachedBrokerViewers(nodeId: string): boolean;
|
|
7
8
|
export interface BrokerClient {
|
|
8
9
|
id: string;
|
|
@@ -10,6 +11,8 @@ export interface BrokerClient {
|
|
|
10
11
|
socket: Socket;
|
|
11
12
|
decoder: FrameDecoder;
|
|
12
13
|
helloed: boolean;
|
|
14
|
+
/** Fixed by the first hello; absent `attends` remains backward-compatible true. */
|
|
15
|
+
attends: boolean;
|
|
13
16
|
snapshotTail: number | undefined;
|
|
14
17
|
pendingBytes: number;
|
|
15
18
|
queuedFrames: number;
|
|
@@ -5,9 +5,10 @@ import { encodeFrame, FrameDecoder, BROKER_READ_CAPS, } from '../broker-protocol
|
|
|
5
5
|
/** Per-viewer outbound high-water mark. Slow viewers are shed rather than allowed to backpressure the shared engine indefinitely. */
|
|
6
6
|
const MAX_QUEUED_FRAMES = 1000;
|
|
7
7
|
const MAX_PENDING_BYTES = 32 * 1024 * 1024;
|
|
8
|
-
/** True when the live broker has at least one
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
/** True when the live broker has at least one client that declared itself to
|
|
9
|
+
* be attending the conversation. A system observer may read the same stream
|
|
10
|
+
* without keeping an otherwise idle broker alive. Callers establish broker
|
|
11
|
+
* liveness separately because a crash can leave the last persisted count behind. */
|
|
11
12
|
export function hasAttachedBrokerViewers(nodeId) {
|
|
12
13
|
try {
|
|
13
14
|
const record = JSON.parse(readFileSync(`${jobDir(nodeId)}/attach.json`, 'utf8'));
|
|
@@ -36,6 +37,7 @@ export class BrokerClientRegistry {
|
|
|
36
37
|
socket,
|
|
37
38
|
decoder: new FrameDecoder(BROKER_READ_CAPS),
|
|
38
39
|
helloed: false,
|
|
40
|
+
attends: true,
|
|
39
41
|
snapshotTail: undefined,
|
|
40
42
|
pendingBytes: 0,
|
|
41
43
|
queuedFrames: 0,
|
|
@@ -58,7 +60,7 @@ export class BrokerClientRegistry {
|
|
|
58
60
|
try {
|
|
59
61
|
let viewers = 0;
|
|
60
62
|
for (const client of this.clients)
|
|
61
|
-
if (client.helloed)
|
|
63
|
+
if (client.helloed && client.attends)
|
|
62
64
|
viewers += 1;
|
|
63
65
|
const dir = jobDir(this.nodeId);
|
|
64
66
|
if (!existsSync(dir))
|
|
@@ -757,6 +757,7 @@ export function createFrameDispatchContext(deps) {
|
|
|
757
757
|
if (firstHello) {
|
|
758
758
|
client.helloed = true;
|
|
759
759
|
client.role = frame.role === 'controller' ? 'controller' : 'observer';
|
|
760
|
+
client.attends = frame.attends !== false;
|
|
760
761
|
}
|
|
761
762
|
sendWelcome(client);
|
|
762
763
|
if (firstHello)
|
|
@@ -82,6 +82,10 @@ export interface HelloFrame {
|
|
|
82
82
|
type: 'hello';
|
|
83
83
|
role: ClientRole;
|
|
84
84
|
client_id: string;
|
|
85
|
+
/** Whether this socket represents a person attending the conversation. Defaults
|
|
86
|
+
* true for backward compatibility; system observers set false without changing
|
|
87
|
+
* their read-only `role`. */
|
|
88
|
+
attends?: boolean;
|
|
85
89
|
/** Per-attach welcome transcript bound; absent or invalid keeps the full history. */
|
|
86
90
|
snapshot_tail?: number;
|
|
87
91
|
/** Terminal geometry of a tmux-pane viewer; absent for a headless client. */
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Runs the synchronous configuration resolver outside crtrd's process. A
|
|
2
|
+
// network filesystem can block a native read indefinitely; unlike a worker
|
|
3
|
+
// thread, this subprocess can be killed at the launch-config timeout.
|
|
4
|
+
import { readMergedLaunchConfig } from '../config.js';
|
|
5
|
+
process.once('message', (message) => {
|
|
6
|
+
try {
|
|
7
|
+
process.send?.({ config: readMergedLaunchConfig(message.cwd, message.profileId) });
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
process.send?.({ error: error instanceof Error ? error.message : String(error) });
|
|
11
|
+
}
|
|
12
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type MergedLaunchConfig } from '../config.js';
|
|
1
2
|
import type { PiInvocation } from './invocation.js';
|
|
2
3
|
export { CANVAS_EXTENSIONS, CANVAS_STOPHOOK_PATH, CANVAS_INBOX_WATCHER_PATH, CANVAS_RECAP_PATH, CANVAS_GOAL_CAPTURE_PATH, CANVAS_PASSIVE_CONTEXT_PATH, CANVAS_CONTEXT_INTRO_PATH, CANVAS_TOOL_GUIDE_PATH, CANVAS_DOC_SUBSTRATE_PATH, CANVAS_STRUCTURED_OUTPUT_PATH, CANVAS_BASH_VALVE_PATH, } from './canvas-extensions.js';
|
|
3
4
|
import type { NodeMeta, LaunchSpec, Mode, Lifecycle } from '../canvas/index.js';
|
|
@@ -59,7 +60,7 @@ export declare function nextLadderModel(currentSpec: string, direction?: 1 | -1,
|
|
|
59
60
|
* a polymorph/flip rebuilds the recipe faithfully; `lifecycle` is returned
|
|
60
61
|
* as-given, never derived from persona frontmatter. The two canvas
|
|
61
62
|
* extensions are always first; kind-declared extensions follow. */
|
|
62
|
-
export
|
|
63
|
+
export interface LaunchBuildOptions {
|
|
63
64
|
lifecycle: Lifecycle;
|
|
64
65
|
hasManager: boolean;
|
|
65
66
|
extraEnv?: Record<string, string>;
|
|
@@ -101,10 +102,23 @@ export declare function buildLaunchSpec(kind: string, mode: Mode, opts: {
|
|
|
101
102
|
* broker's launch-time/turn-time resolution paths must bypass every
|
|
102
103
|
* fallback for and fail closed on unavailability. */
|
|
103
104
|
modelExact?: boolean;
|
|
104
|
-
}
|
|
105
|
+
}
|
|
106
|
+
/** Build a launch recipe for callers that are not on crtrd's event loop. */
|
|
107
|
+
export declare function buildLaunchSpec(kind: string, mode: Mode, opts: LaunchBuildOptions): {
|
|
105
108
|
launch: LaunchSpec;
|
|
106
109
|
lifecycle: 'terminal' | 'resident';
|
|
107
110
|
};
|
|
111
|
+
/**
|
|
112
|
+
* Resolve launch config in a bounded subprocess before building a recipe.
|
|
113
|
+
* crtrd is single-threaded: project/profile roots can live on a network
|
|
114
|
+
* filesystem, so even an ordinary synchronous config read must never run on
|
|
115
|
+
* its event loop.
|
|
116
|
+
*/
|
|
117
|
+
export declare function buildLaunchSpecAsync(kind: string, mode: Mode, opts: LaunchBuildOptions): Promise<{
|
|
118
|
+
launch: LaunchSpec;
|
|
119
|
+
lifecycle: 'terminal' | 'resident';
|
|
120
|
+
}>;
|
|
121
|
+
export declare function readMergedLaunchConfigAsync(cwd: string, profileId: string | null): Promise<MergedLaunchConfig>;
|
|
108
122
|
export type { PiInvocation } from './invocation.js';
|
|
109
123
|
/** Construct the pi invocation for a node.
|
|
110
124
|
* - fresh start: pass `prompt` (the node's first user message), no resume.
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { readMergedLaunchConfig } from '../config.js';
|
|
14
|
+
import { fork } from 'node:child_process';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
14
16
|
import { LAUNCH_SYSTEM_PROMPT } from './launch-prompt.js';
|
|
15
17
|
import { nodeEnv } from './nodes.js';
|
|
16
18
|
import { editorLabel } from '../canvas/index.js';
|
|
@@ -232,27 +234,7 @@ export function nextLadderModel(currentSpec, direction = 1, providers) {
|
|
|
232
234
|
return order[0];
|
|
233
235
|
return order[(idx + direction + order.length) % order.length];
|
|
234
236
|
}
|
|
235
|
-
|
|
236
|
-
// Build the launch spec from {kind, mode}
|
|
237
|
-
// ---------------------------------------------------------------------------
|
|
238
|
-
/** Compose a node's full pi launch recipe from the kind registry. The system
|
|
239
|
-
* prompt is now a minimal static scaffold (`LAUNCH_SYSTEM_PROMPT`) — runtime/
|
|
240
|
-
* spine/lifecycle/kind prose is spliced per-turn from gated builtin memory docs,
|
|
241
|
-
* not baked in here. Launch KNOBS (model/tools/extensions) still come from the
|
|
242
|
-
* kind registry (`resolveKindConfig`), keyed on the FULL kind string (top-level
|
|
243
|
-
* or sub-kind). Model precedence, strongest to weakest:
|
|
244
|
-
* 1. Caller-supplied `opts.model` (durable on `meta.model_override`, re-passed
|
|
245
|
-
* on every polymorph).
|
|
246
|
-
* 2. The kind's registered model — mode-aware: orchestrator mode prefers
|
|
247
|
-
* `orchestratorModel`, falling back to `model`; base mode always uses
|
|
248
|
-
* `model`.
|
|
249
|
-
* 3. Unset → pi default.
|
|
250
|
-
* Callers pass the authoritative lifecycle + hasManager (`parent !== null`) so
|
|
251
|
-
* a polymorph/flip rebuilds the recipe faithfully; `lifecycle` is returned
|
|
252
|
-
* as-given, never derived from persona frontmatter. The two canvas
|
|
253
|
-
* extensions are always first; kind-declared extensions follow. */
|
|
254
|
-
export function buildLaunchSpec(kind, mode, opts) {
|
|
255
|
-
const merged = readMergedLaunchConfig(opts.cwd ?? process.cwd(), opts.profileId !== undefined ? opts.profileId : (process.env['CRTR_PROFILE_ID'] || null));
|
|
237
|
+
function buildLaunchSpecFromMerged(kind, mode, opts, merged) {
|
|
256
238
|
const kindConfig = merged.kinds[kind];
|
|
257
239
|
const ladders = merged.modelLadders;
|
|
258
240
|
const kindModel = mode === 'orchestrator' ? kindConfig?.orchestratorModel ?? kindConfig?.model : kindConfig?.model;
|
|
@@ -274,6 +256,58 @@ export function buildLaunchSpec(kind, mode, opts) {
|
|
|
274
256
|
};
|
|
275
257
|
return { launch, lifecycle: opts.lifecycle };
|
|
276
258
|
}
|
|
259
|
+
/** Build a launch recipe for callers that are not on crtrd's event loop. */
|
|
260
|
+
export function buildLaunchSpec(kind, mode, opts) {
|
|
261
|
+
return buildLaunchSpecFromMerged(kind, mode, opts, readMergedLaunchConfig(opts.cwd ?? process.cwd(), opts.profileId !== undefined ? opts.profileId : (process.env['CRTR_PROFILE_ID'] || null)));
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Resolve launch config in a bounded subprocess before building a recipe.
|
|
265
|
+
* crtrd is single-threaded: project/profile roots can live on a network
|
|
266
|
+
* filesystem, so even an ordinary synchronous config read must never run on
|
|
267
|
+
* its event loop.
|
|
268
|
+
*/
|
|
269
|
+
export async function buildLaunchSpecAsync(kind, mode, opts) {
|
|
270
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
271
|
+
const profileId = opts.profileId !== undefined ? opts.profileId : (process.env['CRTR_PROFILE_ID'] || null);
|
|
272
|
+
const merged = await readMergedLaunchConfigAsync(cwd, profileId);
|
|
273
|
+
return buildLaunchSpecFromMerged(kind, mode, opts, merged);
|
|
274
|
+
}
|
|
275
|
+
const LAUNCH_CONFIG_TIMEOUT_MS = 15_000;
|
|
276
|
+
export function readMergedLaunchConfigAsync(cwd, profileId) {
|
|
277
|
+
return new Promise((resolve, reject) => {
|
|
278
|
+
const child = fork(fileURLToPath(new URL('./launch-config-worker.js', import.meta.url)), [], {
|
|
279
|
+
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
280
|
+
serialization: 'advanced',
|
|
281
|
+
});
|
|
282
|
+
child.send({ cwd, profileId });
|
|
283
|
+
let settled = false;
|
|
284
|
+
const finish = (fn) => {
|
|
285
|
+
if (settled)
|
|
286
|
+
return;
|
|
287
|
+
settled = true;
|
|
288
|
+
clearTimeout(timeout);
|
|
289
|
+
if (child.connected)
|
|
290
|
+
child.disconnect();
|
|
291
|
+
if (!child.killed)
|
|
292
|
+
child.kill('SIGKILL');
|
|
293
|
+
fn();
|
|
294
|
+
};
|
|
295
|
+
const timeout = setTimeout(() => finish(() => reject(new Error(`timed out after ${LAUNCH_CONFIG_TIMEOUT_MS}ms reading launch config for ${cwd}`))), LAUNCH_CONFIG_TIMEOUT_MS);
|
|
296
|
+
child.once('message', (message) => {
|
|
297
|
+
if (message.error !== undefined)
|
|
298
|
+
finish(() => reject(new Error(message.error)));
|
|
299
|
+
else if (message.config !== undefined)
|
|
300
|
+
finish(() => resolve(message.config));
|
|
301
|
+
else
|
|
302
|
+
finish(() => reject(new Error('launch config subprocess returned no result')));
|
|
303
|
+
});
|
|
304
|
+
child.once('error', (error) => finish(() => reject(error)));
|
|
305
|
+
child.once('exit', (code, signal) => {
|
|
306
|
+
if (code !== 0 && signal !== 'SIGKILL')
|
|
307
|
+
finish(() => reject(new Error(`launch config subprocess exited with code ${code}`)));
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
}
|
|
277
311
|
// ---------------------------------------------------------------------------
|
|
278
312
|
// Build the pi argv to launch / revive a node
|
|
279
313
|
// ---------------------------------------------------------------------------
|
|
@@ -14,13 +14,17 @@ export interface PromoteResult {
|
|
|
14
14
|
* is what a plain `crtr node yield --kind/--model` reshape rides, with no
|
|
15
15
|
* mode flip and no roadmap seeding. `promote()` below is a thin wrapper that
|
|
16
16
|
* also forces mode→orchestrator and seeds the roadmap. */
|
|
17
|
-
|
|
17
|
+
type ReshapeOptions = {
|
|
18
18
|
kind?: string;
|
|
19
19
|
model?: string;
|
|
20
20
|
mode?: Mode;
|
|
21
21
|
lifecycle?: Lifecycle;
|
|
22
22
|
profileId?: string | null;
|
|
23
|
-
}
|
|
23
|
+
};
|
|
24
|
+
/** Synchronous reshape for CLI callers outside the daemon. */
|
|
25
|
+
export declare function reshapeNode(nodeId: string, opts?: ReshapeOptions, beforePersist?: () => void): NodeMeta;
|
|
26
|
+
/** Daemon-safe reshape: network-backed config resolution happens off its event loop. */
|
|
27
|
+
export declare function reshapeNodeAsync(nodeId: string, opts?: ReshapeOptions, beforePersist?: () => void): Promise<NodeMeta>;
|
|
24
28
|
/** Promote a node to an orchestrator (mode→orchestrator), optionally
|
|
25
29
|
* specializing its kind (e.g. a `general` worker becoming a
|
|
26
30
|
* `developer.orchestrator`) and optionally also making it resident. Idempotent:
|
|
@@ -33,6 +37,12 @@ export declare function promote(nodeId: string, opts?: {
|
|
|
33
37
|
resident?: boolean;
|
|
34
38
|
model?: string;
|
|
35
39
|
}): PromoteResult;
|
|
40
|
+
/** Daemon-safe promotion variant; preserves the same validation/seed ordering. */
|
|
41
|
+
export declare function promoteAsync(nodeId: string, opts?: {
|
|
42
|
+
kind?: string;
|
|
43
|
+
resident?: boolean;
|
|
44
|
+
model?: string;
|
|
45
|
+
}): Promise<PromoteResult>;
|
|
36
46
|
export interface YieldResult {
|
|
37
47
|
meta: NodeMeta;
|
|
38
48
|
/** True iff `promote:true` was passed — a plain yield never flips mode. */
|
|
@@ -49,6 +59,13 @@ export interface YieldResult {
|
|
|
49
59
|
* same path `crtr node promote` would — mode flips to orchestrator and a
|
|
50
60
|
* roadmap scaffold is seeded. Sets intent='refresh'; the stophook shuts the
|
|
51
61
|
* process down on the next stop and the daemon revives it fresh. */
|
|
62
|
+
export declare function requestYieldAsync(nodeId: string, opts?: {
|
|
63
|
+
kind?: string;
|
|
64
|
+
model?: string;
|
|
65
|
+
promote?: boolean;
|
|
66
|
+
}, deps?: {
|
|
67
|
+
ensure?: () => void;
|
|
68
|
+
}): Promise<YieldResult>;
|
|
52
69
|
export declare function requestYield(nodeId: string, opts?: {
|
|
53
70
|
kind?: string;
|
|
54
71
|
model?: string;
|
|
@@ -56,3 +73,4 @@ export declare function requestYield(nodeId: string, opts?: {
|
|
|
56
73
|
}, deps?: {
|
|
57
74
|
ensure?: () => void;
|
|
58
75
|
}): YieldResult;
|
|
76
|
+
export {};
|
|
@@ -21,75 +21,48 @@
|
|
|
21
21
|
import { getNode, updateNode } from '../canvas/index.js';
|
|
22
22
|
import { transition } from './lifecycle.js';
|
|
23
23
|
import { ensureDaemon } from '../../daemon/manage.js';
|
|
24
|
-
import { buildLaunchSpec } from './launch.js';
|
|
24
|
+
import { buildLaunchSpec, buildLaunchSpecAsync } from './launch.js';
|
|
25
25
|
import { hasRoadmap, seedRoadmap, roadmapPath } from './roadmap.js';
|
|
26
26
|
import { readGoal, goalPath } from './kickoff.js';
|
|
27
|
-
|
|
28
|
-
* caller supplies them, mode/lifecycle — by rebuilding the launch spec so the
|
|
29
|
-
* change is durable across a future revive. MODE-PRESERVING by default (omit
|
|
30
|
-
* `mode`/`lifecycle` and the node's own current values carry through) — this
|
|
31
|
-
* is what a plain `crtr node yield --kind/--model` reshape rides, with no
|
|
32
|
-
* mode flip and no roadmap seeding. `promote()` below is a thin wrapper that
|
|
33
|
-
* also forces mode→orchestrator and seeds the roadmap. */
|
|
34
|
-
export function reshapeNode(nodeId, opts = {}, beforePersist) {
|
|
27
|
+
function reshapeInputs(nodeId, opts) {
|
|
35
28
|
const node = getNode(nodeId);
|
|
36
29
|
if (node === null)
|
|
37
30
|
throw new Error(`unknown node: ${nodeId}`);
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
// default and is persisted below; absent one, the existing pin carries
|
|
55
|
-
// across the polymorph (the persona default is recomputed fresh for
|
|
56
|
-
// targetKind).
|
|
57
|
-
model: targetModel,
|
|
58
|
-
// opts.model given — an explicit new pick — lets buildLaunchSpec re-decide
|
|
59
|
-
// the pin from ITS raw shape (undefined here). No opts.model — targetModel
|
|
60
|
-
// just re-supplies the current value for resolution, not a new user
|
|
61
|
-
// choice — so carry the node's existing pin verbatim instead.
|
|
62
|
-
modelProviderPinned: opts.model === undefined ? node.launch?.modelProviderPinned : undefined,
|
|
63
|
-
modelIntent: opts.model === undefined ? node.launch?.modelIntent : undefined,
|
|
64
|
-
// Exact-model human-work forks must stay fail-closed after a polymorph.
|
|
65
|
-
// Dropping this bit produces a recipe reviveNode refuses to relaunch.
|
|
66
|
-
modelExact: opts.model === undefined ? node.launch?.modelExact : undefined,
|
|
67
|
-
// Resolve kind/ladder config against the NODE's own cwd + profile, not
|
|
68
|
-
// this process's ambient scope.
|
|
69
|
-
cwd: node.cwd,
|
|
70
|
-
profileId: targetProfileId,
|
|
71
|
-
});
|
|
72
|
-
// Promotion seeds after its target launch has been built but before the
|
|
73
|
-
// mutation is persisted, preserving the original promote() failure order.
|
|
31
|
+
const kind = opts.kind ?? node.kind;
|
|
32
|
+
const mode = opts.mode ?? node.mode;
|
|
33
|
+
const lifecycle = opts.lifecycle ?? node.lifecycle;
|
|
34
|
+
const profileId = opts.profileId === undefined ? node.profile_id ?? null : opts.profileId;
|
|
35
|
+
return {
|
|
36
|
+
node, kind, mode, lifecycle, profileId,
|
|
37
|
+
launchOpts: {
|
|
38
|
+
lifecycle, hasManager: node.parent !== null, model: opts.model ?? node.model_override ?? undefined,
|
|
39
|
+
modelProviderPinned: opts.model === undefined ? node.launch?.modelProviderPinned : undefined,
|
|
40
|
+
modelIntent: opts.model === undefined ? node.launch?.modelIntent : undefined,
|
|
41
|
+
modelExact: opts.model === undefined ? node.launch?.modelExact : undefined,
|
|
42
|
+
cwd: node.cwd, profileId,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function persistReshape(nodeId, opts, input, launch, beforePersist) {
|
|
74
47
|
beforePersist?.();
|
|
75
|
-
|
|
76
|
-
// carry it to the new kind so the "name === kind ⇒ default, drop it" invariant
|
|
77
|
-
// in fullName() holds across a kind change. Otherwise a node spawned `general`
|
|
78
|
-
// (name="general") that reshapes to `plan` would surface a stale "general"
|
|
79
|
-
// handle in front of its real description.
|
|
80
|
-
const carriesDefaultHandle = node.name === node.kind && targetKind !== node.kind;
|
|
48
|
+
const carriesDefaultHandle = input.node.name === input.node.kind && input.kind !== input.node.kind;
|
|
81
49
|
return updateNode(nodeId, {
|
|
82
|
-
kind:
|
|
83
|
-
|
|
84
|
-
lifecycle: targetLifecycle,
|
|
85
|
-
profile_id: targetProfileId,
|
|
86
|
-
launch,
|
|
87
|
-
...(carriesDefaultHandle ? { name: targetKind } : {}),
|
|
88
|
-
// Persist a newly-chosen tier so it is durable across future revives; omit
|
|
89
|
-
// when unchanged so the existing pin (or persona default) stands.
|
|
50
|
+
kind: input.kind, mode: input.mode, lifecycle: input.lifecycle, profile_id: input.profileId, launch,
|
|
51
|
+
...(carriesDefaultHandle ? { name: input.kind } : {}),
|
|
90
52
|
...(opts.model !== undefined ? { model_override: opts.model } : {}),
|
|
91
53
|
});
|
|
92
54
|
}
|
|
55
|
+
/** Synchronous reshape for CLI callers outside the daemon. */
|
|
56
|
+
export function reshapeNode(nodeId, opts = {}, beforePersist) {
|
|
57
|
+
const input = reshapeInputs(nodeId, opts);
|
|
58
|
+
return persistReshape(nodeId, opts, input, buildLaunchSpec(input.kind, input.mode, input.launchOpts).launch, beforePersist);
|
|
59
|
+
}
|
|
60
|
+
/** Daemon-safe reshape: network-backed config resolution happens off its event loop. */
|
|
61
|
+
export async function reshapeNodeAsync(nodeId, opts = {}, beforePersist) {
|
|
62
|
+
const input = reshapeInputs(nodeId, opts);
|
|
63
|
+
const { launch } = await buildLaunchSpecAsync(input.kind, input.mode, input.launchOpts);
|
|
64
|
+
return persistReshape(nodeId, opts, input, launch, beforePersist);
|
|
65
|
+
}
|
|
93
66
|
/** Promote a node to an orchestrator (mode→orchestrator), optionally
|
|
94
67
|
* specializing its kind (e.g. a `general` worker becoming a
|
|
95
68
|
* `developer.orchestrator`) and optionally also making it resident. Idempotent:
|
|
@@ -129,6 +102,25 @@ export function promote(nodeId, opts = {}) {
|
|
|
129
102
|
goalPath: goalPath(nodeId),
|
|
130
103
|
};
|
|
131
104
|
}
|
|
105
|
+
/** Daemon-safe promotion variant; preserves the same validation/seed ordering. */
|
|
106
|
+
export async function promoteAsync(nodeId, opts = {}) {
|
|
107
|
+
const node = getNode(nodeId);
|
|
108
|
+
if (node === null)
|
|
109
|
+
throw new Error(`unknown node: ${nodeId}`);
|
|
110
|
+
let roadmapWritten = false;
|
|
111
|
+
const meta = await reshapeNodeAsync(nodeId, {
|
|
112
|
+
...(opts.kind !== undefined ? { kind: opts.kind } : {}),
|
|
113
|
+
...(opts.model !== undefined ? { model: opts.model } : {}),
|
|
114
|
+
mode: 'orchestrator', lifecycle: opts.resident === true ? 'resident' : node.lifecycle,
|
|
115
|
+
}, () => {
|
|
116
|
+
if (!hasRoadmap(nodeId)) {
|
|
117
|
+
const goal = readGoal(nodeId);
|
|
118
|
+
seedRoadmap(nodeId, goal !== null && goal.trim() !== '' ? { goal: goal.trim() } : {});
|
|
119
|
+
roadmapWritten = true;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return { meta, roadmapWritten, roadmapPath: roadmapPath(nodeId), goalPath: goalPath(nodeId) };
|
|
123
|
+
}
|
|
132
124
|
/** Request a refresh-yield: discard in-memory context and revive fresh, mode
|
|
133
125
|
* and lifecycle UNCHANGED, against the roadmap if one exists else the
|
|
134
126
|
* original goal (`buildReviveKickoff` falls back to it). A plain yield is
|
|
@@ -138,6 +130,18 @@ export function promote(nodeId, opts = {}) {
|
|
|
138
130
|
* same path `crtr node promote` would — mode flips to orchestrator and a
|
|
139
131
|
* roadmap scaffold is seeded. Sets intent='refresh'; the stophook shuts the
|
|
140
132
|
* process down on the next stop and the daemon revives it fresh. */
|
|
133
|
+
export async function requestYieldAsync(nodeId, opts = {}, deps = {}) {
|
|
134
|
+
const node = getNode(nodeId);
|
|
135
|
+
if (node === null)
|
|
136
|
+
throw new Error(`unknown node: ${nodeId}`);
|
|
137
|
+
if (opts.promote === true) {
|
|
138
|
+
await promoteAsync(nodeId, { ...(opts.kind !== undefined ? { kind: opts.kind } : {}), ...(opts.model !== undefined ? { model: opts.model } : {}) });
|
|
139
|
+
}
|
|
140
|
+
else if (opts.kind !== undefined || opts.model !== undefined) {
|
|
141
|
+
await reshapeNodeAsync(nodeId, { ...(opts.kind !== undefined ? { kind: opts.kind } : {}), ...(opts.model !== undefined ? { model: opts.model } : {}) });
|
|
142
|
+
}
|
|
143
|
+
return { ...requestYield(nodeId, {}, deps), promoted: opts.promote === true };
|
|
144
|
+
}
|
|
141
145
|
export function requestYield(nodeId, opts = {}, deps = {}) {
|
|
142
146
|
const ensure = deps.ensure ?? ensureDaemon;
|
|
143
147
|
const node = getNode(nodeId);
|
|
@@ -21,7 +21,7 @@ import { getNode, setPresence, setFocusOccupant, fullName } from '../canvas/inde
|
|
|
21
21
|
import { reportsDir } from '../canvas/paths.js';
|
|
22
22
|
import { pushFinal } from '../feed/feed.js';
|
|
23
23
|
import { spawnNode, rootOfSpine } from './nodes.js';
|
|
24
|
-
import {
|
|
24
|
+
import { buildLaunchSpecAsync, buildPiArgv } from './launch.js';
|
|
25
25
|
import { focusOf, respawnPaneSync, setPaneOption } from './placement.js';
|
|
26
26
|
import { waitForBrokerViewSocket, viewerSplitEnv } from './placement-tmux.js';
|
|
27
27
|
import { headlessBrokerHost } from './host.js';
|
|
@@ -102,7 +102,7 @@ export async function recycleNode(nodeId, callerPane) {
|
|
|
102
102
|
// ambient scope) and carry the profile onto the fresh root — it is the same
|
|
103
103
|
// person's seat, so profile-scope defaults (e.g. a persisted /model default)
|
|
104
104
|
// must keep applying.
|
|
105
|
-
const { launch } =
|
|
105
|
+
const { launch } = await buildLaunchSpecAsync('general', 'base', { lifecycle: 'resident', hasManager: false, cwd: meta.cwd, profileId: meta.profile_id });
|
|
106
106
|
const root = spawnNode({
|
|
107
107
|
kind: 'general',
|
|
108
108
|
mode: 'base',
|
|
@@ -25,7 +25,7 @@ import { transition } from './lifecycle.js';
|
|
|
25
25
|
import { headlessBrokerHost } from './host.js';
|
|
26
26
|
import { tearDownNode, focusOf, registerViewerFocus, respawnPaneSync, windowOfPane, renameWindow, } from './placement.js';
|
|
27
27
|
import { viewerSplitEnv, waitForBrokerViewSocket } from './placement-tmux.js';
|
|
28
|
-
import {
|
|
28
|
+
import { buildLaunchSpecAsync, buildPiArgv } from './launch.js';
|
|
29
29
|
import { spawnNode, rootOfSpine } from './nodes.js';
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
31
31
|
// reapDescendants — tear down a root's descendant sub-DAG (shared helper)
|
|
@@ -83,7 +83,7 @@ export async function relaunchRoot(oldId, deps = {}) {
|
|
|
83
83
|
// --- mint + boot the NEW broker FIRST (a failure here leaves the old root
|
|
84
84
|
// untouched) ---
|
|
85
85
|
// A relaunched root is a fresh resident base, exactly like the front door.
|
|
86
|
-
const { launch } =
|
|
86
|
+
const { launch } = await buildLaunchSpecAsync(old.kind, 'base', {
|
|
87
87
|
lifecycle: 'resident',
|
|
88
88
|
hasManager: false,
|
|
89
89
|
model: old.model_override ?? undefined,
|
|
@@ -14,7 +14,7 @@ import { isAbsolute, resolve, join } from 'node:path';
|
|
|
14
14
|
import { spawnNode, currentNodeContext, rootOfSpine, newNodeId, preflightNodeId } from './nodes.js';
|
|
15
15
|
import { loadProfileManifest } from '../profiles/manifest.js';
|
|
16
16
|
import { selectProfileForCwd } from '../profiles/select.js';
|
|
17
|
-
import {
|
|
17
|
+
import { buildLaunchSpecAsync, buildPiArgv } from './launch.js';
|
|
18
18
|
import { createManagedWorktree, rollbackManagedWorktree } from '../worktree.js';
|
|
19
19
|
import { usage, brokerLaunchFailed } from '../errors.js';
|
|
20
20
|
import { writeGoal } from './kickoff.js';
|
|
@@ -237,7 +237,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
|
|
|
237
237
|
if (opts.nodeId !== undefined)
|
|
238
238
|
preflightNodeId(opts.nodeId);
|
|
239
239
|
const profileId = await resolveProfileId(opts.profile, spawner, opts.cwd);
|
|
240
|
-
const preflightLaunch =
|
|
240
|
+
const preflightLaunch = (await buildLaunchSpecAsync(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model, cwd: opts.cwd, profileId })).launch;
|
|
241
241
|
await assertLaunchModelRegistered(requestFromLaunch(preflightLaunch), { cwd: opts.cwd, profileId });
|
|
242
242
|
const forkFrom = opts.forkFrom !== undefined ? resolveForkSource(opts.forkFrom) : undefined;
|
|
243
243
|
let managedWorktree;
|
|
@@ -255,7 +255,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
|
|
|
255
255
|
// this process's ambient scope (a front-door shell has no CRTR_PROFILE_ID,
|
|
256
256
|
// which silently hid every profile-scope kind override).
|
|
257
257
|
const launch = wantsWorktree
|
|
258
|
-
?
|
|
258
|
+
? (await buildLaunchSpecAsync(opts.kind, mode, { lifecycle, hasManager: !root, model: opts.model, cwd: spawnCwd, profileId })).launch
|
|
259
259
|
: preflightLaunch;
|
|
260
260
|
if (wantsWorktree) {
|
|
261
261
|
await assertLaunchModelRegistered(requestFromLaunch(launch), { cwd: spawnCwd, profileId });
|