@crouton-kit/crouter 0.3.40 → 0.3.41
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 +706 -733
- package/dist/clients/attach/chat-view.js +11 -0
- 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/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/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
|
@@ -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 {
|