@north-light/crouter 0.3.309 → 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.
@@ -76,6 +76,13 @@ function isValidNeighborEntry(x) {
76
76
  * lifecycle fields local/API-backed row renderers dereference unguarded;
77
77
  * the remaining nullable runtime
78
78
  * fields (host_kind, parent, pi_pid, ...) are left to cast through as-is. */
79
+ function isValidRosterEdge(x) {
80
+ return (isPlainObject(x) &&
81
+ typeof x['from_id'] === 'string' &&
82
+ typeof x['to_id'] === 'string' &&
83
+ typeof x['active'] === 'boolean' &&
84
+ typeof x['created'] === 'string');
85
+ }
79
86
  function isValidNodeRow(x) {
80
87
  if (!isPlainObject(x))
81
88
  return false;
@@ -94,6 +101,7 @@ export class RemoteCanvasSource {
94
101
  exec;
95
102
  nodeCache = new Map();
96
103
  rosterCache = new Map();
104
+ topologyCache = null;
97
105
  viewCache = new Map();
98
106
  constructor(previewEndpoint, relayToken) {
99
107
  this.previewEndpoint = previewEndpoint;
@@ -134,6 +142,46 @@ export class RemoteCanvasSource {
134
142
  this.nodeCache.set(id, { value: res, at: Date.now() });
135
143
  return res;
136
144
  }
145
+ topology() {
146
+ if (this.topologyCache !== null && Date.now() - this.topologyCache.at < CACHE_TTL_MS)
147
+ return this.topologyCache.value;
148
+ const request = this.loadTopology();
149
+ this.topologyCache = { value: request, at: Date.now() };
150
+ return request;
151
+ }
152
+ async loadTopology() {
153
+ const response = await this.exec('crtr', ['--json', 'canvas', 'roster']);
154
+ if (!response.ok || (response.exitCode !== undefined && response.exitCode !== 0)) {
155
+ throw new Error('remote canvas topology is unavailable');
156
+ }
157
+ let raw;
158
+ try {
159
+ raw = JSON.parse(response.stdout.trim());
160
+ }
161
+ catch {
162
+ throw new Error('remote canvas topology is invalid');
163
+ }
164
+ if (!isPlainObject(raw) || !Array.isArray(raw['edges']) || !raw['edges'].every(isValidRosterEdge)) {
165
+ throw new Error('remote canvas topology is invalid');
166
+ }
167
+ const edges = raw['edges'];
168
+ const topology = { subscriptions: new Map(), subscribers: new Map() };
169
+ for (const edge of edges) {
170
+ const subscription = { node_id: edge.to_id, active: edge.active, created: edge.created };
171
+ const subscriber = { node_id: edge.from_id, active: edge.active, created: edge.created };
172
+ const subscriptions = topology.subscriptions.get(edge.from_id);
173
+ if (subscriptions === undefined)
174
+ topology.subscriptions.set(edge.from_id, [subscription]);
175
+ else
176
+ subscriptions.push(subscription);
177
+ const subscribers = topology.subscribers.get(edge.to_id);
178
+ if (subscribers === undefined)
179
+ topology.subscribers.set(edge.to_id, [subscriber]);
180
+ else
181
+ subscribers.push(subscriber);
182
+ }
183
+ return topology;
184
+ }
137
185
  /** The full passthrough object `node inspect show` produces — used by
138
186
  * `nodeShow`'s remote branch (`src/commands/node/inspect.ts`) to return the exact
139
187
  * same shape a local read would, without re-deriving it. */
@@ -186,12 +234,10 @@ export class RemoteCanvasSource {
186
234
  return nodes;
187
235
  }
188
236
  async subscriptionsOf(id) {
189
- const reports = (await this.fetchShow(id))?.reports ?? [];
190
- return reports.map((r) => ({ node_id: r.node_id, active: r.active, created: '' }));
237
+ return (await this.topology()).subscriptions.get(id) ?? [];
191
238
  }
192
239
  async subscribersOf(id) {
193
- const managers = (await this.fetchShow(id))?.managers ?? [];
194
- return managers.map((r) => ({ node_id: r.node_id, active: r.active, created: '' }));
240
+ return (await this.topology()).subscribers.get(id) ?? [];
195
241
  }
196
242
  async view(root) {
197
243
  const cached = this.viewCache.get(root);
@@ -214,9 +260,15 @@ export class RemoteCanvasSource {
214
260
  this.viewCache.set(root, { value: out, at: Date.now() });
215
261
  return out;
216
262
  }
263
+ async ticketCountsForNodes(nodeIds) {
264
+ // The `canvas attention map` transport's `counts` values mean all pending
265
+ // human tickets (page/review), not just asks.
266
+ if (nodeIds.length === 0)
267
+ return {};
268
+ const res = await this.execJson(['canvas', 'attention', 'map', '--nodes', nodeIds.join(',')]);
269
+ return isPlainObject(res) && isPlainObject(res['counts']) ? res['counts'] : {};
270
+ }
217
271
  async ticketCountsForView(root) {
218
- // The `canvas attention map` transport is unchanged; its `counts` values now
219
- // mean all pending human tickets (page/review), not just asks.
220
272
  const res = await this.execJson(['canvas', 'attention', 'map', '--view', root]);
221
273
  return isPlainObject(res) && isPlainObject(res['counts']) ? res['counts'] : {};
222
274
  }
@@ -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);
@@ -247,9 +247,8 @@ export async function abandonManagedWorktreeAsync(nodeId, by) {
247
247
  cleanBase = base;
248
248
  }
249
249
  catch (error) {
250
- if (!(error instanceof WorktreeError) || error.code !== 'missing_base_branch' || head !== current.base_sha)
250
+ if (!(error instanceof WorktreeError) || error.code !== 'missing_base_branch')
251
251
  throw error;
252
- cleanBase = current.base_sha;
253
252
  }
254
253
  const at = new Date().toISOString();
255
254
  const status = await runGit(current.path, ['status', '--porcelain'], 'status_failed', 'Inspect the worktree manually, then retry.');
@@ -76,7 +76,6 @@ export interface AbandonManagedWorktreeResult {
76
76
  worktree_path: string;
77
77
  branch_deleted: boolean;
78
78
  }
79
- export declare function abandonManagedWorktree(nodeId: string, by: string): AbandonManagedWorktreeResult;
80
79
  /** Recovery guidance for a vanished checkout whose refs do not prove that its
81
80
  * work is delivered. This deliberately never recommends `worktree close`:
82
81
  * that command cannot operate on the absent checkout. */
@@ -3,7 +3,6 @@ import { createHash } from 'node:crypto';
3
3
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  import { gitAsync, gitSync } from './git.js';
5
5
  import { crtrHome } from './canvas/paths.js';
6
- import { isPidAlive } from './canvas/pid.js';
7
6
  import { getNode, updateNode } from './canvas/index.js';
8
7
  import { CONFIG_FILE, CRTR_DIR_NAME, STATE_FILE } from '../types.js';
9
8
  import { withExclusiveDirectoryLock } from './exclusive-lock.js';
@@ -658,118 +657,6 @@ export function closeManagedWorktree(nodeId) {
658
657
  // the child checkout instead of the parent that created it.
659
658
  return withRepositoryWorktreeLock(existsSync(wt.path) ? wt.path : wt.repo_root, () => closeManagedWorktreeLocked(nodeId));
660
659
  }
661
- const ABANDON_AUTHOR_NAME = 'crtr-abandon';
662
- const ABANDON_AUTHOR_EMAIL = 'crtr-abandon@localhost';
663
- const ABANDON_SUBJECT = 'crtr-abandon';
664
- function persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by, branch = wt.branch) {
665
- updateNode(nodeId, {
666
- cwd: wt.repo_root,
667
- managed_worktree: {
668
- ...wt,
669
- state: 'abandoned',
670
- abandoned: { branch, tip_sha: tipSha, at, by },
671
- },
672
- });
673
- }
674
- /** The disposal path for `abandon` when the recorded `crtr/*` branch is gone:
675
- * ordinary PR work moved this checkout onto a real branch. Abandon never
676
- * lands that branch and — unlike the ordinary path above — never invents a
677
- * commit on it: a synthetic `crtr-abandon` marker on someone's real PR
678
- * branch is not a trade this code may make on their behalf. So this
679
- * requires the checkout already clean and not mid-operation, then removes
680
- * only the checkout and preserves the actual branch and its commits exactly
681
- * as they stand. The caller already confirmed the node is not running. */
682
- function abandonBranchMovedWorktree(nodeId, wt, actualBranch, by) {
683
- if (isRebaseInProgress(wt.path)) {
684
- throw new WorktreeError('rebase_in_progress', 'this managed worktree is mid-rebase', `Resolve conflicts in ${wt.path}, run \`git rebase --continue\` there, then rerun \`crtr node worktree abandon\`.`);
685
- }
686
- const dirty = statusPorcelain(wt.path);
687
- if (dirty !== '') {
688
- throw new WorktreeError('dirty_worktree', 'managed worktree has uncommitted changes', `The recorded branch ${wt.branch} no longer exists; its checkout is now on ${actualBranch} with uncommitted changes crtr will not commit on your behalf. Commit or stash them in ${wt.path}, then rerun \`crtr node worktree abandon\`.`);
689
- }
690
- if (registeredWorktree(wt.repo_root, wt.path, actualBranch) !== true) {
691
- throw new WorktreeError('wrong_branch', `managed worktree's recorded branch ${wt.branch} no longer exists, and its checkout at ${wt.path} is not registered to ${actualBranch} either`, `Inspect ${wt.path} and its git worktree registration, then ask the user with \`crtr human send\` if you are unsure how to proceed.`);
692
- }
693
- const actualSha = runGit(wt.path, ['rev-parse', 'HEAD'], 'abandon_tip_failed', 'Inspect the worktree manually, then retry.');
694
- // Recheck identity immediately before the destructive step.
695
- if (registeredWorktree(wt.repo_root, wt.path, actualBranch) !== true || currentBranch(wt.path) !== actualBranch) {
696
- throw new WorktreeError('wrong_branch', `managed worktree at ${wt.path} changed while abandoning`, `Inspect ${wt.path}, then rerun \`crtr node worktree abandon\`.`);
697
- }
698
- runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
699
- const at = new Date().toISOString();
700
- persistAbandonedWorktreeState(nodeId, wt, actualSha, at, by, actualBranch);
701
- return { node_id: nodeId, branch: actualBranch, tip_sha: actualSha, worktree_path: wt.path, branch_deleted: false };
702
- }
703
- function createAbandonMarker(wt, parentSha, nodeId, at) {
704
- const treeSha = runGit(wt.repo_root, ['rev-parse', `${parentSha}^{tree}`], 'abandon_tree_failed', 'Inspect the branch manually, then retry.');
705
- const markerSha = runGit(wt.repo_root, ['-c', `user.name=${ABANDON_AUTHOR_NAME}`, '-c', `user.email=${ABANDON_AUTHOR_EMAIL}`, 'commit-tree', treeSha, '-p', parentSha, '-m', ABANDON_SUBJECT, '-m', `Crtr-Abandon: ${nodeId} ${at}`], 'abandon_commit_failed', 'Inspect the branch manually, then retry.');
706
- runGit(wt.repo_root, ['update-ref', `refs/heads/${wt.branch}`, markerSha, parentSha], 'abandon_marker_failed', 'The branch changed while abandon was starting; inspect the worktree, then retry.');
707
- return markerSha;
708
- }
709
- function abandonManagedWorktreeLocked(nodeId, by) {
710
- const node = getNode(nodeId);
711
- if (node === null) {
712
- throw new WorktreeError('unknown_node', `unknown node: ${nodeId}`, 'Pass a real node id.');
713
- }
714
- const wt = openManagedWorktreeForNode(nodeId);
715
- if (wt === null) {
716
- const { message, next } = noManagedWorktreeMessage(nodeId, 'abandon');
717
- throw new WorktreeError('no_managed_worktree', message, next);
718
- }
719
- if (node.status === 'active' || node.status === 'idle' || isPidAlive(node.pi_pid)) {
720
- throw new WorktreeError('node_running', `node ${nodeId} is still running`, 'Stop the node before abandoning its managed worktree.');
721
- }
722
- if (!existsSync(wt.path)) {
723
- throw new WorktreeError('worktree_missing', `managed worktree path is missing: ${wt.path}`, missingWorktreeRecoveryHint(wt));
724
- }
725
- const actualBranch = currentBranch(wt.path);
726
- if (actualBranch !== wt.branch) {
727
- const recordedRefExists = recordedBranchExists(wt.path, wt.branch);
728
- if (recordedRefExists || actualBranch === 'HEAD') {
729
- throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch}${actualBranch === 'HEAD' ? ', found detached HEAD' : ''})`, recordedRefExists
730
- ? `Check out ${wt.branch} in ${wt.path}, then rerun \`crtr node worktree abandon\`.`
731
- : `The recorded branch ${wt.branch} no longer exists, and ${wt.path} is in detached HEAD, so there is no branch to preserve. Check out or create a branch there, then rerun \`crtr node worktree abandon\`.`);
732
- }
733
- // The recorded crtr/* branch is gone: ordinary PR work moved this checkout
734
- // onto a real branch.
735
- return abandonBranchMovedWorktree(nodeId, wt, actualBranch, by);
736
- }
737
- const at = new Date().toISOString();
738
- const clean = proveCleanEmptyManagedWorktree(wt);
739
- if (clean !== null && clean.branchSha === wt.base_sha) {
740
- const markerSha = createAbandonMarker(wt, clean.branchSha, nodeId, at);
741
- runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
742
- const deleted = bestEffortDeleteBranchWithContainmentProof(wt, markerSha, `refs/heads/${wt.base_ref}`, clean.baseSha);
743
- if (deleted.branchDeleted) {
744
- persistClosedWorktreeState(nodeId, wt, 'complete');
745
- return { node_id: nodeId, branch: wt.branch, tip_sha: markerSha, worktree_path: wt.path, branch_deleted: true };
746
- }
747
- const tipSha = runGit(wt.repo_root, ['rev-parse', `refs/heads/${wt.branch}`], 'abandon_tip_failed', 'Inspect the branch manually, then retry.');
748
- persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by);
749
- return { node_id: nodeId, branch: wt.branch, tip_sha: tipSha, worktree_path: wt.path, branch_deleted: false };
750
- }
751
- // A clean branch with committed work already has its complete history. The
752
- // abandon record supplies the disposition; an empty provenance commit would
753
- // only move the local branch away from an already-pushed tip.
754
- if (!isRebaseInProgress(wt.path) && statusPorcelain(wt.path) === '') {
755
- runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
756
- const tipSha = runGit(wt.repo_root, ['rev-parse', `refs/heads/${wt.branch}`], 'abandon_tip_failed', 'Inspect the branch manually, then retry.');
757
- persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by);
758
- return { node_id: nodeId, branch: wt.branch, tip_sha: tipSha, worktree_path: wt.path, branch_deleted: false };
759
- }
760
- runGit(wt.path, ['add', '--all'], 'abandon_stage_failed', 'Inspect the worktree manually, then retry.');
761
- runGit(wt.path, ['-c', `user.name=${ABANDON_AUTHOR_NAME}`, '-c', `user.email=${ABANDON_AUTHOR_EMAIL}`, 'commit', '--allow-empty', '-m', ABANDON_SUBJECT, '-m', `Crtr-Abandon: ${nodeId} ${at}`], 'abandon_commit_failed', 'Inspect the worktree manually, then retry.');
762
- runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
763
- const tipSha = runGit(wt.repo_root, ['rev-parse', `refs/heads/${wt.branch}`], 'abandon_tip_failed', 'Inspect the branch manually, then retry.');
764
- persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by);
765
- return { node_id: nodeId, branch: wt.branch, tip_sha: tipSha, worktree_path: wt.path, branch_deleted: false };
766
- }
767
- export function abandonManagedWorktree(nodeId, by) {
768
- const wt = openManagedWorktreeForNode(nodeId);
769
- if (wt === null)
770
- return abandonManagedWorktreeLocked(nodeId, by);
771
- return withRepositoryWorktreeLock(wt.repo_root, () => abandonManagedWorktreeLocked(nodeId, by));
772
- }
773
660
  // Push-final auto-drop of a clean, zero-commit managed worktree
774
661
  /** Delete a source branch only while the ref that proved it already delivered
775
662
  * is still at the exact inspected SHA. `containingRef` can be the recorded
@@ -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.309",
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.309",
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.309",
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.309",
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.309",
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.309",
5219
+ "version": "0.3.311",
5220
5220
  "license": "UNLICENSED",
5221
5221
  "dependencies": {
5222
5222
  "@north-light/crouter-api": "^0.3.295"