@north-light/crouter 0.3.235 → 0.3.237

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 (181) hide show
  1. package/dist/api/client.d.ts +2 -1
  2. package/dist/api/client.js +3 -0
  3. package/dist/api/dto/broker-ops.d.ts +1 -1
  4. package/dist/api/dto/common.d.ts +2 -0
  5. package/dist/api/dto/lifecycle.d.ts +9 -2
  6. package/dist/api/dto/messages.d.ts +5 -0
  7. package/dist/api/dto/nodes.d.ts +14 -5
  8. package/dist/api/dto/worktree.d.ts +10 -0
  9. package/dist/api/routes.d.ts +1 -0
  10. package/dist/api/routes.js +1 -0
  11. package/dist/builtin-memory/00-runtime-base/00-authoring.md +1 -1
  12. package/dist/builtin-memory/internal/agent-shaping.md +1 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +1 -1
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/sysprompt-window.ts +1 -1
  15. package/dist/clients/attach/render/tool-calls.js +18 -3
  16. package/dist/clients/attach/viewer.js +399 -399
  17. package/dist/clients/inbox/review/__tests__/editor-roundtrip.test.js +6 -0
  18. package/dist/clients/inbox/review/roundtrip.js +1 -1
  19. package/dist/commands/__tests__/node-new.test.js +58 -0
  20. package/dist/commands/memory/lint.js +22 -13
  21. package/dist/commands/memory/shared.js +1 -1
  22. package/dist/commands/node/create.d.ts +1 -1
  23. package/dist/commands/node/create.js +4 -1
  24. package/dist/commands/node/lifecycle.js +2 -2
  25. package/dist/commands/node-worktree.js +39 -8
  26. package/dist/commands/revive.js +11 -2
  27. package/dist/commands/sys/context/admin/actions.d.ts +48 -0
  28. package/dist/commands/sys/context/admin/actions.js +246 -0
  29. package/dist/commands/sys/context/admin/axes-panel.d.ts +21 -0
  30. package/dist/commands/sys/context/admin/axes-panel.js +117 -0
  31. package/dist/commands/sys/context/admin/detail-panel.d.ts +53 -0
  32. package/dist/commands/sys/context/admin/detail-panel.js +178 -0
  33. package/dist/commands/sys/context/admin/docs-panel.d.ts +45 -0
  34. package/dist/commands/sys/context/admin/docs-panel.js +186 -0
  35. package/dist/commands/sys/context/admin/model.d.ts +75 -0
  36. package/dist/commands/sys/context/admin/model.js +209 -0
  37. package/dist/commands/sys/context/admin/shell.d.ts +82 -0
  38. package/dist/commands/sys/context/admin/shell.js +669 -0
  39. package/dist/commands/sys/context/admin.d.ts +1 -0
  40. package/dist/commands/sys/context/admin.js +124 -0
  41. package/dist/commands/sys/context/doc.d.ts +1 -0
  42. package/dist/commands/sys/context/doc.js +196 -0
  43. package/dist/commands/sys/context/prompt-review.d.ts +1 -0
  44. package/dist/commands/sys/{prompt-review.js → context/prompt-review.js} +10 -10
  45. package/dist/commands/sys/context/resolve.d.ts +55 -0
  46. package/dist/commands/sys/context/resolve.js +428 -0
  47. package/dist/commands/sys/context/sysprompt.d.ts +1 -0
  48. package/dist/commands/sys/{sysprompt.js → context/sysprompt.js} +11 -11
  49. package/dist/commands/sys/context.d.ts +2 -0
  50. package/dist/commands/sys/context.js +20 -0
  51. package/dist/commands/sys.js +3 -4
  52. package/dist/core/__tests__/canvas-inbox-watcher-naming.test.d.ts +1 -0
  53. package/dist/core/__tests__/canvas-inbox-watcher-naming.test.js +258 -0
  54. package/dist/core/__tests__/child-death-wake.test.js +38 -22
  55. package/dist/core/__tests__/cron-self-cancel-delivery.test.d.ts +1 -0
  56. package/dist/core/__tests__/cron-self-cancel-delivery.test.js +72 -0
  57. package/dist/core/__tests__/daemon-boot.test.js +40 -28
  58. package/dist/core/__tests__/helpers/harness.js +1 -0
  59. package/dist/core/__tests__/human-deliver.test.js +1 -1
  60. package/dist/core/__tests__/human-node-not-supervised.test.js +10 -15
  61. package/dist/core/__tests__/integration/revive.test.js +12 -12
  62. package/dist/core/__tests__/integration/spawn-root.test.js +6 -0
  63. package/dist/core/__tests__/integration/worktree-reap.test.js +32 -0
  64. package/dist/core/__tests__/lifecycle-terminal-reasons.test.d.ts +1 -0
  65. package/dist/core/__tests__/lifecycle-terminal-reasons.test.js +61 -0
  66. package/dist/core/__tests__/migration.test.js +38 -1
  67. package/dist/core/__tests__/push-final-guard.test.js +1 -1
  68. package/dist/core/__tests__/revive-capacity.test.d.ts +1 -0
  69. package/dist/core/__tests__/revive-capacity.test.js +127 -0
  70. package/dist/core/__tests__/revive-parked-fresh.test.js +7 -7
  71. package/dist/core/__tests__/seam/broker-cap-freeze.test.d.ts +1 -0
  72. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +81 -0
  73. package/dist/core/__tests__/seam/dormancy-release.test.js +76 -7
  74. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +1 -1
  75. package/dist/core/canvas/browse/render.js +11 -3
  76. package/dist/core/canvas/canvas.d.ts +15 -3
  77. package/dist/core/canvas/canvas.js +52 -16
  78. package/dist/core/canvas/migrations.js +21 -0
  79. package/dist/core/canvas/nav-render.js +3 -1
  80. package/dist/core/canvas/remote-canvas-source.js +2 -0
  81. package/dist/core/canvas/render-source.d.ts +3 -1
  82. package/dist/core/canvas/render-source.js +7 -3
  83. package/dist/core/canvas/status-glyph.d.ts +10 -1
  84. package/dist/core/canvas/status-glyph.js +19 -3
  85. package/dist/core/canvas/types.d.ts +22 -3
  86. package/dist/core/feed/feed.js +1 -1
  87. package/dist/core/human/feedback-companion.js +5 -2
  88. package/dist/core/memory/lint.d.ts +19 -0
  89. package/dist/core/memory/lint.js +97 -0
  90. package/dist/core/memory-resolver.d.ts +4 -0
  91. package/dist/core/memory-resolver.js +7 -7
  92. package/dist/core/preview-registry.js +4 -4
  93. package/dist/core/review/realize.js +4 -2
  94. package/dist/core/runtime/close.js +1 -1
  95. package/dist/core/runtime/fleet.d.ts +29 -1
  96. package/dist/core/runtime/fleet.js +23 -0
  97. package/dist/core/runtime/host.js +32 -11
  98. package/dist/core/runtime/launch-prompt.d.ts +1 -1
  99. package/dist/core/runtime/launch-prompt.js +2 -2
  100. package/dist/core/runtime/launch-target.js +1 -1
  101. package/dist/core/runtime/lifecycle.d.ts +4 -2
  102. package/dist/core/runtime/lifecycle.js +12 -1
  103. package/dist/core/runtime/memory.js +1 -1
  104. package/dist/core/runtime/reopen.js +10 -1
  105. package/dist/core/runtime/reset.js +1 -1
  106. package/dist/core/runtime/revive-all.d.ts +3 -0
  107. package/dist/core/runtime/revive-all.js +10 -4
  108. package/dist/core/runtime/revive.d.ts +12 -0
  109. package/dist/core/runtime/revive.js +62 -3
  110. package/dist/core/runtime/spawn.d.ts +9 -13
  111. package/dist/core/runtime/spawn.js +40 -17
  112. package/dist/core/substrate/frontmatter-validation.js +1 -1
  113. package/dist/core/substrate/index.d.ts +6 -3
  114. package/dist/core/substrate/index.js +4 -3
  115. package/dist/core/substrate/on-read.d.ts +0 -8
  116. package/dist/core/substrate/on-read.js +45 -124
  117. package/dist/core/substrate/plan.d.ts +94 -0
  118. package/dist/core/substrate/plan.js +266 -0
  119. package/dist/core/substrate/render.d.ts +5 -6
  120. package/dist/core/substrate/render.js +76 -129
  121. package/dist/core/substrate/schema.d.ts +1 -6
  122. package/dist/core/substrate/schema.js +1 -15
  123. package/dist/core/substrate/session-cache.d.ts +2 -10
  124. package/dist/core/substrate/session-cache.js +9 -47
  125. package/dist/core/substrate/subject-fields.d.ts +1 -4
  126. package/dist/core/substrate/subject-fields.js +2 -5
  127. package/dist/core/substrate/surface-match.d.ts +37 -17
  128. package/dist/core/substrate/surface-match.js +44 -53
  129. package/dist/core/tui/page-host.d.ts +6 -0
  130. package/dist/core/tui/page-host.js +12 -3
  131. package/dist/core/worktree.d.ts +8 -0
  132. package/dist/core/worktree.js +65 -0
  133. package/dist/daemon/__tests__/reconciler-signature.test.js +3 -2
  134. package/dist/daemon/__tests__/thaw-order.test.d.ts +1 -0
  135. package/dist/daemon/__tests__/thaw-order.test.js +55 -0
  136. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +6 -0
  137. package/dist/daemon/api/__tests__/node-create-description.test.js +6 -0
  138. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +1 -1
  139. package/dist/daemon/api/bridge.js +5 -1
  140. package/dist/daemon/api/handlers/attach.js +5 -1
  141. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  142. package/dist/daemon/api/handlers/messages.js +10 -2
  143. package/dist/daemon/api/handlers/nodes.js +8 -4
  144. package/dist/daemon/api/handlers/worktree.js +10 -1
  145. package/dist/daemon/api/map.js +7 -0
  146. package/dist/daemon/companion-retire.js +1 -1
  147. package/dist/daemon/cron/passes.d.ts +0 -5
  148. package/dist/daemon/cron/passes.js +0 -20
  149. package/dist/daemon/cron/sinks.js +1 -1
  150. package/dist/daemon/cron-run.d.ts +5 -6
  151. package/dist/daemon/cron-run.js +7 -16
  152. package/dist/daemon/crtrd.d.ts +0 -10
  153. package/dist/daemon/crtrd.js +23 -61
  154. package/dist/daemon/fleet.d.ts +10 -7
  155. package/dist/daemon/fleet.js +71 -12
  156. package/dist/daemon/messaging/node-message.js +3 -1
  157. package/dist/daemon/profile-delete.js +1 -1
  158. package/dist/daemon/reconcilers/broker-supervision.d.ts +26 -9
  159. package/dist/daemon/reconcilers/broker-supervision.js +39 -52
  160. package/dist/daemon/reconcilers/controller-death.d.ts +7 -0
  161. package/dist/daemon/reconcilers/controller-death.js +26 -0
  162. package/dist/daemon/reconcilers/cron-lane.d.ts +0 -3
  163. package/dist/daemon/reconcilers/cron-lane.js +0 -3
  164. package/dist/daemon/reconcilers/live-obligation.js +3 -1
  165. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +53 -0
  166. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +170 -0
  167. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +5 -0
  168. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +16 -0
  169. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -0
  170. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +13 -0
  171. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +26 -0
  172. package/dist/daemon/reconcilers/node-lifecycle/tick.js +140 -0
  173. package/dist/pi-extensions/canvas-inbox-watcher.js +12 -0
  174. package/package.json +1 -1
  175. package/runtime.lock.json +2 -2
  176. package/dist/commands/sys/prompt-review.d.ts +0 -1
  177. package/dist/commands/sys/sysprompt.d.ts +0 -1
  178. package/dist/core/__tests__/cron-broker-capacity.test.js +0 -119
  179. package/dist/daemon/reconcilers/dormant-inbox.d.ts +0 -18
  180. package/dist/daemon/reconcilers/dormant-inbox.js +0 -157
  181. /package/dist/{core/__tests__/cron-broker-capacity.test.d.ts → commands/__tests__/node-new.test.d.ts} +0 -0
@@ -323,69 +323,60 @@ export function matchesPreCommandEntry(entry, subject, command) {
323
323
  const stripped = stripHeredocs(command);
324
324
  return entry.match.some((g) => commandGlobMatches(g, stripped));
325
325
  }
326
- // Per-doc rung folds — a doc's delivery rung for one event occurrence is the
327
- // highest `at` over its matching entries of that event, `none` when none
328
- // match (entries OR together; `none` is the internal "does not deliver"
329
- // floor, never an authored rung).
330
- /** The doc's delivery rung for a read of `absReadFile` (already realpathed). */
331
- export function readDeliveryRung(doc, subject, absReadFile, readFrontmatter) {
332
- let r = 'none';
333
- for (const e of doc.surfaces) {
334
- if (!matchesReadEntry(e, doc, subject, absReadFile, readFrontmatter))
335
- continue;
336
- if (rungRank(e.at) > rungRank(r))
337
- r = e.at;
326
+ /** Does ONE entry of `event` fit this occurrence — constraints and the entry's
327
+ * own gate together? */
328
+ export function surfaceEntryParticipates(entry, doc, subject, event, payload) {
329
+ if (entry.on !== event)
330
+ return false;
331
+ switch (event) {
332
+ // Presence of the entry is the match; callers scope the corpus.
333
+ case 'boot':
334
+ case 'workspace-open':
335
+ return surfaceEntryGatePasses(entry, subject);
336
+ case 'read':
337
+ if (payload.absoluteFile === undefined || doc.path === undefined || doc.scope === undefined)
338
+ return false;
339
+ return matchesReadEntry(entry, { path: doc.path, scope: doc.scope }, subject, payload.absoluteFile, payload.readFrontmatter ?? {});
340
+ case 'memory-read':
341
+ if (payload.resolvedCanonicalName === undefined)
342
+ return false;
343
+ return matchesMemoryReadEntry(entry, payload.routingAnchor ?? '', subject, payload.resolvedCanonicalName);
344
+ case 'command':
345
+ return payload.command !== undefined && matchesCommandEntry(entry, subject, payload.command);
346
+ case 'pre-command':
347
+ return payload.command !== undefined && matchesPreCommandEntry(entry, subject, payload.command);
338
348
  }
339
- return r;
340
349
  }
341
- /** The doc's workspace-open rung. Presence of an entry is the match; callers
342
- * scope the corpus to project stores mounted by the subject's cwd/profile. */
343
- export function workspaceOpenRung(doc, subject) {
344
- let r = 'none';
345
- for (const e of doc.surfaces) {
346
- if (e.on !== 'workspace-open' || !surfaceEntryGatePasses(e, subject))
350
+ /** The entry that decides this doc's delivery for one event occurrence: the
351
+ * highest-rung participating entry, or null when none participates. */
352
+ export function matchingSurfaceEntry(doc, subject, event, payload) {
353
+ let best = null;
354
+ for (const entry of doc.surfaces) {
355
+ if (!surfaceEntryParticipates(entry, doc, subject, event, payload))
347
356
  continue;
348
- if (rungRank(e.at) > rungRank(r))
349
- r = e.at;
357
+ if (best === null || rungRank(entry.at) > rungRank(best.at))
358
+ best = entry;
350
359
  }
351
- return r;
360
+ return best;
361
+ }
362
+ /** The doc's delivery rung for a read of `absReadFile` (already realpathed). */
363
+ export function readDeliveryRung(doc, subject, absReadFile, readFrontmatter) {
364
+ return matchingSurfaceEntry(doc, subject, 'read', { absoluteFile: absReadFile, readFrontmatter })?.at ?? 'none';
365
+ }
366
+ /** The doc's workspace-open rung. */
367
+ export function workspaceOpenRung(doc, subject) {
368
+ return matchingSurfaceEntry(doc, subject, 'workspace-open', {})?.at ?? 'none';
352
369
  }
353
370
  /** The doc's delivery rung for a `crtr memory read` that resolved a doc known
354
- * by its exact canonical `name` — no mount-stripped or other alias.
355
- * `routingAnchor` comes from the doc's resolver descriptor — it is not
356
- * derivable from the canonical name, which cannot say whether the doc fronts
357
- * its own directory. */
371
+ * by its exact canonical `name` — no mount-stripped or other alias. */
358
372
  export function memoryReadDeliveryRung(doc, routingAnchor, subject, name) {
359
- let r = 'none';
360
- for (const e of doc.surfaces) {
361
- if (!matchesMemoryReadEntry(e, routingAnchor, subject, name))
362
- continue;
363
- if (rungRank(e.at) > rungRank(r))
364
- r = e.at;
365
- }
366
- return r;
373
+ return matchingSurfaceEntry(doc, subject, 'memory-read', { routingAnchor, resolvedCanonicalName: name })?.at ?? 'none';
367
374
  }
368
375
  /** The doc's delivery rung for an executed command string. */
369
376
  export function commandDeliveryRung(doc, subject, command) {
370
- let r = 'none';
371
- for (const e of doc.surfaces) {
372
- if (!matchesCommandEntry(e, subject, command))
373
- continue;
374
- if (rungRank(e.at) > rungRank(r))
375
- r = e.at;
376
- }
377
- return r;
377
+ return matchingSurfaceEntry(doc, subject, 'command', { command })?.at ?? 'none';
378
378
  }
379
- /** The doc's delivery rung for a command about to run — the highest `at`
380
- * over the doc's matching `pre-command` entries, folded the same way as every
381
- * other event. */
379
+ /** The doc's delivery rung for a command about to run. */
382
380
  export function preCommandDeliveryRung(doc, subject, command) {
383
- let r = 'none';
384
- for (const e of doc.surfaces) {
385
- if (!matchesPreCommandEntry(e, subject, command))
386
- continue;
387
- if (rungRank(e.at) > rungRank(r))
388
- r = e.at;
389
- }
390
- return r;
381
+ return matchingSurfaceEntry(doc, subject, 'pre-command', { command })?.at ?? 'none';
391
382
  }
@@ -10,6 +10,12 @@ export interface TuiPageContext {
10
10
  terminal: ProcessTerminal;
11
11
  bindings: BindingResolution<BindingId>;
12
12
  rawUserConfig: ReturnType<typeof readRawScopeConfig>;
13
+ /**
14
+ * Hand the terminal to a child process ($EDITOR) and return the closure that
15
+ * takes it back with a full repaint. Callers must invoke the closure in a
16
+ * `finally` block. Same contract as the attach viewer's `SurfaceHost`.
17
+ */
18
+ suspendForChild(): () => void;
13
19
  }
14
20
  export interface TuiPageControl<Result> {
15
21
  /** Close the page with a result. */
@@ -2,8 +2,9 @@
2
2
  // `crtr sys settings`, the setup wizard, and `crtr pkg browse`.
3
3
  //
4
4
  // It owns exactly the concerns every page shares: theme, resolved keybindings,
5
- // the raw user config those pages edit, the ProcessTerminal/TUI pair, and a
6
- // restore path that survives an exception, a signal, and process exit. Anything
5
+ // the raw user config those pages edit, the ProcessTerminal/TUI pair, the
6
+ // release and reclaim of that terminal for a child process, and a restore path
7
+ // that survives an exception, a signal, and process exit. Anything
7
8
  // a single page needs beyond that — pi's model runtime, a canvas client — the
8
9
  // page's own runner builds and closes over before calling in here.
9
10
  import { ProcessTerminal, TuiMainScreen, } from '@earendil-works/pi-tui';
@@ -40,8 +41,16 @@ export async function runTuiPage(createSurface, interceptCancel) {
40
41
  restore();
41
42
  resolve(result);
42
43
  };
44
+ const suspendForChild = () => {
45
+ tui.stop();
46
+ return () => {
47
+ tui.start();
48
+ tui.invalidate();
49
+ tui.requestRender(true);
50
+ };
51
+ };
43
52
  try {
44
- surface = createSurface({ tui, terminal, bindings, rawUserConfig }, { submit: finish, cancel: () => finish(undefined) });
53
+ surface = createSurface({ tui, terminal, bindings, rawUserConfig, suspendForChild }, { submit: finish, cancel: () => finish(undefined) });
45
54
  tui.addChild(surface);
46
55
  tui.setFocus(surface);
47
56
  process.once("exit", restore);
@@ -31,6 +31,14 @@ export interface CloseManagedWorktreeResult {
31
31
  branch_delete_error?: string;
32
32
  }
33
33
  export declare function closeManagedWorktree(nodeId: string): CloseManagedWorktreeResult;
34
+ export interface AbandonManagedWorktreeResult {
35
+ node_id: string;
36
+ branch: string;
37
+ tip_sha: string;
38
+ worktree_path: string;
39
+ branch_deleted: boolean;
40
+ }
41
+ export declare function abandonManagedWorktree(nodeId: string, by: string): AbandonManagedWorktreeResult;
34
42
  /** Recovery guidance for a vanished checkout whose refs do not prove that its
35
43
  * work is delivered. This deliberately never recommends `worktree close`:
36
44
  * that command cannot operate on the absent checkout. */
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { gitSync } from './git.js';
5
5
  import { crtrHome } from './canvas/paths.js';
6
+ import { isPidAlive } from './canvas/pid.js';
6
7
  import { getNode, updateNode } from './canvas/index.js';
7
8
  import { CONFIG_FILE, CRTR_DIR_NAME, STATE_FILE } from '../types.js';
8
9
  import { withExclusiveDirectoryLock } from './exclusive-lock.js';
@@ -357,6 +358,70 @@ export function closeManagedWorktree(nodeId) {
357
358
  return closeManagedWorktreeLocked(nodeId);
358
359
  return withRepositoryWorktreeLock(wt.repo_root, () => closeManagedWorktreeLocked(nodeId));
359
360
  }
361
+ const ABANDON_AUTHOR_NAME = 'crtr-abandon';
362
+ const ABANDON_AUTHOR_EMAIL = 'crtr-abandon@localhost';
363
+ const ABANDON_SUBJECT = 'crtr-abandon';
364
+ function persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by) {
365
+ updateNode(nodeId, {
366
+ cwd: wt.repo_root,
367
+ managed_worktree: {
368
+ ...wt,
369
+ state: 'abandoned',
370
+ abandoned: { branch: wt.branch, tip_sha: tipSha, at, by },
371
+ },
372
+ });
373
+ }
374
+ function createAbandonMarker(wt, parentSha, nodeId, at) {
375
+ const treeSha = runGit(wt.repo_root, ['rev-parse', `${parentSha}^{tree}`], 'abandon_tree_failed', 'Inspect the branch manually, then retry.');
376
+ 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.');
377
+ 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.');
378
+ return markerSha;
379
+ }
380
+ function abandonManagedWorktreeLocked(nodeId, by) {
381
+ const node = getNode(nodeId);
382
+ if (node === null) {
383
+ throw new WorktreeError('unknown_node', `unknown node: ${nodeId}`, 'Pass a real node id.');
384
+ }
385
+ const wt = openManagedWorktreeForNode(nodeId);
386
+ if (wt === null) {
387
+ throw new WorktreeError('no_managed_worktree', `node ${nodeId} has no open managed worktree`, 'Nothing to abandon for this node.');
388
+ }
389
+ if (node.status === 'active' || node.status === 'idle' || isPidAlive(node.pi_pid)) {
390
+ throw new WorktreeError('node_running', `node ${nodeId} is still running`, 'Stop the node before abandoning its managed worktree.');
391
+ }
392
+ if (!existsSync(wt.path)) {
393
+ throw new WorktreeError('worktree_missing', `managed worktree path is missing: ${wt.path}`, missingWorktreeRecoveryHint(wt));
394
+ }
395
+ if (currentBranch(wt.path) !== wt.branch) {
396
+ throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch})`, `Check out ${wt.branch} in ${wt.path}, then rerun \`crtr node worktree abandon\`.`);
397
+ }
398
+ const at = new Date().toISOString();
399
+ const clean = proveCleanEmptyManagedWorktree(wt);
400
+ if (clean !== null && clean.branchSha === wt.base_sha) {
401
+ const markerSha = createAbandonMarker(wt, clean.branchSha, nodeId, at);
402
+ runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
403
+ const deleted = bestEffortDeleteBranchWithContainmentProof(wt, markerSha, `refs/heads/${wt.base_ref}`, clean.baseSha);
404
+ if (deleted.branchDeleted) {
405
+ persistClosedWorktreeState(nodeId, wt, 'complete');
406
+ return { node_id: nodeId, branch: wt.branch, tip_sha: markerSha, worktree_path: wt.path, branch_deleted: true };
407
+ }
408
+ const tipSha = runGit(wt.repo_root, ['rev-parse', `refs/heads/${wt.branch}`], 'abandon_tip_failed', 'Inspect the branch manually, then retry.');
409
+ persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by);
410
+ return { node_id: nodeId, branch: wt.branch, tip_sha: tipSha, worktree_path: wt.path, branch_deleted: false };
411
+ }
412
+ runGit(wt.path, ['add', '--all'], 'abandon_stage_failed', 'Inspect the worktree manually, then retry.');
413
+ 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.');
414
+ runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
415
+ const tipSha = runGit(wt.repo_root, ['rev-parse', `refs/heads/${wt.branch}`], 'abandon_tip_failed', 'Inspect the branch manually, then retry.');
416
+ persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by);
417
+ return { node_id: nodeId, branch: wt.branch, tip_sha: tipSha, worktree_path: wt.path, branch_deleted: false };
418
+ }
419
+ export function abandonManagedWorktree(nodeId, by) {
420
+ const wt = openManagedWorktreeForNode(nodeId);
421
+ if (wt === null)
422
+ return abandonManagedWorktreeLocked(nodeId, by);
423
+ return withRepositoryWorktreeLock(wt.repo_root, () => abandonManagedWorktreeLocked(nodeId, by));
424
+ }
360
425
  // Push-final auto-drop of a clean, zero-commit managed worktree
361
426
  /** Delete a source branch only while the ref that proved it already delivered
362
427
  * is still at the exact inspected SHA. `containingRef` can be the recorded
@@ -9,7 +9,8 @@
9
9
  import { test } from 'node:test';
10
10
  import assert from 'node:assert/strict';
11
11
  import { BrokerSupervisionReconciler } from '../reconcilers/broker-supervision.js';
12
- import { ControllerDeathReconciler, DormantInboxReconciler } from '../reconcilers/dormant-inbox.js';
12
+ import { ControllerDeathReconciler } from '../reconcilers/controller-death.js';
13
+ import { NodeLifecycleReconciler } from '../reconcilers/node-lifecycle/tick.js';
13
14
  import { CronLaneReconciler } from '../reconcilers/cron-lane.js';
14
15
  import { HumanDeliveryLaneReconciler } from '../reconcilers/human-delivery-lane.js';
15
16
  import { PendingReviewSubmitReconciler } from '../reconcilers/pending-review-submit.js';
@@ -18,7 +19,7 @@ import { StorageMaintenanceReconciler } from '../reconcilers/storage-maintenance
18
19
  const RECONCILERS = [
19
20
  ['BrokerSupervisionReconciler', BrokerSupervisionReconciler],
20
21
  ['ControllerDeathReconciler', ControllerDeathReconciler],
21
- ['DormantInboxReconciler', DormantInboxReconciler],
22
+ ['NodeLifecycleReconciler', NodeLifecycleReconciler],
22
23
  ['CronLaneReconciler', CronLaneReconciler],
23
24
  ['HumanDeliveryLaneReconciler', HumanDeliveryLaneReconciler],
24
25
  ['PendingReviewSubmitReconciler', PendingReviewSubmitReconciler],
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,55 @@
1
+ // Run with: node --conditions=crtr-src --import tsx/esm --test src/daemon/__tests__/thaw-order.test.ts
2
+ //
3
+ // Thaw ordering IS the freeze lane's allocation policy: the lane enacts
4
+ // sequentially on the daemon's serial lane, so whichever candidate it reaches
5
+ // first takes the free slot. `thawTier` + `compareThaw` are that whole policy,
6
+ // and both are pure — this file pins them directly so a re-stalling node cannot
7
+ // silently starve the mail queued behind it.
8
+ import { test } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { compareThaw, thawTier } from '../reconcilers/node-lifecycle/freeze-lane.js';
11
+ function row(over = {}) {
12
+ return {
13
+ node_id: over.node_id ?? 'N',
14
+ name: 'n',
15
+ kind: 'developer',
16
+ mode: 'base',
17
+ lifecycle: 'terminal',
18
+ status: 'active',
19
+ cwd: '/tmp',
20
+ host_kind: 'broker',
21
+ profile_id: null,
22
+ parent: null,
23
+ created: '2026-01-01T00:00:00.000Z',
24
+ intent: null,
25
+ waiting_for: null,
26
+ pi_pid: null,
27
+ pi_pid_identity: null,
28
+ window: null,
29
+ tmux_session: null,
30
+ pane: null,
31
+ ...over,
32
+ };
33
+ }
34
+ test('a node that spent a slot and settled nothing drops behind the mail it stalled', () => {
35
+ assert.equal(thawTier(row(), false), 1, 'a never-launched row has spent nothing');
36
+ assert.equal(thawTier(row({ launched_at: '2026-01-01T00:00:10.000Z', last_settled_at: '2026-01-01T00:00:20.000Z' }), false), 1, 'a launch that settled a turn earned its place at the front');
37
+ assert.equal(thawTier(row({ launched_at: '2026-01-01T00:00:10.000Z' }), false), 2, 're-stall demotion: a slot was spent and nothing was settled with it');
38
+ assert.equal(thawTier(row({ launched_at: '2026-01-01T00:00:20.000Z', last_settled_at: '2026-01-01T00:00:10.000Z' }), false), 2, 'a settlement from BEFORE the launch does not count for that launch');
39
+ assert.equal(thawTier(row(), true), 2, 'a row waiting on mail competes in tier 2 regardless of its history');
40
+ });
41
+ test('thaw order is tier first, then longest-frozen first, with the unfrozen last', () => {
42
+ const entry = (node_id, tier, frozen_at) => ({
43
+ row: row({ node_id, ...(frozen_at !== undefined ? { frozen_at } : {}) }),
44
+ enact: 'policy',
45
+ tier,
46
+ });
47
+ const entries = [
48
+ entry('never-frozen-stalled', 1),
49
+ entry('mail-old', 2, '2026-01-01T00:00:01.000Z'),
50
+ entry('stalled-new', 1, '2026-01-01T00:00:09.000Z'),
51
+ entry('stalled-old', 1, '2026-01-01T00:00:02.000Z'),
52
+ ];
53
+ entries.sort(compareThaw);
54
+ assert.deepEqual(entries.map((e) => e.row.node_id), ['stalled-old', 'stalled-new', 'never-frozen-stalled', 'mail-old'], 'tier 1 takes every free slot before tier 1 exhausts, oldest wait first; a row that has not waited sorts last in its tier');
55
+ });
@@ -8,6 +8,8 @@ import { createNode, getNode } from '../../../core/canvas/canvas.js';
8
8
  import { contextDir, jobDir, viewSocketPath } from '../../../core/canvas/paths.js';
9
9
  import { recordFault } from '../../../core/runtime/fault.js';
10
10
  import { headlessBrokerHost } from '../../../core/runtime/host.js';
11
+ import { resetFleetForTests } from '../../../core/runtime/fleet.js';
12
+ import { bindTestFleet } from '../../../core/__tests__/helpers/fleet.js';
11
13
  import { handleCreate } from '../handlers/nodes.js';
12
14
  import { canvasRoutes } from '../handlers/canvas.js';
13
15
  let home = '';
@@ -18,6 +20,9 @@ let originalLaunch;
18
20
  before(() => {
19
21
  home = mkdtempSync('/tmp/crtr-snapshot-fields-');
20
22
  process.env['CRTR_HOME'] = home;
23
+ // Every launcher claims a slot from the ledger before it reaches the host, so
24
+ // a spawn in-process needs a bound fleet (D-2).
25
+ bindTestFleet();
21
26
  originalLaunch = headlessBrokerHost.launch;
22
27
  headlessBrokerHost.launch = (nodeId) => {
23
28
  const broker = createServer();
@@ -29,6 +34,7 @@ before(() => {
29
34
  });
30
35
  after(async () => {
31
36
  headlessBrokerHost.launch = originalLaunch;
37
+ resetFleetForTests();
32
38
  await Promise.all(brokers.map((broker) => new Promise((resolve) => {
33
39
  if (broker.listening)
34
40
  broker.close(() => resolve());
@@ -7,6 +7,8 @@ import { closeDb } from '../../../core/canvas/db.js';
7
7
  import { getNode } from '../../../core/canvas/canvas.js';
8
8
  import { nodeDir } from '../../../core/canvas/paths.js';
9
9
  import { headlessBrokerHost } from '../../../core/runtime/host.js';
10
+ import { resetFleetForTests } from '../../../core/runtime/fleet.js';
11
+ import { bindTestFleet } from '../../../core/__tests__/helpers/fleet.js';
10
12
  import { handleCreate, nodeRoutes } from '../handlers/nodes.js';
11
13
  import { brokerOperationRoutes } from '../handlers/broker-ops.js';
12
14
  let home = '';
@@ -18,6 +20,9 @@ before(() => {
18
20
  // macOS Unix-domain sockets have a short path limit; keep the canvas home short.
19
21
  home = mkdtempSync('/tmp/crtr-node-description-');
20
22
  process.env['CRTR_HOME'] = home;
23
+ // Every launcher claims a slot from the ledger before it reaches the host, so
24
+ // a spawn in-process needs a bound fleet (D-2).
25
+ bindTestFleet();
21
26
  originalLaunch = headlessBrokerHost.launch;
22
27
  headlessBrokerHost.launch = (nodeId) => {
23
28
  const broker = createServer();
@@ -28,6 +33,7 @@ before(() => {
28
33
  });
29
34
  after(async () => {
30
35
  headlessBrokerHost.launch = originalLaunch;
36
+ resetFleetForTests();
31
37
  await Promise.all(brokers.map((broker) => new Promise((resolve) => {
32
38
  if (broker.listening)
33
39
  broker.close(() => resolve());
@@ -78,7 +78,7 @@ test('revive refuses a paused profile before launching a broker', () => {
78
78
  parent: null,
79
79
  profile_id: profile.profileId,
80
80
  });
81
- assert.throws(() => reviveNode(nodeId, { resume: true }), (error) => {
81
+ assert.throws(() => reviveNode(nodeId, { resume: true, capacity: 'freeze' }), (error) => {
82
82
  assert.ok(error instanceof CrtrError);
83
83
  assert.equal(error.code, 'usage');
84
84
  assert.match(error.details?.['next'], new RegExp(`crtr profile resume ${profile.profileId}`));
@@ -359,7 +359,11 @@ export async function handleAttachUpgrade(req, socket, head, nodeId) {
359
359
  // pass only `{ resume }`.
360
360
  let launchExited;
361
361
  try {
362
- const result = reviveNode(nodeId, { resume: true });
362
+ // The bridge upgrade waits on this node's view socket, which only a
363
+ // live broker publishes: at the cap, refuse the upgrade with the real
364
+ // reason instead of freezing and then timing out on a socket that was
365
+ // never going to appear.
366
+ const result = reviveNode(nodeId, { resume: true, capacity: 'refuse' });
363
367
  launchExited = result.launch?.exited;
364
368
  }
365
369
  catch (err) {
@@ -36,7 +36,11 @@ async function handleAttach(ctx) {
36
36
  // reviveNode is the ONLY sanctioned broker launcher; a double-revive of a
37
37
  // live node is an idempotent no-op (no `launch` handle) — `revived` reflects
38
38
  // whether a fresh engine actually started.
39
- const result = reviveNode(id, { resume });
39
+ // An attach has a viewer waiting on a socket that only a live broker
40
+ // publishes, so there is nothing to wait for it with: refuse at the cap and
41
+ // let the caller see why, rather than freezing and returning a `revived`
42
+ // that never becomes true.
43
+ const result = reviveNode(id, { resume, capacity: 'refuse' });
40
44
  revived = result.launch !== undefined;
41
45
  resumed = result.resumed;
42
46
  // If we launched, let the broker publish its view socket so the local
@@ -77,7 +77,7 @@ async function handleStop(ctx) {
77
77
  const node = getNode(nodeId);
78
78
  if (node !== null && !isBrokerLive(node)) {
79
79
  try {
80
- reviveNode(nodeId, { resume: true });
80
+ reviveNode(nodeId, { resume: true, capacity: 'freeze' });
81
81
  }
82
82
  catch { /* the notice remains durable */ }
83
83
  }
@@ -221,7 +221,9 @@ async function handleMessage(ctx) {
221
221
  appendSituationalContext(id, sidecar);
222
222
  if (req.reopen)
223
223
  commitReopen(id, expectedFinalReport);
224
- const result = reviveNode(id, { resume: false });
224
+ // A fresh revive appends no inbox entry, so nothing survives a freeze for
225
+ // the daemon to act on later: at the cap the caller is told, not ignored.
226
+ const result = reviveNode(id, { resume: false, capacity: 'refuse' });
225
227
  const body = {
226
228
  node_id: id,
227
229
  delivered: false,
@@ -324,10 +326,15 @@ async function handleMessage(ctx) {
324
326
  // A wake-capable tier revives a dormant target so its inbox-watcher delivers
325
327
  // this; deferred never wakes — it rides the target's next natural cycle.
326
328
  let revived = false;
329
+ let frozen = false;
327
330
  if (tier !== 'deferred' && !paused && !isBrokerLive(meta)) {
328
331
  try {
329
- const result = reviveNode(id, { resume: true });
332
+ // The entry is already durable, so a wake denied for capacity is only
333
+ // delayed: the row freezes and the tick relaunches it — at which point
334
+ // the inbox watcher delivers this entry as it always would.
335
+ const result = reviveNode(id, { resume: true, capacity: 'freeze' });
330
336
  revived = result.launch !== undefined;
337
+ frozen = result.outcome === 'frozen';
331
338
  }
332
339
  catch {
333
340
  /* best-effort wake — the entry is durably appended regardless */
@@ -337,6 +344,7 @@ async function handleMessage(ctx) {
337
344
  node_id: id,
338
345
  delivered: true,
339
346
  revived,
347
+ ...(frozen ? { not_revived_reason: 'capacity_frozen' } : {}),
340
348
  delivered_at: entry.ts,
341
349
  ...(req.delivery === 'interactive' ? { delivered_via: 'inbox' } : {}),
342
350
  };
@@ -108,9 +108,6 @@ function parseCreateBody(body) {
108
108
  rootLifecycle !== 'terminal') {
109
109
  throw usage("root_lifecycle must be 'resident' or 'terminal'");
110
110
  }
111
- if (rootLifecycle !== undefined && b['root'] !== true) {
112
- throw usage('root_lifecycle is only valid with root: true');
113
- }
114
111
  return b;
115
112
  }
116
113
  /** Build the immediate spawn recipe. */
@@ -490,6 +487,9 @@ function handleRevive(ctx) {
490
487
  const cron = !resume && body?.cron_id !== undefined ? getCron(body.cron_id) : null;
491
488
  const result = reviveNode(id, {
492
489
  resume,
490
+ // An explicit revive is a caller asking for this node NOW; at the cap it
491
+ // gets the refusal naming it rather than a silent nothing-happened.
492
+ capacity: 'refuse',
493
493
  ...(cron !== null ? { wakeReason: cronWakeOrigin(cron, 'schedule_revive') } : {}),
494
494
  });
495
495
  const status = (getNode(id)?.status ?? 'active');
@@ -603,7 +603,11 @@ function handleWait(ctx) {
603
603
  }
604
604
  function handleReviveAll() {
605
605
  const result = reviveAll();
606
- const body = { revived: [...result.revived], failed: [...result.failed] };
606
+ const body = {
607
+ revived: [...result.revived],
608
+ failed: [...result.failed],
609
+ frozen: [...result.frozen],
610
+ };
607
611
  return { status: 200, body };
608
612
  }
609
613
  // PATCH /v1/nodes/{id}/config — model / situational-context (spec §6.2)
@@ -12,13 +12,22 @@
12
12
  // all of that onto `CrtrError` for every route that can raise it — so the CLI
13
13
  // leaf can reconstruct the original agent-facing contract instead of collapsing
14
14
  // to a generic status code.
15
- import { closeManagedWorktree } from '../../../core/worktree.js';
15
+ import { abandonManagedWorktree, closeManagedWorktree } from '../../../core/worktree.js';
16
+ import { usage } from '../../../core/errors.js';
16
17
  function handleWorktreeClose(ctx) {
17
18
  const id = ctx.params['id'];
18
19
  // No requireMeta: closeManagedWorktree already throws the precise
19
20
  // `unknown_node` / `no_managed_worktree` WorktreeError for a bad id.
20
21
  return { status: 200, body: closeManagedWorktree(id) };
21
22
  }
23
+ function handleWorktreeAbandon(ctx) {
24
+ const rawBy = ctx.body?.['by'];
25
+ const by = typeof rawBy === 'string' ? rawBy.trim() : '';
26
+ if (by === '')
27
+ throw usage('abandon requires a non-empty invoking identity', { received: ctx.body, next: 'Pass the invoking node id or human identity.' });
28
+ return { status: 200, body: abandonManagedWorktree(ctx.params['id'], by) };
29
+ }
22
30
  export const worktreeRoutes = [
23
31
  { method: 'POST', pattern: '/v1/nodes/:id/worktree/close', handler: handleWorktreeClose },
32
+ { method: 'POST', pattern: '/v1/nodes/:id/worktree/abandon', handler: handleWorktreeAbandon },
24
33
  ];
@@ -38,6 +38,8 @@ export function toNodeSummaryDTO(row) {
38
38
  created: row.created,
39
39
  intent: row.intent,
40
40
  waiting_for: row.waiting_for,
41
+ frozen_at: row.frozen_at ?? null,
42
+ terminal_reason: row.terminal_reason ?? null,
41
43
  pi_pid: row.pi_pid,
42
44
  pi_pid_identity: row.pi_pid_identity,
43
45
  window: row.window,
@@ -85,6 +87,8 @@ export function toNodeWorktreeDTO(w) {
85
87
  dto.cleanup = w.cleanup;
86
88
  if (w.closed !== undefined)
87
89
  dto.closed = w.closed;
90
+ if (w.abandoned !== undefined)
91
+ dto.abandoned = { ...w.abandoned };
88
92
  return dto;
89
93
  }
90
94
  /** Project the hydrated node view (`getNode` → `NodeMeta`) into a detail DTO.
@@ -106,6 +110,8 @@ export function toNodeDetailDTO(meta, edges) {
106
110
  created: meta.created,
107
111
  intent: (meta.intent ?? null),
108
112
  waiting_for: meta.waiting_for ?? null,
113
+ frozen_at: meta.frozen_at ?? null,
114
+ terminal_reason: meta.terminal_reason ?? null,
109
115
  pi_pid: meta.pi_pid ?? null,
110
116
  pi_pid_identity: meta.pi_pid_identity ?? null,
111
117
  window: meta.window ?? null,
@@ -154,6 +160,7 @@ export function toReviveResultDTO(nodeId, result, status) {
154
160
  revived: result.launch !== undefined,
155
161
  resumed: result.resumed,
156
162
  status,
163
+ outcome: result.outcome,
157
164
  };
158
165
  }
159
166
  /** Project a `closeNode` cascade result. Field names align 1:1. */
@@ -29,7 +29,7 @@ export async function retireCompanion(nodeId, outcome) {
29
29
  catch { /* the canonical final is authoritative */ }
30
30
  return result;
31
31
  }
32
- transition(nodeId, 'cancel');
32
+ transition(nodeId, 'cancel', { reason: 'retired' });
33
33
  if (node.status === 'active' || node.status === 'idle') {
34
34
  try {
35
35
  headlessBrokerHost.teardown(nodeId);
@@ -1,11 +1,6 @@
1
- import { type Cron } from '../../core/canvas/crons.js';
2
1
  export interface RunDueCronOptions {
3
2
  /** Registers settlement work for graceful-teardown draining. */
4
3
  register?: (work: Promise<void>) => void;
5
- /** Node-launching runs need capacity; absent means all runs are admitted. */
6
- hasBrokerCapacity?: () => boolean;
7
4
  }
8
- export declare function cronLaunchesNode(c: Cron): boolean;
9
5
  export declare function expireDueCrons(nowIso: string): void;
10
6
  export declare function applyOverlapPolicies(now: number, nowIso: string): void;
11
- export declare function emitCapacityDeferral(now: number, deferred: number): void;
@@ -3,17 +3,9 @@ import { cronDaemonInstanceId, liveRunFor } from './live-runs.js';
3
3
  import { advanceCron, cancelCron, dueOverlapCrons, expiredIdleCrons, listCronRuns, recordCronRun, } from '../../core/canvas/crons.js';
4
4
  import { emitEvent } from '../../core/events/emit.js';
5
5
  import { nextSlotAfter } from '../../core/wake.js';
6
- import { parseSink } from '../cron-sink.js';
7
6
  /** Lead of a `cron_runs.delivered` string written by the overlap=skip pass.
8
7
  * Matched (not just written) — see `latestRunIsSkip`. */
9
8
  const SKIP_MARKER = 'skipped (overlap=skip)';
10
- // A false negative is an ungated run; a false positive delays one poll at capacity.
11
- const NODE_LAUNCHING_COMMAND = /\bcrtr\b[^\n]*?\bnode\s+lifecycle\s+revive\b|\bcrtr\b[^\n]*?\bnode\s+new\b|\bcrtr\b[^\n]*?\bcanvas\s+revive\b|\bcrtr\b[^\n]*?\bnode\s+message\s+send\b[^\n]*?\s--fresh\b/;
12
- export function cronLaunchesNode(c) {
13
- if (parseSink(c.sink)?.kind === 'spawn')
14
- return true;
15
- return NODE_LAUNCHING_COMMAND.test(c.command);
16
- }
17
9
  export function expireDueCrons(nowIso) {
18
10
  for (const c of expiredIdleCrons(nowIso)) {
19
11
  cancelCron(c.cron_id);
@@ -64,15 +56,3 @@ export function applyOverlapPolicies(now, nowIso) {
64
56
  }
65
57
  }
66
58
  }
67
- const CAPACITY_DEFER_LOG_THROTTLE_MS = 30_000;
68
- let lastCapacityDeferLogAt = Number.NEGATIVE_INFINITY;
69
- export function emitCapacityDeferral(now, deferred) {
70
- if (now - lastCapacityDeferLogAt < CAPACITY_DEFER_LOG_THROTTLE_MS)
71
- return;
72
- lastCapacityDeferLogAt = now;
73
- emitEvent({
74
- level: 'warn',
75
- event: 'cron.capacity.run_deferred',
76
- fields: { deferred_runs: deferred },
77
- });
78
- }
@@ -33,7 +33,7 @@ function deliverNodeSink(c, target, stdout) {
33
33
  // The durable append succeeds even if its best-effort wake fails.
34
34
  if (c.tier !== 'deferred' && !isBrokerLive(meta)) {
35
35
  try {
36
- reviveNode(target, { resume: true });
36
+ reviveNode(target, { resume: true, capacity: 'freeze' });
37
37
  }
38
38
  catch {
39
39
  /* best-effort wake — the entry is durably appended regardless */