@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
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { existsSync, realpathSync } from 'node:fs';
|
|
6
6
|
import { basename, resolve as resolvePath, sep } from 'node:path';
|
|
7
7
|
import { createInterface } from 'node:readline/promises';
|
|
8
|
-
import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, } from './manifest.js';
|
|
8
|
+
import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, addProfileProject, } from './manifest.js';
|
|
9
9
|
/** Resolve cwd to an absolute, realpath'd form so it compares against the
|
|
10
10
|
* realpath'd project dirs `createProfile`/`addProfileProject` already store
|
|
11
11
|
* (manifest.ts resolves every project dir through `realpathSync`). Falls back
|
|
@@ -66,6 +66,25 @@ function recoveryMessage(cwd) {
|
|
|
66
66
|
* artifact or a queued interaction, not a two-choice gate on the same TTY pi
|
|
67
67
|
* is about to take over — a raw `readline` question is the actual minimal
|
|
68
68
|
* fit here. */
|
|
69
|
+
/** Explicit `--profile` names a profile whose manifest does NOT cover cwd.
|
|
70
|
+
* Interactive only: offer to widen the profile's purview to include this
|
|
71
|
+
* directory (default yes), so the next node started here is covered without a
|
|
72
|
+
* separate `crtr profile add-project`. Declining leaves the manifest untouched
|
|
73
|
+
* and runs the node under the profile anyway. Same raw-readline rationale as
|
|
74
|
+
* `promptCreateOrRoot` — this gates pi's boot on the same TTY. */
|
|
75
|
+
async function promptAddDirToProfile(entry, cwd) {
|
|
76
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
77
|
+
try {
|
|
78
|
+
process.stdout.write(`\nProfile "${entry.manifest.name}" does not cover this directory:\n ${cwd}\n\n`);
|
|
79
|
+
const choice = (await rl.question('Add it to the profile? [Y/n]: ')).trim().toLowerCase();
|
|
80
|
+
if (choice === 'n' || choice === 'no')
|
|
81
|
+
return;
|
|
82
|
+
addProfileProject(entry.profileId, cwd);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
rl.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
69
88
|
async function promptCreateOrRoot(cwd) {
|
|
70
89
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
71
90
|
try {
|
|
@@ -86,8 +105,10 @@ async function promptCreateOrRoot(cwd) {
|
|
|
86
105
|
}
|
|
87
106
|
/** Select the profile a node about to boot at `cwd` should run under.
|
|
88
107
|
*
|
|
89
|
-
* 1. `explicitProfile` present → resolve id/name via `loadProfileManifest
|
|
90
|
-
*
|
|
108
|
+
* 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`. If
|
|
109
|
+
* its manifest does not already cover `cwd` and the session is interactive,
|
|
110
|
+
* offer to add `cwd` to its purview (default yes). Bump `last_used_at`,
|
|
111
|
+
* return the id.
|
|
91
112
|
* 2. Else, among profiles whose project list COVERS `cwd`, pick the
|
|
92
113
|
* most-recently-used (nulls oldest), bump its `last_used_at`, return it.
|
|
93
114
|
* 3. Else — nothing covers cwd. Interactive session: prompt synchronously to
|
|
@@ -95,12 +116,17 @@ async function promptCreateOrRoot(cwd) {
|
|
|
95
116
|
* interactive (no TTY): default to root (null) and print the recovery
|
|
96
117
|
* instruction to STDERR — never stdout, which the caller may be piping. */
|
|
97
118
|
export async function selectProfileForCwd(cwd, explicitProfile) {
|
|
119
|
+
const resolvedCwd = resolveCwd(cwd);
|
|
98
120
|
if (explicitProfile !== undefined && explicitProfile !== null && explicitProfile !== '') {
|
|
99
121
|
const entry = loadProfileManifest(explicitProfile);
|
|
122
|
+
// Selecting a profile from a directory it does not yet cover: offer to
|
|
123
|
+
// widen its purview (interactive only, default yes) before pi boots.
|
|
124
|
+
if (!profileCoversCwd(entry, resolvedCwd) && isInteractive()) {
|
|
125
|
+
await promptAddDirToProfile(entry, resolvedCwd);
|
|
126
|
+
}
|
|
100
127
|
updateProfileLastUsed(entry.profileId);
|
|
101
128
|
return entry.profileId;
|
|
102
129
|
}
|
|
103
|
-
const resolvedCwd = resolveCwd(cwd);
|
|
104
130
|
const covering = listProfiles().filter((p) => profileCoversCwd(p, resolvedCwd));
|
|
105
131
|
if (covering.length > 0) {
|
|
106
132
|
const winner = pickMostRecent(covering);
|
|
@@ -446,6 +446,10 @@ export function buildContextBearings(nodeId) {
|
|
|
446
446
|
const profileSection = profileNote(nodeId);
|
|
447
447
|
if (profileSection !== '')
|
|
448
448
|
bearings.push('', profileSection);
|
|
449
|
+
const wt = node?.managed_worktree;
|
|
450
|
+
if (wt?.state === 'open') {
|
|
451
|
+
bearings.push('', '## Your managed worktree', `Branch: \`${wt.branch}\`\nWorktree: \`${wt.path}\`\nBase: \`${wt.base_ref}\` @ \`${wt.base_sha}\`\nCommit work here. Land it with \`crtr worktree close\` before finishing; \`crtr push final\` is blocked until this worktree is closed.`);
|
|
452
|
+
}
|
|
449
453
|
// Orchestrator-only: the across-cycles framing (a terminal has no future cycle).
|
|
450
454
|
if (node?.mode === 'orchestrator')
|
|
451
455
|
bearings.push('', orchestratorContextNote(nodeId));
|
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
* to the installed node, shared across every canvas home. */
|
|
4
4
|
export declare function brandedHostDir(): string;
|
|
5
5
|
export declare function brandedHostBin(): string;
|
|
6
|
+
/** macOS keys FDA grants on the Mach-O cdhash. A real developer identity
|
|
7
|
+
* keeps that hash stable across branded-host rebuilds; ad-hoc signing is the
|
|
8
|
+
* portable default for machines without a local signing certificate. */
|
|
9
|
+
export declare function codesignIdentitiesForBrandedHost(opts?: {
|
|
10
|
+
configured?: string | null;
|
|
11
|
+
discovered?: string | null;
|
|
12
|
+
}): string[];
|
|
6
13
|
export declare function signBrandedHostArtifacts(binPath: string, libnodePath: string | null, sign?: (path: string) => void): void;
|
|
7
14
|
export declare function brandedHostWorks(binPath: string): boolean;
|
|
8
15
|
/** Ensure the branded host binary exists and matches the live node; return its
|
|
@@ -72,30 +72,57 @@ function readMarker() {
|
|
|
72
72
|
return null;
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
-
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
|
|
75
|
+
/** macOS keys FDA grants on the Mach-O cdhash. A real developer identity
|
|
76
|
+
* keeps that hash stable across branded-host rebuilds; ad-hoc signing is the
|
|
77
|
+
* portable default for machines without a local signing certificate. */
|
|
78
|
+
export function codesignIdentitiesForBrandedHost(opts = {}) {
|
|
79
|
+
const configured = opts.configured?.trim();
|
|
80
|
+
if (configured)
|
|
81
|
+
return [configured];
|
|
82
|
+
const discovered = opts.discovered?.trim();
|
|
83
|
+
return [discovered || '-'];
|
|
84
|
+
}
|
|
85
|
+
function discoverCodesignIdentity() {
|
|
86
|
+
if (process.platform !== 'darwin')
|
|
87
|
+
return null;
|
|
88
|
+
const res = spawnSync('security', ['find-identity', '-v', '-p', 'codesigning'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
89
|
+
if (res.status !== 0)
|
|
90
|
+
return null;
|
|
91
|
+
const stdout = (res.stdout ?? '').toString();
|
|
92
|
+
const identities = stdout
|
|
93
|
+
.split('\n')
|
|
94
|
+
.map((line) => line.match(/\)\s+[A-Fa-f0-9]{40}\s+"([^"]+)"/)?.[1] ?? null)
|
|
95
|
+
.filter((identity) => identity !== null);
|
|
96
|
+
return identities.find((identity) => identity.startsWith('Apple Development:'))
|
|
97
|
+
?? identities.find((identity) => identity.startsWith('Developer ID Application:'))
|
|
98
|
+
?? identities[0]
|
|
99
|
+
?? null;
|
|
100
|
+
}
|
|
101
|
+
function runCodesign(binPath, identity) {
|
|
102
|
+
const res = spawnSync('codesign', ['-s', identity, '--deep', '--force', binPath], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
103
|
+
return { ok: res.status === 0, stderr: (res.stderr ?? '').toString().trim() };
|
|
104
|
+
}
|
|
79
105
|
function signBrandedHost(binPath) {
|
|
80
106
|
if (process.platform !== 'darwin')
|
|
81
107
|
return;
|
|
82
108
|
try {
|
|
83
|
-
const
|
|
84
|
-
'
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
109
|
+
const identities = codesignIdentitiesForBrandedHost({
|
|
110
|
+
configured: process.env['CRTR_CODESIGN_IDENTITY'],
|
|
111
|
+
discovered: discoverCodesignIdentity(),
|
|
112
|
+
});
|
|
113
|
+
let lastError = '';
|
|
114
|
+
for (const identity of identities) {
|
|
115
|
+
const res = runCodesign(binPath, identity);
|
|
116
|
+
if (res.ok)
|
|
117
|
+
return;
|
|
118
|
+
lastError = res.stderr;
|
|
119
|
+
}
|
|
120
|
+
if (lastError && !lastError.includes('valid on disk')) {
|
|
121
|
+
console.debug(`[crouter] codesign failed (expected on some systems): ${lastError}`);
|
|
95
122
|
}
|
|
96
123
|
}
|
|
97
124
|
catch {
|
|
98
|
-
// codesign not found or other spawn error — non-fatal, continue.
|
|
125
|
+
// codesign/security not found or other spawn error — non-fatal, continue.
|
|
99
126
|
}
|
|
100
127
|
}
|
|
101
128
|
export function signBrandedHostArtifacts(binPath, libnodePath, sign = signBrandedHost) {
|
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
// broker-sdk.ts — the single import-and-version-assert shim between the headless
|
|
2
2
|
// broker and the pi SDK (`@earendil-works/pi-coding-agent`).
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// The broker imports the SDK in-process, and the bundled pi CLI is used only for
|
|
5
|
+
// incidental headless helper commands and the version tripwire. This shim is the
|
|
6
|
+
// one place the dependency is named, so:
|
|
7
7
|
// - the version tripwire (`assertEngineVersion`) lives here, and
|
|
8
8
|
// - the `CRTR_BROKER_ENGINE` test seam (T11) has a single dynamic-import
|
|
9
9
|
// indirection point (`loadBrokerEngine`) to swap in the fake engine.
|
|
10
|
-
//
|
|
11
|
-
// Nothing else in the tree imports this yet; it compiles but stays inert so the
|
|
12
|
-
// lifecycle suite stays green until the broker is wired up.
|
|
13
10
|
import { execFileSync } from 'node:child_process';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
11
|
+
import { readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
16
13
|
import { createAgentSessionServices, createAgentSessionFromServices, createAgentSessionRuntime, SessionManager, VERSION, } from '@earendil-works/pi-coding-agent';
|
|
17
14
|
import { crtrHome } from '../canvas/paths.js';
|
|
15
|
+
import { resolveBundledPiCliPath } from './pi-cli.js';
|
|
18
16
|
// Static re-exports — the real SDK surface the broker drives in production.
|
|
19
17
|
export { createAgentSessionServices, createAgentSessionFromServices, createAgentSessionRuntime, SessionManager, VERSION, };
|
|
20
18
|
/**
|
|
@@ -44,44 +42,10 @@ export async function loadBrokerEngine() {
|
|
|
44
42
|
VERSION: typeof mod.VERSION === 'string' ? mod.VERSION : 'unknown',
|
|
45
43
|
};
|
|
46
44
|
}
|
|
47
|
-
/**
|
|
48
|
-
* Boot-time tripwire: compare the imported SDK `VERSION` against the `pi` binary
|
|
49
|
-
* crouter forks elsewhere (`pi --version`). On mismatch (or if the binary can't be
|
|
50
|
-
* probed) it logs a LOUD warning to stderr and proceeds — it NEVER throws. The v3
|
|
51
|
-
* session format auto-migrates on load, so minor skew round-trips; a hard fail here
|
|
52
|
-
* would risk a grace-revive crash loop. Phase 5 may harden this to fail-fast once
|
|
53
|
-
* the broker is the only host.
|
|
54
|
-
*
|
|
55
|
-
* @param engineVersion the version actually loaded (defaults to the statically
|
|
56
|
-
* imported `VERSION`; pass `engine.VERSION` from `loadBrokerEngine` to assert the
|
|
57
|
-
* version the broker is really driving).
|
|
58
|
-
*/
|
|
59
|
-
/** Resolve the `pi` executable on PATH without spawning anything — a plain walk
|
|
60
|
-
* of `$PATH` entries (`statSync` follows symlinks, so an npm/brew bin shim
|
|
61
|
-
* resolves to the real target). Returns the first match, or undefined. */
|
|
62
|
-
function resolvePiBinary() {
|
|
63
|
-
const path = process.env['PATH'];
|
|
64
|
-
if (path === undefined || path === '')
|
|
65
|
-
return undefined;
|
|
66
|
-
for (const dir of path.split(delimiter)) {
|
|
67
|
-
if (dir === '')
|
|
68
|
-
continue;
|
|
69
|
-
const candidate = join(dir, 'pi');
|
|
70
|
-
try {
|
|
71
|
-
if (existsSync(candidate))
|
|
72
|
-
return candidate;
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
/* unreadable PATH entry — skip */
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return undefined;
|
|
79
|
-
}
|
|
80
45
|
const PI_VERSION_CACHE = () => join(crtrHome(), 'pi-version.json');
|
|
81
|
-
/** Cached `pi --version`, keyed on the
|
|
82
|
-
* re-probes but the steady state never spawns. Returns undefined when the
|
|
83
|
-
*
|
|
84
|
-
* once and back-fills via {@link writePiVersionCache}. */
|
|
46
|
+
/** Cached `pi --version`, keyed on the CLI's path+mtime+size so a pi upgrade
|
|
47
|
+
* re-probes but the steady state never spawns. Returns undefined when the cache
|
|
48
|
+
* misses — the caller then spawns once and back-fills via {@link writePiVersionCache}. */
|
|
85
49
|
function readPiVersionCache(binPath, mtimeMs, size) {
|
|
86
50
|
try {
|
|
87
51
|
const c = JSON.parse(readFileSync(PI_VERSION_CACHE(), 'utf8'));
|
|
@@ -103,41 +67,37 @@ function writePiVersionCache(entry) {
|
|
|
103
67
|
}
|
|
104
68
|
}
|
|
105
69
|
export function assertEngineVersion(engineVersion = VERSION) {
|
|
106
|
-
// Resolve + stat the
|
|
107
|
-
// cache, reuse the stored version and skip the ~0.4s `pi --version` boot —
|
|
108
|
-
// common path on every broker start. A pi upgrade changes the stat and re-probes.
|
|
109
|
-
|
|
70
|
+
// Resolve + stat the bundled CLI (no spawn). When its path+mtime+size matches
|
|
71
|
+
// the cache, reuse the stored version and skip the ~0.4s `pi --version` boot —
|
|
72
|
+
// the common path on every broker start. A pi upgrade changes the stat and re-probes.
|
|
73
|
+
let binPath;
|
|
110
74
|
let stat;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
stat = undefined;
|
|
118
|
-
}
|
|
75
|
+
try {
|
|
76
|
+
binPath = resolveBundledPiCliPath();
|
|
77
|
+
const s = statSync(binPath);
|
|
78
|
+
stat = { mtimeMs: s.mtimeMs, size: s.size };
|
|
119
79
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
80
|
+
catch (err) {
|
|
81
|
+
process.stderr.write(`[broker] WARNING: could not resolve bundled pi CLI to verify engine parity ` +
|
|
82
|
+
`(SDK ${engineVersion}): ${err.message}\n`);
|
|
83
|
+
return;
|
|
123
84
|
}
|
|
85
|
+
let binaryVersion = readPiVersionCache(binPath, stat.mtimeMs, stat.size);
|
|
124
86
|
if (binaryVersion === undefined) {
|
|
125
87
|
try {
|
|
126
|
-
binaryVersion = execFileSync(
|
|
88
|
+
binaryVersion = execFileSync(process.execPath, [binPath, '--version'], { encoding: 'utf8' }).trim();
|
|
127
89
|
}
|
|
128
90
|
catch (err) {
|
|
129
|
-
process.stderr.write(`[broker] WARNING: could not run 'pi --version' to verify engine parity ` +
|
|
91
|
+
process.stderr.write(`[broker] WARNING: could not run bundled 'pi --version' to verify engine parity ` +
|
|
130
92
|
`(SDK ${engineVersion}): ${err.message}\n`);
|
|
131
93
|
return;
|
|
132
94
|
}
|
|
133
|
-
|
|
134
|
-
writePiVersionCache({ path: binPath, mtimeMs: stat.mtimeMs, size: stat.size, version: binaryVersion });
|
|
135
|
-
}
|
|
95
|
+
writePiVersionCache({ path: binPath, mtimeMs: stat.mtimeMs, size: stat.size, version: binaryVersion });
|
|
136
96
|
}
|
|
137
97
|
if (binaryVersion !== engineVersion) {
|
|
138
98
|
process.stderr.write(`[broker] WARNING: pi SDK version mismatch — imported SDK is ${engineVersion} but ` +
|
|
139
|
-
`the
|
|
140
|
-
`minor skew round-trips, but
|
|
141
|
-
`
|
|
99
|
+
`the bundled pi CLI is ${binaryVersion}. The v3 session format auto-migrates on load so ` +
|
|
100
|
+
`minor skew round-trips, but keep the crouter dependency on '@earendil-works/pi-coding-agent' ` +
|
|
101
|
+
`in lockstep with the in-process engine.\n`);
|
|
142
102
|
}
|
|
143
103
|
}
|
|
@@ -32,6 +32,7 @@ import { isModelNotFoundError, readFault, recordFault } from './fault.js';
|
|
|
32
32
|
import { CONNECTION_FAULT_RECOVERY_BODY, PROVIDER_FAULT_RECOVERY_BODY } from './fault-recovery-nudge.js';
|
|
33
33
|
import { markFatalOnExhaust, nextFaultRetry, policyFor } from './fault-recovery.js';
|
|
34
34
|
import { probeOnline as probeOnlineReal } from './connectivity.js';
|
|
35
|
+
import { CRTR_CYCLE_CUSTOM_TYPE, cycleAwareMessages } from './session-cycles.js';
|
|
35
36
|
import { BUILTIN_SLASH_COMMANDS } from './pi-vendored.js';
|
|
36
37
|
import { encodeFrame, FrameDecoder, FrameOverflowError, BROKER_READ_CAPS, } from './broker-protocol.js';
|
|
37
38
|
const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh']);
|
|
@@ -1646,9 +1647,19 @@ export async function runBroker(nodeId) {
|
|
|
1646
1647
|
//
|
|
1647
1648
|
// `AgentSession.sessionManager` is a public readonly field of the real pi SDK; the
|
|
1648
1649
|
// fake-engine test fixture mirrors the same `sessionManager.buildSessionContext()`
|
|
1649
|
-
// surface,
|
|
1650
|
+
// surface (plus, where present, the tree accessors `cycleAwareMessages` reads).
|
|
1651
|
+
// A refresh-yielded node keeps every prior cycle in the SAME `.jsonl` as sibling
|
|
1652
|
+
// branches (session-cycles.ts) — `cycleAwareMessages` replays them ahead of the
|
|
1653
|
+
// live cycle's context so the welcome snapshot renders one continuous
|
|
1654
|
+
// conversation instead of just the post-yield window; single-cycle sessions and
|
|
1655
|
+
// the fake-engine fixture (no tree accessors) fall through to plain
|
|
1656
|
+
// `buildSessionContext().messages` unchanged.
|
|
1650
1657
|
export function snapshotMessages(session) {
|
|
1651
|
-
|
|
1658
|
+
// Cast: the real SDK's `SessionEntry.data` is `unknown` (untyped custom-entry
|
|
1659
|
+
// payload), narrower than `CycleSessionManagerLike`'s structural read of the
|
|
1660
|
+
// `crtr-cycle` marker's `{cycle, fromLeaf}` shape — the fields we read off it
|
|
1661
|
+
// are ones ONLY this module's own `appendCustomEntry` call (above) ever writes.
|
|
1662
|
+
return cycleAwareMessages(session.sessionManager);
|
|
1652
1663
|
}
|
|
1653
1664
|
// ---------------------------------------------------------------------------
|
|
1654
1665
|
// buildBrokerSession (plan T4 steps 2–4) — turn the launch recipe into a live
|
|
@@ -1745,7 +1756,19 @@ export async function buildBrokerSession(engine, cfg) {
|
|
|
1745
1756
|
// A fork is a fresh spawn (resuming=false) so the kickoff firstPrompt fires: a
|
|
1746
1757
|
// `node new --fork-from <id>` gives the new node the source's full history AND a
|
|
1747
1758
|
// new task to start on. A resume replays the inbox instead and sends no kickoff.
|
|
1748
|
-
|
|
1759
|
+
//
|
|
1760
|
+
// A refresh-yield CYCLES instead: `cfg.newCycle` (the `--crtr-new-cycle` token)
|
|
1761
|
+
// marks a revive that reuses this SAME `.jsonl` rather than resuming it — the
|
|
1762
|
+
// session manager opens the file, then resets the leaf and roots a `crtr-cycle`
|
|
1763
|
+
// marker entry (data: `{cycle, fromLeaf}`) as the new cycle's ROOT before the
|
|
1764
|
+
// session is built. Custom entries never enter LLM context, so the freshly
|
|
1765
|
+
// rooted branch is exactly as clean as a brand-new session; `cycleAwareMessages`
|
|
1766
|
+
// (session-cycles.ts) is the display-side counterpart that replays every prior
|
|
1767
|
+
// cycle for snapshot rendering. cycling ⇒ resuming=false, so the kickoff
|
|
1768
|
+
// firstPrompt fires below just like a true fresh launch.
|
|
1769
|
+
const hasSessionPath = !forking && resumePath !== undefined && resumePath !== '';
|
|
1770
|
+
const cycling = hasSessionPath && cfg.newCycle === true;
|
|
1771
|
+
const resuming = hasSessionPath && !cycling;
|
|
1749
1772
|
let sessionManager;
|
|
1750
1773
|
if (forking) {
|
|
1751
1774
|
// Spawn-time fork via pi's REAL fork seam, SessionManager.forkFrom — the exact
|
|
@@ -1759,8 +1782,16 @@ export async function buildBrokerSession(engine, cfg) {
|
|
|
1759
1782
|
// fails loudly here (forkFrom throws on an empty/invalid/header-less source).
|
|
1760
1783
|
sessionManager = engine.SessionManager.forkFrom(cfg.forkFrom, cfg.cwd);
|
|
1761
1784
|
}
|
|
1762
|
-
else if (resuming) {
|
|
1785
|
+
else if (resuming || cycling) {
|
|
1763
1786
|
sessionManager = engine.SessionManager.open(resumePath);
|
|
1787
|
+
if (cycling) {
|
|
1788
|
+
const fromLeaf = sessionManager.getLeafId();
|
|
1789
|
+
sessionManager.resetLeaf();
|
|
1790
|
+
sessionManager.appendCustomEntry(CRTR_CYCLE_CUSTOM_TYPE, {
|
|
1791
|
+
cycle: Number(process.env['CRTR_CYCLES']),
|
|
1792
|
+
fromLeaf,
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1764
1795
|
}
|
|
1765
1796
|
else {
|
|
1766
1797
|
sessionManager = engine.SessionManager.create(cfg.cwd);
|
|
@@ -11,9 +11,9 @@ export interface LaunchPlacement {
|
|
|
11
11
|
* the broker pid recorded as pi_pid; no tmux placement coords exist (the engine
|
|
12
12
|
* is never in a pane). */
|
|
13
13
|
export interface HostHandle {
|
|
14
|
-
/** Supervised pid (signal-0 target).
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
/** Supervised pid (signal-0 target). Null means the host failed to spawn an
|
|
15
|
+
* engine at all. Spawn/revive record this pid immediately; the broker
|
|
16
|
+
* re-records the same pid during extension bind. */
|
|
17
17
|
pid: number | null;
|
|
18
18
|
/** Real child-exit signal from the launch handle. Resolves when the detached
|
|
19
19
|
* broker exits, so readiness waits can fail fast without pid probes. */
|
|
@@ -20,7 +20,8 @@ import { nodeDir, jobDir, viewSocketPath } from '../canvas/paths.js';
|
|
|
20
20
|
import { encodeFrame } from './broker-protocol.js';
|
|
21
21
|
import { FRONT_DOOR_ENV } from './front-door.js';
|
|
22
22
|
import { hostExecPath } from './branded-host.js';
|
|
23
|
-
import { isPidAlive } from '../canvas/pid.js';
|
|
23
|
+
import { isPidAlive, killProcessTreePids, isAnyPidAlive, captureTeardownSnapshot } from '../canvas/pid.js';
|
|
24
|
+
import { logger } from '../log.js';
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
25
26
|
// HeadlessBrokerHost (design §3.3): a detached broker PROCESS, no tmux pane. It
|
|
26
27
|
// boots the dedicated broker-cli entry directly (NEVER src/cli.ts), supervises
|
|
@@ -88,8 +89,80 @@ export function preflightBrokerLaunch(nodeId, inv, cwd) {
|
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
/** How long teardown waits after sending the graceful `shutdown` frame before
|
|
91
|
-
*
|
|
92
|
+
* escalating a broker/tree that connected but never fully exited (e.g. a hung
|
|
93
|
+
* dispose(), or a dispose() that reaped the broker but left a descendant
|
|
94
|
+
* behind). */
|
|
92
95
|
const BROKER_SHUTDOWN_GRACE_MS = 2_000;
|
|
96
|
+
/** How long teardown waits after the tree-wide SIGTERM before the final
|
|
97
|
+
* tree-wide SIGKILL sweep — the last resort for a broker (or its
|
|
98
|
+
* descendants) that ignores SIGTERM entirely. */
|
|
99
|
+
const BROKER_SIGKILL_GRACE_MS = 2_000;
|
|
100
|
+
/** How often `pollUntilTreeDead` re-checks the process TREE. Short enough that
|
|
101
|
+
* the common/happy path (tree already dead) pays only a couple of ticks; the
|
|
102
|
+
* full grace ceilings above are reserved for the genuinely-wedged case,
|
|
103
|
+
* where the added latency is the whole point (crouter#98 review finding 3). */
|
|
104
|
+
const TEARDOWN_POLL_MS = 150;
|
|
105
|
+
/** Poll `isAlive` every `TEARDOWN_POLL_MS` until it reports dead. If it is
|
|
106
|
+
* STILL alive once `timeoutMs` elapses, fire `onTimeout` exactly once (the
|
|
107
|
+
* next escalation rung) and keep polling so the caller can confirm that
|
|
108
|
+
* rung actually worked — up to `hardCeilingMs` total, after which this gives
|
|
109
|
+
* up (an unkillable/D-state descendant is out of scope for this primitive).
|
|
110
|
+
*
|
|
111
|
+
* Deliberately a recursive, NEVER-`.unref()`'d `setTimeout` rather than a
|
|
112
|
+
* fixed unref'd wait (crouter#98 review finding 3): most teardown callers
|
|
113
|
+
* are short-lived CLI subprocesses (`node lifecycle close`, `canvas prune`)
|
|
114
|
+
* that would otherwise exit before an unref'd SIGKILL timer ever fires,
|
|
115
|
+
* silently skipping the last-resort rung. Polling (instead of a blind fixed
|
|
116
|
+
* wait) is what keeps the happy path cheap despite being ref'd. */
|
|
117
|
+
function pollUntil(isAlive, timeoutMs, onTimeout, hardCeilingMs = timeoutMs + 5_000) {
|
|
118
|
+
const start = Date.now();
|
|
119
|
+
let escalated = false;
|
|
120
|
+
const tick = () => {
|
|
121
|
+
if (!isAlive())
|
|
122
|
+
return; // confirmed dead — stop polling
|
|
123
|
+
const elapsed = Date.now() - start;
|
|
124
|
+
if (!escalated && elapsed >= timeoutMs) {
|
|
125
|
+
escalated = true;
|
|
126
|
+
onTimeout();
|
|
127
|
+
}
|
|
128
|
+
if (elapsed >= hardCeilingMs)
|
|
129
|
+
return; // best-effort exhausted — give up
|
|
130
|
+
setTimeout(tick, TEARDOWN_POLL_MS);
|
|
131
|
+
};
|
|
132
|
+
tick();
|
|
133
|
+
}
|
|
134
|
+
/** Escalate teardown of a broker/tree that did not exit cleanly off the
|
|
135
|
+
* graceful `shutdown` frame (or was never reachable at all): SIGTERM the
|
|
136
|
+
* whole tree, then — once `BROKER_SIGKILL_GRACE_MS` has elapsed with the tree
|
|
137
|
+
* still alive — SIGKILL the whole tree as the last resort.
|
|
138
|
+
*
|
|
139
|
+
* Takes an ALREADY-CAPTURED `tree` (pid list) and `identities` (a
|
|
140
|
+
* `captureTeardownSnapshot` identity map over that same list) — both taken
|
|
141
|
+
* ONCE by `teardown()` synchronously at its very top, before EITHER
|
|
142
|
+
* escalation path (connect-error or post-shutdown-frame) can run, and
|
|
143
|
+
* threaded through here unchanged (crouter#98 review finding 1). This
|
|
144
|
+
* function must NEVER re-walk the process tree itself: `launch()` spawns the
|
|
145
|
+
* broker `detached: true`, so
|
|
146
|
+
* signaling the broker's OWN group (the first thing `killProcessTreePids`
|
|
147
|
+
* does) typically kills the broker itself almost immediately — and the
|
|
148
|
+
* kernel reparents any surviving child away from the broker's pid the
|
|
149
|
+
* INSTANT the broker exits, severing the very ppid link a fresh
|
|
150
|
+
* `descendantPids` walk depends on. Re-deriving descendants at ANY point
|
|
151
|
+
* after the broker may have exited (including a per-rung re-walk here, or a
|
|
152
|
+
* poll that re-derives instead of checking this fixed list) would silently
|
|
153
|
+
* "lose" a still-alive detached descendant — e.g. the pi bash tool's shell
|
|
154
|
+
* child, itself `detached: true` and thus its own group leader, distinct
|
|
155
|
+
* from the broker's group, so a plain `kill(-brokerPid)` never reaches it
|
|
156
|
+
* either (crouton-labs/crouter#98). `identities` guards each signal against
|
|
157
|
+
* PID REUSE across the multi-second escalation window (review finding 3) —
|
|
158
|
+
* see `killProcessTreePids`. `null` means the initial `captureTeardownSnapshot`
|
|
159
|
+
* probe itself failed (no baseline to compare against) — `killProcessTreePids`
|
|
160
|
+
* falls through to its normal unguarded signal path in that case, never
|
|
161
|
+
* reading a probe failure as proof the pid is gone or reused. */
|
|
162
|
+
function escalateBrokerTeardown(tree, identities) {
|
|
163
|
+
killProcessTreePids(tree, 'SIGTERM', identities);
|
|
164
|
+
pollUntil(() => isAnyPidAlive(tree), BROKER_SIGKILL_GRACE_MS, () => killProcessTreePids(tree, 'SIGKILL', identities));
|
|
165
|
+
}
|
|
93
166
|
export const headlessBrokerHost = {
|
|
94
167
|
launch(nodeId, inv, opts) {
|
|
95
168
|
// Fast-fail known launch errors before we detach a broker process.
|
|
@@ -136,8 +209,51 @@ export const headlessBrokerHost = {
|
|
|
136
209
|
// Graceful: connect to view.sock + send a `shutdown` frame → the broker
|
|
137
210
|
// dispose()s the engine, unlinks the socket, exits 0. Status is already
|
|
138
211
|
// flipped done/canceled by the caller (crash-safe ordering), so the daemon
|
|
139
|
-
// won't revive. On connect failure (broker dead/crashed) fall back
|
|
140
|
-
//
|
|
212
|
+
// won't revive. On connect failure (broker dead/crashed/wedged) fall back
|
|
213
|
+
// to escalateBrokerTeardown — a tree-wide SIGTERM now, a tree-wide SIGKILL
|
|
214
|
+
// as the last resort — so no orphaned descendant of a dead/wedged broker
|
|
215
|
+
// survives the node (crouton-labs/crouter#98) — + unlink the stale socket.
|
|
216
|
+
//
|
|
217
|
+
// Capture the broker pid ONCE, synchronously, right here — never re-query
|
|
218
|
+
// `getNode(nodeId)` inside the async callbacks/timers below. Callers such
|
|
219
|
+
// as placement.ts's `reapIfEmpty()` call `teardown()` then immediately
|
|
220
|
+
// `deleteNode()` in the same tick; by the time an async callback fires the
|
|
221
|
+
// row can already be gone, so a re-query would silently resolve to
|
|
222
|
+
// `undefined` and skip escalation entirely (crouter#98 review finding 2).
|
|
223
|
+
const node = getNode(nodeId);
|
|
224
|
+
const pid = node?.pi_pid;
|
|
225
|
+
// Snapshot the full descendant TREE (crouter#98 review finding 1) — the
|
|
226
|
+
// root pid plus every transitive descendant — and a stable identity
|
|
227
|
+
// fingerprint for every pid in it (review finding 3), BOTH from ONE `ps`
|
|
228
|
+
// table read (`captureTeardownSnapshot`, final review: closes the small
|
|
229
|
+
// race between two separate probes), exactly ONCE, right here, while the
|
|
230
|
+
// broker (if alive) still holds parentage over any detached descendant.
|
|
231
|
+
// EVERY escalation path below — connect-error AND post-shutdown-frame —
|
|
232
|
+
// reuses this ONE fixed snapshot; neither may re-walk `ps` by ppid once
|
|
233
|
+
// the broker might have exited, because the kernel reparents any
|
|
234
|
+
// surviving child away from `pid` the INSTANT the broker exits, severing
|
|
235
|
+
// the ppid link a fresh walk depends on — exactly the reparenting hazard
|
|
236
|
+
// this fix closes. (If the broker is ALREADY dead when `teardown()`
|
|
237
|
+
// starts, its detached descendants were already reparented before we ever
|
|
238
|
+
// got here — unrecoverable by ppid, and out of scope: the #98 repro is
|
|
239
|
+
// the wedged-but-ALIVE broker, which this covers.)
|
|
240
|
+
//
|
|
241
|
+
// `node?.pi_pid_identity` is the LAUNCH-time baseline `recordPid` captured
|
|
242
|
+
// the instant this broker was spawned/bound (final review, Fix 1): if
|
|
243
|
+
// `pid` has ALREADY been recycled for an unrelated process before
|
|
244
|
+
// teardown ever ran — the pid-reuse hazard this guard exists for — the
|
|
245
|
+
// snapshot's `reused: true` comes back with an EMPTY tree, so nothing
|
|
246
|
+
// below ever signals the stranger (or walks its process tree looking for
|
|
247
|
+
// "descendants", which would be the stranger's children, not ours).
|
|
248
|
+
const snapshot = pid != null ? captureTeardownSnapshot(pid, node?.pi_pid_identity ?? null) : null;
|
|
249
|
+
if (snapshot?.reused === true) {
|
|
250
|
+
logger('host').warn('refusing to signal a reused pid — launch-time identity mismatch', {
|
|
251
|
+
nodeId,
|
|
252
|
+
pid,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
const tree = snapshot?.tree ?? [];
|
|
256
|
+
const identities = snapshot?.identities ?? null;
|
|
141
257
|
const sockPath = viewSocketPath(nodeId);
|
|
142
258
|
const cleanupSocket = () => {
|
|
143
259
|
try {
|
|
@@ -152,18 +268,11 @@ export const headlessBrokerHost = {
|
|
|
152
268
|
let connected = false;
|
|
153
269
|
sock.on('error', () => {
|
|
154
270
|
// A post-connect error (broker exiting after we sent shutdown) is benign
|
|
155
|
-
// and swallowed; only a connect failure triggers the
|
|
271
|
+
// and swallowed; only a connect failure triggers the escalation fallback.
|
|
156
272
|
if (connected)
|
|
157
273
|
return;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
try {
|
|
161
|
-
process.kill(pid, 'SIGTERM');
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
/* already gone */
|
|
165
|
-
}
|
|
166
|
-
}
|
|
274
|
+
if (tree.length > 0)
|
|
275
|
+
escalateBrokerTeardown(tree, identities);
|
|
167
276
|
cleanupSocket();
|
|
168
277
|
});
|
|
169
278
|
sock.once('connect', () => {
|
|
@@ -176,25 +285,31 @@ export const headlessBrokerHost = {
|
|
|
176
285
|
}
|
|
177
286
|
sock.end();
|
|
178
287
|
cleanupSocket();
|
|
179
|
-
// Bounded exit confirmation
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
288
|
+
// Bounded exit confirmation: a broker that connects fine but then HANGS
|
|
289
|
+
// inside session.dispose() (disposeAndExit catches a throw, not a hang)
|
|
290
|
+
// would leak the process holding the sole .jsonl writer — and any
|
|
291
|
+
// bash→test→app descendants it spawned along the way (crouter#98). Poll
|
|
292
|
+
// the whole process TREE (not just the broker's own pid) for up to
|
|
293
|
+
// `BROKER_SHUTDOWN_GRACE_MS`, UNCONDITIONAL on the broker's own liveness
|
|
294
|
+
// (crouter#98 review finding 1): a broker that exits cleanly can still
|
|
295
|
+
// leave a live descendant behind (dispose() partially failed, or never
|
|
296
|
+
// reached a detached SDK child), which is exactly the shape this
|
|
297
|
+
// closes. `pollUntil` is ref'd — not `.unref()`'d — so a short-lived
|
|
298
|
+
// CLI caller stays alive long enough for the SIGKILL rung to actually
|
|
299
|
+
// fire, while the happy path (tree already dead) still pays only a
|
|
300
|
+
// couple of poll ticks.
|
|
301
|
+
//
|
|
302
|
+
// Checks `isAnyPidAlive(tree)` against the FIXED pre-signal snapshot,
|
|
303
|
+
// NEVER a fresh `isProcessTreeAlive`/`descendantPids` re-walk (crouter#98
|
|
304
|
+
// review finding 1, fixed): the broker can exit at any point during this
|
|
305
|
+
// grace window while leaving a detached descendant alive, and the
|
|
306
|
+
// instant it does, the kernel reparents that descendant away from `pid`
|
|
307
|
+
// — a re-walk-by-ppid would then find nothing and silently skip
|
|
308
|
+
// escalation, even though the descendant is still very much alive. The
|
|
309
|
+
// fixed `tree` snapshot (taken before the shutdown frame was even sent)
|
|
310
|
+
// has no such blind spot.
|
|
311
|
+
if (tree.length > 0) {
|
|
312
|
+
pollUntil(() => isAnyPidAlive(tree), BROKER_SHUTDOWN_GRACE_MS, () => escalateBrokerTeardown(tree, identities));
|
|
198
313
|
}
|
|
199
314
|
});
|
|
200
315
|
},
|