@north-light/crouter 0.3.252 → 0.3.254

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 (39) hide show
  1. package/dist/api/dto/config.d.ts +2 -0
  2. package/dist/builtin-memory/04-base-worker-exploring.md +13 -0
  3. package/dist/builtin-memory/04-base-worker.md +1 -4
  4. package/dist/builtin-memory/05-kinds/explore/00-base.md +2 -2
  5. package/dist/builtin-memory/05-kinds/explore/01-orchestrator.md +4 -2
  6. package/dist/builtin-memory/explore/exploration-doc.md +27 -0
  7. package/dist/clients/attach/__tests__/group-activity.test.js +81 -0
  8. package/dist/clients/attach/render/group-activity.d.ts +9 -1
  9. package/dist/clients/attach/render/group-activity.js +75 -111
  10. package/dist/clients/attach/render/group-recap.js +24 -9
  11. package/dist/clients/attach/viewer.js +508 -509
  12. package/dist/commands/memory/delete.js +2 -0
  13. package/dist/commands/node/lifecycle.js +21 -6
  14. package/dist/core/__tests__/daemon-boot.test.js +5 -5
  15. package/dist/core/__tests__/preview-mirror-cap.test.js +16 -0
  16. package/dist/core/exclusive-lock.js +13 -2
  17. package/dist/core/io.d.ts +11 -2
  18. package/dist/core/io.js +10 -11
  19. package/dist/core/runtime/revive.js +6 -1
  20. package/dist/core/substrate/surface-match.js +7 -0
  21. package/dist/daemon/__tests__/autostart-storm.test.d.ts +1 -0
  22. package/dist/daemon/__tests__/autostart-storm.test.js +166 -0
  23. package/dist/daemon/__tests__/integration/migration-startup.test.js +4 -7
  24. package/dist/daemon/api/handlers/nodes.js +124 -4
  25. package/dist/daemon/api/map.d.ts +1 -1
  26. package/dist/daemon/api/map.js +3 -2
  27. package/dist/daemon/crtrd-cli.js +15 -1
  28. package/dist/daemon/crtrd.js +14 -1
  29. package/dist/daemon/manage.d.ts +24 -3
  30. package/dist/daemon/manage.js +98 -12
  31. package/dist/daemon/startup-policy.d.ts +5 -0
  32. package/dist/daemon/startup-policy.js +5 -0
  33. package/dist/migrations/activation.d.ts +9 -0
  34. package/dist/migrations/activation.js +14 -1
  35. package/dist/pi-extensions/canvas-bash-valve.js +4 -0
  36. package/dist/pi-extensions/canvas-preview-result.js +32 -7
  37. package/dist/shared/generated-context.js +1 -1
  38. package/package.json +1 -1
  39. package/runtime.lock.json +2 -2
@@ -37,6 +37,7 @@ export const deleteLeaf = defineLeaf({
37
37
  selectorParam('dir', {}, 'Deletes from that one store, including a non-winning duplicate no target view resolves to.'),
38
38
  ],
39
39
  output: [
40
+ { name: 'name', type: 'string', required: true, constraint: 'Full canonical name of the document that was deleted, as resolution settled it.' },
40
41
  { name: 'scope', type: 'string', required: true, constraint: 'Scope the document was deleted from: node, user, project, or profile.' },
41
42
  { name: 'path', type: 'string', required: true, constraint: 'Absolute path of the file that was removed.' },
42
43
  { name: 'log_path', type: 'string', required: true, constraint: 'Absolute path to the document’s revision log, which OUTLIVES the document — the delete is appended to it as a tombstone carrying the full final text, still readable with `crtr memory history`.' },
@@ -87,6 +88,7 @@ export const deleteLeaf = defineLeaf({
87
88
  // this same log — one log per physical path over its whole life.
88
89
  appendHistoryRecord(logPath, buildHistoryRecord({ op: 'delete', before, after: '' }));
89
90
  return {
91
+ name: doc.name,
90
92
  scope: doc.scope,
91
93
  path: doc.path,
92
94
  log_path: logPath,
@@ -109,15 +109,15 @@ const nodeClose = defineLeaf({
109
109
  return `Closed ${r['node_id']} and its exclusive subtree — ${r['count']} node(s) closed${spared > 0 ? `, ${spared} spared (still managed from outside the subtree)` : ''}.`;
110
110
  },
111
111
  });
112
- // node config — reconfigure a node's settings headlessly (model/lifecycle/kind/mode/name)
112
+ // node config — reconfigure a node's settings headlessly (model/lifecycle/kind/mode/name/cwd repair)
113
113
  const NODE_CONFIG_MODEL_EFFECT = 'Model change first: a live broker switches immediately over its socket; a dormant node persists the durable model override and launch recipe for its next revive.';
114
114
  export const nodeConfig = defineLeaf({
115
115
  name: 'config',
116
- description: 'reconfigure a node\'s settings headlessly — model, lifecycle (terminal/resident), kind, mode, name',
117
- whenToUse: 'you want to reconfigure a node\'s settings headlessly after spawn — swap its model/provider, flip it terminal↔resident (interactable), respecialize its kind, switch base↔orchestrator mode, or rename it — without opening its viewer. The durable attribute setter; reuses the same recipes a revive reads. Use `node promote` for the opinionated self-promotion path, and `node lifecycle` for teardown actions (recycle/close/demote).',
116
+ description: 'reconfigure a node\'s settings headlessly — model, lifecycle (terminal/resident), kind, mode, name; repair a missing cwd',
117
+ whenToUse: 'you want to reconfigure a node\'s settings headlessly after spawn — swap its model/provider, flip it terminal↔resident (interactable), respecialize its kind, switch base↔orchestrator mode, or rename it — without opening its viewer. It also repairs a dormant node whose recorded cwd is gone, starting its next revive in a fresh conversation. The durable attribute setter; reuses the same recipes a revive reads. Use `node promote` for the opinionated self-promotion path, and `node lifecycle` for teardown actions (recycle/close/demote).',
118
118
  help: {
119
119
  name: 'node config',
120
- summary: 'reconfigure a node\'s settings headlessly — model, lifecycle, kind, mode, name',
120
+ summary: 'reconfigure a node\'s settings headlessly — model, lifecycle, kind, mode, name; repair a missing cwd',
121
121
  params: [
122
122
  { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Target node id. Defaults to the node in --pane, else CRTR_NODE_ID.' },
123
123
  { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id to resolve the node from. Defaults to $TMUX_PANE.' },
@@ -140,20 +140,23 @@ export const nodeConfig = defineLeaf({
140
140
  { 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
141
  { 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
142
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Rename the node; if the node has a live window, also rename that viewer window.' },
143
+ { 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.' },
143
144
  ],
144
145
  output: [
145
- { name: 'node_id', type: 'string', required: true, constraint: 'The reconfigured node.' },
146
+ { name: 'node_id', type: 'string', required: false, constraint: 'The reconfigured node, present for ordinary config changes.' },
146
147
  { name: 'model', type: 'string', required: false, constraint: 'The resolved model, present only when --model changed it.' },
147
148
  { name: 'lifecycle', type: 'string', required: false, constraint: 'The new lifecycle, present only when --lifecycle changed it.' },
148
149
  { name: 'kind', type: 'string', required: false, constraint: 'The new kind, present only when --kind changed it.' },
149
150
  { name: 'mode', type: 'string', required: false, constraint: 'The new mode, present only when --mode changed it.' },
150
151
  { name: 'name', type: 'string', required: false, constraint: 'The new name, present only when --name changed it.' },
152
+ { name: 'cwd', type: 'string', required: false, constraint: 'The lexically resolved replacement directory, present only after a cwd repair.' },
151
153
  ],
152
154
  outputKind: 'object',
153
155
  effects: [
154
156
  NODE_CONFIG_MODEL_EFFECT,
155
157
  'Kind, mode, and lifecycle changes rebuild the launch spec from the fresh node meta; setting mode to orchestrator seeds a roadmap scaffold if absent.',
156
158
  'Name changes update the row and, when the node already has a live window, rename that viewer window to the new full name.',
159
+ 'A cwd repair writes only the lexically resolved cwd and clears saved Pi session pointers; the old transcript and durable node work remain untouched.',
157
160
  ],
158
161
  dynamicState: () => kindsStateBlock(),
159
162
  },
@@ -165,6 +168,10 @@ export const nodeConfig = defineLeaf({
165
168
  // kind is validated against the caller's installed personas here for the
166
169
  // clean listing error, and re-validated server-side against the TARGET
167
170
  // node's scope as a backstop.
171
+ const cwdSpec = input['cwd'];
172
+ if (cwdSpec !== undefined && ['model', 'kind', 'lifecycle', 'mode', 'name'].some((flag) => input[flag] !== undefined)) {
173
+ throw new InputError({ error: 'cwd_not_exclusive', message: 'cwd repair cannot be combined with another config flag', field: 'cwd', next: 'Pass --cwd alone.' });
174
+ }
168
175
  const modelSpec = input['model']?.trim();
169
176
  if (modelSpec === '') {
170
177
  throw new InputError({ error: 'empty_spec', message: 'a model spec is required', field: 'model', next: 'Run `crtr node config --model -h` and read the focused contract before retrying.' });
@@ -203,12 +210,18 @@ export const nodeConfig = defineLeaf({
203
210
  patch.name = nameSpec;
204
211
  applied.push('name');
205
212
  }
213
+ if (cwdSpec !== undefined) {
214
+ patch.cwd = cwdSpec;
215
+ applied.push('cwd');
216
+ }
206
217
  if (applied.length === 0) {
207
- throw new InputError({ error: 'no_change', message: 'no config changes requested', next: 'Pass at least one of --model, --lifecycle, --kind, --mode, --name.' });
218
+ throw new InputError({ error: 'no_change', message: 'no config changes requested', next: 'Pass at least one of --model, --lifecycle, --kind, --mode, --name, --cwd.' });
208
219
  }
209
220
  const detail = await cliClient()
210
221
  .patchConfig(nodeId, patch)
211
222
  .catch((err) => rethrowAsCliError(err, 'List nodes with `crtr node inspect list`.'));
223
+ if (cwdSpec !== undefined)
224
+ return { cwd: detail.cwd };
212
225
  const result = { node_id: nodeId };
213
226
  if (applied.includes('model'))
214
227
  result.model = detail.model_override ?? modelSpec;
@@ -223,6 +236,8 @@ export const nodeConfig = defineLeaf({
223
236
  return result;
224
237
  },
225
238
  render: (r) => {
239
+ if (r['cwd'] !== undefined)
240
+ return `Repaired cwd: ${r['cwd']}.`;
226
241
  const bits = [];
227
242
  if (r['model'] !== undefined)
228
243
  bits.push(`model=${r['model']}`);
@@ -193,17 +193,17 @@ test('verifyDaemonStartup waits for pidfile, live pid, and serving socket before
193
193
  assert.equal(socketServing, true, 'the Unix API socket must serve before success');
194
194
  assert.equal(slept >= 40, true, 'the guard must actually poll through socket readiness');
195
195
  });
196
- test('runDaemon awaits activation before ownership and readiness', () => {
196
+ test('runDaemon claims ownership before activation and readiness', () => {
197
197
  const source = readFileSync(fileURLToPath(new URL('../../daemon/crtrd.ts', import.meta.url)), 'utf8');
198
198
  const runDaemonAt = source.indexOf('export async function runDaemon');
199
199
  const activationAt = source.indexOf('await ensureOnDiskMigrations();', runDaemonAt);
200
200
  const ownershipAt = source.indexOf('claimDaemonOwnership(', runDaemonAt);
201
201
  const readinessAt = source.indexOf('writePidfile();', runDaemonAt);
202
202
  assert.ok(runDaemonAt >= 0, 'runDaemon exists');
203
- assert.ok(activationAt > runDaemonAt, 'runDaemon awaits on-disk activation');
204
- assert.equal(source.slice(runDaemonAt, activationAt).includes('await '), false, 'activation is the first awaited operation');
205
- assert.ok(activationAt < ownershipAt, 'activation precedes the ownership claim that opens canvas.db');
206
- assert.ok(ownershipAt < readinessAt, 'ownership follows activation before readiness is published');
203
+ assert.ok(ownershipAt > runDaemonAt, 'runDaemon claims ownership');
204
+ assert.equal(source.slice(runDaemonAt, ownershipAt).includes('await '), false, 'ownership is claimed before startup can await');
205
+ assert.ok(ownershipAt < activationAt, 'ownership precedes on-disk activation');
206
+ assert.ok(activationAt < readinessAt, 'activation completes before readiness is published');
207
207
  });
208
208
  // Regression for the cold-guest recreate blocker: the production startup window
209
209
  // must tolerate a slower cold start than the old 500ms bound.
@@ -3,6 +3,9 @@
3
3
  // this channel — bypassing pi's 50 KB stdout truncation entirely — and five
4
4
  // such calls made a 16 MB transcript that Northlight could no longer open.
5
5
  // The writer is the one choke point, so the writer is what this pins.
6
+ //
7
+ // The file accumulates one record per crtr invocation in a bash call, so the
8
+ // bound is on the file total: chaining commands must not be a way past it.
6
9
  import { strict as assert } from 'node:assert';
7
10
  import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
8
11
  import { tmpdir } from 'node:os';
@@ -35,3 +38,16 @@ test('a bounded record still mirrors verbatim', () => {
35
38
  assert.equal(record.path, 'integration run');
36
39
  assert.equal(record.result.output, 'small');
37
40
  });
41
+ test('chained invocations accumulate as JSONL, and stop at the file budget', () => {
42
+ const resultPath = join(dir, 'chained.json');
43
+ process.env[PREVIEW_RESULT_PATH_ENV] = resultPath;
44
+ const quarter = 'x'.repeat(Math.floor(PREVIEW_RESULT_MAX_BYTES / 4));
45
+ for (let i = 0; i < 6; i += 1) {
46
+ beginPreview('memory read');
47
+ publishPreviewResult({ i, output: quarter });
48
+ }
49
+ const lines = readFileSync(resultPath, 'utf8').split('\n').filter((line) => line !== '');
50
+ assert.equal(lines.length, 3, 'records land in order until the budget is spent, then are dropped whole');
51
+ assert.deepEqual(lines.map((line) => JSON.parse(line).result.i), [0, 1, 2]);
52
+ assert.ok(readFileSync(resultPath).byteLength <= PREVIEW_RESULT_MAX_BYTES, 'the accumulated file stays within the bound');
53
+ });
@@ -1,9 +1,16 @@
1
1
  import { existsSync, mkdirSync, readdirSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  const MARKER_PREFIX = 'owner.';
4
+ // Contention backoff. The first polls stay tight so an uncontended hand-off is
5
+ // still effectively immediate; the interval then doubles to a ceiling so a lock
6
+ // held across a long operation (a corpus migration, say) cannot turn a crowd of
7
+ // waiters into a filesystem hot loop — N waiters at a fixed 10ms is N*100
8
+ // tryAcquire() syscall bursts per second on one directory.
4
9
  const POLL_MS = 10;
10
+ const POLL_MAX_MS = 250;
5
11
  const DEFAULT_STALE_MS = 30_000;
6
12
  const DEFAULT_TIMEOUT_MS = 5_000;
13
+ function nextPollMs(current) { return Math.min(current * 2, POLL_MAX_MS); }
7
14
  function pause(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
8
15
  function pauseAsync(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
9
16
  function markerPath(path, token) { return `${path}/${MARKER_PREFIX}${token}`; }
@@ -107,12 +114,14 @@ function tryAcquire(path, staleMs) {
107
114
  export function withExclusiveDirectoryLock(path, operation, options = {}) {
108
115
  const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
109
116
  const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
117
+ let pollMs = POLL_MS;
110
118
  let lock = tryAcquire(path, staleMs);
111
119
  while (lock === null) {
112
120
  if (Date.now() >= deadline) {
113
121
  throw options.timeoutError?.() ?? new Error(`timed out waiting for the exclusive lock: ${path}`);
114
122
  }
115
- pause(POLL_MS);
123
+ pause(Math.min(pollMs, Math.max(0, deadline - Date.now())));
124
+ pollMs = nextPollMs(pollMs);
116
125
  lock = tryAcquire(path, staleMs);
117
126
  }
118
127
  try {
@@ -127,12 +136,14 @@ export function withExclusiveDirectoryLock(path, operation, options = {}) {
127
136
  export async function withExclusiveDirectoryLockAsync(path, operation, options = {}) {
128
137
  const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
129
138
  const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
139
+ let pollMs = POLL_MS;
130
140
  let lock = tryAcquire(path, staleMs);
131
141
  while (lock === null) {
132
142
  if (Date.now() >= deadline) {
133
143
  throw options.timeoutError?.() ?? new Error(`timed out waiting for the exclusive lock: ${path}`);
134
144
  }
135
- await pauseAsync(Math.min(POLL_MS, Math.max(0, deadline - Date.now())));
145
+ await pauseAsync(Math.min(pollMs, Math.max(0, deadline - Date.now())));
146
+ pollMs = nextPollMs(pollMs);
136
147
  lock = tryAcquire(path, staleMs);
137
148
  }
138
149
  try {
package/dist/core/io.d.ts CHANGED
@@ -3,7 +3,12 @@ import { type ExitCodeValue } from '../types.js';
3
3
  import { ApiError } from '../api/index.js';
4
4
  /** The private result mirror for one crtr CLI invocation launched by the
5
5
  * canvas bash-preview extension. This deliberately never enters stdout: bash
6
- * text is model context, while Pi tool-result details are viewer-only. */
6
+ * text is model context, while Pi tool-result details are viewer-only.
7
+ *
8
+ * One bash tool call may run several crtr invocations (`read a && read b`), so
9
+ * the mirror file is JSONL: one record per invocation, appended in order. Each
10
+ * invocation is its own process and `beginPreview` resets this module's state,
11
+ * so the appends compose across them with no shared state or locking. */
7
12
  export interface CrtrPreviewRecord {
8
13
  path: string;
9
14
  result?: Record<string, unknown>;
@@ -17,7 +22,11 @@ export interface CrtrPreviewRecord {
17
22
  * stdout truncation never sees this channel. The mirror write is the one
18
23
  * choke point every record passes, so the bound lives here: an oversized
19
24
  * record is dropped, and the viewer falls back to rendering the agent-facing
20
- * stdout text, which pi bounds itself. */
25
+ * stdout text, which pi bounds itself.
26
+ *
27
+ * Because records accumulate, this bounds the FILE TOTAL, not one line: a
28
+ * record that would overrun the remaining budget is dropped whole, so every
29
+ * line on disk stays valid JSON. */
21
30
  export declare const PREVIEW_RESULT_MAX_BYTES: number;
22
31
  /** Begin collecting the current leaf's structured response, if its caller
23
32
  * supplied the private per-tool-call result path. */
package/dist/core/io.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // the model, not data it parses); structured errors; stderr is diagnostics only
4
4
  // and never carries the result. The raw JSON object is available behind the
5
5
  // `--json` global for tooling. See the cli-design reference.
6
- import { renameSync, writeFileSync } from 'node:fs';
6
+ import { appendFileSync, statSync } from 'node:fs';
7
7
  import { CrtrError } from './errors.js';
8
8
  import { ExitCode } from '../types.js';
9
9
  import { PREVIEW_RESULT_PATH_ENV } from './preview-result-path.js';
@@ -19,7 +19,11 @@ let preview;
19
19
  * stdout truncation never sees this channel. The mirror write is the one
20
20
  * choke point every record passes, so the bound lives here: an oversized
21
21
  * record is dropped, and the viewer falls back to rendering the agent-facing
22
- * stdout text, which pi bounds itself. */
22
+ * stdout text, which pi bounds itself.
23
+ *
24
+ * Because records accumulate, this bounds the FILE TOTAL, not one line: a
25
+ * record that would overrun the remaining budget is dropped whole, so every
26
+ * line on disk stays valid JSON. */
23
27
  export const PREVIEW_RESULT_MAX_BYTES = 256 * 1024;
24
28
  /** Begin collecting the current leaf's structured response, if its caller
25
29
  * supplied the private per-tool-call result path. */
@@ -32,20 +36,15 @@ function publishPreview(record) {
32
36
  if (resultPath === undefined || resultPath === '')
33
37
  return;
34
38
  const line = `${JSON.stringify({ path: preview?.path ?? '', ...record })}\n`;
35
- if (Buffer.byteLength(line, 'utf8') > PREVIEW_RESULT_MAX_BYTES)
36
- return;
37
- const tempPath = `${resultPath}.${process.pid}.tmp`;
38
39
  try {
39
- writeFileSync(tempPath, line, 'utf8');
40
- renameSync(tempPath, resultPath);
40
+ const spent = statSync(resultPath, { throwIfNoEntry: false })?.size ?? 0;
41
+ if (spent + Buffer.byteLength(line, 'utf8') > PREVIEW_RESULT_MAX_BYTES)
42
+ return;
43
+ appendFileSync(resultPath, line, 'utf8');
41
44
  }
42
45
  catch {
43
46
  // Preview transport is optional UI metadata. Its failure must never alter
44
47
  // the CLI's stdout/error contract.
45
- try {
46
- writeFileSync(tempPath, '', 'utf8');
47
- }
48
- catch { /* nothing to clean */ }
49
48
  }
50
49
  }
51
50
  /** Publish the single-object or collected JSONL result after leaf dispatch. */
@@ -230,10 +230,15 @@ function launchRevive(nodeId, meta, opts) {
230
230
  // fail deep inside the crash/doctrine-wake catch below, where it would
231
231
  // surface as an opaque `internal error` (toErrorBody masks every 5xx).
232
232
  if (!existsSync(meta.cwd)) {
233
+ const next = meta.fork_from != null
234
+ ? `Restore ${meta.cwd}, then revive the fork normally with \`crtr node lifecycle revive ${nodeId}\`.`
235
+ : meta.managed_worktree?.state === 'open'
236
+ ? 'Restore or reconcile the recorded checkout, then use the managed-worktree close or abandon recovery.'
237
+ : `Recreate ${meta.cwd}, or repair it with \`crtr node config --cwd <path> --node ${nodeId}\`; durable node work resumes in a fresh conversation. Then retry \`crtr node lifecycle revive ${nodeId}\`.`;
233
238
  throw new InputError({
234
239
  error: 'cwd_missing',
235
240
  message: `${nodeId} cannot be revived — its recorded cwd no longer exists: ${meta.cwd}`,
236
- next: `Recreate ${meta.cwd}, or update the node's cwd, then retry \`crtr node lifecycle revive ${nodeId}\`.`,
241
+ next,
237
242
  });
238
243
  }
239
244
  // Lazy host_kind coerce (§C): every launch now uses the broker host. Persist
@@ -102,6 +102,10 @@ export function matchesMemoryReadEntry(entry, routingAnchor, subject, name) {
102
102
  return false;
103
103
  return entry.match.some((g) => memoryReadGlobMatches(g, routingAnchor, name));
104
104
  }
105
+ /** Split a command into shell segments on unquoted `;`, newline, `&&`, `||`,
106
+ * and `|`. A single `&`, subshell parentheses, and backticks are left inside
107
+ * their segment — this is a best-effort guardrail, not a shell parser.
108
+ * Heredoc bodies are a separate pre-match concern (see `stripHeredocs`). */
105
109
  function splitCommandSegments(command) {
106
110
  const segments = [];
107
111
  let current = '';
@@ -152,6 +156,7 @@ function splitCommandSegments(command) {
152
156
  finish();
153
157
  return segments;
154
158
  }
159
+ /** Tokenize one segment into shell words, marking quoted spans opaque. */
155
160
  function tokenizeCommandSegment(segment) {
156
161
  const tokens = [];
157
162
  let text = '';
@@ -207,6 +212,8 @@ function tokenizeCommandSegment(segment) {
207
212
  finish();
208
213
  return tokens;
209
214
  }
215
+ /** Every non-empty command segment as its env-stripped token list: leading
216
+ * `VAR=value` assignments are dropped so `tokens[0]` is the executable. */
210
217
  function commandSegments(command) {
211
218
  return splitCommandSegments(command)
212
219
  .map(tokenizeCommandSegment)
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,166 @@
1
+ import { test, beforeEach, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { spawn } from 'node:child_process';
7
+ import { DaemonStartupBlockedError, daemonAutostartSuppression, ensureDaemon, resetDaemonAutostart, verifyDaemonStartup, } from '../manage.js';
8
+ import { DAEMON_EXIT_STARTUP_BLOCKED } from '../startup-policy.js';
9
+ import { withExclusiveDirectoryLockAsync } from '../../core/exclusive-lock.js';
10
+ // REGRESSION (daemon respawn storm): a daemon whose startup hit a STANDING
11
+ // condition — a blocked on-disk migration — exited, its attach clients saw a
12
+ // cold socket, autostarted a replacement, and that replacement hit the same
13
+ // wall. Nothing tracked the repeat and nothing reaped the losers, so failed
14
+ // starts accumulated into a pile that pegged the machine. Three properties keep
15
+ // that from recurring: the terminal exit is TYPED, a typed failure SUPPRESSES
16
+ // further autostarts, and an ordinary failure at least BACKS OFF.
17
+ const settle = async () => { await new Promise((resolve) => setImmediate(resolve)); };
18
+ // The test lane exports CRTR_NO_DAEMON_AUTOSTART=1 so no suite ever spawns a
19
+ // real daemon. These tests drive ensureDaemon through injected deps — nothing
20
+ // is spawned — so the guard has to come off for the gate under test to be
21
+ // reachable at all.
22
+ const autostartEnv = process.env['CRTR_NO_DAEMON_AUTOSTART'];
23
+ beforeEach(() => {
24
+ delete process.env['CRTR_NO_DAEMON_AUTOSTART'];
25
+ resetDaemonAutostart();
26
+ });
27
+ after(() => {
28
+ if (autostartEnv === undefined)
29
+ delete process.env['CRTR_NO_DAEMON_AUTOSTART'];
30
+ else
31
+ process.env['CRTR_NO_DAEMON_AUTOSTART'] = autostartEnv;
32
+ resetDaemonAutostart();
33
+ });
34
+ test('the blocked-startup exit code surfaces as a typed terminal error, not a generic exit', async () => {
35
+ let exited = null;
36
+ const child = spawn(process.execPath, ['-e', `process.exit(${DAEMON_EXIT_STARTUP_BLOCKED})`], { stdio: 'ignore' });
37
+ child.once('exit', (code, signal) => {
38
+ exited = { code, signal };
39
+ });
40
+ await assert.rejects(async () => verifyDaemonStartup(77, 2_000, {
41
+ readPidfile: () => null,
42
+ isPidAlive: () => false,
43
+ isDaemonServing: async () => false,
44
+ childExited: () => exited,
45
+ }), (error) => {
46
+ assert.ok(error instanceof DaemonStartupBlockedError, 'must be typed, so the spawner can stop retrying');
47
+ assert.match(error.message, /crtr sys migrate/);
48
+ return true;
49
+ });
50
+ });
51
+ test('a blocked start suppresses every later autostart instead of respawning into the same wall', async () => {
52
+ let spawns = 0;
53
+ const deps = {
54
+ isDaemonRunning: () => false,
55
+ spawnDaemon: async () => {
56
+ spawns += 1;
57
+ throw new DaemonStartupBlockedError(4242);
58
+ },
59
+ };
60
+ ensureDaemon(deps);
61
+ await settle();
62
+ assert.equal(spawns, 1);
63
+ assert.match(daemonAutostartSuppression() ?? '', /crtr sys migrate/);
64
+ // The storm: every redial of every attach client lands here. None may spawn.
65
+ for (let i = 0; i < 50; i++)
66
+ ensureDaemon(deps);
67
+ await settle();
68
+ assert.equal(spawns, 1, 'a standing failure must never be retried');
69
+ });
70
+ test('an ordinary spawn failure backs off, then retries once the window passes', async () => {
71
+ let spawns = 0;
72
+ let now = 1_000_000;
73
+ const deps = {
74
+ isDaemonRunning: () => false,
75
+ now: () => now,
76
+ spawnDaemon: async () => {
77
+ spawns += 1;
78
+ throw new Error('dist/daemon/crtrd-cli.js missing');
79
+ },
80
+ };
81
+ ensureDaemon(deps);
82
+ await settle();
83
+ assert.equal(spawns, 1);
84
+ for (let i = 0; i < 20; i++)
85
+ ensureDaemon(deps);
86
+ await settle();
87
+ assert.equal(spawns, 1, 'retries inside the backoff window must be dropped');
88
+ now += 5_000;
89
+ ensureDaemon(deps);
90
+ await settle();
91
+ assert.equal(spawns, 2, 'the window expiring re-opens exactly one attempt');
92
+ // Backoff grows, so the second failure buys a longer quiet period than the first.
93
+ now += 5_000;
94
+ ensureDaemon(deps);
95
+ await settle();
96
+ assert.equal(spawns, 2, 'the second failure must back off further than the first');
97
+ });
98
+ test('a spawn already in flight blocks a concurrent autostart', async () => {
99
+ let spawns = 0;
100
+ let release = () => { };
101
+ const gate = new Promise((resolve) => { release = resolve; });
102
+ const deps = {
103
+ isDaemonRunning: () => false,
104
+ spawnDaemon: async () => {
105
+ spawns += 1;
106
+ await gate;
107
+ return { started: true, pid: 1 };
108
+ },
109
+ };
110
+ ensureDaemon(deps);
111
+ for (let i = 0; i < 10; i++)
112
+ ensureDaemon(deps);
113
+ await settle();
114
+ assert.equal(spawns, 1, 'concurrent callers must not each spawn a daemon');
115
+ release();
116
+ await settle();
117
+ });
118
+ test('a success clears the backoff so the next cold socket starts immediately', async () => {
119
+ let spawns = 0;
120
+ let fail = true;
121
+ let now = 1_000_000;
122
+ const deps = {
123
+ isDaemonRunning: () => false,
124
+ now: () => now,
125
+ spawnDaemon: async () => {
126
+ spawns += 1;
127
+ if (fail)
128
+ throw new Error('transient');
129
+ return { started: true, pid: 1 };
130
+ },
131
+ };
132
+ ensureDaemon(deps);
133
+ await settle();
134
+ fail = false;
135
+ now += 5_000;
136
+ ensureDaemon(deps);
137
+ await settle();
138
+ assert.equal(spawns, 2);
139
+ ensureDaemon(deps);
140
+ await settle();
141
+ assert.equal(spawns, 3, 'a healthy start must not leave a backoff behind');
142
+ });
143
+ // The pile pegged the machine because each waiter re-probed the lock every 10ms
144
+ // with no backoff. Backing off must not make an uncontended hand-off sluggish.
145
+ test('a contended lock waiter still acquires promptly once the holder releases', async () => {
146
+ const home = mkdtempSync(join(tmpdir(), 'crtr-lock-backoff-'));
147
+ const lockPath = join(home, 'contended.lock');
148
+ try {
149
+ let released = 0;
150
+ const holder = withExclusiveDirectoryLockAsync(lockPath, async () => {
151
+ await new Promise((resolve) => setTimeout(resolve, 300));
152
+ released = Date.now();
153
+ }, { timeoutMs: 5_000 });
154
+ await new Promise((resolve) => setTimeout(resolve, 20));
155
+ let acquired = 0;
156
+ const waiter = withExclusiveDirectoryLockAsync(lockPath, async () => {
157
+ acquired = Date.now();
158
+ }, { timeoutMs: 5_000 });
159
+ await Promise.all([holder, waiter]);
160
+ assert.ok(acquired >= released, 'the waiter must not enter before the holder leaves');
161
+ assert.ok(acquired - released < 1_000, `hand-off took ${acquired - released}ms — backoff ceiling is too coarse`);
162
+ }
163
+ finally {
164
+ rmSync(home, { recursive: true, force: true });
165
+ }
166
+ });
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, watch, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, statSync, watch, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import test from 'node:test';
5
5
  import { onDiskMigrationIdentity } from '../../../migrations/registry.js';
@@ -52,7 +52,7 @@ test('source daemon migrates the pre-migration corpus and writes its marker befo
52
52
  assert.deepEqual(JSON.parse(readFileSync(markerPath, 'utf8')), onDiskMigrationIdentity());
53
53
  assert.equal(existsSync(fixture.pidfile), true, 'health readiness follows the pidfile readiness mirror');
54
54
  assert.equal(existsSync(fixture.socket), true, 'the unique unix API listener is bound at readiness');
55
- assert.equal(existsSync(dbPath), true, 'daemon ownership opens canvas.db only after migration');
55
+ assert.equal(existsSync(dbPath), true, 'daemon ownership has opened canvas.db by readiness');
56
56
  assert.ok(statSync(markerPath).mtimeMs <= statSync(fixture.pidfile).mtimeMs, 'the completion marker is written no later than the readiness pidfile');
57
57
  assert.ok(statSync(markerPath).mtimeMs <= statSync(fixture.socket).mtimeMs, 'the completion marker is written no later than the API listener');
58
58
  }
@@ -62,7 +62,7 @@ test('source daemon migrates the pre-migration corpus and writes its marker befo
62
62
  assert.deepEqual(exit, { code: 0, signal: null }, `daemon must stop cleanly; stderr:\n${daemon.stderr()}`);
63
63
  }
64
64
  });
65
- test('a real migration blocker exits before database, pidfile, listeners, or broker effects', { timeout: 30_000 }, async () => {
65
+ test('a real migration blocker exits before pidfile, listeners, or broker effects', { timeout: 30_000 }, async () => {
66
66
  const fixture = await createSourceDaemonFixture();
67
67
  const blockerPath = writeUserMemory(fixture.home, 'blocked.md', `---\nkind: knowledge\nbroken: [\n---\n# Blocked\n`);
68
68
  const effectNames = new Set();
@@ -82,14 +82,11 @@ test('a real migration blocker exits before database, pidfile, listeners, or bro
82
82
  assert.match(daemon.stderr(), new RegExp(blockerPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
83
83
  assert.match(daemon.stderr(), /Repair: crtr sys migrate/);
84
84
  assert.equal(wasReady, false, 'the unique TCP endpoint never answered health');
85
- const databaseFiles = readdirSync(fixture.canvasHome).filter((name) => name.startsWith('canvas.db'));
86
- assert.deepEqual(databaseFiles, [], 'canvas.db and its journals were never created');
87
85
  assert.equal(existsSync(fixture.pidfile), false, 'readiness pidfile was never created');
88
86
  assert.equal(existsSync(fixture.socket), false, 'unix API listener was never created');
89
87
  assert.equal(existsSync(join(fixture.canvasHome, 'on-disk-migrations.json')), false, 'blocker never gets a completion marker');
90
- assert.equal(existsSync(join(fixture.canvasHome, 'nodes')), false, 'no node/broker state was created');
91
88
  assert.deepEqual(brokerPidsForCanvas(fixture.canvasHome), [], 'no broker process declares the isolated canvas');
92
- assert.deepEqual([...effectNames].filter((name) => name.startsWith('canvas.db') || name === 'ready.pid' || name === 'crtrd.sock' || name === 'nodes'), [], `filesystem watcher saw no transient daemon effects (all events: ${[...effectNames].join(', ')})`);
89
+ assert.deepEqual([...effectNames].filter((name) => name === 'ready.pid' || name === 'crtrd.sock'), [], `filesystem watcher saw no transient readiness or broker effects (all events: ${[...effectNames].join(', ')})`);
93
90
  }
94
91
  finally {
95
92
  fixture.dispose();