@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.
@@ -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 { buildLaunchSpec } from './launch.js';
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 } = buildLaunchSpec(request.kind, request.mode, {
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
- mint(resolveRecipe({ ...request, model: null, situationalContext: null }));
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
- mint(resolveRecipe({
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,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: 'manual' },
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: 'manual' },
237
+ retry: { disposition: 'auto' },
201
238
  message: 'remote attach websocket error',
202
239
  });
203
240
  }
@@ -19,15 +19,14 @@ import { nodeArtifacts } from '../../../core/canvas/history.js';
19
19
  import { readNodeMessagesPage, readNodeSession, readNodeSnapshot, transcriptMarkdown } from '../../../core/runtime/node-read.js';
20
20
  import { assembleNodeSubject } from '../../../core/substrate/subject.js';
21
21
  import { reviveAll } from '../../../core/runtime/revive-all.js';
22
- import { promote, requestYield, reshapeNode } from '../../../core/runtime/promote.js';
22
+ import { promoteAsync, requestYieldAsync, reshapeNodeAsync } from '../../../core/runtime/promote.js';
23
23
  import { recycleNode } from '../../../core/runtime/recycle.js';
24
24
  import { relaunchRoot } from '../../../core/runtime/reset.js';
25
25
  import { assertReopenGate } from '../../../core/runtime/reopen.js';
26
26
  import { writeYieldMessage, readGoal } from '../../../core/runtime/kickoff.js';
27
27
  import { hasRoadmap, seedRoadmap } from '../../../core/runtime/roadmap.js';
28
28
  import { renameWindow } from '../../../core/runtime/placement.js';
29
- import { readMergedLaunchConfig } from '../../../core/config.js';
30
- import { buildLaunchSpec, isProviderPinnedModelToken, normalizeModel } from '../../../core/runtime/launch.js';
29
+ import { buildLaunchSpecAsync, isProviderPinnedModelToken, normalizeModel, readMergedLaunchConfigAsync } from '../../../core/runtime/launch.js';
31
30
  import { modelRequestFromConfig } from '../../../core/model-routes.js';
32
31
  import { BrokerUnreachableError } from '../../../core/runtime/broker-request.js';
33
32
  import { isBrokerLive, persistDormantModel, setModelLive } from '../../../core/runtime/model-swap.js';
@@ -196,13 +195,13 @@ function requestFromLaunch(launch) {
196
195
  return modelRequestFromConfig(launch.model, intent, launch.modelExact === true);
197
196
  }
198
197
  async function assertNodeModelChangeRegistered(meta, target, registry) {
199
- const launch = buildLaunchSpec(target.kind, target.mode, {
198
+ const launch = (await buildLaunchSpecAsync(target.kind, target.mode, {
200
199
  lifecycle: target.lifecycle,
201
200
  hasManager: meta.parent !== null,
202
201
  model: target.model,
203
202
  cwd: meta.cwd,
204
203
  profileId: meta.profile_id ?? null,
205
- }).launch;
204
+ })).launch;
206
205
  await assertLaunchModelRegistered(requestFromLaunch(launch), { cwd: meta.cwd, profileId: meta.profile_id ?? null }, registry);
207
206
  }
208
207
  export async function handleCreate(ctx, deps = {}) {
@@ -234,16 +233,16 @@ export async function handleCreate(ctx, deps = {}) {
234
233
  const profileId = await resolveProfileId(req.profile, parent ?? null, contextCwd);
235
234
  const profile = assertProfileActive(profileId);
236
235
  const kind = resolveCreateKind(req.kind, profile);
237
- assertKindServer(kind);
238
236
  const resolvedReq = { ...req, kind };
239
237
  const cwd = resolveNodeCwd(resolvedReq, parent, profileId, contextCwd);
240
- const launch = buildLaunchSpec(kind, (resolvedReq.mode ?? 'base'), {
238
+ await assertKindServer(kind, cwd, profileId);
239
+ const launch = (await buildLaunchSpecAsync(kind, (resolvedReq.mode ?? 'base'), {
241
240
  lifecycle: req.root === true ? (req.root_lifecycle ?? 'resident') : 'terminal',
242
241
  hasManager: req.root !== true,
243
242
  ...(req.model !== undefined ? { model: req.model } : {}),
244
243
  cwd,
245
244
  profileId,
246
- }).launch;
245
+ })).launch;
247
246
  await assertLaunchModelRegistered(requestFromLaunch(launch), { cwd, profileId }, deps.modelRegistry);
248
247
  // --- Warm path: hand out an already-booted spare instead of paying a full
249
248
  // engine boot. A miss is not a failure — it falls through to the cold
@@ -555,12 +554,12 @@ async function handleRecycle(ctx) {
555
554
  },
556
555
  };
557
556
  }
558
- function handleDemote(ctx) {
557
+ async function handleDemote(ctx) {
559
558
  const id = ctx.params['id'];
560
559
  requireMeta(id);
561
560
  // Use the canonical reshape primitive to ensure modelExact, provider
562
561
  // pin/intent, and default-name migration are all preserved.
563
- reshapeNode(id, { lifecycle: 'terminal' });
562
+ await reshapeNodeAsync(id, { lifecycle: 'terminal' });
564
563
  return { status: 200, body: detailOf(id) };
565
564
  }
566
565
  export async function handlePromote(ctx, deps = {}) {
@@ -575,7 +574,7 @@ export async function handlePromote(ctx, deps = {}) {
575
574
  model: req.model,
576
575
  }, deps.modelRegistry);
577
576
  }
578
- const result = promote(id, {
577
+ const result = await promoteAsync(id, {
579
578
  ...(req.kind !== undefined ? { kind: req.kind } : {}),
580
579
  ...(req.resident === true ? { resident: true } : {}),
581
580
  ...(req.model !== undefined ? { model: req.model } : {}),
@@ -602,7 +601,7 @@ export async function handleYield(ctx, deps = {}) {
602
601
  }
603
602
  const note = req.note ?? 'Refresh: continue against your roadmap or goal.';
604
603
  writeYieldMessage(id, note);
605
- const result = requestYield(id, {
604
+ const result = await requestYieldAsync(id, {
606
605
  ...(req.promote === true ? { promote: true } : {}),
607
606
  ...(req.kind !== undefined ? { kind: req.kind } : {}),
608
607
  ...(req.model !== undefined ? { model: req.model } : {}),
@@ -645,7 +644,7 @@ export async function handleConfig(ctx, deps = {}) {
645
644
  }
646
645
  if (patch.model !== undefined) {
647
646
  const pinned = isProviderPinnedModelToken(patch.model);
648
- const ladders = readMergedLaunchConfig(meta.cwd, meta.profile_id ?? null).modelLadders;
647
+ const ladders = (await readMergedLaunchConfigAsync(meta.cwd, meta.profile_id ?? null)).modelLadders;
649
648
  const spec = normalizeModel(patch.model, ladders);
650
649
  await assertLaunchModelRegistered({ kind: 'concrete', spec }, { cwd: meta.cwd, profileId: meta.profile_id ?? null }, deps.modelRegistry);
651
650
  const persistDormant = () => {
@@ -681,7 +680,7 @@ export async function handleConfig(ctx, deps = {}) {
681
680
  // --- kind / mode / lifecycle: reshape via canonical primitive (CLI parity). ---
682
681
  if (patch.kind !== undefined || patch.mode !== undefined || patch.lifecycle !== undefined) {
683
682
  if (patch.kind !== undefined)
684
- assertKindServer(patch.kind);
683
+ await assertKindServer(patch.kind, meta.cwd, meta.profile_id ?? null);
685
684
  const newKind = patch.kind ?? meta.kind;
686
685
  const newMode = (patch.mode ?? meta.mode);
687
686
  const newLifecycle = patch.lifecycle ?? meta.lifecycle;
@@ -697,7 +696,7 @@ export async function handleConfig(ctx, deps = {}) {
697
696
  }
698
697
  // Use canonical reshape primitive to preserve modelExact, provider pin/intent,
699
698
  // and default-name migration.
700
- meta = reshapeNode(id, {
699
+ meta = await reshapeNodeAsync(id, {
701
700
  ...(patch.kind !== undefined ? { kind: newKind } : {}),
702
701
  ...(patch.mode !== undefined ? { mode: newMode } : {}),
703
702
  ...(patch.lifecycle !== undefined ? { lifecycle: newLifecycle } : {}),
@@ -715,8 +714,8 @@ export async function handleConfig(ctx, deps = {}) {
715
714
  }
716
715
  /** Validate a `--kind` against the installed personas server-side (crtrd reads
717
716
  * the merged launch config). Mirrors the CLI's assertKind. */
718
- function assertKindServer(kind) {
719
- const kinds = Object.keys(readMergedLaunchConfig().kinds);
717
+ async function assertKindServer(kind, cwd, profileId) {
718
+ const kinds = Object.keys((await readMergedLaunchConfigAsync(cwd, profileId)).kinds);
720
719
  if (!kinds.includes(kind)) {
721
720
  throw usage(`unknown kind: ${kind}`, { received: kind, next: `Valid kinds: ${kinds.sort().join(', ')}.` });
722
721
  }
package/dist/types.js CHANGED
@@ -77,7 +77,11 @@ export function defaultScopeConfig() {
77
77
  auto_update: { crtr: 'notify', content: 'notify', interval_hours: 24 },
78
78
  max_panes_per_window: DEFAULT_MAX_PANES_PER_WINDOW,
79
79
  auto_open_child_viewers: true,
80
- brokerThresholds: { warning: 128, automaticReviveCap: 256 },
80
+ // Every live broker is a whole pi engine. 256 of them is not a bound any
81
+ // real host survives — an 8 GB guest dies around a few dozen — so these are
82
+ // sized as a backstop that actually engages before the machine does.
83
+ // Override per host with CRTR_MAX_LIVE_BROKERS / CRTR_WARN_LIVE_BROKERS.
84
+ brokerThresholds: { warning: 16, automaticReviveCap: 32 },
81
85
  completion_bell: true,
82
86
  working_gerunds: [...DEFAULT_WORKING_GERUNDS],
83
87
  whip_mode: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.218",
3
+ "version": "0.3.220",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.218",
3
+ "version": "0.3.220",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.218",
9
+ "version": "0.3.220",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {