@north-light/crouter 0.3.305 → 0.3.307
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/plugin-manifest-schema.d.ts +2 -1
- package/dist/builtin-memory/internal/plugins.md +1 -1
- package/dist/commands/cron.js +9 -7
- package/dist/core/__tests__/integration/command-plugins.test.js +44 -4
- package/dist/core/__tests__/integration/git-optional-locks.test.d.ts +1 -0
- package/dist/core/__tests__/integration/git-optional-locks.test.js +81 -0
- package/dist/core/__tests__/integration/lifecycle-hooks.test.js +5 -0
- package/dist/core/__tests__/relaunch-root.test.js +2 -2
- package/dist/core/command-hooks/lifecycle-catalog.d.ts +1 -1
- package/dist/core/command-hooks/lifecycle-catalog.js +1 -1
- package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +1 -1
- package/dist/core/command-hooks/transport/exec-lifecycle.js +1 -1
- package/dist/core/command-manifests/schema.js +2 -1
- package/dist/core/command-plugins/discovery.js +36 -1
- package/dist/core/command.d.ts +2 -1
- package/dist/core/git.js +16 -2
- package/dist/core/human/component-docs.js +3 -1
- package/dist/core/runtime/bearings-render.js +13 -4
- package/dist/core/runtime/close.d.ts +2 -0
- package/dist/core/runtime/close.js +51 -9
- package/dist/core/runtime/host.d.ts +13 -5
- package/dist/core/runtime/host.js +144 -177
- package/dist/core/runtime/placement.d.ts +1 -1
- package/dist/core/runtime/placement.js +3 -2
- package/dist/daemon/api/handlers/nodes.js +3 -0
- package/dist/daemon/control.d.ts +2 -0
- package/dist/daemon/crtrd.js +1 -1
- package/dist/hook-authoring.d.ts +2 -2
- package/dist/hook-authoring.js +2 -2
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
|
@@ -103,7 +103,8 @@ export interface ManifestTimeouts {
|
|
|
103
103
|
streamIdleMs?: number;
|
|
104
104
|
}
|
|
105
105
|
/** Exec transport only: forward every argv token after this branch to an
|
|
106
|
-
* external binary instead of parsing children.
|
|
106
|
+
* external binary instead of parsing children. `bin` is either a bare PATH
|
|
107
|
+
* command or a plugin-root-relative executable path. A passthrough branch is
|
|
107
108
|
* childless by construction; an HTTP manifest rejects it, because an HTTP
|
|
108
109
|
* transport must not name a local binary to execute. */
|
|
109
110
|
export interface ManifestPassthrough {
|
|
@@ -266,7 +266,7 @@ An HTTP-transport plugin replaces that declaration with:
|
|
|
266
266
|
"transport": { "kind": "http", "endpoint": "https://example.com", "authEnv": "DEPLOY_TOKEN" }
|
|
267
267
|
```
|
|
268
268
|
|
|
269
|
-
For `exec`, `executable` is required when the manifest has any executable leaf; a passthrough-only manifest omits it. When declared, it is plugin-root-relative, resolves inside the plugin root to a regular file, and carries the POSIX exec bit. For `http`, `transport.endpoint` is the backend origin and `authEnv` is optional. crouter stores only the environment variable **name**, never its credential; it reads the credential when invoking a leaf. A crouter-materialized archive plugin additionally records its archive URL separately in `bundle` for installation and update.
|
|
269
|
+
For `exec`, `executable` is required when the manifest has any executable leaf; a passthrough-only manifest omits it. When declared, it is plugin-root-relative, resolves inside the plugin root to a regular file, and carries the POSIX exec bit. A passthrough `bin` is either a bare command resolved through `PATH` or a plugin-root-relative executable path subject to the same containment and permission checks. For `http`, `transport.endpoint` is the backend origin and `authEnv` is optional. crouter stores only the environment variable **name**, never its credential; it reads the credential when invoking a leaf. A crouter-materialized archive plugin additionally records its archive URL separately in `bundle` for installation and update.
|
|
270
270
|
|
|
271
271
|
Only an installed, **enabled** plugin's command manifest contributes. Discovery is per-invocation and reads the installed `commands.json` only: enable, disable, update, and remove take effect on the next `crtr` call with no daemon restart, cache clearing, or network fetch.
|
|
272
272
|
|
package/dist/commands/cron.js
CHANGED
|
@@ -132,7 +132,7 @@ const addLeaf = defineLeaf({
|
|
|
132
132
|
{ kind: 'stdin', name: 'command', required: true, constraint: "Arbitrary bash the daemon runs at each fire. Pipe it from a single-quoted heredoc (`<<'EOF'`) to preserve literal bytes. Runs via `bash -c` in this cwd with $CRTR_CRON_ID, $CRTR_CRON_NAME, and $CRTR_RUN_ID in the env ($CRTR_ANCHOR too when anchored). The scheduler does not interpret it as a node action; a bash predicate can decide whether this fire acts, not arm a native predicate trigger. A run that must branch on canvas state should call `crtr --json <cmd>` and read the leaf's declared outputs, rather than parsing the prose output." },
|
|
133
133
|
{ kind: 'flag', name: 'name', type: 'string', required: true, constraint: 'Agent-legible label — how the cron shows in `cron list`.' },
|
|
134
134
|
{ kind: 'flag', name: 'at', type: 'string', required: false, constraint: 'One-shot: run the stored bash once at <when> — a duration ("90s","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00", interpreted in --tz else host zone). Exactly one of --at / --every is required. The row is deleted after its run unless the run\'s disposition pauses it.' },
|
|
135
|
-
{ kind: 'flag', name: 'every', type: 'string', required: false, constraint: 'Recurring: run the stored bash at each cadence — a fixed interval ("6h" — first fire one interval from now), a 5-field cron ("0 9 * * *"), or an @alias ("@daily"). Each fire is another bash run. Minimum cadence 60s. Exactly one of --at / --every is required.
|
|
135
|
+
{ kind: 'flag', name: 'every', type: 'string', required: false, constraint: 'Recurring: run the stored bash at each cadence — a fixed interval ("6h" — first fire one interval from now), a 5-field cron ("0 9 * * *"), or an @alias ("@daily"). Each fire is another bash run. Minimum cadence 60s. Exactly one of --at / --every is required. Use this for a bash condition that only the local filesystem knows: if it exits 75, the next cadence re-checks it. A host platform can also re-check it early by calling the daemon\'s `POST /v1/crons/poke` endpoint.' },
|
|
136
136
|
{ kind: 'flag', name: 'tz', type: 'string', required: false, constraint: 'IANA zone (e.g. "America/New_York") for a bare-ISO --at or a calendar --every. Defaults to the host zone.' },
|
|
137
137
|
{ kind: 'flag', name: 'on-output', type: 'enum', choices: ['silent', 'on-failure', 'always', 'on-change'], required: false, constraint: 'What each run does with its output. Defaults to on-change for a node:<id> sink; otherwise on-failure. silent: record in the run log, tell nobody — never escalate. on-failure: no success delivery; a failing run pauses the cron and spawns a node to deal with it. always: deliver stdout every run to --sink at --tier. on-change: deliver stdout only when it differs from the previous run\'s — the shape polling wants.' },
|
|
138
138
|
{ kind: 'flag', name: 'sink', type: 'string', required: false, constraint: 'Where always/on-change stdout deliveries go: "node:<id>" (an existing node\'s inbox — must exist now; gone/finalized at fire time is a failure), "spawn:<kind>" (each delivered output creates a self-finishing node with stdout as kickoff — a managed child while its creator exists, otherwise a terminal parentless root), or "human" (the humanloop inbox). Required by always/on-change, rejected otherwise.' },
|
|
@@ -282,7 +282,7 @@ const listLeaf = defineLeaf({
|
|
|
282
282
|
summary: 'every cron in scope, next-fire order',
|
|
283
283
|
params: [],
|
|
284
284
|
output: [
|
|
285
|
-
{ name: 'crons', type: 'object[]', required: true, constraint: 'One row per cron: cron_id, name, fire_at (next fire, UTC), recur (cadence display), state (active|paused), held (true
|
|
285
|
+
{ name: 'crons', type: 'object[]', required: true, constraint: 'One row per cron: cron_id, name, fire_at (next fire, UTC), recur (cadence display), state (active|paused), held (true after an exit-75 gate; a recurring row re-checks at fire_at or when a host platform calls `POST /v1/crons/poke`, while a one-shot needs that call or `crtr cron run <cron-id>`), on_output, sink, expires_at, last_run (the most recent settled run: finished, exit_code, delivered — null if it never ran).' },
|
|
286
286
|
],
|
|
287
287
|
outputKind: 'object',
|
|
288
288
|
effects: ['None. Read-only.'],
|
|
@@ -325,7 +325,9 @@ const listLeaf = defineLeaf({
|
|
|
325
325
|
? ' [PAUSED (held) — resume or cancel]'
|
|
326
326
|
: ' [PAUSED — resume or cancel]'
|
|
327
327
|
: c.held
|
|
328
|
-
?
|
|
328
|
+
? c.recur === 'none'
|
|
329
|
+
? ` [HELD — last run exited 75; run manually with \`crtr cron run ${c.cron_id}\`, or wait for a host platform to call \`POST /v1/crons/poke\`]`
|
|
330
|
+
: ' [HELD — last run exited 75; re-checks at the next cadence or when a host platform calls `POST /v1/crons/poke`]'
|
|
329
331
|
: '';
|
|
330
332
|
return `- ${c.name} (${c.cron_id}) — ${bits.join(', ')}${marker}`;
|
|
331
333
|
});
|
|
@@ -366,10 +368,10 @@ const showLeaf = defineLeaf({
|
|
|
366
368
|
const scheduleLine = !c.held
|
|
367
369
|
? `- schedule: ${schedule}${tzPart} — next fire ${c.fire_at}`
|
|
368
370
|
: c.recur !== null
|
|
369
|
-
? `- schedule: ${schedule}${tzPart} —
|
|
371
|
+
? `- schedule: ${schedule}${tzPart} — next re-check ${c.fire_at} (held — also re-checks when a host platform calls \`POST /v1/crons/poke\`; last run exited 75)`
|
|
370
372
|
: c.expires_at !== null
|
|
371
|
-
? `- schedule: ${schedule}${tzPart} —
|
|
372
|
-
: `- schedule: ${schedule}${tzPart} —
|
|
373
|
+
? `- schedule: ${schedule}${tzPart} — no scheduled re-check — expires ${c.expires_at} (held — run manually with \`crtr cron run ${c.cron_id}\`, or wait for a host platform to call \`POST /v1/crons/poke\`; deleted unfired at expiry)`
|
|
374
|
+
: `- schedule: ${schedule}${tzPart} — no scheduled re-check (held — run manually with \`crtr cron run ${c.cron_id}\`, or wait for a host platform to call \`POST /v1/crons/poke\`)`;
|
|
373
375
|
const stateTag = c.state === 'paused' ? (c.held ? ' — PAUSED (held)' : ' — PAUSED') : c.held ? ' — HELD' : '';
|
|
374
376
|
const lines = [
|
|
375
377
|
`# ${c.name} (${c.cron_id})${stateTag}`,
|
|
@@ -525,7 +527,7 @@ export function registerCron() {
|
|
|
525
527
|
help: {
|
|
526
528
|
name: 'cron',
|
|
527
529
|
summary: 'scheduled bash commands, daemon-run',
|
|
528
|
-
model: 'A cron stores and daemon-runs arbitrary bash at its clock. Use ordinary bash for ordinary work; choose node:<id> for stdout as inbox information to an existing node (its disposition defaults to on-change), an existing node\'s lifecycle fresh-revive action in the bash for a clean re-check without inbox output, or spawn:<kind> to route each output-producing fire to a fresh self-finishing node (a managed child while its creator exists, otherwise a terminal parentless root). Per-fire fresh agent work belongs on the existing spawn:<kind> sink, not an independent resident-root birth. A gate is bash, at the top of the command. Exit 0 after deciding not to act and the occurrence is simply spent — right when the recurrence is your polling cadence. Exit 75
|
|
530
|
+
model: 'A cron stores and daemon-runs arbitrary bash at its clock. Use ordinary bash for ordinary work; choose node:<id> for stdout as inbox information to an existing node (its disposition defaults to on-change), an existing node\'s lifecycle fresh-revive action in the bash for a clean re-check without inbox output, or spawn:<kind> to route each output-producing fire to a fresh self-finishing node (a managed child while its creator exists, otherwise a terminal parentless root). Per-fire fresh agent work belongs on the existing spawn:<kind> sink, not an independent resident-root birth. A gate is bash, at the top of the command. Exit 0 after deciding not to act and the occurrence is simply spent — right when the recurrence is your polling cadence. Exit 75 holds the row. A held recurring row re-checks at its next natural slot, or when a host platform calls the daemon\'s `POST /v1/crons/poke` endpoint. A held one-shot has no scheduled re-check: it stays held until that endpoint is called, or you execute its bash explicitly with `crtr cron run <cron-id>` (which does not unhold or reschedule the row). A local filesystem condition does not call the daemon, so use --every for that condition or run the cron explicitly after it changes. Any other nonzero exit is a real failure and escalates per --on-output. Keep gate errors nonzero so a broken probe is loud instead of silently held. Beyond the exit-code contract, cron has no native predicate-trigger semantics. --at runs once and deletes the row unless the run\'s disposition pauses it; --every runs each cadence. Output disposition (--on-output) records silently, escalates failures, or routes stdout to its --sink. Anchor a cron to delete it with a node; --cancel-on-wake instead deletes it when the anchor wakes. An unanchored recurring cron ends through --expires, explicit cancellation, or self-cancellation from its bash. Every run lands in a bounded per-cron run log (`cron show`); cwd, env, and profile are snapshotted at arm time, and --scope controls who lists and cancels it.',
|
|
529
531
|
},
|
|
530
532
|
children: [addLeaf, listLeaf, showLeaf, runLeaf, pauseLeaf, resumeLeaf, cancelLeaf],
|
|
531
533
|
});
|
|
@@ -153,7 +153,7 @@ process.stdin.on('end', () => {
|
|
|
153
153
|
process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true, result: { value: request.input.thing } }));
|
|
154
154
|
});
|
|
155
155
|
`;
|
|
156
|
-
function passthroughCommandsJson() {
|
|
156
|
+
function passthroughCommandsJson(bin = 'capture') {
|
|
157
157
|
return {
|
|
158
158
|
schemaVersion: 1,
|
|
159
159
|
mounts: [{
|
|
@@ -169,7 +169,7 @@ function passthroughCommandsJson() {
|
|
|
169
169
|
whenToUse: 'driving a browser through the Capture CLI',
|
|
170
170
|
},
|
|
171
171
|
summary: 'browser automation through Capture',
|
|
172
|
-
passthrough: { bin
|
|
172
|
+
passthrough: { bin, installHint: 'Install @crouton-kit/capture globally.' },
|
|
173
173
|
children: [],
|
|
174
174
|
},
|
|
175
175
|
}],
|
|
@@ -417,17 +417,17 @@ describe('protocol integration', () => {
|
|
|
417
417
|
});
|
|
418
418
|
}
|
|
419
419
|
});
|
|
420
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', 'dist', 'cli.js');
|
|
420
421
|
// 4b. True end-to-end CLI invocation (argv → parseArgv → adapter → renderer)
|
|
421
422
|
describe('end-to-end CLI invocation', () => {
|
|
422
423
|
// dist/cli.js is up 4 from src/core/__tests__/integration/. Requires a prior
|
|
423
424
|
// `npm run build` (the harness/CI builds before tests). Drives the WHOLE seam: the
|
|
424
425
|
// declared positional `app-id` must arrive as `appId` in the request, and
|
|
425
426
|
// the rendered + --json outputs come through the real dispatcher.
|
|
426
|
-
const cli = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', 'dist', 'cli.js');
|
|
427
427
|
const runCrtr = (args) => {
|
|
428
428
|
installPlugin(userRoot, 'deploy-tools');
|
|
429
429
|
resetScopeCache();
|
|
430
|
-
return execFileSync(process.execPath, [
|
|
430
|
+
return execFileSync(process.execPath, [CLI, ...args], {
|
|
431
431
|
cwd: emptyStart,
|
|
432
432
|
env: { ...process.env, HOME: home, CRTR_FRONT_DOOR: '' },
|
|
433
433
|
encoding: 'utf8',
|
|
@@ -445,6 +445,38 @@ describe('end-to-end CLI invocation', () => {
|
|
|
445
445
|
assert.equal(obj.status, 'running');
|
|
446
446
|
});
|
|
447
447
|
});
|
|
448
|
+
// Passthrough paths resolve inside their plugin during discovery. Bare names are
|
|
449
|
+
// deliberately left for the invoking process's PATH.
|
|
450
|
+
describe('passthrough binary resolution', () => {
|
|
451
|
+
function runPassthrough(args, env = process.env) {
|
|
452
|
+
return execFileSync(process.execPath, [CLI, ...args], {
|
|
453
|
+
cwd: emptyStart,
|
|
454
|
+
env: { ...env, HOME: home, CRTR_FRONT_DOOR: '' },
|
|
455
|
+
encoding: 'utf8',
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
test('a plugin-relative bin runs the plugin program', () => {
|
|
459
|
+
const root = installPlugin(userRoot, 'passthrough-fixture', { manifest: passthroughCommandsJson('bin/passthrough.js'), executable: null });
|
|
460
|
+
const program = join(root, 'bin', 'passthrough.js');
|
|
461
|
+
writeFileSync(program, '#!/bin/sh\nprintf "plugin program: %s\\n" "$*"\n');
|
|
462
|
+
chmodSync(program, 0o755);
|
|
463
|
+
const validation = validatePluginCommands(listInstalledPluginsInRoot('user', userRoot).find((plugin) => plugin.name === 'passthrough-fixture'), RESERVED);
|
|
464
|
+
assert.equal(validation.issues.length, 0, JSON.stringify(validation.issues));
|
|
465
|
+
assert.equal(validation.contributions[0].node.passthrough?.bin, realpathSync(program));
|
|
466
|
+
assert.equal(runPassthrough(['capture', 'one', '--two']).trim(), 'plugin program: one --two');
|
|
467
|
+
});
|
|
468
|
+
test('a bare bin runs the program from PATH', () => {
|
|
469
|
+
installPlugin(userRoot, 'passthrough-fixture', { manifest: passthroughCommandsJson('capture-fixture'), executable: null });
|
|
470
|
+
const pathDir = mintDir('crtr-cmdplugin-path-');
|
|
471
|
+
const program = join(pathDir, 'capture-fixture');
|
|
472
|
+
writeFileSync(program, '#!/bin/sh\nprintf "PATH program: %s\\n" "$*"\n');
|
|
473
|
+
chmodSync(program, 0o755);
|
|
474
|
+
const validation = validatePluginCommands(listInstalledPluginsInRoot('user', userRoot).find((plugin) => plugin.name === 'passthrough-fixture'), RESERVED);
|
|
475
|
+
assert.equal(validation.issues.length, 0, JSON.stringify(validation.issues));
|
|
476
|
+
assert.equal(validation.contributions[0].node.passthrough?.bin, 'capture-fixture');
|
|
477
|
+
assert.equal(runPassthrough(['capture', 'three'], { ...process.env, PATH: `${pathDir}:${process.env['PATH'] ?? ''}` }).trim(), 'PATH program: three');
|
|
478
|
+
});
|
|
479
|
+
});
|
|
448
480
|
// Extensible plugin command branches — repository fragments attach to a marked
|
|
449
481
|
// top-level branch as one native forest and stay entirely inside the existing
|
|
450
482
|
// parser/help/exec contract.
|
|
@@ -794,6 +826,14 @@ describe('typed validation issues', () => {
|
|
|
794
826
|
const v = validate(commandsJson(), { executable: '../../../../bin/sh' });
|
|
795
827
|
assert.ok(v.issues.some((i) => i.code === 'command_path_unsafe'));
|
|
796
828
|
});
|
|
829
|
+
test('passthrough path escaping the plugin root → command_path_unsafe', () => {
|
|
830
|
+
const v = validate(passthroughCommandsJson('../../../../bin/sh'), { executable: null });
|
|
831
|
+
assert.ok(v.issues.some((i) => i.code === 'command_path_unsafe'));
|
|
832
|
+
});
|
|
833
|
+
test('non-executable passthrough path → command_not_executable', () => {
|
|
834
|
+
const v = validate(passthroughCommandsJson('bin/cmd.js'), { executable: null, execBit: false });
|
|
835
|
+
assert.ok(v.issues.some((i) => i.code === 'command_not_executable'));
|
|
836
|
+
});
|
|
797
837
|
test('a top-level leaf (no rootEntry) → command_node_invalid', () => {
|
|
798
838
|
const m = {
|
|
799
839
|
schemaVersion: 1,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Run with: npm run test:integration
|
|
2
|
+
//
|
|
3
|
+
// Regression: crouter's read-only git calls must not take `.git/index.lock`.
|
|
4
|
+
//
|
|
5
|
+
// `git status` refreshes the index stat cache and writes the index back, which
|
|
6
|
+
// means creating `.git/index.lock` and renaming it over `.git/index`. A process
|
|
7
|
+
// killed between those two steps orphans the lock, and it then blocks every
|
|
8
|
+
// later writer in that checkout with no owning process left to name. Agent
|
|
9
|
+
// processes are killed mid-command routinely — a closed node, a cancelled daemon
|
|
10
|
+
// tick, a timed-out shell — so the residue accumulates in a shared checkout
|
|
11
|
+
// until someone removes it by hand.
|
|
12
|
+
//
|
|
13
|
+
// `GIT_OPTIONAL_LOCKS=0` suppresses exactly that optional lock. These tests pin
|
|
14
|
+
// the two paths that reach git: the shared spawner in `core/git.ts`, and the
|
|
15
|
+
// bearings project-context block, which renders on every node boot and revive
|
|
16
|
+
// and is therefore the most frequent git caller in the runtime.
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { mkdtempSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { gitSync } from '../../git.js';
|
|
24
|
+
import { buildProjectContextBlock } from '../../runtime/bearings-render.js';
|
|
25
|
+
/** A repo with one committed file whose stat cache is deliberately stale, so a
|
|
26
|
+
* status that refreshes the index has a reason to write it back. Without the
|
|
27
|
+
* stale stat there is nothing to refresh and the assertion passes vacuously. */
|
|
28
|
+
function repoWithStaleStatCache() {
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), 'crtr-git-locks-'));
|
|
30
|
+
const git = (args) => {
|
|
31
|
+
const result = spawnSync('git', args, { cwd: dir, encoding: 'utf8' });
|
|
32
|
+
assert.equal(result.status, 0, `git ${args.join(' ')}: ${result.stderr}`);
|
|
33
|
+
};
|
|
34
|
+
writeFileSync(join(dir, 'f.txt'), 'a\n', 'utf8');
|
|
35
|
+
git(['init', '-q', '.']);
|
|
36
|
+
git(['-c', 'user.email=t@t', '-c', 'user.name=t', 'add', 'f.txt']);
|
|
37
|
+
git(['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init']);
|
|
38
|
+
// Move the file's mtime forward so git's cached stat no longer matches.
|
|
39
|
+
const future = new Date(Date.now() + 10_000);
|
|
40
|
+
utimesSync(join(dir, 'f.txt'), future, future);
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
function indexMtime(dir) {
|
|
44
|
+
return statSync(join(dir, '.git', 'index')).mtimeMs;
|
|
45
|
+
}
|
|
46
|
+
test('gitSync status does not rewrite the index', (t) => {
|
|
47
|
+
if (spawnSync('git', ['--version']).status !== 0) {
|
|
48
|
+
t.skip('git is unavailable');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const dir = repoWithStaleStatCache();
|
|
52
|
+
try {
|
|
53
|
+
const before = indexMtime(dir);
|
|
54
|
+
const result = gitSync(['status', '--porcelain'], dir);
|
|
55
|
+
assert.equal(result.status, 0, result.stderr);
|
|
56
|
+
// Suppressing the lock must not change what the command reports.
|
|
57
|
+
assert.equal(result.stdout, '');
|
|
58
|
+
assert.equal(indexMtime(dir), before, 'status took .git/index.lock and rewrote the index');
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
rmSync(dir, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
test('the bearings project-context block does not rewrite the index', (t) => {
|
|
65
|
+
if (spawnSync('git', ['--version']).status !== 0) {
|
|
66
|
+
t.skip('git is unavailable');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const dir = repoWithStaleStatCache();
|
|
70
|
+
try {
|
|
71
|
+
const before = indexMtime(dir);
|
|
72
|
+
const block = buildProjectContextBlock(dir);
|
|
73
|
+
// The snapshot still reports real git state; it just does not lock to do it.
|
|
74
|
+
assert.match(block, /Git: /);
|
|
75
|
+
assert.match(block, /clean/);
|
|
76
|
+
assert.equal(indexMtime(dir), before, 'the bearings snapshot took .git/index.lock');
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
rmSync(dir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
@@ -171,6 +171,11 @@ test('hooks.json v1 remains command-only while v2 accepts a lifecycle-only decla
|
|
|
171
171
|
hooks: [],
|
|
172
172
|
lifecycle: [{ event: 'node:start', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }],
|
|
173
173
|
});
|
|
174
|
+
const close = validateHookManifest({
|
|
175
|
+
schemaVersion: 2,
|
|
176
|
+
lifecycle: [{ event: 'node:close', phase: 'on', op: 'cleanup', description: 'cleanup', effects: ['removes a directory'] }],
|
|
177
|
+
});
|
|
178
|
+
assert.deepEqual(close.issues, []);
|
|
174
179
|
for (const manifest of [
|
|
175
180
|
{ schemaVersion: 2, hooks: [], lifecycle: [] },
|
|
176
181
|
{ schemaVersion: 2, lifecycle: [{ event: 'node:stop', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }] },
|
|
@@ -61,7 +61,7 @@ function makeDeps(pid) {
|
|
|
61
61
|
},
|
|
62
62
|
waitForViewSocket: () => true,
|
|
63
63
|
respawnViewer: () => true,
|
|
64
|
-
teardownBroker: () => { },
|
|
64
|
+
teardownBroker: async () => ({ confirmed: true }),
|
|
65
65
|
};
|
|
66
66
|
return { deps, launched };
|
|
67
67
|
}
|
|
@@ -151,7 +151,7 @@ test('relaunchRoot at capacity refuses visibly and settles the half-born row bef
|
|
|
151
151
|
},
|
|
152
152
|
waitForViewSocket: () => true,
|
|
153
153
|
respawnViewer: () => true,
|
|
154
|
-
teardownBroker: () => { },
|
|
154
|
+
teardownBroker: async () => ({ confirmed: true }),
|
|
155
155
|
};
|
|
156
156
|
await assert.rejects(() => relaunchRoot('root', deps), (error) => {
|
|
157
157
|
const details = error.details ?? {};
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export declare const LIFECYCLE_EVENTS: readonly ["node:start"];
|
|
1
|
+
export declare const LIFECYCLE_EVENTS: readonly ["node:start", "node:close"];
|
|
2
2
|
export type LifecycleEvent = typeof LIFECYCLE_EVENTS[number];
|
|
3
3
|
export declare function isLifecycleEvent(value: string): value is LifecycleEvent;
|
|
@@ -8,5 +8,5 @@ export interface LifecycleHookInvocation {
|
|
|
8
8
|
}
|
|
9
9
|
/** Runs one lifecycle hook. The node launch remains authoritative on every failure. */
|
|
10
10
|
export declare function invokeLifecycleHook(hook: EffectiveLifecycleHook, invocation: LifecycleHookInvocation, timeoutMs: number): Promise<void>;
|
|
11
|
-
/** Executes lifecycle hooks in discovery order, within one bounded
|
|
11
|
+
/** Executes lifecycle hooks in discovery order, within one bounded event budget. */
|
|
12
12
|
export declare function invokeLifecycleHooks(hooks: readonly EffectiveLifecycleHook[], invocation: LifecycleHookInvocation): Promise<void>;
|
|
@@ -34,7 +34,7 @@ export async function invokeLifecycleHook(hook, invocation, timeoutMs) {
|
|
|
34
34
|
diag(`crtr: ${label(hook)} failed: ${describeError(error)}`);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
-
/** Executes lifecycle hooks in discovery order, within one bounded
|
|
37
|
+
/** Executes lifecycle hooks in discovery order, within one bounded event budget. */
|
|
38
38
|
export async function invokeLifecycleHooks(hooks, invocation) {
|
|
39
39
|
const deadline = Date.now() + LIFECYCLE_EVENT_BUDGET_MS;
|
|
40
40
|
for (const hook of hooks) {
|
|
@@ -436,7 +436,8 @@ function validateBranch(raw, path, topLevel, transport, issue, options) {
|
|
|
436
436
|
...(passthrough !== undefined ? { passthrough } : {}), children: validated,
|
|
437
437
|
};
|
|
438
438
|
}
|
|
439
|
-
/** `passthrough: { bin, installHint }` —
|
|
439
|
+
/** `passthrough: { bin, installHint }` — `bin` is a bare PATH command or a
|
|
440
|
+
* plugin-root-relative executable path; both fields are required non-empty strings. */
|
|
440
441
|
function validatePassthrough(raw, path, issue) {
|
|
441
442
|
if (!isRecord(raw)) {
|
|
442
443
|
issue('command_node_invalid', 'passthrough must be an object', typeName(raw), 'an object { bin, installHint }', 'Fix or remove passthrough.', path);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { realpathSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
-
import { resolve, sep } from 'node:path';
|
|
2
|
+
import { isAbsolute, resolve, sep } from 'node:path';
|
|
3
3
|
import { listInstalledPlugins, listInstalledPluginsInRoot } from '../resolver.js';
|
|
4
4
|
import { projectScopeRoots } from '../scope.js';
|
|
5
5
|
import { validateCommandManifest } from '../command-manifests/manifest.js';
|
|
@@ -82,6 +82,38 @@ function validateExecExecutable(plugin, transport, manifest, issues) {
|
|
|
82
82
|
}
|
|
83
83
|
return { kind: 'exec', executable };
|
|
84
84
|
}
|
|
85
|
+
function namesPath(bin) {
|
|
86
|
+
return isAbsolute(bin) || bin.startsWith('.') || bin.includes('/') || bin.includes('\\');
|
|
87
|
+
}
|
|
88
|
+
/** Resolve plugin-owned passthrough programs during discovery. Bare program
|
|
89
|
+
* names intentionally remain untouched so spawn resolves them through PATH. */
|
|
90
|
+
function resolvePassthroughBinaries(plugin, manifest, issues) {
|
|
91
|
+
let valid = true;
|
|
92
|
+
const visit = (node) => {
|
|
93
|
+
if (node.kind !== 'branch')
|
|
94
|
+
return;
|
|
95
|
+
const passthrough = node.passthrough;
|
|
96
|
+
if (passthrough !== undefined && namesPath(passthrough.bin)) {
|
|
97
|
+
const executable = safePath(plugin.root, passthrough.bin);
|
|
98
|
+
if (executable === null) {
|
|
99
|
+
issues.push({ code: 'command_path_unsafe', plugin: plugin.name, message: 'passthrough binary escapes the plugin root or is not a regular file', received: passthrough.bin, expected: 'a relative path to a regular file inside the plugin root', next: 'Fix passthrough.bin in commands.json.', path: 'passthrough.bin' });
|
|
100
|
+
valid = false;
|
|
101
|
+
}
|
|
102
|
+
else if ((statSync(executable).mode & 0o111) === 0) {
|
|
103
|
+
issues.push({ code: 'command_not_executable', plugin: plugin.name, message: 'passthrough binary lacks the POSIX exec bit', received: passthrough.bin, expected: 'a file with an executable permission bit', next: 'chmod +x the plugin executable, then update commands.json.', path: 'passthrough.bin' });
|
|
104
|
+
valid = false;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
node.passthrough = { ...passthrough, bin: executable };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const child of node.children)
|
|
111
|
+
visit(child);
|
|
112
|
+
};
|
|
113
|
+
for (const root of manifest.roots)
|
|
114
|
+
visit(root);
|
|
115
|
+
return valid;
|
|
116
|
+
}
|
|
85
117
|
export function validatePluginCommands(plugin, reservedNames = new Set(), coreCommandPaths) {
|
|
86
118
|
const issues = [];
|
|
87
119
|
const commands = plugin.manifest.commands;
|
|
@@ -131,6 +163,9 @@ export function validatePluginCommands(plugin, reservedNames = new Set(), coreCo
|
|
|
131
163
|
: transport;
|
|
132
164
|
if (resolvedTransport === undefined)
|
|
133
165
|
return { plugin, manifestPath, transport, contributions: [], issues };
|
|
166
|
+
if (resolvedTransport.kind === 'exec' && !resolvePassthroughBinaries(plugin, validation.manifest, issues)) {
|
|
167
|
+
return { plugin, manifestPath, transport: resolvedTransport, contributions: [], issues };
|
|
168
|
+
}
|
|
134
169
|
const roots = validation.manifest.roots;
|
|
135
170
|
if (!roots.every((node) => node.kind === 'branch')) {
|
|
136
171
|
throw new Error('plugin manifest validation returned a top-level leaf');
|
package/dist/core/command.d.ts
CHANGED
|
@@ -82,7 +82,8 @@ export interface BranchDef {
|
|
|
82
82
|
/** Opt this branch out of the tree model entirely: every token after this
|
|
83
83
|
* branch's name is forwarded VERBATIM (raw argv, not the `--json`-filtered
|
|
84
84
|
* tokens) to an external binary via spawn, with stdio inherited and the
|
|
85
|
-
* child's exit code propagated.
|
|
85
|
+
* child's exit code propagated. Plugin-owned paths have already resolved to
|
|
86
|
+
* absolute executable paths; bare program names resolve through PATH. A deliberate, documented exception for
|
|
86
87
|
* wrapping an external CLI whose own schema crtr cannot and must not
|
|
87
88
|
* duplicate (the official marketplace's `capture` plugin mounts `crtr
|
|
88
89
|
* capture` this way) — not a general escape hatch. A
|
package/dist/core/git.js
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { network } from './errors.js';
|
|
3
|
+
/** Git takes `.git/index.lock` to refresh the index stat cache even during
|
|
4
|
+
* otherwise read-only commands — `status` is the common one. Killed between
|
|
5
|
+
* creating that lock and renaming it over the index, the lock orphans and
|
|
6
|
+
* blocks every later writer in the checkout with no owning process left to
|
|
7
|
+
* name. Agent processes are killed mid-command routinely (a closed node, a
|
|
8
|
+
* cancelled daemon tick, a timed-out shell), so the residue accumulates.
|
|
9
|
+
*
|
|
10
|
+
* `GIT_OPTIONAL_LOCKS=0` suppresses exactly those optional locks and nothing
|
|
11
|
+
* else: a commit, rebase, or `add` still takes the lock it requires, and
|
|
12
|
+
* command output is byte-identical. Built per call rather than snapshotted at
|
|
13
|
+
* module load, so a later change to the ambient environment still reaches git. */
|
|
14
|
+
function gitEnv(base = process.env) {
|
|
15
|
+
return { ...base, GIT_OPTIONAL_LOCKS: '0' };
|
|
16
|
+
}
|
|
3
17
|
export function gitSync(args, cwd, input, options = {}) {
|
|
4
|
-
const res = spawnSync('git', args, { cwd, encoding: 'utf8', input, timeout: options.timeoutMs });
|
|
18
|
+
const res = spawnSync('git', args, { cwd, encoding: 'utf8', input, env: gitEnv(), timeout: options.timeoutMs });
|
|
5
19
|
const status = typeof res.status === 'number' ? res.status : 1;
|
|
6
20
|
const completed = typeof res.status === 'number';
|
|
7
21
|
const stdout = typeof res.stdout === 'string' ? res.stdout : '';
|
|
@@ -12,7 +26,7 @@ export function gitSync(args, cwd, input, options = {}) {
|
|
|
12
26
|
}
|
|
13
27
|
export async function gitAsync(args, cwd, input, options = {}) {
|
|
14
28
|
return new Promise((resolve) => {
|
|
15
|
-
const child = spawn('git', args, { cwd,
|
|
29
|
+
const child = spawn('git', args, { cwd, env: gitEnv(options.env) });
|
|
16
30
|
if (input === undefined)
|
|
17
31
|
child.stdin.end();
|
|
18
32
|
else
|
|
@@ -9,6 +9,7 @@ const SLOT_RULES = `Component contract
|
|
|
9
9
|
- \`id\` is required, must match \`^[A-Za-z0-9_-]{1,64}$\`, and must be unique across the page.
|
|
10
10
|
- When a page has two or more response-bearing components, every one needs a nonempty \`label\`.
|
|
11
11
|
- Config objects and every documented nested object reject unknown fields.
|
|
12
|
+
- A quoted JSX attribute does not interpret escape sequences, so \`body="one.\\n\\ntwo."\` reaches the reader as one run-on paragraph with a literal backslash-n in the middle of it. Write any markdown body that has a line or paragraph break as a braced string — \`body={"one.\\n\\ntwo."}\` — where the escape is real.
|
|
12
13
|
- The page is rendered once at submit to build the ticket manifest, so every prop reaches it whatever its shape — mapped data, a helper component, a value from \`useState\`. Event handlers are dropped, because a manifest is JSON. The user's answer lands in \`response.json\` keyed by this \`id\`.`;
|
|
13
14
|
const COMMENT_SHAPE = `Comments are \`{id:string (nonempty), text:string, anchor:Anchor}\` and qualify the answer itself. \`Anchor\` is \`{kind:"option",optionId:string}\` or \`{kind:"row",rowId:string}\`; each component accepts only the anchors named next. The reader writes one against a specific option or row in whatever host they read in — in the terminal inbox, \`c\` on the selected one — so every option and row needs a label that reads on its own. Feedback on the page's own prose is a separate channel, offered only by hosts that render the page, and never part of a response.`;
|
|
14
15
|
const DISPLAY_RULES = `Display-only: it is not recorded in the page manifest and contributes nothing to the response. Every display component also takes \`className\` for Tailwind utilities, and standard shadcn props otherwise.`;
|
|
@@ -92,7 +93,8 @@ Response rules
|
|
|
92
93
|
|
|
93
94
|
Example
|
|
94
95
|
\`\`\`jsx
|
|
95
|
-
<UserQuestion id="lane" label="Release lane"
|
|
96
|
+
<UserQuestion id="lane" label="Release lane" mode="single" allowFreetext
|
|
97
|
+
body={"The audit closes tomorrow.\\n\\nBoth release gates are green as of this morning."}
|
|
96
98
|
freetextLabel="Something else"
|
|
97
99
|
options={[
|
|
98
100
|
{ id: 'now', label: 'Ship now', description: 'Both gates are green.', recommended: true },
|
|
@@ -57,8 +57,8 @@ function directoryListingLines(cwd) {
|
|
|
57
57
|
return ['Directory listing: unavailable (cwd missing or unreadable).'];
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
-
function runCommand(cwd, cmd, args) {
|
|
61
|
-
const result = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
|
|
60
|
+
function runCommand(cwd, cmd, args, env) {
|
|
61
|
+
const result = spawnSync(cmd, args, { cwd, encoding: 'utf8', ...(env === undefined ? {} : { env }) });
|
|
62
62
|
if (result.error !== undefined) {
|
|
63
63
|
return { ok: false, stdout: '', stderr: result.error.message };
|
|
64
64
|
}
|
|
@@ -68,8 +68,17 @@ function runCommand(cwd, cmd, args) {
|
|
|
68
68
|
stderr: (result.stderr ?? '').trim(),
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
|
+
/** This snapshot renders on every node boot and every revive, which makes it the
|
|
72
|
+
* most frequent git caller in the runtime — and boot is exactly when a process
|
|
73
|
+
* gets killed. `git status` would take `.git/index.lock` to refresh the stat
|
|
74
|
+
* cache; suppressing that optional lock keeps an interrupted render from
|
|
75
|
+
* orphaning a lock that then blocks every writer in the user's checkout.
|
|
76
|
+
* Only optional locks are affected, and the output is unchanged. */
|
|
77
|
+
function gitEnv() {
|
|
78
|
+
return { ...process.env, GIT_OPTIONAL_LOCKS: '0' };
|
|
79
|
+
}
|
|
71
80
|
function runGit(cwd, args) {
|
|
72
|
-
const result = runCommand(cwd, 'git', args);
|
|
81
|
+
const result = runCommand(cwd, 'git', args, gitEnv());
|
|
73
82
|
return result.ok ? result.stdout : '';
|
|
74
83
|
}
|
|
75
84
|
function parseWorktrees(lines) {
|
|
@@ -98,7 +107,7 @@ function parseWorktrees(lines) {
|
|
|
98
107
|
* benign state. Every other probe here is genuinely optional and already
|
|
99
108
|
* falls back to 'unknown'/'HEAD'/a zero count on failure, which stays as-is. */
|
|
100
109
|
function gitSnapshotLines(cwd) {
|
|
101
|
-
const probe = runCommand(cwd, 'git', ['rev-parse', '--is-inside-work-tree']);
|
|
110
|
+
const probe = runCommand(cwd, 'git', ['rev-parse', '--is-inside-work-tree'], gitEnv());
|
|
102
111
|
if (!probe.ok) {
|
|
103
112
|
if (/not a git repository/i.test(probe.stderr)) {
|
|
104
113
|
return ['Git: not a git repository.'];
|
|
@@ -33,4 +33,6 @@ export interface CloseNodeResult {
|
|
|
33
33
|
export declare function closeNode(rootId: string, opts?: {
|
|
34
34
|
rootEvent?: 'cancel' | 'finish';
|
|
35
35
|
callerPid?: number;
|
|
36
|
+
/** Daemon-owned close work that must drain before this daemon exits. */
|
|
37
|
+
registerDetached?: (work: Promise<void>) => void;
|
|
36
38
|
}): CloseNodeResult;
|
|
@@ -36,6 +36,10 @@ import { cancelCronsOnWake, getNode, subscriptionsOf, subscribersOf, } from '../
|
|
|
36
36
|
import { transition } from './lifecycle.js';
|
|
37
37
|
import { requestBrokerTeardown } from './host.js';
|
|
38
38
|
import { tearDownNode, reapIfEmpty } from './placement.js';
|
|
39
|
+
import { crtrHome, contextDir, jobDir, nodeDir, reportsDir } from '../canvas/paths.js';
|
|
40
|
+
import { discoverLifecycleHookRegistry } from '../command-hooks/discovery.js';
|
|
41
|
+
import { invokeLifecycleHooks } from '../command-hooks/transport/exec-lifecycle.js';
|
|
42
|
+
import { emitEvent } from '../events/emit.js';
|
|
39
43
|
import { appendInbox } from '../feed/inbox.js';
|
|
40
44
|
import { appendPassive } from '../feed/passive.js';
|
|
41
45
|
/** Fan a lifecycle or birth notice to every given subscriber. Active subscribers
|
|
@@ -173,12 +177,50 @@ export function closeNode(rootId, opts = {}) {
|
|
|
173
177
|
// Surviving managers captured BEFORE any teardown — a reap (below) deletes
|
|
174
178
|
// this node's edges, so the step-4 fan-out must read them up front.
|
|
175
179
|
const survivors = subscribersOf(id).filter((s) => !closing.has(s.node_id));
|
|
180
|
+
// Capture the target-scoped hook plan and payload before an empty node's
|
|
181
|
+
// reap can remove its row and directories. The executable still runs from
|
|
182
|
+
// its plugin root; the payload paths can therefore name a reaped directory.
|
|
183
|
+
let afterTeardown;
|
|
184
|
+
try {
|
|
185
|
+
const hooks = discoverLifecycleHookRegistry(m.cwd, m.profile_id).plans.get('node:close') ?? [];
|
|
186
|
+
if (hooks.length > 0) {
|
|
187
|
+
const invocation = {
|
|
188
|
+
node: {
|
|
189
|
+
id,
|
|
190
|
+
name: m.name,
|
|
191
|
+
kind: m.kind,
|
|
192
|
+
mode: m.mode,
|
|
193
|
+
lifecycle: m.lifecycle,
|
|
194
|
+
cwd: m.cwd,
|
|
195
|
+
nodeDir: nodeDir(id),
|
|
196
|
+
contextDir: contextDir(id),
|
|
197
|
+
jobDir: jobDir(id),
|
|
198
|
+
reportsDir: reportsDir(id),
|
|
199
|
+
},
|
|
200
|
+
runtime: { isBirth: false, canvasHome: crtrHome(), profile: m.profile_id ?? null },
|
|
201
|
+
};
|
|
202
|
+
afterTeardown = () => invokeLifecycleHooks(hooks, invocation);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
emitEvent({ level: 'error', event: 'node.close_hook.discovery_failed', node_id: id, error });
|
|
207
|
+
}
|
|
208
|
+
// Capture the process snapshot while the row exists, then execute after
|
|
209
|
+
// the response settles. A failed teardown skips the lifecycle hook rather
|
|
210
|
+
// than claiming cleanup was safe.
|
|
211
|
+
const teardown = requestBrokerTeardown(id, id === rootId ? { excludePid: opts.callerPid } : {}, afterTeardown);
|
|
212
|
+
try {
|
|
213
|
+
opts.registerDetached?.(teardown.then(() => { }));
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
emitEvent({ level: 'error', event: 'node.close_hook.detached_registration_failed', node_id: id, error });
|
|
217
|
+
}
|
|
176
218
|
// 0) A node with no substantive assistant output is a useless shell — don't
|
|
177
219
|
// park it as a canceled husk; reap it outright (engine + viewer + row +
|
|
178
|
-
// dir).
|
|
179
|
-
// skip the cancel transition + resume notice (the node
|
|
180
|
-
// fan the "child gone" wake out to
|
|
181
|
-
if (!reapIfEmpty(id)) {
|
|
220
|
+
// dir). Its teardown was queued above from the preserved snapshot; when
|
|
221
|
+
// reaping fires we skip the cancel transition + resume notice (the node
|
|
222
|
+
// is gone) but still fan the "child gone" wake out to survivors below.
|
|
223
|
+
if (!reapIfEmpty(id, undefined, false)) {
|
|
182
224
|
// 1) Terminal status set BEFORE the window dies (daemon race). The root may
|
|
183
225
|
// finish to `done` (a deliberate "mark complete" close-out); every
|
|
184
226
|
// descendant, and the root by default, `cancel`s. finish is legal only
|
|
@@ -199,9 +241,9 @@ export function closeNode(rootId, opts = {}) {
|
|
|
199
241
|
else {
|
|
200
242
|
transition(id, 'cancel', { reason: 'closed' });
|
|
201
243
|
}
|
|
202
|
-
// 2)
|
|
203
|
-
//
|
|
204
|
-
|
|
244
|
+
// 2) The daemon-owned teardown was queued above after capturing this
|
|
245
|
+
// node's process tree. The root caller's shell was excluded from its
|
|
246
|
+
// own broker tree before the response can settle.
|
|
205
247
|
tearDownNode(id);
|
|
206
248
|
// 3) Leave the resume notice AFTER the watcher is gone, so it survives.
|
|
207
249
|
appendInbox(id, {
|
|
@@ -226,8 +268,8 @@ export function closeNode(rootId, opts = {}) {
|
|
|
226
268
|
fanDoctrineWake(id, survivors, `Child closed — ${m.name} (${id}) was closed from the canvas and is no longer running.`, { reason: 'child-closed', child: id });
|
|
227
269
|
closed.push(id);
|
|
228
270
|
}
|
|
229
|
-
catch {
|
|
230
|
-
|
|
271
|
+
catch (error) {
|
|
272
|
+
emitEvent({ level: 'error', event: 'node.close.failed', node_id: id, error });
|
|
231
273
|
}
|
|
232
274
|
}
|
|
233
275
|
return { root: rootId, closed, spared };
|
|
@@ -25,6 +25,13 @@ export interface HostHandle {
|
|
|
25
25
|
export interface TeardownOptions {
|
|
26
26
|
excludePid?: number;
|
|
27
27
|
}
|
|
28
|
+
/** The only completion signal a caller may use for post-teardown work. A false
|
|
29
|
+
* result means the original engine tree could not be confirmed dead, so no
|
|
30
|
+
* cleanup may claim it was safe to remove resources it owned. */
|
|
31
|
+
export interface TeardownResult {
|
|
32
|
+
confirmed: boolean;
|
|
33
|
+
error?: Error;
|
|
34
|
+
}
|
|
28
35
|
export interface Host {
|
|
29
36
|
/** Bring a node's ENGINE into existence from its launch recipe; return a
|
|
30
37
|
* supervisable handle. */
|
|
@@ -33,8 +40,9 @@ export interface Host {
|
|
|
33
40
|
* both call sites — the revive guard (passes the id) and the daemon (passes a
|
|
34
41
|
* row) — share one selector. For the broker this IS isPidAlive(pi_pid). */
|
|
35
42
|
isAlive(node: string | NodeRow): boolean;
|
|
36
|
-
/** Tear the engine down (close/cancel teardown).
|
|
37
|
-
|
|
43
|
+
/** Tear the engine down (close/cancel teardown). Resolves only after the
|
|
44
|
+
* fixed process snapshot is confirmed dead, or with a diagnostic failure. */
|
|
45
|
+
teardown(nodeId: string, opts?: TeardownOptions): Promise<TeardownResult>;
|
|
38
46
|
/** Deliver an OS signal to the engine container — present so the daemon never
|
|
39
47
|
* reaches around the abstraction. */
|
|
40
48
|
signal(nodeId: string, sig: NodeJS.Signals): void;
|
|
@@ -43,7 +51,7 @@ export interface Host {
|
|
|
43
51
|
* the fast-fail seam for known module/path errors: a bad engine resolve or a
|
|
44
52
|
* missing plain session path throws here, before spawn(). */
|
|
45
53
|
export declare function preflightBrokerLaunch(nodeId: string, inv: PiInvocation, cwd: string): void;
|
|
46
|
-
/** Queue broker teardown
|
|
47
|
-
*
|
|
48
|
-
export declare function requestBrokerTeardown(nodeId: string, opts?: TeardownOptions):
|
|
54
|
+
/** Queue broker teardown after the close response settles. Completion includes
|
|
55
|
+
* any post-teardown work, which lets the daemon drain it during shutdown. */
|
|
56
|
+
export declare function requestBrokerTeardown(nodeId: string, opts?: TeardownOptions, after?: () => Promise<void>): Promise<TeardownResult>;
|
|
49
57
|
export declare const headlessBrokerHost: Host;
|
|
@@ -128,84 +128,153 @@ const BROKER_SIGKILL_GRACE_MS = 2_000;
|
|
|
128
128
|
* full grace ceilings above are reserved for the genuinely-wedged case,
|
|
129
129
|
* where the added latency is the whole point. */
|
|
130
130
|
const TEARDOWN_POLL_MS = 150;
|
|
131
|
-
/**
|
|
132
|
-
*
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
* wait) is what keeps the happy path cheap despite being ref'd. */
|
|
143
|
-
function pollUntil(isAlive, timeoutMs, onTimeout, hardCeilingMs = timeoutMs + 5_000) {
|
|
144
|
-
const start = Date.now();
|
|
145
|
-
let escalated = false;
|
|
146
|
-
const tick = () => {
|
|
147
|
-
if (!isAlive())
|
|
148
|
-
return; // confirmed dead — stop polling
|
|
149
|
-
const elapsed = Date.now() - start;
|
|
150
|
-
if (!escalated && elapsed >= timeoutMs) {
|
|
151
|
-
escalated = true;
|
|
152
|
-
onTimeout();
|
|
153
|
-
}
|
|
154
|
-
if (elapsed >= hardCeilingMs)
|
|
155
|
-
return; // best-effort exhausted — give up
|
|
156
|
-
setTimeout(tick, TEARDOWN_POLL_MS);
|
|
157
|
-
};
|
|
158
|
-
tick();
|
|
131
|
+
/** Wait against the fixed snapshot. The ref'd timer keeps a short-lived
|
|
132
|
+
* caller alive through the final SIGKILL confirmation. */
|
|
133
|
+
async function waitForTreeExit(tree, timeoutMs) {
|
|
134
|
+
const deadline = Date.now() + timeoutMs;
|
|
135
|
+
while (isAnyPidAlive(tree)) {
|
|
136
|
+
const remaining = deadline - Date.now();
|
|
137
|
+
if (remaining <= 0)
|
|
138
|
+
return false;
|
|
139
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(TEARDOWN_POLL_MS, remaining)));
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
159
142
|
}
|
|
160
|
-
/**
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
* escalation path (connect-error or post-shutdown-frame) can run, and
|
|
169
|
-
* threaded through here unchanged. This
|
|
170
|
-
* function must NEVER re-walk the process tree itself: `launch()` spawns the
|
|
171
|
-
* broker `detached: true`, so
|
|
172
|
-
* signaling the broker's OWN group (the first thing `killProcessTreePids`
|
|
173
|
-
* does) typically kills the broker itself almost immediately — and the
|
|
174
|
-
* kernel reparents any surviving child away from the broker's pid the
|
|
175
|
-
* INSTANT the broker exits, severing the very ppid link a fresh
|
|
176
|
-
* `descendantPids` walk depends on. Re-deriving descendants at ANY point
|
|
177
|
-
* after the broker may have exited (including a per-rung re-walk here, or a
|
|
178
|
-
* poll that re-derives instead of checking this fixed list) would silently
|
|
179
|
-
* "lose" a still-alive detached descendant — e.g. the pi bash tool's shell
|
|
180
|
-
* child, itself `detached: true` and thus its own group leader, distinct
|
|
181
|
-
* from the broker's group, so a plain `kill(-brokerPid)` never reaches it
|
|
182
|
-
* either. `identities` guards each signal against
|
|
183
|
-
* PID REUSE across the multi-second escalation window —
|
|
184
|
-
* see `killProcessTreePids`. `null` means the initial `captureTeardownSnapshot`
|
|
185
|
-
* probe itself failed (no baseline to compare against) — `killProcessTreePids`
|
|
186
|
-
* falls through to its normal unguarded signal path in that case, never
|
|
187
|
-
* reading a probe failure as proof the pid is gone or reused. */
|
|
188
|
-
function escalateBrokerTeardown(tree, identities) {
|
|
143
|
+
/** Finish one teardown using the snapshot captured before any signal can sever
|
|
144
|
+
* broker parentage. A failed confirmation is explicit so post-teardown cleanup
|
|
145
|
+
* never runs against an engine tree that may still own its resources. */
|
|
146
|
+
async function terminateBrokerTree(tree, identities, graceful) {
|
|
147
|
+
if (tree.length === 0)
|
|
148
|
+
return { confirmed: true };
|
|
149
|
+
if (graceful && await waitForTreeExit(tree, BROKER_SHUTDOWN_GRACE_MS))
|
|
150
|
+
return { confirmed: true };
|
|
189
151
|
killProcessTreePids(tree, 'SIGTERM', identities);
|
|
190
|
-
|
|
152
|
+
if (await waitForTreeExit(tree, BROKER_SIGKILL_GRACE_MS))
|
|
153
|
+
return { confirmed: true };
|
|
154
|
+
killProcessTreePids(tree, 'SIGKILL', identities);
|
|
155
|
+
if (await waitForTreeExit(tree, 5_000))
|
|
156
|
+
return { confirmed: true };
|
|
157
|
+
return { confirmed: false, error: new Error(`broker process tree for ${tree.join(', ')} remained alive after SIGKILL`) };
|
|
158
|
+
}
|
|
159
|
+
/** Capture the one authoritative process snapshot before a caller can reap the
|
|
160
|
+
* node row. No signal or socket operation occurs here; execution remains queued
|
|
161
|
+
* until the close response has settled. */
|
|
162
|
+
function prepareBrokerTeardown(nodeId, opts) {
|
|
163
|
+
const node = getNode(nodeId);
|
|
164
|
+
const pid = node?.pi_pid;
|
|
165
|
+
const snapshot = pid != null
|
|
166
|
+
? captureTeardownSnapshot(pid, node?.pi_pid_identity ?? null, opts.excludePid)
|
|
167
|
+
: null;
|
|
168
|
+
if (snapshot?.reused === true) {
|
|
169
|
+
emitEvent({
|
|
170
|
+
level: 'warn',
|
|
171
|
+
event: 'broker.teardown.pid_reuse_refused',
|
|
172
|
+
node_id: nodeId,
|
|
173
|
+
fields: { recorded_pid: pid, expected_identity: node?.pi_pid_identity ?? null },
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
tree: snapshot?.tree ?? [],
|
|
178
|
+
identities: snapshot?.identities ?? null,
|
|
179
|
+
sockPath: viewSocketPath(nodeId),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
async function runPreparedBrokerTeardown(prepared) {
|
|
183
|
+
// Even a dead broker pid can still be the process-group leader for an orphan
|
|
184
|
+
// that the initial ppid walk could no longer reach. The connect-failure path
|
|
185
|
+
// must retain that leader id for group signaling rather than treating its dead
|
|
186
|
+
// status as confirmation that no descendant survives.
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
let connected = false;
|
|
189
|
+
let settled = false;
|
|
190
|
+
const settle = (result) => {
|
|
191
|
+
if (settled)
|
|
192
|
+
return;
|
|
193
|
+
settled = true;
|
|
194
|
+
resolve(result);
|
|
195
|
+
};
|
|
196
|
+
const cleanupSocket = () => {
|
|
197
|
+
try {
|
|
198
|
+
if (existsSync(prepared.sockPath))
|
|
199
|
+
unlinkSync(prepared.sockPath);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
/* best-effort cleanup */
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
const finish = (graceful) => {
|
|
206
|
+
void terminateBrokerTree(prepared.tree, prepared.identities, graceful).then(settle, (error) => settle({ confirmed: false, error: error instanceof Error ? error : new Error(String(error)) }));
|
|
207
|
+
};
|
|
208
|
+
const sock = connect(prepared.sockPath);
|
|
209
|
+
sock.once('error', () => {
|
|
210
|
+
if (connected)
|
|
211
|
+
return;
|
|
212
|
+
cleanupSocket();
|
|
213
|
+
finish(false);
|
|
214
|
+
});
|
|
215
|
+
sock.once('connect', () => {
|
|
216
|
+
connected = true;
|
|
217
|
+
try {
|
|
218
|
+
sock.write(encodeFrame({ type: 'shutdown' }));
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
/* the broker may have raced us to exit */
|
|
222
|
+
}
|
|
223
|
+
sock.end();
|
|
224
|
+
cleanupSocket();
|
|
225
|
+
finish(true);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
191
228
|
}
|
|
192
229
|
const requestedTeardowns = new Map();
|
|
193
|
-
/** Queue broker teardown
|
|
194
|
-
*
|
|
195
|
-
export function requestBrokerTeardown(nodeId, opts = {}) {
|
|
230
|
+
/** Queue broker teardown after the close response settles. Completion includes
|
|
231
|
+
* any post-teardown work, which lets the daemon drain it during shutdown. */
|
|
232
|
+
export function requestBrokerTeardown(nodeId, opts = {}, after) {
|
|
196
233
|
const existing = requestedTeardowns.get(nodeId);
|
|
197
234
|
if (existing !== undefined) {
|
|
198
|
-
if (
|
|
199
|
-
existing.
|
|
200
|
-
return;
|
|
235
|
+
if (after !== undefined)
|
|
236
|
+
existing.after.push(after);
|
|
237
|
+
return existing.completion;
|
|
238
|
+
}
|
|
239
|
+
let resolve;
|
|
240
|
+
const completion = new Promise((done) => { resolve = done; });
|
|
241
|
+
const request = { completion, resolve, after: after === undefined ? [] : [after] };
|
|
242
|
+
requestedTeardowns.set(nodeId, request);
|
|
243
|
+
let prepared;
|
|
244
|
+
try {
|
|
245
|
+
prepared = prepareBrokerTeardown(nodeId, opts);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
const failure = { confirmed: false, error: error instanceof Error ? error : new Error(String(error)) };
|
|
249
|
+
requestedTeardowns.delete(nodeId);
|
|
250
|
+
emitEvent({ level: 'error', event: 'broker.teardown.unconfirmed', node_id: nodeId, error: failure.error });
|
|
251
|
+
request.resolve(failure);
|
|
252
|
+
return completion;
|
|
201
253
|
}
|
|
202
|
-
requestedTeardowns.set(nodeId, { ...opts });
|
|
203
254
|
setImmediate(() => {
|
|
204
|
-
const request = requestedTeardowns.get(nodeId);
|
|
205
255
|
requestedTeardowns.delete(nodeId);
|
|
206
|
-
|
|
207
|
-
|
|
256
|
+
void runPreparedBrokerTeardown(prepared).then(async (result) => {
|
|
257
|
+
if (result.confirmed) {
|
|
258
|
+
for (const callback of request.after) {
|
|
259
|
+
try {
|
|
260
|
+
await callback();
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
emitEvent({ level: 'error', event: 'broker.teardown.after_failed', node_id: nodeId, error });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
emitEvent({ level: 'error', event: 'broker.teardown.unconfirmed', node_id: nodeId, error: result.error });
|
|
269
|
+
}
|
|
270
|
+
request.resolve(result);
|
|
271
|
+
}, (error) => {
|
|
272
|
+
const failure = { confirmed: false, error: error instanceof Error ? error : new Error(String(error)) };
|
|
273
|
+
emitEvent({ level: 'error', event: 'broker.teardown.unconfirmed', node_id: nodeId, error: failure.error });
|
|
274
|
+
request.resolve(failure);
|
|
275
|
+
});
|
|
208
276
|
});
|
|
277
|
+
return completion;
|
|
209
278
|
}
|
|
210
279
|
export const headlessBrokerHost = {
|
|
211
280
|
launch(nodeId, inv, opts) {
|
|
@@ -364,117 +433,15 @@ export const headlessBrokerHost = {
|
|
|
364
433
|
return isPidAlive((typeof node === 'string' ? getNode(node) : node)?.pi_pid);
|
|
365
434
|
},
|
|
366
435
|
teardown(nodeId, opts = {}) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
//
|
|
375
|
-
// Capture the broker pid ONCE, synchronously, right here — never re-query
|
|
376
|
-
// `getNode(nodeId)` inside the async callbacks/timers below. Callers such
|
|
377
|
-
// as placement.ts's `reapIfEmpty()` call `teardown()` then immediately
|
|
378
|
-
// `deleteNode()` in the same tick; by the time an async callback fires the
|
|
379
|
-
// row can already be gone, so a re-query would silently resolve to
|
|
380
|
-
// `undefined` and skip escalation entirely.
|
|
381
|
-
const node = getNode(nodeId);
|
|
382
|
-
const pid = node?.pi_pid;
|
|
383
|
-
// Snapshot the full descendant TREE — the
|
|
384
|
-
// root pid plus every transitive descendant — and a stable identity
|
|
385
|
-
// fingerprint for every pid in it, BOTH from ONE `ps`
|
|
386
|
-
// table read (`captureTeardownSnapshot`, final review: closes the small
|
|
387
|
-
// race between two separate probes), exactly ONCE, right here, while the
|
|
388
|
-
// broker (if alive) still holds parentage over any detached descendant.
|
|
389
|
-
// EVERY escalation path below — connect-error AND post-shutdown-frame —
|
|
390
|
-
// reuses this ONE fixed snapshot; neither may re-walk `ps` by ppid once
|
|
391
|
-
// the broker might have exited, because the kernel reparents any
|
|
392
|
-
// surviving child away from `pid` the INSTANT the broker exits, severing
|
|
393
|
-
// the ppid link a fresh walk depends on — exactly the reparenting hazard
|
|
394
|
-
// this fix closes. (If the broker is ALREADY dead when `teardown()`
|
|
395
|
-
// starts, its detached descendants were already reparented before we ever
|
|
396
|
-
// got here — unrecoverable by ppid, and out of scope: the #98 repro is
|
|
397
|
-
// the wedged-but-ALIVE broker, which this covers.)
|
|
398
|
-
//
|
|
399
|
-
// `node?.pi_pid_identity` is the LAUNCH-time baseline `recordPid` captured
|
|
400
|
-
// the instant this broker was spawned/bound (final review, Fix 1): if
|
|
401
|
-
// `pid` has ALREADY been recycled for an unrelated process before
|
|
402
|
-
// teardown ever ran — the pid-reuse hazard this guard exists for — the
|
|
403
|
-
// snapshot's `reused: true` comes back with an EMPTY tree, so nothing
|
|
404
|
-
// below ever signals the stranger (or walks its process tree looking for
|
|
405
|
-
// "descendants", which would be the stranger's children, not ours).
|
|
406
|
-
const snapshot = pid != null ? captureTeardownSnapshot(pid, node?.pi_pid_identity ?? null, opts.excludePid) : null;
|
|
407
|
-
if (snapshot?.reused === true) {
|
|
408
|
-
emitEvent({
|
|
409
|
-
level: 'warn',
|
|
410
|
-
event: 'broker.teardown.pid_reuse_refused',
|
|
411
|
-
node_id: nodeId,
|
|
412
|
-
fields: {
|
|
413
|
-
recorded_pid: pid,
|
|
414
|
-
expected_identity: node?.pi_pid_identity ?? null,
|
|
415
|
-
},
|
|
436
|
+
try {
|
|
437
|
+
return runPreparedBrokerTeardown(prepareBrokerTeardown(nodeId, opts));
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
return Promise.resolve({
|
|
441
|
+
confirmed: false,
|
|
442
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
416
443
|
});
|
|
417
444
|
}
|
|
418
|
-
const tree = snapshot?.tree ?? [];
|
|
419
|
-
const identities = snapshot?.identities ?? null;
|
|
420
|
-
const sockPath = viewSocketPath(nodeId);
|
|
421
|
-
const cleanupSocket = () => {
|
|
422
|
-
try {
|
|
423
|
-
if (existsSync(sockPath))
|
|
424
|
-
unlinkSync(sockPath);
|
|
425
|
-
}
|
|
426
|
-
catch {
|
|
427
|
-
/* best-effort cleanup */
|
|
428
|
-
}
|
|
429
|
-
};
|
|
430
|
-
const sock = connect(sockPath);
|
|
431
|
-
let connected = false;
|
|
432
|
-
sock.on('error', () => {
|
|
433
|
-
// A post-connect error (broker exiting after we sent shutdown) is benign
|
|
434
|
-
// and swallowed; only a connect failure triggers the escalation fallback.
|
|
435
|
-
if (connected)
|
|
436
|
-
return;
|
|
437
|
-
if (tree.length > 0)
|
|
438
|
-
escalateBrokerTeardown(tree, identities);
|
|
439
|
-
cleanupSocket();
|
|
440
|
-
});
|
|
441
|
-
sock.once('connect', () => {
|
|
442
|
-
connected = true;
|
|
443
|
-
try {
|
|
444
|
-
sock.write(encodeFrame({ type: 'shutdown' }));
|
|
445
|
-
}
|
|
446
|
-
catch {
|
|
447
|
-
/* the broker may have raced us to exit */
|
|
448
|
-
}
|
|
449
|
-
sock.end();
|
|
450
|
-
cleanupSocket();
|
|
451
|
-
// Bounded exit confirmation: a broker that connects fine but then HANGS
|
|
452
|
-
// inside session.dispose() (disposeAndExit catches a throw, not a hang)
|
|
453
|
-
// would leak the process holding the sole .jsonl writer — and any
|
|
454
|
-
// bash→test→app descendants it spawned along the way. Poll
|
|
455
|
-
// the whole process TREE (not just the broker's own pid) for up to
|
|
456
|
-
// `BROKER_SHUTDOWN_GRACE_MS`, UNCONDITIONAL on the broker's own liveness:
|
|
457
|
-
// a broker that exits cleanly can still
|
|
458
|
-
// leave a live descendant behind (dispose() partially failed, or never
|
|
459
|
-
// reached a detached SDK child), which is exactly the shape this
|
|
460
|
-
// closes. `pollUntil` is ref'd — not `.unref()`'d — so a short-lived
|
|
461
|
-
// CLI caller stays alive long enough for the SIGKILL rung to actually
|
|
462
|
-
// fire, while the happy path (tree already dead) still pays only a
|
|
463
|
-
// couple of poll ticks.
|
|
464
|
-
//
|
|
465
|
-
// Checks `isAnyPidAlive(tree)` against the FIXED pre-signal snapshot,
|
|
466
|
-
// NEVER a fresh `isProcessTreeAlive`/`descendantPids` re-walk: the broker
|
|
467
|
-
// can exit at any point during this
|
|
468
|
-
// grace window while leaving a detached descendant alive, and the
|
|
469
|
-
// instant it does, the kernel reparents that descendant away from `pid`
|
|
470
|
-
// — a re-walk-by-ppid would then find nothing and silently skip
|
|
471
|
-
// escalation, even though the descendant is still very much alive. The
|
|
472
|
-
// fixed `tree` snapshot (taken before the shutdown frame was even sent)
|
|
473
|
-
// has no such blind spot.
|
|
474
|
-
if (tree.length > 0) {
|
|
475
|
-
pollUntil(() => isAnyPidAlive(tree), BROKER_SHUTDOWN_GRACE_MS, () => escalateBrokerTeardown(tree, identities));
|
|
476
|
-
}
|
|
477
|
-
});
|
|
478
445
|
},
|
|
479
446
|
signal(nodeId, sig) {
|
|
480
447
|
const pid = getNode(nodeId)?.pi_pid;
|
|
@@ -86,7 +86,7 @@ export declare function detachToBackground(nodeId: string, pane?: string): boole
|
|
|
86
86
|
* its node identity so the worktree remains owned and can later be closed.
|
|
87
87
|
* Returns true when it reaped. No-op (false) on a node that produced output,
|
|
88
88
|
* is mid-first-turn, owns an open managed worktree, or no longer exists. */
|
|
89
|
-
export declare function reapIfEmpty(nodeId: string, keepPane?: string): boolean;
|
|
89
|
+
export declare function reapIfEmpty(nodeId: string, keepPane?: string, teardown?: boolean): boolean;
|
|
90
90
|
/** Sweep the whole canvas for empty shells and reap them — the bulk cleanup
|
|
91
91
|
* behind `crtr canvas prune --empty`, for the husks that accumulated before
|
|
92
92
|
* close/detach learned to reap. Skips: the caller ($CRTR_NODE_ID), dead rows,
|
|
@@ -342,11 +342,12 @@ function isEmptyNode(nodeId) {
|
|
|
342
342
|
* its node identity so the worktree remains owned and can later be closed.
|
|
343
343
|
* Returns true when it reaped. No-op (false) on a node that produced output,
|
|
344
344
|
* is mid-first-turn, owns an open managed worktree, or no longer exists. */
|
|
345
|
-
export function reapIfEmpty(nodeId, keepPane) {
|
|
345
|
+
export function reapIfEmpty(nodeId, keepPane, teardown = true) {
|
|
346
346
|
const meta = getNode(nodeId);
|
|
347
347
|
if (meta?.managed_worktree?.state === 'open' || !isEmptyNode(nodeId))
|
|
348
348
|
return false;
|
|
349
|
-
|
|
349
|
+
if (teardown)
|
|
350
|
+
void headlessBrokerHost.teardown(nodeId);
|
|
350
351
|
tearDownNode(nodeId, keepPane);
|
|
351
352
|
return deleteNode(nodeId).deleted;
|
|
352
353
|
}
|
|
@@ -47,6 +47,7 @@ import { fullName } from '../../../core/canvas/labels.js';
|
|
|
47
47
|
import { reportsForNode } from './reports.js';
|
|
48
48
|
import { getCron } from '../../../core/canvas/crons.js';
|
|
49
49
|
import { cronWakeOrigin } from '../../../core/runtime/bearings.js';
|
|
50
|
+
import { boundDaemonControl } from '../../control.js';
|
|
50
51
|
import { notFound, usage } from '../../../core/errors.js';
|
|
51
52
|
import { toCloseResultDTO, toNodeDetailDTO, toNodeSummaryDTO, toReviveResultDTO } from '../map.js';
|
|
52
53
|
import { ApiError } from '../../../api/index.js';
|
|
@@ -604,9 +605,11 @@ function handleClose(ctx) {
|
|
|
604
605
|
if (body?.caller_pid !== undefined && (!Number.isInteger(body.caller_pid) || body.caller_pid <= 0)) {
|
|
605
606
|
throw usage('caller_pid must be a positive integer when provided');
|
|
606
607
|
}
|
|
608
|
+
const daemon = boundDaemonControl();
|
|
607
609
|
const result = closeNode(id, {
|
|
608
610
|
rootEvent: body?.finish === true ? 'finish' : 'cancel',
|
|
609
611
|
callerPid: body?.caller_pid,
|
|
612
|
+
...(daemon === null ? {} : { registerDetached: daemon.registerDetached }),
|
|
610
613
|
});
|
|
611
614
|
return { status: 200, body: toCloseResultDTO(result) };
|
|
612
615
|
}
|
package/dist/daemon/control.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface DaemonControl {
|
|
|
12
12
|
requestHandover(): {
|
|
13
13
|
graceMs: number;
|
|
14
14
|
};
|
|
15
|
+
/** Register daemon-owned post-response work for shutdown draining. */
|
|
16
|
+
registerDetached(work: Promise<void>): void;
|
|
15
17
|
}
|
|
16
18
|
/** Bind this process's control surface. Called once, by `runDaemon`. */
|
|
17
19
|
export declare function bindDaemonControl(control: DaemonControl): void;
|
package/dist/daemon/crtrd.js
CHANGED
|
@@ -607,7 +607,7 @@ export async function runDaemon(opts = {}) {
|
|
|
607
607
|
// The self-control seam the API's handover handler reaches this closure
|
|
608
608
|
// through (see control.ts). Bound alongside the fleet so anything that can
|
|
609
609
|
// launch a broker can also hand the fleet over.
|
|
610
|
-
bindDaemonControl({ epoch, requestHandover });
|
|
610
|
+
bindDaemonControl({ epoch, requestHandover, registerDetached });
|
|
611
611
|
operationIdContext.fresh(() => {
|
|
612
612
|
emitEvent({
|
|
613
613
|
level: 'info',
|
package/dist/hook-authoring.d.ts
CHANGED
|
@@ -43,11 +43,11 @@ export interface AfterHookRequest extends HookRequestBase {
|
|
|
43
43
|
export interface ReplaceHookRequest extends HookRequestBase {
|
|
44
44
|
phase: 'replace';
|
|
45
45
|
}
|
|
46
|
-
/** Lifecycle handlers run on every matching event.
|
|
46
|
+
/** Lifecycle handlers run on every matching event. `node:start` handlers must be idempotent because they fire for birth and every revive. */
|
|
47
47
|
export interface LifecycleHookRequest {
|
|
48
48
|
protocolVersion: 1;
|
|
49
49
|
op: string;
|
|
50
|
-
event: 'node:start';
|
|
50
|
+
event: 'node:start' | 'node:close';
|
|
51
51
|
phase: 'on';
|
|
52
52
|
operationId: string;
|
|
53
53
|
node: LifecycleHookNode;
|
package/dist/hook-authoring.js
CHANGED
|
@@ -249,7 +249,7 @@ function validateRequest(value) {
|
|
|
249
249
|
if (value['phase'] === 'on') {
|
|
250
250
|
if (!hasOnlyKeys(value, ['protocolVersion', 'op', 'event', 'phase', 'operationId', 'node', 'runtime', 'context']))
|
|
251
251
|
return invalid('lifecycle request contains unknown fields');
|
|
252
|
-
if (value['event'] !== 'node:start')
|
|
252
|
+
if (value['event'] !== 'node:start' && value['event'] !== 'node:close')
|
|
253
253
|
return invalid('unsupported lifecycle event');
|
|
254
254
|
if (!isLifecycleNode(value['node']))
|
|
255
255
|
return invalid('lifecycle request node contains invalid fields');
|
|
@@ -260,7 +260,7 @@ function validateRequest(value) {
|
|
|
260
260
|
return valid({
|
|
261
261
|
protocolVersion: 1,
|
|
262
262
|
op: value['op'],
|
|
263
|
-
event: '
|
|
263
|
+
event: value['event'],
|
|
264
264
|
phase: 'on',
|
|
265
265
|
operationId: value['operationId'],
|
|
266
266
|
node: value['node'],
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.307",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.307",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"workspaces": [
|
|
@@ -5206,17 +5206,17 @@
|
|
|
5206
5206
|
},
|
|
5207
5207
|
"packages/crouter-api": {
|
|
5208
5208
|
"name": "@north-light/crouter-api",
|
|
5209
|
-
"version": "0.3.
|
|
5209
|
+
"version": "0.3.307",
|
|
5210
5210
|
"license": "UNLICENSED"
|
|
5211
5211
|
},
|
|
5212
5212
|
"packages/crouter-env-docker": {
|
|
5213
5213
|
"name": "@north-light/crouter-env-docker",
|
|
5214
|
-
"version": "0.3.
|
|
5214
|
+
"version": "0.3.307",
|
|
5215
5215
|
"license": "UNLICENSED"
|
|
5216
5216
|
},
|
|
5217
5217
|
"packages/crouter-sdk": {
|
|
5218
5218
|
"name": "@north-light/crouter-sdk",
|
|
5219
|
-
"version": "0.3.
|
|
5219
|
+
"version": "0.3.307",
|
|
5220
5220
|
"license": "UNLICENSED",
|
|
5221
5221
|
"dependencies": {
|
|
5222
5222
|
"@north-light/crouter-api": "^0.3.295"
|