@crouton-kit/crouter 0.3.40 → 0.3.42
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/build-root.js +1 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
- package/dist/clients/attach/attach-cmd.js +720 -747
- package/dist/clients/attach/chat-view.js +11 -0
- package/dist/clients/attach/view-socket.d.ts +8 -1
- package/dist/clients/attach/view-socket.js +15 -1
- package/dist/clients/web/server.js +5 -28
- package/dist/clients/web/web-cmd.js +1 -1
- package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
- package/dist/commands/canvas-rebuild-index.js +5 -5
- package/dist/commands/memory/shared.d.ts +7 -3
- package/dist/commands/memory/shared.js +35 -5
- package/dist/commands/memory/write.js +5 -3
- package/dist/commands/node-snapshot.js +9 -1
- package/dist/commands/node.js +37 -17
- package/dist/commands/push.js +8 -0
- package/dist/commands/revive.d.ts +10 -0
- package/dist/commands/revive.js +28 -10
- package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
- package/dist/commands/sys/doctor.js +1 -1
- package/dist/commands/sys/setup-core.js +3 -2
- package/dist/commands/sys/setup.js +1 -1
- package/dist/commands/worktree.d.ts +2 -0
- package/dist/commands/worktree.js +94 -0
- package/dist/core/__tests__/boot.test.js +4 -4
- package/dist/core/__tests__/canvas.test.js +19 -7
- package/dist/core/__tests__/child-followup.test.js +15 -5
- package/dist/core/__tests__/daemon-boot.test.js +6 -1
- package/dist/core/__tests__/daemon-wedge.test.js +18 -1
- package/dist/core/__tests__/fault-classifier.test.js +30 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
- package/dist/core/__tests__/full/spike-harness.test.js +8 -5
- package/dist/core/__tests__/grace-clock.test.js +18 -2
- package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
- package/dist/core/__tests__/prune-to-limit.test.js +14 -0
- package/dist/core/__tests__/review-model-floor.test.js +32 -0
- package/dist/core/__tests__/revive.test.js +18 -4
- package/dist/core/__tests__/session-cycles.test.js +77 -0
- package/dist/core/__tests__/worktree.test.js +85 -0
- package/dist/core/canvas/boot.js +12 -7
- package/dist/core/canvas/canvas.d.ts +27 -8
- package/dist/core/canvas/canvas.js +54 -26
- package/dist/core/canvas/db.js +14 -0
- package/dist/core/canvas/history.js +1 -0
- package/dist/core/canvas/paths.d.ts +10 -9
- package/dist/core/canvas/paths.js +10 -9
- package/dist/core/canvas/pid.d.ts +155 -1
- package/dist/core/canvas/pid.js +306 -1
- package/dist/core/canvas/status-glyph.d.ts +7 -0
- package/dist/core/canvas/status-glyph.js +10 -1
- package/dist/core/canvas/types.d.ts +34 -0
- package/dist/core/fault-classifier.js +5 -1
- package/dist/core/profiles/select.d.ts +4 -2
- package/dist/core/profiles/select.js +30 -4
- package/dist/core/runtime/bearings.js +4 -0
- package/dist/core/runtime/branded-host.d.ts +7 -0
- package/dist/core/runtime/branded-host.js +44 -17
- package/dist/core/runtime/broker-sdk.js +28 -68
- package/dist/core/runtime/broker.js +35 -4
- package/dist/core/runtime/host.d.ts +3 -3
- package/dist/core/runtime/host.js +148 -33
- package/dist/core/runtime/launch.d.ts +15 -15
- package/dist/core/runtime/launch.js +58 -4
- package/dist/core/runtime/naming.js +3 -2
- package/dist/core/runtime/nodes.d.ts +3 -1
- package/dist/core/runtime/nodes.js +1 -0
- package/dist/core/runtime/pi-cli.d.ts +2 -0
- package/dist/core/runtime/pi-cli.js +51 -0
- package/dist/core/runtime/placement.d.ts +1 -1
- package/dist/core/runtime/placement.js +5 -2
- package/dist/core/runtime/recap.js +2 -1
- package/dist/core/runtime/revive.d.ts +21 -15
- package/dist/core/runtime/revive.js +87 -52
- package/dist/core/runtime/session-cycles.d.ts +30 -0
- package/dist/core/runtime/session-cycles.js +77 -0
- package/dist/core/runtime/spawn.d.ts +4 -0
- package/dist/core/runtime/spawn.js +152 -100
- package/dist/core/runtime/tmux.d.ts +7 -10
- package/dist/core/runtime/tmux.js +9 -11
- package/dist/core/worktree.d.ts +35 -0
- package/dist/core/worktree.js +158 -0
- package/dist/daemon/crtrd.d.ts +17 -8
- package/dist/daemon/crtrd.js +191 -40
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
- package/dist/pi-extensions/canvas-context-intro.js +34 -23
- package/dist/pi-extensions/canvas-stophook.js +11 -5
- package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
- package/dist/web-client/assets/index-DrkcvANq.css +2 -0
- package/dist/web-client/index.html +2 -2
- package/docs/compat/hearth-crtr-v1.md +191 -0
- package/docs/public-api.md +75 -0
- package/package.json +3 -4
- package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
- package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
- package/dist/core/hearth/config.d.ts +0 -3
- package/dist/core/hearth/config.js +0 -108
- package/dist/core/hearth/guest-env.d.ts +0 -9
- package/dist/core/hearth/guest-env.js +0 -27
- package/dist/core/hearth/index.d.ts +0 -4
- package/dist/core/hearth/index.js +0 -4
- package/dist/core/hearth/model-auth-guest.d.ts +0 -8
- package/dist/core/hearth/model-auth-guest.js +0 -430
- package/dist/core/hearth/provider.d.ts +0 -36
- package/dist/core/hearth/provider.js +0 -10
- package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -362
- package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
- package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
- package/dist/core/hearth/providers/blaxel-home.d.ts +0 -60
- package/dist/core/hearth/providers/blaxel-home.js +0 -405
- package/dist/core/hearth/providers/blaxel.d.ts +0 -36
- package/dist/core/hearth/providers/blaxel.js +0 -364
- package/dist/core/hearth/providers/types.d.ts +0 -93
- package/dist/core/hearth/types.d.ts +0 -155
- package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
- package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
- package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
- package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
- package/dist/hearth/control-plane/config.d.ts +0 -21
- package/dist/hearth/control-plane/config.js +0 -77
- package/dist/hearth/control-plane/db.d.ts +0 -30
- package/dist/hearth/control-plane/db.js +0 -561
- package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
- package/dist/hearth/control-plane/hearth-target.js +0 -68
- package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
- package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
- package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
- package/dist/hearth/control-plane/ingress/route-store.js +0 -61
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
- package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
- package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
- package/dist/hearth/control-plane/main.d.ts +0 -1
- package/dist/hearth/control-plane/main.js +0 -88
- package/dist/hearth/control-plane/node-message.d.ts +0 -31
- package/dist/hearth/control-plane/node-message.js +0 -98
- package/dist/hearth/control-plane/register.d.ts +0 -15
- package/dist/hearth/control-plane/register.js +0 -34
- package/dist/hearth/control-plane/registry.d.ts +0 -22
- package/dist/hearth/control-plane/registry.js +0 -168
- package/dist/hearth/control-plane/relay.d.ts +0 -44
- package/dist/hearth/control-plane/relay.js +0 -711
- package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
- package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
- package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
- package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
- package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
- package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
- package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
- package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
- package/dist/hearth/control-plane/secrets.d.ts +0 -31
- package/dist/hearth/control-plane/secrets.js +0 -134
- package/dist/hearth/control-plane/server.d.ts +0 -27
- package/dist/hearth/control-plane/server.js +0 -482
- package/dist/hearth/control-plane/serving.d.ts +0 -15
- package/dist/hearth/control-plane/serving.js +0 -106
- package/dist/hearth/control-plane/session.d.ts +0 -68
- package/dist/hearth/control-plane/session.js +0 -273
- package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
- package/dist/hearth/control-plane/triggers/acl.js +0 -52
- package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
- package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
- package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
- package/dist/hearth/control-plane/triggers/deliver.js +0 -76
- package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
- package/dist/hearth/control-plane/triggers/envelope.js +0 -38
- package/dist/hearth/control-plane/types.d.ts +0 -86
- package/dist/hearth/control-plane/types.js +0 -1
- package/dist/hearth/control-plane/wake.d.ts +0 -86
- package/dist/hearth/control-plane/wake.js +0 -550
- package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
- /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
- /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
- /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
package/dist/commands/revive.js
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// Bypasses the daemon: directly relaunches the broker engine for a node that is
|
|
4
4
|
// done, idle, or dead. Default behavior resumes the saved pi conversation by its
|
|
5
5
|
// absolute session-file path (--session <path>) when that .jsonl still exists,
|
|
6
|
-
// else falls through to a fresh launch
|
|
7
|
-
//
|
|
6
|
+
// else falls through to a fresh launch. `--fresh` skips the true resume: if the
|
|
7
|
+
// `.jsonl` already exists it CYCLES in place instead (same file, a fresh
|
|
8
|
+
// `crtr-cycle` branch — `resumed:false`), and only starts a genuinely clean
|
|
9
|
+
// session when no `.jsonl` exists yet (also `resumed:false`).
|
|
8
10
|
//
|
|
9
11
|
// `--all` sweeps EVERY disconnected node (engine not running, resumable session)
|
|
10
12
|
// in one shot — the recovery for a reboot / mass-crash / daemon-down-a-while
|
|
@@ -19,6 +21,22 @@ import { getNode, fullName } from '../core/canvas/index.js';
|
|
|
19
21
|
import { isPidAlive } from '../core/canvas/pid.js';
|
|
20
22
|
import { readFault } from '../core/runtime/fault.js';
|
|
21
23
|
import { isBusy } from '../core/runtime/busy.js';
|
|
24
|
+
import { faultNeedsYou } from '../core/canvas/status-glyph.js';
|
|
25
|
+
/** --now eligibility for a live, non-busy fault: a daemon-owned AUTO retry
|
|
26
|
+
* (kick it early instead of waiting out the schedule), or any fault the
|
|
27
|
+
* canvas dashboard flags "needs you" (`faultNeedsYou`, status-glyph.ts) — the
|
|
28
|
+
* disposition that otherwise has NO sanctioned recovery verb (issue #115).
|
|
29
|
+
* Both are scoped to `pi→provider`: refuses a client-owned auto retry
|
|
30
|
+
* (redialing) already in flight, and refuses any OTHER link — notably a
|
|
31
|
+
* `daemon→node` wedge fault, which --now can't clear (SIGTERM doesn't touch
|
|
32
|
+
* the runaway subprocess; see `wedged-child-on-runaway-bash`). */
|
|
33
|
+
export function isNowEligibleFault(fault) {
|
|
34
|
+
if (fault === null || fault.link !== 'pi→provider')
|
|
35
|
+
return false;
|
|
36
|
+
if (faultNeedsYou(fault))
|
|
37
|
+
return true;
|
|
38
|
+
return fault.retry.by === 'daemon';
|
|
39
|
+
}
|
|
22
40
|
// ---------------------------------------------------------------------------
|
|
23
41
|
// revive node
|
|
24
42
|
// ---------------------------------------------------------------------------
|
|
@@ -50,7 +68,7 @@ export const reviveLeaf = defineLeaf({
|
|
|
50
68
|
type: 'bool',
|
|
51
69
|
required: false,
|
|
52
70
|
default: false,
|
|
53
|
-
constraint: 'When set,
|
|
71
|
+
constraint: 'When set, do NOT do a true resume. If the node already has a session `.jsonl`, this instead CYCLES it in place — reuses that SAME file, resets the tree leaf, and roots a fresh `crtr-cycle` branch (a clean-context restart that still carries the file\'s history, output `resumed:false`). Only when no `.jsonl` exists yet does this start a genuinely brand-new session (also `resumed:false`). Default (no --fresh): a true resume — replays the existing `.jsonl` as-is (`resumed:true`). Ignored with --all (which always resumes).',
|
|
54
72
|
},
|
|
55
73
|
{
|
|
56
74
|
kind: 'flag',
|
|
@@ -58,7 +76,7 @@ export const reviveLeaf = defineLeaf({
|
|
|
58
76
|
type: 'bool',
|
|
59
77
|
required: false,
|
|
60
78
|
default: false,
|
|
61
|
-
constraint: 'On-demand kick of an active-fault node (parked on a provider
|
|
79
|
+
constraint: 'On-demand kick of an active-fault node (parked on a `pi→provider` fault, broker still alive). Its broker pid is alive, so an ordinary revive no-ops (double-launch guard) — --now SIGTERMs the live broker instead, so the daemon\'s crash→grace→resume path brings it back in ~20s. Valid for a live `pi→provider` fault that is either a daemon-owned auto retry (kicks it early), or ANY fault the canvas dashboard flags "needs you" (e.g. exhausted retries, an auth fault you just fixed) — that disposition otherwise has no recovery verb. Mutually exclusive with --all/--fresh.',
|
|
62
80
|
},
|
|
63
81
|
// --force is a DELIBERATELY UNDOCUMENTED confirmation gate for --all (hidden
|
|
64
82
|
// from -h; see FlagParam.hidden). Silas's intent (2026-06-13): a mass revive
|
|
@@ -80,7 +98,7 @@ export const reviveLeaf = defineLeaf({
|
|
|
80
98
|
output: [
|
|
81
99
|
{ name: 'window', type: 'string', required: false, constraint: 'Always null — the revived broker is headless and opens no tmux window. Kept for caller back-compat.' },
|
|
82
100
|
{ name: 'session', type: 'string', required: false, constraint: 'The node\'s last live location session, or null — the headless broker has no tmux session of its own.' },
|
|
83
|
-
{ name: 'resumed', type: 'boolean', required: false, constraint: 'True
|
|
101
|
+
{ name: 'resumed', type: 'boolean', required: false, constraint: 'True ONLY for a true resume (the existing `.jsonl` replayed as-is). False for a --fresh revive, whether that landed as an in-place CYCLE (an existing `.jsonl` reused with a fresh `crtr-cycle` branch — pi is still told --session, but this is not a resume) or a genuinely fresh launch (no `.jsonl` existed). Single-node revive only.' },
|
|
84
102
|
{ name: 'ready', type: 'boolean', required: false, constraint: 'True when the revived broker\'s view.sock accepted a connection before return — the node is immediately attachable/drivable. Single-node revive only.' },
|
|
85
103
|
{ name: 'mode', type: 'string', required: false, constraint: '"preview" (the --all candidate list, nothing launched), "revived" (the --all sweep ran), or absent for a single-node revive.' },
|
|
86
104
|
{ name: 'candidates', type: 'string', required: false, constraint: '--all preview: the node ids that WOULD be revived (newline-joined). Empty when none are disconnected.' },
|
|
@@ -145,8 +163,8 @@ export const reviveLeaf = defineLeaf({
|
|
|
145
163
|
// reviveNode would no-op the double-launch guard) — SIGTERM it so the daemon's
|
|
146
164
|
// ordinary crash→grace→resume path recovers it on the saved session, the same
|
|
147
165
|
// thing the daemon does at the auto-recovery grace, just on demand. Gated on
|
|
148
|
-
// a live pid and
|
|
149
|
-
// healthy node.
|
|
166
|
+
// a live pid and an eligible pi→provider fault (`isNowEligibleFault` above) so
|
|
167
|
+
// it can't be used to nuke a healthy node.
|
|
150
168
|
if (now) {
|
|
151
169
|
const pid = meta.pi_pid;
|
|
152
170
|
const stall = readFault(nodeId);
|
|
@@ -157,11 +175,11 @@ export const reviveLeaf = defineLeaf({
|
|
|
157
175
|
next: 'Revive a dormant node with `crtr canvas revive ' + nodeId + '` (no --now).',
|
|
158
176
|
});
|
|
159
177
|
}
|
|
160
|
-
if (stall
|
|
178
|
+
if (!isNowEligibleFault(stall)) {
|
|
161
179
|
throw new InputError({
|
|
162
180
|
error: 'not_hanging',
|
|
163
|
-
message: `${nodeId} is live but
|
|
164
|
-
next: 'Use --now only on a node the canvas shows as hanging (⚠). For a routine relaunch, omit --now.',
|
|
181
|
+
message: `${nodeId} is live but has no pi→provider fault eligible for --now (either healthy, or a client-owned retry / non-provider fault already being handled elsewhere) — --now refuses to SIGTERM a node that isn't stuck on a kickable provider fault.`,
|
|
182
|
+
next: 'Use --now only on a node the canvas shows as hanging (⚠) with a `pi→provider` fault. For a routine relaunch, omit --now.',
|
|
165
183
|
});
|
|
166
184
|
}
|
|
167
185
|
// The daemon never kills a BUSY engine (its verdict paths all return 'leave'
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { resolveBundledPiCliPath } from '../../../core/runtime/pi-cli.js';
|
|
5
|
+
test('resolveBundledPiCliPath uses crouter bundled pi instead of a standalone pi on PATH', () => {
|
|
6
|
+
const prevPath = process.env.PATH;
|
|
7
|
+
process.env.PATH = '';
|
|
8
|
+
try {
|
|
9
|
+
const cliPath = resolveBundledPiCliPath();
|
|
10
|
+
assert.equal(existsSync(cliPath), true);
|
|
11
|
+
assert.match(cliPath, /@earendil-works[\/]pi-coding-agent[\/]dist[\/]cli\.js$/);
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
if (prevPath === undefined)
|
|
15
|
+
delete process.env.PATH;
|
|
16
|
+
else
|
|
17
|
+
process.env.PATH = prevPath;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
@@ -71,7 +71,7 @@ function faultRemediation(fault) {
|
|
|
71
71
|
auth: 're-authenticate',
|
|
72
72
|
protocol: 'fix the protocol mismatch',
|
|
73
73
|
other: 'fix the underlying issue',
|
|
74
|
-
wedged: 'read `crtr memory read wedged-child-on-runaway-bash`
|
|
74
|
+
wedged: 'read `crtr memory read wedged-child-on-runaway-bash` — if a runaway subprocess is to blame, kill it (not the node); if the broker had no subprocess, the daemon already SIGTERM-kicked it to resume on its own (recheck node status; nudge it if it came back idle)',
|
|
75
75
|
};
|
|
76
76
|
const progress = fault.retry.disposition === 'auto'
|
|
77
77
|
? fault.retry.by === 'client'
|
|
@@ -4,6 +4,7 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { builtinPiPackageDir } from '../../core/scope.js';
|
|
6
6
|
import { readJsonIfExists } from '../../core/fs-utils.js';
|
|
7
|
+
import { resolveBundledPiCliPath } from '../../core/runtime/pi-cli.js';
|
|
7
8
|
const PACKAGE_COPY = {
|
|
8
9
|
'crtr-extensions': {
|
|
9
10
|
name: 'pi-crtr-extensions',
|
|
@@ -285,8 +286,8 @@ function runShellCommand(command) {
|
|
|
285
286
|
throw new Error(`command failed: ${command}`);
|
|
286
287
|
}
|
|
287
288
|
}
|
|
288
|
-
function installPiPackage(source) {
|
|
289
|
-
const result = spawnSync(
|
|
289
|
+
function installPiPackage(source, piCliPath = resolveBundledPiCliPath()) {
|
|
290
|
+
const result = spawnSync(process.execPath, [piCliPath, 'install', source], { stdio: 'inherit' });
|
|
290
291
|
if (result.status !== 0) {
|
|
291
292
|
throw new Error(`pi install failed: ${source}`);
|
|
292
293
|
}
|
|
@@ -506,7 +506,7 @@ export const sysSetupLeaf = defineLeaf({
|
|
|
506
506
|
outputKind: 'object',
|
|
507
507
|
effects: [
|
|
508
508
|
'Opens a pi-tui wizard that collects companion package installs, system dependency installs, and an Exa API key.',
|
|
509
|
-
'Runs pi
|
|
509
|
+
'Runs the bundled pi CLI to install the selected companion packages, writes EXA_API_KEY to ~/.crouter/exa.key when provided, and prints a summary.',
|
|
510
510
|
],
|
|
511
511
|
},
|
|
512
512
|
run: async () => {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { defineBranch, defineLeaf } from '../core/command.js';
|
|
2
|
+
import { InputError } from '../core/io.js';
|
|
3
|
+
import { closeManagedWorktree, WorktreeError } from '../core/worktree.js';
|
|
4
|
+
function requireCallerNode() {
|
|
5
|
+
const id = process.env['CRTR_NODE_ID'];
|
|
6
|
+
if (id === undefined || id.trim() === '') {
|
|
7
|
+
throw new InputError({
|
|
8
|
+
error: 'no_node_context',
|
|
9
|
+
message: 'CRTR_NODE_ID is not set — worktree commands run inside a canvas node.',
|
|
10
|
+
next: 'Run this command from a node process spawned by the runtime.',
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
return id.trim();
|
|
14
|
+
}
|
|
15
|
+
function mapWorktreeError(err) {
|
|
16
|
+
if (!(err instanceof WorktreeError))
|
|
17
|
+
return null;
|
|
18
|
+
return new InputError({
|
|
19
|
+
error: err.code,
|
|
20
|
+
message: err.message,
|
|
21
|
+
...(err.detail !== undefined ? { received: err.detail } : {}),
|
|
22
|
+
next: err.next,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
const worktreeClose = defineLeaf({
|
|
26
|
+
name: 'close',
|
|
27
|
+
description: 'land and close the current node-managed worktree',
|
|
28
|
+
whenToUse: 'you are inside the node that owns an open managed worktree and are ready to rebase it onto origin/main, push it to main, and (best-effort) remove the worktree and delete the branch before finishing',
|
|
29
|
+
help: {
|
|
30
|
+
name: 'worktree close',
|
|
31
|
+
summary: 'land and close the current node-managed worktree',
|
|
32
|
+
params: [],
|
|
33
|
+
output: [
|
|
34
|
+
{ name: 'node_id', type: 'string', required: true, constraint: 'The caller node id whose managed worktree was closed.' },
|
|
35
|
+
{ name: 'branch', type: 'string', required: true, constraint: 'The worktree branch that was fast-forwarded into main.' },
|
|
36
|
+
{ name: 'pushed_sha', type: 'string', required: true, constraint: 'The landed commit SHA that was pushed to origin/main.' },
|
|
37
|
+
{ name: 'worktree_path', type: 'string', required: true, constraint: 'The managed worktree path that was closed (best-effort removed; see worktree_removed).' },
|
|
38
|
+
{ name: 'worktree_removed', type: 'boolean', required: true, constraint: 'True when the worktree directory was removed; false means the push still landed (the close is done) but automatic removal failed and needs a manual follow-up.' },
|
|
39
|
+
{ name: 'worktree_remove_error', type: 'string', required: false, constraint: 'Present only when worktree_removed is false: the manual cleanup command to run.' },
|
|
40
|
+
{ name: 'branch_deleted', type: 'boolean', required: true, constraint: 'True when the local branch was deleted; false means the push still landed (the close is done) but the local branch cleanup failed and needs a manual follow-up.' },
|
|
41
|
+
{ name: 'branch_delete_error', type: 'string', required: false, constraint: 'Present only when branch_deleted is false: the manual cleanup command to run.' },
|
|
42
|
+
],
|
|
43
|
+
outputKind: 'object',
|
|
44
|
+
effects: [
|
|
45
|
+
'Fetches origin/main, rebases the worktree onto it, fast-forwards origin/main, marks the node-managed worktree closed, then attempts (best-effort) to remove the worktree and delete the local branch.',
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
run: async () => {
|
|
49
|
+
const nodeId = requireCallerNode();
|
|
50
|
+
try {
|
|
51
|
+
return { ...closeManagedWorktree(nodeId) };
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
const mapped = mapWorktreeError(err);
|
|
55
|
+
if (mapped !== null)
|
|
56
|
+
throw mapped;
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
render: (r) => {
|
|
61
|
+
const branch = String(r['branch'] ?? '');
|
|
62
|
+
const sha = String(r['pushed_sha'] ?? '');
|
|
63
|
+
const path = String(r['worktree_path'] ?? '');
|
|
64
|
+
let base = `Managed worktree landed — branch ${branch} pushed as ${sha}.`;
|
|
65
|
+
const notes = [];
|
|
66
|
+
if (r['worktree_removed'] === false) {
|
|
67
|
+
notes.push(String(r['worktree_remove_error'] ?? `worktree checkout at ${path} could not be removed automatically.`));
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
base += ` Worktree removed at ${path}.`;
|
|
71
|
+
}
|
|
72
|
+
if (r['branch_deleted'] === false) {
|
|
73
|
+
notes.push(String(r['branch_delete_error'] ?? `local branch ${branch} could not be deleted automatically.`));
|
|
74
|
+
}
|
|
75
|
+
if (notes.length > 0)
|
|
76
|
+
return `${base} Note: ${notes.join(' ')}`;
|
|
77
|
+
return base;
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
export function registerWorktree() {
|
|
81
|
+
return defineBranch({
|
|
82
|
+
name: 'worktree',
|
|
83
|
+
rootEntry: {
|
|
84
|
+
concept: 'crouter-managed git worktrees tied to node lifecycle',
|
|
85
|
+
desc: 'close a node-owned managed worktree',
|
|
86
|
+
useWhen: 'landing completed work from inside the node that owns the worktree',
|
|
87
|
+
},
|
|
88
|
+
help: {
|
|
89
|
+
name: 'worktree',
|
|
90
|
+
summary: 'manage a node-owned git worktree',
|
|
91
|
+
},
|
|
92
|
+
children: [worktreeClose],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Run with: node --import tsx/esm --test src/core/__tests__/boot.test.ts
|
|
2
2
|
//
|
|
3
|
-
// BUG LOCKED (
|
|
3
|
+
// BUG LOCKED (a sandbox/VM destroy+recreate, or a host
|
|
4
4
|
// reboot, leaves stale `pi_pid` values in the durable canvas.db; a pid-reuse
|
|
5
5
|
// collision on the fresh boot can fool `isPidAlive` into reading a dead
|
|
6
6
|
// broker as alive, wedging the node forever). reconcileBootLiveness is the
|
|
@@ -61,11 +61,11 @@ test('bootChanged: no persisted history is never a change (never clears a genuin
|
|
|
61
61
|
test('bootChanged: same kernel boot_id is the SAME boot (daemon restart, no reboot)', () => {
|
|
62
62
|
assert.equal(bootChanged(ident('boot-A', 1_000_000 + 5_000), ident('boot-A', 1_000_000)), false, 'identical boot_id — same boot regardless of epoch');
|
|
63
63
|
});
|
|
64
|
-
test('bootChanged: a DIFFERENT boot_id under 60s wall-delta is still detected
|
|
64
|
+
test('bootChanged: a DIFFERENT boot_id under 60s wall-delta is still detected', () => {
|
|
65
65
|
// A genuinely fast reboot/recreate: boot B starts only 42s after boot A
|
|
66
|
-
// (
|
|
66
|
+
// (a measured real-world recreate window), well inside the 60s wall tolerance.
|
|
67
67
|
// The exact kernel boot_id catches it with NO reliance on the wall-clock
|
|
68
|
-
// window —
|
|
68
|
+
// window — boot_id is the primary signal, not wall time.
|
|
69
69
|
assert.equal(bootChanged(ident('boot-B', 1_000_000 + 42_000), ident('boot-A', 1_000_000)), true, 'distinct boot_id 42s apart — a real quick recreate, detected');
|
|
70
70
|
});
|
|
71
71
|
test('bootChanged: same boot_id with a huge epoch jump is NOT a boot change (NTP step within a boot)', () => {
|
|
@@ -60,6 +60,20 @@ test('updateNode merges meta and re-indexes the row', () => {
|
|
|
60
60
|
// unspecified fields preserved
|
|
61
61
|
assert.equal(getNode('a')?.kind, 'general');
|
|
62
62
|
});
|
|
63
|
+
// Regression: cycle_pending is an IDENTITY field (meta.json-only, no db
|
|
64
|
+
// column — like pi_session_file) written by reviveNode/canvas-stophook and
|
|
65
|
+
// read back by the daemon's retry sites. writeMeta only persists keys listed
|
|
66
|
+
// in IDENTITY_KEYS; if cycle_pending is missing from that whitelist, updateNode
|
|
67
|
+
// silently drops it and getNode reads back undefined, so retryResumeMode
|
|
68
|
+
// always falls back to strict resume (the exact bug this guards against).
|
|
69
|
+
test('cycle_pending survives the updateNode/getNode meta.json round-trip', () => {
|
|
70
|
+
createNode(node('a'));
|
|
71
|
+
assert.equal(getNode('a')?.cycle_pending, undefined); // never set
|
|
72
|
+
updateNode('a', { cycle_pending: true });
|
|
73
|
+
assert.equal(getNode('a')?.cycle_pending, true);
|
|
74
|
+
updateNode('a', { cycle_pending: false });
|
|
75
|
+
assert.equal(getNode('a')?.cycle_pending, false);
|
|
76
|
+
});
|
|
63
77
|
test('setStatus updates both meta and row', () => {
|
|
64
78
|
createNode(node('a'));
|
|
65
79
|
setStatus('a', 'done');
|
|
@@ -139,13 +153,11 @@ test('recordSpawn writes the audit-only spawned_by edge', () => {
|
|
|
139
153
|
assert.equal(subscriptionsOf('child').length, 0);
|
|
140
154
|
assert.equal(subscribersOf('parent').length, 0);
|
|
141
155
|
});
|
|
142
|
-
// ---
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
// daemon-only, no web on 7878. Fix: writeMeta fsyncs temp+parent dir (durable), and
|
|
148
|
-
// readMeta/rebuildIndex tolerate an empty/corrupt meta as MISSING instead of throwing.
|
|
156
|
+
// --- Durability contract: meta.json is durable and never truncated -------
|
|
157
|
+
// writeMeta uses atomic rename with fsync on both the temp file and parent
|
|
158
|
+
// directory to ensure atomicity and durability. readMeta/rebuildIndex tolerate
|
|
159
|
+
// an empty/corrupt meta.json as MISSING (returning null) rather than throwing,
|
|
160
|
+
// gracefully handling rare disk/NFS anomalies without breaking recovery.
|
|
149
161
|
test('writeMeta produces a durable, non-empty, atomically-named meta.json', () => {
|
|
150
162
|
createNode(node('a'));
|
|
151
163
|
const p = nodeMetaPath('a');
|
|
@@ -42,6 +42,7 @@ after(() => {
|
|
|
42
42
|
delete process.env['CRTR_HOME'];
|
|
43
43
|
});
|
|
44
44
|
const STD = 'Two moves only';
|
|
45
|
+
const DEDUPE = 'duplicate';
|
|
45
46
|
const YIELD = 'crtr node yield';
|
|
46
47
|
test('readContextTokens: prefers context_tokens, falls back to tokens_in, else null', () => {
|
|
47
48
|
createNode(node('n1'));
|
|
@@ -59,25 +60,34 @@ test('childFollowUp: orchestrator past 100k → yield nudge with rounded k', ()
|
|
|
59
60
|
const msg = childFollowUp('orch');
|
|
60
61
|
assert.match(msg, /crtr node yield/);
|
|
61
62
|
assert.match(msg, /~135k/); // rounded
|
|
63
|
+
assert.match(msg, new RegExp(DEDUPE));
|
|
62
64
|
assert.doesNotMatch(msg, /Two moves only/);
|
|
63
65
|
});
|
|
64
66
|
test('childFollowUp: orchestrator below 100k → standard road sign', () => {
|
|
65
67
|
createNode(node('orch', { mode: 'orchestrator' }));
|
|
66
68
|
writeTelemetry('orch', { context_tokens: 80_000 });
|
|
67
|
-
|
|
68
|
-
assert.
|
|
69
|
+
const msg = childFollowUp('orch');
|
|
70
|
+
assert.match(msg, new RegExp(STD));
|
|
71
|
+
assert.match(msg, new RegExp(DEDUPE));
|
|
72
|
+
assert.doesNotMatch(msg, new RegExp(YIELD));
|
|
69
73
|
});
|
|
70
74
|
test('childFollowUp: base spawner past 100k → standard (only orchestrators yield)', () => {
|
|
71
75
|
createNode(node('base', { mode: 'base' }));
|
|
72
76
|
writeTelemetry('base', { context_tokens: 150_000 });
|
|
73
|
-
|
|
77
|
+
const msg = childFollowUp('base');
|
|
78
|
+
assert.match(msg, new RegExp(STD));
|
|
79
|
+
assert.match(msg, new RegExp(DEDUPE));
|
|
74
80
|
});
|
|
75
81
|
test('childFollowUp: orchestrator with no telemetry → standard (unknown size)', () => {
|
|
76
82
|
createNode(node('orch', { mode: 'orchestrator' }));
|
|
77
|
-
|
|
83
|
+
const msg = childFollowUp('orch');
|
|
84
|
+
assert.match(msg, new RegExp(STD));
|
|
85
|
+
assert.match(msg, new RegExp(DEDUPE));
|
|
78
86
|
});
|
|
79
87
|
test('childFollowUp: missing / undefined spawner → standard', () => {
|
|
80
88
|
assert.match(childFollowUp(undefined), new RegExp(STD));
|
|
81
89
|
assert.match(childFollowUp(''), new RegExp(STD));
|
|
82
|
-
|
|
90
|
+
const msg = childFollowUp('ghost');
|
|
91
|
+
assert.match(msg, new RegExp(STD)); // no such node
|
|
92
|
+
assert.match(msg, new RegExp(DEDUPE));
|
|
83
93
|
});
|
|
@@ -12,7 +12,7 @@ import { superviseTick, readPidfile } from '../../daemon/crtrd.js';
|
|
|
12
12
|
import { verifyDaemonStartup } from '../../daemon/manage.js';
|
|
13
13
|
import { waitForBrokerViewSocket } from '../runtime/placement.js';
|
|
14
14
|
import { viewSocketPath } from '../canvas/paths.js';
|
|
15
|
-
import { signBrandedHostArtifacts, brandedHostWorks } from '../runtime/branded-host.js';
|
|
15
|
+
import { signBrandedHostArtifacts, brandedHostWorks, codesignIdentitiesForBrandedHost } from '../runtime/branded-host.js';
|
|
16
16
|
import { markBusy, clearBusy } from '../runtime/busy.js';
|
|
17
17
|
let home;
|
|
18
18
|
function node(id, over = {}) {
|
|
@@ -301,6 +301,11 @@ test('signBrandedHostArtifacts signs the copied libnode before the branded binar
|
|
|
301
301
|
signBrandedHostArtifacts('/tmp/crouter', '/tmp/libnode.dylib', (path) => signed.push(path));
|
|
302
302
|
assert.deepEqual(signed, ['/tmp/libnode.dylib', '/tmp/crouter']);
|
|
303
303
|
});
|
|
304
|
+
test('codesignIdentitiesForBrandedHost prefers configured or discovered identities and otherwise ad-hoc signs', () => {
|
|
305
|
+
assert.deepEqual(codesignIdentitiesForBrandedHost({ configured: 'Developer ID Application: Example', discovered: 'Apple Development: Local' }), ['Developer ID Application: Example']);
|
|
306
|
+
assert.deepEqual(codesignIdentitiesForBrandedHost({ discovered: 'Apple Development: Local' }), ['Apple Development: Local']);
|
|
307
|
+
assert.deepEqual(codesignIdentitiesForBrandedHost(), ['-']);
|
|
308
|
+
});
|
|
304
309
|
test('brandedHostWorks health-checks if a binary is executable', () => {
|
|
305
310
|
// Issue #102: the branded host binary can be corrupted or killed by macOS
|
|
306
311
|
// before exec, causing spawn() to fail silently. The health-check detects this.
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// fires on its own (fails toward 'pending', not 'wedged').
|
|
13
13
|
import { test } from 'node:test';
|
|
14
14
|
import assert from 'node:assert/strict';
|
|
15
|
-
import { wedgeVerdict, WEDGE_QUIET_MS, WEDGE_CPU_MAX_PERCENT, sumTreeCpu } from '../../daemon/crtrd.js';
|
|
15
|
+
import { wedgeVerdict, WEDGE_QUIET_MS, WEDGE_CPU_MAX_PERCENT, sumTreeCpu, treeHasDescendants } from '../../daemon/crtrd.js';
|
|
16
16
|
test('wedgeVerdict: a node whose turn already ended is never wedged, regardless of the other signals', () => {
|
|
17
17
|
assert.equal(wedgeVerdict({ busy: false, quietForMs: null, cpuPercent: null }), 'leave');
|
|
18
18
|
assert.equal(wedgeVerdict({ busy: false, quietForMs: 10 * 60 * 60_000, cpuPercent: 0 }), 'leave');
|
|
@@ -85,3 +85,20 @@ test('sumTreeCpu: malformed/blank lines are skipped; empty output returns null',
|
|
|
85
85
|
assert.equal(sumTreeCpu('', 100), null);
|
|
86
86
|
assert.equal(sumTreeCpu(' \n \n', 100), null);
|
|
87
87
|
});
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// treeHasDescendants (pure) — issue #119: the broker-alone case has no
|
|
90
|
+
// subprocess to kill, so remediation must branch on this instead of assuming
|
|
91
|
+
// a runaway child always exists.
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
test('treeHasDescendants: broker alone in its own tree — nothing to kill', () => {
|
|
94
|
+
const ps = [' 100 1 0.0'].join('\n');
|
|
95
|
+
assert.equal(treeHasDescendants(ps, 100), false);
|
|
96
|
+
});
|
|
97
|
+
test('treeHasDescendants: a child present — a subprocess to kill', () => {
|
|
98
|
+
const ps = [' 100 1 0.0', ' 200 100 0.0'].join('\n');
|
|
99
|
+
assert.equal(treeHasDescendants(ps, 100), true);
|
|
100
|
+
});
|
|
101
|
+
test('treeHasDescendants: root pid absent from the ps output returns null (can\'t tell)', () => {
|
|
102
|
+
const ps = [' 200 1 0.0'].join('\n');
|
|
103
|
+
assert.equal(treeHasDescendants(ps, 100), null);
|
|
104
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/fault-classifier.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Issue #94 — a fatal provider AUTH fault (a dead OAuth grant) must classify as
|
|
4
|
+
// kind 'auth' + disposition 'fatal', NOT 'other'. The stophook hands `classify`
|
|
5
|
+
// a raw provider error STRING (not an HTTP object), so the text branch alone
|
|
6
|
+
// has to recognise auth — otherwise `invalid_grant` mislabelled as 'other' and
|
|
7
|
+
// the canvas said "other · needs you" instead of "auth · needs you". Both are
|
|
8
|
+
// fatal, but the taxonomy must name the cause so the notice can say re-login.
|
|
9
|
+
import { test } from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { classify } from '../fault-classifier.js';
|
|
12
|
+
test('invalid_grant text classifies as fatal auth (issue #94)', () => {
|
|
13
|
+
const c = classify('pi→provider', 'Anthropic token refresh failed: invalid_grant');
|
|
14
|
+
assert.equal(c.kind, 'auth');
|
|
15
|
+
assert.equal(c.disposition, 'fatal');
|
|
16
|
+
});
|
|
17
|
+
test('"refresh token not found or invalid" is auth, not a transient connection blip', () => {
|
|
18
|
+
const c = classify('pi→provider', 'Refresh token not found or invalid');
|
|
19
|
+
assert.equal(c.kind, 'auth', 'must not fall into the not-found/connection branch');
|
|
20
|
+
assert.equal(c.disposition, 'fatal');
|
|
21
|
+
});
|
|
22
|
+
test('the auth branch does not steal transient rate-limit / overloaded errors', () => {
|
|
23
|
+
assert.equal(classify('pi→provider', 'rate limit exceeded (429)').kind, 'rate-limit');
|
|
24
|
+
assert.equal(classify('pi→provider', 'Overloaded: server busy (529)').kind, 'overloaded');
|
|
25
|
+
assert.equal(classify('pi→provider', 'ECONNRESET while streaming').kind, 'connection');
|
|
26
|
+
});
|
|
27
|
+
test('HTTP 401/403 objects still classify as auth (unchanged path)', () => {
|
|
28
|
+
assert.equal(classify('pi→provider', { status: 401, statusText: 'Unauthorized' }).kind, 'auth');
|
|
29
|
+
assert.equal(classify('pi→provider', { status: 403 }).kind, 'auth');
|
|
30
|
+
});
|
|
@@ -17,6 +17,19 @@ export declare class SessionManager {
|
|
|
17
17
|
static open(path: string): SessionManager;
|
|
18
18
|
getSessionId(): string;
|
|
19
19
|
getSessionFile(): string;
|
|
20
|
+
private leafId;
|
|
21
|
+
/** Cycling support (crtr-cycle marker) — mirrors the real SDK's getLeafId/
|
|
22
|
+
* resetLeaf/appendCustomEntry just enough that `buildBrokerSession`'s cycling
|
|
23
|
+
* branch (broker.ts) doesn't throw. The fake has no real cross-boot tree at
|
|
24
|
+
* this point (FakeSession builds a fresh SessionTree every boot regardless),
|
|
25
|
+
* so these are recording stubs, not a faithful branch model —
|
|
26
|
+
* cycleAwareMessages already degrades to single-cycle passthrough for a
|
|
27
|
+
* session manager without getEntries/getBranch (never implemented on the
|
|
28
|
+
* fake), so multi-cycle REPLAY is out of scope for the fixture; only "does
|
|
29
|
+
* not crash" is in scope. */
|
|
30
|
+
getLeafId(): string | null;
|
|
31
|
+
resetLeaf(): void;
|
|
32
|
+
appendCustomEntry(_customType: string, _data?: unknown): string;
|
|
20
33
|
}
|
|
21
34
|
type Handler = (ev: unknown, ctx: unknown) => void | Promise<void>;
|
|
22
35
|
interface BindExtensionsOpts {
|
|
@@ -100,6 +100,26 @@ export class SessionManager {
|
|
|
100
100
|
getSessionFile() {
|
|
101
101
|
return this.sessionFile;
|
|
102
102
|
}
|
|
103
|
+
leafId = null;
|
|
104
|
+
/** Cycling support (crtr-cycle marker) — mirrors the real SDK's getLeafId/
|
|
105
|
+
* resetLeaf/appendCustomEntry just enough that `buildBrokerSession`'s cycling
|
|
106
|
+
* branch (broker.ts) doesn't throw. The fake has no real cross-boot tree at
|
|
107
|
+
* this point (FakeSession builds a fresh SessionTree every boot regardless),
|
|
108
|
+
* so these are recording stubs, not a faithful branch model —
|
|
109
|
+
* cycleAwareMessages already degrades to single-cycle passthrough for a
|
|
110
|
+
* session manager without getEntries/getBranch (never implemented on the
|
|
111
|
+
* fake), so multi-cycle REPLAY is out of scope for the fixture; only "does
|
|
112
|
+
* not crash" is in scope. */
|
|
113
|
+
getLeafId() {
|
|
114
|
+
return this.leafId;
|
|
115
|
+
}
|
|
116
|
+
resetLeaf() {
|
|
117
|
+
this.leafId = null;
|
|
118
|
+
}
|
|
119
|
+
appendCustomEntry(_customType, _data) {
|
|
120
|
+
this.leafId = `fake-cycle-${randomUUID().slice(0, 8)}`;
|
|
121
|
+
return this.leafId;
|
|
122
|
+
}
|
|
103
123
|
}
|
|
104
124
|
// ---------------------------------------------------------------------------
|
|
105
125
|
// AgentSessionEvent stream synthesis (T8 / G1, G3, G4, G8). The fake emits a
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// canvas transitions.
|
|
12
12
|
//
|
|
13
13
|
// Milestones (de-risk order):
|
|
14
|
-
// 1. SEAM — piCommand
|
|
14
|
+
// 1. SEAM — piCommand execs the bundled pi CLI by default, substitutes
|
|
15
|
+
// CRTR_PI_BINARY only when set (pure unit).
|
|
15
16
|
// 2. ROUND-TRIP— real `node new --headless` → real broker boots with CRTR_NODE_ID
|
|
16
17
|
// + the -e canvas extensions intact (GO/NO-GO).
|
|
17
18
|
// 3. REAL HOOKS— the broker loads the real stophook: session_start captures
|
|
@@ -41,7 +42,8 @@
|
|
|
41
42
|
import { test, before, after } from 'node:test';
|
|
42
43
|
import assert from 'node:assert/strict';
|
|
43
44
|
import { createHarness } from '../helpers/harness.js';
|
|
44
|
-
import { piCommand } from '../../runtime/placement.js';
|
|
45
|
+
import { piCommand, shellQuote } from '../../runtime/placement.js';
|
|
46
|
+
import { bundledPiCommandForShell } from '../../runtime/pi-cli.js';
|
|
45
47
|
import { CANVAS_EXTENSIONS } from '../../runtime/launch.js';
|
|
46
48
|
let h;
|
|
47
49
|
let root;
|
|
@@ -56,13 +58,14 @@ after(async () => {
|
|
|
56
58
|
// ===========================================================================
|
|
57
59
|
// MILESTONE 1 — the CRTR_PI_BINARY seam (pure unit; no boot, no tmux).
|
|
58
60
|
// ===========================================================================
|
|
59
|
-
test('M1 seam: piCommand
|
|
61
|
+
test('M1 seam: piCommand execs the bundled pi CLI when CRTR_PI_BINARY is unset, substitutes when set', () => {
|
|
60
62
|
const saved = process.env['CRTR_PI_BINARY'];
|
|
61
63
|
try {
|
|
62
64
|
delete process.env['CRTR_PI_BINARY'];
|
|
65
|
+
const bundled = bundledPiCommandForShell(shellQuote);
|
|
63
66
|
const unset = piCommand(['-e', '/abs/ext.ts', '-n', 'label']);
|
|
64
|
-
assert.equal(unset,
|
|
65
|
-
assert.ok(unset.startsWith(
|
|
67
|
+
assert.equal(unset, `${bundled} '-e' '/abs/ext.ts' '-n' 'label'`, 'unset → identical to exec\'ing the bundled pi CLI');
|
|
68
|
+
assert.ok(unset.startsWith(bundled), 'unset → leads with the resolved bundled-pi command');
|
|
66
69
|
process.env['CRTR_PI_BINARY'] = '/tmp/fake-pi';
|
|
67
70
|
const set = piCommand(['-e', '/abs/ext.ts']);
|
|
68
71
|
assert.ok(set.startsWith('/tmp/fake-pi '), 'set → leads with the substituted binary');
|
|
@@ -34,7 +34,22 @@ import { test } from 'node:test';
|
|
|
34
34
|
import assert from 'node:assert/strict';
|
|
35
35
|
import { spawnSync } from 'node:child_process';
|
|
36
36
|
import { createHarness } from './helpers/harness.js';
|
|
37
|
-
import { isPidAlive } from '../../daemon/crtrd.js';
|
|
37
|
+
import { isPidAlive, retryResumeMode } from '../../daemon/crtrd.js';
|
|
38
|
+
// ===========================================================================
|
|
39
|
+
// retryResumeMode (pure) — a crash-retry of a dead/stranded engine retries AS
|
|
40
|
+
// A CYCLE (resume:false) when the dead attempt's own launch was itself a
|
|
41
|
+
// cycling revive that never reached session_start (cycle_pending), and
|
|
42
|
+
// resumes STRICTLY (resume:true) otherwise. See canvas.test.ts's
|
|
43
|
+
// updateNode/getNode round-trip for the companion guarantee that cycle_pending
|
|
44
|
+
// actually survives the meta.json persistence the daemon reads it back from.
|
|
45
|
+
// ===========================================================================
|
|
46
|
+
test('retryResumeMode: cycle_pending=true retries as a cycle, everything else resumes strictly', () => {
|
|
47
|
+
assert.equal(retryResumeMode({ cycle_pending: true }), false, 'a pending cycling attempt retries AS a cycle');
|
|
48
|
+
assert.equal(retryResumeMode({ cycle_pending: false }), true, 'cycle_pending=false resumes strictly');
|
|
49
|
+
assert.equal(retryResumeMode({ cycle_pending: null }), true, 'cycle_pending=null resumes strictly');
|
|
50
|
+
assert.equal(retryResumeMode({}), true, 'cycle_pending undefined resumes strictly');
|
|
51
|
+
assert.equal(retryResumeMode(null), true, 'no meta at all resumes strictly');
|
|
52
|
+
});
|
|
38
53
|
// A pid that is reaped (dead) by the time spawnSync returns — the "dead but
|
|
39
54
|
// supervised" pid the grace path judges.
|
|
40
55
|
function deadPid() {
|
|
@@ -103,7 +118,8 @@ test('grace clock: a dead-pi broker does NOT revive within REVIVE_GRACE_MS, but
|
|
|
103
118
|
assert.equal(b.cycles ?? 0, 1, 'past REVIVE_GRACE_MS: the dead pi is revived → cycles bumped to 1');
|
|
104
119
|
assert.equal(b.status, 'active', 'B active after the grace-window revive (transition(revive))');
|
|
105
120
|
assert.equal(b.intent ?? null, null, 'intent cleared by the revive');
|
|
106
|
-
assert.
|
|
121
|
+
assert.notEqual(b.pi_pid, dead, 'pi_pid no longer the stale dead pid — the revive records the fresh broker pid immediately');
|
|
122
|
+
assert.equal(b.pi_pid == null ? false : b.pi_pid > 0, true, 'revive leaves no pid=null boot window for the daemon to false-crash (cc2c754)');
|
|
107
123
|
}
|
|
108
124
|
}
|
|
109
125
|
finally {
|