@north-light/crouter 0.3.230 → 0.3.232

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 (173) hide show
  1. package/dist/api/client.d.ts +3 -1
  2. package/dist/api/client.js +4 -0
  3. package/dist/api/dto/canvas.d.ts +10 -0
  4. package/dist/api/dto/common.d.ts +1 -1
  5. package/dist/api/dto/health.d.ts +2 -1
  6. package/dist/api/dto/lifecycle.d.ts +3 -4
  7. package/dist/api/dto/messages.d.ts +5 -4
  8. package/dist/api/dto/nodes.d.ts +2 -0
  9. package/dist/api/dto/profiles.d.ts +5 -0
  10. package/dist/api/routes.d.ts +1 -0
  11. package/dist/api/routes.js +1 -0
  12. package/dist/builtin-memory/00-runtime-base/00-authoring.md +8 -0
  13. package/dist/builtin-memory/00-runtime-base/01-escalation.md +1 -1
  14. package/dist/builtin-memory/01-spine/00-has-manager.md +1 -1
  15. package/dist/builtin-memory/02-turn-lifecycle/00-ending-a-turn.md +5 -0
  16. package/dist/builtin-memory/02-turn-lifecycle/02-resident.md +5 -3
  17. package/dist/builtin-memory/04-orchestration-kernel.md +2 -2
  18. package/dist/builtin-memory/insights/capture.md +3 -2
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +6 -1
  20. package/dist/clients/attach/render/diagram.js +13 -5
  21. package/dist/clients/attach/render/page-block.d.ts +0 -1
  22. package/dist/clients/attach/render/page-block.js +4 -57
  23. package/dist/clients/attach/viewer.js +570 -563
  24. package/dist/clients/inbox/__tests__/integration/inbox-controller.test.js +9 -0
  25. package/dist/clients/inbox/__tests__/integration/mount-panel.test.js +62 -1
  26. package/dist/clients/inbox/controller.d.ts +10 -0
  27. package/dist/clients/inbox/controller.js +56 -12
  28. package/dist/clients/inbox/tui/input.js +38 -10
  29. package/dist/clients/inbox/tui/page-body.d.ts +10 -0
  30. package/dist/clients/inbox/tui/page-body.js +66 -0
  31. package/dist/clients/inbox/tui/panel.js +6 -4
  32. package/dist/clients/inbox/tui/render.js +89 -17
  33. package/dist/clients/inbox/tui/types.d.ts +4 -4
  34. package/dist/commands/__tests__/human.test.js +18 -3
  35. package/dist/commands/__tests__/node-message.test.js +3 -3
  36. package/dist/commands/api-client.js +1 -7
  37. package/dist/commands/canvas-config.js +6 -14
  38. package/dist/commands/canvas-use.js +4 -6
  39. package/dist/commands/cron.js +16 -22
  40. package/dist/commands/human/prompts.d.ts +1 -1
  41. package/dist/commands/human/prompts.js +118 -112
  42. package/dist/commands/human/request.js +13 -14
  43. package/dist/commands/human/review.js +3 -4
  44. package/dist/commands/human/shared.d.ts +6 -0
  45. package/dist/commands/human/shared.js +43 -5
  46. package/dist/commands/human.js +1 -1
  47. package/dist/commands/memory/delete.js +4 -6
  48. package/dist/commands/memory/edit.js +0 -4
  49. package/dist/commands/memory/move.js +3 -5
  50. package/dist/commands/memory/shared.d.ts +1 -1
  51. package/dist/commands/memory/shared.js +9 -5
  52. package/dist/commands/memory/write.js +60 -29
  53. package/dist/commands/memory.js +1 -1
  54. package/dist/commands/node/bash.js +6 -9
  55. package/dist/commands/node/create.js +89 -22
  56. package/dist/commands/node/inspect.js +3 -3
  57. package/dist/commands/node/lifecycle.js +30 -27
  58. package/dist/commands/node/message.js +24 -45
  59. package/dist/commands/node/subscription.js +6 -15
  60. package/dist/commands/node/wait.js +2 -3
  61. package/dist/commands/node-lifecycle-revive.js +1 -12
  62. package/dist/commands/pkg/browse/actions.js +2 -3
  63. package/dist/commands/pkg/market-manage.js +2 -5
  64. package/dist/commands/pkg/plugin-manage.js +7 -8
  65. package/dist/commands/profile/default.js +5 -5
  66. package/dist/commands/profile/delete.js +1 -1
  67. package/dist/commands/profile/env.js +9 -15
  68. package/dist/commands/profile/kind.js +3 -7
  69. package/dist/commands/profile/meta.js +3 -5
  70. package/dist/commands/profile/new.js +0 -6
  71. package/dist/commands/profile/pause.js +4 -8
  72. package/dist/commands/profile/project.js +5 -9
  73. package/dist/commands/profile/rename.js +3 -7
  74. package/dist/commands/profile/show.js +3 -3
  75. package/dist/commands/profile.js +4 -3
  76. package/dist/commands/surface-tmux-spread.js +1 -3
  77. package/dist/commands/sys/config.js +3 -4
  78. package/dist/commands/sys/support/prepare.js +8 -4
  79. package/dist/commands/sys/support/submit.js +2 -3
  80. package/dist/commands/sys/sync-deps.js +1 -9
  81. package/dist/commands/sys/sync-project-guidance.js +1 -7
  82. package/dist/commands/sys/sync-skills.js +1 -11
  83. package/dist/core/__tests__/cron-node-sink-parked-root.test.d.ts +1 -0
  84. package/dist/core/__tests__/cron-node-sink-parked-root.test.js +147 -0
  85. package/dist/core/__tests__/history-inbox.test.js +11 -1
  86. package/dist/core/__tests__/human-deliver.test.js +2 -1
  87. package/dist/core/__tests__/integration/command-plugins.test.js +0 -1
  88. package/dist/core/__tests__/integration/deferred-no-wake.test.js +0 -1
  89. package/dist/core/__tests__/lifecycle.test.js +30 -2
  90. package/dist/core/__tests__/revive-parked-fresh.test.d.ts +1 -0
  91. package/dist/core/__tests__/revive-parked-fresh.test.js +109 -0
  92. package/dist/core/__tests__/seam/dormancy-release.test.js +32 -5
  93. package/dist/core/canvas/attention.d.ts +2 -0
  94. package/dist/core/canvas/attention.js +25 -18
  95. package/dist/core/canvas/extensions.d.ts +1 -1
  96. package/dist/core/canvas/extensions.js +7 -1
  97. package/dist/core/canvas/history.js +20 -2
  98. package/dist/core/canvas/types.d.ts +1 -1
  99. package/dist/core/command.js +33 -8
  100. package/dist/core/help.d.ts +28 -2
  101. package/dist/core/help.js +46 -10
  102. package/dist/core/human/__tests__/page-html-markdown.test.d.ts +1 -0
  103. package/dist/core/human/__tests__/page-html-markdown.test.js +48 -0
  104. package/dist/core/human/component-docs.js +4 -4
  105. package/dist/core/human/page-html-markdown.d.ts +8 -0
  106. package/dist/core/human/page-html-markdown.js +260 -0
  107. package/dist/core/memory/lint.d.ts +15 -0
  108. package/dist/core/memory/lint.js +150 -90
  109. package/dist/core/profiles/__tests__/fuzzy-match.test.d.ts +1 -0
  110. package/dist/core/profiles/__tests__/fuzzy-match.test.js +51 -0
  111. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  112. package/dist/core/profiles/fuzzy-match.js +92 -0
  113. package/dist/core/profiles/manifest.d.ts +14 -7
  114. package/dist/core/profiles/manifest.js +62 -12
  115. package/dist/core/profiles/select.d.ts +3 -1
  116. package/dist/core/profiles/select.js +5 -3
  117. package/dist/core/profiles/state-block.js +4 -3
  118. package/dist/core/runtime/boot-root.d.ts +3 -2
  119. package/dist/core/runtime/canvas-extensions.d.ts +7 -1
  120. package/dist/core/runtime/canvas-extensions.js +8 -1
  121. package/dist/core/runtime/lifecycle.d.ts +11 -2
  122. package/dist/core/runtime/lifecycle.js +15 -2
  123. package/dist/core/runtime/model-selection.d.ts +4 -0
  124. package/dist/core/runtime/model-selection.js +5 -0
  125. package/dist/core/runtime/nodes.js +5 -0
  126. package/dist/core/runtime/reopen.d.ts +6 -0
  127. package/dist/core/runtime/reopen.js +12 -1
  128. package/dist/core/runtime/revive.d.ts +6 -0
  129. package/dist/core/runtime/revive.js +22 -2
  130. package/dist/core/runtime/spawn.d.ts +5 -2
  131. package/dist/core/runtime/spawn.js +18 -32
  132. package/dist/core/runtime/structured-output.d.ts +6 -0
  133. package/dist/core/runtime/structured-output.js +6 -0
  134. package/dist/core/substrate/__tests__/surface-match-command.test.d.ts +1 -0
  135. package/dist/core/substrate/__tests__/surface-match-command.test.js +89 -0
  136. package/dist/core/substrate/on-read.js +2 -1
  137. package/dist/core/substrate/surface-match.js +164 -12
  138. package/dist/core/termrender/version.d.ts +1 -1
  139. package/dist/core/termrender/version.js +1 -1
  140. package/dist/core/user-settings.js +1 -1
  141. package/dist/daemon/api/__tests__/broker-settle-park.test.d.ts +1 -0
  142. package/dist/daemon/api/__tests__/broker-settle-park.test.js +102 -0
  143. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.d.ts +1 -0
  144. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +188 -0
  145. package/dist/daemon/api/__tests__/node-create-description.test.d.ts +1 -0
  146. package/dist/daemon/api/__tests__/node-create-description.test.js +83 -0
  147. package/dist/daemon/api/__tests__/profile-metadata-route.test.d.ts +1 -0
  148. package/dist/daemon/api/__tests__/profile-metadata-route.test.js +92 -0
  149. package/dist/daemon/api/__tests__/reopen-delivery.test.d.ts +1 -0
  150. package/dist/daemon/api/__tests__/reopen-delivery.test.js +173 -0
  151. package/dist/daemon/api/handlers/broker-ops.js +21 -0
  152. package/dist/daemon/api/handlers/canvas.js +10 -0
  153. package/dist/daemon/api/handlers/messages.js +25 -16
  154. package/dist/daemon/api/handlers/nodes.js +5 -0
  155. package/dist/daemon/api/handlers/profiles.js +22 -1
  156. package/dist/daemon/cron-run.js +19 -1
  157. package/dist/daemon/manage.d.ts +16 -1
  158. package/dist/daemon/manage.js +20 -1
  159. package/dist/daemon/park-pending.d.ts +13 -0
  160. package/dist/daemon/park-pending.js +42 -0
  161. package/dist/daemon/reconcilers/broker-supervision.d.ts +13 -0
  162. package/dist/daemon/reconcilers/broker-supervision.js +139 -21
  163. package/dist/daemon/reconcilers/live-obligation.d.ts +10 -4
  164. package/dist/daemon/reconcilers/live-obligation.js +7 -3
  165. package/dist/daemon/reconcilers/storage-maintenance.d.ts +0 -4
  166. package/dist/daemon/reconcilers/storage-maintenance.js +1 -33
  167. package/dist/pi-extensions/canvas-prompt-scrub.d.ts +13 -0
  168. package/dist/pi-extensions/canvas-prompt-scrub.js +53 -0
  169. package/dist/shared/generated-context.d.ts +7 -0
  170. package/dist/shared/generated-context.js +11 -0
  171. package/package.json +4 -4
  172. package/runtime.lock.json +2 -2
  173. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +0 -47
@@ -8,13 +8,12 @@ import { deliverLive, interruptLive } from '../../../core/runtime/interactive-de
8
8
  import { cancelPendingHumanMessages } from '../../../core/feed/inbox.js';
9
9
  import { isProfilePaused } from '../../../core/profiles/manifest.js';
10
10
  import { withFreshTerminalGuard } from '../../../core/canvas/canvas.js';
11
- import { assertNotFinalized, assertFinalizedForReopen, commitReopen } from '../../../core/runtime/reopen.js';
11
+ import { assertNotFinalized, assertFinalizedForReopen, commitReopen, commitReopenResident, } from '../../../core/runtime/reopen.js';
12
12
  import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
13
13
  import { appendSituationalContext, formatSituationalProse } from '../../../core/runtime/situational-context.js';
14
14
  import { formatDataCard } from '../../../shared/generated-context.js';
15
15
  import { hasNoNaturalCycle } from '../../../core/runtime/revive-all.js';
16
- import { readGoal } from '../../../core/runtime/kickoff.js';
17
- import { readRoadmap } from '../../../core/runtime/roadmap.js';
16
+ import { hasFreshGroundState } from '../../../core/runtime/revive.js';
18
17
  import { notFound, usage } from '../../../core/errors.js';
19
18
  import { InputError } from '../../../core/io.js';
20
19
  import { ApiError } from '../../../api/index.js';
@@ -137,8 +136,8 @@ function parseSendBody(body) {
137
136
  // Runtime cards ARE accepted here: a card-bearing send is an ordinary human
138
137
  // send that happens to carry context, and the live deliver frame places the
139
138
  // cards ahead of the body in one turn (D11).
140
- if (fresh || out.reopen === true || out.situational_context !== undefined || out.output_schema !== undefined) {
141
- throw usage('interactive delivery supports a body and runtime cards only (no fresh, reopen, situational_context, or output_schema)');
139
+ if (fresh || out.situational_context !== undefined || out.output_schema !== undefined) {
140
+ throw usage('interactive delivery supports a body, runtime cards, and reopen only (no fresh, situational_context, or output_schema)');
142
141
  }
143
142
  if (!hasBody)
144
143
  throw usage('interactive delivery requires a non-empty body');
@@ -179,11 +178,7 @@ function deferredNoNaturalCycle(id, status) {
179
178
  * would resume amnesiac. Rejected fail-loud (422) BEFORE any revive/arm — the
180
179
  * CLI's old pure pre-check, relocated behind the API so `--fresh` parity holds. */
181
180
  function assertRecoverableForFresh(id) {
182
- const goal = readGoal(id);
183
- if (goal !== null && goal.trim() !== '')
184
- return;
185
- const roadmap = readRoadmap(id);
186
- if (roadmap !== null && roadmap.trim() !== '')
181
+ if (hasFreshGroundState(id))
187
182
  return;
188
183
  // ApiError (not a plain literal) so its message survives `toErrorBody`.
189
184
  throw new ApiError(422, 'unprocessable', `node ${id} has no goal/roadmap on disk — a fresh revive would resume amnesiac.`);
@@ -236,6 +231,13 @@ async function handleMessage(ctx) {
236
231
  };
237
232
  return { status: 201, body };
238
233
  }
234
+ // An interactive reopen commits before even choosing its engine-or-inbox
235
+ // route. A live transport race then falls back with this same committed state.
236
+ let reopenCommitted = false;
237
+ if (req.delivery === 'interactive' && req.reopen) {
238
+ commitReopenResident(id);
239
+ reopenCommitted = true;
240
+ }
239
241
  // --- Interactive delivery: route via the LIVE broker's serialized frame
240
242
  // loop (broker routes idle→prompt / streaming→steer itself) instead of
241
243
  // the durable inbox — no watcher latency, and send/stop share one
@@ -261,19 +263,19 @@ async function handleMessage(ctx) {
261
263
  // fall through to the durable inbox path
262
264
  }
263
265
  }
264
- // --- Immediate delivery (spec §7.7) — full CLI `deliverImmediate` parity:
265
- // reopen latch clear, one-off output-schema grant, situational sidecar,
266
- // then the single inbox append (body / one-off request / ambient-only). ---
266
+ // --- Immediate delivery (spec §7.7) — pre-delivery side effects first,
267
+ // then the reopen commit, then exactly one inbox append. Once the commit
268
+ // succeeds, an append failure makes no further state change. ---
267
269
  const deliverImmediate = () => {
268
- const expectedFinalReport = req.reopen ? assertFinalizedForReopen(id) : null;
269
270
  if (!req.reopen)
270
271
  assertNotFinalized(id);
271
272
  if (schema !== null)
272
273
  writeOutputSchema(id, 'oneoff', schema);
273
274
  if (hasSidecar)
274
275
  appendSituationalContext(id, sidecar);
275
- if (req.reopen)
276
- commitReopen(id, expectedFinalReport);
276
+ if (req.reopen && !reopenCommitted) {
277
+ commitReopenResident(id);
278
+ }
277
279
  const cards = oneShotCards.length > 0 ? { cards: oneShotCards } : {};
278
280
  if (hasBody || schema !== null) {
279
281
  const messageBody = hasBody
@@ -302,6 +304,13 @@ async function handleMessage(ctx) {
302
304
  // cycle ahead of it, so a deferred entry would sit unseen forever — reject
303
305
  // fail-loud BEFORE any side effect, atomically with the append (the read and
304
306
  // the write share one canvas write-lock). Non-deferred tiers deliver directly.
307
+ //
308
+ // `--reopen` does NOT exempt a target from this guard. It commits the target
309
+ // resident and retracts the finalization latch, but it never moves `status`
310
+ // out of `done` — so a reopened target is still one `hasNoNaturalCycle` says
311
+ // nothing will ever bring back on its own, and a deferred entry appended to it
312
+ // would wait forever. Reopen widens the WAKE-CAPABLE tiers to a finalized
313
+ // target; deferred stays rejected, and the rejection names those tiers.
305
314
  let entry;
306
315
  if (tier === 'deferred') {
307
316
  const outcome = withFreshTerminalGuard(id, hasNoNaturalCycle, deliverImmediate);
@@ -88,6 +88,9 @@ function parseCreateBody(body) {
88
88
  if (b['kind'] !== undefined && (typeof b['kind'] !== 'string' || b['kind'].trim() === '')) {
89
89
  throw usage('kind must be a non-empty string when provided');
90
90
  }
91
+ if (b['description'] !== undefined && (typeof b['description'] !== 'string' || b['description'].trim() === '')) {
92
+ throw usage('description must be a non-empty string when provided');
93
+ }
91
94
  const rootLifecycle = b['root_lifecycle'];
92
95
  if (rootLifecycle !== undefined &&
93
96
  rootLifecycle !== 'resident' &&
@@ -110,6 +113,8 @@ function buildRecipe(req, parent, cwd) {
110
113
  recipe.mode = req.mode;
111
114
  if (req.name !== undefined)
112
115
  recipe.name = req.name;
116
+ if (req.description !== undefined)
117
+ recipe.description = req.description;
113
118
  if (parent !== undefined)
114
119
  recipe.parent = parent;
115
120
  if (req.root === true)
@@ -7,7 +7,8 @@
7
7
  // GET /v1/profiles/:name resolve one profile by name or id
8
8
  // DELETE /v1/profiles/:name required-force cascade or detach
9
9
  import { assertInstalledKind } from '../../../core/config.js';
10
- import { assertProfileProjects, createProfile, ensureRootProfile, listProfiles, loadProfileManifest, } from '../../../core/profiles/manifest.js';
10
+ import { usage } from '../../../core/errors.js';
11
+ import { assertProfileProjects, createProfile, ensureRootProfile, listProfiles, loadProfileManifest, updateProfileMetadata, } from '../../../core/profiles/manifest.js';
11
12
  import { requestProfileDeletion } from '../../profile-delete.js';
12
13
  import { toProfileDTO } from '../map.js';
13
14
  const ROOT_PROFILE_NAME = 'root';
@@ -42,6 +43,25 @@ function handleEnsure(ctx) {
42
43
  });
43
44
  return { status: 200, body: toProfileDTO(created) };
44
45
  }
46
+ /** `PATCH /v1/profiles/:name/metadata` — merge and remove metadata entries
47
+ * on an existing profile. Resolution accepts the profile id or unique name,
48
+ * matching the read and delete routes; this is deliberately separate from the
49
+ * create-only ensure route. */
50
+ function handleSetMetadata(ctx) {
51
+ const profile = loadProfileManifest(ctx.params['name']);
52
+ const raw = ctx.body ?? {};
53
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
54
+ throw usage('profile metadata body must be an object', { field: 'body' });
55
+ }
56
+ const req = raw;
57
+ const unset = req.unset === undefined ? [] : req.unset;
58
+ if (!Array.isArray(unset) || unset.some((key) => typeof key !== 'string')) {
59
+ throw usage('profile metadata `unset` must be an array of strings', { field: 'unset' });
60
+ }
61
+ const set = req.set === undefined ? {} : req.set;
62
+ const updated = updateProfileMetadata(profile.profileId, set, unset);
63
+ return { status: 200, body: toProfileDTO(updated) };
64
+ }
45
65
  /** `GET /v1/profiles` — every profile with a readable manifest. */
46
66
  function handleList() {
47
67
  return { status: 200, body: listProfiles().map(toProfileDTO) };
@@ -61,6 +81,7 @@ async function handleDelete(ctx) {
61
81
  }
62
82
  export const profileRoutes = [
63
83
  { method: 'PUT', pattern: '/v1/profiles/:name', handler: handleEnsure },
84
+ { method: 'PATCH', pattern: '/v1/profiles/:name/metadata', handler: handleSetMetadata },
64
85
  { method: 'DELETE', pattern: '/v1/profiles/:name', handler: handleDelete },
65
86
  { method: 'GET', pattern: '/v1/profiles', handler: handleList },
66
87
  { method: 'GET', pattern: '/v1/profiles/:name', handler: handleGet },
@@ -44,6 +44,9 @@ import { appendInbox } from '../core/feed/inbox.js';
44
44
  import { emitEvent } from '../core/events/emit.js';
45
45
  import { loadProfileManifest } from '../core/profiles/manifest.js';
46
46
  import { hasNoNaturalCycle } from '../core/runtime/revive-all.js';
47
+ import { isParked } from '../core/runtime/lifecycle.js';
48
+ import { reviveNode } from '../core/runtime/revive.js';
49
+ import { isBrokerLive } from '../core/runtime/model-swap.js';
47
50
  import { spawnChild } from '../core/runtime/spawn.js';
48
51
  import { cronWakeOrigin } from '../core/runtime/bearings.js';
49
52
  import { nextSlotAfter, cadenceDisplay } from '../core/wake.js';
@@ -181,7 +184,12 @@ function deliverNodeSink(c, target, stdout) {
181
184
  const meta = getNode(target);
182
185
  if (meta === null)
183
186
  throw new Error(`node sink ${target} is gone`);
184
- if (hasNoNaturalCycle(meta)) {
187
+ // A node parked by the unattended clock is written `done` with the park
188
+ // marker. Its cron is an explicit next cycle, unlike mass revival or deferred
189
+ // delivery, so accept precisely that parked shape. A final latch, a
190
+ // cancellation, or a node finished any other way remains undeliverable and
191
+ // takes the ordinary failure path.
192
+ if (hasNoNaturalCycle(meta) && !isParked(meta)) {
185
193
  throw new Error(`node sink ${target} has no natural cycle ahead (done or finalized)`);
186
194
  }
187
195
  const from = c.created_by != null && getNode(c.created_by) !== null ? c.created_by : null;
@@ -192,6 +200,16 @@ function deliverNodeSink(c, target, stdout) {
192
200
  label: `⏰ cron ${c.name}`,
193
201
  data: { body: stdout },
194
202
  });
203
+ // Mirror ordinary durable message delivery: the append is authoritative, and
204
+ // a wake failure must not turn that successful delivery into a cron failure.
205
+ if (c.tier !== 'deferred' && !isBrokerLive(meta)) {
206
+ try {
207
+ reviveNode(target, { resume: true });
208
+ }
209
+ catch {
210
+ /* best-effort wake — the entry is durably appended regardless */
211
+ }
212
+ }
195
213
  }
196
214
  /** Birth a fresh node with stdout as its kickoff (the recommended sink for
197
215
  * jobs meant to outlive their creator). Returns the newborn's node id. */
@@ -99,7 +99,22 @@ export declare function stopDaemonProcess(pid: number, timeoutMs?: number, deps?
99
99
  * If the daemon is already running, returns {started:false, existing_pid}.
100
100
  * If spawning fails (e.g. missing dist — run `npm run build` first), throws. */
101
101
  export declare function spawnDaemon(): Promise<SpawnDaemonResult>;
102
- /** Start the daemon if it is not already running. No-op if already up.
102
+ /** Whether autostart is suppressed for this invocation. `--no-autostart` or
103
+ * `CRTR_NO_DAEMON_AUTOSTART=1` forces fail-loud on a cold socket (spec §7.1) —
104
+ * for environments that manage crtrd externally and want the diagnostic, and
105
+ * for test lanes, which point `CRTR_HOME` at a throwaway directory: an
106
+ * autostarted daemon there is detached and resident, so it outlives both the
107
+ * test process and the deleted temp home with nothing left to stop it.
108
+ *
109
+ * This is the ONE gate. `ensureDaemon` below is the only path that spawns a
110
+ * daemon implicitly, so the check belongs here rather than at each caller —
111
+ * `cliClient()` reads it too, but the in-process spawn/recycle/promote callers
112
+ * reach `ensureDaemon` without going through the CLI client at all. An
113
+ * explicit `crtr sys daemon start` calls `spawnDaemon` directly and is
114
+ * deliberately unaffected. */
115
+ export declare function autostartDisabled(): boolean;
116
+ /** Start the daemon if it is not already running. No-op if already up, or if
117
+ * autostart is suppressed for this invocation.
103
118
  * Silently swallows spawn errors (the canvas still works without the daemon;
104
119
  * nodes just won't be auto-revived). */
105
120
  export declare function ensureDaemon(): void;
@@ -379,10 +379,29 @@ export async function spawnDaemon() {
379
379
  // ---------------------------------------------------------------------------
380
380
  // ensureDaemon — fire-and-forget front-door helper
381
381
  // ---------------------------------------------------------------------------
382
- /** Start the daemon if it is not already running. No-op if already up.
382
+ /** Whether autostart is suppressed for this invocation. `--no-autostart` or
383
+ * `CRTR_NO_DAEMON_AUTOSTART=1` forces fail-loud on a cold socket (spec §7.1) —
384
+ * for environments that manage crtrd externally and want the diagnostic, and
385
+ * for test lanes, which point `CRTR_HOME` at a throwaway directory: an
386
+ * autostarted daemon there is detached and resident, so it outlives both the
387
+ * test process and the deleted temp home with nothing left to stop it.
388
+ *
389
+ * This is the ONE gate. `ensureDaemon` below is the only path that spawns a
390
+ * daemon implicitly, so the check belongs here rather than at each caller —
391
+ * `cliClient()` reads it too, but the in-process spawn/recycle/promote callers
392
+ * reach `ensureDaemon` without going through the CLI client at all. An
393
+ * explicit `crtr sys daemon start` calls `spawnDaemon` directly and is
394
+ * deliberately unaffected. */
395
+ export function autostartDisabled() {
396
+ return process.env['CRTR_NO_DAEMON_AUTOSTART'] === '1' || process.argv.includes('--no-autostart');
397
+ }
398
+ /** Start the daemon if it is not already running. No-op if already up, or if
399
+ * autostart is suppressed for this invocation.
383
400
  * Silently swallows spawn errors (the canvas still works without the daemon;
384
401
  * nodes just won't be auto-revived). */
385
402
  export function ensureDaemon() {
403
+ if (autostartDisabled())
404
+ return;
386
405
  if (!isDaemonRunning()) {
387
406
  void spawnDaemon().catch(() => {
388
407
  // Intentionally silent — a missing dist/daemon/crtrd-cli.js (dev mode,
@@ -0,0 +1,13 @@
1
+ /** Arm a pending park for `nodeId`. The caller delivers the parking prompt. */
2
+ export declare function beginPendingPark(nodeId: string, now: number): void;
3
+ export declare function isPendingPark(nodeId: string): boolean;
4
+ /** When the pending park was armed, or undefined when none is armed. */
5
+ export declare function pendingParkStartedAt(nodeId: string): number | undefined;
6
+ /** Delete-and-report: true only for the caller that actually took the pending
7
+ * park. Both the supervision tick and the settle handler run on the daemon
8
+ * thread with no await between their check and this call, so exactly one of
9
+ * them ever enacts a given park. */
10
+ export declare function consumePendingPark(nodeId: string): boolean;
11
+ /** Every node with a parking turn in flight — the reconciler's sweep drops the
12
+ * ones whose broker has left the fleet. */
13
+ export declare function pendingParkIds(): string[];
@@ -0,0 +1,42 @@
1
+ // park-pending.ts — the in-flight parking turns, one map keyed by node id.
2
+ //
3
+ // When the unattended clock expires on a live resident, the daemon does not
4
+ // terminalize it: it asks the still-live engine for one summary turn and hands
5
+ // the terminal decision to broker settlement. This module is the only record
6
+ // that a given node's next settlement means "park", read by exactly two
7
+ // collaborators inside crtrd — the supervision reconciler (which armed it, and
8
+ // which enforces its abort conditions and grace) and the broker-settle handler
9
+ // (which consumes it).
10
+ //
11
+ // The pending park is PROCESS-LOCAL on purpose. The park clock and the pending
12
+ // park are the same authority and must die together: if crtrd is replaced
13
+ // mid-turn, the successor has no clock reading and no pending park, the node
14
+ // simply stays live, and its 15-minute clock restarts. Nothing is inconsistent
15
+ // and nothing needs repair. A durable in-flight flag would instead need its own
16
+ // reconciliation on every daemon start to avoid stranding a node that never got
17
+ // its prompt.
18
+ /** node id → the timestamp its parking turn was requested at. */
19
+ const pending = new Map();
20
+ /** Arm a pending park for `nodeId`. The caller delivers the parking prompt. */
21
+ export function beginPendingPark(nodeId, now) {
22
+ pending.set(nodeId, now);
23
+ }
24
+ export function isPendingPark(nodeId) {
25
+ return pending.has(nodeId);
26
+ }
27
+ /** When the pending park was armed, or undefined when none is armed. */
28
+ export function pendingParkStartedAt(nodeId) {
29
+ return pending.get(nodeId);
30
+ }
31
+ /** Delete-and-report: true only for the caller that actually took the pending
32
+ * park. Both the supervision tick and the settle handler run on the daemon
33
+ * thread with no await between their check and this call, so exactly one of
34
+ * them ever enacts a given park. */
35
+ export function consumePendingPark(nodeId) {
36
+ return pending.delete(nodeId);
37
+ }
38
+ /** Every node with a parking turn in flight — the reconciler's sweep drops the
39
+ * ones whose broker has left the fleet. */
40
+ export function pendingParkIds() {
41
+ return Array.from(pending.keys());
42
+ }
@@ -16,6 +16,7 @@ export declare const YIELD_STALL_GRACE_MS: number;
16
16
  export declare const WEDGE_QUIET_MS: number;
17
17
  export declare const WEDGE_CPU_MAX_PERCENT = 2;
18
18
  export declare function unattendedParkMsForDaemon(): number;
19
+ export declare function parkSummaryGraceMsForDaemon(): number;
19
20
  export type WedgeVerdict = 'leave' | 'pending' | 'wedged';
20
21
  export declare function yieldStallVerdict(piPidAlive: boolean | null, intent: NodeRow['intent'], busy: boolean, stalledFor: number | null): 'leave' | 'pending' | 'kill';
21
22
  export declare function wedgeVerdict(input: {
@@ -38,7 +39,19 @@ export declare class BrokerSupervisionReconciler {
38
39
  warning: number;
39
40
  automaticReviveCap: number;
40
41
  }>;
42
+ /** Every condition that resets the unattended clock EXCEPT `isBusy` — the
43
+ * node is unavailable to park, attended, or has work waiting. Short-circuits
44
+ * in cost order so the common tick does no file reads it can avoid.
45
+ * Evaluated for a pending park too, where a busy engine is precisely what is
46
+ * expected (it is writing the summary) and every other hit aborts. */
47
+ private parkResetCondition;
41
48
  private handleUnattendedParking;
49
+ /** Deliver the parking prompt over the live broker's serialized frame loop.
50
+ * Two outcomes cancel the park immediately: an unreachable broker (nothing
51
+ * live to summarize with) falls through to the degraded enactment, and a
52
+ * `steer` route means a turn started between the idle gate and the frame, so
53
+ * the node is working again and its clock simply restarts. */
54
+ private requestParkSummary;
42
55
  private warnOverCapInTmux;
43
56
  private handleYieldStall;
44
57
  private readTreeSample;
@@ -21,6 +21,10 @@ import { readCursor, readInboxSince } from '../../core/feed/inbox.js';
21
21
  import { activeBackgroundBashJobs } from '../../core/bash-jobs.js';
22
22
  import { focusOf } from '../../core/runtime/placement.js';
23
23
  import { hasAttachedBrokerViewers } from '../../core/runtime/broker/client-registry.js';
24
+ import { deliverLive } from '../../core/runtime/interactive-deliver.js';
25
+ import { BrokerUnreachableError } from '../../core/runtime/broker-request.js';
26
+ import { formatCard, PARK_SUMMARY_PROMPT } from '../../shared/generated-context.js';
27
+ import { beginPendingPark, consumePendingPark, pendingParkIds, pendingParkStartedAt } from '../park-pending.js';
24
28
  import { applyParkEvent, parkEvent } from './live-obligation.js';
25
29
  const execFileAsync = promisify(execFile);
26
30
  function configuredPositiveInteger(name, fallback) {
@@ -43,6 +47,9 @@ export const WEDGE_CPU_MAX_PERCENT = 2;
43
47
  const OVER_CAP_WARN_THROTTLE_MS = 5 * 60_000;
44
48
  const CAPACITY_LOG_THROTTLE_MS = 30_000;
45
49
  const DEFAULT_UNATTENDED_PARK_MS = 15 * 60_000;
50
+ // Longer than a summary turn and shorter than the unattended clock itself, so a
51
+ // stuck parking turn cannot hold a broker for a whole extra clock period.
52
+ const DEFAULT_PARK_SUMMARY_GRACE_MS = 5 * 60_000;
46
53
  export function unattendedParkMsForDaemon() {
47
54
  const raw = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
48
55
  if (raw === undefined)
@@ -50,6 +57,13 @@ export function unattendedParkMsForDaemon() {
50
57
  const parsed = Number(raw);
51
58
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_UNATTENDED_PARK_MS;
52
59
  }
60
+ export function parkSummaryGraceMsForDaemon() {
61
+ const raw = process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'];
62
+ if (raw === undefined)
63
+ return DEFAULT_PARK_SUMMARY_GRACE_MS;
64
+ const parsed = Number(raw);
65
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PARK_SUMMARY_GRACE_MS;
66
+ }
53
67
  export function yieldStallVerdict(piPidAlive, intent, busy, stalledFor) {
54
68
  if (piPidAlive !== true)
55
69
  return 'leave';
@@ -155,7 +169,7 @@ export class BrokerSupervisionReconciler {
155
169
  this.handleYieldStall(row, entry.pid, now);
156
170
  await this.handleWedgeDetection(id, entry.pid, row.pi_pid_identity, now);
157
171
  this.handleFatalFault(id);
158
- this.handleUnattendedParking(id, getNode(id), entry.pid, now);
172
+ this.handleUnattendedParking(id, getNode(id), entry.pid, now, ctx);
159
173
  }
160
174
  catch (err) {
161
175
  this.unattendedSince.delete(id);
@@ -172,39 +186,90 @@ export class BrokerSupervisionReconciler {
172
186
  if (!ctx.fleet.has(id))
173
187
  this.unattendedSince.delete(id);
174
188
  }
189
+ // A broker that died mid-summary leaves no pending park behind: its parking
190
+ // turn can never settle, and the node's next clock starts from scratch.
191
+ for (const id of pendingParkIds()) {
192
+ if (!ctx.fleet.has(id))
193
+ consumePendingPark(id);
194
+ }
175
195
  return thresholds;
176
196
  }
177
- handleUnattendedParking(id, row, livePid, now) {
178
- // An unclaimed warm-pool spare is not a conversation: the booted broker it
179
- // holds IS its whole value, and nobody is meant to be attending it. Its
180
- // lifetime belongs to `reapStaleSpares` (warm-pool.ts) and its own TTL, so
181
- // parking one here would empty the pool every 15 minutes.
182
- if (isWarmSpare(id)) {
183
- this.unattendedSince.delete(id);
184
- return;
185
- }
186
- if (row === null
187
- || row.pi_session_id == null
197
+ /** Every condition that resets the unattended clock EXCEPT `isBusy` — the
198
+ * node is unavailable to park, attended, or has work waiting. Short-circuits
199
+ * in cost order so the common tick does no file reads it can avoid.
200
+ * Evaluated for a pending park too, where a busy engine is precisely what is
201
+ * expected (it is writing the summary) and every other hit aborts. */
202
+ parkResetCondition(id, row, livePid, now) {
203
+ if (row.pi_session_id == null
188
204
  || row.pi_pid !== livePid
189
205
  || row.status === 'done'
190
206
  || row.status === 'canceled'
191
207
  || row.status === 'dead'
192
208
  || row.intent !== null
193
- || isBusy(id)
194
209
  || activeBackgroundBashJobs(contextDir(id)).length > 0
195
210
  || focusOf(id) !== null
196
- || hasAttachedBrokerViewers(id)) {
197
- this.unattendedSince.delete(id);
198
- return;
199
- }
211
+ || hasAttachedBrokerViewers(id))
212
+ return true;
200
213
  const fault = readFault(id);
201
214
  const activeAutoProviderRetry = fault !== null
202
215
  && fault.retry.disposition === 'auto'
203
216
  && fault.retry.by === 'daemon'
204
217
  && (fault.kind === 'connection' || fault.kind === 'rate-limit' || fault.kind === 'overloaded')
205
218
  && (fault.retry.nextAt !== undefined || nextFaultRetry(fault, now) !== null);
206
- if (activeAutoProviderRetry
207
- || readInboxSince(id, readCursor(id)).some((entry) => entry.tier !== 'deferred')) {
219
+ if (activeAutoProviderRetry)
220
+ return true;
221
+ return readInboxSince(id, readCursor(id)).some((entry) => entry.tier !== 'deferred');
222
+ }
223
+ handleUnattendedParking(id, row, livePid, now, ctx) {
224
+ // An unclaimed warm-pool spare is not a conversation: the booted broker it
225
+ // holds IS its whole value, and nobody is meant to be attending it. Its
226
+ // lifetime belongs to `reapStaleSpares` (warm-pool.ts) and its own TTL, so
227
+ // parking one here would empty the pool every 15 minutes.
228
+ if (isWarmSpare(id)) {
229
+ this.unattendedSince.delete(id);
230
+ consumePendingPark(id);
231
+ return;
232
+ }
233
+ if (row === null) {
234
+ this.unattendedSince.delete(id);
235
+ consumePendingPark(id);
236
+ return;
237
+ }
238
+ const reset = this.parkResetCondition(id, row, livePid, now);
239
+ // A parking turn is in flight for this node: it owns the decision until it
240
+ // settles, aborts, or runs out of grace.
241
+ const parkingSince = pendingParkStartedAt(id);
242
+ if (parkingSince !== undefined) {
243
+ if (reset) {
244
+ // Something worth attending to arrived (or the node became unavailable)
245
+ // during the summary turn. The node stays live and the park consequence
246
+ // is discarded; its clock restarts from scratch.
247
+ consumePendingPark(id);
248
+ this.unattendedSince.delete(id);
249
+ return;
250
+ }
251
+ if (now - parkingSince < parkSummaryGraceMsForDaemon())
252
+ return;
253
+ // The degraded path: park anyway, with the marker but no report, exactly
254
+ // as this clock behaved before the summary turn existed. Status flips
255
+ // BEFORE the host dies (the crash-safety invariant).
256
+ if (!consumePendingPark(id))
257
+ return;
258
+ this.unattendedSince.delete(id);
259
+ emitEvent({
260
+ level: 'warn',
261
+ event: 'broker.park.summary_expired',
262
+ ...(isSafeNodeId(id) ? { node_id: id } : {}),
263
+ fields: {
264
+ ...(isSafeNodeId(id) ? {} : { affected_node_id: id }),
265
+ grace_ms: parkSummaryGraceMsForDaemon(),
266
+ },
267
+ });
268
+ applyParkEvent(id, 'park');
269
+ headlessBrokerHost.teardown(id);
270
+ return;
271
+ }
272
+ if (reset || isBusy(id)) {
208
273
  this.unattendedSince.delete(id);
209
274
  return;
210
275
  }
@@ -216,8 +281,61 @@ export class BrokerSupervisionReconciler {
216
281
  if (now - since < unattendedParkMsForDaemon())
217
282
  return;
218
283
  this.unattendedSince.delete(id);
219
- applyParkEvent(id, parkEvent(id, row.lifecycle));
220
- headlessBrokerHost.teardown(id);
284
+ const event = parkEvent(id, row.lifecycle);
285
+ if (event === 'release') {
286
+ // A pause, not a terminal write: something can still wake this node, so
287
+ // its row stays wakeable while the idle broker is released. Skipping the
288
+ // teardown here would hold the broker process for as long as the
289
+ // obligation lasts — the exact leak this clock exists to close.
290
+ applyParkEvent(id, event);
291
+ headlessBrokerHost.teardown(id);
292
+ return;
293
+ }
294
+ if (event === 'cancel') {
295
+ applyParkEvent(id, event);
296
+ headlessBrokerHost.teardown(id);
297
+ return;
298
+ }
299
+ // A resident ends its conversation the way a worker ends a task: it gets one
300
+ // last turn to leave a roadmap and a report behind. Terminalization moves to
301
+ // broker settlement, which re-evaluates this verdict once that turn ends.
302
+ if (!ctx.lifecycle.acceptsDetachedWork())
303
+ return;
304
+ beginPendingPark(id, now);
305
+ ctx.lifecycle.registerDetached(this.requestParkSummary(id));
306
+ }
307
+ /** Deliver the parking prompt over the live broker's serialized frame loop.
308
+ * Two outcomes cancel the park immediately: an unreachable broker (nothing
309
+ * live to summarize with) falls through to the degraded enactment, and a
310
+ * `steer` route means a turn started between the idle gate and the frame, so
311
+ * the node is working again and its clock simply restarts. */
312
+ async requestParkSummary(id) {
313
+ try {
314
+ const route = await deliverLive(id, formatCard('park', {}, PARK_SUMMARY_PROMPT));
315
+ if (route === 'steer' && consumePendingPark(id))
316
+ return;
317
+ emitEvent({
318
+ level: 'info',
319
+ event: 'broker.park.summary_requested',
320
+ ...(isSafeNodeId(id) ? { node_id: id } : {}),
321
+ fields: { ...(isSafeNodeId(id) ? {} : { affected_node_id: id }), route },
322
+ });
323
+ }
324
+ catch (err) {
325
+ if (!consumePendingPark(id))
326
+ return;
327
+ if (err instanceof BrokerUnreachableError) {
328
+ applyParkEvent(id, 'park');
329
+ headlessBrokerHost.teardown(id);
330
+ return;
331
+ }
332
+ emitEvent({
333
+ level: 'error',
334
+ event: 'broker.park.summary_failed',
335
+ ...(isSafeNodeId(id) ? { node_id: id } : { fields: { affected_node_id: id } }),
336
+ error: err,
337
+ });
338
+ }
221
339
  }
222
340
  async warnOverCapInTmux(count, warning, max) {
223
341
  const now = Date.now();
@@ -16,8 +16,10 @@ export declare function hasPendingWake(nodeId: string): boolean;
16
16
  * reconciliation (dormant-inbox) apply.
17
17
  *
18
18
  * `release` — something can still wake it, so parking is a pause.
19
- * `finish` — a resident with nothing left to wake it has completed its
20
- * conversation, and the row becomes prunable history.
19
+ * `park` — a resident with nothing left to wake it has completed its
20
+ * conversation, and the row becomes prunable history. The live
21
+ * clock asks it for a summary turn first; this verdict is what
22
+ * that turn's settlement re-evaluates before enacting.
21
23
  * `cancel` — a terminal node that never pushed a final was torn down before
22
24
  * finishing, which is what `canceled` records; it stays revivable
23
25
  * and becomes prunable. A live subscriber still awaiting its
@@ -29,7 +31,11 @@ export declare function hasPendingWake(nodeId: string): boolean;
29
31
  * terminalized row no longer takes that cycle, so writing one off would drop
30
32
  * content the sender already handed over.
31
33
  */
32
- export type ParkEvent = Extract<LifecycleEvent, 'release' | 'finish' | 'cancel'>;
34
+ export type ParkEvent = Extract<LifecycleEvent, 'release' | 'park' | 'cancel'>;
33
35
  export declare function parkEvent(nodeId: string, lifecycle: Lifecycle): ParkEvent;
34
- /** Apply an unattended decision and notify subscribers of automatic completion. */
36
+ /** Apply an unattended decision and notify subscribers of automatic completion.
37
+ * This is the DEGRADED enactment: no summary turn ran, so subscribers get the
38
+ * doctrine wake instead of the node's own report. The live clock's good path
39
+ * (broker settlement after a parking turn) enacts `park` directly and fans
40
+ * nothing — the node's report is the notice. */
35
41
  export declare function applyParkEvent(nodeId: string, event: ParkEvent): void;
@@ -36,13 +36,17 @@ export function parkEvent(nodeId, lifecycle) {
36
36
  if (readInboxSince(nodeId, readCursor(nodeId)).length > 0)
37
37
  return 'release';
38
38
  if (lifecycle === 'resident')
39
- return 'finish';
39
+ return 'park';
40
40
  return hasLiveSubscriber(nodeId) ? 'release' : 'cancel';
41
41
  }
42
- /** Apply an unattended decision and notify subscribers of automatic completion. */
42
+ /** Apply an unattended decision and notify subscribers of automatic completion.
43
+ * This is the DEGRADED enactment: no summary turn ran, so subscribers get the
44
+ * doctrine wake instead of the node's own report. The live clock's good path
45
+ * (broker settlement after a parking turn) enacts `park` directly and fans
46
+ * nothing — the node's report is the notice. */
43
47
  export function applyParkEvent(nodeId, event) {
44
48
  const node = transition(nodeId, event);
45
- if (event !== 'finish')
49
+ if (event !== 'park')
46
50
  return;
47
51
  fanDoctrineWake(nodeId, subscribersOf(nodeId), `Child done — ${fullName(node)} (${nodeId}) was automatically marked done after its unattended grace with nothing left to wake it. It sent no final report.`, { reason: 'child-auto-done', child: nodeId });
48
52
  }
@@ -2,7 +2,6 @@ export declare const DEAD_REAP_GRACE_MS: number;
2
2
  /** Recurring storage cleanup. The in-memory throttle clocks intentionally reset
3
3
  * on daemon boot, so the first tick runs both full sweeps. */
4
4
  export declare class StorageMaintenanceReconciler {
5
- private lastPruneAt;
6
5
  private lastSpareSweepAt;
7
6
  private lastGhostSweepAt;
8
7
  run(now: number): void;
@@ -19,9 +18,6 @@ export declare class StorageMaintenanceReconciler {
19
18
  /** Delete focus rows whose recorded tmux pane no longer exists. A failed tmux
20
19
  * probe is "can't tell" and never authorizes deleting every viewport. */
21
20
  private gcStaleFocuses;
22
- /** Count-based full-canvas prune, throttled to once per hour. Live rows are
23
- * never deleted by pruneToLimit, so the true floor can exceed AUTO_PRUNE_MAX. */
24
- private autoPrune;
25
21
  /** Collect dead or expired hidden warm-pool spares. */
26
22
  private gcWarmPool;
27
23
  }