@north-light/crouter 0.3.303 → 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__/broker-stream-watchdog-floor.test.js +0 -2
- package/dist/core/__tests__/daemon-boot.test.js +1 -1
- 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-startup-diagnostics.test.d.ts +1 -0
- package/dist/core/__tests__/seam/broker-startup-diagnostics.test.js +82 -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.js +8 -0
- 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/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 -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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
test('daemon status reports its failed probe without telling the operator to stop a live process', () => {
|
|
8
|
+
const home = mkdtempSync(join(tmpdir(), 'crtr-daemon-status-'));
|
|
9
|
+
const pidfile = join(home, 'crtrd.pid');
|
|
10
|
+
const owner = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)'], { stdio: 'ignore' });
|
|
11
|
+
try {
|
|
12
|
+
assert.ok(owner.pid !== undefined, 'fixture process has a pid');
|
|
13
|
+
writeFileSync(pidfile, String(owner.pid));
|
|
14
|
+
const result = spawnSync(process.execPath, [join(process.cwd(), 'dist', 'cli.js'), '--json', 'sys', 'daemon', 'status'], {
|
|
15
|
+
encoding: 'utf8',
|
|
16
|
+
env: { ...process.env, CRTR_HOME: home, CRTR_PIDFILE: pidfile, CRTR_NO_DAEMON_AUTOSTART: '1' },
|
|
17
|
+
timeout: 10_000,
|
|
18
|
+
});
|
|
19
|
+
assert.equal(result.status, 0, result.stderr);
|
|
20
|
+
const output = JSON.parse(result.stdout);
|
|
21
|
+
assert.deepEqual({ running: output.running, serving: output.serving }, { running: true, serving: false });
|
|
22
|
+
assert.match(output.probe_error ?? '', /ENOENT|ECONNREFUSED/);
|
|
23
|
+
assert.equal(output.next, undefined, 'a single failed health probe is not a reason to tell the operator to stop the owner');
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
owner.kill('SIGKILL');
|
|
27
|
+
rmSync(home, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
@@ -38,9 +38,9 @@ export declare function readColdStartDiagnostic(): string | undefined;
|
|
|
38
38
|
* default: on a cold socket the injected `onColdSocket` hook fires
|
|
39
39
|
* `ensureDaemon()` (spawns crtrd detached via the branded host), then the
|
|
40
40
|
* client polls `/healthz` to a bounded deadline and retries once (the poll
|
|
41
|
-
* lives inside `CrtrClient`, A-2). If crtrd never becomes reachable
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
* lives inside `CrtrClient`, A-2). If crtrd never becomes reachable, the
|
|
42
|
+
* client surfaces the final observed transport failure. This is the ONLY CLI
|
|
43
|
+
* reference to `ensureDaemon`.
|
|
44
44
|
*
|
|
45
45
|
* `onColdSocket` is fire-and-forget — `ensureDaemon()` spawns crtrd and
|
|
46
46
|
* returns immediately, so the client's OWN `/healthz` poll is the only
|
|
@@ -104,9 +104,9 @@ export function readColdStartDiagnostic() {
|
|
|
104
104
|
* default: on a cold socket the injected `onColdSocket` hook fires
|
|
105
105
|
* `ensureDaemon()` (spawns crtrd detached via the branded host), then the
|
|
106
106
|
* client polls `/healthz` to a bounded deadline and retries once (the poll
|
|
107
|
-
* lives inside `CrtrClient`, A-2). If crtrd never becomes reachable
|
|
108
|
-
*
|
|
109
|
-
*
|
|
107
|
+
* lives inside `CrtrClient`, A-2). If crtrd never becomes reachable, the
|
|
108
|
+
* client surfaces the final observed transport failure. This is the ONLY CLI
|
|
109
|
+
* reference to `ensureDaemon`.
|
|
110
110
|
*
|
|
111
111
|
* `onColdSocket` is fire-and-forget — `ensureDaemon()` spawns crtrd and
|
|
112
112
|
* returns immediately, so the client's OWN `/healthz` poll is the only
|
|
@@ -22,7 +22,8 @@ async function fetchSubject(nodeId) {
|
|
|
22
22
|
if (nodeId === undefined)
|
|
23
23
|
return null;
|
|
24
24
|
try {
|
|
25
|
-
|
|
25
|
+
// This optional gate lookup must not hold a memory read while a daemon is unavailable: an absent subject simply skips gated docs.
|
|
26
|
+
return await CrtrClient.forLocalSocket({ autostart: false, coldStartPollWindowMs: 0 }).nodeSubject(nodeId);
|
|
26
27
|
}
|
|
27
28
|
catch {
|
|
28
29
|
return null;
|
|
@@ -117,7 +117,7 @@ const nodeBashKill = defineLeaf({
|
|
|
117
117
|
tier: 'hidden',
|
|
118
118
|
help: {
|
|
119
119
|
name: 'node bash kill',
|
|
120
|
-
summary: '
|
|
120
|
+
summary: 'best-effort stop a background bash job and tell the owning node it was canceled',
|
|
121
121
|
params: [
|
|
122
122
|
{ kind: 'positional', name: 'job', required: true, constraint: 'Job id, as reported by node bash list.' },
|
|
123
123
|
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node owning the job. Defaults to the node in --pane, else the caller (CRTR_NODE_ID).' },
|
|
@@ -125,11 +125,11 @@ const nodeBashKill = defineLeaf({
|
|
|
125
125
|
],
|
|
126
126
|
output: [
|
|
127
127
|
{ name: 'node_id', type: 'string', required: true, constraint: 'The owning node.' },
|
|
128
|
-
{ name: 'signaled', type: 'boolean', required: true, constraint: 'True when
|
|
128
|
+
{ name: 'signaled', type: 'boolean', required: true, constraint: 'True when at least one observed supervisor-tree process received SIGTERM.' },
|
|
129
129
|
],
|
|
130
130
|
outputKind: 'object',
|
|
131
131
|
effects: [
|
|
132
|
-
'
|
|
132
|
+
'Snapshots the supervisor’s observable process tree, sends SIGTERM then SIGKILL to those processes, and cannot stop a descendant that detached and reparented before the snapshot.',
|
|
133
133
|
'Records the job’s exit so it stops showing as live, and sends the owning node an urgent inbox message saying the job was stopped — which steers that node mid-turn.',
|
|
134
134
|
],
|
|
135
135
|
},
|
|
@@ -159,8 +159,8 @@ const nodeBashKill = defineLeaf({
|
|
|
159
159
|
if (stopped.kind === 'not-stoppable') {
|
|
160
160
|
throw new InputError({
|
|
161
161
|
error: 'not_stoppable',
|
|
162
|
-
message: `job ${jobId} recorded no process
|
|
163
|
-
next: `It started before crtr persisted
|
|
162
|
+
message: `job ${jobId} recorded no trusted process identity`,
|
|
163
|
+
next: `It started before crtr persisted its supervisor identity. Stop it by hand, then it will disappear from the list. Log: ${paths.jobLog}`,
|
|
164
164
|
});
|
|
165
165
|
}
|
|
166
166
|
await cliClient().sendMessage(node.node_id, {
|
|
@@ -169,7 +169,7 @@ const nodeBashKill = defineLeaf({
|
|
|
169
169
|
});
|
|
170
170
|
return { node_id: node.node_id, signaled: stopped.signaled };
|
|
171
171
|
},
|
|
172
|
-
render: (result) => `Canceled a background bash job for ${result['node_id']}${result['signaled'] === true ? '' : ' (
|
|
172
|
+
render: (result) => `Canceled a background bash job for ${result['node_id']}${result['signaled'] === true ? '' : ' (no observed job process was still running)'}.`,
|
|
173
173
|
});
|
|
174
174
|
const nodeBashExtend = defineLeaf({
|
|
175
175
|
name: 'extend',
|
|
@@ -38,7 +38,7 @@ async function resolveMsgTarget(input, hasBody) {
|
|
|
38
38
|
}
|
|
39
39
|
function messageOutput() {
|
|
40
40
|
return [
|
|
41
|
-
{ name: 'revived', type: 'boolean', required: false, constraint: 'True when the target
|
|
41
|
+
{ name: 'revived', type: 'boolean', required: false, constraint: 'True when this request synchronously revived the target; ordinary durable delivery to an active or idle dormant target queues lifecycle revival instead.' },
|
|
42
42
|
{ name: 'guidance', type: 'string', required: true, constraint: 'Immediate action confirmation.' },
|
|
43
43
|
];
|
|
44
44
|
}
|
|
@@ -67,7 +67,7 @@ function messageRequestParams() {
|
|
|
67
67
|
{ kind: 'stdin', name: 'body', required: false, constraint: 'Visible message body. The schema alone is a complete request.', positionalStdinConflict: missingMessageTargetForBody },
|
|
68
68
|
{ kind: 'flag', name: 'to', type: 'string', required: false, constraint: 'Target an existing node by id. Exactly one of --to or --self is required.' },
|
|
69
69
|
{ kind: 'flag', name: 'self', type: 'bool', required: false, constraint: 'Target the calling node. Exactly one of --to or --self is required.' },
|
|
70
|
-
{ kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal'], required: false, default: 'normal', constraint: 'Delivery urgency. Every accepted tier
|
|
70
|
+
{ kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal'], required: false, default: 'normal', constraint: 'Delivery urgency. Every accepted tier queues lifecycle revival for an active or idle dormant target because the request must be answered; deferred is rejected for that reason.' },
|
|
71
71
|
{ kind: 'flag', name: 'output-schema', type: 'string', required: true, constraint: `JSON Schema the result must satisfy. ${OUTPUT_SCHEMA_TRANSPORT} The target answers with \`crtr push result\` (or declines with \`crtr push result --decline "<reason>" --code <token>\`), then keeps working. ${OUTPUT_SCHEMA_CONSTRAINTS}` },
|
|
72
72
|
{ kind: 'flag', name: 'situational-context', type: 'string', required: false, constraint: 'Non-empty hidden ambient context delivered beside the message, or alone.' },
|
|
73
73
|
{ kind: 'flag', name: 'reopen', type: 'bool', required: false, hidden: true, constraint: 'Clear a finalized target latch before delivery or fresh revive.' },
|
|
@@ -125,7 +125,7 @@ const nodeMessageSend = defineLeaf({
|
|
|
125
125
|
params: messageSendParams(),
|
|
126
126
|
output: messageOutput(),
|
|
127
127
|
outputKind: 'object',
|
|
128
|
-
effects: ['Appends one inbox entry. Critical, urgent, and normal delivery
|
|
128
|
+
effects: ['Appends one inbox entry. Critical, urgent, and normal delivery queues lifecycle revival for an active or idle dormant target; deferred waits for its next natural cycle.', '--reopen commits the target resident and clears any finalization latch before delivery, allowing it to take a new mandate; it is valid for live, parked, and finalized targets. Without it, a finalized target rejects delivery before an inbox entry is appended or a revive is attempted.'],
|
|
129
129
|
},
|
|
130
130
|
run: (input) => runMessageEngine(input),
|
|
131
131
|
render: (r) => String(r['guidance']),
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// This subtree starts, checks, and stops the daemon process.
|
|
9
9
|
import { defineLeaf, defineBranch } from '../../core/command.js';
|
|
10
10
|
import { InputError } from '../../core/io.js';
|
|
11
|
-
import {
|
|
11
|
+
import { probeDaemonServing, spawnDaemon, stopDaemonProcess, sweepStrayDaemons } from '../../daemon/manage.js';
|
|
12
12
|
import { isDaemonRunning, readPidfile, isPidAlive } from '../../daemon/pidfile.js';
|
|
13
13
|
import { cliClient } from '../api-client.js';
|
|
14
14
|
// daemon start
|
|
@@ -26,7 +26,7 @@ const daemonStart = defineLeaf({
|
|
|
26
26
|
{ name: 'existing_pid', type: 'number', required: false, constraint: 'PID of the already-running daemon (when started=false).' },
|
|
27
27
|
{ name: 'running', type: 'boolean', required: false, constraint: 'True only when a live pidfile owner does not serve its Unix API socket.' },
|
|
28
28
|
{ name: 'serving', type: 'boolean', required: false, constraint: 'False only when a live pidfile owner does not answer /healthz.' },
|
|
29
|
-
{ name: '
|
|
29
|
+
{ name: 'probe_error', type: 'string', required: false, constraint: 'The final /healthz probe failure when a live owner missed readiness.' },
|
|
30
30
|
],
|
|
31
31
|
outputKind: 'object',
|
|
32
32
|
effects: [
|
|
@@ -50,9 +50,9 @@ const daemonStatus = defineLeaf({
|
|
|
50
50
|
params: [],
|
|
51
51
|
output: [
|
|
52
52
|
{ name: 'running', type: 'boolean', required: true, constraint: 'True when the daemon pidfile owner is alive.' },
|
|
53
|
-
{ name: 'serving', type: 'boolean', required: false, constraint: 'True when the live daemon answers /healthz; false when
|
|
53
|
+
{ name: 'serving', type: 'boolean', required: false, constraint: 'True when the live daemon answers /healthz; false when one probe does not.' },
|
|
54
54
|
{ name: 'pid', type: 'number', required: false, constraint: 'PID of the running daemon.' },
|
|
55
|
-
{ name: '
|
|
55
|
+
{ name: 'probe_error', type: 'string', required: false, constraint: 'The failed /healthz observation when the live owner did not answer.' },
|
|
56
56
|
],
|
|
57
57
|
outputKind: 'object',
|
|
58
58
|
effects: ['Read-only: reads the pidfile, probes the pid, and requests /healthz without autostart.'],
|
|
@@ -62,15 +62,13 @@ const daemonStatus = defineLeaf({
|
|
|
62
62
|
const running = pid !== null && isPidAlive(pid);
|
|
63
63
|
if (!running)
|
|
64
64
|
return { running: false };
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
next: 'Run `crtr sys daemon stop` to release the non-serving daemon before starting it again.',
|
|
73
|
-
};
|
|
65
|
+
try {
|
|
66
|
+
await probeDaemonServing();
|
|
67
|
+
return { running: true, serving: true, pid };
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
return { running: true, serving: false, pid, probe_error: error.message };
|
|
71
|
+
}
|
|
74
72
|
},
|
|
75
73
|
});
|
|
76
74
|
// daemon restart
|
|
@@ -23,8 +23,6 @@ function projectionFor(session, generation) {
|
|
|
23
23
|
notifyTurnAccepted: () => undefined,
|
|
24
24
|
emitStartupMilestone: () => undefined,
|
|
25
25
|
formatModelSpec: () => null,
|
|
26
|
-
refreshIntent: async () => false,
|
|
27
|
-
onRefreshIntentError: () => undefined,
|
|
28
26
|
});
|
|
29
27
|
}
|
|
30
28
|
test('agent_start arms the provider watchdog before another engine event arrives', (t) => {
|
|
@@ -294,7 +294,7 @@ test('verifyDaemonStartup throws after its tiny startup window expires', async (
|
|
|
294
294
|
sleepMs: (ms) => {
|
|
295
295
|
now += ms;
|
|
296
296
|
},
|
|
297
|
-
}), /
|
|
297
|
+
}), /has no live pidfile owner/);
|
|
298
298
|
});
|
|
299
299
|
// REGRESSION (bug #102): the verifier polls with a real async sleep, NOT a
|
|
300
300
|
// synchronous Atomics.wait, so the event loop stays free to dispatch the spawned
|
|
@@ -101,6 +101,8 @@ export interface Harness {
|
|
|
101
101
|
inbox(nodeId: string): InboxEntry[];
|
|
102
102
|
injected(nodeId: string): Injected[];
|
|
103
103
|
fakeCmd(nodeId: string, cmd: Record<string, unknown>): void;
|
|
104
|
+
/** Stop only this harness's API server to exercise a broker daemon-read failure. */
|
|
105
|
+
stopApiServer(): Promise<void>;
|
|
104
106
|
dialogResults(nodeId: string): {
|
|
105
107
|
resolved: unknown;
|
|
106
108
|
ms: number;
|
|
@@ -637,6 +637,15 @@ export async function createHarness(opts = {}) {
|
|
|
637
637
|
fakeCmd(nodeId, cmd) {
|
|
638
638
|
sendCmd(nodeId, cmd);
|
|
639
639
|
},
|
|
640
|
+
async stopApiServer() {
|
|
641
|
+
try {
|
|
642
|
+
apiServerProc.kill('SIGTERM');
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
/* already gone */
|
|
646
|
+
}
|
|
647
|
+
await waitFor(() => apiServerProc.exitCode === null ? null : true, { label: 'harness API server exit' });
|
|
648
|
+
},
|
|
640
649
|
dialogResults(nodeId) {
|
|
641
650
|
return readLines(join(nodeDir(nodeId), 'fake-pi.dialog.jsonl'))
|
|
642
651
|
.map((l) => {
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|