@north-light/crouter 0.3.249 → 0.3.250

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.
Files changed (170) hide show
  1. package/README.md +1 -1
  2. package/dist/api/__tests__/integration/client.test.js +28 -7
  3. package/dist/api/client.d.ts +1 -1
  4. package/dist/api/client.js +2 -2
  5. package/dist/api/dto/lifecycle.d.ts +3 -0
  6. package/dist/api/dto/messages.d.ts +4 -0
  7. package/dist/api/dto/nodes.d.ts +4 -0
  8. package/dist/api/dto/reports.d.ts +7 -2
  9. package/dist/builtin-memory/00-runtime-base/01-escalation.md +1 -1
  10. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +3 -0
  11. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +4 -3
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +4 -3
  15. package/dist/clients/attach/session/chrome-refresh.d.ts +3 -0
  16. package/dist/clients/attach/session/chrome-refresh.js +1 -0
  17. package/dist/clients/attach/viewer.js +532 -532
  18. package/dist/clients/inbox/__tests__/integration/stale-row-activation.test.d.ts +1 -0
  19. package/dist/clients/inbox/__tests__/integration/stale-row-activation.test.js +53 -0
  20. package/dist/clients/inbox/controller.js +32 -5
  21. package/dist/clients/inbox/tui/ansi.d.ts +3 -1
  22. package/dist/clients/inbox/tui/ansi.js +14 -10
  23. package/dist/commands/__tests__/api-client-cold-start-diagnostic.test.js +7 -7
  24. package/dist/commands/api-client.d.ts +2 -2
  25. package/dist/commands/api-client.js +5 -5
  26. package/dist/commands/canvas-browse.js +1 -1
  27. package/dist/commands/node/create.js +3 -1
  28. package/dist/commands/node/inspect.js +1 -0
  29. package/dist/commands/node/lifecycle.js +1 -1
  30. package/dist/commands/node/message.js +6 -4
  31. package/dist/commands/node/wait.js +1 -1
  32. package/dist/commands/push.js +36 -37
  33. package/dist/commands/sys/daemon.js +21 -8
  34. package/dist/commands/sys/setup-core.js +2 -2
  35. package/dist/core/__tests__/broker-stream-watchdog-floor.test.d.ts +1 -0
  36. package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +84 -0
  37. package/dist/core/__tests__/broker-turn-admission.test.d.ts +1 -0
  38. package/dist/core/__tests__/broker-turn-admission.test.js +44 -0
  39. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +5 -1
  40. package/dist/core/__tests__/child-death-wake.test.js +11 -7
  41. package/dist/core/__tests__/collectible.test.d.ts +1 -0
  42. package/dist/core/__tests__/collectible.test.js +88 -0
  43. package/dist/core/__tests__/context-intro.test.js +4 -41
  44. package/dist/core/__tests__/daemon-boot.test.js +30 -14
  45. package/dist/core/__tests__/daemon-wedge.test.js +25 -19
  46. package/dist/core/__tests__/fixtures/fake-engine.js +11 -0
  47. package/dist/core/__tests__/human-deliver.test.js +5 -5
  48. package/dist/core/__tests__/integration/deferred-no-wake.test.js +65 -4
  49. package/dist/core/__tests__/integration/human-deliver-e2e.test.js +3 -3
  50. package/dist/core/__tests__/integration/revive.test.js +48 -0
  51. package/dist/core/__tests__/integration/spawn-root.test.js +36 -1
  52. package/dist/core/__tests__/integration/worktree-land.test.js +82 -3
  53. package/dist/core/__tests__/integration/worktree-reap.test.js +44 -6
  54. package/dist/core/__tests__/migration.test.js +30 -0
  55. package/dist/core/__tests__/passive-subscription.test.js +39 -1
  56. package/dist/core/__tests__/prune.test.js +12 -3
  57. package/dist/core/__tests__/relaunch-root.test.js +1 -1
  58. package/dist/core/__tests__/respawn-throttle.test.js +16 -146
  59. package/dist/core/__tests__/seam/broker-crash-teardown.test.js +23 -11
  60. package/dist/core/__tests__/seam/broker-provider-retry.test.js +81 -2
  61. package/dist/core/__tests__/seam/dormancy-release.test.js +1 -1
  62. package/dist/core/__tests__/seam/self-close-teardown.test.d.ts +1 -0
  63. package/dist/core/__tests__/seam/self-close-teardown.test.js +49 -0
  64. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +18 -10
  65. package/dist/core/canvas/canvas.d.ts +9 -15
  66. package/dist/core/canvas/canvas.js +41 -47
  67. package/dist/core/canvas/crons.d.ts +5 -8
  68. package/dist/core/canvas/crons.js +5 -8
  69. package/dist/core/canvas/history.d.ts +5 -0
  70. package/dist/core/canvas/history.js +12 -1
  71. package/dist/core/canvas/migrations.js +58 -1
  72. package/dist/core/canvas/pid.d.ts +1 -1
  73. package/dist/core/canvas/pid.js +5 -2
  74. package/dist/core/canvas/types.d.ts +4 -0
  75. package/dist/core/feed/feed.d.ts +4 -0
  76. package/dist/core/feed/feed.js +13 -8
  77. package/dist/core/feed/inbox.d.ts +2 -1
  78. package/dist/core/feed/inbox.js +22 -3
  79. package/dist/core/git.d.ts +3 -0
  80. package/dist/core/git.js +3 -1
  81. package/dist/core/human/feedback-companion.js +1 -0
  82. package/dist/core/preview-registry.js +0 -1
  83. package/dist/core/review/companion.js +1 -0
  84. package/dist/core/review/realize.js +1 -0
  85. package/dist/core/runtime/broker/auth-reload.d.ts +2 -0
  86. package/dist/core/runtime/broker/auth-reload.js +9 -3
  87. package/dist/core/runtime/broker/engine-drive.d.ts +2 -0
  88. package/dist/core/runtime/broker/engine-drive.js +28 -23
  89. package/dist/core/runtime/broker/event-projection.d.ts +4 -0
  90. package/dist/core/runtime/broker/event-projection.js +43 -23
  91. package/dist/core/runtime/broker/fault-retry.d.ts +9 -0
  92. package/dist/core/runtime/broker/fault-retry.js +94 -8
  93. package/dist/core/runtime/broker/frame-dispatch.d.ts +2 -0
  94. package/dist/core/runtime/broker/frame-dispatch.js +4 -1
  95. package/dist/core/runtime/broker/inbox.js +4 -0
  96. package/dist/core/runtime/broker/rebind.js +18 -0
  97. package/dist/core/runtime/broker/turn-admission.d.ts +11 -0
  98. package/dist/core/runtime/broker/turn-admission.js +31 -0
  99. package/dist/core/runtime/broker/turn-ignition.d.ts +2 -0
  100. package/dist/core/runtime/broker/turn-ignition.js +25 -15
  101. package/dist/core/runtime/broker.js +21 -2
  102. package/dist/core/runtime/close.d.ts +11 -9
  103. package/dist/core/runtime/close.js +23 -19
  104. package/dist/core/runtime/fault.d.ts +21 -0
  105. package/dist/core/runtime/fault.js +129 -19
  106. package/dist/core/runtime/fleet.d.ts +0 -6
  107. package/dist/core/runtime/host.d.ts +7 -1
  108. package/dist/core/runtime/host.js +20 -2
  109. package/dist/core/runtime/nodes.d.ts +5 -1
  110. package/dist/core/runtime/nodes.js +24 -1
  111. package/dist/core/runtime/placement.d.ts +6 -6
  112. package/dist/core/runtime/placement.js +15 -8
  113. package/dist/core/runtime/reset.js +2 -2
  114. package/dist/core/runtime/revive.js +40 -4
  115. package/dist/core/runtime/spawn.d.ts +3 -0
  116. package/dist/core/runtime/spawn.js +1 -0
  117. package/dist/core/runtime/warm-pool.d.ts +4 -0
  118. package/dist/core/runtime/warm-pool.js +2 -0
  119. package/dist/core/worktree.d.ts +4 -3
  120. package/dist/core/worktree.js +73 -27
  121. package/dist/daemon/__tests__/integration/api-startup-readiness.test.d.ts +1 -0
  122. package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +81 -0
  123. package/dist/daemon/api/__tests__/reopen-delivery.test.js +38 -0
  124. package/dist/daemon/api/__tests__/seam/api-server.test.js +1 -0
  125. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +13 -2
  126. package/dist/daemon/api/handlers/bash-jobs.js +2 -0
  127. package/dist/daemon/api/handlers/broker-ops.js +23 -2
  128. package/dist/daemon/api/handlers/human.js +1 -1
  129. package/dist/daemon/api/handlers/messages.js +11 -2
  130. package/dist/daemon/api/handlers/nodes.js +13 -1
  131. package/dist/daemon/api/handlers/reports.js +14 -6
  132. package/dist/daemon/api/map.js +1 -0
  133. package/dist/daemon/api/server.d.ts +9 -12
  134. package/dist/daemon/api/server.js +30 -19
  135. package/dist/daemon/companion-retire.js +3 -9
  136. package/dist/daemon/cron/sinks.js +7 -2
  137. package/dist/daemon/crtrd.js +4 -1
  138. package/dist/daemon/fleet.d.ts +2 -16
  139. package/dist/daemon/fleet.js +63 -150
  140. package/dist/daemon/human/finish.js +2 -0
  141. package/dist/daemon/manage.d.ts +11 -0
  142. package/dist/daemon/manage.js +32 -3
  143. package/dist/daemon/messaging/node-message.js +7 -1
  144. package/dist/daemon/reconcilers/bash-deadline.js +2 -0
  145. package/dist/daemon/reconcilers/broker-supervision.d.ts +1 -0
  146. package/dist/daemon/reconcilers/broker-supervision.js +22 -9
  147. package/dist/daemon/reconcilers/live-obligation.d.ts +16 -6
  148. package/dist/daemon/reconcilers/live-obligation.js +18 -6
  149. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +15 -4
  150. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +34 -3
  151. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -1
  152. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +11 -2
  153. package/dist/daemon/reconcilers/node-lifecycle/tick.js +14 -7
  154. package/dist/daemon/reconcilers/storage-maintenance.d.ts +2 -2
  155. package/dist/daemon/reconcilers/storage-maintenance.js +3 -3
  156. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +4 -4
  157. package/dist/pi-extensions/canvas-context-intro.d.ts +3 -34
  158. package/dist/pi-extensions/canvas-context-intro.js +4 -76
  159. package/dist/pi-extensions/canvas-review-boundary.d.ts +3 -24
  160. package/dist/pi-extensions/canvas-review-boundary.js +4 -29
  161. package/dist/pi-extensions/canvas-stophook.js +1 -6
  162. package/dist/shared/birth-announcement.d.ts +12 -0
  163. package/dist/shared/birth-announcement.js +25 -0
  164. package/dist/shared/generated-context.d.ts +8 -4
  165. package/dist/shared/generated-context.js +15 -9
  166. package/package.json +4 -4
  167. package/runtime.lock.json +2 -2
  168. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +0 -254
  169. package/dist/pi-extensions/truncate.d.ts +0 -5
  170. package/dist/pi-extensions/truncate.js +0 -14
@@ -23,9 +23,10 @@
23
23
  // model-over-driver. Raw driver access is confined to placement, its pure
24
24
  // tmux half, the bindings owner, and tmux chrome.
25
25
  import { envNodeId } from '../../shared/env.js';
26
- import { getNode, fullName, deleteNode, listNodes, openFocusRow, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, listFocuses as listFocusRows, } from '../canvas/index.js';
26
+ import { getNode, fullName, deleteNode, listNodes, subscribersOf, openFocusRow, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, listFocuses as listFocusRows, } from '../canvas/index.js';
27
27
  import { nodeHasAssistantMessage, isStreaming } from '../canvas/render-source.js';
28
28
  import { headlessBrokerHost } from './host.js';
29
+ import { fanDoctrineWake } from './close.js';
29
30
  import { paneExists, paneLocation, closePane, currentTmux, switchClient, selectWindow, selectLayout, selectPane, getPaneOption, setPaneOption, respawnPaneSync, windowOfPane, renameWindow, } from './tmux-driver.js';
30
31
  // The PURE tmux + broker-socket half now lives in placement-tmux.ts. placement
31
32
  // composes the viewer OPENER over openViewerPane (tmux) + registerViewerFocus
@@ -351,12 +352,12 @@ export function reapIfEmpty(nodeId, keepPane) {
351
352
  }
352
353
  /** Sweep the whole canvas for empty shells and reap them — the bulk cleanup
353
354
  * behind `crtr canvas prune --empty`, for the husks that accumulated before
354
- * close/detach learned to reap. Skips: the caller ($CRTR_NODE_ID), nodes
355
- * mid-first-turn, and any node with a LIVE broker (pi_pid alive) — a running
356
- * engine is the daemon's domain and may have output not yet persisted, so bulk
357
- * GC never kills one (the per-action {@link reapIfEmpty} does, since the user
358
- * explicitly closed/detached that node). `dryRun` reports candidates without
359
- * deleting. Returns the reaped (or, under dryRun, reapable) node ids. */
355
+ * close/detach learned to reap. Skips: the caller ($CRTR_NODE_ID), dead rows,
356
+ * nodes mid-first-turn, and any node with a LIVE broker (pi_pid alive) — a
357
+ * running engine is the daemon's domain and may have output not yet persisted,
358
+ * so bulk GC never kills one (the per-action {@link reapIfEmpty} does, since
359
+ * the user explicitly closed/detached that node). `dryRun` reports candidates
360
+ * without deleting. Returns the reaped (or, under dryRun, reapable) node ids. */
360
361
  export function reapEmptyNodes(opts = {}) {
361
362
  const dryRun = opts.dryRun ?? false;
362
363
  const selfId = envNodeId() ?? '';
@@ -364,9 +365,15 @@ export function reapEmptyNodes(opts = {}) {
364
365
  for (const row of listNodes()) {
365
366
  const id = row.node_id;
366
367
  const meta = getNode(id);
367
- if (id === selfId || isPidAlive(row.pi_pid) || meta?.managed_worktree?.state === 'open' || !isEmptyNode(id))
368
+ if (id === selfId
369
+ || row.status === 'dead'
370
+ || isPidAlive(row.pi_pid)
371
+ || meta?.managed_worktree?.state === 'open'
372
+ || !isEmptyNode(id))
368
373
  continue;
374
+ const subscribers = subscribersOf(id);
369
375
  if (!dryRun) {
376
+ fanDoctrineWake(id, subscribers, `Child closed — ${meta?.name ?? id} (${id}) was closed from the canvas and is no longer running.`, { reason: 'child-closed', child: id });
370
377
  headlessBrokerHost.teardown(id);
371
378
  tearDownNode(id);
372
379
  if (!deleteNode(id).deleted)
@@ -22,7 +22,7 @@
22
22
  // Best-effort throughout: a tmux/fs failure on one node never aborts the reap.
23
23
  import { getNode, updateNode, fullName, closeFocusRow, view, } from '../canvas/index.js';
24
24
  import { transition } from './lifecycle.js';
25
- import { headlessBrokerHost } from './host.js';
25
+ import { headlessBrokerHost, requestBrokerTeardown } 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
28
  import { buildLaunchSpecAsync, buildPiArgv } from './launch.js';
@@ -51,7 +51,7 @@ export function reapDescendants(rootId) {
51
51
  // viewer pane + registry row (attach auto-reconnects, so the viewer must be
52
52
  // closed here or it lingers ~30s in a misleading "reconnecting…" state).
53
53
  transition(id, 'cancel', { reason: 'closed' });
54
- headlessBrokerHost.teardown(id);
54
+ requestBrokerTeardown(id);
55
55
  tearDownNode(id);
56
56
  reaped.push(id);
57
57
  }
@@ -22,9 +22,9 @@
22
22
  // and there is goal/roadmap ground state to re-enter on.
23
23
  //
24
24
  // reviveNode remains the ONLY sanctioned launcher of the node engine.
25
- import { existsSync } from 'node:fs';
25
+ import { existsSync, readFileSync } from 'node:fs';
26
26
  import { isAbsolute } from 'node:path';
27
- import { findNodeBySessionFile, getNode, updateNode, clearPid, recordPid, setFrozen, clearFrozen, fullName, subscribersOf, } from '../canvas/index.js';
27
+ import { findNodeBySessionFile, getNode, updateNode, clearPid, recordLaunch, recordPid, setFrozen, clearFrozen, fullName, subscribersOf, } from '../canvas/index.js';
28
28
  import { emitEvent } from '../events/emit.js';
29
29
  import { cancelCronsOnWake } from '../canvas/crons.js';
30
30
  import { isParked, transition } from './lifecycle.js';
@@ -41,6 +41,7 @@ import { clearContextExposureState, initializeForkContextExposure, } from '../su
41
41
  import { rootOfSpine } from './nodes.js';
42
42
  import { isReviewCompanionBound } from '../review/companion.js';
43
43
  import { assertProfileActive } from '../profiles/manifest.js';
44
+ import { isPidAlive } from '../canvas/pid.js';
44
45
  // resumeArgs — which session source a revive resumes from
45
46
  /** Pick the `--session` source for a revive. Both resume=true (a true resume)
46
47
  * and resume=false (a refresh-yield) resume by the absolute session-file path
@@ -64,6 +65,40 @@ export function resumeArgs(meta, resume) {
64
65
  return {};
65
66
  return resume ? { resumeSessionPath: path } : { resumeSessionPath: path, newCycle: true };
66
67
  }
68
+ /** Return false only when a valid Pi session header positively names a cwd that
69
+ * has gone away. Missing/corrupt headers remain the engine's concern: old
70
+ * fixtures and partial transcripts do not prove a cwd mismatch. */
71
+ function sessionCwdExists(sessionFile) {
72
+ try {
73
+ for (const line of readFileSync(sessionFile, 'utf8').split('\n')) {
74
+ if (line.trim() === '')
75
+ continue;
76
+ const entry = JSON.parse(line);
77
+ if (entry.type !== 'session')
78
+ continue;
79
+ return typeof entry.cwd !== 'string' || existsSync(entry.cwd);
80
+ }
81
+ }
82
+ catch {
83
+ // The broker/engine owns malformed-transcript diagnostics; it is not proof
84
+ // that the stored cwd is absent.
85
+ }
86
+ return true;
87
+ }
88
+ /** The session-file preflight has two ordered vetoes. A live owner wins before
89
+ * inspecting its transcript, so this node can never adopt or clear another
90
+ * broker's file. Only an unowned/dead-owner file then reaches the cwd check. */
91
+ function resumableSessionArgs(nodeId, meta, resume) {
92
+ const path = meta.pi_session_file;
93
+ if (path == null || path === '' || !existsSync(path))
94
+ return {};
95
+ const owner = findNodeBySessionFile(path);
96
+ if (owner !== null && owner.node_id !== nodeId && isPidAlive(owner.pi_pid))
97
+ return {};
98
+ if (!sessionCwdExists(path))
99
+ return {};
100
+ return resumeArgs(meta, resume);
101
+ }
67
102
  // Return type
68
103
  /** A relaunch that has NO transcript to resume or branch from, and no durable
69
104
  * work state on disk (no roadmap, no yield note), is not a revive at all — the
@@ -262,7 +297,7 @@ function launchRevive(nodeId, meta, opts) {
262
297
  const refreshPending = meta.intent === 'refresh' || meta.cycle_pending === true;
263
298
  const parkedReentry = isParked(meta) && hasFreshGroundState(nodeId) && hasUnseenWakeMail(nodeId);
264
299
  const effectiveResume = refreshPending || parkedReentry ? false : opts.resume;
265
- const resume = forkBirthPending ? {} : resumeArgs(meta, effectiveResume);
300
+ const resume = forkBirthPending ? {} : resumableSessionArgs(nodeId, meta, effectiveResume);
266
301
  // hasSessionPath: there is a real `.jsonl` to hand pi via `--session`, whether
267
302
  // as a true resume OR a cycling refresh-yield (resumeArgs sets `newCycle` in
268
303
  // the latter case). cycling and resuming are mutually exclusive; `resuming`
@@ -354,7 +389,7 @@ function launchRevive(nodeId, meta, opts) {
354
389
  // this marker advertised. Snapshot the boot-fault attempt in the SAME
355
390
  // breath (see beginBootFaultAttempt): its own verification, not this call's
356
391
  // unchecked result, is what scopes a later diagnostic read to this attempt.
357
- clearFault(nodeId);
392
+ clearFault(nodeId, { preserveProviderRetryEpisode: true });
358
393
  const bootFault = beginBootFaultAttempt(nodeId);
359
394
  // A cron armed --cancel-on-wake belongs to the dormancy being left, so a WAKE
360
395
  // of its anchor deletes it — an inbox delivery, an attach, an explicit
@@ -394,6 +429,7 @@ function launchRevive(nodeId, meta, opts) {
394
429
  clearPid(nodeId);
395
430
  throw new Error(`[broker] refusing to revive ${nodeId}: ${error.message}`);
396
431
  }
432
+ recordLaunch(nodeId, new Date().toISOString());
397
433
  // Record the real broker pid. The pid is METADATA now (design D-13) — the
398
434
  // liveness authority is the fleet entry registered synchronously inside
399
435
  // `launch` above — but session_start re-records it after extension bind,
@@ -11,6 +11,9 @@ export interface SpawnChildOpts {
11
11
  prompt: string;
12
12
  /** Override the parent (defaults to the calling node from env). */
13
13
  parent?: string;
14
+ /** Actor that created this node. Unlike parent (manager) and spawned_by
15
+ * (provenance), it controls the parent's birth announcement. */
16
+ creator?: string | null;
14
17
  /** Spawn an INDEPENDENT root instead of a managed child: parent=null, no
15
18
  * subscription back to the spawner, spawned_by=spawner. Resident and brought
16
19
  * forefront by default — see `rootLifecycle` for the unattended variant. */
@@ -275,6 +275,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
275
275
  // records spawned_by=spawner when a node (not a human shell) spawned it.
276
276
  // A child's parent IS its manager.
277
277
  parent: root ? null : spawner,
278
+ creator: opts.creator,
278
279
  spawnedBy: root ? (spawner ?? undefined) : undefined,
279
280
  profile_id: profileId,
280
281
  forkFrom: opts.forkFrom,
@@ -24,6 +24,10 @@ export interface WarmRequest {
24
24
  model: string | null;
25
25
  situationalContext: string | null;
26
26
  name: string | null;
27
+ /** Node that asked for this create, or null for an external process. Claim-time
28
+ * identity like `name` — a spare is minted creator-less and adopts the
29
+ * claimant's, so provenance survives the warm path. */
30
+ creator: string | null;
27
31
  }
28
32
  /** Claim a spare for `request`, reshaped onto its persona — or null when the
29
33
  * pool cannot serve it. The caller falls back to a cold spawn; a claim never
@@ -128,6 +128,7 @@ export async function claimWarmNode(request) {
128
128
  kind: request.kind,
129
129
  mode: request.mode,
130
130
  name: request.name ?? request.kind,
131
+ creator: request.creator,
131
132
  // The recipe a later revive replays — skip it and the node silently comes
132
133
  // back as the spare's persona.
133
134
  launch: recipe.launch,
@@ -354,6 +355,7 @@ export function prewarmRecentRecipes() {
354
355
  model: null,
355
356
  situationalContext: null,
356
357
  name: null,
358
+ creator: null,
357
359
  }).then(mint).catch((err) => console.error(`[warm-pool] prewarm failed for ${candidate.cwd}: ${err.message}`));
358
360
  }
359
361
  catch (err) {
@@ -57,9 +57,10 @@ interface CleanEmptyProof {
57
57
  * be a git-ancestor of (or equal to) the CURRENT tip of its recorded base
58
58
  * branch — i.e. landing it would be a pure no-op. Any ambiguity (missing
59
59
  * path, mid-rebase, wrong branch, an unresolvable base, or a real ancestry
60
- * divergence) returns null: this function only ever proves a no-op land
61
- * when a live git check establishes it, never on a heuristic guess, so real
62
- * unlanded work always falls through to the explicit-close guard. The locked
60
+ * divergence) returns null. A missing base is the one exception: equality
61
+ * between the live HEAD and recorded creation SHA proves no work was added.
62
+ * This function only ever proves a no-op land from live Git state, never on
63
+ * a heuristic guess, so real unlanded work always falls through to the explicit-close guard. The locked
63
64
  * path re-checks this predicate before recording deferred cleanup; production
64
65
  * code must always go through `autoDropCleanManagedWorktree`. */
65
66
  export declare function proveCleanEmptyManagedWorktree(wt: ManagedWorktree): CleanEmptyProof | null;
@@ -51,9 +51,17 @@ function resolveLandableBase(repoRoot, baseRef) {
51
51
  // syntax, which could turn a SHA or a `^{commit}` suffix into a non-landable
52
52
  // base even though no local branch by that exact name exists.
53
53
  const exists = gitSync(['show-ref', '--verify', '--quiet', ref], repoRoot);
54
- if (exists.status !== 0) {
54
+ // `show-ref --verify --quiet` reserves exit 1 for an absent ref. gitSync
55
+ // preserves that numeric status for compatibility, but marks spawn failures,
56
+ // timeouts, and signal exits so they cannot masquerade as an absent branch.
57
+ if (exists.status === 1 && exists.executionFailed !== true) {
55
58
  throw new WorktreeError('missing_base_branch', `managed worktree base '${baseRef}' does not name an existing local branch`, 'The base must be a local branch that landing can fast-forward — freshen your local branch from origin first (rebase-not-merge), then base on it.');
56
59
  }
60
+ if (exists.status !== 0) {
61
+ const stderr = exists.stderr.trim() || '<empty>';
62
+ const execution = exists.processError === undefined ? '' : `\nprocess error: ${exists.processError}`;
63
+ throw new WorktreeError('base_branch_inspection_failed', `could not inspect local base branch '${baseRef}' (git show-ref exited with status ${exists.status})`, 'Inspect the repository and its Git metadata, then retry.', `status: ${exists.status}\nstderr: ${stderr}${execution}`);
64
+ }
57
65
  return runGit(repoRoot, ['rev-parse', '--verify', ref], 'base_branch_resolve_failed', 'Inspect the local base branch, then retry.');
58
66
  }
59
67
  /** Path of the worktree where a local branch is currently checked out, or null
@@ -238,16 +246,23 @@ export function isRebaseInProgress(cwd) {
238
246
  return existsSync(gitPath(cwd, 'rebase-merge')) || existsSync(gitPath(cwd, 'rebase-apply'));
239
247
  }
240
248
  /** The ONE authoritative closed-worktree state transition, shared by both
241
- * closing paths (explicit `closeManagedWorktreeLocked` land, and the clean
242
- * `autoDropCleanManagedWorktreeLocked` push-final drop): persist
243
- * `managed_worktree.state: 'closed'`, name its cleanup state, and repoint the
244
- * node's launch `cwd` to the durable base checkout atomically in the same
245
- * `updateNode` write. A live node keeps its checkout intact for explicit
246
- * post-exit cleanup: `git worktree remove` recursively deletes the caller's
247
- * cwd and cannot safely run against it. Both closing paths MUST go through this helper instead of
248
- * hand-rolling the `updateNode` call, so neither can drift out of sync. */
249
+ * closing paths (explicit `closeManagedWorktreeLocked` land, and the clean
250
+ * `autoDropCleanManagedWorktreeLocked` push-final drop): persist
251
+ * `managed_worktree.state: 'closed'`, name its cleanup state, and repoint the
252
+ * node's launch `cwd` to the durable base checkout. The caller broker stays
253
+ * alive and is Pi's sole transcript writer, so its session header cannot be
254
+ * safely rewritten here; clear the session coordinates and let the later
255
+ * revive launch fresh instead. Both closing paths MUST go through this helper
256
+ * instead of hand-rolling the `updateNode` call, so neither can drift out of
257
+ * sync. */
249
258
  function persistClosedWorktreeState(nodeId, wt, cleanup = 'pending') {
250
- updateNode(nodeId, { cwd: wt.repo_root, managed_worktree: { ...wt, state: 'closed', cleanup, closed: new Date().toISOString() } });
259
+ const node = getNode(nodeId);
260
+ const sessionInvalidated = node?.pi_session_id != null || node?.pi_session_file != null;
261
+ updateNode(nodeId, {
262
+ cwd: wt.repo_root,
263
+ ...(sessionInvalidated ? { pi_session_id: null, pi_session_file: null } : {}),
264
+ managed_worktree: { ...wt, state: 'closed', cleanup, closed: new Date().toISOString() },
265
+ });
251
266
  }
252
267
  function deferredCleanupResult(wt) {
253
268
  const remove = `git -C ${shellQuote(wt.repo_root)} worktree remove ${shellQuote(wt.path)}`;
@@ -301,7 +316,29 @@ function closeManagedWorktreeLocked(nodeId) {
301
316
  const found = branch === 'HEAD' ? 'detached HEAD' : branch;
302
317
  throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch}, found ${found})`, `Check out ${wt.branch} in ${wt.path} before closing, or ask the user with \`crtr human send\` if you are unsure why it changed, then rerun \`crtr node worktree close\`.`);
303
318
  }
304
- const baseSha = resolveLandableBase(wt.repo_root, wt.base_ref);
319
+ let baseSha;
320
+ try {
321
+ // `repo_root` is the checkout that created this child, and it can vanish
322
+ // when that parent finishes first. The live managed checkout still names
323
+ // the shared repository and is the authority for every close operation.
324
+ baseSha = resolveLandableBase(wt.path, wt.base_ref);
325
+ }
326
+ catch (error) {
327
+ // A deleted parent base cannot receive a fast-forward. A clean child at
328
+ // its recorded creation SHA has nothing to land, however, so conclude its
329
+ // deferred cleanup without guessing where work should go.
330
+ const head = runGit(wt.path, ['rev-parse', 'HEAD'], 'landed_sha_failed', 'Inspect the worktree checkout manually, then rerun `crtr node worktree close`.');
331
+ if (!(error instanceof WorktreeError) || error.code !== 'missing_base_branch' || head !== wt.base_sha)
332
+ throw error;
333
+ persistClosedWorktreeState(nodeId, wt, 'pending');
334
+ return {
335
+ node_id: nodeId,
336
+ branch: wt.branch,
337
+ worktree_path: wt.path,
338
+ landed_sha: wt.base_sha,
339
+ ...deferredCleanupResult(wt),
340
+ };
341
+ }
305
342
  const rebase = gitSync(['rebase', baseSha], wt.path);
306
343
  if (rebase.status !== 0) {
307
344
  const detail = (rebase.stderr.trim() || rebase.stdout.trim()).trim();
@@ -312,11 +349,11 @@ function closeManagedWorktreeLocked(nodeId) {
312
349
  // captured baseSha — landing is purely local, nothing is ever pushed to
313
350
  // origin. Both landing paths require the base to still be exactly at that
314
351
  // captured tip, so neither can overwrite a concurrent base change.
315
- const fastForward = gitSync(['merge-base', '--is-ancestor', baseSha, landedSha], wt.repo_root);
352
+ const fastForward = gitSync(['merge-base', '--is-ancestor', baseSha, landedSha], wt.path);
316
353
  if (fastForward.status !== 0) {
317
354
  throw new WorktreeError('land_base_failed', `rebased worktree is not a fast-forward of local base branch '${wt.base_ref}'`, 'Ask the user with `crtr human send` how to land this managed worktree.', (fastForward.stderr.trim() || fastForward.stdout.trim()).trim());
318
355
  }
319
- const baseCheckout = checkoutPathForBranch(wt.repo_root, wt.base_ref);
356
+ const baseCheckout = checkoutPathForBranch(wt.path, wt.base_ref);
320
357
  if (baseCheckout !== null) {
321
358
  const checkoutHead = gitSync(['rev-parse', 'HEAD'], baseCheckout);
322
359
  const actualHead = checkoutHead.stdout.trim();
@@ -329,7 +366,7 @@ function closeManagedWorktreeLocked(nodeId) {
329
366
  }
330
367
  const land = baseCheckout !== null
331
368
  ? gitSync(['merge', '--ff-only', landedSha], baseCheckout)
332
- : gitSync(['update-ref', `refs/heads/${wt.base_ref}`, landedSha, baseSha], wt.repo_root);
369
+ : gitSync(['update-ref', `refs/heads/${wt.base_ref}`, landedSha, baseSha], wt.path);
333
370
  if (land.status !== 0) {
334
371
  const detail = (land.stderr.trim() || land.stdout.trim()).trim();
335
372
  throw new WorktreeError('land_base_failed', `could not fast-forward local base branch '${wt.base_ref}' to this worktree`, baseCheckout !== null
@@ -356,7 +393,10 @@ export function closeManagedWorktree(nodeId) {
356
393
  // repository to lock; every state-mutating close path runs under the lock.
357
394
  if (wt === null)
358
395
  return closeManagedWorktreeLocked(nodeId);
359
- return withRepositoryWorktreeLock(wt.repo_root, () => closeManagedWorktreeLocked(nodeId));
396
+ // An absent checkout has no live Git directory; its separate recovery proof
397
+ // still relies on the recorded repository path. Every live close locks from
398
+ // the child checkout instead of the parent that created it.
399
+ return withRepositoryWorktreeLock(existsSync(wt.path) ? wt.path : wt.repo_root, () => closeManagedWorktreeLocked(nodeId));
360
400
  }
361
401
  const ABANDON_AUTHOR_NAME = 'crtr-abandon';
362
402
  const ABANDON_AUTHOR_EMAIL = 'crtr-abandon@localhost';
@@ -519,9 +559,10 @@ export function missingWorktreeRecoveryHint(wt) {
519
559
  * be a git-ancestor of (or equal to) the CURRENT tip of its recorded base
520
560
  * branch — i.e. landing it would be a pure no-op. Any ambiguity (missing
521
561
  * path, mid-rebase, wrong branch, an unresolvable base, or a real ancestry
522
- * divergence) returns null: this function only ever proves a no-op land
523
- * when a live git check establishes it, never on a heuristic guess, so real
524
- * unlanded work always falls through to the explicit-close guard. The locked
562
+ * divergence) returns null. A missing base is the one exception: equality
563
+ * between the live HEAD and recorded creation SHA proves no work was added.
564
+ * This function only ever proves a no-op land from live Git state, never on
565
+ * a heuristic guess, so real unlanded work always falls through to the explicit-close guard. The locked
525
566
  * path re-checks this predicate before recording deferred cleanup; production
526
567
  * code must always go through `autoDropCleanManagedWorktree`. */
527
568
  export function proveCleanEmptyManagedWorktree(wt) {
@@ -537,18 +578,23 @@ export function proveCleanEmptyManagedWorktree(wt) {
537
578
  const status = gitSync(['status', '--porcelain'], wt.path);
538
579
  if (status.status !== 0 || status.stdout.trim() !== '')
539
580
  return null;
540
- let baseSha;
541
- try {
542
- baseSha = resolveLandableBase(wt.repo_root, wt.base_ref);
543
- }
544
- catch {
545
- return null;
546
- }
547
581
  const head = gitSync(['rev-parse', 'HEAD'], wt.path);
548
582
  if (head.status !== 0)
549
583
  return null;
550
584
  const branchSha = head.stdout.trim();
551
- const ancestor = gitSync(['merge-base', '--is-ancestor', branchSha, baseSha], wt.repo_root);
585
+ let baseSha;
586
+ try {
587
+ baseSha = resolveLandableBase(wt.path, wt.base_ref);
588
+ }
589
+ catch (error) {
590
+ // A parent-first close can remove the recorded base branch. Equality with
591
+ // the durable creation SHA proves this clean child never added work.
592
+ if (error instanceof WorktreeError && error.code === 'missing_base_branch' && branchSha === wt.base_sha) {
593
+ return { branchSha, baseSha: wt.base_sha };
594
+ }
595
+ throw error;
596
+ }
597
+ const ancestor = gitSync(['merge-base', '--is-ancestor', branchSha, baseSha], wt.path);
552
598
  if (ancestor.status !== 0)
553
599
  return null;
554
600
  return { branchSha, baseSha };
@@ -612,7 +658,7 @@ export function autoDropCleanManagedWorktree(nodeId) {
612
658
  const pathAbsent = !existsSync(wt.path);
613
659
  if (pathAbsent ? proveAbsentPathManagedWorktree(wt) === null : proveCleanEmptyManagedWorktree(wt) === null)
614
660
  return null;
615
- return withRepositoryWorktreeLock(wt.repo_root, () => {
661
+ return withRepositoryWorktreeLock(pathAbsent ? wt.repo_root : wt.path, () => {
616
662
  // Re-fetch inside the lock: state may have changed since the caller's
617
663
  // hasOpenManagedWorktree check ran outside it.
618
664
  const locked = openManagedWorktreeForNode(nodeId);
@@ -0,0 +1,81 @@
1
+ // Regression for NL-159 / #571: the daemon's pidfile/ownership claim must not
2
+ // survive a failed mandatory Unix API bind. A directory at the socket path
3
+ // deterministically makes `listen()` emit EADDRINUSE without touching crtrd's
4
+ // real canvas or runtime homes.
5
+ import assert from 'node:assert/strict';
6
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import test from 'node:test';
9
+ import { closeDb } from '../../../core/canvas/db.js';
10
+ import { claimDaemonOwnership, releaseDaemonOwnership } from '../../../core/canvas/daemon-owner.js';
11
+ import { resetEventSourceForTests } from '../../../core/events/source.js';
12
+ import { resetFleetForTests } from '../../../core/runtime/fleet.js';
13
+ import { runDaemon } from '../../crtrd.js';
14
+ import { spawnDaemon } from '../../manage.js';
15
+ const isolatedEnvKeys = [
16
+ 'HOME',
17
+ 'CRTR_HOME',
18
+ 'CRTR_PIDFILE',
19
+ 'CRTR_RUNTIME_HOME',
20
+ 'XDG_CACHE_HOME',
21
+ 'XDG_CONFIG_HOME',
22
+ 'XDG_DATA_HOME',
23
+ 'PI_CODING_AGENT_DIR',
24
+ ];
25
+ test('a Unix socket bind failure rejects readiness and releases daemon ownership', async () => {
26
+ const root = mkdtempSync('/tmp/crtr-api-bind-failure-');
27
+ const home = join(root, 'home');
28
+ const canvasHome = join(root, 'canvas');
29
+ const socketPath = join(canvasHome, 'crtrd.sock');
30
+ const previousCwd = process.cwd();
31
+ const previousEnv = new Map(isolatedEnvKeys.map((key) => [key, process.env[key]]));
32
+ mkdirSync(home, { recursive: true });
33
+ mkdirSync(socketPath, { recursive: true });
34
+ try {
35
+ process.chdir(home);
36
+ process.env['HOME'] = home;
37
+ process.env['CRTR_HOME'] = canvasHome;
38
+ process.env['CRTR_PIDFILE'] = join(canvasHome, 'crtrd.pid');
39
+ process.env['CRTR_RUNTIME_HOME'] = join(root, 'runtime');
40
+ process.env['XDG_CACHE_HOME'] = join(root, 'cache');
41
+ process.env['XDG_CONFIG_HOME'] = join(root, 'config');
42
+ process.env['XDG_DATA_HOME'] = join(root, 'data');
43
+ process.env['PI_CODING_AGENT_DIR'] = join(home, '.pi', 'agent');
44
+ closeDb();
45
+ resetEventSourceForTests();
46
+ resetFleetForTests();
47
+ await assert.rejects(() => runDaemon(), (error) => error.code === 'EADDRINUSE', 'the mandatory API socket bind must reject startup');
48
+ const pidfile = process.env['CRTR_PIDFILE'];
49
+ assert.equal(existsSync(pidfile), false, 'failed readiness removes the pidfile mirror');
50
+ assert.equal(existsSync(socketPath), true, 'the pre-existing directory was never mistaken for a serving socket');
51
+ assert.match(readFileSync(join(canvasHome, 'crtrd.log'), 'utf8'), /api\.server\.failed/, 'the bind failure is recorded for cold-start diagnostics');
52
+ writeFileSync(pidfile, String(process.pid));
53
+ assert.deepEqual(await spawnDaemon(), {
54
+ started: false,
55
+ existing_pid: process.pid,
56
+ running: true,
57
+ serving: false,
58
+ next: 'Run `crtr sys daemon stop` to release the non-serving daemon before starting it again.',
59
+ }, 'start distinguishes a live non-serving pidfile owner from a serving daemon');
60
+ rmSync(pidfile, { force: true });
61
+ const retryClaim = claimDaemonOwnership('post-api-bind-failure');
62
+ assert.equal(retryClaim.claimed, true, 'failed readiness releases the authoritative ownership claim');
63
+ if (retryClaim.claimed) {
64
+ assert.equal(releaseDaemonOwnership(retryClaim.claim).released, true, 'the test-only retry claim releases cleanly');
65
+ }
66
+ }
67
+ finally {
68
+ closeDb();
69
+ resetEventSourceForTests();
70
+ resetFleetForTests();
71
+ process.chdir(previousCwd);
72
+ for (const key of isolatedEnvKeys) {
73
+ const previous = previousEnv.get(key);
74
+ if (previous === undefined)
75
+ delete process.env[key];
76
+ else
77
+ process.env[key] = previous;
78
+ }
79
+ rmSync(root, { recursive: true, force: true });
80
+ }
81
+ });
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import { mkdtempSync, rmSync } from 'node:fs';
4
4
  import { createServer } from 'node:net';
5
5
  import { createNode, getNode, openDb } from '../../../core/canvas/index.js';
6
+ import { armCron, getCron } from '../../../core/canvas/crons.js';
6
7
  import { closeDb } from '../../../core/canvas/db.js';
7
8
  import { viewSocketPath } from '../../../core/canvas/paths.js';
8
9
  import { readInboxSince } from '../../../core/feed/inbox.js';
@@ -98,6 +99,43 @@ function assertReopened(id) {
98
99
  assert.equal(meta?.lifecycle, 'resident');
99
100
  assert.equal(meta?.final_report, null);
100
101
  }
102
+ function armDeadline(id) {
103
+ armCron({
104
+ cron_id: `${id}-deadline`,
105
+ name: `${id} deadline`,
106
+ command: 'echo stale deadline',
107
+ fire_at: new Date(Date.now() + 60_000).toISOString(),
108
+ recur: null,
109
+ tz: null,
110
+ expires_at: null,
111
+ anchor_node: id,
112
+ cancel_on_wake: true,
113
+ created_by: null,
114
+ cwd: home,
115
+ env_json: null,
116
+ profile: null,
117
+ scope: 'profile',
118
+ run_timeout_s: 30,
119
+ overlap: 'skip',
120
+ on_output: 'silent',
121
+ sink: '{}',
122
+ tier: 'normal',
123
+ });
124
+ }
125
+ test('live interactive delivery clears the target deadline', async () => {
126
+ const id = 'live-deadline';
127
+ createNode(node(id, 'terminal', 'active', process.pid));
128
+ armDeadline(id);
129
+ const broker = await startLiveBroker(id);
130
+ try {
131
+ const result = await sendMessage(id, { body: 'wake before deadline', delivery: 'interactive' });
132
+ assert.equal(result.body.delivered_via, 'engine');
133
+ assert.equal(getCron(`${id}-deadline`), null, 'the accepted engine delivery won the deadline race');
134
+ }
135
+ finally {
136
+ await broker.close();
137
+ }
138
+ });
101
139
  test('reopen makes live terminal, finalized terminal, and parked targets resident and unlatched before delivery', async () => {
102
140
  const liveId = 'live-terminal';
103
141
  createNode(node(liveId, 'terminal', 'active', process.pid));
@@ -117,6 +117,7 @@ test('A3: messages append inbox.jsonl; final report writes + notifies subscriber
117
117
  assert.ok(res.notified.includes(A), `final push notifies the spawn-subscribed manager A (notified=${JSON.stringify(res.notified)})`);
118
118
  assert.deepEqual(res.transitioned, { from: 'active', to: 'done' }, 'row transitioned active→done');
119
119
  assert.equal(h.node(B)?.status, 'done', 'row status is done after final');
120
+ await assert.rejects(() => client.pushReport(B, { tier: 'final', delivery_tier: 'deferred', body: 'invalid tiered final' }), (error) => error instanceof ApiError && error.code === 'usage' && error.message === 'delivery_tier is only valid for update reports', 'the API rejects a delivery tier on final before it can alter final delivery');
120
121
  });
121
122
  // A4 — WS attach reconstructs the same settled broker snapshot as a direct
122
123
  // ViewSocketClient; ?revive=0 on a dormant node → 409 without launching a broker
@@ -30,6 +30,7 @@ import { registerPush } from '../../../../commands/push.js';
30
30
  import { registerNode } from '../../../../commands/node.js';
31
31
  import { createNode, getNode } from '../../../../core/canvas/canvas.js';
32
32
  import { createManagedWorktree } from '../../../../core/worktree.js';
33
+ import { readInboxSince } from '../../../../core/feed/inbox.js';
33
34
  import { gitSync } from '../../../../core/git.js';
34
35
  import { InputError } from '../../../../core/io.js';
35
36
  // A terminal fabricated node (no broker needed) that OWNS a managed worktree —
@@ -72,11 +73,11 @@ function pushLeaf(tier) {
72
73
  // Drive the leaf as the given caller node — sets CRTR_NODE_ID (client-side
73
74
  // identity resolution stays in the CLI process, per the B-5 scope) around the
74
75
  // run, restoring it after.
75
- async function runPushAs(caller, tier, body) {
76
+ async function runPushAs(caller, tier, body, input = {}) {
76
77
  const prev = process.env['CRTR_NODE_ID'];
77
78
  process.env['CRTR_NODE_ID'] = caller;
78
79
  try {
79
- return (await pushLeaf(tier).run({ body }));
80
+ return (await pushLeaf(tier).run({ body, ...input }));
80
81
  }
81
82
  finally {
82
83
  if (prev === undefined)
@@ -151,6 +152,16 @@ test('`crtr push final` from a child writes a report, notifies the subscribed pa
151
152
  // — the re-homed guard only engages for a managed-worktree owner.
152
153
  assert.equal('worktree_auto_dropped' in out, false, 'a normal node final is unaffected by the worktree guard');
153
154
  });
155
+ test('`crtr push update --tier deferred` fans a deferred pointer while preserving an update report', async () => {
156
+ const parent = h.spawnRoot('leaf-api-parity deferred parent');
157
+ const child = (await client.createNode({ kind: 'developer', parent, prompt: 'deferred worker' })).node_id;
158
+ await h.awaitBoot(child);
159
+ const out = await runPushAs(child, 'update', 'deferred progress', { tier: 'deferred' });
160
+ assert.ok(out['notified'].includes(parent), 'the subscribed parent receives the report pointer');
161
+ const entry = readInboxSince(parent).find((candidate) => candidate.from === child && candidate.label === 'deferred progress');
162
+ assert.equal(entry?.kind, 'update', 'the stored report remains an update');
163
+ assert.equal(entry?.tier, 'deferred', 'the requested delivery tier reaches the parent');
164
+ });
154
165
  // Re-homed worktree guard/auto-drop (ambiguity-2 follow-up): the pre-B-5 CLI
155
166
  // `push final` worktree behavior now lives SERVER-side in handlers/reports.ts,
156
167
  // exercised end-to-end through the real push.ts leaf.
@@ -4,6 +4,7 @@
4
4
  import { appendInbox } from '../../../core/feed/inbox.js';
5
5
  import { contextDir, isSafeNodeId } from '../../../core/canvas/paths.js';
6
6
  import { getNode } from '../../../core/canvas/canvas.js';
7
+ import { cancelCronsOnWake } from '../../../core/canvas/crons.js';
7
8
  import { isBrokerLive } from '../../../core/runtime/model-swap.js';
8
9
  import { assertNotFinalized } from '../../../core/runtime/reopen.js';
9
10
  import { reviveNode } from '../../../core/runtime/revive.js';
@@ -74,6 +75,7 @@ async function handleStop(ctx) {
74
75
  label: body.split('\n')[0].slice(0, 120),
75
76
  data: { body },
76
77
  });
78
+ cancelCronsOnWake(nodeId);
77
79
  const node = getNode(nodeId);
78
80
  if (node !== null && !isBrokerLive(node)) {
79
81
  try {