@north-light/crouter 0.3.300 → 0.3.302

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 (51) hide show
  1. package/dist/api/dto/reports.d.ts +8 -8
  2. package/dist/clients/attach/session/profile-files.d.ts +6 -3
  3. package/dist/clients/attach/session/profile-files.js +39 -10
  4. package/dist/clients/attach/viewer.js +663 -663
  5. package/dist/commands/memory/__tests__/command-selector-and-mutation-guards.test.js +30 -1
  6. package/dist/commands/memory/read.js +12 -14
  7. package/dist/commands/node/create.js +1 -1
  8. package/dist/commands/node/lifecycle.js +36 -8
  9. package/dist/commands/push.js +4 -4
  10. package/dist/core/__tests__/human-action-delivery.test.js +13 -11
  11. package/dist/core/__tests__/integration/revive.test.js +3 -3
  12. package/dist/core/__tests__/node-outcome.test.js +1 -6
  13. package/dist/core/__tests__/seam/dormancy-release.test.js +42 -0
  14. package/dist/core/__tests__/stop-guard.test.js +64 -0
  15. package/dist/core/bash-jobs.d.ts +22 -0
  16. package/dist/core/bash-jobs.js +38 -1
  17. package/dist/core/canvas/crons.d.ts +20 -4
  18. package/dist/core/canvas/crons.js +54 -7
  19. package/dist/core/canvas/delivery-contract.d.ts +19 -0
  20. package/dist/core/canvas/delivery-contract.js +8 -0
  21. package/dist/core/canvas/human-deliveries.d.ts +3 -17
  22. package/dist/core/canvas/node-outcome-deliveries.d.ts +3 -19
  23. package/dist/core/canvas/node-outcome-deliveries.js +0 -4
  24. package/dist/core/keybindings/catalog.js +1 -1
  25. package/dist/core/memory-resolver.d.ts +3 -1
  26. package/dist/core/memory-resolver.js +3 -3
  27. package/dist/core/nested-stores.d.ts +1 -1
  28. package/dist/core/nested-stores.js +3 -3
  29. package/dist/core/runtime/bearings-render.js +1 -1
  30. package/dist/core/runtime/close.js +8 -6
  31. package/dist/core/runtime/stop-guard.js +6 -6
  32. package/dist/core/worktree.js +3 -6
  33. package/dist/daemon/__tests__/node-outcome-delivery.test.js +3 -2
  34. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +21 -11
  35. package/dist/daemon/api/handlers/reports.js +1 -1
  36. package/dist/daemon/api/map.d.ts +1 -1
  37. package/dist/daemon/api/map.js +2 -2
  38. package/dist/daemon/cron/sinks.d.ts +1 -1
  39. package/dist/daemon/cron/sinks.js +2 -2
  40. package/dist/daemon/delivery/run-delivery.d.ts +25 -0
  41. package/dist/daemon/delivery/run-delivery.js +164 -0
  42. package/dist/daemon/human/deliver-action.d.ts +1 -14
  43. package/dist/daemon/human/deliver-action.js +5 -165
  44. package/dist/daemon/node-outcome/deliver-outcome.d.ts +1 -12
  45. package/dist/daemon/node-outcome/deliver-outcome.js +6 -152
  46. package/dist/daemon/reconcilers/live-obligation.js +2 -2
  47. package/dist/pi-extensions/canvas-bash-valve.js +7 -10
  48. package/package.json +1 -1
  49. package/runtime.lock.json +5 -5
  50. package/dist/daemon/cron-sink.d.ts +0 -26
  51. package/dist/daemon/cron-sink.js +0 -43
@@ -14,7 +14,7 @@
14
14
  // Run: node --conditions=crtr-src --import tsx/esm --test --test-concurrency=4 --test-timeout=10000 src/commands/memory/__tests__/command-selector-and-mutation-guards.test.ts
15
15
  import { test, beforeEach, after } from 'node:test';
16
16
  import assert from 'node:assert/strict';
17
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
17
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
18
18
  import { tmpdir } from 'node:os';
19
19
  import { join } from 'node:path';
20
20
  import { resetScopeCache } from '../../../core/scope.js';
@@ -23,6 +23,7 @@ import { createProfile, profileMemoryDir } from '../../../core/profiles/manifest
23
23
  import { openProjectMemoryStore } from '../../../core/memory-resolver.js';
24
24
  import { appendHistoryRecord, buildHistoryRecord, readHistoryRecords } from '../../../core/memory/history.js';
25
25
  import { CrtrError } from '../../../core/errors.js';
26
+ import { gitSync } from '../../../core/git.js';
26
27
  import { deleteLeaf } from '../delete.js';
27
28
  import { editLeaf } from '../edit.js';
28
29
  import { historyLeaf } from '../history.js';
@@ -319,6 +320,34 @@ test('a targeted read suppresses malformed descendant-store warnings', async ()
319
320
  }
320
321
  assert.equal(writes.some((line) => line.includes('memory store not mounted')), false, writes.join(''));
321
322
  });
323
+ test('a truncated nested-store read reports that its corpus was incomplete', async () => {
324
+ assert.equal(gitSync(['init', '-q'], root).status, 0, 'fixture repository initializes');
325
+ const fakeBin = join(base, 'slow-git');
326
+ mkdirSync(fakeBin);
327
+ const originalPath = process.env['PATH'] ?? '';
328
+ const fakeGit = join(fakeBin, 'git');
329
+ writeFileSync(fakeGit, `#!/bin/sh\nfor arg in "$@"; do\n if [ "$arg" = "ls-files" ]; then sleep 1; exit 0; fi\ndone\nPATH=${JSON.stringify(originalPath)}\nexport PATH\nexec git "$@"\n`, 'utf8');
330
+ chmodSync(fakeGit, 0o755);
331
+ process.chdir(root);
332
+ resetScopeCache();
333
+ const stderr = process.stderr;
334
+ const originalWrite = stderr.write;
335
+ const writes = [];
336
+ stderr.write = ((chunk) => {
337
+ writes.push(String(chunk));
338
+ return true;
339
+ });
340
+ process.env['PATH'] = `${fakeBin}:${originalPath}`;
341
+ try {
342
+ const error = await thrownByAsync(() => readLeaf.run({ name: 'acme/child/foo', kind: undefined, frontmatter: false, scope: undefined, dir: undefined }));
343
+ assert.equal(error.code, 'not_found');
344
+ }
345
+ finally {
346
+ process.env['PATH'] = originalPath;
347
+ stderr.write = originalWrite;
348
+ }
349
+ assert.match(writes.join(''), /nested-store discovery.*result may omit nested stores.*--dir <owner>/s, `a timed-out scan must tell a failed read how to bypass discovery; got:\n${writes.join('')}`);
350
+ });
322
351
  test('deleted history points at the revision holding the retained text, never a read that cannot answer', async () => {
323
352
  await deleteLeaf.run({ name: 'acme/disposable', dir: root });
324
353
  const result = (await historyLeaf.run({ name: 'acme/disposable', dir: root }));
@@ -1,10 +1,9 @@
1
1
  import { envNodeId } from '../../shared/env.js';
2
2
  import { CrtrClient } from '../../api/index.js';
3
3
  import { defineLeaf } from '../../core/command.js';
4
- import { CrtrError } from '../../core/errors.js';
5
4
  import { memoryExtensionEffectiveCatalog, projectEffectiveMemoryExtensions } from '../../core/memory/extensions.js';
6
5
  import { docLinkNames } from '../../core/memory/doc-link-grammar.js';
7
- import { ambientMemoryTarget, createMemoryDocSnapshot, listAllMemoryDocs, loadMemoryStoreView, readMemoryDocContent, resolveMemoryCandidates, resolveMemoryDocs, } from '../../core/memory-resolver.js';
6
+ import { ambientMemoryTarget, createMemoryDocSnapshot, listAllMemoryDocs, loadMemoryStoreView, loadMemoryTargetView, readMemoryDocContent, resolveMemoryDocs, } from '../../core/memory-resolver.js';
8
7
  import { expandShellBlocks, hasShellBlocks, makeNodeShellRunner } from '../../core/runtime/shell-expansion.js';
9
8
  import { emptyContextExposureState, exposureTarget, loadContextExposureState, registerDocumentExposure, registerExposure, saveContextExposureState, } from '../../core/substrate/injected-store.js';
10
9
  import { dirDedupKey, docsByName, isDirName, renderDirListing } from '../../core/substrate/listings.js';
@@ -121,18 +120,17 @@ export const readLeaf = defineLeaf({
121
120
  ...(selector.scope === undefined ? {} : { scope: selector.scope }),
122
121
  includeDescendants: true,
123
122
  };
124
- // A named read must not turn malformed stores elsewhere in the corpus
125
- // into warnings about the requested document. `memory lint` owns that
126
- // corpus health report; resolution still includes every candidate.
127
- listingByName = linkByName = docsByName(listAllMemoryDocs(selector.scope, true, true));
128
- try {
129
- set = resolveMemoryCandidates(nameRaw, resolveOpts);
130
- }
131
- catch (e) {
132
- if (!(e instanceof CrtrError && e.code === 'not_found'))
133
- throw e;
134
- }
135
- fail = () => resolveMemoryCandidates(nameRaw, resolveOpts);
123
+ // A named read keeps unrelated malformed documents quiet, but a timed
124
+ // discovery leaves its corpus incomplete and must remain visible before
125
+ // this branch can report not_found.
126
+ const view = loadMemoryTargetView(ambientMemoryTarget(), {
127
+ ...resolveOpts,
128
+ quiet: true,
129
+ reportDiscovery: true,
130
+ });
131
+ listingByName = linkByName = docsByName(view.docs);
132
+ set = view.candidates(nameRaw, kindOpts);
133
+ fail = () => view.resolve(nameRaw, kindOpts);
136
134
  }
137
135
  const nodeId = envNodeId() || undefined;
138
136
  if (set === null) {
@@ -83,7 +83,7 @@ function nodeNewParams() {
83
83
  { kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node.' },
84
84
  { kind: 'flag', name: 'node-id', type: 'string', required: false, constraint: 'Exact id for the new node; lowercase letters, digits, and hyphens only, at most 128 characters. Rejected if already present.' },
85
85
  { kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an independent resident root with no parent or subscription. Mutually exclusive with --worktree.' },
86
- { kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'Birth the node as resident: it parks and stays wakeable without being forced to submit a final. Applies to managed children and roots; roots default to resident.' },
86
+ { kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'Birth the node as resident: it parks and stays wakeable without being forced to submit a final. Pass it ONLY when the user will open this node’s viewer pane and work with it back and forth; a child that only other nodes message stays terminal. Applies to managed children and roots; roots default to resident.' },
87
87
  { kind: 'flag', name: 'worktree', type: 'string', required: false, constraint: 'Local base branch for a crouter-managed worktree for this child. Mutually exclusive with --root.' },
88
88
  { kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'Fork the new node from an existing node id, absolute session path, or partial pi session uuid.' },
89
89
  {
@@ -3,6 +3,8 @@ import { envNodeId } from '../../shared/env.js';
3
3
  import { defineBranch, defineLeaf } from '../../core/command.js';
4
4
  import { InputError } from '../../core/io.js';
5
5
  import { cliClient, getNodeOrNull, rethrowAsCliError } from '../api-client.js';
6
+ import { contextDir } from '../../core/canvas/paths.js';
7
+ import { DEFAULT_BASH_NICENESS, MAX_BASH_NICENESS, writeBashNiceness } from '../../core/bash-jobs.js';
6
8
  import { nodeReviveLeaf } from '../node-lifecycle-revive.js';
7
9
  import { assertKind, kindsStateBlock } from './create.js';
8
10
  import { MODEL_SPEC_FORMS } from '../../core/runtime/model-selection.js';
@@ -69,11 +71,11 @@ const nodeRecycle = defineLeaf({
69
71
  // node lifecycle close — close a node + cascade-cancel its exclusive subtree (Alt+C → x)
70
72
  const nodeClose = defineLeaf({
71
73
  name: 'close',
72
- description: 'close a node + cascade-cancel its exclusive subtree (revivable)',
73
- whenToUse: 'you want to tear a node down WITHOUT finishing it, cascade-cancelling every descendant it exclusively owns: abandoning a line of work, killing a stuck or wrong-turn subtree, clearing a branch you no longer need. Windows die but nothing is deleted — each closed node keeps its pi session and can be revived later (`node lifecycle revive`). Use `node lifecycle recycle` instead to FINISH the agent in your pane with a final report, and `push final` when a worker should end its own work normally (Alt+C → x)',
74
+ description: 'close a node + cascade-cancel its exclusive subtree',
75
+ whenToUse: 'you want to tear a node down WITHOUT finishing it, cascade-cancelling every descendant it exclusively owns: abandoning a line of work, killing a stuck or wrong-turn subtree, clearing a branch you no longer need. A node that never produced substantive assistant output is deleted and cannot be revived; streaming nodes and nodes with an open managed worktree are retained. Other closed nodes keep their pi session and can be revived later (`node lifecycle revive`). Use `node lifecycle recycle` instead to FINISH the agent in your pane with a final report, and `push final` when a worker should end its own work normally (Alt+C → x)',
74
76
  help: {
75
77
  name: 'node lifecycle close',
76
- summary: 'close a node and cascade-cancel its subtree — kill its tmux window plus those of every descendant it EXCLUSIVELY owns (down the subscribes_to spine), mark them all canceled, and leave each a notice it reads on resume. A descendant still subscribed to by a manager outside the subtree is left running. Nothing is deleted: every closed node keeps its pi session and can be revived later (`crtr node lifecycle revive`)',
78
+ summary: 'close a node and cascade-cancel its subtree — kill its tmux window plus those of every descendant it EXCLUSIVELY owns (down the subscribes_to spine), mark retained nodes canceled, and leave them a notice they read on resume. A descendant still subscribed to by a manager outside the subtree is left running. A node that never produced substantive assistant output is deleted and cannot be revived; streaming nodes and nodes with an open managed worktree are retained. Other closed nodes keep their pi session and can be revived later (`crtr node lifecycle revive`)',
77
79
  params: [
78
80
  { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to close. Defaults to the node occupying --pane (or your current pane).' },
79
81
  { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id whose node to close. Defaults to $TMUX_PANE / your current pane. The Alt+C menu passes this for you.' },
@@ -86,9 +88,10 @@ const nodeClose = defineLeaf({
86
88
  ],
87
89
  outputKind: 'object',
88
90
  effects: [
89
- 'Marks the node and its exclusive descendants `canceled` and clears intent (the daemon never revives a canceled node).',
90
- 'Kills each closed node\'s tmux window; their pi sessions and canvas edges persist for a later revive.',
91
- 'Appends a cancellation notice to each closed node\'s inbox, surfaced on its next resume.',
91
+ 'Marks retained nodes and their exclusive descendants `canceled` and clears intent (the daemon never revives a canceled node).',
92
+ 'Deletes a non-streaming node with no substantive assistant output unless it has an open managed worktree; a deleted node cannot be revived.',
93
+ 'Kills each closed node\'s tmux window; retained nodes keep their pi sessions and canvas edges for a later revive.',
94
+ 'Appends a cancellation notice to each retained node\'s inbox, surfaced on its next resume.',
92
95
  ],
93
96
  },
94
97
  run: async (input) => {
@@ -140,6 +143,7 @@ export const nodeConfig = defineLeaf({
140
143
  { kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Persona kind. The <kinds> list below names every top-level installable kind and when to use each; a registered sub-kind is valid too by exact path but not listed here.' },
141
144
  { kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, constraint: 'Set persona mode headlessly. base is hands-on; orchestrator holds a roadmap and delegates. orchestrator seeds a roadmap scaffold if absent.' },
142
145
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Rename the node; if the node has a live window, also rename that viewer window.' },
146
+ { kind: 'flag', name: 'nice', type: 'int', required: false, constraint: `Scheduler niceness every command this node's bash tool starts at, 0–${MAX_BASH_NICENESS} (default ${DEFAULT_BASH_NICENESS}). ${DEFAULT_BASH_NICENESS} keeps the node's builds and test suites below the daemon, the brokers, the viewers, and the person's own shell; 0 lets them compete on equal terms with everything else on the host; ${MAX_BASH_NICENESS} makes them yield to it. Applies to the node's very next command, foreground and background alike.` },
143
147
  { kind: 'flag', name: 'cwd', type: 'string', required: false, constraint: 'Repair-only replacement launch directory. Must be a non-empty absolute existing directory and used alone; the daemon accepts it only for a dormant, non-forked node with no open managed worktree whose recorded cwd is gone. Clears the saved Pi session, so the next revive starts fresh while durable node work remains.' },
144
148
  ],
145
149
  output: [
@@ -149,6 +153,7 @@ export const nodeConfig = defineLeaf({
149
153
  { name: 'kind', type: 'string', required: false, constraint: 'The new kind, present only when --kind changed it.' },
150
154
  { name: 'mode', type: 'string', required: false, constraint: 'The new mode, present only when --mode changed it.' },
151
155
  { name: 'name', type: 'string', required: false, constraint: 'The new name, present only when --name changed it.' },
156
+ { name: 'nice', type: 'number', required: false, constraint: 'The new bash niceness, present only when --nice changed it.' },
152
157
  { name: 'cwd', type: 'string', required: false, constraint: 'The lexically resolved replacement directory, present only after a cwd repair.' },
153
158
  ],
154
159
  outputKind: 'object',
@@ -157,6 +162,7 @@ export const nodeConfig = defineLeaf({
157
162
  'Kind, mode, and lifecycle changes rebuild the launch spec from the fresh node meta; setting mode to orchestrator seeds a roadmap scaffold if absent.',
158
163
  'Name changes update the row and, when the node already has a live window, rename that viewer window to the new full name.',
159
164
  'A cwd repair writes only the lexically resolved cwd and clears saved Pi session pointers; the old transcript and durable node work remain untouched.',
165
+ 'A niceness change is stored with the node\'s own bash-job state, so it takes effect on the next command without a revive and survives one.',
160
166
  ],
161
167
  dynamicState: () => kindsStateBlock(),
162
168
  },
@@ -169,7 +175,7 @@ export const nodeConfig = defineLeaf({
169
175
  // clean listing error, and re-validated server-side against the TARGET
170
176
  // node's scope as a backstop.
171
177
  const cwdSpec = input['cwd'];
172
- if (cwdSpec !== undefined && ['model', 'kind', 'lifecycle', 'mode', 'name'].some((flag) => input[flag] !== undefined)) {
178
+ if (cwdSpec !== undefined && ['model', 'kind', 'lifecycle', 'mode', 'name', 'nice'].some((flag) => input[flag] !== undefined)) {
173
179
  throw new InputError({ error: 'cwd_not_exclusive', message: 'cwd repair cannot be combined with another config flag', field: 'cwd', next: 'Pass --cwd alone.' });
174
180
  }
175
181
  const modelSpec = input['model']?.trim();
@@ -180,6 +186,15 @@ export const nodeConfig = defineLeaf({
180
186
  const lifecycleSpec = input['lifecycle']?.trim();
181
187
  const modeSpec = input['mode']?.trim();
182
188
  const nameSpec = input['name'];
189
+ const niceSpec = input['nice'];
190
+ if (niceSpec !== undefined && (niceSpec < 0 || niceSpec > MAX_BASH_NICENESS)) {
191
+ throw new InputError({
192
+ error: 'bad_nice',
193
+ message: `niceness must be between 0 and ${MAX_BASH_NICENESS}: ${niceSpec}`,
194
+ field: 'nice',
195
+ next: `A negative niceness needs root, so pass 0–${MAX_BASH_NICENESS}.`,
196
+ });
197
+ }
183
198
  if (kindSpec !== undefined)
184
199
  assertKind(kindSpec);
185
200
  if (lifecycleSpec !== undefined && lifecycleSpec.toLowerCase() !== 'terminal' && lifecycleSpec.toLowerCase() !== 'resident') {
@@ -214,8 +229,17 @@ export const nodeConfig = defineLeaf({
214
229
  patch.cwd = cwdSpec;
215
230
  applied.push('cwd');
216
231
  }
232
+ // Niceness is node-local bash-lane state, not canvas state: it is stored
233
+ // beside this node's job dirs so the valve reads it when it starts the next
234
+ // command, rather than waiting for a launch recipe to be rebuilt.
235
+ if (niceSpec !== undefined) {
236
+ writeBashNiceness(contextDir(nodeId), niceSpec);
237
+ applied.push('nice');
238
+ if (applied.length === 1)
239
+ return { node_id: nodeId, nice: niceSpec };
240
+ }
217
241
  if (applied.length === 0) {
218
- throw new InputError({ error: 'no_change', message: 'no config changes requested', next: 'Pass at least one of --model, --lifecycle, --kind, --mode, --name, --cwd.' });
242
+ throw new InputError({ error: 'no_change', message: 'no config changes requested', next: 'Pass at least one of --model, --lifecycle, --kind, --mode, --name, --nice, --cwd.' });
219
243
  }
220
244
  const detail = await cliClient()
221
245
  .patchConfig(nodeId, patch)
@@ -233,6 +257,8 @@ export const nodeConfig = defineLeaf({
233
257
  result.mode = detail.mode;
234
258
  if (applied.includes('name'))
235
259
  result.name = detail.name;
260
+ if (applied.includes('nice'))
261
+ result.nice = niceSpec;
236
262
  return result;
237
263
  },
238
264
  render: (r) => {
@@ -249,6 +275,8 @@ export const nodeConfig = defineLeaf({
249
275
  bits.push(`mode=${r['mode']}`);
250
276
  if (r['name'] !== undefined)
251
277
  bits.push(`name=${r['name']}`);
278
+ if (r['nice'] !== undefined)
279
+ bits.push(`nice=${r['nice']}`);
252
280
  return `Configured ${r['node_id']} — ${bits.join(', ')}`;
253
281
  },
254
282
  });
@@ -160,8 +160,8 @@ function makePushLeaf(kind) {
160
160
  ...(kind === 'final'
161
161
  ? [
162
162
  { name: 'transitioned', type: 'object', required: false, constraint: 'Present only for a final that flipped the node — { from, to } lifecycle-status transition applied server-side.' },
163
- { name: 'worktree_auto_dropped', type: 'boolean', required: false, constraint: 'Present only when this node owned a managed worktree with zero commits ahead of its base and no uncommitted changes — the server auto-dropped it instead of requiring `node worktree close` first (see worktree_auto_dropped_path).' },
164
- { name: 'worktree_auto_dropped_path', type: 'string', required: false, constraint: 'Present only alongside worktree_auto_dropped: the checkout path that was dropped.' },
163
+ { name: 'worktree_auto_dropped', type: 'boolean', required: false, constraint: 'Present only when the server closed this node’s managed-worktree record without requiring `node worktree close`; cleanup of an existing checkout remains pending (see worktree_auto_dropped_path).' },
164
+ { name: 'worktree_auto_dropped_path', type: 'string', required: false, constraint: 'Present only alongside worktree_auto_dropped: the closed worktree path; an existing checkout remains until pending cleanup.' },
165
165
  ]
166
166
  : []),
167
167
  ],
@@ -172,7 +172,7 @@ function makePushLeaf(kind) {
172
172
  ...(kind === 'final'
173
173
  ? [
174
174
  'Marks the node done (status + intent) server-side; its engine shuts down on next stop.',
175
- 'When this node owns a clean managed worktree already contained in its local base, the server auto-drops it instead of blocking on `node worktree close`. A clean worktree whose exact tip exists on any branch on origin may also finalize while retaining its branch and checkout; uncommitted changes or a tip absent from origin still block with open_managed_worktree.',
175
+ 'When this node owns a clean managed worktree already contained in its local base, the server closes its record with cleanup pending instead of blocking on `node worktree close`. A clean worktree whose exact tip exists on any branch on origin may also finalize while retaining its branch and checkout; uncommitted changes or a tip absent from origin still block with open_managed_worktree.',
176
176
  ]
177
177
  : []),
178
178
  ],
@@ -242,7 +242,7 @@ function makePushLeaf(kind) {
242
242
  const line = kind === 'final'
243
243
  ? 'Result recorded — node finished; its engine shuts down on next stop. Nothing more to do here: STOP your turn immediately. Reply with just "Done." and nothing else.'
244
244
  : `Progress report fanned to ${n} subscriber(s).`;
245
- const dropNote = r['worktree_auto_dropped'] === true ? `\n\nAuto-dropped a clean, zero-commit managed worktree at ${String(r['worktree_auto_dropped_path'])} — nothing to land.` : '';
245
+ const dropNote = r['worktree_auto_dropped'] === true ? `\n\nClosed a clean managed-worktree record at ${String(r['worktree_auto_dropped_path'])} — nothing to land; cleanup remains pending if the checkout still exists.` : '';
246
246
  return `${line}\n\n- report: ${r['report_path']}${dropNote}`;
247
247
  },
248
248
  });
@@ -5,7 +5,9 @@ import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { closeDb } from '../canvas/db.js';
7
7
  import { claimActionDelivery, enqueueActionDelivery, readActionDelivery, } from '../canvas/human-deliveries.js';
8
- import { actionDeliveryBackoffMs, actionDeliverySettlementForOutcome, deliverAction, } from '../../daemon/human/deliver-action.js';
8
+ import { deliveryBackoffMs } from '../canvas/delivery-contract.js';
9
+ import { deliverySettlementForOutcome } from '../../daemon/delivery/run-delivery.js';
10
+ import { deliverAction } from '../../daemon/human/deliver-action.js';
9
11
  let home;
10
12
  const priorHome = process.env['CRTR_HOME'];
11
13
  beforeEach(() => {
@@ -25,14 +27,14 @@ after(() => {
25
27
  else
26
28
  process.env['CRTR_HOME'] = priorHome;
27
29
  });
28
- test('action delivery retries follow the durable backoff and exit boundary', () => {
29
- assert.equal(actionDeliveryBackoffMs(1), 5_000);
30
- assert.equal(actionDeliveryBackoffMs(2), 10_000);
31
- assert.equal(actionDeliveryBackoffMs(3), 20_000);
32
- assert.equal(actionDeliveryBackoffMs(11), 3_600_000);
33
- assert.equal(actionDeliveryBackoffMs(100), 3_600_000);
30
+ test('delivery retries follow the durable backoff and exit boundary', () => {
31
+ assert.equal(deliveryBackoffMs(1), 5_000);
32
+ assert.equal(deliveryBackoffMs(2), 10_000);
33
+ assert.equal(deliveryBackoffMs(3), 20_000);
34
+ assert.equal(deliveryBackoffMs(11), 3_600_000);
35
+ assert.equal(deliveryBackoffMs(100), 3_600_000);
34
36
  const now = 1_000_000;
35
- const outcome = (exitCode, signal = null) => actionDeliverySettlementForOutcome({
37
+ const outcome = (exitCode, signal = null) => deliverySettlementForOutcome({
36
38
  exitCode,
37
39
  signal,
38
40
  timedOut: false,
@@ -50,14 +52,14 @@ test('action delivery retries follow the durable backoff and exit boundary', ()
50
52
  });
51
53
  assert.equal(outcome(79).state, 'pending');
52
54
  assert.equal(outcome(null, 'SIGTERM').state, 'pending');
53
- assert.equal(actionDeliverySettlementForOutcome({
55
+ assert.equal(deliverySettlementForOutcome({
54
56
  exitCode: null,
55
57
  signal: null,
56
58
  timedOut: true,
57
59
  stderr: '',
58
60
  }, 1, now).state, 'pending');
59
61
  // The kill escalation's signal is the only way to tell the graceful term from the SIGKILL.
60
- assert.deepEqual(actionDeliverySettlementForOutcome({
62
+ assert.deepEqual(deliverySettlementForOutcome({
61
63
  exitCode: null,
62
64
  signal: 'SIGKILL',
63
65
  timedOut: true,
@@ -67,7 +69,7 @@ test('action delivery retries follow the durable backoff and exit boundary', ()
67
69
  nextAttemptAt: now + 5_000,
68
70
  failure: { kind: 'timeout', signal: 'SIGKILL', stderr: 'stalled' },
69
71
  });
70
- assert.equal(actionDeliverySettlementForOutcome({
72
+ assert.equal(deliverySettlementForOutcome({
71
73
  exitCode: null,
72
74
  signal: null,
73
75
  timedOut: false,
@@ -33,7 +33,7 @@ import { tmpdir } from 'node:os';
33
33
  import { join } from 'node:path';
34
34
  import { spawn, spawnSync } from 'node:child_process';
35
35
  import { createNode, deleteNode, getNode, subscribe, updateNode } from '../../canvas/canvas.js';
36
- import { armCron, hasPendingCancelOnWakeCron } from '../../canvas/crons.js';
36
+ import { armCron, getCron } from '../../canvas/crons.js';
37
37
  import { readInboxSince } from '../../feed/inbox.js';
38
38
  import { closeDb } from '../../canvas/db.js';
39
39
  import { apiSocketPath } from '../../canvas/paths.js';
@@ -304,11 +304,11 @@ test('a recovery relaunch keeps the armed deadline; a wake revive consumes it',
304
304
  const recovered = h.fabricateBrokerNode({ status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'uuid-1' });
305
305
  armDeadline(recovered, 'cron-recovery');
306
306
  await reviveNode(recovered, { resume: true, recovery: true, capacity: 'freeze' });
307
- assert.equal(hasPendingCancelOnWakeCron(recovered), true, 'the daemon replacing a dead broker is not a wake — the deadline survives so the wait can still settle');
307
+ assert.equal(getCron('cron-recovery') !== null, true, 'the daemon replacing a dead broker is not a wake — the deadline survives so the wait can still settle');
308
308
  const woken = h.fabricateBrokerNode({ status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'uuid-2' });
309
309
  armDeadline(woken, 'cron-wake');
310
310
  await reviveNode(woken, { resume: true, capacity: 'freeze' });
311
- assert.equal(hasPendingCancelOnWakeCron(woken), false, 'a wake revive won the race and cancels the deadline');
311
+ assert.equal(getCron('cron-wake'), null, 'a wake revive won the race and cancels the deadline');
312
312
  }
313
313
  finally {
314
314
  await h.dispose();
@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { closeDb, MIGRATIONS } from '../canvas/db.js';
8
8
  import { createNode, getRow } from '../canvas/canvas.js';
9
- import { armOutcomeDelivery, claimOutcomeDelivery, outcomeDeliveryBackoffMs, readOutcomeDelivery, recoverRunningOutcomeDeliveries, settleOutcomeDelivery, } from '../canvas/node-outcome-deliveries.js';
9
+ import { armOutcomeDelivery, claimOutcomeDelivery, readOutcomeDelivery, recoverRunningOutcomeDeliveries, settleOutcomeDelivery, } from '../canvas/node-outcome-deliveries.js';
10
10
  import { transition } from '../runtime/lifecycle.js';
11
11
  import { composeNodeOutcomeDocument } from '../runtime/outcome-document.js';
12
12
  import { pushFinal } from '../feed/feed.js';
@@ -153,11 +153,6 @@ test('outcome delivery state is fenced, recoverable, and follows the durable bac
153
153
  nextAttemptAt: readOutcomeDelivery('recover').nextAttemptAt,
154
154
  claimOwner: readOutcomeDelivery('recover').claimOwner,
155
155
  }, { state: 'pending', nextAttemptAt: recoveredAt, claimOwner: null });
156
- assert.equal(outcomeDeliveryBackoffMs(1), 5_000);
157
- assert.equal(outcomeDeliveryBackoffMs(2), 10_000);
158
- assert.equal(outcomeDeliveryBackoffMs(3), 20_000);
159
- assert.equal(outcomeDeliveryBackoffMs(11), 3_600_000);
160
- assert.equal(outcomeDeliveryBackoffMs(100), 3_600_000);
161
156
  });
162
157
  test('v39 migrates a v38 database and backfills finalized and terminal rows', () => {
163
158
  const dbPath = join(home, 'v38.db');
@@ -22,6 +22,7 @@ import assert from 'node:assert/strict';
22
22
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { appendInbox, readCursor, readInboxSince } from '../../feed/inbox.js';
25
+ import { acquireCronRunLease, armCron, setCronState } from '../../canvas/crons.js';
25
26
  import { registerWarmSpare, subscribe, subscriptionsOf, updateNode } from '../../canvas/index.js';
26
27
  import { isPidAlive } from '../../canvas/pid.js';
27
28
  import { reviveNode } from '../../runtime/revive.js';
@@ -45,6 +46,47 @@ async function awaitReleased(nodeId) {
45
46
  return node?.status === 'idle' && node.intent === 'idle-release' && typeof node.pi_pid === 'number' && !isPidAlive(node.pi_pid);
46
47
  }, { label: `idle-release broker exit for ${nodeId}` });
47
48
  }
49
+ async function assertCronWakeReleases(nodeId, running) {
50
+ const cronId = `${nodeId}-cron`;
51
+ const target = h.fabricateBrokerNode({ id: nodeId, parent: root, status: 'active' });
52
+ const now = new Date();
53
+ armCron({
54
+ cron_id: cronId,
55
+ name: cronId,
56
+ created_by: null,
57
+ command: 'printf wake',
58
+ fire_at: new Date(now.getTime() + 3_600_000).toISOString(),
59
+ recur: null,
60
+ tz: null,
61
+ expires_at: null,
62
+ anchor_node: nodeId,
63
+ cancel_on_wake: false,
64
+ cwd: h.home,
65
+ env_json: null,
66
+ profile: null,
67
+ scope: 'profile',
68
+ run_timeout_s: 30,
69
+ overlap: 'skip',
70
+ on_output: 'always',
71
+ sink: JSON.stringify({ node: nodeId }),
72
+ tier: 'normal',
73
+ });
74
+ if (running) {
75
+ assert.equal(acquireCronRunLease(cronId, 'seam-fixture', process.pid, now.toISOString(), false), true);
76
+ setCronState(cronId, 'paused');
77
+ }
78
+ updateNode(target, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
79
+ await reviveNode(target, { resume: true, capacity: 'freeze' });
80
+ await h.awaitBoot(target);
81
+ await h.stop(target);
82
+ await awaitReleased(target);
83
+ await h.awaitFleetExit(target);
84
+ assert.equal(h.node(target)?.status, 'idle', `${running ? 'running' : 'scheduled'} cron did not trigger a stop-guard reprompt`);
85
+ }
86
+ test('an anchored node-sink cron releases a terminal broker when scheduled or in flight', { timeout: 60_000 }, async () => {
87
+ await assertCronWakeReleases('scheduled-cron-wake', false);
88
+ await assertCronWakeReleases('running-cron-wake', true);
89
+ });
48
90
  test('idle-release settles its real watcher before exit; daemon revives exactly once for a later row', { timeout: 30_000 }, async () => {
49
91
  // Reproduce the observed #353/#362 edge: real pi reported
50
92
  // ctx.hasPendingMessages() === true at the sole agent_settled even though the
@@ -9,7 +9,9 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { createNode, deliverAndSettleMessageWait, getNode, subscribe, setMessageWait, setStatus } from '../canvas/canvas.js';
12
+ import { armCron, cancelCron, setCronState } from '../canvas/crons.js';
12
13
  import { closeDb, openDb } from '../canvas/db.js';
14
+ import { hasLiveObligation } from '../../daemon/reconcilers/live-obligation.js';
13
15
  import { contextDir } from '../canvas/paths.js';
14
16
  import { bashJobPaths } from '../bash-jobs.js';
15
17
  import { evaluateStop } from '../runtime/stop-guard.js';
@@ -29,6 +31,29 @@ function node(id, over = {}) {
29
31
  };
30
32
  }
31
33
  const noSignals = { pushedFinal: false, requestedHumanReply: false };
34
+ function arm(overrides) {
35
+ armCron({
36
+ name: overrides.cron_id,
37
+ created_by: null,
38
+ command: 'printf cron',
39
+ fire_at: new Date(Date.now() + 3_600_000).toISOString(),
40
+ recur: null,
41
+ tz: null,
42
+ expires_at: null,
43
+ anchor_node: null,
44
+ cancel_on_wake: false,
45
+ cwd: home,
46
+ env_json: null,
47
+ profile: null,
48
+ scope: 'profile',
49
+ run_timeout_s: 30,
50
+ overlap: 'skip',
51
+ on_output: 'always',
52
+ sink: '{}',
53
+ tier: 'normal',
54
+ ...overrides,
55
+ });
56
+ }
32
57
  before(() => {
33
58
  home = mkdtempSync(join(tmpdir(), 'crtr-stopguard-'));
34
59
  process.env['CRTR_HOME'] = home;
@@ -82,6 +107,45 @@ test('a TERMINAL node awaiting a background bash job may dorm until its completi
82
107
  assert.equal(completed.action, 'reprompt');
83
108
  assert.equal(completed.reason, 'stalled');
84
109
  });
110
+ test('a pending node-sink cron wake releases terminal nodes and holds unattended nodes', () => {
111
+ createNode(node('target'));
112
+ const assertsWake = (id) => {
113
+ assert.deepEqual(evaluateStop('target', noSignals), { action: 'allow', reason: 'scheduled' }, id);
114
+ assert.equal(hasLiveObligation('target'), true, `${id} holds the unattended node`);
115
+ };
116
+ const assertsNoWake = (id) => {
117
+ assert.equal(evaluateStop('target', noSignals).action, 'reprompt', id);
118
+ assert.equal(hasLiveObligation('target'), false, `${id} does not hold the unattended node`);
119
+ };
120
+ arm({ cron_id: 'scheduled', anchor_node: 'target', sink: JSON.stringify({ node: 'target' }) });
121
+ assertsWake('an anchored scheduled always sink');
122
+ cancelCron('scheduled');
123
+ arm({ cron_id: 'running', anchor_node: 'target', sink: JSON.stringify({ node: 'target' }) });
124
+ openDb().prepare("UPDATE crons SET run_state = 'running' WHERE cron_id = ?").run('running');
125
+ setCronState('running', 'paused');
126
+ assertsWake('a paused in-flight sink');
127
+ cancelCron('running');
128
+ arm({ cron_id: 'unanchored-change', on_output: 'on-change', sink: JSON.stringify({ node: 'target' }) });
129
+ assertsWake('an unanchored on-change sink');
130
+ cancelCron('unanchored-change');
131
+ arm({ cron_id: 'other-target', sink: JSON.stringify({ node: 'other' }) });
132
+ assertsNoWake('a sink for another node');
133
+ cancelCron('other-target');
134
+ arm({ cron_id: 'silent', on_output: 'silent', sink: JSON.stringify({ node: 'target' }) });
135
+ assertsNoWake('a silent cron');
136
+ cancelCron('silent');
137
+ arm({ cron_id: 'on-failure', on_output: 'on-failure', sink: JSON.stringify({ node: 'target' }) });
138
+ assertsNoWake('an on-failure cron');
139
+ cancelCron('on-failure');
140
+ arm({ cron_id: 'expired', expires_at: new Date(Date.now() - 1_000).toISOString(), sink: JSON.stringify({ node: 'target' }) });
141
+ assertsNoWake('an expired idle cron');
142
+ cancelCron('expired');
143
+ arm({ cron_id: 'canceled', sink: JSON.stringify({ node: 'target' }) });
144
+ cancelCron('canceled');
145
+ assertsNoWake('a canceled cron');
146
+ arm({ cron_id: 'deadline', anchor_node: 'target', cancel_on_wake: true, on_output: 'on-failure' });
147
+ assertsWake('an active deadline');
148
+ });
85
149
  test('only an explicit live controller wait permits dormancy; ancestry alone does not', () => {
86
150
  createNode(node('parent', { lifecycle: 'terminal', status: 'active' }));
87
151
  createNode(node('child', { parent: 'parent' }));
@@ -36,6 +36,28 @@ export declare function readJobPgid(contextDir: string, jobId: string): number |
36
36
  /** The optional human-readable job label. Jobs created before labels existed
37
37
  * have no file and deliberately project null. */
38
38
  export declare function readJobPurpose(contextDir: string, jobId: string): string | null;
39
+ /** Scheduler niceness every command a node runs is started with. Agent work —
40
+ * builds, test suites, typechecks — sits below the daemon, the brokers, the
41
+ * viewers, and the person's own shell, so a fan-out of release builds cannot
42
+ * starve them: the scheduler only honors this under contention and it costs
43
+ * nothing on an idle host. */
44
+ export declare const DEFAULT_BASH_NICENESS = 10;
45
+ /** The politest priority the scheduler accepts. A NEGATIVE niceness (more
46
+ * priority than the broker itself) needs root: `nice` prints a permission error
47
+ * into the command's own log and runs it at the inherited priority anyway, so
48
+ * the override range stops at 0. */
49
+ export declare const MAX_BASH_NICENESS = 19;
50
+ /** Per-node override of the bash niceness, written by `crtr node config --nice`
51
+ * and read by the valve when it starts each command. Node-local like the job
52
+ * dirs beside it, so a change applies to the node's very next command without
53
+ * waiting for a revive. */
54
+ export declare function bashNicenessPath(contextDir: string): string;
55
+ /** The niceness this node's commands run at. Any missing, malformed, or
56
+ * out-of-range file reads as the default rather than an odd priority. */
57
+ export declare function readBashNiceness(contextDir: string): number;
58
+ /** Set the node's bash niceness. The caller owns validation; the store keeps
59
+ * one integer so the valve's read stays a single file read per command. */
60
+ export declare function writeBashNiceness(contextDir: string, niceness: number): void;
39
61
  /** The auto-background cancel deadline (epoch ms), or undefined for a job with
40
62
  * no deadline (a deliberate handoff, or a legacy job dir). A non-finite or
41
63
  * malformed file reads as no deadline rather than an instant expiry. */
@@ -3,7 +3,7 @@
3
3
  // job.bg, while foreground completion renames it to job.done. The detached
4
4
  // supervisor can therefore outlive pi and still decide whether to report exit.
5
5
  import { randomBytes } from 'node:crypto';
6
- import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
6
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  const MAX_BASH_JOB_PURPOSE_BYTES = 280;
9
9
  /** A purpose is display text, never shell input. Keep the file-backed control
@@ -66,6 +66,43 @@ export function readJobPurpose(contextDir, jobId) {
66
66
  return null;
67
67
  }
68
68
  }
69
+ /** Scheduler niceness every command a node runs is started with. Agent work —
70
+ * builds, test suites, typechecks — sits below the daemon, the brokers, the
71
+ * viewers, and the person's own shell, so a fan-out of release builds cannot
72
+ * starve them: the scheduler only honors this under contention and it costs
73
+ * nothing on an idle host. */
74
+ export const DEFAULT_BASH_NICENESS = 10;
75
+ /** The politest priority the scheduler accepts. A NEGATIVE niceness (more
76
+ * priority than the broker itself) needs root: `nice` prints a permission error
77
+ * into the command's own log and runs it at the inherited priority anyway, so
78
+ * the override range stops at 0. */
79
+ export const MAX_BASH_NICENESS = 19;
80
+ /** Per-node override of the bash niceness, written by `crtr node config --nice`
81
+ * and read by the valve when it starts each command. Node-local like the job
82
+ * dirs beside it, so a change applies to the node's very next command without
83
+ * waiting for a revive. */
84
+ export function bashNicenessPath(contextDir) {
85
+ return join(bashJobsDir(contextDir), 'niceness');
86
+ }
87
+ /** The niceness this node's commands run at. Any missing, malformed, or
88
+ * out-of-range file reads as the default rather than an odd priority. */
89
+ export function readBashNiceness(contextDir) {
90
+ try {
91
+ const n = Number(readFileSync(bashNicenessPath(contextDir), 'utf8').trim());
92
+ if (!Number.isInteger(n) || n < 0 || n > MAX_BASH_NICENESS)
93
+ return DEFAULT_BASH_NICENESS;
94
+ return n;
95
+ }
96
+ catch {
97
+ return DEFAULT_BASH_NICENESS;
98
+ }
99
+ }
100
+ /** Set the node's bash niceness. The caller owns validation; the store keeps
101
+ * one integer so the valve's read stays a single file read per command. */
102
+ export function writeBashNiceness(contextDir, niceness) {
103
+ mkdirSync(bashJobsDir(contextDir), { recursive: true });
104
+ writeFileSync(bashNicenessPath(contextDir), `${Math.trunc(niceness)}\n`);
105
+ }
69
106
  /** The auto-background cancel deadline (epoch ms), or undefined for a job with
70
107
  * no deadline (a deliberate handoff, or a legacy job dir). A non-finite or
71
108
  * malformed file reads as no deadline rather than an instant expiry. */