@north-light/crouter 0.3.306 → 0.3.308
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/builtin-pi-packages/pi-crtr-extensions/package-lock.json +635 -163
- package/dist/commands/cron.js +9 -7
- package/dist/core/__tests__/integration/command-plugins.test.js +44 -4
- 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/human/component-docs.js +3 -1
- 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
|
@@ -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.308",
|
|
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.308",
|
|
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.308",
|
|
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.308",
|
|
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.308",
|
|
5220
5220
|
"license": "UNLICENSED",
|
|
5221
5221
|
"dependencies": {
|
|
5222
5222
|
"@north-light/crouter-api": "^0.3.295"
|