@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.
- package/dist/api/dto/config.d.ts +2 -0
- package/dist/builtin-memory/04-base-worker-exploring.md +13 -0
- package/dist/builtin-memory/04-base-worker.md +1 -4
- package/dist/builtin-memory/05-kinds/explore/00-base.md +2 -2
- package/dist/builtin-memory/05-kinds/explore/01-orchestrator.md +4 -2
- package/dist/builtin-memory/explore/exploration-doc.md +27 -0
- package/dist/clients/attach/__tests__/group-activity.test.js +81 -0
- package/dist/clients/attach/render/group-activity.d.ts +9 -1
- package/dist/clients/attach/render/group-activity.js +75 -111
- package/dist/clients/attach/render/group-recap.js +24 -9
- package/dist/clients/attach/viewer.js +508 -509
- package/dist/commands/memory/delete.js +2 -0
- package/dist/commands/node/lifecycle.js +21 -6
- package/dist/core/__tests__/daemon-boot.test.js +5 -5
- package/dist/core/__tests__/preview-mirror-cap.test.js +16 -0
- package/dist/core/exclusive-lock.js +13 -2
- package/dist/core/io.d.ts +11 -2
- package/dist/core/io.js +10 -11
- package/dist/core/runtime/revive.js +6 -1
- package/dist/core/substrate/surface-match.js +7 -0
- package/dist/daemon/__tests__/autostart-storm.test.d.ts +1 -0
- package/dist/daemon/__tests__/autostart-storm.test.js +166 -0
- package/dist/daemon/__tests__/integration/migration-startup.test.js +4 -7
- package/dist/daemon/api/handlers/nodes.js +124 -4
- package/dist/daemon/api/map.d.ts +1 -1
- package/dist/daemon/api/map.js +3 -2
- package/dist/daemon/crtrd-cli.js +15 -1
- package/dist/daemon/crtrd.js +14 -1
- package/dist/daemon/manage.d.ts +24 -3
- package/dist/daemon/manage.js +98 -12
- package/dist/daemon/startup-policy.d.ts +5 -0
- package/dist/daemon/startup-policy.js +5 -0
- package/dist/migrations/activation.d.ts +9 -0
- package/dist/migrations/activation.js +14 -1
- package/dist/pi-extensions/canvas-bash-valve.js +4 -0
- package/dist/pi-extensions/canvas-preview-result.js +32 -7
- package/dist/shared/generated-context.js +1 -1
- package/package.json +1 -1
- 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:
|
|
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
|
|
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(
|
|
204
|
-
assert.equal(source.slice(runDaemonAt,
|
|
205
|
-
assert.ok(
|
|
206
|
-
assert.ok(
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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();
|