@north-light/crouter 0.3.269 → 0.3.271
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/dto/broker.d.ts +11 -2
- package/dist/api/dto/broker.js +2 -2
- package/dist/api/dto/nodes.d.ts +4 -0
- package/dist/commands/pkg/plugin-inspect.js +2 -2
- package/dist/commands/pkg/plugin-manage.js +3 -3
- package/dist/commands/sys/doctor.js +4 -3
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +2 -2
- package/dist/core/__tests__/helpers/harness.js +1 -0
- package/dist/core/__tests__/human-deliver.test.js +1 -1
- package/dist/core/__tests__/integration/lifecycle-hooks.test.d.ts +1 -0
- package/dist/core/__tests__/integration/lifecycle-hooks.test.js +182 -0
- package/dist/core/__tests__/integration/revive.test.js +15 -15
- package/dist/core/__tests__/push-final-guard.test.js +1 -1
- package/dist/core/__tests__/revive-capacity.test.js +20 -20
- package/dist/core/__tests__/revive-parked-fresh.test.js +14 -14
- package/dist/core/__tests__/seam/broker-cap-freeze.test.js +2 -2
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +2 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
- package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +1 -1
- package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +2 -2
- package/dist/core/command-hooks/discovery.d.ts +27 -1
- package/dist/core/command-hooks/discovery.js +52 -0
- package/dist/core/command-hooks/index.d.ts +4 -3
- package/dist/core/command-hooks/index.js +2 -1
- package/dist/core/command-hooks/lifecycle-catalog.d.ts +3 -0
- package/dist/core/command-hooks/lifecycle-catalog.js +4 -0
- package/dist/core/command-hooks/report.d.ts +11 -2
- package/dist/core/command-hooks/report.js +10 -1
- package/dist/core/command-hooks/schema.d.ts +16 -1
- package/dist/core/command-hooks/schema.js +128 -40
- package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +12 -0
- package/dist/core/command-hooks/transport/exec-lifecycle.js +152 -0
- package/dist/core/human/feedback-companion.js +1 -1
- package/dist/core/review/realize.js +1 -1
- package/dist/core/runtime/broker/engine-drive.d.ts +14 -3
- package/dist/core/runtime/broker/engine-drive.js +29 -1
- package/dist/core/runtime/broker/frame-client.js +3 -4
- package/dist/core/runtime/broker/rebind.js +7 -0
- package/dist/core/runtime/broker-protocol.d.ts +11 -2
- package/dist/core/runtime/broker.d.ts +1 -1
- package/dist/core/runtime/broker.js +13 -3
- package/dist/core/runtime/fleet.d.ts +5 -6
- package/dist/core/runtime/node-read.d.ts +2 -0
- package/dist/core/runtime/node-read.js +18 -9
- package/dist/core/runtime/nodes.js +6 -1
- package/dist/core/runtime/revive-all.d.ts +1 -1
- package/dist/core/runtime/revive-all.js +2 -2
- package/dist/core/runtime/revive.d.ts +2 -2
- package/dist/core/runtime/revive.js +49 -13
- package/dist/core/runtime/session-visibility.d.ts +10 -14
- package/dist/core/runtime/session-visibility.js +43 -30
- package/dist/core/runtime/stamp/channel.d.ts +10 -6
- package/dist/core/runtime/stamp/channel.js +16 -7
- package/dist/core/runtime/stamp/protocol.d.ts +4 -0
- package/dist/core/runtime/turn-visibility.d.ts +10 -0
- package/dist/core/runtime/turn-visibility.js +31 -0
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
- package/dist/daemon/api/bridge.js +1 -1
- package/dist/daemon/api/handlers/attach.js +4 -4
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/messages.js +2 -2
- package/dist/daemon/api/handlers/nodes.js +7 -4
- package/dist/daemon/cron/sinks.js +3 -3
- package/dist/daemon/fleet.js +10 -22
- package/dist/daemon/messaging/node-message.js +1 -1
- package/dist/daemon/profile-delete.js +1 -1
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +6 -6
- package/dist/hook-authoring.d.ts +31 -1
- package/dist/hook-authoring.js +42 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/pi-extensions/canvas-stamp.js +3 -3
- package/dist/shared/env.d.ts +2 -0
- package/dist/shared/env.js +4 -0
- package/dist/shared/generated-context.d.ts +1 -2
- package/dist/shared/generated-context.js +3 -4
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
package/dist/api/dto/broker.d.ts
CHANGED
|
@@ -26,6 +26,10 @@ export interface BrokerWelcomeSnapshot<M = unknown> {
|
|
|
26
26
|
messages: M[];
|
|
27
27
|
/** Stable session-entry ids aligned 1:1 with `messages`. */
|
|
28
28
|
messageIds?: string[];
|
|
29
|
+
/** Presentation visibility aligned 1:1 with `messages`. */
|
|
30
|
+
messageVisibility: Array<'visible' | 'internal'>;
|
|
31
|
+
/** Presentation visibility of the run currently owned by the engine. */
|
|
32
|
+
turnVisibility: 'visible' | 'internal';
|
|
29
33
|
state?: {
|
|
30
34
|
isStreaming?: boolean;
|
|
31
35
|
};
|
|
@@ -42,6 +46,11 @@ export interface BrokerWelcomeFrame<M = unknown> {
|
|
|
42
46
|
type: 'welcome';
|
|
43
47
|
snapshot?: BrokerWelcomeSnapshot<M>;
|
|
44
48
|
}
|
|
49
|
+
/** The presentation classification of the run whose native events follow. */
|
|
50
|
+
export interface BrokerTurnVisibilityFrame {
|
|
51
|
+
type: 'turn_visibility';
|
|
52
|
+
visibility: 'visible' | 'internal';
|
|
53
|
+
}
|
|
45
54
|
/**
|
|
46
55
|
* The crtrd broker `node_named` control frame — the node's generated name at the
|
|
47
56
|
* instant crtrd committed it.
|
|
@@ -68,11 +77,11 @@ export interface BrokerNodeNamedFrame {
|
|
|
68
77
|
}
|
|
69
78
|
/**
|
|
70
79
|
* The broker-control frames a relay consumer reads: `welcome` (catch-up snapshot),
|
|
71
|
-
* `error`, and `node_named`. The broker interleaves others (display_*, ack, …)
|
|
80
|
+
* `turn_visibility`, `error`, and `node_named`. The broker interleaves others (display_*, ack, …)
|
|
72
81
|
* under non-colliding `type` discriminants; a relay mapper drops those through its
|
|
73
82
|
* `default` arm untyped, so they are not enumerated here.
|
|
74
83
|
*/
|
|
75
|
-
export type BrokerControlFrame<M = unknown> = BrokerWelcomeFrame<M> | BrokerErrorFrame | BrokerNodeNamedFrame;
|
|
84
|
+
export type BrokerControlFrame<M = unknown> = BrokerWelcomeFrame<M> | BrokerTurnVisibilityFrame | BrokerErrorFrame | BrokerNodeNamedFrame;
|
|
76
85
|
/**
|
|
77
86
|
* What the crtrd broker attach delivers to a relay consumer: the live engine
|
|
78
87
|
* event stream (`E` — pi's `AgentSessionEvent`, relayed verbatim) unioned with the
|
package/dist/api/dto/broker.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Broker attach-protocol envelope DTOs — the crtrd-specific control frames the
|
|
2
2
|
// broker interleaves around pi's native agent-session event stream over a node's
|
|
3
3
|
// `view.sock` (and its WS bridge, spec §5). This module owns ONLY that envelope
|
|
4
|
-
// vocabulary: the
|
|
5
|
-
//
|
|
4
|
+
// vocabulary: the broker-control frames a relay consumer reads, the welcome
|
|
5
|
+
// snapshot they carry, and the relay union that folds them
|
|
6
6
|
// together with the live engine event stream.
|
|
7
7
|
//
|
|
8
8
|
// PURITY (spec §3.1): like every file under `src/api/`, this imports NOTHING —
|
package/dist/api/dto/nodes.d.ts
CHANGED
|
@@ -244,6 +244,8 @@ export interface NodeSnapshotDTO {
|
|
|
244
244
|
snapshot: {
|
|
245
245
|
messages: unknown[];
|
|
246
246
|
messageIds?: string[];
|
|
247
|
+
messageVisibility: Array<'visible' | 'internal'>;
|
|
248
|
+
turnVisibility: 'visible' | 'internal';
|
|
247
249
|
stats: unknown;
|
|
248
250
|
state: Record<string, unknown>;
|
|
249
251
|
display: {
|
|
@@ -282,6 +284,8 @@ export interface NodeMessagesPageDTO {
|
|
|
282
284
|
messages: unknown[];
|
|
283
285
|
/** Stable session-entry ids aligned 1:1 with `messages`. */
|
|
284
286
|
message_ids?: string[];
|
|
287
|
+
/** Presentation visibility aligned 1:1 with `messages`. */
|
|
288
|
+
message_visibility: Array<'visible' | 'internal'>;
|
|
285
289
|
/** Opaque cursor toward older messages; null at the start of the session. */
|
|
286
290
|
next_cursor: Cursor | null;
|
|
287
291
|
captured_at: IsoTime;
|
|
@@ -107,10 +107,10 @@ export const pluginShow = defineLeaf({
|
|
|
107
107
|
{ name: 'manifest', type: 'object', required: true, constraint: 'Full plugin.json contents.' },
|
|
108
108
|
{ name: 'docs', type: 'object[]', required: true, constraint: 'Each: {name, path}. Memory docs provided by the plugin (its `<pluginName>/` memory subtree).' },
|
|
109
109
|
{ name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest (manifest.commands). {manifestPath: string, mounts: string[] (accepted top-level command names), extensibleBranches: string[] (mount names accepting repository fragments), issues: object[] ({code, path?, message, received, expected, next})}. Validated statically; command execution never occurs.' },
|
|
110
|
-
{ name: 'hooks', type: 'object', required: false, constraint: 'Present for every hook declaration, including hook-only plugins. {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], issues: object[] ({code, path?, message, received, expected, next}), trust: string}. Static only: no hook executable runs.' },
|
|
110
|
+
{ name: 'hooks', type: 'object', required: false, constraint: 'Present for every hook declaration, including hook-only plugins. {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[] ({code, path?, message, received, expected, next}), trust: string}. Static only: no hook executable runs.' },
|
|
111
111
|
],
|
|
112
112
|
outputKind: 'object',
|
|
113
|
-
effects: ['None. Read-only. Command and hook manifests are validated statically; plugin executables are never executed.'],
|
|
113
|
+
effects: ['None. Read-only. Command and lifecycle-hook manifests are validated statically; plugin executables are never executed.'],
|
|
114
114
|
},
|
|
115
115
|
run: async (input) => {
|
|
116
116
|
const name = input['name'];
|
|
@@ -729,7 +729,7 @@ export const pluginInstall = defineLeaf({
|
|
|
729
729
|
{ name: 'kinds', type: 'integer', required: false, constraint: 'Kind-registry entry count declared by an --endpoint archive\u2019s bundle.json.' },
|
|
730
730
|
{ name: 'pageComponents', type: 'integer', required: false, constraint: 'Page-component registration count declared by an --endpoint archive\u2019s bundle.json.' },
|
|
731
731
|
{ name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest. {mounts: string[] (accepted top-level command names now live for the next invocation), issues: object[] (typed validation issues that rejected a contribution — {code, path?, message, received, expected, next})}. Validated statically; commands are never executed.' },
|
|
732
|
-
{ name: 'hooks', type: 'object', required: false, constraint: 'Present only when the source plugin declares hooks. {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], issues: object[], trust: string}.
|
|
732
|
+
{ name: 'hooks', type: 'object', required: false, constraint: 'Present only when the source plugin declares hooks. {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[], trust: string}. Command targets, lifecycle events, and replacement collisions are validated statically; hook executables are never run.' },
|
|
733
733
|
{ name: 'warnings', type: 'string[]', required: false, constraint: 'Advisory missing PATH executable requirements declared by the installed plugin. The install still succeeds and leaves the plugin enabled.' },
|
|
734
734
|
{ name: 'memory', type: 'object', required: false, constraint: 'Present only when the plugin ships a memory store. {docs: integer (documents checked), migrated: string[] (store-relative documents rewritten to reach exact canonical identity), linkedSource?: string (the caller-owned directory converged in place, present only when the install keeps that directory as a live link)}.' },
|
|
735
735
|
],
|
|
@@ -738,7 +738,7 @@ export const pluginInstall = defineLeaf({
|
|
|
738
738
|
'A ref install clones, links, or copies the plugin into the scope plugins directory and registers it with enabled=true. Marketplace installs refresh the source marketplace before resolving the plugin entry.',
|
|
739
739
|
'An --endpoint install fetches and validates one authenticated uncompressed tar archive before writing. It replaces the complete plugin directory with synthesized provenance and invocation metadata, commands.json, memory docs, any kind-registry entries the bundle.json declares (they join the launch registry below this scope\u2019s own config.json kinds), and any page components it declares (they join the active page-component catalog alongside the user config entries); a fetch or validation failure preserves the prior package.',
|
|
740
740
|
'If the plugin declares a command manifest, its commands go live on the next crtr invocation. Exec commands run trusted local code only when explicitly invoked; HTTP commands call their declared endpoint only when explicitly invoked.',
|
|
741
|
-
'Source plugin hooks go live on the next crtr invocation. They run implicitly with caller authority, may receive normalized inputs and after results, may block or replace declared targets, and may transmit received data. Hook executables are never run during install or update validation.',
|
|
741
|
+
'Source plugin command hooks go live on the next crtr invocation. They run implicitly with caller authority, may receive normalized inputs and after results, may block or replace declared targets, and may transmit received data. Lifecycle hooks run unprompted at every declared event before node launch and may transmit node identity and resolved paths. Hook executables are never run during install or update validation.',
|
|
742
742
|
'A plugin manifest `requires` declaration is checked against the PATH a node bash receives after install. Missing executables emit a warning with the declared install hint but never fail the install, disable the plugin, or remove its bare-binary contributions.',
|
|
743
743
|
'Before activation the candidate\u2019s memory corpus is migrated to exact canonical identity and refused if it cannot mount. A staged candidate is migrated in staging and rolls back with the package; a local path or marketplace-relative source stays a live link, so its own directory is rewritten in place, keeps that rewrite whether or not the install completes, and is named as `memory.linkedSource`. A refused corpus writes nothing and installs nothing.',
|
|
744
744
|
],
|
|
@@ -904,7 +904,7 @@ export const pluginUpdate = defineLeaf({
|
|
|
904
904
|
{ kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Narrows resolution.' },
|
|
905
905
|
],
|
|
906
906
|
output: [
|
|
907
|
-
{ name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, transport?, updated, sha?, version?, docs?, kinds?, pageComponents?, commands?, hooks?, memory?, warnings?, error?}. memory is present for a plugin shipping a memory store and reports the same corpus migration install performs: {docs, migrated, linkedSource?}. Archive plugins report transport=http and replace their complete directory after an authenticated refetch; version is the archive content identifier, docs is the written memory-document count, kinds is the declared kind-registry entry count, and pageComponents is the declared page-component registration count. sha is present for git updates. A bulk update reports and skips a failed archive plugin. commands is present for validated command plugins: {mounts: string[], issues: object[]}. hooks is present for source hook plugins: {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], issues: object[], trust: string}; it is static and never executes hooks. warnings lists advisory PATH requirements that remain absent after update.' }
|
|
907
|
+
{ name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, transport?, updated, sha?, version?, docs?, kinds?, pageComponents?, commands?, hooks?, memory?, warnings?, error?}. memory is present for a plugin shipping a memory store and reports the same corpus migration install performs: {docs, migrated, linkedSource?}. Archive plugins report transport=http and replace their complete directory after an authenticated refetch; version is the archive content identifier, docs is the written memory-document count, kinds is the declared kind-registry entry count, and pageComponents is the declared page-component registration count. sha is present for git updates. A bulk update reports and skips a failed archive plugin. commands is present for validated command plugins: {mounts: string[], issues: object[]}. hooks is present for source hook plugins: {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[], trust: string}; it is static and never executes hooks. warnings lists advisory PATH requirements that remain absent after update.' }
|
|
908
908
|
],
|
|
909
909
|
outputKind: 'object',
|
|
910
910
|
effects: [
|
|
@@ -178,8 +178,9 @@ async function runHookPluginChecks(scopes) {
|
|
|
178
178
|
continue;
|
|
179
179
|
const issues = registry.issues.filter((issue) => issue.plugin === plugin.name && issue.scope === plugin.scope);
|
|
180
180
|
if (issues.length === 0) {
|
|
181
|
-
const
|
|
182
|
-
|
|
181
|
+
const commandDeclarations = validation.hooks.map((hook) => `${hook.target} [${hook.phase} ${hook.op}]`);
|
|
182
|
+
const lifecycleDeclarations = (validation.manifest?.schemaVersion === 2 ? validation.manifest.lifecycle : []).map((hook) => `${hook.event} [${hook.phase} ${hook.op}; effects: ${hook.effects.join(', ')}]`);
|
|
183
|
+
results.push(pass(plugin.scope, `plugin:${plugin.name}:hooks`, `effective hook declaration(s): ${[...commandDeclarations, ...lifecycleDeclarations].join('; ')}`));
|
|
183
184
|
continue;
|
|
184
185
|
}
|
|
185
186
|
issues.forEach((issue, index) => {
|
|
@@ -578,7 +579,7 @@ export const sysDoctorLeaf = defineLeaf({
|
|
|
578
579
|
{ kind: 'flag', name: 'remote', type: 'bool', required: false, constraint: 'Check git remotes with ls-remote (slow — makes network calls).' },
|
|
579
580
|
],
|
|
580
581
|
output: [
|
|
581
|
-
{ name: 'checks', type: 'object[]', required: true, constraint: 'Each: {scope, name, status, message, fixed?, remediation?}. status: pass | fail. scope may be user | project | canvas. bin:<name> checks report bare-binary contributions (a `bin` block in a plugin manifest or a scope config.json): a pass names the contributor and the resolved target, a fail names an unsafe/reserved command name, malformed target declaration, missing/non-executable/escaping target, or same-precedence name collision that installed nothing; their remediation is always a human_action. humanActions:<name>@<config-origin> checks validate each user/project scope completion-action declaration: action-name and argv shape, cwd directory, argv[0] regular-file existence, and execute permission; they never execute an action. A malformed effective declaration fails; a malformed shadowed declaration is a pass-level warning that names the winning and shadowed origins. requires:<name> checks report each enabled plugin manifest PATH requirement: a pass names the plugin and resolved executable path; a fail names the plugin and carries its declared install hint as remediation. Runtime checks validate selected and process generation manifests, sealing/containment, and full SHA-256 inventories. terminal:nerd-font (user scope) reports whether a Nerd Font is installed for the viewer\'s icon glyphs; its remediation is always a human_action carrying the platform install command, never auto-fixed. Plugin checks appear as plugin:<name>:commands and plugin:<name>:hooks; both are validated statically and never execute plugin code. Hook failures identify manifest/path/target/collision problems and carry disable/update/remove/show remediation; healthy hooks list effective target, phase, and op. remediation (when present) is {kind, description, ...payload} where kind is remove_config_key | rm_path | human_action; fault human_action remediations include the canonical logs command. Sorted by scope then name.' },
|
|
582
|
+
{ name: 'checks', type: 'object[]', required: true, constraint: 'Each: {scope, name, status, message, fixed?, remediation?}. status: pass | fail. scope may be user | project | canvas. bin:<name> checks report bare-binary contributions (a `bin` block in a plugin manifest or a scope config.json): a pass names the contributor and the resolved target, a fail names an unsafe/reserved command name, malformed target declaration, missing/non-executable/escaping target, or same-precedence name collision that installed nothing; their remediation is always a human_action. humanActions:<name>@<config-origin> checks validate each user/project scope completion-action declaration: action-name and argv shape, cwd directory, argv[0] regular-file existence, and execute permission; they never execute an action. A malformed effective declaration fails; a malformed shadowed declaration is a pass-level warning that names the winning and shadowed origins. requires:<name> checks report each enabled plugin manifest PATH requirement: a pass names the plugin and resolved executable path; a fail names the plugin and carries its declared install hint as remediation. Runtime checks validate selected and process generation manifests, sealing/containment, and full SHA-256 inventories. terminal:nerd-font (user scope) reports whether a Nerd Font is installed for the viewer\'s icon glyphs; its remediation is always a human_action carrying the platform install command, never auto-fixed. Plugin checks appear as plugin:<name>:commands and plugin:<name>:hooks; both are validated statically and never execute plugin code. Hook failures identify manifest/path/target/event/collision problems and carry disable/update/remove/show remediation; healthy command hooks list effective target, phase, and op, while lifecycle hooks list event, phase, op, and effects. remediation (when present) is {kind, description, ...payload} where kind is remove_config_key | rm_path | human_action; fault human_action remediations include the canonical logs command. Sorted by scope then name.' },
|
|
582
583
|
{ name: 'ok', type: 'boolean', required: true, constraint: 'True when no unresolved fail checks remain.' },
|
|
583
584
|
],
|
|
584
585
|
outputKind: 'object',
|
|
@@ -28,7 +28,7 @@ import { ApiError } from '../../api/errors.js';
|
|
|
28
28
|
// debounce window before asserting, exactly as against the real 800/1000 cadence.
|
|
29
29
|
const TICK_MS = 20;
|
|
30
30
|
const DEBOUNCE_MS = 25;
|
|
31
|
-
const SETTLE_MS = TICK_MS * 2 + DEBOUNCE_MS +
|
|
31
|
+
const SETTLE_MS = TICK_MS * 2 + DEBOUNCE_MS + 200;
|
|
32
32
|
let origHome;
|
|
33
33
|
let origNode;
|
|
34
34
|
const homes = [];
|
|
@@ -302,7 +302,7 @@ describe('canvas inbox watcher — hold + idle delivery', () => {
|
|
|
302
302
|
},
|
|
303
303
|
};
|
|
304
304
|
const drive = createEngineDriver({
|
|
305
|
-
registry: { sendFrame: () => undefined },
|
|
305
|
+
registry: { sendFrame: () => undefined, broadcast: () => undefined },
|
|
306
306
|
replies: {},
|
|
307
307
|
session: () => session,
|
|
308
308
|
dispatches: { track: (promise) => promise },
|
|
@@ -192,7 +192,7 @@ describe('terminal ticket delivery', () => {
|
|
|
192
192
|
await deliverTerminalResult(bridge);
|
|
193
193
|
assert.equal(getNode(companion)?.final_report, finalReport, 'terminal replay preserves the first canonical final');
|
|
194
194
|
assert.equal(getNode(companion)?.finalized_at, finalizedAt, 'terminal replay preserves the first finalization time');
|
|
195
|
-
assert.
|
|
195
|
+
await assert.rejects(reviveNode(companion, { resume: true, capacity: 'freeze' }), /finalization latch/);
|
|
196
196
|
const { bridge: dismissedBridge } = bridgeUnder();
|
|
197
197
|
const dismissedDir = submitBridgePage(dismissedBridge);
|
|
198
198
|
const dismissedCompanion = spawnNode({ kind: 'general', parent: dismissedBridge, creator: dismissedBridge, cwd: home, name: 'dismissed feedback companion', lifecycle: 'resident' }).node_id;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import { contextDir, jobDir, nodeDir, reportsDir } from '../../canvas/paths.js';
|
|
6
|
+
import { validateHookManifest } from '../../command-hooks/schema.js';
|
|
7
|
+
import { headlessBrokerHost } from '../../runtime/host.js';
|
|
8
|
+
import { reviveNode } from '../../runtime/revive.js';
|
|
9
|
+
import { createHarness } from '../helpers/harness.js';
|
|
10
|
+
function installLifecyclePlugin(harness, name, op) {
|
|
11
|
+
const ops = typeof op === 'string' ? [op] : op;
|
|
12
|
+
const root = join(harness.agentHome, '.crouter', 'plugins', name);
|
|
13
|
+
mkdirSync(join(root, '.crouter-plugin'), { recursive: true });
|
|
14
|
+
writeFileSync(join(root, '.crouter-plugin', 'plugin.json'), JSON.stringify({
|
|
15
|
+
name,
|
|
16
|
+
version: '0.1.0',
|
|
17
|
+
description: 'lifecycle hook fixture',
|
|
18
|
+
hooks: 'hooks.json',
|
|
19
|
+
hookExecutable: 'bin/hook.js',
|
|
20
|
+
}));
|
|
21
|
+
writeFileSync(join(root, 'hooks.json'), JSON.stringify({
|
|
22
|
+
schemaVersion: 2,
|
|
23
|
+
lifecycle: ops.map((entry) => ({ event: 'node:start', phase: 'on', op: entry, description: `${entry} fixture`, effects: ['writes fixture state'] })),
|
|
24
|
+
}));
|
|
25
|
+
const executable = join(root, 'bin', 'hook.js');
|
|
26
|
+
mkdirSync(join(root, 'bin'), { recursive: true });
|
|
27
|
+
writeFileSync(executable, `#!/usr/bin/env node
|
|
28
|
+
const { existsSync, writeFileSync } = require('node:fs');
|
|
29
|
+
const { join } = require('node:path');
|
|
30
|
+
let input = '';
|
|
31
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
32
|
+
process.stdin.on('end', () => {
|
|
33
|
+
const request = JSON.parse(input);
|
|
34
|
+
writeFileSync(join(request.node.contextDir, 'lifecycle-order'), request.op + '\\n', { flag: 'a' });
|
|
35
|
+
if (request.op === 'record') writeFileSync(join(request.node.contextDir, 'lifecycle-request.json'), JSON.stringify(request));
|
|
36
|
+
if (request.op === 'hang') {
|
|
37
|
+
writeFileSync(join(request.node.contextDir, 'lifecycle-hang-started'), 'started');
|
|
38
|
+
setInterval(() => {}, 1000);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (request.op === 'bad') {
|
|
42
|
+
process.stdout.write('not a JSON envelope');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (request.op === 'nonzero') {
|
|
46
|
+
writeFileSync(join(request.node.contextDir, 'lifecycle-nonzero-started'), 'started');
|
|
47
|
+
process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true }));
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true }));
|
|
52
|
+
});
|
|
53
|
+
`);
|
|
54
|
+
chmodSync(executable, 0o755);
|
|
55
|
+
}
|
|
56
|
+
function installV1CommandHook(harness) {
|
|
57
|
+
const root = join(harness.agentHome, '.crouter', 'plugins', 'z-v1-command');
|
|
58
|
+
mkdirSync(join(root, '.crouter-plugin'), { recursive: true });
|
|
59
|
+
writeFileSync(join(root, '.crouter-plugin', 'plugin.json'), JSON.stringify({
|
|
60
|
+
name: 'z-v1-command', version: '0.1.0', description: 'v1 command hook fixture', hooks: 'hooks.json', hookExecutable: 'bin/hook.js',
|
|
61
|
+
}));
|
|
62
|
+
writeFileSync(join(root, 'hooks.json'), JSON.stringify({
|
|
63
|
+
schemaVersion: 1,
|
|
64
|
+
hooks: [{ target: 'sys version', phase: 'before', op: 'v1_check', description: 'v1 command hook', effects: ['writes fixture state'] }],
|
|
65
|
+
}));
|
|
66
|
+
const executable = join(root, 'bin', 'hook.js');
|
|
67
|
+
mkdirSync(join(root, 'bin'), { recursive: true });
|
|
68
|
+
writeFileSync(executable, `#!/usr/bin/env node
|
|
69
|
+
const { writeFileSync } = require('node:fs');
|
|
70
|
+
const { join } = require('node:path');
|
|
71
|
+
let input = '';
|
|
72
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
73
|
+
process.stdin.on('end', () => {
|
|
74
|
+
JSON.parse(input);
|
|
75
|
+
writeFileSync(join(__dirname, '..', 'v1-ran'), 'ran');
|
|
76
|
+
process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true }));
|
|
77
|
+
});
|
|
78
|
+
`);
|
|
79
|
+
chmodSync(executable, 0o755);
|
|
80
|
+
return join(root, 'v1-ran');
|
|
81
|
+
}
|
|
82
|
+
test('node:start hooks receive resolved paths before broker launch; invalid and hanging hooks do not block launch', async () => {
|
|
83
|
+
const harness = await createHarness({ headless: true, sessionPrefix: 'crtr-lifecycle-hooks' });
|
|
84
|
+
const originalLaunch = headlessBrokerHost.launch;
|
|
85
|
+
try {
|
|
86
|
+
installLifecyclePlugin(harness, 'aa-lifecycle-order', ['first', 'second']);
|
|
87
|
+
installLifecyclePlugin(harness, 'a-lifecycle-record', 'record');
|
|
88
|
+
installLifecyclePlugin(harness, 'b-lifecycle-bad', 'bad');
|
|
89
|
+
installLifecyclePlugin(harness, 'c-lifecycle-nonzero', 'nonzero');
|
|
90
|
+
installLifecyclePlugin(harness, 'd-lifecycle-hang', 'hang');
|
|
91
|
+
const v1Marker = installV1CommandHook(harness);
|
|
92
|
+
const show = harness.cli(null, ['--json', 'pkg', 'plugin', 'show', 'a-lifecycle-record', '--scope', 'user']);
|
|
93
|
+
assert.equal(show.code, 0, show.stderr);
|
|
94
|
+
assert.deepEqual(show.json.hooks.lifecycle, [{
|
|
95
|
+
event: 'node:start', phase: 'on', op: 'record', description: 'record fixture', effects: ['writes fixture state'],
|
|
96
|
+
}]);
|
|
97
|
+
const doctor = harness.cli(null, ['--json', 'sys', 'doctor', '--scope', 'user']);
|
|
98
|
+
assert.equal(doctor.code, 0, doctor.stderr);
|
|
99
|
+
const hookCheck = doctor.json.checks.find((check) => check.name === 'plugin:a-lifecycle-record:hooks');
|
|
100
|
+
assert.match(hookCheck?.message ?? '', /node:start \[on record; effects: writes fixture state\]/);
|
|
101
|
+
const nodeId = harness.fabricateBrokerNode({ id: 'lifecycle-hook-node', pi_pid: null, pi_session_id: null, pi_session_file: null });
|
|
102
|
+
const requestPath = join(contextDir(nodeId), 'lifecycle-request.json');
|
|
103
|
+
let requestExistedBeforeLaunch = false;
|
|
104
|
+
headlessBrokerHost.launch = ((id, ...args) => {
|
|
105
|
+
if (id === nodeId)
|
|
106
|
+
requestExistedBeforeLaunch = existsSync(requestPath);
|
|
107
|
+
return originalLaunch.call(headlessBrokerHost, id, ...args);
|
|
108
|
+
});
|
|
109
|
+
const startedAt = Date.now();
|
|
110
|
+
const starting = reviveNode(nodeId, { resume: false, capacity: 'freeze' });
|
|
111
|
+
const contender = await reviveNode(nodeId, { resume: false, capacity: 'freeze' });
|
|
112
|
+
const result = await starting;
|
|
113
|
+
const elapsedMs = Date.now() - startedAt;
|
|
114
|
+
await harness.awaitBoot(nodeId);
|
|
115
|
+
assert.equal(result.outcome, 'launched');
|
|
116
|
+
assert.equal(contender.outcome, 'already-live', 'a concurrent revive cannot cross the awaited hook gap');
|
|
117
|
+
assert.equal(requestExistedBeforeLaunch, true, 'the lifecycle hook finishes before broker launch');
|
|
118
|
+
assert.equal(existsSync(join(contextDir(nodeId), 'lifecycle-nonzero-started')), true, 'a non-zero executable ran without blocking launch');
|
|
119
|
+
assert.equal(existsSync(join(contextDir(nodeId), 'lifecycle-hang-started')), true, 'the hanging executable started and was subject to the lifecycle timeout');
|
|
120
|
+
assert.ok(elapsedMs >= 4_500 && elapsedMs < 8_000, `the 5s hanging hook was bounded before launch (${elapsedMs}ms)`);
|
|
121
|
+
assert.deepEqual(readFileSync(join(contextDir(nodeId), 'lifecycle-order'), 'utf8').trim().split('\n'), ['record', 'first', 'second', 'bad', 'nonzero', 'hang'], 'lifecycle hooks preserve plugin-name then declaration-index discovery order');
|
|
122
|
+
assert.equal(existsSync(v1Marker), false, 'a command-only v1 manifest adds no node-start subprocess');
|
|
123
|
+
const request = JSON.parse(readFileSync(requestPath, 'utf8'));
|
|
124
|
+
assert.equal(request.protocolVersion, 1);
|
|
125
|
+
assert.equal(request.op, 'record');
|
|
126
|
+
assert.equal(request.event, 'node:start');
|
|
127
|
+
assert.equal(request.phase, 'on');
|
|
128
|
+
assert.match(request.operationId, /^[0-9a-f]{32}$/);
|
|
129
|
+
assert.deepEqual(request.context, { cwd: harness.node(nodeId).cwd });
|
|
130
|
+
assert.deepEqual(request.node, {
|
|
131
|
+
id: nodeId,
|
|
132
|
+
name: nodeId,
|
|
133
|
+
kind: 'developer',
|
|
134
|
+
mode: 'base',
|
|
135
|
+
lifecycle: 'terminal',
|
|
136
|
+
cwd: harness.node(nodeId).cwd,
|
|
137
|
+
nodeDir: nodeDir(nodeId),
|
|
138
|
+
contextDir: contextDir(nodeId),
|
|
139
|
+
jobDir: jobDir(nodeId),
|
|
140
|
+
reportsDir: reportsDir(nodeId),
|
|
141
|
+
});
|
|
142
|
+
assert.deepEqual(request.runtime, { isBirth: true, canvasHome: harness.home, profile: null });
|
|
143
|
+
const v1Command = harness.cli(null, ['--json', 'sys', 'version']);
|
|
144
|
+
assert.equal(v1Command.code, 0, v1Command.stderr);
|
|
145
|
+
assert.equal(existsSync(v1Marker), true, 'a v1 command hook still executes around its declared command');
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
headlessBrokerHost.launch = originalLaunch;
|
|
149
|
+
await harness.dispose();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
test('hooks.json v1 remains command-only while v2 accepts a lifecycle-only declaration', () => {
|
|
153
|
+
const v1 = validateHookManifest({
|
|
154
|
+
schemaVersion: 1,
|
|
155
|
+
hooks: [{ target: 'canvas list', phase: 'before', op: 'check', description: 'check', effects: ['writes a log'] }],
|
|
156
|
+
});
|
|
157
|
+
assert.deepEqual(v1, {
|
|
158
|
+
manifest: {
|
|
159
|
+
schemaVersion: 1,
|
|
160
|
+
hooks: [{ target: 'canvas list', phase: 'before', op: 'check', description: 'check', effects: ['writes a log'] }],
|
|
161
|
+
},
|
|
162
|
+
issues: [],
|
|
163
|
+
});
|
|
164
|
+
const v2 = validateHookManifest({
|
|
165
|
+
schemaVersion: 2,
|
|
166
|
+
lifecycle: [{ event: 'node:start', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }],
|
|
167
|
+
});
|
|
168
|
+
assert.deepEqual(v2.issues, []);
|
|
169
|
+
assert.deepEqual(v2.manifest, {
|
|
170
|
+
schemaVersion: 2,
|
|
171
|
+
hooks: [],
|
|
172
|
+
lifecycle: [{ event: 'node:start', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }],
|
|
173
|
+
});
|
|
174
|
+
for (const manifest of [
|
|
175
|
+
{ schemaVersion: 2, hooks: [], lifecycle: [] },
|
|
176
|
+
{ schemaVersion: 2, lifecycle: [{ event: 'node:stop', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }] },
|
|
177
|
+
{ schemaVersion: 2, lifecycle: [{ event: 'node:start', phase: 'before', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }] },
|
|
178
|
+
{ schemaVersion: 2, lifecycle: [{ event: 'node:start', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'], unexpected: true }] },
|
|
179
|
+
]) {
|
|
180
|
+
assert.equal(validateHookManifest(manifest).manifest, undefined, 'invalid v2 lifecycle declarations are rejected');
|
|
181
|
+
}
|
|
182
|
+
});
|
|
@@ -231,7 +231,7 @@ test('reviveNode no-ops when the node has a live fleet entry — double-revive g
|
|
|
231
231
|
});
|
|
232
232
|
h.fleet.register(M, { pid: 424242, exited: new Promise(() => { }) });
|
|
233
233
|
const before = h.node(M).cycles ?? 0;
|
|
234
|
-
const res = reviveNode(M, { resume: true, capacity: 'freeze' });
|
|
234
|
+
const res = await reviveNode(M, { resume: true, capacity: 'freeze' });
|
|
235
235
|
assert.equal(res.resumed, false, 'guard path does not re-resume');
|
|
236
236
|
assert.equal(h.node(M).cycles ?? 0, before, 'cycle counter NOT bumped (guard returned early)');
|
|
237
237
|
}
|
|
@@ -259,7 +259,7 @@ test('reviveNode PROCEEDS when the node has NO fleet entry — cycle counter bum
|
|
|
259
259
|
pi_session_file: sessionFile,
|
|
260
260
|
});
|
|
261
261
|
assert.equal(h.node(M).cycles ?? 0, 0, 'fabricated at cycle 0 (no revive yet)');
|
|
262
|
-
reviveNode(M, { resume: true, capacity: 'freeze' });
|
|
262
|
+
await reviveNode(M, { resume: true, capacity: 'freeze' });
|
|
263
263
|
const after = h.node(M);
|
|
264
264
|
assert.equal(after.cycles, 1, 'a real (non-guard) revive bumped the cycle counter to 1');
|
|
265
265
|
assert.notEqual(after.pi_pid, stalePid, 'revive records the fresh broker pid immediately, not the stale dead pid');
|
|
@@ -303,11 +303,11 @@ test('a recovery relaunch keeps the armed deadline; a wake revive consumes it',
|
|
|
303
303
|
try {
|
|
304
304
|
const recovered = h.fabricateBrokerNode({ status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'uuid-1' });
|
|
305
305
|
armDeadline(recovered, 'cron-recovery');
|
|
306
|
-
reviveNode(recovered, { resume: true, recovery: true, capacity: 'freeze' });
|
|
306
|
+
await reviveNode(recovered, { resume: true, recovery: true, capacity: 'freeze' });
|
|
307
307
|
assert.equal(hasPendingCancelOnWakeCron(recovered), true, 'the daemon replacing a dead broker is not a wake — the deadline survives so the wait can still settle');
|
|
308
308
|
const woken = h.fabricateBrokerNode({ status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'uuid-2' });
|
|
309
309
|
armDeadline(woken, 'cron-wake');
|
|
310
|
-
reviveNode(woken, { resume: true, capacity: 'freeze' });
|
|
310
|
+
await reviveNode(woken, { resume: true, capacity: 'freeze' });
|
|
311
311
|
assert.equal(hasPendingCancelOnWakeCron(woken), false, 'a wake revive won the race and cancels the deadline');
|
|
312
312
|
}
|
|
313
313
|
finally {
|
|
@@ -358,7 +358,7 @@ test('reviveNode fresh-fallback clears the stale session identity (Major-3: no p
|
|
|
358
358
|
pi_session_id: 'uuid-stale',
|
|
359
359
|
pi_session_file: missingFile,
|
|
360
360
|
});
|
|
361
|
-
const res = reviveNode(M, { resume: true, capacity: 'freeze' });
|
|
361
|
+
const res = await reviveNode(M, { resume: true, capacity: 'freeze' });
|
|
362
362
|
assert.equal(res.resumed, false, 'a missing .jsonl forces a fresh (non-resuming) launch');
|
|
363
363
|
const after = h.node(M);
|
|
364
364
|
assert.equal(after.pi_session_id, null, 'stale pi_session_id cleared before the fresh launch');
|
|
@@ -370,7 +370,7 @@ test('reviveNode fresh-fallback clears the stale session identity (Major-3: no p
|
|
|
370
370
|
await h.dispose();
|
|
371
371
|
}
|
|
372
372
|
});
|
|
373
|
-
test('unbound forks replay one durable source file for node-id, path, and UUID operands', () => {
|
|
373
|
+
test('unbound forks replay one durable source file for node-id, path, and UUID operands', async () => {
|
|
374
374
|
const originalLaunch = headlessBrokerHost.launch;
|
|
375
375
|
const tf = bindTestFleet();
|
|
376
376
|
const forkFile = join(home, 'ordinary-fork-source.jsonl');
|
|
@@ -404,7 +404,7 @@ test('unbound forks replay one durable source file for node-id, path, and UUID o
|
|
|
404
404
|
deleteNode('ordinary-fork-source');
|
|
405
405
|
for (let index = 0; index < operands.length; index++) {
|
|
406
406
|
const destination = `ordinary-fork-${index}`;
|
|
407
|
-
reviveNode(destination, { resume: index !== 1, capacity: 'freeze' });
|
|
407
|
+
await reviveNode(destination, { resume: index !== 1, capacity: 'freeze' });
|
|
408
408
|
const argv = launches[index];
|
|
409
409
|
const fork = argv.indexOf('--fork');
|
|
410
410
|
assert.equal(argv[fork + 1], forkFile, `${operands[index]} retries from the durable resolved file`);
|
|
@@ -437,7 +437,7 @@ test('reviveNode throws a typed cwd_missing error and does NOT crash the node wh
|
|
|
437
437
|
pi_pid: deadPid(),
|
|
438
438
|
});
|
|
439
439
|
updateNode(M, { cwd: missingCwd });
|
|
440
|
-
assert.
|
|
440
|
+
await assert.rejects(reviveNode(M, { resume: true, capacity: 'freeze' }), (e) => e instanceof InputError && e.payload.error === 'cwd_missing' && e.message.includes(missingCwd), 'reviveNode must throw a typed InputError naming the missing cwd path');
|
|
441
441
|
const after = h.node(M);
|
|
442
442
|
assert.equal(after.status, 'active', 'a cwd-missing preflight refusal must NOT crash the node (fixable in place, not a dead engine)');
|
|
443
443
|
}
|
|
@@ -455,7 +455,7 @@ test('reviveNode relaunches fresh when the session header cwd was removed (NL-14
|
|
|
455
455
|
const M = h.fabricateBrokerNode({
|
|
456
456
|
status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'stale-session', pi_session_file: sessionFile,
|
|
457
457
|
});
|
|
458
|
-
const result = reviveNode(M, { resume: true, capacity: 'freeze', recovery: true });
|
|
458
|
+
const result = await reviveNode(M, { resume: true, capacity: 'freeze', recovery: true });
|
|
459
459
|
assert.equal(result.resumed, false, 'the missing session cwd vetoes --session and launches fresh');
|
|
460
460
|
assert.equal(h.node(M)?.pi_session_id, null, 'the stale session id is cleared before the fresh launch');
|
|
461
461
|
assert.equal(h.node(M)?.pi_session_file, null, 'the stale session file is cleared before the fresh launch');
|
|
@@ -479,7 +479,7 @@ test('reviveNode crashes and clears pid when launch/preflight throws', async ()
|
|
|
479
479
|
pi_session_file: sessionFile,
|
|
480
480
|
});
|
|
481
481
|
headlessBrokerHost.launch = (_nodeId, _inv, _opts) => { throw new Error('boom'); };
|
|
482
|
-
assert.
|
|
482
|
+
await assert.rejects(reviveNode(M, { resume: true, capacity: 'freeze' }), /refusing to revive/i);
|
|
483
483
|
const after = h.node(M);
|
|
484
484
|
assert.equal(after.status, 'dead', 'failed revive marks the node dead');
|
|
485
485
|
assert.equal(after.pi_pid, null, 'failed revive clears the stale pid');
|
|
@@ -512,7 +512,7 @@ test('reviveNode fans a doctrine wake to subscribers when launch/preflight throw
|
|
|
512
512
|
// was fabricated directly, not spawned through the CLI.
|
|
513
513
|
subscribe(root, M, true);
|
|
514
514
|
headlessBrokerHost.launch = (_nodeId, _inv, _opts) => { throw new Error('boom'); };
|
|
515
|
-
assert.
|
|
515
|
+
await assert.rejects(reviveNode(M, { resume: true, capacity: 'freeze' }), /refusing to revive/i);
|
|
516
516
|
assert.equal(h.node(M).status, 'dead', 'failed revive marks the node dead');
|
|
517
517
|
const entries = readInboxSince(root, undefined);
|
|
518
518
|
const notice = entries.find((e) => e.data?.['reason'] === 'child-crashed' && e.data?.['child'] === M);
|
|
@@ -532,7 +532,7 @@ test('reviveNode fans a doctrine wake to subscribers when launch/preflight throw
|
|
|
532
532
|
// nothing. It woke, flailed over an empty context dir and reported itself
|
|
533
533
|
// confused. Such a relaunch is a BIRTH: replay the spawn mandate if there is
|
|
534
534
|
// one, else boot promptless and take no turn at all.
|
|
535
|
-
test('an unstarted node relaunches as a birth, not a "revived fresh" kickoff', () => {
|
|
535
|
+
test('an unstarted node relaunches as a birth, not a "revived fresh" kickoff', async () => {
|
|
536
536
|
const originalLaunch = headlessBrokerHost.launch;
|
|
537
537
|
const tf = bindTestFleet();
|
|
538
538
|
const launches = [];
|
|
@@ -547,19 +547,19 @@ test('an unstarted node relaunches as a birth, not a "revived fresh" kickoff', (
|
|
|
547
547
|
const missing = join(home, 'sessions', 'never-written.jsonl');
|
|
548
548
|
// (1) Bare interactive node — nothing on disk anywhere: launch promptless.
|
|
549
549
|
createNode(node('bare-unstarted', { cwd: home, pi_session_id: 'uuid-a', pi_session_file: missing }));
|
|
550
|
-
reviveNode('bare-unstarted', { resume: true, capacity: 'freeze' });
|
|
550
|
+
await reviveNode('bare-unstarted', { resume: true, capacity: 'freeze' });
|
|
551
551
|
const bare = launches[0];
|
|
552
552
|
assert.equal(bare.some((a) => isReviveKickoff(a)), false, 'an unstarted node is never told it lost context it never had');
|
|
553
553
|
assert.deepEqual(bare, buildPiArgv(getNode('bare-unstarted'), {}).argv, 'a promptless boot — the argv carries no trailing prompt positional at all');
|
|
554
554
|
// (2) Spawned with a mandate whose first turn never persisted: replay it.
|
|
555
555
|
createNode(node('mandate-unstarted', { cwd: home, pi_session_id: 'uuid-b', pi_session_file: missing }));
|
|
556
556
|
writeGoal('mandate-unstarted', 'do the thing');
|
|
557
|
-
reviveNode('mandate-unstarted', { resume: true, capacity: 'freeze' });
|
|
557
|
+
await reviveNode('mandate-unstarted', { resume: true, capacity: 'freeze' });
|
|
558
558
|
assert.equal(launches[1].at(-1), 'do the thing', 'a birth replays the spawn mandate');
|
|
559
559
|
// (3) Real durable progress on disk → still a genuine fresh revive.
|
|
560
560
|
createNode(node('progressed', { cwd: home, pi_session_id: 'uuid-c', pi_session_file: missing }));
|
|
561
561
|
seedRoadmap('progressed', { goal: 'ship it' });
|
|
562
|
-
reviveNode('progressed', { resume: true, capacity: 'freeze' });
|
|
562
|
+
await reviveNode('progressed', { resume: true, capacity: 'freeze' });
|
|
563
563
|
assert.equal(isReviveKickoff(launches[2].at(-1)), true, 'a node with a roadmap still rebuilds from the kickoff');
|
|
564
564
|
}
|
|
565
565
|
finally {
|
|
@@ -179,6 +179,6 @@ test('reviveNode refuses to revive a node that became finalized after the caller
|
|
|
179
179
|
process.env['CRTR_NODE_ID'] = id;
|
|
180
180
|
await pushFinal(id, 'concurrent final');
|
|
181
181
|
assert.equal(getNode(id)?.status, 'done');
|
|
182
|
-
assert.
|
|
182
|
+
await assert.rejects(reviveNode(id, { resume: true, capacity: 'freeze' }), /finaliz/i, 'reviveNode\'s own belt-and-suspenders check refuses a now-latched target');
|
|
183
183
|
assert.equal(getNode(id)?.status, 'done', 'the node stays done, never silently revived into an unsatisfiable mandate');
|
|
184
184
|
});
|
|
@@ -52,9 +52,9 @@ function node(id, over = {}) {
|
|
|
52
52
|
return id;
|
|
53
53
|
}
|
|
54
54
|
/** Fill the ledger to the cap with live brokers. */
|
|
55
|
-
function saturate() {
|
|
56
|
-
reviveNode(node(`filler-a-${seq}`), { resume: false, capacity: 'refuse' });
|
|
57
|
-
reviveNode(node(`filler-b-${seq}`), { resume: false, capacity: 'refuse' });
|
|
55
|
+
async function saturate() {
|
|
56
|
+
await reviveNode(node(`filler-a-${seq}`), { resume: false, capacity: 'refuse' });
|
|
57
|
+
await reviveNode(node(`filler-b-${seq}`), { resume: false, capacity: 'refuse' });
|
|
58
58
|
assert.equal(tf.fleet.size(), 2, 'precondition: the ledger is at its cap of 2');
|
|
59
59
|
}
|
|
60
60
|
before(() => {
|
|
@@ -78,10 +78,10 @@ after(() => {
|
|
|
78
78
|
delete process.env['CRTR_HOME'];
|
|
79
79
|
rmSync(home, { recursive: true, force: true });
|
|
80
80
|
});
|
|
81
|
-
test('a refusing caller gets an error naming the cap and the live count, never a silent no-op', () => {
|
|
82
|
-
saturate();
|
|
81
|
+
test('a refusing caller gets an error naming the cap and the live count, never a silent no-op', async () => {
|
|
82
|
+
await saturate();
|
|
83
83
|
const turned = node('refused', { pi_session_id: 'sess-refused' });
|
|
84
|
-
assert.
|
|
84
|
+
await assert.rejects(() => reviveNode(turned, { resume: true, capacity: 'refuse' }), (err) => {
|
|
85
85
|
const details = err.details ?? {};
|
|
86
86
|
assert.equal(details['error'], 'broker_cap_reached', 'the refusal is a typed InputError, so it survives the /v1 error body');
|
|
87
87
|
assert.match(String(details['message']), /2 live brokers at the cap of 2/, 'the message names both the live count and the cap');
|
|
@@ -110,21 +110,21 @@ test('a pre-spawn launch failure releases the reservation', () => {
|
|
|
110
110
|
stubLaunch();
|
|
111
111
|
}
|
|
112
112
|
});
|
|
113
|
-
test('a freezing caller gets a frozen row, marked once and left otherwise untouched', () => {
|
|
114
|
-
saturate();
|
|
113
|
+
test('a freezing caller gets a frozen row, marked once and left otherwise untouched', async () => {
|
|
114
|
+
await saturate();
|
|
115
115
|
const waiting = node('frozen', { pi_session_id: 'sess-frozen' });
|
|
116
|
-
const first = reviveNode(waiting, { resume: true, capacity: 'freeze' });
|
|
116
|
+
const first = await reviveNode(waiting, { resume: true, capacity: 'freeze' });
|
|
117
117
|
assert.equal(first.outcome, 'frozen', 'the caller learns it got no engine');
|
|
118
118
|
const marked = getNode(waiting);
|
|
119
119
|
assert.ok(marked.frozen_at != null, 'the row carries the durable wait mark the thaw order reads');
|
|
120
120
|
assert.equal(marked.pi_pid ?? null, null, 'a frozen row holds no pid — every status surface reads it as frozen, not stale-active');
|
|
121
121
|
assert.equal(marked.cycles ?? 0, 0, 'a frozen node never spent a cycle');
|
|
122
|
-
const second = reviveNode(waiting, { resume: true, capacity: 'freeze' });
|
|
122
|
+
const second = await reviveNode(waiting, { resume: true, capacity: 'freeze' });
|
|
123
123
|
assert.equal(second.outcome, 'frozen');
|
|
124
124
|
assert.equal(getNode(waiting).frozen_at, marked.frozen_at, 'freezing is once-per-episode: a second saturated tick keeps the original wait timestamp');
|
|
125
125
|
});
|
|
126
126
|
test('a durable daemon message reports capacity_frozen when its wake cannot launch', async () => {
|
|
127
|
-
saturate();
|
|
127
|
+
await saturate();
|
|
128
128
|
const waiting = node('message-frozen');
|
|
129
129
|
const delivery = await deliverNodeMessage({
|
|
130
130
|
node_id: waiting,
|
|
@@ -137,26 +137,26 @@ test('a durable daemon message reports capacity_frozen when its wake cannot laun
|
|
|
137
137
|
assert.equal(delivery.revived, false);
|
|
138
138
|
assert.equal(delivery.reason, 'capacity_frozen');
|
|
139
139
|
});
|
|
140
|
-
test('an already-live node is admitted at the cap — it consumes no new slot', () => {
|
|
140
|
+
test('an already-live node is admitted at the cap — it consumes no new slot', async () => {
|
|
141
141
|
const live = node('already-live');
|
|
142
|
-
reviveNode(live, { resume: false, capacity: 'refuse' });
|
|
143
|
-
reviveNode(node('already-live-filler'), { resume: false, capacity: 'refuse' });
|
|
142
|
+
await reviveNode(live, { resume: false, capacity: 'refuse' });
|
|
143
|
+
await reviveNode(node('already-live-filler'), { resume: false, capacity: 'refuse' });
|
|
144
144
|
assert.equal(tf.fleet.size(), 2, 'precondition: this node holds one of the two slots, and the ledger is full');
|
|
145
145
|
// Both dispositions agree: liveness is answered before capacity ever is.
|
|
146
|
-
assert.equal(reviveNode(live, { resume: true, capacity: 'refuse' }).outcome, 'already-live');
|
|
147
|
-
assert.equal(reviveNode(live, { resume: true, capacity: 'freeze' }).outcome, 'already-live');
|
|
146
|
+
assert.equal((await reviveNode(live, { resume: true, capacity: 'refuse' })).outcome, 'already-live');
|
|
147
|
+
assert.equal((await reviveNode(live, { resume: true, capacity: 'freeze' })).outcome, 'already-live');
|
|
148
148
|
assert.equal(getNode(live).frozen_at ?? null, null, 'a live node is never marked as waiting for a slot');
|
|
149
149
|
});
|
|
150
150
|
test('a freed slot lets the frozen row launch, and the launch retires the mark', async () => {
|
|
151
151
|
const filler = node('filler-exits');
|
|
152
|
-
reviveNode(filler, { resume: false, capacity: 'refuse' });
|
|
153
|
-
reviveNode(node('filler-stays'), { resume: false, capacity: 'refuse' });
|
|
152
|
+
await reviveNode(filler, { resume: false, capacity: 'refuse' });
|
|
153
|
+
await reviveNode(node('filler-stays'), { resume: false, capacity: 'refuse' });
|
|
154
154
|
const waiting = node('thaws', { pi_session_id: 'sess-thaws' });
|
|
155
|
-
assert.equal(reviveNode(waiting, { resume: true, capacity: 'freeze' }).outcome, 'frozen');
|
|
155
|
+
assert.equal((await reviveNode(waiting, { resume: true, capacity: 'freeze' })).outcome, 'frozen');
|
|
156
156
|
// A real exit frees the slot; its policy job runs on the lane like production.
|
|
157
157
|
tf.fleet.deliverExit(filler, { code: 0, signal: null });
|
|
158
158
|
await tf.drain();
|
|
159
|
-
assert.equal(reviveNode(waiting, { resume: true, capacity: 'freeze' }).outcome, 'launched');
|
|
159
|
+
assert.equal((await reviveNode(waiting, { resume: true, capacity: 'freeze' })).outcome, 'launched');
|
|
160
160
|
assert.equal(getNode(waiting).frozen_at ?? null, null, 'a launch is the definition of not-frozen');
|
|
161
161
|
assert.equal(tf.fleet.has(waiting), true, 'the thawed node owns the freed slot');
|
|
162
162
|
});
|