@north-light/crouter 0.3.236 → 0.3.238

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 (177) 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 +10 -2
  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 +442 -442
  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/memory/lint.js +22 -13
  20. package/dist/commands/memory/shared.js +1 -1
  21. package/dist/commands/node/create.d.ts +1 -1
  22. package/dist/commands/node/create.js +1 -1
  23. package/dist/commands/node/lifecycle.js +1 -1
  24. package/dist/commands/node-worktree.js +39 -8
  25. package/dist/commands/revive.js +11 -2
  26. package/dist/commands/sys/context/admin/actions.d.ts +48 -0
  27. package/dist/commands/sys/context/admin/actions.js +246 -0
  28. package/dist/commands/sys/context/admin/axes-panel.d.ts +21 -0
  29. package/dist/commands/sys/context/admin/axes-panel.js +117 -0
  30. package/dist/commands/sys/context/admin/detail-panel.d.ts +53 -0
  31. package/dist/commands/sys/context/admin/detail-panel.js +178 -0
  32. package/dist/commands/sys/context/admin/docs-panel.d.ts +45 -0
  33. package/dist/commands/sys/context/admin/docs-panel.js +186 -0
  34. package/dist/commands/sys/context/admin/model.d.ts +75 -0
  35. package/dist/commands/sys/context/admin/model.js +209 -0
  36. package/dist/commands/sys/context/admin/shell.d.ts +82 -0
  37. package/dist/commands/sys/context/admin/shell.js +669 -0
  38. package/dist/commands/sys/context/admin.d.ts +1 -0
  39. package/dist/commands/sys/context/admin.js +124 -0
  40. package/dist/commands/sys/context/doc.d.ts +1 -0
  41. package/dist/commands/sys/context/doc.js +196 -0
  42. package/dist/commands/sys/context/prompt-review.d.ts +1 -0
  43. package/dist/commands/sys/{prompt-review.js → context/prompt-review.js} +10 -10
  44. package/dist/commands/sys/context/resolve.d.ts +55 -0
  45. package/dist/commands/sys/context/resolve.js +428 -0
  46. package/dist/commands/sys/context/sysprompt.d.ts +1 -0
  47. package/dist/commands/sys/{sysprompt.js → context/sysprompt.js} +11 -11
  48. package/dist/commands/sys/context.d.ts +2 -0
  49. package/dist/commands/sys/context.js +20 -0
  50. package/dist/commands/sys.js +3 -4
  51. package/dist/core/__tests__/child-death-wake.test.js +38 -22
  52. package/dist/core/__tests__/cron-self-cancel-delivery.test.js +72 -0
  53. package/dist/core/__tests__/daemon-boot.test.js +40 -28
  54. package/dist/core/__tests__/human-deliver.test.js +1 -1
  55. package/dist/core/__tests__/human-node-not-supervised.test.js +10 -15
  56. package/dist/core/__tests__/integration/revive.test.js +12 -12
  57. package/dist/core/__tests__/integration/spawn-root.test.js +6 -0
  58. package/dist/core/__tests__/integration/worktree-reap.test.js +32 -0
  59. package/dist/core/__tests__/lifecycle-terminal-reasons.test.d.ts +1 -0
  60. package/dist/core/__tests__/lifecycle-terminal-reasons.test.js +61 -0
  61. package/dist/core/__tests__/migration.test.js +38 -1
  62. package/dist/core/__tests__/push-final-guard.test.js +1 -1
  63. package/dist/core/__tests__/revive-capacity.test.d.ts +1 -0
  64. package/dist/core/__tests__/revive-capacity.test.js +127 -0
  65. package/dist/core/__tests__/revive-parked-fresh.test.js +7 -7
  66. package/dist/core/__tests__/seam/broker-cap-freeze.test.d.ts +1 -0
  67. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +81 -0
  68. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
  69. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +1 -1
  70. package/dist/core/canvas/browse/render.js +11 -3
  71. package/dist/core/canvas/canvas.d.ts +15 -3
  72. package/dist/core/canvas/canvas.js +52 -16
  73. package/dist/core/canvas/migrations.js +21 -0
  74. package/dist/core/canvas/nav-render.js +3 -1
  75. package/dist/core/canvas/remote-canvas-source.js +2 -0
  76. package/dist/core/canvas/render-source.d.ts +3 -1
  77. package/dist/core/canvas/render-source.js +7 -3
  78. package/dist/core/canvas/status-glyph.d.ts +10 -1
  79. package/dist/core/canvas/status-glyph.js +19 -3
  80. package/dist/core/canvas/types.d.ts +20 -1
  81. package/dist/core/feed/feed.js +1 -1
  82. package/dist/core/human/feedback-companion.js +5 -2
  83. package/dist/core/keybindings/catalog.d.ts +1 -1
  84. package/dist/core/keybindings/catalog.js +6 -2
  85. package/dist/core/memory/lint.d.ts +19 -0
  86. package/dist/core/memory/lint.js +97 -0
  87. package/dist/core/memory-resolver.d.ts +4 -0
  88. package/dist/core/memory-resolver.js +7 -7
  89. package/dist/core/preview-registry.js +4 -4
  90. package/dist/core/review/realize.js +4 -2
  91. package/dist/core/runtime/close.js +1 -1
  92. package/dist/core/runtime/fleet.d.ts +29 -1
  93. package/dist/core/runtime/fleet.js +23 -0
  94. package/dist/core/runtime/host.js +32 -11
  95. package/dist/core/runtime/launch-prompt.d.ts +1 -1
  96. package/dist/core/runtime/launch-prompt.js +2 -2
  97. package/dist/core/runtime/lifecycle.d.ts +4 -2
  98. package/dist/core/runtime/lifecycle.js +12 -1
  99. package/dist/core/runtime/memory.js +1 -1
  100. package/dist/core/runtime/reopen.js +10 -1
  101. package/dist/core/runtime/reset.js +1 -1
  102. package/dist/core/runtime/revive-all.d.ts +3 -0
  103. package/dist/core/runtime/revive-all.js +10 -4
  104. package/dist/core/runtime/revive.d.ts +12 -0
  105. package/dist/core/runtime/revive.js +62 -3
  106. package/dist/core/runtime/spawn.d.ts +4 -0
  107. package/dist/core/runtime/spawn.js +36 -7
  108. package/dist/core/runtime/tmux-bindings.js +4 -0
  109. package/dist/core/substrate/frontmatter-validation.js +1 -1
  110. package/dist/core/substrate/index.d.ts +6 -3
  111. package/dist/core/substrate/index.js +4 -3
  112. package/dist/core/substrate/on-read.d.ts +0 -8
  113. package/dist/core/substrate/on-read.js +45 -124
  114. package/dist/core/substrate/plan.d.ts +94 -0
  115. package/dist/core/substrate/plan.js +266 -0
  116. package/dist/core/substrate/render.d.ts +5 -6
  117. package/dist/core/substrate/render.js +76 -129
  118. package/dist/core/substrate/schema.d.ts +1 -6
  119. package/dist/core/substrate/schema.js +1 -15
  120. package/dist/core/substrate/session-cache.d.ts +2 -10
  121. package/dist/core/substrate/session-cache.js +9 -47
  122. package/dist/core/substrate/subject-fields.d.ts +1 -4
  123. package/dist/core/substrate/subject-fields.js +2 -5
  124. package/dist/core/substrate/surface-match.d.ts +37 -17
  125. package/dist/core/substrate/surface-match.js +44 -53
  126. package/dist/core/tui/page-host.d.ts +6 -0
  127. package/dist/core/tui/page-host.js +12 -3
  128. package/dist/core/worktree.d.ts +8 -0
  129. package/dist/core/worktree.js +65 -0
  130. package/dist/daemon/__tests__/reconciler-signature.test.js +3 -2
  131. package/dist/daemon/__tests__/thaw-order.test.d.ts +1 -0
  132. package/dist/daemon/__tests__/thaw-order.test.js +55 -0
  133. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +6 -0
  134. package/dist/daemon/api/__tests__/node-create-description.test.js +6 -0
  135. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +1 -1
  136. package/dist/daemon/api/bridge.js +5 -1
  137. package/dist/daemon/api/handlers/attach.js +5 -1
  138. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  139. package/dist/daemon/api/handlers/messages.js +10 -2
  140. package/dist/daemon/api/handlers/nodes.js +8 -1
  141. package/dist/daemon/api/handlers/worktree.js +10 -1
  142. package/dist/daemon/api/map.js +7 -0
  143. package/dist/daemon/companion-retire.js +1 -1
  144. package/dist/daemon/cron/passes.d.ts +0 -5
  145. package/dist/daemon/cron/passes.js +0 -20
  146. package/dist/daemon/cron/sinks.js +1 -1
  147. package/dist/daemon/cron-run.d.ts +5 -6
  148. package/dist/daemon/cron-run.js +7 -16
  149. package/dist/daemon/crtrd.d.ts +0 -10
  150. package/dist/daemon/crtrd.js +23 -61
  151. package/dist/daemon/fleet.d.ts +10 -7
  152. package/dist/daemon/fleet.js +71 -12
  153. package/dist/daemon/messaging/node-message.js +3 -1
  154. package/dist/daemon/profile-delete.js +1 -1
  155. package/dist/daemon/reconcilers/broker-supervision.d.ts +26 -9
  156. package/dist/daemon/reconcilers/broker-supervision.js +39 -52
  157. package/dist/daemon/reconcilers/controller-death.d.ts +7 -0
  158. package/dist/daemon/reconcilers/controller-death.js +26 -0
  159. package/dist/daemon/reconcilers/cron-lane.d.ts +0 -3
  160. package/dist/daemon/reconcilers/cron-lane.js +0 -3
  161. package/dist/daemon/reconcilers/live-obligation.js +3 -1
  162. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +53 -0
  163. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +170 -0
  164. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +5 -0
  165. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +16 -0
  166. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -0
  167. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +13 -0
  168. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +26 -0
  169. package/dist/daemon/reconcilers/node-lifecycle/tick.js +140 -0
  170. package/package.json +1 -1
  171. package/runtime.lock.json +2 -2
  172. package/dist/commands/sys/prompt-review.d.ts +0 -1
  173. package/dist/commands/sys/sysprompt.d.ts +0 -1
  174. package/dist/core/__tests__/cron-broker-capacity.test.js +0 -119
  175. package/dist/daemon/reconcilers/dormant-inbox.d.ts +0 -18
  176. package/dist/daemon/reconcilers/dormant-inbox.js +0 -157
  177. /package/dist/core/__tests__/{cron-broker-capacity.test.d.ts → cron-self-cancel-delivery.test.d.ts} +0 -0
@@ -0,0 +1,140 @@
1
+ // tick.ts — the node-lifecycle tick: one pass over durable rows that decides,
2
+ // for every node, whether it should be executing and what is owed if it is not.
3
+ //
4
+ // Durable row state is the input, and the fleet handle is liveness (design
5
+ // D-1/D-4). The pass is two-phase on purpose: phase one classifies every row and
6
+ // enacts everything that costs no slot, phase two hands the surviving candidates
7
+ // to the freeze lane, which orders them by tier and spends the free slots in
8
+ // that order. Splitting the phases is what makes the tier order mean anything —
9
+ // a single interleaved pass would give slots out in row-creation order.
10
+ import { clearFrozen, clearRecordedPid, getNode, } from '../../../core/canvas/index.js';
11
+ import { isSafeNodeId } from '../../../core/canvas/paths.js';
12
+ import { recordedPidLiveness } from '../../../core/canvas/pid.js';
13
+ import { emitEvent } from '../../../core/events/emit.js';
14
+ import { operationIdContext } from '../../../core/events/operation-id.js';
15
+ import { hasUnseenWakeMail } from '../../../core/feed/inbox.js';
16
+ import { hasCleanAbort, isBusy } from '../../../core/runtime/busy.js';
17
+ import { isProfilePaused } from '../../../core/profiles/manifest.js';
18
+ import { classifyDeadNode } from '../../fleet.js';
19
+ import { applyParkEvent, hasPendingWake, parkEvent } from '../live-obligation.js';
20
+ import { FreezeLaneReconciler } from './freeze-lane.js';
21
+ import { isInBackoff } from './respawn-policy.js';
22
+ import { handleTerminatingRow } from './terminating.js';
23
+ function isTerminal(row) {
24
+ return row.status === 'done' || row.status === 'canceled' || row.status === 'dead';
25
+ }
26
+ /** Whether this row should have a live engine right now. An active row always
27
+ * should — that status IS the claim that it is executing. An idle row was
28
+ * deliberately released and holds no engine on purpose: only mail it has not
29
+ * seen brings one back, because a live obligation keeps a node WAKEABLE rather
30
+ * than awake, and relaunching on one would re-release it every tick. */
31
+ export function wantsExecution(row) {
32
+ if (row.status === 'active')
33
+ return true;
34
+ if (row.status !== 'idle')
35
+ return false;
36
+ return hasUnseenWakeMail(row.node_id);
37
+ }
38
+ export class NodeLifecycleReconciler {
39
+ freezeLane = new FreezeLaneReconciler();
40
+ async run(now, ctx) {
41
+ const candidates = [];
42
+ for (const row of ctx.rows) {
43
+ await operationIdContext.fresh(async () => {
44
+ try {
45
+ await this.classifyRow(now, row, ctx, candidates);
46
+ }
47
+ catch (err) {
48
+ const id = row.node_id;
49
+ emitEvent({
50
+ level: 'error',
51
+ event: 'node.lifecycle.classify_failed',
52
+ ...(isSafeNodeId(id) ? { node_id: id } : { fields: { affected_node_id: id } }),
53
+ error: err,
54
+ });
55
+ }
56
+ });
57
+ }
58
+ return this.freezeLane.run(now, { fleet: ctx.fleet, candidates });
59
+ }
60
+ async classifyRow(now, row, ctx, candidates) {
61
+ const id = row.node_id;
62
+ // 1. A fleet handle IS execution. Whatever froze this row is over.
63
+ if (ctx.fleet.has(id)) {
64
+ this.thawMark(row);
65
+ return;
66
+ }
67
+ // 2. A terminal row still holding a process is Terminating, and P4's.
68
+ if (isTerminal(row)) {
69
+ handleTerminatingRow(row);
70
+ return;
71
+ }
72
+ // A paused profile launches nothing, and its stuck-delivery episode is over
73
+ // rather than merely stalled — the pause is not a delivery failure.
74
+ if (isProfilePaused(row.profile_id)) {
75
+ this.freezeLane.endEpisode(id);
76
+ return;
77
+ }
78
+ // 3. Nothing is owed: drop the freeze mark, and settle a parked row whose
79
+ // conversation has nothing left to wake it.
80
+ if (!wantsExecution(row)) {
81
+ this.thawMark(row);
82
+ this.settleParked(row);
83
+ return;
84
+ }
85
+ // 4. Backoff owns this row's next launch; it holds no slot.
86
+ if (isInBackoff(row, now))
87
+ return;
88
+ // An idle row that wants execution is a stuck delivery, and the freeze lane
89
+ // owns its episode rules.
90
+ if (row.status === 'idle') {
91
+ candidates.push({ row, enact: 'idle-release' });
92
+ return;
93
+ }
94
+ // 5. An active row with no recorded pid has no broker to rule on, so the
95
+ // dead-row table — which answers "this node's engine died, what now?" —
96
+ // has no jurisdiction. Only the freeze mark distinguishes the two ways a
97
+ // row gets here: a launcher that reached the ledger and was turned away
98
+ // (the mark, and the launch it is still owed), versus a birth still on
99
+ // its way to the launch seam, which owns its own launch and must be left
100
+ // alone rather than raced or written off as a boot failure.
101
+ if (row.pi_pid == null) {
102
+ if (row.frozen_at != null)
103
+ candidates.push({ row, enact: 'thaw' });
104
+ return;
105
+ }
106
+ // 6. A recorded pid: prove it is not alive before writing it off. An
107
+ // indeterminate probe leaves the row for the next tick — the failure
108
+ // mode is delay, never a wrong kill.
109
+ if (recordedPidLiveness(row.pi_pid, row.pi_pid_identity) !== 'dead')
110
+ return;
111
+ clearRecordedPid(id);
112
+ // 7. The invariant-5 policy table, on the same inputs an exit event gives
113
+ // it. 8. A verdict that needs no broker is enacted right here, because it
114
+ // costs no slot; a respawn becomes a candidate for the freeze lane.
115
+ const action = classifyDeadNode(getNode(id), isBusy(id) || hasCleanAbort(id), hasPendingWake(id));
116
+ if (action === 'respawn-fresh' || action === 'respawn-cycle' || action === 'respawn-resume') {
117
+ candidates.push({ row, enact: 'policy' });
118
+ return;
119
+ }
120
+ await ctx.fleet.applyDeadRowPolicy(id);
121
+ this.thawMark(row);
122
+ }
123
+ thawMark(row) {
124
+ if (row.frozen_at == null)
125
+ return;
126
+ clearFrozen(row.node_id);
127
+ }
128
+ /** An idle row with no wake-worthy inbox entry still needs the lifecycle
129
+ * settlement the live park clock applies: once nothing live remains, this is
130
+ * history rather than a permanently protected idle row. The row is already
131
+ * parked, so only a terminalizing verdict is worth writing. */
132
+ settleParked(row) {
133
+ if (row.status !== 'idle')
134
+ return;
135
+ this.freezeLane.endEpisode(row.node_id);
136
+ const event = parkEvent(row.node_id, row.lifecycle);
137
+ if (event !== 'release')
138
+ applyParkEvent(row.node_id, event);
139
+ }
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.236",
3
+ "version": "0.3.238",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.236",
3
+ "version": "0.3.238",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.236",
9
+ "version": "0.3.238",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {
@@ -1 +0,0 @@
1
- export declare const promptReviewLeaf: import("../../core/command.js").LeafDef;
@@ -1 +0,0 @@
1
- export declare const sysSyspromptLeaf: import("../../core/command.js").LeafDef;
@@ -1,119 +0,0 @@
1
- // Run with: node --import tsx/esm --test src/core/__tests__/cron-broker-capacity.test.ts
2
- //
3
- // Broker-capacity admission for the daemon's due-cron pass (design D-7, the
4
- // fleet-safety invariant the retired clock pass carried): a due run that would
5
- // LAUNCH OR REVIVE a node is admitted only while the fleet is under its
6
- // automatic-revive cap. An unadmitted run is DEFERRED, never consumed — no
7
- // record, no recurrence advance, no one-shot deletion, no escalation — so it
8
- // stays due for the next poll. A cron that merely runs a shell command consumes
9
- // no broker capacity and is never gated.
10
- import { after, afterEach, before, test } from 'node:test';
11
- import assert from 'node:assert/strict';
12
- import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
13
- import { tmpdir } from 'node:os';
14
- import { join } from 'node:path';
15
- import { setTimeout as delay } from 'node:timers/promises';
16
- import { closeDb } from '../canvas/db.js';
17
- import { armCron, cancelCron, getCron, listCronRuns } from '../canvas/crons.js';
18
- import { cronLaunchesNode, executeCron, markSelfCanceledCronRun, runDueCrons } from '../../daemon/cron-run.js';
19
- let home;
20
- let realHome;
21
- function arm(overrides) {
22
- armCron({
23
- name: overrides.cron_id,
24
- created_by: null,
25
- fire_at: new Date(Date.now() - 1_000).toISOString(),
26
- recur: null,
27
- tz: null,
28
- expires_at: null,
29
- anchor_node: null,
30
- cancel_on_wake: false,
31
- cwd: home,
32
- env_json: null,
33
- profile: null,
34
- scope: 'profile',
35
- run_timeout_s: 30,
36
- overlap: 'skip',
37
- on_output: 'silent',
38
- sink: '{}',
39
- tier: 'normal',
40
- ...overrides,
41
- });
42
- }
43
- before(() => {
44
- home = mkdtempSync(join(tmpdir(), 'crtr-cron-broker-capacity-'));
45
- realHome = process.env['CRTR_HOME'];
46
- process.env['CRTR_HOME'] = home;
47
- });
48
- afterEach(() => {
49
- closeDb();
50
- });
51
- after(() => {
52
- closeDb();
53
- rmSync(home, { recursive: true, force: true });
54
- if (realHome === undefined)
55
- delete process.env['CRTR_HOME'];
56
- else
57
- process.env['CRTR_HOME'] = realHome;
58
- });
59
- test('a capacity-deferred one-shot revive cron stays due, unconsumed and unrun', async () => {
60
- arm({ cron_id: 'revive-at-capacity', command: "crtr node lifecycle revive 'target-node' --fresh" });
61
- const settlements = runDueCrons(Date.now(), { hasBrokerCapacity: () => false });
62
- await Promise.all(settlements);
63
- assert.equal(settlements.length, 0, 'no run was started');
64
- const row = getCron('revive-at-capacity');
65
- assert.notEqual(row, null, 'the one-shot was not consumed');
66
- assert.equal(row.state, 'active', 'deferral is not a failure — the row is not paused');
67
- assert.equal(row.run_state, 'idle', 'no lease was taken');
68
- assert.equal(listCronRuns('revive-at-capacity').length, 0, 'nothing was recorded');
69
- });
70
- test('a capacity-deferred recurring spawn cron keeps its past-due fire_at', async () => {
71
- const fireAt = new Date(Date.now() - 1_000).toISOString();
72
- arm({
73
- cron_id: 'spawn-at-capacity',
74
- command: 'crtr node new --kind general --root',
75
- fire_at: fireAt,
76
- recur: JSON.stringify({ every: 'hour' }),
77
- });
78
- await Promise.all(runDueCrons(Date.now(), { hasBrokerCapacity: () => false }));
79
- // Deferring must never advance the recurrence past its window: the slot is
80
- // still owed, so the row is retried on the next poll.
81
- assert.equal(getCron('spawn-at-capacity').fire_at, fireAt);
82
- assert.equal(listCronRuns('spawn-at-capacity').length, 0);
83
- });
84
- test('a plain shell cron runs at zero capacity — only launches are gated', async () => {
85
- arm({ cron_id: 'plain-shell', command: 'exit 0' });
86
- const settlements = runDueCrons(Date.now(), { hasBrokerCapacity: () => false });
87
- assert.equal(settlements.length, 1, 'the run was admitted');
88
- await Promise.all(settlements);
89
- assert.equal(getCron('plain-shell'), null, 'the one-shot settled and was consumed');
90
- });
91
- test('an on-change cron that cancels itself still delivers its final output', async () => {
92
- const runIdPath = join(home, 'self-cancel-run-id');
93
- arm({
94
- cron_id: 'self-cancel-delivery',
95
- command: `printf '%s' "$CRTR_RUN_ID" > '${runIdPath}'; echo settled; sleep 0.1`,
96
- recur: JSON.stringify({ every: '1h' }),
97
- on_output: 'on-change',
98
- sink: JSON.stringify({ human: true }),
99
- });
100
- const run = executeCron(getCron('self-cancel-delivery'), { outOfBand: false });
101
- for (let i = 0; i < 50 && !existsSync(runIdPath); i++)
102
- await delay(10);
103
- assert.ok(existsSync(runIdPath), 'the live run exposed its id to its self-cancel command');
104
- assert.equal(markSelfCanceledCronRun('self-cancel-delivery', readFileSync(runIdPath, 'utf8')), true);
105
- assert.equal(cancelCron('self-cancel-delivery', { preserveRunningProcess: true }), true);
106
- const record = await run;
107
- assert.equal(record?.delivered, 'delivered: human send --inbox');
108
- });
109
- test('cronLaunchesNode classifies by command verb and armed sink', () => {
110
- const of = (command, sink = '{}') => cronLaunchesNode({ command, sink });
111
- assert.equal(of("crtr node lifecycle revive 'n' --fresh"), true);
112
- assert.equal(of('crtr node new --kind general --root'), true);
113
- assert.equal(of('crtr canvas revive --all'), true);
114
- assert.equal(of("crtr node message send --to 'n' --tier urgent --fresh"), true);
115
- assert.equal(of('echo hi', JSON.stringify({ spawn: 'general' })), true);
116
- assert.equal(of("crtr node message send --to 'n' --tier urgent"), false);
117
- assert.equal(of('npm test'), false);
118
- assert.equal(of('echo hi', JSON.stringify({ node: 'n' })), false);
119
- });
@@ -1,18 +0,0 @@
1
- import { type NodeRow } from '../../core/canvas/index.js';
2
- import type { FleetRegistry } from '../../core/runtime/fleet.js';
3
- export interface ControllerDeathContext {
4
- rows: readonly NodeRow[];
5
- }
6
- export interface DormantInboxContext extends ControllerDeathContext {
7
- fleet: FleetRegistry;
8
- /** P3.4's capacity authority, bound to the thresholds returned by the live
9
- * supervision pass for this same tick. */
10
- hasBrokerCapacity(): boolean;
11
- }
12
- export declare class ControllerDeathReconciler {
13
- run(now: number, ctx: ControllerDeathContext): void;
14
- }
15
- export declare class DormantInboxReconciler {
16
- private readonly relaunchState;
17
- run(now: number, ctx: DormantInboxContext): void;
18
- }
@@ -1,157 +0,0 @@
1
- import { getNode, getRow, subscribersOf, } from '../../core/canvas/index.js';
2
- import { fullName } from '../../core/canvas/labels.js';
3
- import { isSafeNodeId } from '../../core/canvas/paths.js';
4
- import { emitEvent } from '../../core/events/emit.js';
5
- import { operationIdContext } from '../../core/events/operation-id.js';
6
- import { appendControllerDeathNotice, readCursor, readInboxSince, } from '../../core/feed/inbox.js';
7
- import { fanDoctrineWake } from '../../core/runtime/close.js';
8
- import { transition } from '../../core/runtime/lifecycle.js';
9
- import { reviveNode } from '../../core/runtime/revive.js';
10
- import { isProfilePaused } from '../../core/profiles/manifest.js';
11
- import { applyParkEvent, parkEvent } from './live-obligation.js';
12
- // Idle-release/inbox retry cap (2026-07-10 diagnosis, round 2): the SAME
13
- // livelock shape as the stranded-relaunch case, but on the separate pass that
14
- // resumes a dormant idle-released node when its inbox gains an unseen entry.
15
- // The counter is keyed on inbox-cursor progress, not row-state residency or pid
16
- // liveness: a transiently-live broker does not prove that delivery completed.
17
- const IDLE_RELEASE_RELAUNCH_MAX_ATTEMPTS = 6;
18
- const IDLE_RELEASE_RELAUNCH_MAX_BACKOFF_MS = 10 * 60_000;
19
- const IDLE_RELEASE_RELAUNCH_BASE_MS = 20_000;
20
- export class ControllerDeathReconciler {
21
- run(now, ctx) {
22
- void now; // no time-gating here — the death check is already-controller-status-driven; the param is kept for six-way call-site uniformity, matching every other reconciler's run(now, ctx) shape.
23
- // Settle every wait whose controller has ended, independent of the waiter's
24
- // current status. Active and focused terminal nodes own a live inbox
25
- // watcher; idle-released nodes are revived by the following reconciler.
26
- for (const row of ctx.rows) {
27
- const current = getRow(row.node_id);
28
- const controllerId = current?.waiting_for;
29
- if (controllerId === null || controllerId === undefined)
30
- continue;
31
- // Read-gate before the write boundary: only a dead controller warrants a
32
- // notice. appendControllerDeathNotice re-checks liveness inside its
33
- // transaction, so this gate cannot misfire.
34
- const controller = getRow(controllerId);
35
- if (controller !== null && (controller.status === 'active' || controller.status === 'idle'))
36
- continue;
37
- operationIdContext.fresh(() => {
38
- appendControllerDeathNotice(row.node_id, controllerId);
39
- });
40
- }
41
- }
42
- }
43
- export class DormantInboxReconciler {
44
- relaunchState = new Map();
45
- run(now, ctx) {
46
- // Re-read each row because live supervision may have mutated it earlier in
47
- // this tick. A fleet entry is the live engine; no pid probe participates.
48
- for (const row of ctx.rows) {
49
- const id = row.node_id;
50
- let current;
51
- try {
52
- current = getRow(id);
53
- if (current === null) {
54
- this.relaunchState.delete(id);
55
- continue;
56
- }
57
- // Do not clear state on status flaps or transient liveness. Only cursor
58
- // progress, no wake-worthy work, row deletion, or exhaustion ends the
59
- // stuck-delivery episode.
60
- if (current.status !== 'idle' || current.intent !== 'idle-release')
61
- continue;
62
- if (ctx.fleet.has(id))
63
- continue;
64
- if (isProfilePaused(current.profile_id)) {
65
- this.relaunchState.delete(id);
66
- continue;
67
- }
68
- }
69
- catch (err) {
70
- operationIdContext.fresh(() => {
71
- emitEvent({
72
- level: 'error',
73
- event: 'inbox.poll.failed',
74
- ...(isSafeNodeId(id) ? { node_id: id } : { fields: { affected_node_id: id } }),
75
- error: err,
76
- });
77
- });
78
- continue;
79
- }
80
- operationIdContext.fresh(() => {
81
- try {
82
- const cursorNow = readCursor(id);
83
- const entries = readInboxSince(id, cursorNow);
84
- // Deferred entries ride the next natural cycle. A deferred-only unseen
85
- // set neither revives the node nor starts a stuck-relaunch episode.
86
- if (!entries.some((entry) => entry.tier !== 'deferred')) {
87
- this.relaunchState.delete(id);
88
- const meta = getNode(id);
89
- // An idle row with no wake-worthy inbox entry still needs the same
90
- // lifecycle settlement the live park clock applies: once nothing
91
- // live remains, this is history rather than a permanently protected
92
- // idle row. The row is already parked, so only a terminalizing
93
- // verdict is worth writing. Unclaimed warm-pool spares never reach
94
- // here — `listNodes` hides them from `ctx.rows`.
95
- if (meta !== null) {
96
- const event = parkEvent(id, meta.lifecycle);
97
- if (event !== 'release')
98
- applyParkEvent(id, event);
99
- }
100
- return;
101
- }
102
- const prior = this.relaunchState.get(id);
103
- const cursorChanged = prior === undefined || prior.cursor !== cursorNow;
104
- const attempts = cursorChanged ? 0 : prior.attempts;
105
- if (attempts >= IDLE_RELEASE_RELAUNCH_MAX_ATTEMPTS) {
106
- this.relaunchState.delete(id);
107
- const meta = getNode(id);
108
- emitEvent({
109
- level: 'warn',
110
- event: 'broker.idle_release.exhausted',
111
- ...(isSafeNodeId(id) ? { node_id: id } : {}),
112
- fields: {
113
- ...(isSafeNodeId(id) ? {} : { affected_node_id: id }),
114
- attempts,
115
- max_attempts: IDLE_RELEASE_RELAUNCH_MAX_ATTEMPTS,
116
- },
117
- });
118
- transition(id, 'crash');
119
- if (meta !== null) {
120
- fanDoctrineWake(id, subscribersOf(id), `Child crashed — ${fullName(meta)} (${id}) failed to relaunch after ${attempts} attempts while trying to ` +
121
- `deliver a pending inbox message (no delivery progress across any attempt), and is now dead. It stays ` +
122
- `dead until you revive it — \`crtr node lifecycle revive ${id}\`.`, { reason: 'child-crashed', child: id });
123
- }
124
- return;
125
- }
126
- const lastAttempt = cursorChanged ? undefined : prior?.lastAttempt;
127
- const backoff = Math.min(IDLE_RELEASE_RELAUNCH_BASE_MS * 2 ** attempts, IDLE_RELEASE_RELAUNCH_MAX_BACKOFF_MS);
128
- if (lastAttempt !== undefined && now - lastAttempt < backoff)
129
- return;
130
- if (!ctx.hasBrokerCapacity())
131
- return;
132
- this.relaunchState.set(id, { cursor: cursorNow, attempts: attempts + 1, lastAttempt: now });
133
- emitEvent({
134
- level: 'info',
135
- event: 'broker.revive.idle_release',
136
- ...(isSafeNodeId(id) ? { node_id: id } : {}),
137
- attempt: attempts + 1,
138
- fields: {
139
- ...(isSafeNodeId(id) ? {} : { affected_node_id: id }),
140
- attempts: attempts + 1,
141
- max_attempts: IDLE_RELEASE_RELAUNCH_MAX_ATTEMPTS,
142
- },
143
- });
144
- reviveNode(id, { resume: true });
145
- }
146
- catch (err) {
147
- emitEvent({
148
- level: 'error',
149
- event: 'inbox.poll.failed',
150
- ...(isSafeNodeId(id) ? { node_id: id } : { fields: { affected_node_id: id } }),
151
- error: err,
152
- });
153
- }
154
- });
155
- }
156
- }
157
- }