@north-light/crouter 0.3.249 → 0.3.251

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 (175) 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 +100 -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 +20 -2
  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/held-deferred-human-prompt.test.d.ts +1 -0
  63. package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +63 -0
  64. package/dist/core/__tests__/seam/self-close-teardown.test.d.ts +1 -0
  65. package/dist/core/__tests__/seam/self-close-teardown.test.js +49 -0
  66. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +18 -10
  67. package/dist/core/canvas/canvas.d.ts +9 -15
  68. package/dist/core/canvas/canvas.js +41 -47
  69. package/dist/core/canvas/crons.d.ts +5 -8
  70. package/dist/core/canvas/crons.js +5 -8
  71. package/dist/core/canvas/history.d.ts +5 -0
  72. package/dist/core/canvas/history.js +12 -1
  73. package/dist/core/canvas/migrations.js +58 -1
  74. package/dist/core/canvas/pid.d.ts +1 -1
  75. package/dist/core/canvas/pid.js +5 -2
  76. package/dist/core/canvas/types.d.ts +4 -0
  77. package/dist/core/feed/feed.d.ts +4 -0
  78. package/dist/core/feed/feed.js +13 -8
  79. package/dist/core/feed/inbox.d.ts +2 -1
  80. package/dist/core/feed/inbox.js +22 -3
  81. package/dist/core/git.d.ts +3 -0
  82. package/dist/core/git.js +3 -1
  83. package/dist/core/human/feedback-companion.js +1 -0
  84. package/dist/core/preview-registry.js +0 -1
  85. package/dist/core/review/companion.js +1 -0
  86. package/dist/core/review/realize.js +1 -0
  87. package/dist/core/runtime/broker/auth-reload.d.ts +2 -0
  88. package/dist/core/runtime/broker/auth-reload.js +9 -3
  89. package/dist/core/runtime/broker/engine-drive.d.ts +2 -0
  90. package/dist/core/runtime/broker/engine-drive.js +39 -23
  91. package/dist/core/runtime/broker/event-projection.d.ts +4 -0
  92. package/dist/core/runtime/broker/event-projection.js +43 -23
  93. package/dist/core/runtime/broker/fault-retry.d.ts +9 -0
  94. package/dist/core/runtime/broker/fault-retry.js +94 -8
  95. package/dist/core/runtime/broker/frame-dispatch.d.ts +2 -0
  96. package/dist/core/runtime/broker/frame-dispatch.js +4 -1
  97. package/dist/core/runtime/broker/held-deferred-inbox.d.ts +9 -0
  98. package/dist/core/runtime/broker/held-deferred-inbox.js +8 -0
  99. package/dist/core/runtime/broker/inbox.js +4 -0
  100. package/dist/core/runtime/broker/rebind.js +18 -0
  101. package/dist/core/runtime/broker/turn-admission.d.ts +11 -0
  102. package/dist/core/runtime/broker/turn-admission.js +31 -0
  103. package/dist/core/runtime/broker/turn-ignition.d.ts +2 -0
  104. package/dist/core/runtime/broker/turn-ignition.js +25 -15
  105. package/dist/core/runtime/broker.js +21 -2
  106. package/dist/core/runtime/close.d.ts +11 -9
  107. package/dist/core/runtime/close.js +23 -19
  108. package/dist/core/runtime/fault.d.ts +21 -0
  109. package/dist/core/runtime/fault.js +129 -19
  110. package/dist/core/runtime/fleet.d.ts +0 -6
  111. package/dist/core/runtime/host.d.ts +7 -1
  112. package/dist/core/runtime/host.js +20 -2
  113. package/dist/core/runtime/nodes.d.ts +5 -1
  114. package/dist/core/runtime/nodes.js +24 -1
  115. package/dist/core/runtime/placement.d.ts +6 -6
  116. package/dist/core/runtime/placement.js +15 -8
  117. package/dist/core/runtime/reset.js +2 -2
  118. package/dist/core/runtime/revive.js +40 -4
  119. package/dist/core/runtime/spawn.d.ts +3 -0
  120. package/dist/core/runtime/spawn.js +1 -0
  121. package/dist/core/runtime/warm-pool.d.ts +4 -0
  122. package/dist/core/runtime/warm-pool.js +2 -0
  123. package/dist/core/worktree.d.ts +4 -3
  124. package/dist/core/worktree.js +73 -27
  125. package/dist/daemon/__tests__/integration/api-startup-readiness.test.d.ts +1 -0
  126. package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +81 -0
  127. package/dist/daemon/api/__tests__/reopen-delivery.test.js +38 -0
  128. package/dist/daemon/api/__tests__/seam/api-server.test.js +1 -0
  129. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +13 -2
  130. package/dist/daemon/api/handlers/bash-jobs.js +2 -0
  131. package/dist/daemon/api/handlers/broker-ops.js +23 -2
  132. package/dist/daemon/api/handlers/human.js +1 -1
  133. package/dist/daemon/api/handlers/messages.js +11 -2
  134. package/dist/daemon/api/handlers/nodes.js +13 -1
  135. package/dist/daemon/api/handlers/reports.js +14 -6
  136. package/dist/daemon/api/map.js +1 -0
  137. package/dist/daemon/api/server.d.ts +9 -12
  138. package/dist/daemon/api/server.js +30 -19
  139. package/dist/daemon/companion-retire.js +3 -9
  140. package/dist/daemon/cron/sinks.js +7 -2
  141. package/dist/daemon/crtrd.js +4 -1
  142. package/dist/daemon/fleet.d.ts +2 -16
  143. package/dist/daemon/fleet.js +63 -150
  144. package/dist/daemon/human/finish.js +2 -0
  145. package/dist/daemon/manage.d.ts +11 -0
  146. package/dist/daemon/manage.js +32 -3
  147. package/dist/daemon/messaging/node-message.js +7 -1
  148. package/dist/daemon/reconcilers/bash-deadline.js +2 -0
  149. package/dist/daemon/reconcilers/broker-supervision.d.ts +1 -0
  150. package/dist/daemon/reconcilers/broker-supervision.js +22 -9
  151. package/dist/daemon/reconcilers/live-obligation.d.ts +16 -6
  152. package/dist/daemon/reconcilers/live-obligation.js +18 -6
  153. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +15 -4
  154. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +34 -3
  155. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -1
  156. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +11 -2
  157. package/dist/daemon/reconcilers/node-lifecycle/tick.js +14 -7
  158. package/dist/daemon/reconcilers/storage-maintenance.d.ts +2 -2
  159. package/dist/daemon/reconcilers/storage-maintenance.js +3 -3
  160. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +4 -4
  161. package/dist/pi-extensions/canvas-context-intro.d.ts +3 -34
  162. package/dist/pi-extensions/canvas-context-intro.js +4 -76
  163. package/dist/pi-extensions/canvas-inbox-watcher.js +105 -4
  164. package/dist/pi-extensions/canvas-review-boundary.d.ts +3 -24
  165. package/dist/pi-extensions/canvas-review-boundary.js +4 -29
  166. package/dist/pi-extensions/canvas-stophook.js +1 -6
  167. package/dist/shared/birth-announcement.d.ts +12 -0
  168. package/dist/shared/birth-announcement.js +25 -0
  169. package/dist/shared/generated-context.d.ts +8 -4
  170. package/dist/shared/generated-context.js +15 -9
  171. package/package.json +4 -4
  172. package/runtime.lock.json +2 -2
  173. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +0 -254
  174. package/dist/pi-extensions/truncate.d.ts +0 -5
  175. package/dist/pi-extensions/truncate.js +0 -14
@@ -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 {
@@ -1,7 +1,8 @@
1
1
  // Broker-originated canvas operations. The broker sends Pi lifecycle facts;
2
2
  // crtrd validates and commits their durable effects while the handler stays
3
3
  // strictly canvas-write-only — no broker launch, kill, or view.sock round trip.
4
- import { canonicalSessionFile, findNodeBySessionFile, getNode, isWarmSpare, listNodes, recordPid, setPresence, subscriptionsOf, updateNode } from '../../../core/canvas/canvas.js';
4
+ import { canonicalSessionFile, findNodeBySessionFile, getNode, isWarmSpare, listNodes, recordPid, recordSettledTurn, setPresence, subscriptionsOf, updateNode } from '../../../core/canvas/canvas.js';
5
+ import { isPidAlive } from '../../../core/canvas/pid.js';
5
6
  import { editorLabel } from '../../../core/canvas/labels.js';
6
7
  import { assembleNodeSubject } from '../../../core/substrate/subject.js';
7
8
  import { withCanvasWrite } from '../../../core/canvas/db.js';
@@ -13,7 +14,7 @@ import { focusOf, tearDownNode } from '../../../core/runtime/placement.js';
13
14
  import { hasAttachedBrokerViewers } from '../../../core/runtime/broker/client-registry.js';
14
15
  import { handleNewSession } from '../../../core/runtime/reset.js';
15
16
  import { evaluateStop } from '../../../core/runtime/stop-guard.js';
16
- import { clearFault } from '../../../core/runtime/fault.js';
17
+ import { clearFault, recordFault } from '../../../core/runtime/fault.js';
17
18
  import { updateModelRecipe } from '../../../core/runtime/model-swap.js';
18
19
  import { completeCompanionRequestedSubmit } from '../../review/finish.js';
19
20
  import { consumePendingPark } from '../../park-pending.js';
@@ -187,6 +188,25 @@ function handleSessionBound(ctx) {
187
188
  const sessionFile = request.sessionFile === null ? null : canonicalSessionFile(request.sessionFile);
188
189
  const result = withCanvasWrite(() => {
189
190
  const before = requireNode(nodeId);
191
+ // A transcript is one broker's identity. Check ownership before every
192
+ // session-bound mutation: a live owner wins, and the claimant must stop
193
+ // rather than overwrite its own process-scoped identity onto that file.
194
+ const owner = sessionFile === null ? null : findNodeBySessionFile(sessionFile);
195
+ if (owner !== null && owner.node_id !== nodeId) {
196
+ if (isPidAlive(owner.pi_pid)) {
197
+ recordFault(nodeId, {
198
+ link: 'daemon→node',
199
+ op: 'session-file-ownership',
200
+ kind: 'protocol',
201
+ retry: { disposition: 'fatal' },
202
+ message: `refusing to bind ${sessionFile}: it is owned by live node ${owner.node_id}`,
203
+ });
204
+ return { action: 'shutdown' };
205
+ }
206
+ // The prior owner is dead, so transfer the durable coordinate before the
207
+ // claimant writes it; the UNIQUE projection and both meta files agree.
208
+ updateNode(owner.node_id, { pi_session_id: null, pi_session_file: null });
209
+ }
190
210
  // The broker reports the markers present in Pi's current branch; crtrd owns
191
211
  // the review binding and decides whether that branch belongs to this node.
192
212
  const reviewId = before.review_binding?.review_id;
@@ -247,6 +267,7 @@ function settleBroker(nodeId, request) {
247
267
  return { action: 'stay_dormant' };
248
268
  throw notFound(`unknown node: ${nodeId}`, { received: nodeId });
249
269
  }
270
+ recordSettledTurn(nodeId, new Date().toISOString());
250
271
  if (request.stopReason === 'quit') {
251
272
  if ((node.status === 'active' || node.status === 'idle') && node.intent === null) {
252
273
  transition(nodeId, 'finish');
@@ -37,7 +37,7 @@ function handleCreateBridge(ctx) {
37
37
  throw new ApiError(400, 'invalid_request', 'bridge `parent` must be a node id or null');
38
38
  }
39
39
  try {
40
- const node = spawnNode({ kind: 'human', parent, cwd, name, lifecycle: 'terminal' });
40
+ const node = spawnNode({ kind: 'human', parent, creator: parent, cwd, name, lifecycle: 'terminal' });
41
41
  return { status: 200, body: { node_id: node.node_id, name: node.name } };
42
42
  }
43
43
  catch (error) {
@@ -8,6 +8,7 @@ import { isBrokerLive } from '../../../core/runtime/model-swap.js';
8
8
  import { deliverLive, interruptLive } from '../../../core/runtime/interactive-deliver.js';
9
9
  import { isProfilePaused } from '../../../core/profiles/manifest.js';
10
10
  import { getNode, withFreshTerminalGuard } from '../../../core/canvas/canvas.js';
11
+ import { cancelCronsOnWake } from '../../../core/canvas/crons.js';
11
12
  import { assertNotFinalized, assertFinalizedForReopen, commitReopen, commitReopenResident, } from '../../../core/runtime/reopen.js';
12
13
  import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
13
14
  import { appendSituationalContext, formatSituationalProse } from '../../../core/runtime/situational-context.js';
@@ -249,6 +250,7 @@ async function handleMessage(ctx) {
249
250
  if (hasSidecar)
250
251
  appendSituationalContext(id, sidecar);
251
252
  await deliverLive(id, req.body, [...(hasSidecar ? [sidecar] : []), ...oneShotCards]);
253
+ cancelCronsOnWake(id);
252
254
  const body = {
253
255
  node_id: id,
254
256
  delivered: true,
@@ -323,11 +325,17 @@ async function handleMessage(ctx) {
323
325
  return deliverImmediate();
324
326
  };
325
327
  const entry = deliver();
328
+ // Deferred mail normally waits for a natural cycle, but a frozen row already
329
+ // owes a thaw; consume its deadline when that durable future wake arrives.
330
+ if (tier !== 'deferred' || meta.frozen_at !== null)
331
+ cancelCronsOnWake(id);
326
332
  // A wake-capable tier revives a dormant target so its inbox-watcher delivers
327
- // this; deferred never wakes — it rides the target's next natural cycle.
333
+ // this; deferred never wakes — it rides the target's next natural cycle. The
334
+ // APPENDED entry's tier decides, because a terminal target's deferred was
335
+ // already raised to a wake-capable tier by the append.
328
336
  let revived = false;
329
337
  let frozen = false;
330
- if (tier !== 'deferred' && !paused && !isBrokerLive(meta)) {
338
+ if (entry.tier !== 'deferred' && !paused && !isBrokerLive(meta)) {
331
339
  try {
332
340
  // The entry is already durable, so a wake denied for capacity is only
333
341
  // delayed: the row freezes and the tick relaunches it — at which point
@@ -346,6 +354,7 @@ async function handleMessage(ctx) {
346
354
  revived,
347
355
  ...(frozen ? { not_revived_reason: 'capacity_frozen' } : {}),
348
356
  delivered_at: entry.ts,
357
+ ...(entry.tier === tier ? {} : { delivered_tier: entry.tier }),
349
358
  ...(req.delivery === 'interactive' ? { delivered_via: 'inbox' } : {}),
350
359
  };
351
360
  return { status: 201, body };
@@ -102,6 +102,9 @@ function parseCreateBody(body) {
102
102
  if (b['description'] !== undefined && (typeof b['description'] !== 'string' || b['description'].trim() === '')) {
103
103
  throw usage('description must be a non-empty string when provided');
104
104
  }
105
+ if (b['creator'] !== undefined && (typeof b['creator'] !== 'string' || b['creator'].trim() === '')) {
106
+ throw usage('creator must be a non-empty node id when provided');
107
+ }
105
108
  const rootLifecycle = b['root_lifecycle'];
106
109
  if (rootLifecycle !== undefined &&
107
110
  rootLifecycle !== 'resident' &&
@@ -125,6 +128,8 @@ function buildRecipe(req, parent, cwd) {
125
128
  recipe.description = req.description;
126
129
  if (parent !== undefined)
127
130
  recipe.parent = parent;
131
+ if (req.creator !== undefined)
132
+ recipe.creator = req.creator;
128
133
  if (req.root === true)
129
134
  recipe.root = true;
130
135
  if (req.root_lifecycle !== undefined) {
@@ -174,6 +179,7 @@ function warmRequestFor(req, cwd, profileId) {
174
179
  model: req.model ?? null,
175
180
  situationalContext: req.situational_context ?? null,
176
181
  name: req.name ?? null,
182
+ creator: req.creator ?? null,
177
183
  };
178
184
  }
179
185
  export { resolveCreateKind } from '../../../core/runtime/launch-target.js';
@@ -508,7 +514,13 @@ function handleClose(ctx) {
508
514
  // `finish` selects the root disposition: finalize to `done` (browse `x`) vs the
509
515
  // default cancel.
510
516
  const body = ctx.body;
511
- const result = closeNode(id, { rootEvent: body?.finish === true ? 'finish' : 'cancel' });
517
+ if (body?.caller_pid !== undefined && (!Number.isInteger(body.caller_pid) || body.caller_pid <= 0)) {
518
+ throw usage('caller_pid must be a positive integer when provided');
519
+ }
520
+ const result = closeNode(id, {
521
+ rootEvent: body?.finish === true ? 'finish' : 'cancel',
522
+ callerPid: body?.caller_pid,
523
+ });
512
524
  return { status: 200, body: toCloseResultDTO(result) };
513
525
  }
514
526
  async function handleRecycle(ctx) {
@@ -1,6 +1,7 @@
1
1
  // Report handlers (spec §6.4, §7.4). `POST /nodes/{id}/reports` is the single
2
- // push route — the tier is a body field; `final` drives the server-side
3
- // lifecycle transition inside feed `push()` (the sanctioned path). `GET
2
+ // push route — `tier` is the report kind and update-only `delivery_tier` sets
3
+ // subscriber delivery urgency; `final` drives the server-side lifecycle
4
+ // transition inside feed `push()` (the sanctioned path). `GET
4
5
  // /nodes/{id}/reports` lists the node's stored report files.
5
6
  //
6
7
  // `{id}` is the REPORTING node (client-side = CRTR_NODE_ID).
@@ -24,14 +25,21 @@ function parsePushBody(body) {
24
25
  throw usage('a push report requires a JSON body { tier, body }');
25
26
  }
26
27
  const tier = body.tier;
28
+ const deliveryTier = body.delivery_tier;
27
29
  const text = body.body;
28
30
  if (tier !== 'update' && tier !== 'urgent' && tier !== 'final') {
29
31
  throw usage(`invalid report tier: ${String(tier)} (expected update|urgent|final)`);
30
32
  }
33
+ if (deliveryTier !== undefined && deliveryTier !== 'deferred' && deliveryTier !== 'normal' && deliveryTier !== 'urgent') {
34
+ throw usage(`invalid delivery_tier: ${String(deliveryTier)} (expected deferred|normal|urgent)`);
35
+ }
36
+ if (tier === 'final' && deliveryTier !== undefined) {
37
+ throw usage('delivery_tier is only valid for update reports');
38
+ }
31
39
  if (typeof text !== 'string' || text.trim() === '') {
32
40
  throw usage('a push report requires a non-empty body');
33
41
  }
34
- return { tier, body: text };
42
+ return { tier, ...(deliveryTier === undefined ? {} : { delivery_tier: deliveryTier }), body: text };
35
43
  }
36
44
  async function handlePush(ctx) {
37
45
  const id = ctx.params['id'];
@@ -68,7 +76,7 @@ async function handlePush(ctx) {
68
76
  }
69
77
  autoDroppedPath = dropped.worktree_path;
70
78
  }
71
- const result = await pushOrRethrow(id, req.tier, req.body);
79
+ const result = await pushOrRethrow(id, req);
72
80
  const transitioned = req.tier === 'final' && before !== undefined
73
81
  ? { from: before, to: getNode(id)?.status ?? 'done' }
74
82
  : undefined;
@@ -83,9 +91,9 @@ async function handlePush(ctx) {
83
91
  * survives (a bare 409 would otherwise collapse to the generic `conflict`
84
92
  * code). `unknown_node` is unreachable here (`requireNode` guards above), so
85
93
  * it falls through as the generic 404. */
86
- async function pushOrRethrow(id, tier, body) {
94
+ async function pushOrRethrow(id, req) {
87
95
  try {
88
- return await push(id, { kind: tier, body });
96
+ return await push(id, { kind: req.tier, body: req.body, ...(req.delivery_tier === undefined ? {} : { deliveryTier: req.delivery_tier }) });
89
97
  }
90
98
  catch (err) {
91
99
  if (err instanceof FinalizationError && err.code === 'already_finalized') {
@@ -119,6 +119,7 @@ export function toNodeDetailDTO(meta, edges) {
119
119
  pane: meta.pane ?? null,
120
120
  final_report: meta.final_report ?? null,
121
121
  finalized_at: meta.finalized_at ?? null,
122
+ creator: meta.creator ?? null,
122
123
  edges: toNodeEdgesDTO(meta, edges),
123
124
  paths: toNodePathsDTO(meta.node_id),
124
125
  };
@@ -4,20 +4,17 @@ export interface CreateApiServerOptions {
4
4
  tcp?: string;
5
5
  }
6
6
  export interface ApiServer {
7
+ /** Settles only after the mandatory Unix socket binds; rejects on its bind failure. */
8
+ ready: Promise<void>;
7
9
  /** Tear down both listeners and unlink the socket file. Double-close safe. */
8
10
  close(): Promise<void>;
9
11
  }
10
- /** Create and start the crtrd API server (A-9). Returns immediately with a
11
- * `close()` handle; the listeners bind asynchronously (a unix socket's file is
12
- * created on `listen`, so its 0600 chmod happens in the listen callback). A
13
- * bind failure or a post-listen accept error is emitted and swallowed — it must
14
- * NEVER become an unhandled 'error' that crashes the daemon.
12
+ /** Create and start the crtrd API server. Returns immediately with a `close()`
13
+ * handle and a `ready` promise for the mandatory Unix socket bind. TCP remains
14
+ * opt-in, and its bind or post-listen accept errors are emitted but non-fatal.
15
15
  *
16
- * NOTE (correction to the plan's "one http.Server bound twice"): a Node
17
- * `net.Server`/`http.Server` binds exactly ONE handle — a second `.listen()`
18
- * on the same instance throws `ERR_SERVER_ALREADY_LISTEN`. So the two listeners
19
- * are two `http.Server` instances SHARING the same request + upgrade handler
20
- * functions (the standard way to bind one logical API to two transports). The
21
- * Router and attach bridge are process-wide singletons, so both servers route
22
- * into identical state. */
16
+ * A Node `net.Server`/`http.Server` binds exactly ONE handle, so the two
17
+ * listeners are separate `http.Server` instances sharing request and upgrade
18
+ * handlers. The Router and attach bridge are process-wide singletons, so both
19
+ * servers route into identical state. */
23
20
  export declare function createApiServer(opts?: CreateApiServerOptions): ApiServer;