@north-light/crouter 0.3.302 → 0.3.304
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/__tests__/integration/client.test.js +37 -36
- package/dist/api/client.d.ts +32 -55
- package/dist/api/client.js +102 -98
- package/dist/api/dto/messages.d.ts +6 -5
- package/dist/api/errors.d.ts +3 -0
- package/dist/api/errors.js +10 -0
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/builtin-memory/internal/plugins.md +1 -1
- package/dist/clients/attach/viewer.js +532 -532
- package/dist/commands/__tests__/seam/daemon-status.test.d.ts +1 -0
- package/dist/commands/__tests__/seam/daemon-status.test.js +29 -0
- package/dist/commands/api-client.d.ts +3 -3
- package/dist/commands/api-client.js +3 -3
- package/dist/commands/memory/read.js +2 -1
- package/dist/commands/node/bash.js +6 -6
- package/dist/commands/node/message.js +3 -3
- package/dist/commands/sys/daemon.js +11 -13
- package/dist/core/__tests__/bash-guard.test.d.ts +1 -0
- package/dist/core/__tests__/bash-guard.test.js +190 -0
- package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +0 -2
- package/dist/core/__tests__/daemon-boot.test.js +1 -1
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +51 -11
- package/dist/core/__tests__/helpers/harness.d.ts +2 -0
- package/dist/core/__tests__/helpers/harness.js +9 -0
- package/dist/core/__tests__/integration/worktree-land.test.js +50 -0
- package/dist/core/__tests__/integration/worktree-reap.test.js +184 -5
- package/dist/core/__tests__/parse-argv-stdin-secret.test.js +14 -0
- package/dist/core/__tests__/seam/broker-attach-stream.test.js +13 -0
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +57 -0
- package/dist/core/__tests__/seam/broker-startup-diagnostics.test.d.ts +1 -0
- package/dist/core/__tests__/seam/broker-startup-diagnostics.test.js +82 -0
- package/dist/core/bash-guard.d.ts +6 -0
- package/dist/core/bash-guard.js +393 -0
- package/dist/core/bash-jobs.d.ts +20 -8
- package/dist/core/bash-jobs.js +40 -21
- package/dist/core/canvas/types.d.ts +4 -2
- package/dist/core/command.js +1 -1
- package/dist/core/fault-classifier.d.ts +1 -1
- package/dist/core/runtime/broker/event-projection.d.ts +0 -3
- package/dist/core/runtime/broker/event-projection.js +2 -15
- package/dist/core/runtime/broker/fault-retry.d.ts +4 -0
- package/dist/core/runtime/broker/fault-retry.js +74 -6
- package/dist/core/runtime/broker-persona-guidance.js +12 -0
- package/dist/core/runtime/broker.js +0 -5
- package/dist/core/runtime/fault.js +1 -1
- package/dist/core/runtime/host.js +10 -1
- package/dist/core/runtime/spawn.js +5 -6
- package/dist/core/shell-segments.d.ts +42 -0
- package/dist/core/shell-segments.js +169 -0
- package/dist/core/substrate/surface-match.d.ts +0 -9
- package/dist/core/substrate/surface-match.js +4 -160
- package/dist/core/worktree-close.d.ts +4 -0
- package/dist/core/worktree-close.js +225 -0
- package/dist/core/worktree-containment.d.ts +14 -0
- package/dist/core/worktree-containment.js +48 -0
- package/dist/core/worktree-mutation-async.d.ts +13 -0
- package/dist/core/worktree-mutation-async.js +281 -0
- package/dist/core/worktree-sweep.js +17 -79
- package/dist/core/worktree.d.ts +1 -0
- package/dist/core/worktree.js +1 -1
- package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +10 -8
- package/dist/daemon/api/__tests__/seam/api-server.test.js +70 -1
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/messages.js +7 -5
- package/dist/daemon/api/handlers/reports.js +3 -2
- package/dist/daemon/api/handlers/worktree.js +7 -6
- package/dist/daemon/fleet.d.ts +1 -1
- package/dist/daemon/fleet.js +30 -12
- package/dist/daemon/manage.d.ts +8 -5
- package/dist/daemon/manage.js +63 -40
- package/dist/daemon/reconcilers/managed-worktree-sweep.js +12 -0
- package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +0 -6
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +1 -13
- package/dist/daemon/reconcilers/node-lifecycle/wants-execution.d.ts +5 -0
- package/dist/daemon/reconcilers/node-lifecycle/wants-execution.js +11 -0
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +83 -0
- package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.js +133 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -3
- package/dist/pi-extensions/canvas-bash-valve.js +75 -77
- package/dist/pi-extensions/canvas-inbox-watcher.js +0 -2
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
|
@@ -10,6 +10,7 @@ import { tmpdir } from 'node:os';
|
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { gitSync } from '../../git.js';
|
|
12
12
|
import { createManagedWorktree, closeManagedWorktree, managedWorktreePath, rollbackManagedWorktree } from '../../worktree.js';
|
|
13
|
+
import { closeManagedWorktreeAsync } from '../../worktree-close.js';
|
|
13
14
|
import { reconcileManagedWorktree } from '../../worktree-sweep.js';
|
|
14
15
|
import { createNode, getNode } from '../../canvas/canvas.js';
|
|
15
16
|
import { closeDb } from '../../canvas/db.js';
|
|
@@ -223,6 +224,55 @@ test('closeManagedWorktree fast-forwards local main to the worktree tip without
|
|
|
223
224
|
const originMainAfter = gitSync(['rev-parse', 'origin/main'], sourceDir).stdout.trim();
|
|
224
225
|
assert.equal(originMainAfter, originMainBefore, 'origin/main must not move — landing is local-only');
|
|
225
226
|
});
|
|
227
|
+
test('async close leaves an upstream-contained tip and a diverged local base byte-for-byte unchanged (#958)', async () => {
|
|
228
|
+
const repo = mkdtempSync(join(tmpdir(), 'crtr-worktree-contained-close-'));
|
|
229
|
+
const remote = mkdtempSync(join(tmpdir(), 'crtr-worktree-contained-close-remote-'));
|
|
230
|
+
try {
|
|
231
|
+
run(['init', '-b', 'main'], repo);
|
|
232
|
+
run(['config', 'user.email', 'test@example.com'], repo);
|
|
233
|
+
run(['config', 'user.name', 'Test'], repo);
|
|
234
|
+
writeFileSync(join(repo, 'README.md'), 'initial\n');
|
|
235
|
+
run(['add', '.'], repo);
|
|
236
|
+
run(['commit', '-m', 'initial'], repo);
|
|
237
|
+
run(['init', '--bare'], remote);
|
|
238
|
+
run(['remote', 'add', 'origin', remote], repo);
|
|
239
|
+
run(['push', '-u', 'origin', 'main'], repo);
|
|
240
|
+
const nodeId = 'wt-contained-diverged-base';
|
|
241
|
+
const wt = createManagedWorktree(repo, nodeId);
|
|
242
|
+
createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
|
|
243
|
+
writeFileSync(join(wt.path, 'delivered.txt'), 'already delivered\n');
|
|
244
|
+
run(['add', '.'], wt.path);
|
|
245
|
+
run(['commit', '-m', 'delivered work'], wt.path);
|
|
246
|
+
const deliveredHead = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
|
|
247
|
+
run(['push', 'origin', `${wt.branch}:main`], wt.path);
|
|
248
|
+
run(['fetch', 'origin'], repo);
|
|
249
|
+
writeFileSync(join(repo, 'local-only.txt'), 'must not receive a replay\n');
|
|
250
|
+
run(['add', '.'], repo);
|
|
251
|
+
run(['commit', '-m', 'independent local base work'], repo);
|
|
252
|
+
const before = {
|
|
253
|
+
childHead: gitSync(['rev-parse', 'HEAD'], wt.path).stdout,
|
|
254
|
+
baseRef: gitSync(['rev-parse', 'refs/heads/main'], repo).stdout,
|
|
255
|
+
baseHead: gitSync(['rev-parse', 'HEAD'], repo).stdout,
|
|
256
|
+
status: gitSync(['status', '--porcelain=v1'], repo).stdout,
|
|
257
|
+
worktree: gitSync(['worktree', 'list', '--porcelain'], repo).stdout,
|
|
258
|
+
index: readFileSync(join(repo, '.git', 'index')),
|
|
259
|
+
};
|
|
260
|
+
const result = await closeManagedWorktreeAsync(nodeId);
|
|
261
|
+
assert.equal(result.landed_sha, deliveredHead);
|
|
262
|
+
assert.deepEqual({
|
|
263
|
+
childHead: gitSync(['rev-parse', 'HEAD'], wt.path).stdout,
|
|
264
|
+
baseRef: gitSync(['rev-parse', 'refs/heads/main'], repo).stdout,
|
|
265
|
+
baseHead: gitSync(['rev-parse', 'HEAD'], repo).stdout,
|
|
266
|
+
status: gitSync(['status', '--porcelain=v1'], repo).stdout,
|
|
267
|
+
worktree: gitSync(['worktree', 'list', '--porcelain'], repo).stdout,
|
|
268
|
+
index: readFileSync(join(repo, '.git', 'index')),
|
|
269
|
+
}, before, 'a delivered child must not rebase or move the divergent local base');
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
rmSync(repo, { recursive: true, force: true });
|
|
273
|
+
rmSync(remote, { recursive: true, force: true });
|
|
274
|
+
}
|
|
275
|
+
});
|
|
226
276
|
test('custom local base is recorded and fast-forwarded without touching main or origin', () => {
|
|
227
277
|
const nodeId = 'wt-custom-base';
|
|
228
278
|
run(['branch', 'feature/custom-base'], sourceDir);
|
|
@@ -4,20 +4,28 @@
|
|
|
4
4
|
// (worktree-land.test.ts) only so the two run as two parallel files.
|
|
5
5
|
import { test, before, after, beforeEach } from 'node:test';
|
|
6
6
|
import assert from 'node:assert/strict';
|
|
7
|
-
import { mkdtempSync, rmSync, existsSync, writeFileSync, chmodSync } from 'node:fs';
|
|
8
|
-
import {
|
|
7
|
+
import { mkdtempSync, mkdirSync, renameSync, rmSync, existsSync, writeFileSync, chmodSync, realpathSync } from 'node:fs';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { homedir, tmpdir } from 'node:os';
|
|
9
10
|
import { join } from 'node:path';
|
|
10
11
|
import { gitSync } from '../../git.js';
|
|
11
|
-
import {
|
|
12
|
+
import { withExclusiveDirectoryLockAsync } from '../../exclusive-lock.js';
|
|
13
|
+
import { createManagedWorktree, closeManagedWorktree, worktreeLockPathForCommonDir } from '../../worktree.js';
|
|
12
14
|
import { reconcileManagedWorktree } from '../../worktree-sweep.js';
|
|
13
15
|
import { createApiServer } from '../../../daemon/api/server.js';
|
|
16
|
+
import { ManagedWorktreeSweepReconciler } from '../../../daemon/reconcilers/managed-worktree-sweep.js';
|
|
14
17
|
import { apiSocketPath } from '../../canvas/paths.js';
|
|
15
|
-
import { CrtrClient } from '../../../api/index.js';
|
|
18
|
+
import { ApiError, CrtrClient } from '../../../api/index.js';
|
|
16
19
|
import { createNode, getNode } from '../../canvas/canvas.js';
|
|
20
|
+
import { headlessBrokerHost } from '../../runtime/host.js';
|
|
21
|
+
import { reviveNode } from '../../runtime/revive.js';
|
|
22
|
+
import { bindTestFleet } from '../helpers/fleet.js';
|
|
23
|
+
import { resetFleetForTests } from '../../runtime/fleet.js';
|
|
17
24
|
import { closeDb } from '../../canvas/db.js';
|
|
18
25
|
let home;
|
|
19
26
|
let remoteDir;
|
|
20
27
|
let sourceDir;
|
|
28
|
+
const scratchRoot = join(homedir(), '.crouter', 'canvas', 'scratch');
|
|
21
29
|
function run(args, cwd) {
|
|
22
30
|
const res = gitSync(args, cwd);
|
|
23
31
|
if (res.status !== 0) {
|
|
@@ -42,7 +50,8 @@ before(() => {
|
|
|
42
50
|
// `/var` → `/private/var` symlink, so the recorded managed-worktree path and
|
|
43
51
|
// git's canonical `worktree list` output differ as strings while naming one
|
|
44
52
|
// directory. Every path-ownership check must judge identity, not spelling.
|
|
45
|
-
|
|
53
|
+
mkdirSync(scratchRoot, { recursive: true });
|
|
54
|
+
home = mkdtempSync(join(scratchRoot, 'crtr-worktree-home-'));
|
|
46
55
|
process.env['CRTR_HOME'] = home;
|
|
47
56
|
// Bare "remote" wired up as origin so the test can prove landing NEVER pushes
|
|
48
57
|
// to it (managed worktrees are based on and land onto local `main` only).
|
|
@@ -97,6 +106,104 @@ test('autoDropCleanManagedWorktree drops a clean, zero-commit worktree', async (
|
|
|
97
106
|
const swept = getNode(nodeId)?.managed_worktree;
|
|
98
107
|
assert.deepEqual({ state: swept?.state, cleanup: swept?.cleanup }, { state: 'closed', cleanup: 'complete' });
|
|
99
108
|
});
|
|
109
|
+
test('a dead managed child keeps its open checkout through the sweep and revives there (#962)', async () => {
|
|
110
|
+
const repo = mkdtempSync(join(scratchRoot, 'crtr-worktree-962-'));
|
|
111
|
+
const nodeId = 'wt-sweep-dead-open';
|
|
112
|
+
const originalLaunch = headlessBrokerHost.launch;
|
|
113
|
+
try {
|
|
114
|
+
run(['init', '-b', 'main'], repo);
|
|
115
|
+
run(['config', 'user.email', 'test@example.com'], repo);
|
|
116
|
+
run(['config', 'user.name', 'Test'], repo);
|
|
117
|
+
writeFileSync(join(repo, 'README.md'), 'hello\n');
|
|
118
|
+
run(['add', '.'], repo);
|
|
119
|
+
run(['commit', '-m', 'init'], repo);
|
|
120
|
+
const wt = createManagedWorktree(repo, nodeId);
|
|
121
|
+
createNode(node(nodeId, { cwd: wt.path, status: 'dead', managed_worktree: wt }));
|
|
122
|
+
const reconciler = new ManagedWorktreeSweepReconciler();
|
|
123
|
+
let pass;
|
|
124
|
+
reconciler.run(Date.now(), {
|
|
125
|
+
lifecycle: {
|
|
126
|
+
acceptsDetachedWork: () => true,
|
|
127
|
+
registerDetached: (work) => { pass = work; },
|
|
128
|
+
},
|
|
129
|
+
fleet: { isClaimed: () => false },
|
|
130
|
+
});
|
|
131
|
+
await pass;
|
|
132
|
+
const afterSweep = getNode(nodeId);
|
|
133
|
+
assert.equal(afterSweep.managed_worktree?.state, 'open', 'a failed launch does not authorize disposal');
|
|
134
|
+
assert.equal(afterSweep.cwd, wt.path, 'the sweep leaves the durable launch cwd unchanged');
|
|
135
|
+
assert.ok(existsSync(wt.path), 'the isolated checkout remains available for recovery');
|
|
136
|
+
const testFleet = bindTestFleet();
|
|
137
|
+
const launchCwds = [];
|
|
138
|
+
headlessBrokerHost.launch = (id, _inv, options) => {
|
|
139
|
+
launchCwds.push(options.cwd);
|
|
140
|
+
const handle = { pid: process.pid, exited: new Promise(() => { }) };
|
|
141
|
+
testFleet.fleet.register(id, handle);
|
|
142
|
+
return handle;
|
|
143
|
+
};
|
|
144
|
+
const revived = await reviveNode(nodeId, { resume: false, capacity: 'freeze' });
|
|
145
|
+
assert.equal(revived.outcome, 'launched');
|
|
146
|
+
assert.deepEqual(launchCwds, [wt.path], 'a fresh revive launches in the retained managed checkout');
|
|
147
|
+
const closedId = 'wt-sweep-closed';
|
|
148
|
+
const closedWt = createManagedWorktree(repo, closedId);
|
|
149
|
+
createNode(node(closedId, { cwd: closedWt.path, status: 'done', managed_worktree: closedWt }));
|
|
150
|
+
closeManagedWorktree(closedId);
|
|
151
|
+
const cleanup = await reconcileManagedWorktree(closedId, { stillEligible: () => true });
|
|
152
|
+
assert.equal(cleanup.status, 'complete', 'a successful close is still physically cleaned after its engine exits');
|
|
153
|
+
assert.equal(getNode(closedId)?.cwd, repo, 'a successful close deliberately repoints future launch cwd to the repository root');
|
|
154
|
+
launchCwds.length = 0;
|
|
155
|
+
const closedRevive = await reviveNode(closedId, { resume: false, capacity: 'freeze' });
|
|
156
|
+
assert.equal(closedRevive.outcome, 'launched');
|
|
157
|
+
assert.deepEqual(launchCwds, [repo], 'a closed node still revives against the repository root');
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
headlessBrokerHost.launch = originalLaunch;
|
|
161
|
+
resetFleetForTests();
|
|
162
|
+
rmSync(repo, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
test('the sweep does not conclude a missing record while its branch survives in a moved repository', async () => {
|
|
166
|
+
const repo = mkdtempSync(join(scratchRoot, 'crtr-worktree-moved-root-'));
|
|
167
|
+
const movedRepo = join(scratchRoot, `crtr-worktree-moved-root-destination-${Date.now()}`);
|
|
168
|
+
const nodeId = 'wt-sweep-moved-root';
|
|
169
|
+
try {
|
|
170
|
+
run(['init', '-b', 'main'], repo);
|
|
171
|
+
run(['config', 'user.email', 'test@example.com'], repo);
|
|
172
|
+
run(['config', 'user.name', 'Test'], repo);
|
|
173
|
+
writeFileSync(join(repo, 'README.md'), 'hello\n');
|
|
174
|
+
run(['add', '.'], repo);
|
|
175
|
+
run(['commit', '-m', 'init'], repo);
|
|
176
|
+
const wt = createManagedWorktree(repo, nodeId);
|
|
177
|
+
createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
|
|
178
|
+
run(['worktree', 'remove', wt.path], repo);
|
|
179
|
+
renameSync(repo, movedRepo);
|
|
180
|
+
const outcome = await reconcileManagedWorktree(nodeId, { stillEligible: () => true });
|
|
181
|
+
assert.deepEqual(outcome.status === 'refused' ? outcome.reason : undefined, 'repo_unavailable');
|
|
182
|
+
assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'a missing recorded path cannot prove the worktree branch is gone');
|
|
183
|
+
assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/${wt.branch}`], movedRepo).status, 0, 'the moved repository still holds the managed branch');
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
rmSync(repo, { recursive: true, force: true });
|
|
187
|
+
rmSync(movedRepo, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
test('the sweep retries containment when the base upstream advances', async () => {
|
|
191
|
+
const nodeId = 'wt-sweep-upstream-advance';
|
|
192
|
+
const wt = createManagedWorktree(sourceDir, nodeId);
|
|
193
|
+
createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
|
|
194
|
+
writeFileSync(join(wt.path, 'delivered.txt'), 'delivered remotely\n');
|
|
195
|
+
run(['add', 'delivered.txt'], wt.path);
|
|
196
|
+
run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'delivered work'], wt.path);
|
|
197
|
+
const refused = await reconcileManagedWorktree(nodeId, { stillEligible: () => true });
|
|
198
|
+
assert.equal(refused.status, 'refused');
|
|
199
|
+
const before = getNode(nodeId)?.managed_worktree?.sweep?.observed;
|
|
200
|
+
assert.ok(before, 'the initial refusal records the observation that caused backoff');
|
|
201
|
+
run(['push', 'origin', `${wt.branch}:main`], wt.path);
|
|
202
|
+
run(['fetch', 'origin'], sourceDir);
|
|
203
|
+
const completed = await reconcileManagedWorktree(nodeId, { stillEligible: () => true });
|
|
204
|
+
assert.equal(completed.status, 'complete', 'a newly contained branch is re-proved instead of remaining in backoff');
|
|
205
|
+
assert.equal(existsSync(wt.path), false, 'the newly contained checkout is removed');
|
|
206
|
+
});
|
|
100
207
|
test('the sweep refuses a managed checkout with uncommitted changes', async () => {
|
|
101
208
|
const nodeId = 'wt-sweep-dirty';
|
|
102
209
|
const wt = createManagedWorktree(sourceDir, nodeId);
|
|
@@ -297,6 +404,78 @@ test('autoDropCleanManagedWorktree refuses a worktree with real uncommitted chan
|
|
|
297
404
|
assert.equal(result, null, 'real uncommitted changes must never be silently dropped');
|
|
298
405
|
assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open');
|
|
299
406
|
});
|
|
407
|
+
test('async close returns the typed lock refusal before the client socket timeout (#1024)', async () => {
|
|
408
|
+
const nodeId = 'wt-async-close-busy';
|
|
409
|
+
const wt = createManagedWorktree(sourceDir, nodeId);
|
|
410
|
+
createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
|
|
411
|
+
const commonDir = realpathSync(join(sourceDir, gitSync(['rev-parse', '--git-common-dir'], sourceDir).stdout.trim()));
|
|
412
|
+
const lockModule = new URL('../../exclusive-lock.ts', import.meta.url).href;
|
|
413
|
+
const child = spawn(process.execPath, ['--import', 'tsx/esm', '--input-type=module', '--eval', `
|
|
414
|
+
const { withExclusiveDirectoryLockAsync } = await import(${JSON.stringify(lockModule)});
|
|
415
|
+
await withExclusiveDirectoryLockAsync(${JSON.stringify(worktreeLockPathForCommonDir(commonDir))}, async () => {
|
|
416
|
+
console.log('locked');
|
|
417
|
+
await new Promise((resolve) => setTimeout(resolve, 40_000));
|
|
418
|
+
});
|
|
419
|
+
`], { env: { ...process.env, CRTR_HOME: home }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
420
|
+
try {
|
|
421
|
+
await new Promise((resolve, reject) => {
|
|
422
|
+
let output = '';
|
|
423
|
+
child.stdout.on('data', (chunk) => { output += chunk; if (output.includes('locked'))
|
|
424
|
+
resolve(); });
|
|
425
|
+
child.once('error', reject);
|
|
426
|
+
child.once('exit', (code) => reject(new Error(`lock holder exited before acquiring the lock (${code})`)));
|
|
427
|
+
});
|
|
428
|
+
const server = createApiServer();
|
|
429
|
+
try {
|
|
430
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
431
|
+
const client = new CrtrClient({ socketPath: apiSocketPath(), autostart: false });
|
|
432
|
+
const started = Date.now();
|
|
433
|
+
await assert.rejects(client.closeWorktree(nodeId), (error) => error instanceof ApiError && error.code === 'worktree_lock_timeout');
|
|
434
|
+
assert.ok(Date.now() - started < 30_000, 'the daemon must serialize the typed busy refusal before the 30s client timeout');
|
|
435
|
+
}
|
|
436
|
+
finally {
|
|
437
|
+
await server.close();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
finally {
|
|
441
|
+
child.kill('SIGTERM');
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
test('async close yields while an async repository holder runs, keeping API requests responsive (#1024)', async () => {
|
|
445
|
+
const zeroId = 'wt-async-close-zero';
|
|
446
|
+
const committedId = 'wt-async-close-committed';
|
|
447
|
+
const zero = createManagedWorktree(sourceDir, zeroId);
|
|
448
|
+
const committed = createManagedWorktree(sourceDir, committedId);
|
|
449
|
+
createNode(node(zeroId, { cwd: zero.path, managed_worktree: zero }));
|
|
450
|
+
createNode(node(committedId, { cwd: committed.path, managed_worktree: committed }));
|
|
451
|
+
writeFileSync(join(committed.path, 'committed.txt'), 'work\n');
|
|
452
|
+
run(['add', 'committed.txt'], committed.path);
|
|
453
|
+
run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'work'], committed.path);
|
|
454
|
+
const commonDir = realpathSync(join(sourceDir, gitSync(['rev-parse', '--git-common-dir'], sourceDir).stdout.trim()));
|
|
455
|
+
let release;
|
|
456
|
+
const held = new Promise((resolve) => { release = resolve; });
|
|
457
|
+
const holder = withExclusiveDirectoryLockAsync(worktreeLockPathForCommonDir(commonDir), async () => { await held; });
|
|
458
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
459
|
+
const server = createApiServer();
|
|
460
|
+
try {
|
|
461
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
462
|
+
const client = new CrtrClient({ socketPath: apiSocketPath(), autostart: false });
|
|
463
|
+
const zeroClose = client.closeWorktree(zeroId);
|
|
464
|
+
const committedClose = client.closeWorktree(committedId);
|
|
465
|
+
const responsive = await Promise.race([client.listNodes().then(() => true), new Promise((resolve) => setTimeout(() => resolve(false), 250))]);
|
|
466
|
+
assert.equal(responsive, true, 'a waiting close must not block another daemon request');
|
|
467
|
+
release();
|
|
468
|
+
await holder;
|
|
469
|
+
const [zeroResult, committedResult] = await Promise.all([zeroClose, committedClose]);
|
|
470
|
+
assert.equal(zeroResult.landed_sha, zero.base_sha, 'the zero-commit close returns its actual result after the holder releases');
|
|
471
|
+
assert.equal(committedResult.landed_sha, gitSync(['rev-parse', 'HEAD'], committed.path).stdout.trim(), 'the committed close returns its actual landed result after the holder releases');
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
release();
|
|
475
|
+
await holder;
|
|
476
|
+
await server.close();
|
|
477
|
+
}
|
|
478
|
+
});
|
|
300
479
|
test('abandon preserves committed and dirty work, records its retained tip, and releases finalization', async () => {
|
|
301
480
|
const nodeId = 'wt-abandon';
|
|
302
481
|
const wt = createManagedWorktree(sourceDir, nodeId);
|
|
@@ -23,6 +23,9 @@ const PROFILE_SET_SHAPED_PARAMS = [
|
|
|
23
23
|
{ kind: 'flag', name: 'name', type: 'string', required: true, constraint: 'var name' },
|
|
24
24
|
{ kind: 'stdin', name: 'value', required: true, constraint: 'value piped on stdin' },
|
|
25
25
|
];
|
|
26
|
+
const STRING_FLAG_PARAMS = [
|
|
27
|
+
{ kind: 'flag', name: 'arg', type: 'string', required: true, constraint: 'argument' },
|
|
28
|
+
];
|
|
26
29
|
test('a lone positional supplies the stdin body on a leaf declaring no positional param', async () => {
|
|
27
30
|
const result = await parseArgv(YIELD_SHAPED_PARAMS, ['refresh against the original goal']);
|
|
28
31
|
assert.equal(result['message'], 'refresh against the original goal');
|
|
@@ -42,6 +45,17 @@ test('allowPositional:false keeps a stdin-only leaf rejecting positionals by ari
|
|
|
42
45
|
return true;
|
|
43
46
|
});
|
|
44
47
|
});
|
|
48
|
+
test('a value bound with = may start with --', async () => {
|
|
49
|
+
const result = await parseArgv(STRING_FLAG_PARAMS, ['--arg=--help']);
|
|
50
|
+
assert.equal(result['arg'], '--help');
|
|
51
|
+
});
|
|
52
|
+
test('a separate flag value starting with -- is rejected as missing', async () => {
|
|
53
|
+
await assert.rejects(() => parseArgv(STRING_FLAG_PARAMS, ['--arg', '--help']), (err) => {
|
|
54
|
+
assert.equal(err.code, 'missing_parameter');
|
|
55
|
+
assert.equal(err.message, '--arg requires a value');
|
|
56
|
+
return true;
|
|
57
|
+
});
|
|
58
|
+
});
|
|
45
59
|
test('an extra positional on a stdin-bearing leaf is rejected without echoing it', async () => {
|
|
46
60
|
const secret = 'SUPERSECRET_ARGV';
|
|
47
61
|
await assert.rejects(() => parseArgv(PROFILE_SET_SHAPED_PARAMS, ['some-profile', secret, '--name', 'REVIEW_TOKEN']), (err) => {
|
|
@@ -161,3 +161,16 @@ test('G3 — a deliver whose prompt rejects acks ok:false and records deliver_fa
|
|
|
161
161
|
assert.equal(okAck.type === 'ack' && okAck.ok, true, 'G3: an accepted prompt acks delivered:true');
|
|
162
162
|
assert.equal(okAck.type === 'ack' ? okAck.detail : undefined, 'prompt');
|
|
163
163
|
});
|
|
164
|
+
// A message_end is only a broker/viewer relay. It must not depend on a daemon
|
|
165
|
+
// read: a daemon outage after boot leaves tool results and the run settlement intact.
|
|
166
|
+
test('G4 — a daemon read failure after boot does not interrupt a tool result or its settlement', async () => {
|
|
167
|
+
const c = await ctrl('g4-ctrl');
|
|
168
|
+
const pid = brokerPid();
|
|
169
|
+
await h.stopApiServer();
|
|
170
|
+
const token = tok('G4-TOOL-RESULT');
|
|
171
|
+
h.fakeCmd(id, { cmd: 'stream', text: token, updates: 0 });
|
|
172
|
+
await c.waitFrame((f) => f.type === 'tool_execution_end', 'G4 tool result relayed after the daemon server exits');
|
|
173
|
+
await c.waitFrame((f) => f.type === 'agent_settled', 'G4 run settles after the daemon server exits');
|
|
174
|
+
await delay(200);
|
|
175
|
+
assert.equal(isPidAlive(pid), true, 'G4: broker remains alive after relaying and settling the tool result');
|
|
176
|
+
});
|
|
@@ -43,6 +43,33 @@ function seedPool(provider, cooling) {
|
|
|
43
43
|
rateLimitedUntil: cooling ? Date.now() + 60_000 : 0, lastAttemptAt: 0, lastRateLimitedAt: 0,
|
|
44
44
|
}]));
|
|
45
45
|
}
|
|
46
|
+
function modelRoute(spec) {
|
|
47
|
+
return { ultra: spec, strong: spec, medium: spec, light: spec };
|
|
48
|
+
}
|
|
49
|
+
function seedCapacityFallbackRoutes() {
|
|
50
|
+
const userConfigDir = join(h.agentHome, '.crouter');
|
|
51
|
+
const userConfigPath = join(userConfigDir, 'config.json');
|
|
52
|
+
mkdirSync(userConfigDir, { recursive: true });
|
|
53
|
+
writeFileSync(userConfigPath, JSON.stringify({
|
|
54
|
+
kinds: { 'test/capacity-fallback': { whenToUse: 'Capacity fallback regression.', model: 'anthropic/medium' } },
|
|
55
|
+
modelRoutes: {
|
|
56
|
+
primary: { family: 'anthropic', credentialSource: 'user', providerId: 'fake-primary', models: modelRoute('fake-primary/primary') },
|
|
57
|
+
fallback: { family: 'anthropic', credentialSource: 'northlight', providerId: 'fake-small', models: modelRoute('fake-small/small') },
|
|
58
|
+
largerFallback: { family: 'anthropic', credentialSource: 'third', providerId: 'fake-large', models: modelRoute('fake-large/large') },
|
|
59
|
+
},
|
|
60
|
+
modelRouting: { credentialSourceOrder: ['user', 'northlight', 'third'], familyOrder: ['anthropic'], strengthFallback: ['medium'] },
|
|
61
|
+
}));
|
|
62
|
+
return userConfigPath;
|
|
63
|
+
}
|
|
64
|
+
function seedCapacityFixture(id, largeContextWindow = 999) {
|
|
65
|
+
const dir = join(h.home, 'nodes', id);
|
|
66
|
+
writeFileSync(join(dir, 'fake-pi.context-tokens'), '1000');
|
|
67
|
+
writeFileSync(join(dir, 'fake-pi.model-capacities.json'), JSON.stringify({
|
|
68
|
+
'fake-primary/primary': 2000,
|
|
69
|
+
'fake-small/small': 999,
|
|
70
|
+
'fake-large/large': largeContextWindow,
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
46
73
|
// The node's recorded model (fake-pi.model.json, written by the fake's
|
|
47
74
|
// recordModel) — `null` for a model-less boot. A disk read, so it survives the
|
|
48
75
|
// broker exiting after its kickoff turn idle-releases.
|
|
@@ -395,6 +422,36 @@ test('a recovered compact-and-retry commits nothing, while a definitive failure
|
|
|
395
422
|
const notices = h.inbox(parent).filter((entry) => /exceeded its model context window/i.test(entry.label));
|
|
396
423
|
assert.equal(notices.length, 1, 'the parent receives exactly one structured overflow notice');
|
|
397
424
|
});
|
|
425
|
+
test('provider-retry and model-not-found fallback both terminate when every available target has a smaller known context window', { timeout: 30_000 }, async () => {
|
|
426
|
+
const userConfigPath = seedCapacityFallbackRoutes();
|
|
427
|
+
try {
|
|
428
|
+
const parent = h.spawnRoot('capacity fallback parent');
|
|
429
|
+
const providerRetry = await h.spawnHeadlessChild(parent, 'provider retry capacity fallback', { kind: 'test/capacity-fallback' });
|
|
430
|
+
await h.waitFor(() => agentStartCount(providerRetry) >= 1, { label: 'provider retry capacity fixture started' });
|
|
431
|
+
seedCapacityFixture(providerRetry, 2000);
|
|
432
|
+
h.fakeCmd(providerRetry, { cmd: 'auto_retry', errorMessage: 'rate limit exceeded' });
|
|
433
|
+
await h.waitFor(() => model(providerRetry)?.provider === 'fake-large' ? true : null, { label: 'provider retry skips the too-small target for the large enough target' });
|
|
434
|
+
assert.equal(model(providerRetry).provider, 'fake-large', 'a large enough fallback remains eligible after the smaller route is rejected');
|
|
435
|
+
const exhaustedProviderRetry = await h.spawnHeadlessChild(parent, 'exhausted provider retry capacity fallback', { kind: 'test/capacity-fallback' });
|
|
436
|
+
await h.waitFor(() => agentStartCount(exhaustedProviderRetry) >= 1, { label: 'exhausted provider retry capacity fixture started' });
|
|
437
|
+
seedCapacityFixture(exhaustedProviderRetry);
|
|
438
|
+
h.fakeCmd(exhaustedProviderRetry, { cmd: 'auto_retry', errorMessage: 'rate limit exceeded' });
|
|
439
|
+
const providerRetryFault = await h.waitFor(() => readFault(exhaustedProviderRetry)?.kind === 'context-overflow' ? readFault(exhaustedProviderRetry) : null, { label: 'provider retry rejects every too-small fallback target' });
|
|
440
|
+
assert.equal(providerRetryFault.retry.disposition, 'fatal', 'all known-too-small provider retry targets produce a fatal context-overflow fault');
|
|
441
|
+
assert.match(providerRetryFault.message, /1000 tokens/, 'the terminal fault identifies the current conversation size');
|
|
442
|
+
assert.equal(JSON.parse(readFileSync(join(h.home, 'nodes', exhaustedProviderRetry, 'fake-pi.auto-retry.json'), 'utf8')).aborted, true, 'the retry delay is cancelled after Pi creates its retry controller');
|
|
443
|
+
const modelNotFound = await h.spawnHeadlessChild(parent, 'model-not-found capacity fallback', { kind: 'test/capacity-fallback' });
|
|
444
|
+
await h.waitFor(() => agentStartCount(modelNotFound) >= 1, { label: 'model-not-found capacity fixture started' });
|
|
445
|
+
seedCapacityFixture(modelNotFound);
|
|
446
|
+
h.fakeCmd(modelNotFound, { cmd: 'stop', reason: 'error', errorMessage: 'Model not found' });
|
|
447
|
+
const modelNotFoundFault = await h.waitFor(() => readFault(modelNotFound)?.kind === 'context-overflow' ? readFault(modelNotFound) : null, { label: 'model-not-found rejects the too-small fallback target' });
|
|
448
|
+
assert.equal(modelNotFoundFault.retry.disposition, 'fatal', 'all known-too-small model-not-found targets produce a fatal context-overflow fault');
|
|
449
|
+
assert.match(modelNotFoundFault.message, /1000 tokens/, 'the terminal fault survives ordinary settlement staging');
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
rmSync(userConfigPath, { force: true });
|
|
453
|
+
}
|
|
454
|
+
});
|
|
398
455
|
// Two real logical launches keep both user paths non-vacuous: the picker first
|
|
399
456
|
// proves an explicit concrete choice pins and clears routing, while Alt+M runs
|
|
400
457
|
// directly against still-present logical intent before Shift+Tab and replay.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Run with: npm run build && node --test --test-timeout=180000 dist/core/__tests__/seam/broker-startup-diagnostics.test.js
|
|
2
|
+
//
|
|
3
|
+
// A refused broker launch, a broker fatal emitted before session_start, and a
|
|
4
|
+
// SIGKILL all surface through canonical diagnostics. broker.log remains raw
|
|
5
|
+
// stdout/stderr residue, so a canonical fatal need not write anything there.
|
|
6
|
+
import { after, before, test } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { subscribe } from '../../canvas/canvas.js';
|
|
12
|
+
import { resolveRef } from '../../canvas/history.js';
|
|
13
|
+
import { readLogSnapshot } from '../../events/read.js';
|
|
14
|
+
import { spawnChild } from '../../runtime/spawn.js';
|
|
15
|
+
import { FAIL_BEFORE_SESSION_START } from '../fixtures/fake-engine.js';
|
|
16
|
+
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
17
|
+
let h;
|
|
18
|
+
let root;
|
|
19
|
+
function eventsFor(nodeId) {
|
|
20
|
+
return readLogSnapshot({ nodeId }, false).items.flatMap((item) => item.kind === 'event' ? [item.envelope] : []);
|
|
21
|
+
}
|
|
22
|
+
function reportBody(entry) {
|
|
23
|
+
assert.ok(entry.ref, 'the boot-failure notification has a report reference');
|
|
24
|
+
const report = resolveRef(entry.ref);
|
|
25
|
+
assert.ok(report, 'the boot-failure notification report resolves');
|
|
26
|
+
return report.body;
|
|
27
|
+
}
|
|
28
|
+
before(async () => {
|
|
29
|
+
h = await createHeadlessHarness({ sessionPrefix: 'crtr-broker-startup-diagnostics' });
|
|
30
|
+
root = h.spawnRoot('broker startup diagnostics seam root');
|
|
31
|
+
});
|
|
32
|
+
after(async () => {
|
|
33
|
+
if (h !== undefined)
|
|
34
|
+
await h.dispose();
|
|
35
|
+
});
|
|
36
|
+
test('broker startup failures name canonical diagnostics, preserve launch errors, and report the observed exit status', { timeout: 60_000 }, async () => {
|
|
37
|
+
const refusedId = 'launch-refused';
|
|
38
|
+
const missingCwd = join(h.home, 'missing-cwd');
|
|
39
|
+
await assert.rejects(spawnChild({
|
|
40
|
+
kind: 'general',
|
|
41
|
+
cwd: missingCwd,
|
|
42
|
+
prompt: 'this launch must be refused by the operating system',
|
|
43
|
+
parent: root,
|
|
44
|
+
nodeId: refusedId,
|
|
45
|
+
}), (error) => {
|
|
46
|
+
const details = error.details;
|
|
47
|
+
assert.match(String(details?.next), new RegExp(`crtr sys logs --node ${refusedId}`));
|
|
48
|
+
return true;
|
|
49
|
+
});
|
|
50
|
+
const launchFailure = await h.waitFor(() => eventsFor(refusedId).find((event) => event.event === 'broker.launch.failed') ?? null, { label: 'canonical launch refusal' });
|
|
51
|
+
assert.match(launchFailure.error?.message ?? '', /ENOENT/, 'the canonical event preserves the operating-system launch error');
|
|
52
|
+
const refusedLog = join(h.home, 'nodes', refusedId, 'job', 'broker.log');
|
|
53
|
+
assert.equal(existsSync(refusedLog), true, 'the raw broker log exists for the refused launch');
|
|
54
|
+
assert.equal(readFileSync(refusedLog, 'utf8'), '', 'a launch refusal writes its diagnostic canonically, not as raw broker output');
|
|
55
|
+
const fatal = await spawnChild({
|
|
56
|
+
kind: 'general',
|
|
57
|
+
cwd: h.node(root).cwd,
|
|
58
|
+
prompt: `canonical fatal ${FAIL_BEFORE_SESSION_START}`,
|
|
59
|
+
parent: root,
|
|
60
|
+
});
|
|
61
|
+
const fatalId = fatal.node.node_id;
|
|
62
|
+
await h.awaitFleetExit(fatalId);
|
|
63
|
+
await h.tick();
|
|
64
|
+
const fatalEvent = eventsFor(fatalId).find((event) => event.event === 'broker.runtime.fatal');
|
|
65
|
+
assert.ok(fatalEvent, 'a pre-session broker fatal is available in canonical node diagnostics');
|
|
66
|
+
assert.match(fatalEvent.error?.message ?? '', /simulated pre-session_start boot failure/);
|
|
67
|
+
const fatalLog = join(h.home, 'nodes', fatalId, 'job', 'broker.log');
|
|
68
|
+
assert.equal(readFileSync(fatalLog, 'utf8'), '', 'a successfully emitted canonical fatal leaves raw broker.log empty');
|
|
69
|
+
const fatalNotice = h.inbox(root).find((entry) => entry.from === fatalId && entry.tier === 'urgent');
|
|
70
|
+
assert.ok(fatalNotice, 'the parent receives the fatal broker boot-failure notification');
|
|
71
|
+
assert.match(reportBody(fatalNotice), /code 1, signal null/);
|
|
72
|
+
assert.match(reportBody(fatalNotice), new RegExp(`crtr sys logs --node ${fatalId}`));
|
|
73
|
+
const exited = spawnSync(process.execPath, ['-e', ''], { stdio: 'ignore' });
|
|
74
|
+
assert.ok(exited.pid !== undefined, 'the fabricated boot failure has a recorded, dead pid');
|
|
75
|
+
const killedId = h.fabricateBrokerNode({ id: 'killed-before-session', parent: root, pi_pid: exited.pid });
|
|
76
|
+
subscribe(root, killedId, true);
|
|
77
|
+
await h.deliverExit(killedId, { code: null, signal: 'SIGKILL' });
|
|
78
|
+
await h.tick();
|
|
79
|
+
const killNotice = h.inbox(root).find((entry) => entry.from === killedId && entry.tier === 'urgent');
|
|
80
|
+
assert.ok(killNotice, 'the parent receives the SIGKILL boot-failure notification');
|
|
81
|
+
assert.match(reportBody(killNotice), /code null, signal SIGKILL/);
|
|
82
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Seconds a leading `sleep ...` will block for, or null when the command does
|
|
2
|
+
* not open with a sleep (or its duration isn't statically knowable). */
|
|
3
|
+
export declare function leadingSleepSeconds(command: string): number | null;
|
|
4
|
+
/** The refusal this command earns before it runs, or null when it may run.
|
|
5
|
+
* `cwd` is the directory the command will execute in. */
|
|
6
|
+
export declare function bashCommandRefusal(command: string, cwd: string, home?: string): string | null;
|