@north-light/crouter 0.3.310 → 0.3.312
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/builtin-pi-packages/pi-crtr-extensions/package-lock.json +3 -3
- package/dist/clients/attach/viewer.js +389 -389
- package/dist/commands/__tests__/integration/dashboard-snapshot.test.d.ts +1 -0
- package/dist/commands/__tests__/integration/dashboard-snapshot.test.js +212 -0
- package/dist/commands/api-client.d.ts +1 -0
- package/dist/commands/api-client.js +4 -2
- package/dist/commands/canvas-roster.js +24 -17
- package/dist/commands/dashboard.js +23 -16
- package/dist/core/__tests__/context-intro.test.js +4 -4
- package/dist/core/__tests__/cron-timeout-descendants.test.d.ts +1 -0
- package/dist/core/__tests__/cron-timeout-descendants.test.js +102 -0
- package/dist/core/__tests__/human-deliver.test.js +18 -18
- package/dist/core/__tests__/integration/human-deliver-e2e.test.js +5 -5
- package/dist/core/__tests__/integration/lifecycle-hooks.test.js +299 -1
- package/dist/core/__tests__/integration/spawn-root.test.js +11 -11
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +2 -2
- package/dist/core/__tests__/on-read-dedup-resume.test.js +6 -6
- package/dist/core/__tests__/on-read-nested-store.test.js +6 -6
- package/dist/core/__tests__/profile-project-memory-delivery.test.js +31 -31
- package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -3
- package/dist/core/__tests__/warm-claim-preference-snapshot.test.js +1 -1
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +24 -11
- package/dist/core/canvas/remote-canvas-source.d.ts +4 -0
- package/dist/core/canvas/remote-canvas-source.js +58 -6
- package/dist/core/canvas/render-source.d.ts +4 -4
- package/dist/core/canvas/render-source.js +49 -48
- package/dist/core/canvas/source.d.ts +2 -0
- package/dist/core/canvas/source.js +4 -1
- package/dist/core/command-hooks/artifact.d.ts +6 -0
- package/dist/core/command-hooks/artifact.js +22 -6
- package/dist/core/command-hooks/discovery.d.ts +2 -0
- package/dist/core/command-hooks/discovery.js +2 -0
- package/dist/core/command-hooks/lifecycle-catalog.d.ts +20 -2
- package/dist/core/command-hooks/lifecycle-catalog.js +7 -2
- package/dist/core/command-hooks/lifecycle-create.d.ts +4 -0
- package/dist/core/command-hooks/lifecycle-create.js +51 -0
- package/dist/core/command-hooks/schema.d.ts +2 -2
- package/dist/core/command-hooks/schema.js +5 -4
- package/dist/core/command-hooks/transport/exec-invoke.js +3 -1
- package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +18 -8
- package/dist/core/command-hooks/transport/exec-lifecycle.js +89 -30
- package/dist/core/errors.d.ts +1 -0
- package/dist/core/errors.js +4 -0
- package/dist/core/human/feedback-companion.js +1 -1
- package/dist/core/review/companion.d.ts +1 -1
- package/dist/core/review/companion.js +2 -2
- package/dist/core/review/realize.js +6 -6
- package/dist/core/runtime/nodes.d.ts +5 -1
- package/dist/core/runtime/nodes.js +26 -11
- package/dist/core/runtime/recycle.js +16 -12
- package/dist/core/runtime/reset.js +4 -1
- package/dist/core/runtime/revive.js +13 -12
- package/dist/core/runtime/spawn.d.ts +4 -1
- package/dist/core/runtime/spawn.js +30 -3
- package/dist/core/runtime/warm-pool.d.ts +2 -2
- package/dist/core/runtime/warm-pool.js +23 -3
- package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +2 -2
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
- package/dist/daemon/api/__tests__/seam/profile-delete.test.js +2 -2
- package/dist/daemon/api/handlers/human.js +5 -2
- package/dist/daemon/api/handlers/nodes.d.ts +1 -0
- package/dist/daemon/api/handlers/nodes.js +34 -29
- package/dist/daemon/cron-run.js +49 -15
- package/dist/hook-authoring.d.ts +28 -2
- package/dist/hook-authoring.js +53 -1
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
|
@@ -27,6 +27,7 @@ import { waitForBrokerViewSocket, viewerSplitEnv } from './placement-tmux.js';
|
|
|
27
27
|
import { headlessBrokerHost } from './host.js';
|
|
28
28
|
import { transition } from './lifecycle.js';
|
|
29
29
|
import { ensureDaemon } from '../../daemon/manage.js';
|
|
30
|
+
import { invokeBirthLifecycleHooks } from './spawn.js';
|
|
30
31
|
/** The agent's most recent surfaced message: the newest reports/*.md body with
|
|
31
32
|
* its YAML frontmatter stripped. Empty string when the node never reported. */
|
|
32
33
|
function lastReportBody(nodeId) {
|
|
@@ -64,17 +65,6 @@ export async function recycleNode(nodeId, callerPane) {
|
|
|
64
65
|
if (pane === undefined || pane === '') {
|
|
65
66
|
return { recycled: false, finalized: false, newRoot: null, delivered: [] };
|
|
66
67
|
}
|
|
67
|
-
// 1. Finalize — fan the agent's last message out as a `final`, mark it done.
|
|
68
|
-
const body = lastReportBody(nodeId) ||
|
|
69
|
-
`Closed via recycle — no final summary was authored by ${meta.name}.`;
|
|
70
|
-
let delivered = [];
|
|
71
|
-
let finalized = false;
|
|
72
|
-
try {
|
|
73
|
-
const res = await pushFinal(nodeId, body);
|
|
74
|
-
delivered = res.deliveredTo;
|
|
75
|
-
finalized = true;
|
|
76
|
-
}
|
|
77
|
-
catch { /* recycle the pane even if the report failed */ }
|
|
78
68
|
// Capture M's focus viewport (if any) before booting its replacement. The
|
|
79
69
|
// old broker remains live until the new broker proves it can accept a viewer;
|
|
80
70
|
// a failed replacement must not strand this finished conversation without an
|
|
@@ -92,17 +82,31 @@ export async function recycleNode(nodeId, callerPane) {
|
|
|
92
82
|
// person's seat, so profile-scope defaults (e.g. a persisted /model default)
|
|
93
83
|
// must keep applying.
|
|
94
84
|
const { launch } = await buildLaunchSpecAsync('general', 'base', { lifecycle: 'resident', hasManager: false, cwd: meta.cwd, profileId: meta.profile_id });
|
|
95
|
-
const
|
|
85
|
+
const body = lastReportBody(nodeId) ||
|
|
86
|
+
`Closed via recycle — no final summary was authored by ${meta.name}.`;
|
|
87
|
+
let delivered = [];
|
|
88
|
+
let finalized = false;
|
|
89
|
+
const root = await spawnNode({
|
|
96
90
|
kind: 'general',
|
|
97
91
|
mode: 'base',
|
|
98
92
|
lifecycle: 'resident',
|
|
99
93
|
cwd: meta.cwd,
|
|
100
94
|
name: 'general',
|
|
101
95
|
parent: null,
|
|
96
|
+
replaces: nodeId,
|
|
102
97
|
profile_id: meta.profile_id,
|
|
103
98
|
launch,
|
|
99
|
+
beforeCreate: async () => {
|
|
100
|
+
try {
|
|
101
|
+
const res = await pushFinal(nodeId, body);
|
|
102
|
+
delivered = res.deliveredTo;
|
|
103
|
+
finalized = true;
|
|
104
|
+
}
|
|
105
|
+
catch { /* recycle the pane even if the report failed */ }
|
|
106
|
+
},
|
|
104
107
|
});
|
|
105
108
|
const fresh = getNode(root.node_id);
|
|
109
|
+
await invokeBirthLifecycleHooks(fresh);
|
|
106
110
|
const inv = buildPiArgv(fresh);
|
|
107
111
|
// CRTR_SUBTREE groups the fresh root's subtree; FRONT_DOOR is set by the broker
|
|
108
112
|
// host itself, so it is not added here.
|
|
@@ -27,6 +27,7 @@ import { tearDownNode, focusOf, registerViewerFocus, respawnPaneSync, windowOfPa
|
|
|
27
27
|
import { viewerSplitEnv, waitForBrokerViewSocket } from './placement-tmux.js';
|
|
28
28
|
import { buildLaunchSpecAsync, buildPiArgv } from './launch.js';
|
|
29
29
|
import { spawnNode, rootOfSpine } from './nodes.js';
|
|
30
|
+
import { invokeBirthLifecycleHooks } from './spawn.js';
|
|
30
31
|
// reapDescendants — tear down a root's descendant sub-DAG (shared helper)
|
|
31
32
|
/** Reap the descendant sub-DAG of `rootId`: mark each **canceled** (the user
|
|
32
33
|
* moved on — a clean teardown, NOT a fault) + clear intent FIRST, then kill its
|
|
@@ -95,17 +96,19 @@ export async function relaunchRoot(oldId, deps = {}) {
|
|
|
95
96
|
cwd: old.cwd,
|
|
96
97
|
profileId: old.profile_id,
|
|
97
98
|
});
|
|
98
|
-
const newMeta = spawnNode({
|
|
99
|
+
const newMeta = await spawnNode({
|
|
99
100
|
kind: old.kind,
|
|
100
101
|
mode: 'base',
|
|
101
102
|
lifecycle: 'resident',
|
|
102
103
|
cwd: old.cwd,
|
|
103
104
|
name: old.kind,
|
|
104
105
|
parent: null,
|
|
106
|
+
replaces: oldId,
|
|
105
107
|
profile_id: old.profile_id,
|
|
106
108
|
launch,
|
|
107
109
|
modelOverride: old.model_override ?? undefined,
|
|
108
110
|
});
|
|
111
|
+
await invokeBirthLifecycleHooks(newMeta);
|
|
109
112
|
const inv = buildPiArgv(newMeta, {});
|
|
110
113
|
// Mirror bootRoot's subtree routing on inv.env (the broker host merges it; it
|
|
111
114
|
// sets CRTR_FRONT_DOOR itself).
|
|
@@ -278,10 +278,6 @@ async function launchRevive(nodeId, meta, opts) {
|
|
|
278
278
|
next,
|
|
279
279
|
});
|
|
280
280
|
}
|
|
281
|
-
// This is the same durable distinction used to decide between a birth prompt
|
|
282
|
-
// and a revive kickoff below. Compute it before launch mutations so a stale
|
|
283
|
-
// session identity cannot make an ordinary fresh revive look like a birth.
|
|
284
|
-
const isBirth = isUnstartedBirth(nodeId);
|
|
285
281
|
// Lazy host_kind coerce (§C): every launch now uses the broker host. Persist
|
|
286
282
|
// that identity only after this call has passed the no-op liveness guard.
|
|
287
283
|
if (meta.host_kind !== 'broker') {
|
|
@@ -340,6 +336,16 @@ async function launchRevive(nodeId, meta, opts) {
|
|
|
340
336
|
const hasSessionPath = resume.resumeSessionPath !== undefined;
|
|
341
337
|
const cycling = hasSessionPath && resume.newCycle === true;
|
|
342
338
|
const resuming = hasSessionPath && !cycling;
|
|
339
|
+
// Never conversed and nothing on disk to rebuild from — relaunch as a birth
|
|
340
|
+
// (see isUnstartedBirth). A clock-driven wake must keep its <crtr-wake>
|
|
341
|
+
// kickoff — the schedule IS the instruction to take a turn, so it never
|
|
342
|
+
// degrades to a birth replay. This one decision selects the kickoff below
|
|
343
|
+
// and is what the node:start hook payload reports as `isBirth`: a hook is
|
|
344
|
+
// told "birth" exactly when the node boots as one.
|
|
345
|
+
const birthReplay = !hasSessionPath
|
|
346
|
+
&& (opts.wakeReason === undefined || opts.wakeReason === 'runtime-restart-abort')
|
|
347
|
+
&& isUnstartedBirth(nodeId);
|
|
348
|
+
const isBirth = forkBirthPending || birthReplay;
|
|
343
349
|
// A cycling launch is provisional until session_start confirms it. Once
|
|
344
350
|
// true, this marker is monotonic across failed retries; only that confirmed
|
|
345
351
|
// boot may clear it.
|
|
@@ -399,14 +405,9 @@ async function launchRevive(nodeId, meta, opts) {
|
|
|
399
405
|
kickoffOrigin = { type: 'runtime', kind: 'restart-continuation' };
|
|
400
406
|
}
|
|
401
407
|
}
|
|
402
|
-
else if (
|
|
403
|
-
// A
|
|
404
|
-
//
|
|
405
|
-
(opts.wakeReason === undefined || opts.wakeReason === 'runtime-restart-abort') &&
|
|
406
|
-
isUnstartedBirth(nodeId)) {
|
|
407
|
-
// Never conversed and nothing on disk to rebuild from — relaunch as a birth
|
|
408
|
-
// (see isUnstartedBirth). A node with a spawn mandate replays it; a bare
|
|
409
|
-
// interactive node boots promptless and takes no turn at all.
|
|
408
|
+
else if (birthReplay) {
|
|
409
|
+
// A node with a spawn mandate replays it; a bare interactive node boots
|
|
410
|
+
// promptless and takes no turn at all.
|
|
410
411
|
const goal = readGoal(nodeId)?.trim();
|
|
411
412
|
inv = buildPiArgv(meta, goal !== undefined && goal !== '' ? { prompt: goal } : {});
|
|
412
413
|
if (goal !== undefined && goal !== '')
|
|
@@ -127,8 +127,11 @@ export declare function resolveSpawner(parent: string | undefined, ctxNodeId: st
|
|
|
127
127
|
* to the spawner (it carries spawned_by=spawner for provenance only), brought
|
|
128
128
|
* forefront so a human can pick up the conversation directly. */
|
|
129
129
|
type BeforeBrokerLaunch = (nodeId: string) => void;
|
|
130
|
-
|
|
130
|
+
type AfterNodeCreated = (nodeId: string) => void;
|
|
131
|
+
export declare function spawnChildPrepared(opts: SpawnChildOpts, beforeBrokerLaunch?: BeforeBrokerLaunch, afterNodeCreated?: AfterNodeCreated): Promise<SpawnChildResult>;
|
|
131
132
|
export declare function spawnChild(opts: SpawnChildOpts): Promise<SpawnChildResult>;
|
|
133
|
+
/** Invoke the best-effort node-start plan before any cold broker launch. */
|
|
134
|
+
export declare function invokeBirthLifecycleHooks(meta: NodeMeta): Promise<void>;
|
|
132
135
|
/** Create an independent, idle root from a node's conversation and durable
|
|
133
136
|
* context. Runtime/graph state is intentionally not inherited. */
|
|
134
137
|
export declare function forkNode(sourceId: string): Promise<SpawnChildResult>;
|
|
@@ -22,7 +22,7 @@ import { appendSituationalContext, formatSituationalProse } from './situational-
|
|
|
22
22
|
import { hasRoadmap, seedRoadmap } from './roadmap.js';
|
|
23
23
|
import { buildWakeBearings } from './bearings.js';
|
|
24
24
|
import { encodeKickoffOrigin, KICKOFF_ORIGIN_ENV } from './stamp/protocol.js';
|
|
25
|
-
import { canonicalSessionFile, contextDir, findNodeBySessionFile, getNode, fullName, recordPid, setFrozen } from '../canvas/index.js';
|
|
25
|
+
import { canonicalSessionFile, contextDir, crtrHome, findNodeBySessionFile, getNode, fullName, jobDir, nodeDir, recordPid, reportsDir, setFrozen } from '../canvas/index.js';
|
|
26
26
|
import { boundFleet, brokerThresholdsForDaemon } from './fleet.js';
|
|
27
27
|
import { emitEvent } from '../events/emit.js';
|
|
28
28
|
import { openViewerWindow, focusOf, windowOfPane, } from './placement.js';
|
|
@@ -37,6 +37,8 @@ import { modelRequestFromConfig, unregisteredConcreteModel } from '../model-rout
|
|
|
37
37
|
import { openModelRegistry } from './model-registry.js';
|
|
38
38
|
import { MODEL_SPEC_NEXT } from './model-selection.js';
|
|
39
39
|
import { initializeForkContextExposure } from '../substrate/injected-store.js';
|
|
40
|
+
import { discoverLifecycleHookRegistry } from '../command-hooks/discovery.js';
|
|
41
|
+
import { invokeLifecycleHooks } from '../command-hooks/transport/exec-lifecycle.js';
|
|
40
42
|
// Shared broker-launch wrapper — catch launch/preflight failures at the call
|
|
41
43
|
// site, mark the node crashed, then rethrow a clear error.
|
|
42
44
|
function launchFailureOutcome(error) {
|
|
@@ -210,7 +212,7 @@ export function resolveSpawner(parent, ctxNodeId, root) {
|
|
|
210
212
|
}
|
|
211
213
|
return spawner;
|
|
212
214
|
}
|
|
213
|
-
export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
|
|
215
|
+
export async function spawnChildPrepared(opts, beforeBrokerLaunch, afterNodeCreated) {
|
|
214
216
|
try {
|
|
215
217
|
ensureDaemon();
|
|
216
218
|
}
|
|
@@ -276,7 +278,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
|
|
|
276
278
|
if (wantsWorktree) {
|
|
277
279
|
await assertLaunchModelRegistered(requestFromLaunch(launch), { cwd: spawnCwd, profileId });
|
|
278
280
|
}
|
|
279
|
-
const meta = spawnNode({
|
|
281
|
+
const meta = await spawnNode({
|
|
280
282
|
kind: opts.kind,
|
|
281
283
|
mode,
|
|
282
284
|
lifecycle,
|
|
@@ -299,6 +301,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
|
|
|
299
301
|
...(opts.outcomeDelivery === undefined ? {} : { outcomeDelivery: opts.outcomeDelivery }),
|
|
300
302
|
launch,
|
|
301
303
|
});
|
|
304
|
+
afterNodeCreated?.(meta.node_id);
|
|
302
305
|
if (opts.forkFrom !== undefined) {
|
|
303
306
|
try {
|
|
304
307
|
initializeForkContextExposure(meta.node_id, forkSourceNodeId);
|
|
@@ -345,6 +348,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
|
|
|
345
348
|
}
|
|
346
349
|
// (The three scoped long-term memory stores are seeded for EVERY node at birth
|
|
347
350
|
// in spawnNode — no orchestrator-gated seeding needed here.)
|
|
351
|
+
await invokeBirthLifecycleHooks(meta);
|
|
348
352
|
const inv = buildPiArgv(meta, { prompt: kickoff, forkFrom });
|
|
349
353
|
// CRTR_SUBTREE (the spine root) rides the detached broker's env so it can group
|
|
350
354
|
// the subtree; the host sets CRTR_FRONT_DOOR itself.
|
|
@@ -491,6 +495,29 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
|
|
|
491
495
|
export async function spawnChild(opts) {
|
|
492
496
|
return spawnChildPrepared(opts);
|
|
493
497
|
}
|
|
498
|
+
/** Invoke the best-effort node-start plan before any cold broker launch. */
|
|
499
|
+
export async function invokeBirthLifecycleHooks(meta) {
|
|
500
|
+
const lifecycleHooks = discoverLifecycleHookRegistry(meta.cwd, meta.profile_id).plans.get('node:start') ?? [];
|
|
501
|
+
await invokeLifecycleHooks(lifecycleHooks, {
|
|
502
|
+
node: {
|
|
503
|
+
id: meta.node_id,
|
|
504
|
+
name: meta.name,
|
|
505
|
+
kind: meta.kind,
|
|
506
|
+
mode: meta.mode,
|
|
507
|
+
lifecycle: meta.lifecycle,
|
|
508
|
+
cwd: meta.cwd,
|
|
509
|
+
nodeDir: nodeDir(meta.node_id),
|
|
510
|
+
contextDir: contextDir(meta.node_id),
|
|
511
|
+
jobDir: jobDir(meta.node_id),
|
|
512
|
+
reportsDir: reportsDir(meta.node_id),
|
|
513
|
+
},
|
|
514
|
+
runtime: {
|
|
515
|
+
isBirth: true,
|
|
516
|
+
canvasHome: crtrHome(),
|
|
517
|
+
profile: meta.profile_id ?? null,
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
}
|
|
494
521
|
const FORK_CONTEXT_EXCLUDES = new Set(['initial-prompt.md', 'yield-message.md', 'result.json']);
|
|
495
522
|
/** Create an independent, idle root from a node's conversation and durable
|
|
496
523
|
* context. Runtime/graph state is intentionally not inherited. */
|
|
@@ -30,8 +30,8 @@ export interface WarmRequest {
|
|
|
30
30
|
creator: string | null;
|
|
31
31
|
}
|
|
32
32
|
/** Claim a spare for `request`, reshaped onto its persona — or null when the
|
|
33
|
-
* pool cannot serve it. The caller falls back to a cold spawn; a
|
|
34
|
-
*
|
|
33
|
+
* pool cannot serve it. The caller falls back to a cold spawn; a refused
|
|
34
|
+
* create hook leaves the spare untouched and rejects the create. */
|
|
35
35
|
export declare function claimWarmNode(request: WarmRequest, birth?: {
|
|
36
36
|
deadlineAt?: string;
|
|
37
37
|
outcomeDelivery?: OutcomeDeliveryBinding;
|
|
@@ -60,6 +60,7 @@ import { createHash } from 'node:crypto';
|
|
|
60
60
|
import { claimWarmSpare, deleteNode, getNode, listNodes, listWarmSpares, registerWarmSpare, unregisterWarmSpare, updateNode, warmSpareCount, } from '../canvas/index.js';
|
|
61
61
|
import { recordedPidLiveness } from '../canvas/pid.js';
|
|
62
62
|
import { nowIso } from '../fs-utils.js';
|
|
63
|
+
import { admitNodeCreate } from '../command-hooks/lifecycle-create.js';
|
|
63
64
|
import { spawnChildPrepared } from './spawn.js';
|
|
64
65
|
import { buildLaunchSpecAsync } from './launch.js';
|
|
65
66
|
import { setModelLive } from './model-swap.js';
|
|
@@ -110,15 +111,34 @@ async function resolveRecipe(request) {
|
|
|
110
111
|
return { key: warmRecipeKey(request.cwd, request.profileId, launch), launch, request };
|
|
111
112
|
}
|
|
112
113
|
/** Claim a spare for `request`, reshaped onto its persona — or null when the
|
|
113
|
-
* pool cannot serve it. The caller falls back to a cold spawn; a
|
|
114
|
-
*
|
|
114
|
+
* pool cannot serve it. The caller falls back to a cold spawn; a refused
|
|
115
|
+
* create hook leaves the spare untouched and rejects the create. */
|
|
115
116
|
export async function claimWarmNode(request, birth = {}) {
|
|
116
117
|
const recipe = await resolveRecipe(request);
|
|
118
|
+
if (warmSpareCount(recipe.key) === 0)
|
|
119
|
+
return null;
|
|
117
120
|
const created = nowIso();
|
|
118
121
|
// The claim's requested deadline and delivery registration move with its
|
|
119
122
|
// marker deletion, so the spare becomes caller-owned only as a fully armed
|
|
120
123
|
// birth. meta.json follows with the same created stamp.
|
|
121
|
-
const
|
|
124
|
+
const releaseCreateGate = await admitNodeCreate({
|
|
125
|
+
kind: request.kind,
|
|
126
|
+
mode: request.mode,
|
|
127
|
+
cwd: request.cwd,
|
|
128
|
+
profile: request.profileId,
|
|
129
|
+
root: true,
|
|
130
|
+
parentId: null,
|
|
131
|
+
requestingNodeId: request.creator,
|
|
132
|
+
lifecycle: 'resident',
|
|
133
|
+
replaces: null,
|
|
134
|
+
});
|
|
135
|
+
let nodeId;
|
|
136
|
+
try {
|
|
137
|
+
nodeId = claimWarmSpare(recipe.key, created, birth);
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
releaseCreateGate();
|
|
141
|
+
}
|
|
122
142
|
if (nodeId === null)
|
|
123
143
|
return null;
|
|
124
144
|
const spare = getNode(nodeId);
|
|
@@ -47,9 +47,9 @@ after(() => {
|
|
|
47
47
|
else
|
|
48
48
|
process.env.CRTR_HOME = priorHome;
|
|
49
49
|
});
|
|
50
|
-
test('birth commits the requested deadline and outcome registration before spawn orchestration can launch a broker', () => {
|
|
50
|
+
test('birth commits the requested deadline and outcome registration before spawn orchestration can launch a broker', async () => {
|
|
51
51
|
const deadlineAt = '2026-01-01T00:00:00.000Z';
|
|
52
|
-
const born = spawnNode({
|
|
52
|
+
const born = await spawnNode({
|
|
53
53
|
kind: 'general',
|
|
54
54
|
cwd: home,
|
|
55
55
|
lifecycle: 'terminal',
|
|
@@ -99,11 +99,11 @@ test('revive refuses a paused profile before launching a broker', async () => {
|
|
|
99
99
|
// (fork, cron spawn, warm-pool mint, `/new` relaunch, recycle) actually hits,
|
|
100
100
|
// because they all reach the canvas through spawnNode and none of them call
|
|
101
101
|
// handleCreate.
|
|
102
|
-
test('the birth gate refuses a paused profile for a daemon-side spawn', () => {
|
|
102
|
+
test('the birth gate refuses a paused profile for a daemon-side spawn', async () => {
|
|
103
103
|
const profile = createProfile('paused spawn', [{ path: cwd, memory: 'content' }]);
|
|
104
104
|
pauseProfile(profile.profileId);
|
|
105
105
|
const beforeRows = listNodes().length;
|
|
106
|
-
assert.
|
|
106
|
+
await assert.rejects(() => spawnNode({ kind: 'general', cwd, parent: null, profile_id: profile.profileId }), (error) => {
|
|
107
107
|
assert.ok(error instanceof CrtrError);
|
|
108
108
|
assert.equal(error.code, 'usage');
|
|
109
109
|
assert.match(error.details?.['next'], new RegExp(`crtr profile resume ${profile.profileId}`));
|
|
@@ -247,13 +247,13 @@ test('forced profile deletion owns the complete daemon lifecycle', { timeout: 90
|
|
|
247
247
|
assert.equal(readFileSync(hardReview.sourceFile, 'utf8'), 'source\n', 'the reviewed source document is never removed');
|
|
248
248
|
assert.equal(getDefaultProfileId(hardPin), null);
|
|
249
249
|
await assert.rejects(client.getProfile(hardProfile.id), (error) => error instanceof ApiError && error.status === 404);
|
|
250
|
-
assert.
|
|
250
|
+
await assert.rejects(() => spawnNode({
|
|
251
251
|
kind: 'developer',
|
|
252
252
|
cwd: process.cwd(),
|
|
253
253
|
parent: null,
|
|
254
254
|
profile_id: hardProfile.id,
|
|
255
255
|
nodeId: 'hard-stale-birth-deadbeef',
|
|
256
|
-
}), /profile not found/, 'the final
|
|
256
|
+
}), /profile not found/, 'the final birth gate rejects a profile resolved before a completed hard deletion');
|
|
257
257
|
assert.equal(h.node('hard-stale-birth-deadbeef'), null);
|
|
258
258
|
// Detach preserves nodes, topology, ticket/review state, and conversations;
|
|
259
259
|
// matching brokers restart with no profile identity and matching crons become
|
|
@@ -10,6 +10,7 @@ import { readTicketResult, validatePageTicketResponses } from '../../../core/hum
|
|
|
10
10
|
import { getReviewByBridge } from '../../../core/review/store.js';
|
|
11
11
|
import { ReviewOperationError } from '../../../core/review/types.js';
|
|
12
12
|
import { spawnNode } from '../../../core/runtime/nodes.js';
|
|
13
|
+
import { CrtrError } from '../../../core/errors.js';
|
|
13
14
|
import { cancelHumanTicket, resolvePageTicket } from '../../human/finish.js';
|
|
14
15
|
import { cancelReview } from '../../review/finish.js';
|
|
15
16
|
function requireInteractionTicketId(ctx) {
|
|
@@ -20,7 +21,7 @@ function requireInteractionTicketId(ctx) {
|
|
|
20
21
|
return ticketId;
|
|
21
22
|
}
|
|
22
23
|
// POST /v1/human/bridge — create a terminal bridge without a broker
|
|
23
|
-
function handleCreateBridge(ctx) {
|
|
24
|
+
async function handleCreateBridge(ctx) {
|
|
24
25
|
const req = ctx.body;
|
|
25
26
|
if (req === undefined || typeof req !== 'object') {
|
|
26
27
|
throw new ApiError(400, 'invalid_request', 'bridge body is required');
|
|
@@ -37,10 +38,12 @@ function handleCreateBridge(ctx) {
|
|
|
37
38
|
throw new ApiError(400, 'invalid_request', 'bridge `parent` must be a node id or null');
|
|
38
39
|
}
|
|
39
40
|
try {
|
|
40
|
-
const node = spawnNode({ kind: 'human', parent, creator: parent, cwd, name, lifecycle: 'terminal' });
|
|
41
|
+
const node = await spawnNode({ kind: 'human', parent, creator: parent, cwd, name, lifecycle: 'terminal' });
|
|
41
42
|
return { status: 200, body: { node_id: node.node_id, name: node.name } };
|
|
42
43
|
}
|
|
43
44
|
catch (error) {
|
|
45
|
+
if (error instanceof CrtrError && error.code === 'node_create_refused')
|
|
46
|
+
throw error;
|
|
44
47
|
throw new ApiError(400, 'invalid_request', String(error));
|
|
45
48
|
}
|
|
46
49
|
}
|
|
@@ -6,6 +6,7 @@ interface NodeHandlerDeps {
|
|
|
6
6
|
export { resolveCreateKind } from '../../../core/runtime/launch-target.js';
|
|
7
7
|
export declare function handleCreate(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
|
|
8
8
|
export declare function handleList(ctx: RequestContext): HandlerResult;
|
|
9
|
+
export declare function handleFork(ctx: RequestContext): Promise<HandlerResult>;
|
|
9
10
|
export declare function handlePromote(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
|
|
10
11
|
export declare function handleYield(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
|
|
11
12
|
export declare function handleConfig(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
|
|
@@ -13,7 +13,7 @@ import { appendSituationalContext, formatSituationalProse } from '../../../core/
|
|
|
13
13
|
import { closeNode } from '../../../core/runtime/close.js';
|
|
14
14
|
import { reviveNode } from '../../../core/runtime/revive.js';
|
|
15
15
|
import { nowIso } from '../../../core/fs-utils.js';
|
|
16
|
-
import { assertLaunchModelRegistered, forkNode,
|
|
16
|
+
import { assertLaunchModelRegistered, forkNode, spawnChildPrepared } from '../../../core/runtime/spawn.js';
|
|
17
17
|
import { childrenOf, getNode, listNodes, subscribersOf, subscriptionsOf, setMessageWait, updateNode } from '../../../core/canvas/canvas.js';
|
|
18
18
|
import { subtreeIds } from '../../../core/canvas/nav-model.js';
|
|
19
19
|
import { ticketCountsForNodes } from '../../../core/canvas/attention.js';
|
|
@@ -298,32 +298,39 @@ export async function handleCreate(ctx, deps = {}) {
|
|
|
298
298
|
const resolvedReq = { ...req, kind };
|
|
299
299
|
const resolvedOutcomeDelivery = outcomeDelivery === undefined ? undefined : resolveOutcomeDelivery(outcomeDelivery, cwd);
|
|
300
300
|
await assertLaunchModelRegistered(requestFromLaunch(launch), { cwd, profileId }, deps.modelRegistry);
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if (
|
|
313
|
-
|
|
314
|
-
|
|
301
|
+
let warmRefill = null;
|
|
302
|
+
{
|
|
303
|
+
// --- Warm path: hand out an already-booted spare instead of paying a full
|
|
304
|
+
// engine boot. A miss is not a failure — it falls through to the cold
|
|
305
|
+
// spawn below and mints the spare that serves the NEXT create. ---
|
|
306
|
+
const warm = warmRequestFor(resolvedReq, cwd, profileId);
|
|
307
|
+
if (warm !== null) {
|
|
308
|
+
const claimed = await claimWarmNode(warm, {
|
|
309
|
+
...(deadline === undefined ? {} : { deadlineAt: deadline }),
|
|
310
|
+
...(resolvedOutcomeDelivery === undefined ? {} : { outcomeDelivery: resolvedOutcomeDelivery }),
|
|
311
|
+
});
|
|
312
|
+
if (claimed !== null) {
|
|
313
|
+
refillWarmPool(warm);
|
|
314
|
+
if (schema !== null)
|
|
315
|
+
writeOutputSchema(claimed, 'terminal', schema);
|
|
316
|
+
return { status: 201, body: detailOf(claimed) };
|
|
317
|
+
}
|
|
318
|
+
warmRefill = warm;
|
|
315
319
|
}
|
|
320
|
+
const recipe = buildRecipe(resolvedReq, parent, cwd);
|
|
321
|
+
if (deadline !== undefined)
|
|
322
|
+
recipe.deadlineAt = deadline;
|
|
323
|
+
if (resolvedOutcomeDelivery !== undefined)
|
|
324
|
+
recipe.outcomeDelivery = resolvedOutcomeDelivery;
|
|
325
|
+
recipe.readiness = req.root === true ? 'await' : 'launch';
|
|
326
|
+
const result = await spawnChildPrepared(recipe, undefined, () => {
|
|
327
|
+
if (warmRefill !== null)
|
|
328
|
+
refillWarmPool(warmRefill);
|
|
329
|
+
});
|
|
330
|
+
if (schema !== null)
|
|
331
|
+
writeOutputSchema(result.node.node_id, 'terminal', schema);
|
|
332
|
+
return { status: 201, body: detailOf(result.node.node_id) };
|
|
316
333
|
}
|
|
317
|
-
const recipe = buildRecipe(resolvedReq, parent, cwd);
|
|
318
|
-
if (deadline !== undefined)
|
|
319
|
-
recipe.deadlineAt = deadline;
|
|
320
|
-
if (resolvedOutcomeDelivery !== undefined)
|
|
321
|
-
recipe.outcomeDelivery = resolvedOutcomeDelivery;
|
|
322
|
-
recipe.readiness = req.root === true ? 'await' : 'launch';
|
|
323
|
-
const result = await spawnChild(recipe);
|
|
324
|
-
if (schema !== null)
|
|
325
|
-
writeOutputSchema(result.node.node_id, 'terminal', schema);
|
|
326
|
-
return { status: 201, body: detailOf(result.node.node_id) };
|
|
327
334
|
}
|
|
328
335
|
// GET /v1/nodes — list (filters) ; GET /v1/nodes/{id} — detail
|
|
329
336
|
function singleQueryValue(ctx, name) {
|
|
@@ -557,10 +564,8 @@ function handleArtifacts(ctx) {
|
|
|
557
564
|
return { status: 200, body };
|
|
558
565
|
}
|
|
559
566
|
// Lifecycle verbs
|
|
560
|
-
async function handleFork(ctx) {
|
|
561
|
-
const
|
|
562
|
-
requireMeta(id);
|
|
563
|
-
const result = await forkNode(id);
|
|
567
|
+
export async function handleFork(ctx) {
|
|
568
|
+
const result = await forkNode(ctx.params['id']);
|
|
564
569
|
return { status: 201, body: detailOf(result.node.node_id) };
|
|
565
570
|
}
|
|
566
571
|
async function handleRevive(ctx) {
|
package/dist/daemon/cron-run.js
CHANGED
|
@@ -35,14 +35,14 @@
|
|
|
35
35
|
// promises for graceful-teardown drain but never blocks on a slow run.
|
|
36
36
|
import { spawn } from 'node:child_process';
|
|
37
37
|
import { createHash, randomUUID } from 'node:crypto';
|
|
38
|
-
import { CRON_KILL_GRACE_MS, CRON_OUTPUT_HEAD_BYTES, EMPTY_STDOUT_SHA256, newHeadCapture, } from './cron/capture.js';
|
|
38
|
+
import { CRON_DEATH_POLL_MS, CRON_KILL_GRACE_MS, CRON_OUTPUT_HEAD_BYTES, EMPTY_STDOUT_SHA256, newHeadCapture, } from './cron/capture.js';
|
|
39
39
|
import { clearLiveRun, cronDaemonInstanceId, liveRunFor, registerLiveRun } from './cron/live-runs.js';
|
|
40
40
|
import { buildArmedRunContext } from './cron/armed-context.js';
|
|
41
41
|
import { decideHeldSettle, EXIT_HELD, HELD_DELIVERED } from './cron/held.js';
|
|
42
42
|
import { settleRun } from './cron/settle.js';
|
|
43
43
|
import { applyOverlapPolicies, expireDueCrons, } from './cron/passes.js';
|
|
44
44
|
import { dueClockCrons, acquireCronRunLease, advanceCron, setCronState, } from '../core/canvas/crons.js';
|
|
45
|
-
import {
|
|
45
|
+
import { captureTeardownSnapshot, isAnyPidAlive, killProcessTreePids } from '../core/canvas/pid.js';
|
|
46
46
|
import { emitEvent } from '../core/events/emit.js';
|
|
47
47
|
import { nextSlotAfter } from '../core/wake.js';
|
|
48
48
|
import { resolveAdmittedHostCommand } from '../core/runtime/spawn-env.js';
|
|
@@ -239,6 +239,9 @@ export function executeCron(c, opts) {
|
|
|
239
239
|
let settled = false;
|
|
240
240
|
let timer;
|
|
241
241
|
let killTimer;
|
|
242
|
+
let deathTimer;
|
|
243
|
+
let closeCode;
|
|
244
|
+
let killed = false;
|
|
242
245
|
let timedOut = false;
|
|
243
246
|
let leaseLost = false;
|
|
244
247
|
let processError = null;
|
|
@@ -259,19 +262,8 @@ export function executeCron(c, opts) {
|
|
|
259
262
|
child.stderr?.on('data', (chunk) => {
|
|
260
263
|
stderr.take(chunk);
|
|
261
264
|
});
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
if (killTimer === undefined) {
|
|
265
|
-
killTimer = setTimeout(() => killProcessGroup(pid, 'SIGKILL'), CRON_KILL_GRACE_MS);
|
|
266
|
-
killTimer.unref?.();
|
|
267
|
-
}
|
|
268
|
-
};
|
|
269
|
-
// A signal, not settlement: 'close' always follows 'error'.
|
|
270
|
-
child.on('error', (err) => {
|
|
271
|
-
if (processError == null)
|
|
272
|
-
processError = err;
|
|
273
|
-
});
|
|
274
|
-
child.on('close', (code) => {
|
|
265
|
+
let teardown;
|
|
266
|
+
const settleClosedChild = (code) => {
|
|
275
267
|
if (settled)
|
|
276
268
|
return;
|
|
277
269
|
settled = true;
|
|
@@ -279,6 +271,8 @@ export function executeCron(c, opts) {
|
|
|
279
271
|
clearTimeout(timer);
|
|
280
272
|
if (killTimer !== undefined)
|
|
281
273
|
clearTimeout(killTimer);
|
|
274
|
+
if (deathTimer !== undefined)
|
|
275
|
+
clearTimeout(deathTimer);
|
|
282
276
|
const stdoutHead = stdout.decode();
|
|
283
277
|
const stderrHead = stderr.decode();
|
|
284
278
|
const live = liveRunFor(c.cron_id);
|
|
@@ -306,6 +300,46 @@ export function executeCron(c, opts) {
|
|
|
306
300
|
wasReplaced,
|
|
307
301
|
wasSelfCanceled,
|
|
308
302
|
}).then(resolve, reject);
|
|
303
|
+
};
|
|
304
|
+
const awaitTeardownDeath = () => {
|
|
305
|
+
if (teardown === undefined || !isAnyPidAlive(teardown.tree)) {
|
|
306
|
+
if (closeCode !== undefined)
|
|
307
|
+
settleClosedChild(closeCode);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
deathTimer = setTimeout(awaitTeardownDeath, CRON_DEATH_POLL_MS);
|
|
311
|
+
};
|
|
312
|
+
const killWithEscalation = (pid) => {
|
|
313
|
+
teardown ??= captureTeardownSnapshot(pid, null);
|
|
314
|
+
killProcessTreePids(teardown.tree, 'SIGTERM', teardown.identities);
|
|
315
|
+
if (killTimer === undefined) {
|
|
316
|
+
killTimer = setTimeout(() => {
|
|
317
|
+
if (isAnyPidAlive(teardown.tree))
|
|
318
|
+
killProcessTreePids(teardown.tree, 'SIGKILL', teardown.identities);
|
|
319
|
+
killed = true;
|
|
320
|
+
if (closeCode !== undefined)
|
|
321
|
+
awaitTeardownDeath();
|
|
322
|
+
}, CRON_KILL_GRACE_MS);
|
|
323
|
+
killTimer.unref?.();
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
// A signal, not settlement: 'close' always follows 'error'.
|
|
327
|
+
child.on('error', (err) => {
|
|
328
|
+
if (processError == null)
|
|
329
|
+
processError = err;
|
|
330
|
+
});
|
|
331
|
+
child.on('close', (code) => {
|
|
332
|
+
if (settled)
|
|
333
|
+
return;
|
|
334
|
+
closeCode = code;
|
|
335
|
+
if (timer !== undefined)
|
|
336
|
+
clearTimeout(timer);
|
|
337
|
+
if (teardown !== undefined && isAnyPidAlive(teardown.tree)) {
|
|
338
|
+
if (killed)
|
|
339
|
+
awaitTeardownDeath();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
settleClosedChild(code);
|
|
309
343
|
});
|
|
310
344
|
if (child.pid == null ||
|
|
311
345
|
!acquireCronRunLease(c.cron_id, cronDaemonInstanceId, child.pid, startedAtIso, outOfBand)) {
|
package/dist/hook-authoring.d.ts
CHANGED
|
@@ -23,6 +23,32 @@ export interface LifecycleHookRuntime {
|
|
|
23
23
|
canvasHome: string;
|
|
24
24
|
profile: string | null;
|
|
25
25
|
}
|
|
26
|
+
/** `node:create` runs before every node birth, immediately before a cold row is written or a warm spare is claimed. Return `ok:false` to refuse the birth. There is no node payload because no node exists yet. */
|
|
27
|
+
export interface NodeCreateHookRequest {
|
|
28
|
+
protocolVersion: 1;
|
|
29
|
+
op: string;
|
|
30
|
+
event: 'node:create';
|
|
31
|
+
phase: 'before';
|
|
32
|
+
operationId: string;
|
|
33
|
+
create: {
|
|
34
|
+
kind: string;
|
|
35
|
+
mode: string;
|
|
36
|
+
cwd: string;
|
|
37
|
+
profile: string | null;
|
|
38
|
+
root: boolean;
|
|
39
|
+
parentId: string | null;
|
|
40
|
+
requestingNodeId: string | null;
|
|
41
|
+
lifecycle: 'resident' | 'terminal';
|
|
42
|
+
replaces: string | null;
|
|
43
|
+
};
|
|
44
|
+
runtime: {
|
|
45
|
+
canvasHome: string;
|
|
46
|
+
profile: string | null;
|
|
47
|
+
};
|
|
48
|
+
context: {
|
|
49
|
+
cwd: string;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
26
52
|
interface HookRequestBase {
|
|
27
53
|
protocolVersion: 1;
|
|
28
54
|
op: string;
|
|
@@ -43,7 +69,7 @@ export interface AfterHookRequest extends HookRequestBase {
|
|
|
43
69
|
export interface ReplaceHookRequest extends HookRequestBase {
|
|
44
70
|
phase: 'replace';
|
|
45
71
|
}
|
|
46
|
-
/**
|
|
72
|
+
/** `node:start` handlers run for birth and every revive, so they must be idempotent. `node:close` runs after close. */
|
|
47
73
|
export interface LifecycleHookRequest {
|
|
48
74
|
protocolVersion: 1;
|
|
49
75
|
op: string;
|
|
@@ -56,7 +82,7 @@ export interface LifecycleHookRequest {
|
|
|
56
82
|
cwd: string;
|
|
57
83
|
};
|
|
58
84
|
}
|
|
59
|
-
export type HookRequest = BeforeHookRequest | AfterHookRequest | ReplaceHookRequest | LifecycleHookRequest;
|
|
85
|
+
export type HookRequest = BeforeHookRequest | AfterHookRequest | ReplaceHookRequest | NodeCreateHookRequest | LifecycleHookRequest;
|
|
60
86
|
export interface HookSuccess {
|
|
61
87
|
protocolVersion: 1;
|
|
62
88
|
ok: true;
|