@north-light/crouter 0.3.310 → 0.3.311

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.
@@ -13,8 +13,8 @@ export interface NodeTelemetry {
13
13
  export declare function readNodeTelemetry(nodeId: string): NodeTelemetry;
14
14
  /** Format a token count as `Nk` (rounded down to nearest 1 k). */
15
15
  export declare function fmtCtx(tokensIn: number | undefined): string;
16
- export declare function renderTreeFromSource(source: CanvasSource, rootId: string): Promise<string>;
17
- export declare function renderForestFromSource(source: CanvasSource): Promise<string>;
16
+ export declare function renderTreeFromSource(source: CanvasSource, rootId: string, snapshot?: readonly NodeRow[], ticketSnapshot?: Record<string, number>, ticketUnavailable?: string): Promise<string>;
17
+ export declare function renderForestFromSource(source: CanvasSource, snapshot?: readonly NodeRow[], ticketSnapshot?: Record<string, number>, ticketUnavailable?: string): Promise<string>;
18
18
  export interface DashboardRow {
19
19
  node_id: string;
20
20
  /** Full display label: explicit handle plus generated description. */
@@ -183,13 +183,13 @@ export declare function dashboardRowFromNodeRow(row: NodeRow, focusedNodeIds: Re
183
183
  * entirely through the source — the openDb-free sibling of `dashboardRows`.
184
184
  * Ticket counts come from ONE `source.ticketCountsForView(rootId)` call (the
185
185
  * API/remote attention projection), never `countTickets` (openDb). */
186
- export declare function dashboardRowsFromSource(source: CanvasSource, rootId: string): Promise<DashboardRow[]>;
186
+ export declare function dashboardRowsFromSource(source: CanvasSource, rootId: string, snapshot?: readonly NodeRow[], ticketSnapshot?: Record<string, number>): Promise<DashboardRow[]>;
187
187
  /** CHEAP boot builder over a source — one row per node across the whole canvas,
188
188
  * fields that need no per-node meta/session read. Local API callers may supply
189
189
  * the node ids from one `listFocuses()` query so `viewed` remains visible without
190
190
  * coupling the canvas source to local viewer state. Remote sources ignore that
191
191
  * set. Local disk telemetry/fault/streaming are likewise suppressed remotely. */
192
- export declare function dashboardRowsAllFromSource(source: CanvasSource, localFocusedNodeIds?: ReadonlySet<string>, liveOnly?: boolean): Promise<DashboardRow[]>;
192
+ export declare function dashboardRowsAllFromSource(source: CanvasSource, localFocusedNodeIds?: ReadonlySet<string>, liveOnly?: boolean, snapshot?: readonly NodeRow[]): Promise<DashboardRow[]>;
193
193
  export declare function enrichRowsFromSource(_source: CanvasSource, rows: DashboardRow[], asks?: Record<string, number>): Promise<void>;
194
194
  /** goal (initial-prompt.md) and session parts are local disk reads — suppressed
195
195
  * to undefined for a remote source. */
@@ -60,9 +60,8 @@ export function fmtCtx(tokensIn) {
60
60
  // LOCAL disk reads (telemetry.json / fault.json), valid for a same-host source
61
61
  // but meaningless for a truly remote canvas, so both are suppressed when the
62
62
  // source is a `RemoteCanvasSource`.
63
- async function nodeLineFromSource(source, nodeId, indent, connector, remote, tickets) {
64
- const node = await source.getNode(nodeId);
65
- if (node === null)
63
+ function nodeLineFromSource(node, nodeId, indent, connector, remote, tickets) {
64
+ if (node === undefined)
66
65
  return `${indent}${connector}? <missing meta: ${nodeId}>`;
67
66
  const hanging = remote ? null : hangingFor(node);
68
67
  const glyph = resolveNodeVisual(node.status, { hanging, frozen: isFrozenRow(node) }).glyph;
@@ -72,12 +71,12 @@ async function nodeLineFromSource(source, nodeId, indent, connector, remote, tic
72
71
  const faultSuffix = hanging !== null ? ` · ${faultSummary(hanging)}` : '';
73
72
  return `${indent}${connector}${glyph} ${fullName(node)} [${node.kind}/${node.mode}] ctx ${ctx}${askSuffix}${faultSuffix}`;
74
73
  }
75
- async function walkTreeFromSource(source, nodeId, indent, isLast, visited, out, remote, tickets) {
74
+ async function walkTreeFromSource(source, nodes, nodeId, indent, isLast, visited, out, remote, tickets) {
76
75
  if (visited.has(nodeId))
77
76
  return;
78
77
  visited.add(nodeId);
79
78
  const connector = isLast ? '└─ ' : '├─ ';
80
- out.push(await nodeLineFromSource(source, nodeId, indent, connector, remote, tickets));
79
+ out.push(nodeLineFromSource(nodes.get(nodeId), nodeId, indent, connector, remote, tickets));
81
80
  const childIndent = indent + (isLast ? ' ' : '│ ');
82
81
  let children;
83
82
  try {
@@ -95,22 +94,25 @@ async function walkTreeFromSource(source, nodeId, indent, isLast, visited, out,
95
94
  out.push(`${childIndent}${cycleConnector}↺ <cycle: ${child.node_id}>`);
96
95
  continue;
97
96
  }
98
- await walkTreeFromSource(source, child.node_id, childIndent, childIsLast, visited, out, remote, tickets);
97
+ await walkTreeFromSource(source, nodes, child.node_id, childIndent, childIsLast, visited, out, remote, tickets);
99
98
  }
100
99
  }
101
- export async function renderTreeFromSource(source, rootId) {
100
+ export async function renderTreeFromSource(source, rootId, snapshot, ticketSnapshot, ticketUnavailable) {
102
101
  const remote = source instanceof RemoteCanvasSource;
103
- const node = await source.getNode(rootId);
104
- if (node === null)
102
+ const nodes = new Map((snapshot ?? await source.listNodes()).map((node) => [node.node_id, node]));
103
+ const node = nodes.get(rootId);
104
+ if (node === undefined)
105
105
  return `? <missing node: ${rootId}>`;
106
- let tickets;
107
- let ticketsUnavailable = null;
108
- try {
109
- tickets = await source.ticketCountsForView(rootId);
110
- }
111
- catch (err) {
112
- tickets = {};
113
- ticketsUnavailable = err instanceof Error ? err.message : String(err);
106
+ let tickets = ticketSnapshot;
107
+ let ticketsUnavailable = ticketUnavailable ?? null;
108
+ if (tickets === undefined) {
109
+ try {
110
+ tickets = await source.ticketCountsForView(rootId);
111
+ }
112
+ catch (err) {
113
+ tickets = {};
114
+ ticketsUnavailable = err instanceof Error ? err.message : String(err);
115
+ }
114
116
  }
115
117
  const ctx = remote ? '0k' : fmtCtx(readNodeTelemetry(rootId).tokens_in);
116
118
  const asks = tickets[rootId] ?? 0;
@@ -132,14 +134,14 @@ export async function renderTreeFromSource(source, rootId) {
132
134
  for (let i = 0; i < children.length; i++) {
133
135
  const child = children[i];
134
136
  const isLast = i === children.length - 1;
135
- await walkTreeFromSource(source, child.node_id, '', isLast, visited, out, remote, tickets);
137
+ await walkTreeFromSource(source, nodes, child.node_id, '', isLast, visited, out, remote, tickets);
136
138
  }
137
139
  if (ticketsUnavailable !== null)
138
140
  out.push(`Tickets: unavailable (${ticketsUnavailable}).`);
139
141
  return out.join('\n');
140
142
  }
141
- export async function renderForestFromSource(source) {
142
- const all = await source.listNodes();
143
+ export async function renderForestFromSource(source, snapshot, ticketSnapshot, ticketUnavailable) {
144
+ const all = snapshot ?? await source.listNodes();
143
145
  if (all.length === 0)
144
146
  return '(canvas is empty)';
145
147
  const roots = all.filter((n) => n.parent === null && (n.status === 'active' || n.status === 'idle'));
@@ -147,7 +149,7 @@ export async function renderForestFromSource(source) {
147
149
  return '(no live roots)';
148
150
  const parts = [];
149
151
  for (const r of roots) {
150
- parts.push(await renderTreeFromSource(source, r.node_id));
152
+ parts.push(await renderTreeFromSource(source, r.node_id, all, ticketSnapshot, ticketUnavailable));
151
153
  }
152
154
  return parts.join('\n\n');
153
155
  }
@@ -563,44 +565,43 @@ export function dashboardRowFromNodeRow(row, focusedNodeIds, remote) {
563
565
  * entirely through the source — the openDb-free sibling of `dashboardRows`.
564
566
  * Ticket counts come from ONE `source.ticketCountsForView(rootId)` call (the
565
567
  * API/remote attention projection), never `countTickets` (openDb). */
566
- export async function dashboardRowsFromSource(source, rootId) {
568
+ export async function dashboardRowsFromSource(source, rootId, snapshot, ticketSnapshot) {
567
569
  const remote = source instanceof RemoteCanvasSource;
568
- const tickets = await source.ticketCountsForView(rootId).catch(() => ({}));
570
+ const tickets = ticketSnapshot ?? await source.ticketCountsForView(rootId).catch(() => ({}));
571
+ const nodes = new Map((snapshot ?? await source.listNodes()).map((node) => [node.node_id, node]));
569
572
  const ids = [rootId, ...(await source.view(rootId))];
570
- const rows = [];
571
- for (const id of ids) {
572
- const node = await source.getNode(id);
573
- if (node === null)
574
- continue;
573
+ return ids.flatMap((id) => {
574
+ const node = nodes.get(id);
575
+ if (node === undefined)
576
+ return [];
575
577
  const tel = remote ? {} : readNodeTelemetry(id);
576
- rows.push({
577
- node_id: id,
578
- name: fullName(node),
579
- handle: node.name,
580
- description: node.description,
581
- status: node.status,
582
- frozen_at: node.frozen_at ?? null,
583
- terminal_reason: node.terminal_reason ?? null,
584
- kind: node.kind,
585
- mode: node.mode,
586
- ctx_tokens: tel.tokens_in ?? 0,
587
- asks: tickets[id] ?? 0,
588
- cwd: node.cwd,
589
- profile_id: node.profile_id,
590
- created: node.created,
591
- });
592
- }
593
- return rows;
578
+ return [{
579
+ node_id: id,
580
+ name: fullName(node),
581
+ handle: node.name,
582
+ description: node.description,
583
+ status: node.status,
584
+ frozen_at: node.frozen_at ?? null,
585
+ terminal_reason: node.terminal_reason ?? null,
586
+ kind: node.kind,
587
+ mode: node.mode,
588
+ ctx_tokens: tel.tokens_in ?? 0,
589
+ asks: tickets[id] ?? 0,
590
+ cwd: node.cwd,
591
+ profile_id: node.profile_id,
592
+ created: node.created,
593
+ }];
594
+ });
594
595
  }
595
596
  /** CHEAP boot builder over a source — one row per node across the whole canvas,
596
597
  * fields that need no per-node meta/session read. Local API callers may supply
597
598
  * the node ids from one `listFocuses()` query so `viewed` remains visible without
598
599
  * coupling the canvas source to local viewer state. Remote sources ignore that
599
600
  * set. Local disk telemetry/fault/streaming are likewise suppressed remotely. */
600
- export async function dashboardRowsAllFromSource(source, localFocusedNodeIds = new Set(), liveOnly = false) {
601
+ export async function dashboardRowsAllFromSource(source, localFocusedNodeIds = new Set(), liveOnly = false, snapshot) {
601
602
  const remote = source instanceof RemoteCanvasSource;
602
603
  const focusedNodeIds = remote ? new Set() : localFocusedNodeIds;
603
- const rows = await source.listNodes(liveOnly ? { status: ['active', 'idle'] } : undefined);
604
+ const rows = snapshot ?? await source.listNodes(liveOnly ? { status: ['active', 'idle'] } : undefined);
604
605
  return rows.map((row) => dashboardRowFromNodeRow(row, focusedNodeIds, remote));
605
606
  }
606
607
  // Lazy enrichment (source-driven) — fold the per-node reads the cheap boot skips
@@ -9,6 +9,7 @@ export interface CanvasSource {
9
9
  subscriptionsOf(subscriber: string): Promise<SubscriptionRef[]>;
10
10
  subscribersOf(publisher: string): Promise<SubscriptionRef[]>;
11
11
  view(root: string): Promise<string[]>;
12
+ ticketCountsForNodes(nodeIds: readonly string[]): Promise<Record<string, number>>;
12
13
  ticketCountsForView(root: string): Promise<Record<string, number>>;
13
14
  hasActiveLiveSubscription(nodeId: string): Promise<boolean>;
14
15
  }
@@ -21,6 +22,7 @@ export declare class LocalCanvasSource implements CanvasSource {
21
22
  subscriptionsOf(subscriber: string): Promise<SubscriptionRef[]>;
22
23
  subscribersOf(publisher: string): Promise<SubscriptionRef[]>;
23
24
  view(root: string): Promise<string[]>;
25
+ ticketCountsForNodes(nodeIds: readonly string[]): Promise<Record<string, number>>;
24
26
  ticketCountsForView(root: string): Promise<Record<string, number>>;
25
27
  hasActiveLiveSubscription(nodeId: string): Promise<boolean>;
26
28
  }
@@ -22,8 +22,11 @@ export class LocalCanvasSource {
22
22
  async view(root) {
23
23
  return view(root);
24
24
  }
25
+ async ticketCountsForNodes(nodeIds) {
26
+ return ticketCountsForNodes([...nodeIds]);
27
+ }
25
28
  async ticketCountsForView(root) {
26
- return ticketCountsForNodes([root, ...view(root)]);
29
+ return this.ticketCountsForNodes([root, ...view(root)]);
27
30
  }
28
31
  async hasActiveLiveSubscription(nodeId) {
29
32
  return hasActiveLiveSubscription(nodeId);
@@ -35,14 +35,14 @@
35
35
  // promises for graceful-teardown drain but never blocks on a slow run.
36
36
  import { spawn } from 'node:child_process';
37
37
  import { createHash, randomUUID } from 'node:crypto';
38
- import { CRON_KILL_GRACE_MS, CRON_OUTPUT_HEAD_BYTES, EMPTY_STDOUT_SHA256, newHeadCapture, } from './cron/capture.js';
38
+ import { CRON_DEATH_POLL_MS, CRON_KILL_GRACE_MS, CRON_OUTPUT_HEAD_BYTES, EMPTY_STDOUT_SHA256, newHeadCapture, } from './cron/capture.js';
39
39
  import { clearLiveRun, cronDaemonInstanceId, liveRunFor, registerLiveRun } from './cron/live-runs.js';
40
40
  import { buildArmedRunContext } from './cron/armed-context.js';
41
41
  import { decideHeldSettle, EXIT_HELD, HELD_DELIVERED } from './cron/held.js';
42
42
  import { settleRun } from './cron/settle.js';
43
43
  import { applyOverlapPolicies, expireDueCrons, } from './cron/passes.js';
44
44
  import { dueClockCrons, acquireCronRunLease, advanceCron, setCronState, } from '../core/canvas/crons.js';
45
- import { killProcessGroup } from '../core/canvas/pid.js';
45
+ import { captureTeardownSnapshot, isAnyPidAlive, killProcessTreePids } from '../core/canvas/pid.js';
46
46
  import { emitEvent } from '../core/events/emit.js';
47
47
  import { nextSlotAfter } from '../core/wake.js';
48
48
  import { resolveAdmittedHostCommand } from '../core/runtime/spawn-env.js';
@@ -239,6 +239,9 @@ export function executeCron(c, opts) {
239
239
  let settled = false;
240
240
  let timer;
241
241
  let killTimer;
242
+ let deathTimer;
243
+ let closeCode;
244
+ let killed = false;
242
245
  let timedOut = false;
243
246
  let leaseLost = false;
244
247
  let processError = null;
@@ -259,19 +262,8 @@ export function executeCron(c, opts) {
259
262
  child.stderr?.on('data', (chunk) => {
260
263
  stderr.take(chunk);
261
264
  });
262
- const killWithEscalation = (pid) => {
263
- killProcessGroup(pid);
264
- if (killTimer === undefined) {
265
- killTimer = setTimeout(() => killProcessGroup(pid, 'SIGKILL'), CRON_KILL_GRACE_MS);
266
- killTimer.unref?.();
267
- }
268
- };
269
- // A signal, not settlement: 'close' always follows 'error'.
270
- child.on('error', (err) => {
271
- if (processError == null)
272
- processError = err;
273
- });
274
- child.on('close', (code) => {
265
+ let teardown;
266
+ const settleClosedChild = (code) => {
275
267
  if (settled)
276
268
  return;
277
269
  settled = true;
@@ -279,6 +271,8 @@ export function executeCron(c, opts) {
279
271
  clearTimeout(timer);
280
272
  if (killTimer !== undefined)
281
273
  clearTimeout(killTimer);
274
+ if (deathTimer !== undefined)
275
+ clearTimeout(deathTimer);
282
276
  const stdoutHead = stdout.decode();
283
277
  const stderrHead = stderr.decode();
284
278
  const live = liveRunFor(c.cron_id);
@@ -306,6 +300,46 @@ export function executeCron(c, opts) {
306
300
  wasReplaced,
307
301
  wasSelfCanceled,
308
302
  }).then(resolve, reject);
303
+ };
304
+ const awaitTeardownDeath = () => {
305
+ if (teardown === undefined || !isAnyPidAlive(teardown.tree)) {
306
+ if (closeCode !== undefined)
307
+ settleClosedChild(closeCode);
308
+ return;
309
+ }
310
+ deathTimer = setTimeout(awaitTeardownDeath, CRON_DEATH_POLL_MS);
311
+ };
312
+ const killWithEscalation = (pid) => {
313
+ teardown ??= captureTeardownSnapshot(pid, null);
314
+ killProcessTreePids(teardown.tree, 'SIGTERM', teardown.identities);
315
+ if (killTimer === undefined) {
316
+ killTimer = setTimeout(() => {
317
+ if (isAnyPidAlive(teardown.tree))
318
+ killProcessTreePids(teardown.tree, 'SIGKILL', teardown.identities);
319
+ killed = true;
320
+ if (closeCode !== undefined)
321
+ awaitTeardownDeath();
322
+ }, CRON_KILL_GRACE_MS);
323
+ killTimer.unref?.();
324
+ }
325
+ };
326
+ // A signal, not settlement: 'close' always follows 'error'.
327
+ child.on('error', (err) => {
328
+ if (processError == null)
329
+ processError = err;
330
+ });
331
+ child.on('close', (code) => {
332
+ if (settled)
333
+ return;
334
+ closeCode = code;
335
+ if (timer !== undefined)
336
+ clearTimeout(timer);
337
+ if (teardown !== undefined && isAnyPidAlive(teardown.tree)) {
338
+ if (killed)
339
+ awaitTeardownDeath();
340
+ return;
341
+ }
342
+ settleClosedChild(code);
309
343
  });
310
344
  if (child.pid == null ||
311
345
  !acquireCronRunLease(c.cron_id, cronDaemonInstanceId, child.pid, startedAtIso, outOfBand)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.310",
3
+ "version": "0.3.311",
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.310",
3
+ "version": "0.3.311",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.310",
9
+ "version": "0.3.311",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "workspaces": [
@@ -5206,17 +5206,17 @@
5206
5206
  },
5207
5207
  "packages/crouter-api": {
5208
5208
  "name": "@north-light/crouter-api",
5209
- "version": "0.3.310",
5209
+ "version": "0.3.311",
5210
5210
  "license": "UNLICENSED"
5211
5211
  },
5212
5212
  "packages/crouter-env-docker": {
5213
5213
  "name": "@north-light/crouter-env-docker",
5214
- "version": "0.3.310",
5214
+ "version": "0.3.311",
5215
5215
  "license": "UNLICENSED"
5216
5216
  },
5217
5217
  "packages/crouter-sdk": {
5218
5218
  "name": "@north-light/crouter-sdk",
5219
- "version": "0.3.310",
5219
+ "version": "0.3.311",
5220
5220
  "license": "UNLICENSED",
5221
5221
  "dependencies": {
5222
5222
  "@north-light/crouter-api": "^0.3.295"