@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
|
@@ -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',
|
|
@@ -82,8 +82,9 @@ function nodeNewParams() {
|
|
|
82
82
|
{ kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name.' },
|
|
83
83
|
{ kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node.' },
|
|
84
84
|
{ kind: 'flag', name: 'node-id', type: 'string', required: false, constraint: 'Exact id for the new node; lowercase letters, digits, and hyphens only, at most 128 characters. Rejected if already present.' },
|
|
85
|
-
{ kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an independent
|
|
85
|
+
{ kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an independent root with no parent or subscription. Mutually exclusive with --worktree.' },
|
|
86
86
|
{ kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'Birth the node as resident: it parks and stays wakeable without being forced to submit a final. Pass it ONLY when the user will open this node’s viewer pane and work with it back and forth; a child that only other nodes message stays terminal. Applies to managed children and roots; roots default to resident.' },
|
|
87
|
+
{ kind: 'flag', name: 'terminal', type: 'bool', required: false, constraint: 'Birth the node as terminal: it finalizes when its work is done and leaves nothing wakeable behind. Only meaningful with --root, whose default is resident; managed children are already terminal. Pass it for bounded programmatic work no one will open a viewer on. Mutually exclusive with --resident.' },
|
|
87
88
|
{ kind: 'flag', name: 'worktree', type: 'string', required: false, constraint: 'Local base branch for a crouter-managed worktree for this child. Mutually exclusive with --root.' },
|
|
88
89
|
{ kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'Fork the new node from an existing node id, absolute session path, or partial pi session uuid.' },
|
|
89
90
|
{
|
|
@@ -190,6 +191,10 @@ async function runNodeCreation(input) {
|
|
|
190
191
|
const parent = input['parent'];
|
|
191
192
|
const root = input['root'] === true;
|
|
192
193
|
const resident = input['resident'] === true;
|
|
194
|
+
const terminal = input['terminal'] === true;
|
|
195
|
+
if (resident && terminal) {
|
|
196
|
+
throw new InputError({ error: 'lifecycle_conflict', message: '--resident and --terminal cannot both be passed', field: 'terminal', next: 'Pass the one birth lifecycle you want, or neither to take the default (resident for --root, terminal for a managed child).' });
|
|
197
|
+
}
|
|
193
198
|
const worktreeBaseRaw = input['worktree'];
|
|
194
199
|
if (worktreeBaseRaw !== undefined && worktreeBaseRaw.trim() === '') {
|
|
195
200
|
throw new InputError({ error: 'worktree_base_required', message: '--worktree requires a base branch', field: 'worktree', next: 'Pass an existing local branch that landing can fast-forward.' });
|
|
@@ -257,6 +262,7 @@ async function runNodeCreation(input) {
|
|
|
257
262
|
...(root ? { root: true } : { parent: parentId ?? null }),
|
|
258
263
|
...(creator !== undefined ? { creator } : {}),
|
|
259
264
|
...(resident ? { root_lifecycle: 'resident' } : {}),
|
|
265
|
+
...(terminal ? { root_lifecycle: 'terminal' } : {}),
|
|
260
266
|
...(worktreeBase !== undefined ? { worktree: worktreeBase } : {}),
|
|
261
267
|
...(forkFrom !== undefined ? { fork_from: forkFrom } : {}),
|
|
262
268
|
...(modelSpec !== undefined ? { model: modelSpec } : {}),
|
|
@@ -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']),
|
|
@@ -46,7 +46,7 @@ function deadlineHeredoc(body, nodeId) {
|
|
|
46
46
|
const lines = new Set(body.split('\n'));
|
|
47
47
|
while (lines.has(delimiter))
|
|
48
48
|
delimiter = `CRTR_DEADLINE_${++suffix}`;
|
|
49
|
-
return `crtr cron cancel "$CRTR_CRON_ID"
|
|
49
|
+
return `crtr cron cancel "$CRTR_CRON_ID"\ncrtr node message send --to ${shellQuote(nodeId)} --tier urgent <<'${delimiter}'\n${body}\n${delimiter}`;
|
|
50
50
|
}
|
|
51
51
|
const nodeWaitDeadline = defineLeaf({
|
|
52
52
|
name: 'deadline',
|
|
@@ -85,7 +85,8 @@ const worktreeClose = defineLeaf({
|
|
|
85
85
|
{ name: 'worktree_removed', type: 'boolean', required: true, constraint: 'Always false for an active caller: its checkout remains intact rather than being removed beneath the node.' },
|
|
86
86
|
{ name: 'worktree_remove_error', type: 'string', required: true, constraint: 'The named deferred-cleanup state and safe manual follow-up after the caller exits.' },
|
|
87
87
|
{ name: 'branch_deleted', type: 'boolean', required: true, constraint: 'False while the deferred-cleanup checkout still has this branch checked out.' },
|
|
88
|
-
{ name: 'branch_delete_error', type: 'string', required: true, constraint: 'The safe branch-cleanup follow-up after the checkout is removed.' }
|
|
88
|
+
{ name: 'branch_delete_error', type: 'string', required: true, constraint: 'The safe branch-cleanup follow-up after the checkout is removed.' },
|
|
89
|
+
{ name: 'base_checkout_stash', type: 'string', required: false, constraint: 'Present when conflicting base-checkout changes were retained in a stash; names the exact stash SHA and command required to recover them.' }
|
|
89
90
|
],
|
|
90
91
|
outputKind: 'object',
|
|
91
92
|
effects: [
|
|
@@ -116,6 +117,9 @@ const worktreeClose = defineLeaf({
|
|
|
116
117
|
if (r['branch_deleted'] === false) {
|
|
117
118
|
notes.push(String(r['branch_delete_error'] ?? `local branch ${branch} could not be deleted automatically.`));
|
|
118
119
|
}
|
|
120
|
+
if (typeof r['base_checkout_stash'] === 'string') {
|
|
121
|
+
notes.push(`Warning: ${r['base_checkout_stash']}`);
|
|
122
|
+
}
|
|
119
123
|
if (notes.length > 0)
|
|
120
124
|
return `${base} Note: ${notes.join(' ')}`;
|
|
121
125
|
return base;
|
|
@@ -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
|
|
@@ -35,6 +35,7 @@ import { join, dirname } from 'node:path';
|
|
|
35
35
|
import { randomUUID } from 'node:crypto';
|
|
36
36
|
import { DefaultResourceLoader, getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
37
37
|
import { MANAGED_PROVIDER_COOLING_DIAGNOSTIC } from '../../runtime/managed-provider-cooling.js';
|
|
38
|
+
import { PROVIDER_FAILURE_DIAGNOSTIC } from '../../provider-failure.js';
|
|
38
39
|
import registerCanvasBashValve, { createValveOperations } from '../../../pi-extensions/canvas-bash-valve.js';
|
|
39
40
|
/** Test seams for prompt-scoped failures before session_start. The one-shot
|
|
40
41
|
* variant records its first failure per node so the next cycling retry can
|
|
@@ -1085,6 +1086,18 @@ class FakeSession {
|
|
|
1085
1086
|
// before the stophook sees agent_end while the run is active; the
|
|
1086
1087
|
// subscriber receives agent_settled after it clears.
|
|
1087
1088
|
this.streaming = true;
|
|
1089
|
+
const diagnostics = [
|
|
1090
|
+
...(cmd.deadline === undefined ? [] : [{
|
|
1091
|
+
type: MANAGED_PROVIDER_COOLING_DIAGNOSTIC,
|
|
1092
|
+
timestamp: Date.now(),
|
|
1093
|
+
details: { rateLimitedUntil: cmd.deadline },
|
|
1094
|
+
}]),
|
|
1095
|
+
...(cmd.providerFailure === undefined ? [] : [{
|
|
1096
|
+
type: PROVIDER_FAILURE_DIAGNOSTIC,
|
|
1097
|
+
timestamp: Date.now(),
|
|
1098
|
+
details: cmd.providerFailure,
|
|
1099
|
+
}]),
|
|
1100
|
+
];
|
|
1088
1101
|
const stopMessages = [
|
|
1089
1102
|
{
|
|
1090
1103
|
role: 'assistant',
|
|
@@ -1093,21 +1106,7 @@ class FakeSession {
|
|
|
1093
1106
|
// turn; the stophook classifies it for the active-fault marker.
|
|
1094
1107
|
// Forward it so a test can drive a CONNECTION stall faithfully.
|
|
1095
1108
|
...(cmd.errorMessage === undefined ? {} : { errorMessage: cmd.errorMessage }),
|
|
1096
|
-
|
|
1097
|
-
// deadline as a typed diagnostic that survives verbatim into
|
|
1098
|
-
// agent_end.messages; the stophook reads it (extractCoolingDeadline)
|
|
1099
|
-
// to stamp retry.nextAt. Attached only when a test drives `deadline`.
|
|
1100
|
-
...(cmd.deadline === undefined
|
|
1101
|
-
? {}
|
|
1102
|
-
: {
|
|
1103
|
-
diagnostics: [
|
|
1104
|
-
{
|
|
1105
|
-
type: MANAGED_PROVIDER_COOLING_DIAGNOSTIC,
|
|
1106
|
-
timestamp: Date.now(),
|
|
1107
|
-
details: { rateLimitedUntil: cmd.deadline },
|
|
1108
|
-
},
|
|
1109
|
-
],
|
|
1110
|
-
}),
|
|
1109
|
+
...(diagnostics.length === 0 ? {} : { diagnostics }),
|
|
1111
1110
|
content: [{ type: 'text', text: cmd.text ?? '' }],
|
|
1112
1111
|
},
|
|
1113
1112
|
];
|
|
@@ -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) => {
|
|
@@ -275,3 +275,56 @@ test('tmux installer reads pair-array manifests and replaces owned pairs', (t) =
|
|
|
275
275
|
isolatedTmux(socket, ['kill-server']);
|
|
276
276
|
}
|
|
277
277
|
});
|
|
278
|
+
// Regression: a passthrough wrapper survives the user re-sourcing their own tmux
|
|
279
|
+
// config over it. Crouter wraps an occupied root key and fingerprints the result;
|
|
280
|
+
// every `source-file` (and every fresh tmux server) re-installs the user's own
|
|
281
|
+
// bind-key line over that wrapper. Reading the mismatch as a deliberate rebind
|
|
282
|
+
// relinquished the key permanently, so the attach viewer silently stopped
|
|
283
|
+
// receiving its own scroll keys and no reconciliation ever restored them.
|
|
284
|
+
test('tmux passthrough re-wraps a re-sourced config but yields to a real rebind', (t) => {
|
|
285
|
+
if (spawnSync('tmux', ['-V']).status !== 0) {
|
|
286
|
+
t.skip('tmux is unavailable');
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const name = `crtr-passthrough-${process.pid}-${Date.now()}`;
|
|
290
|
+
const boot = spawnSync('tmux', ['-L', name, '-f', '/dev/null', 'new-session', '-d', '-s', 'test'], { encoding: 'utf8' });
|
|
291
|
+
assert.equal(boot.status, 0, boot.stderr);
|
|
292
|
+
const socket = spawnSync('tmux', ['-L', name, 'display-message', '-p', '#{socket_path}'], { encoding: 'utf8' }).stdout.trim();
|
|
293
|
+
assert.notEqual(socket, '');
|
|
294
|
+
const priorTmux = process.env['TMUX'];
|
|
295
|
+
process.env['TMUX'] = `${socket},1,0`;
|
|
296
|
+
const userBinding = ['bind-key', '-T', 'root', 'C-k', 'copy-mode', '-e'];
|
|
297
|
+
const bindings = resolveKeybindings({ 'crtr.attach.scroll.up': ['ctrl+k'] });
|
|
298
|
+
const passthrough = ['crtr.attach.scroll.up'];
|
|
299
|
+
try {
|
|
300
|
+
isolatedTmux(socket, userBinding);
|
|
301
|
+
const original = tmuxBinding(socket, 'root', 'C-k');
|
|
302
|
+
assert.match(original ?? '', /copy-mode/);
|
|
303
|
+
const wrapped = installTmuxBindings(bindings, passthrough);
|
|
304
|
+
assert.equal(wrapped.ok, true, JSON.stringify(wrapped.diagnostics));
|
|
305
|
+
assert.match(tmuxBinding(socket, 'root', 'C-k') ?? '', /@crtr_attach/, 'occupied key should be wrapped');
|
|
306
|
+
// The user reloads their tmux config: their own line lands over the wrapper.
|
|
307
|
+
isolatedTmux(socket, userBinding);
|
|
308
|
+
assert.equal(tmuxBinding(socket, 'root', 'C-k'), original);
|
|
309
|
+
const resourced = installTmuxBindings(bindings, passthrough);
|
|
310
|
+
assert.equal(resourced.ok, true, JSON.stringify(resourced.diagnostics));
|
|
311
|
+
assert.match(tmuxBinding(socket, 'root', 'C-k') ?? '', /@crtr_attach/, 're-sourced config should be re-wrapped, not relinquished');
|
|
312
|
+
// A line matching neither the wrapper nor the recorded original is a real
|
|
313
|
+
// rebind and stays the user's, here and on every later reconciliation.
|
|
314
|
+
isolatedTmux(socket, ['bind-key', '-T', 'root', 'C-k', 'display-message', 'user-owned']);
|
|
315
|
+
for (const pass of ['first', 'second']) {
|
|
316
|
+
const rebound = installTmuxBindings(bindings, passthrough);
|
|
317
|
+
assert.equal(rebound.ok, true, JSON.stringify(rebound.diagnostics));
|
|
318
|
+
const live = tmuxBinding(socket, 'root', 'C-k') ?? '';
|
|
319
|
+
assert.match(live, /user-owned/, `${pass} reconciliation should leave a real rebind alone`);
|
|
320
|
+
assert.doesNotMatch(live, /@crtr_attach/);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
if (priorTmux === undefined)
|
|
325
|
+
delete process.env['TMUX'];
|
|
326
|
+
else
|
|
327
|
+
process.env['TMUX'] = priorTmux;
|
|
328
|
+
isolatedTmux(socket, ['kill-server']);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
@@ -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);
|