@north-light/crouter 0.3.303 → 0.3.305
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/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +87 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +31 -26
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +33 -26
- package/dist/clients/attach/viewer.js +584 -584
- 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/create.js +7 -1
- package/dist/commands/node/message.js +3 -3
- package/dist/commands/node/wait.js +1 -1
- package/dist/commands/node-worktree.js +5 -1
- package/dist/commands/sys/daemon.js +11 -13
- 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.js +14 -15
- package/dist/core/__tests__/helpers/harness.d.ts +2 -0
- package/dist/core/__tests__/helpers/harness.js +9 -0
- package/dist/core/__tests__/integration/tmux-surface.test.js +53 -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__/kickoff.test.js +40 -0
- 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 +25 -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/__tests__/seam/deadline-wake-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/seam/deadline-wake-delivery.test.js +138 -0
- package/dist/core/__tests__/seam/reopen-invalidated-worktree.test.d.ts +1 -0
- package/dist/core/__tests__/seam/reopen-invalidated-worktree.test.js +60 -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 +7 -1
- package/dist/core/fault-classifier.js +9 -4
- package/dist/core/provider-failure.d.ts +30 -0
- package/dist/core/provider-failure.js +86 -0
- 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.js +16 -0
- package/dist/core/runtime/broker-persona-guidance.js +12 -0
- package/dist/core/runtime/broker.js +0 -5
- package/dist/core/runtime/fault.d.ts +2 -1
- package/dist/core/runtime/fault.js +39 -1
- package/dist/core/runtime/host.js +10 -1
- package/dist/core/runtime/kickoff.d.ts +3 -0
- package/dist/core/runtime/kickoff.js +24 -4
- package/dist/core/runtime/reopen.d.ts +2 -2
- package/dist/core/runtime/reopen.js +6 -4
- package/dist/core/runtime/revive.js +3 -3
- package/dist/core/runtime/spawn.js +5 -6
- package/dist/core/runtime/tmux-bindings.js +14 -3
- package/dist/core/termrender/version.d.ts +1 -1
- package/dist/core/termrender/version.js +1 -1
- package/dist/core/worktree-close.d.ts +4 -0
- package/dist/core/worktree-close.js +228 -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 +24 -82
- package/dist/core/worktree.d.ts +1 -0
- package/dist/core/worktree.js +7 -4
- package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +10 -8
- package/dist/daemon/api/__tests__/reopen-delivery.test.js +31 -0
- package/dist/daemon/api/__tests__/seam/api-server.test.js +70 -1
- package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +36 -4
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/messages.js +33 -14
- 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/broker-supervision.js +10 -1
- 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 -0
- package/dist/pi-extensions/canvas-bash-valve.js +73 -43
- package/dist/pi-extensions/canvas-inbox-watcher.js +0 -2
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
|
@@ -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);
|
|
@@ -62,6 +62,46 @@ test('drainBearings preserves the yield note until explicit boot confirmation',
|
|
|
62
62
|
assert.equal(readYieldMessage(id), null, 'confirmation clear is idempotent');
|
|
63
63
|
assert.equal(existsSync(yieldMessagePath(id)), false, 'confirmed handoff removes the note');
|
|
64
64
|
});
|
|
65
|
+
test('fresh kickoff keeps each inbox card directly before its own message', () => {
|
|
66
|
+
const id = 'ordered-cards';
|
|
67
|
+
const meta = createNode(node(id));
|
|
68
|
+
appendInbox(id, {
|
|
69
|
+
from: 'sender',
|
|
70
|
+
tier: 'normal',
|
|
71
|
+
kind: 'message',
|
|
72
|
+
label: 'FIRST-BODY',
|
|
73
|
+
data: { body: 'FIRST-BODY', cards: ['<runtime kind="test-card">FIRST-CARD</runtime>'] },
|
|
74
|
+
});
|
|
75
|
+
appendInbox(id, {
|
|
76
|
+
from: 'sender',
|
|
77
|
+
tier: 'normal',
|
|
78
|
+
kind: 'message',
|
|
79
|
+
label: 'SECOND-BODY',
|
|
80
|
+
data: { body: 'SECOND-BODY', cards: ['<runtime kind="test-card">SECOND-CARD</runtime>'] },
|
|
81
|
+
});
|
|
82
|
+
for (const body of ['GROUPED-BODY-1', 'GROUPED-BODY-2', 'GROUPED-BODY-3']) {
|
|
83
|
+
appendInbox(id, {
|
|
84
|
+
from: 'card-free-sender',
|
|
85
|
+
tier: 'normal',
|
|
86
|
+
kind: 'message',
|
|
87
|
+
label: body,
|
|
88
|
+
data: { body },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const kickoff = buildReviveKickoff(meta, drainBearings(meta));
|
|
92
|
+
const firstCard = kickoff.indexOf('FIRST-CARD');
|
|
93
|
+
const firstBody = kickoff.indexOf('FIRST-BODY');
|
|
94
|
+
const secondCard = kickoff.indexOf('SECOND-CARD');
|
|
95
|
+
const secondBody = kickoff.indexOf('SECOND-BODY');
|
|
96
|
+
assert.ok(firstCard >= 0 && firstCard < firstBody, 'the first card stays ahead of its own message');
|
|
97
|
+
assert.ok(firstBody < secondCard, 'the second card cannot leap ahead of the first message');
|
|
98
|
+
assert.ok(secondCard < secondBody, 'the second card stays ahead of its own message');
|
|
99
|
+
const groupedSections = kickoff.match(/<from id="card-free-sender">([\s\S]*?)<\/from>/g) ?? [];
|
|
100
|
+
assert.equal(groupedSections.length, 1, 'card-free entries from one sender remain one delivery');
|
|
101
|
+
for (const body of ['GROUPED-BODY-1', 'GROUPED-BODY-2', 'GROUPED-BODY-3']) {
|
|
102
|
+
assert.ok(groupedSections[0].includes(body), `${body} remains in the sender's single delivery`);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
65
105
|
test('drainBearings leaves persona drift pending until the replacement context exists', () => {
|
|
66
106
|
const oldPersona = { kind: 'developer', mode: 'base', lifecycle: 'terminal' };
|
|
67
107
|
const meta = createNode(node('pending-persona', {
|
|
@@ -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
|
+
});
|
|
@@ -117,6 +117,31 @@ after(async () => {
|
|
|
117
117
|
afterEach(() => {
|
|
118
118
|
kit.closeAll();
|
|
119
119
|
});
|
|
120
|
+
test('a normalized 503 survives into a durable provider fault and re-drives the turn', { timeout: 30_000 }, async () => {
|
|
121
|
+
const parent = h.spawnRoot('structured provider failure parent');
|
|
122
|
+
seedPool('anthropic', false);
|
|
123
|
+
seedPool('openai-codex', false);
|
|
124
|
+
const child = await h.spawnHeadlessChild(parent, 'structured provider failure', { model: OPENAI_STRONG.spec });
|
|
125
|
+
await h.waitFor(() => agentStartCount(child) >= 1, { label: 'initial structured-failure fixture turn started' });
|
|
126
|
+
const baselineStarts = agentStartCount(child);
|
|
127
|
+
h.fakeCmd(child, {
|
|
128
|
+
cmd: 'stop',
|
|
129
|
+
reason: 'error',
|
|
130
|
+
errorMessage: 'Request failed',
|
|
131
|
+
providerFailure: { status: 503 },
|
|
132
|
+
});
|
|
133
|
+
const fault = await h.waitFor(() => {
|
|
134
|
+
const current = readFault(child);
|
|
135
|
+
return current?.kind === 'overloaded' && current.retry.disposition === 'auto' ? current : undefined;
|
|
136
|
+
}, { label: 'structured 503 published as an automatic provider fault' });
|
|
137
|
+
assert.equal(fault.providerError?.provider, OPENAI_STRONG.model.provider);
|
|
138
|
+
assert.equal(fault.providerError?.model, OPENAI_STRONG.model.id);
|
|
139
|
+
assert.equal(fault.providerError?.status, 503);
|
|
140
|
+
await h.waitFor(() => agentStartCount(child) >= baselineStarts + 1, {
|
|
141
|
+
label: 'structured 503 automatic recovery turn started',
|
|
142
|
+
});
|
|
143
|
+
await h.waitFor(() => readFault(child) === null, { label: 'successful recovery clears the structured provider fault' });
|
|
144
|
+
});
|
|
120
145
|
test('a managed-cooling terminal authors a rate-limit auto fault whose deadline drives exactly one broker re-drive', { timeout: 30_000 }, async () => {
|
|
121
146
|
const parent = h.spawnRoot('cooling terminal parent');
|
|
122
147
|
// The terminal fault path is pool-independent — the stophook classifies purely
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Run with: npm run build && node --test --test-timeout=180000 dist/core/__tests__/seam/deadline-wake-delivery.test.js
|
|
2
|
+
//
|
|
3
|
+
// Pins the compiled deadline command: self-cancel cleanup runs before its urgent
|
|
4
|
+
// inbox wake, but a nonzero cleanup result cannot suppress that durable wake.
|
|
5
|
+
import { after, before, test } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { getCron } from '../../canvas/crons.js';
|
|
11
|
+
import { ensureRootProfile } from '../../profiles/manifest.js';
|
|
12
|
+
import { executeCron } from '../../../daemon/cron-run.js';
|
|
13
|
+
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
14
|
+
let h;
|
|
15
|
+
let root;
|
|
16
|
+
let fakeBin;
|
|
17
|
+
let journal;
|
|
18
|
+
let argsPath;
|
|
19
|
+
let bodyPath;
|
|
20
|
+
let cancelCodePath;
|
|
21
|
+
let sendCodePath;
|
|
22
|
+
let savedPath;
|
|
23
|
+
let savedProfileId;
|
|
24
|
+
let profileId;
|
|
25
|
+
function shellQuote(value) {
|
|
26
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
27
|
+
}
|
|
28
|
+
function writeFakeFrontDoor() {
|
|
29
|
+
writeFileSync(join(fakeBin, 'crtr'), `#!/bin/sh
|
|
30
|
+
journal=${shellQuote(journal)}
|
|
31
|
+
args=${shellQuote(argsPath)}
|
|
32
|
+
body=${shellQuote(bodyPath)}
|
|
33
|
+
cancel_code=${shellQuote(cancelCodePath)}
|
|
34
|
+
send_code=${shellQuote(sendCodePath)}
|
|
35
|
+
|
|
36
|
+
if [ "$1" = cron ] && [ "$2" = cancel ]; then
|
|
37
|
+
printf '%s\\n' cancel >> "$journal"
|
|
38
|
+
${shellQuote(process.execPath)} --input-type=module -e ${shellQuote(`import { cancelCron } from ${JSON.stringify(join(process.cwd(), 'dist', 'core', 'canvas', 'crons.js'))}; cancelCron(process.argv[1], { preserveRunningProcess: true });`)} "$3" || exit $?
|
|
39
|
+
code=$(cat "$cancel_code")
|
|
40
|
+
if [ "$code" -ne 0 ]; then
|
|
41
|
+
printf '%s\\n' 'cancel failed' >&2
|
|
42
|
+
fi
|
|
43
|
+
exit "$code"
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
if [ "$1" = node ] && [ "$2" = message ]; then
|
|
47
|
+
printf '%s\\n' message >> "$journal"
|
|
48
|
+
printf '%s\\n' "$@" > "$args"
|
|
49
|
+
cat > "$body"
|
|
50
|
+
code=$(cat "$send_code")
|
|
51
|
+
if [ "$code" -ne 0 ]; then
|
|
52
|
+
printf '%s\\n' 'send failed' >&2
|
|
53
|
+
exit "$code"
|
|
54
|
+
fi
|
|
55
|
+
exec ${shellQuote(process.execPath)} ${shellQuote(join(process.cwd(), 'dist', 'cli.js'))} "$@" < "$body"
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
printf '%s\\n' unexpected >> "$journal"
|
|
59
|
+
exit 99
|
|
60
|
+
`, { mode: 0o755 });
|
|
61
|
+
chmodSync(join(fakeBin, 'crtr'), 0o755);
|
|
62
|
+
}
|
|
63
|
+
async function armDeadline(body) {
|
|
64
|
+
const target = await h.spawnHeadlessChild(root, 'deadline target', { profile: profileId });
|
|
65
|
+
const armed = h.cli(target, ['--json', 'node', 'wait', 'deadline', body, '--until', '1m']);
|
|
66
|
+
assert.equal(armed.code, 0, `node wait deadline: ${armed.stderr}`);
|
|
67
|
+
const id = armed.json?.id;
|
|
68
|
+
assert.equal(typeof id, 'string', 'the compiled deadline CLI returns its cron id');
|
|
69
|
+
const cron = getCron(id);
|
|
70
|
+
assert.notEqual(cron, null, 'the compiled deadline CLI persisted its cron command');
|
|
71
|
+
return { target, cron: cron };
|
|
72
|
+
}
|
|
73
|
+
async function runDeadline(cancelCode, sendCode, body) {
|
|
74
|
+
writeFileSync(journal, '');
|
|
75
|
+
writeFileSync(argsPath, '');
|
|
76
|
+
writeFileSync(bodyPath, '');
|
|
77
|
+
writeFileSync(cancelCodePath, String(cancelCode));
|
|
78
|
+
writeFileSync(sendCodePath, String(sendCode));
|
|
79
|
+
const { target, cron } = await armDeadline(body);
|
|
80
|
+
process.env['PATH'] = `${fakeBin}:${savedPath ?? ''}`;
|
|
81
|
+
try {
|
|
82
|
+
const record = await executeCron(cron, { outOfBand: false });
|
|
83
|
+
return { target, record: record ?? null };
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
if (savedPath === undefined)
|
|
87
|
+
delete process.env['PATH'];
|
|
88
|
+
else
|
|
89
|
+
process.env['PATH'] = savedPath;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
before(async () => {
|
|
93
|
+
h = await createHeadlessHarness({ sessionPrefix: 'crtr-deadline-wake' });
|
|
94
|
+
profileId = ensureRootProfile().profileId;
|
|
95
|
+
savedProfileId = process.env['CRTR_PROFILE_ID'];
|
|
96
|
+
process.env['CRTR_PROFILE_ID'] = profileId;
|
|
97
|
+
root = h.spawnRoot('deadline wake seam root');
|
|
98
|
+
fakeBin = mkdtempSync(join(tmpdir(), 'crtr-deadline-front-door-'));
|
|
99
|
+
journal = join(fakeBin, 'journal');
|
|
100
|
+
argsPath = join(fakeBin, 'args');
|
|
101
|
+
bodyPath = join(fakeBin, 'body');
|
|
102
|
+
cancelCodePath = join(fakeBin, 'cancel-code');
|
|
103
|
+
sendCodePath = join(fakeBin, 'send-code');
|
|
104
|
+
writeFakeFrontDoor();
|
|
105
|
+
savedPath = process.env['PATH'];
|
|
106
|
+
});
|
|
107
|
+
after(async () => {
|
|
108
|
+
if (savedPath === undefined)
|
|
109
|
+
delete process.env['PATH'];
|
|
110
|
+
else
|
|
111
|
+
process.env['PATH'] = savedPath;
|
|
112
|
+
if (savedProfileId === undefined)
|
|
113
|
+
delete process.env['CRTR_PROFILE_ID'];
|
|
114
|
+
else
|
|
115
|
+
process.env['CRTR_PROFILE_ID'] = savedProfileId;
|
|
116
|
+
rmSync(fakeBin, { recursive: true, force: true });
|
|
117
|
+
if (h !== undefined)
|
|
118
|
+
await h.dispose();
|
|
119
|
+
});
|
|
120
|
+
test('compiled deadline wakes once after cancel failure, retains cancel-first ordering, and propagates send failure', { timeout: 60_000 }, async () => {
|
|
121
|
+
const failedCancelBody = 'deadline wake after failed cancel\nbody remains intact';
|
|
122
|
+
const failedCancel = await runDeadline(2, 0, failedCancelBody);
|
|
123
|
+
assert.equal(failedCancel.record?.exit_code, 0, 'a successful send is the cron command result after failed cleanup');
|
|
124
|
+
assert.match(failedCancel.record?.stderr_head ?? '', /cancel failed/, 'cancel diagnostics remain on stderr');
|
|
125
|
+
assert.deepEqual(readFileSync(journal, 'utf8').trim().split('\n'), ['cancel', 'message'], 'cancel runs before exactly one message send');
|
|
126
|
+
assert.deepEqual(readFileSync(argsPath, 'utf8').trim().split('\n'), ['node', 'message', 'send', '--to', failedCancel.target, '--tier', 'urgent'], 'the generated urgent send receives its target and tier');
|
|
127
|
+
assert.equal(readFileSync(bodyPath, 'utf8'), `${failedCancelBody}\n`, 'the generated heredoc body reaches the front door byte-for-byte');
|
|
128
|
+
await h.awaitWake(failedCancel.target, { match: /deadline wake after failed cancel/ });
|
|
129
|
+
assert.ok(h.inbox(failedCancel.target).some((entry) => entry.tier === 'urgent' && entry.data?.['body'] === failedCancelBody), 'the successful send durably appends the urgent deadline message to the target inbox');
|
|
130
|
+
const successfulCancel = await runDeadline(0, 0, 'deadline wake after successful cancel');
|
|
131
|
+
assert.equal(successfulCancel.record?.exit_code, 0, 'the ordinary successful-cancel path still succeeds');
|
|
132
|
+
assert.deepEqual(readFileSync(journal, 'utf8').trim().split('\n'), ['cancel', 'message'], 'successful cleanup also precedes one message send');
|
|
133
|
+
await h.awaitWake(successfulCancel.target, { match: /deadline wake after successful cancel/ });
|
|
134
|
+
const failedSend = await runDeadline(2, 23, 'deadline send failure');
|
|
135
|
+
assert.equal(failedSend.record?.exit_code, 23, 'the final message-send status remains the cron failure status');
|
|
136
|
+
assert.match(failedSend.record?.stderr_head ?? '', /cancel failed[\s\S]*send failed/, 'both cleanup and send diagnostics remain visible');
|
|
137
|
+
assert.deepEqual(readFileSync(journal, 'utf8').trim().split('\n'), ['cancel', 'message'], 'a failed send is still attempted once after failed cleanup');
|
|
138
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Run with: npm run build && node --test --test-timeout=180000 dist/core/__tests__/seam/reopen-invalidated-worktree.test.js
|
|
2
|
+
//
|
|
3
|
+
// Regression for #1070 and #1064: a completed managed-worktree node loses its
|
|
4
|
+
// session when the worktree closes. Atomic message reopen must start from the
|
|
5
|
+
// retained final report and the new instruction, never replay its birth goal.
|
|
6
|
+
import { after, before, test } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
11
|
+
import { pushFinal } from '../../feed/feed.js';
|
|
12
|
+
import { persistClosedWorktreeState } from '../../worktree.js';
|
|
13
|
+
import { readGoal } from '../../runtime/kickoff.js';
|
|
14
|
+
let h;
|
|
15
|
+
before(async () => {
|
|
16
|
+
h = await createHeadlessHarness({ sessionPrefix: 'crtr-reopen-invalidated-worktree' });
|
|
17
|
+
});
|
|
18
|
+
after(async () => {
|
|
19
|
+
if (h !== undefined)
|
|
20
|
+
await h.dispose();
|
|
21
|
+
});
|
|
22
|
+
test('atomic message reopen of a closed worktree starts fresh from the prior final and follow-up, not the birth goal', { timeout: 30_000 }, async () => {
|
|
23
|
+
const root = h.spawnRoot('reopen invalidated-worktree manager');
|
|
24
|
+
const originalGoal = 'ORIGINAL-MANDATE: do not replay this completed implementation task';
|
|
25
|
+
const followUp = 'FOLLOW-UP-MANDATE: inspect the completed work without changing it';
|
|
26
|
+
const child = await h.spawnHeadlessChild(root, originalGoal);
|
|
27
|
+
await pushFinal(child, 'COMPLETED-FINAL: the original implementation is complete');
|
|
28
|
+
const finalReport = h.node(child)?.final_report;
|
|
29
|
+
assert.ok(finalReport, 'the completed worker has a canonical final report');
|
|
30
|
+
const finalPath = join(h.home, 'nodes', child, 'reports', finalReport);
|
|
31
|
+
assert.equal(existsSync(finalPath), true, 'the canonical final exists before reopening');
|
|
32
|
+
persistClosedWorktreeState(child, {
|
|
33
|
+
state: 'open',
|
|
34
|
+
path: join(h.home, 'removed-worktree'),
|
|
35
|
+
branch: 'crtr/reopen-invalidated-worktree',
|
|
36
|
+
repo_root: process.cwd(),
|
|
37
|
+
base_ref: 'main',
|
|
38
|
+
base_sha: 'test',
|
|
39
|
+
created: new Date().toISOString(),
|
|
40
|
+
});
|
|
41
|
+
assert.equal(h.node(child)?.pi_session_file ?? null, null, 'closed worktree invalidates the old session coordinate');
|
|
42
|
+
const oldPid = h.node(child)?.pi_pid;
|
|
43
|
+
assert.ok(oldPid != null, 'the original broker is still live until its completed turn exits');
|
|
44
|
+
process.kill(oldPid, 'SIGKILL');
|
|
45
|
+
await h.awaitFleetExit(child);
|
|
46
|
+
const result = h.cli(root, ['node', 'message', 'send', '--to', child, '--reopen', followUp]);
|
|
47
|
+
assert.equal(result.code, 0, `atomic reopen message should succeed\n--stderr--\n${result.stderr}`);
|
|
48
|
+
const boot = await h.awaitBoot(child, { minCount: 2 });
|
|
49
|
+
assert.equal(boot.resuming, false, 'the cleared session forces a fresh broker boot');
|
|
50
|
+
assert.equal(boot.prompt?.includes(originalGoal), false, 'a completed node never replays its birth mandate');
|
|
51
|
+
const priorFinalIndex = boot.prompt?.indexOf('reopen-final-report') ?? -1;
|
|
52
|
+
const followUpIndex = boot.prompt?.indexOf(followUp) ?? -1;
|
|
53
|
+
assert.ok(priorFinalIndex >= 0 && boot.prompt?.includes(finalPath), 'the prior final report path is delivered as context');
|
|
54
|
+
assert.ok(followUpIndex >= 0, 'the new instruction is delivered in the fresh kickoff');
|
|
55
|
+
assert.ok(priorFinalIndex < followUpIndex, 'the prior final report context arrives before the new instruction');
|
|
56
|
+
assert.equal(readGoal(child)?.trim(), originalGoal, 'the birth goal remains on disk as historical data');
|
|
57
|
+
assert.equal(existsSync(finalPath), true, 'reopen does not delete the prior final report');
|
|
58
|
+
assert.equal(h.node(child)?.lifecycle, 'resident', 'atomic reopen restores resident lifecycle before the fresh broker starts');
|
|
59
|
+
assert.equal(h.node(child)?.final_report ?? null, null, 'atomic reopen clears only the live finalization latch');
|
|
60
|
+
});
|