@north-light/crouter 0.3.218 → 0.3.219
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/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/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -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 });
|
|
@@ -62,7 +62,7 @@ import { claimWarmSpare, deleteNode, getNode, listNodes, listWarmSpares, registe
|
|
|
62
62
|
import { recordedPidLiveness } from '../canvas/pid.js';
|
|
63
63
|
import { nowIso } from '../fs-utils.js';
|
|
64
64
|
import { spawnChildPrepared } from './spawn.js';
|
|
65
|
-
import {
|
|
65
|
+
import { buildLaunchSpecAsync } from './launch.js';
|
|
66
66
|
import { setModelLive } from './model-swap.js';
|
|
67
67
|
import { setSituationalContextLive } from './situational-live.js';
|
|
68
68
|
import { deliverBearingsLive } from './deliver-live.js';
|
|
@@ -100,8 +100,8 @@ function warmRecipeKey(cwd, profileId, launch) {
|
|
|
100
100
|
}
|
|
101
101
|
/** Resolve a request's frozen half. Both cwd and profile arrive already
|
|
102
102
|
* resolved from the create handler, so this is pure launch-spec construction. */
|
|
103
|
-
function resolveRecipe(request) {
|
|
104
|
-
const { launch } =
|
|
103
|
+
async function resolveRecipe(request) {
|
|
104
|
+
const { launch } = await buildLaunchSpecAsync(request.kind, request.mode, {
|
|
105
105
|
lifecycle: 'resident',
|
|
106
106
|
hasManager: false,
|
|
107
107
|
...(request.model !== null ? { model: request.model } : {}),
|
|
@@ -114,7 +114,7 @@ function resolveRecipe(request) {
|
|
|
114
114
|
* pool cannot serve it. The caller falls back to a cold spawn; a claim never
|
|
115
115
|
* fails the create. */
|
|
116
116
|
export async function claimWarmNode(request) {
|
|
117
|
-
const recipe = resolveRecipe(request);
|
|
117
|
+
const recipe = await resolveRecipe(request);
|
|
118
118
|
const created = nowIso();
|
|
119
119
|
// The row's stamp moves inside the claim transaction; meta.json (the identity
|
|
120
120
|
// source of truth `rebuildIndex` re-derives the row from) follows with the
|
|
@@ -261,7 +261,9 @@ export function refillWarmPool(request) {
|
|
|
261
261
|
// Mint at the kind's DEFAULT model and with NO situational context: both
|
|
262
262
|
// are claim-time properties, so baking either in would only narrow who the
|
|
263
263
|
// spare can serve.
|
|
264
|
-
|
|
264
|
+
void resolveRecipe({ ...request, model: null, situationalContext: null })
|
|
265
|
+
.then(mint)
|
|
266
|
+
.catch((err) => console.error(`[warm-pool] recipe resolution failed: ${err.message}`));
|
|
265
267
|
}
|
|
266
268
|
catch (err) {
|
|
267
269
|
console.error(`[warm-pool] recipe resolution failed: ${err.message}`);
|
|
@@ -349,7 +351,7 @@ export function prewarmRecentRecipes() {
|
|
|
349
351
|
// `general`/`base` is only the shape the spare BOOTS as — the key it is
|
|
350
352
|
// filed under excludes everything a claim can reshape, so any kind can
|
|
351
353
|
// claim it.
|
|
352
|
-
|
|
354
|
+
void resolveRecipe({
|
|
353
355
|
kind: 'general',
|
|
354
356
|
mode: 'base',
|
|
355
357
|
cwd: candidate.cwd,
|
|
@@ -357,7 +359,7 @@ export function prewarmRecentRecipes() {
|
|
|
357
359
|
model: null,
|
|
358
360
|
situationalContext: null,
|
|
359
361
|
name: null,
|
|
360
|
-
}));
|
|
362
|
+
}).then(mint).catch((err) => console.error(`[warm-pool] prewarm failed for ${candidate.cwd}: ${err.message}`));
|
|
361
363
|
}
|
|
362
364
|
catch (err) {
|
|
363
365
|
console.error(`[warm-pool] prewarm failed for ${candidate.cwd}: ${err.message}`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import { startRemoteAttachHeartbeat } from '../bridge.js';
|
|
5
|
+
class FakeSocket extends EventEmitter {
|
|
6
|
+
pings = 0;
|
|
7
|
+
terminated = 0;
|
|
8
|
+
ping() { this.pings += 1; }
|
|
9
|
+
terminate() { this.terminated += 1; }
|
|
10
|
+
}
|
|
11
|
+
function sleep(ms) {
|
|
12
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
}
|
|
14
|
+
test('remote attach heartbeat pings an idle viewer and keeps a ponging observer alive', async () => {
|
|
15
|
+
const ws = new FakeSocket();
|
|
16
|
+
const stop = startRemoteAttachHeartbeat(ws, 20);
|
|
17
|
+
await sleep(25);
|
|
18
|
+
assert.equal(ws.pings, 1, 'an idle attach receives a WebSocket ping before proxy idle timeout');
|
|
19
|
+
ws.emit('pong');
|
|
20
|
+
await sleep(10);
|
|
21
|
+
assert.equal(ws.terminated, 0, 'a pong renews the heartbeat without terminating the observer');
|
|
22
|
+
stop();
|
|
23
|
+
});
|
|
24
|
+
test('remote attach heartbeat drops only an unresponsive observer socket', async () => {
|
|
25
|
+
const ws = new FakeSocket();
|
|
26
|
+
const stop = startRemoteAttachHeartbeat(ws, 5);
|
|
27
|
+
await sleep(16);
|
|
28
|
+
assert.equal(ws.terminated, 1, 'two missed heartbeat intervals terminate the detached viewer');
|
|
29
|
+
stop();
|
|
30
|
+
});
|
|
@@ -169,6 +169,36 @@ test('A4a: WS attach welcome.snapshot is byte-identical to a direct ViewSocketCl
|
|
|
169
169
|
assert.equal(JSON.stringify(wsWelcome.snapshot), JSON.stringify(direct.welcome.snapshot), 'WS welcome.snapshot is byte-identical to the direct ViewSocketClient snapshot');
|
|
170
170
|
direct.close();
|
|
171
171
|
});
|
|
172
|
+
test('A4a regression: reconnecting an observer keeps the live broker instance', async () => {
|
|
173
|
+
const root = h.spawnRoot('a4 reconnect root');
|
|
174
|
+
const id = (await client.createNode({ kind: 'developer', parent: root, prompt: 'a4 reconnect worker' })).node_id;
|
|
175
|
+
await h.awaitBoot(id);
|
|
176
|
+
const pid = h.node(id)?.pi_pid;
|
|
177
|
+
const boots = h.bootCount(id);
|
|
178
|
+
const attachOnce = () => new Promise((resolveAttach, rejectAttach) => {
|
|
179
|
+
const ws = new WebSocket(`ws+unix://${sockPath}:/v1/nodes/${id}/attach`);
|
|
180
|
+
const timer = setTimeout(() => {
|
|
181
|
+
ws.terminate();
|
|
182
|
+
rejectAttach(new Error('timed out waiting for observer welcome'));
|
|
183
|
+
}, 15_000);
|
|
184
|
+
ws.on('open', () => ws.send(JSON.stringify({ type: 'hello', role: 'observer', client_id: `a4-reconnect-${Math.random()}` })));
|
|
185
|
+
ws.on('message', (data) => {
|
|
186
|
+
if (JSON.parse(data.toString('utf8'))['type'] !== 'welcome')
|
|
187
|
+
return;
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
ws.close();
|
|
190
|
+
resolveAttach();
|
|
191
|
+
});
|
|
192
|
+
ws.on('error', (error) => {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
rejectAttach(error);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
await attachOnce();
|
|
198
|
+
await attachOnce();
|
|
199
|
+
assert.equal(h.bootCount(id), boots, 'observer reconnect did not relaunch the broker');
|
|
200
|
+
assert.equal(h.node(id)?.pi_pid, pid, 'the in-flight broker instance was retained');
|
|
201
|
+
});
|
|
172
202
|
test('A4b: ?revive=0 on a dormant node refuses 409 without launching a broker', async () => {
|
|
173
203
|
const root = h.spawnRoot('a4b root');
|
|
174
204
|
// A dormant row: no live broker, no pi_pid, view.sock absent.
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { IncomingMessage } from 'node:http';
|
|
2
2
|
import type { Duplex } from 'node:stream';
|
|
3
|
+
import { type WebSocket } from 'ws';
|
|
4
|
+
/** Keep an otherwise quiet observer connection alive through common 60s proxy
|
|
5
|
+
* idle limits. A viewer only observes a broker, so a missed heartbeat may close
|
|
6
|
+
* this socket but must never affect the broker or its in-flight work. */
|
|
7
|
+
export declare const REMOTE_ATTACH_HEARTBEAT_MS = 25000;
|
|
8
|
+
export declare function startRemoteAttachHeartbeat(ws: WebSocket, intervalMs?: number): () => void;
|
|
3
9
|
/** Handle the HTTP upgrade for `GET /v1/nodes/{id}/attach`. A-9 matches the
|
|
4
10
|
* route, extracts `{id}`, and calls this with the raw upgrade triplet; this
|
|
5
11
|
* owns the WS handshake (its own noServer `wss` above) AND the spec §5.3
|
|
@@ -33,6 +33,40 @@ import { probeViewSocket, waitForBrokerViewSocket } from '../../core/runtime/vie
|
|
|
33
33
|
* client cap) so a remote peer cannot buffer 256MiB before the broker's 24MiB
|
|
34
34
|
* decoder rejects the message. */
|
|
35
35
|
const wss = new WebSocketServer({ noServer: true, maxPayload: BROKER_READ_CAPS.maxLineBytes });
|
|
36
|
+
/** Keep an otherwise quiet observer connection alive through common 60s proxy
|
|
37
|
+
* idle limits. A viewer only observes a broker, so a missed heartbeat may close
|
|
38
|
+
* this socket but must never affect the broker or its in-flight work. */
|
|
39
|
+
export const REMOTE_ATTACH_HEARTBEAT_MS = 25_000;
|
|
40
|
+
export function startRemoteAttachHeartbeat(ws, intervalMs = REMOTE_ATTACH_HEARTBEAT_MS) {
|
|
41
|
+
let alive = true;
|
|
42
|
+
let stopped = false;
|
|
43
|
+
const acknowledge = () => { alive = true; };
|
|
44
|
+
const stop = () => {
|
|
45
|
+
if (stopped)
|
|
46
|
+
return;
|
|
47
|
+
stopped = true;
|
|
48
|
+
clearInterval(timer);
|
|
49
|
+
ws.off('pong', acknowledge);
|
|
50
|
+
};
|
|
51
|
+
ws.on('pong', acknowledge);
|
|
52
|
+
const timer = setInterval(() => {
|
|
53
|
+
if (!alive) {
|
|
54
|
+
stop();
|
|
55
|
+
ws.terminate();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
alive = false;
|
|
59
|
+
try {
|
|
60
|
+
ws.ping();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
stop();
|
|
64
|
+
ws.terminate();
|
|
65
|
+
}
|
|
66
|
+
}, intervalMs);
|
|
67
|
+
timer.unref();
|
|
68
|
+
return stop;
|
|
69
|
+
}
|
|
36
70
|
/** A WS close reason is capped at 123 UTF-8 bytes by the protocol. */
|
|
37
71
|
function clampReason(reason) {
|
|
38
72
|
const buf = Buffer.from(reason, 'utf8');
|
|
@@ -106,6 +140,7 @@ function bridgeConnection(ws, nodeId) {
|
|
|
106
140
|
const decoder = new FrameDecoder(CLIENT_READ_CAPS);
|
|
107
141
|
let closed = false;
|
|
108
142
|
let viewerFaultRecorded = false;
|
|
143
|
+
let stopHeartbeat;
|
|
109
144
|
socket.on('connect', () => {
|
|
110
145
|
clearFault(nodeId, { link: 'relay↔broker' });
|
|
111
146
|
});
|
|
@@ -113,6 +148,7 @@ function bridgeConnection(ws, nodeId) {
|
|
|
113
148
|
if (closed)
|
|
114
149
|
return;
|
|
115
150
|
closed = true;
|
|
151
|
+
stopHeartbeat?.();
|
|
116
152
|
if (!socket.destroyed) {
|
|
117
153
|
try {
|
|
118
154
|
socket.destroy();
|
|
@@ -128,6 +164,7 @@ function bridgeConnection(ws, nodeId) {
|
|
|
128
164
|
/* ignore */
|
|
129
165
|
}
|
|
130
166
|
};
|
|
167
|
+
stopHeartbeat = startRemoteAttachHeartbeat(ws);
|
|
131
168
|
// socket → WS: bounded decode, one WS text message per complete frame.
|
|
132
169
|
socket.on('data', (chunk) => {
|
|
133
170
|
let frames;
|
|
@@ -183,7 +220,7 @@ function bridgeConnection(ws, nodeId) {
|
|
|
183
220
|
link: 'viewer↔crtrd',
|
|
184
221
|
op: 'remote attach websocket',
|
|
185
222
|
kind: classification.kind,
|
|
186
|
-
retry: { disposition: '
|
|
223
|
+
retry: { disposition: 'auto' },
|
|
187
224
|
message: reasonText || `remote attach closed (${code})`,
|
|
188
225
|
});
|
|
189
226
|
}
|
|
@@ -197,7 +234,7 @@ function bridgeConnection(ws, nodeId) {
|
|
|
197
234
|
link: 'viewer↔crtrd',
|
|
198
235
|
op: 'remote attach websocket',
|
|
199
236
|
kind: classification.kind,
|
|
200
|
-
retry: { disposition: '
|
|
237
|
+
retry: { disposition: 'auto' },
|
|
201
238
|
message: 'remote attach websocket error',
|
|
202
239
|
});
|
|
203
240
|
}
|