@north-light/crouter 0.3.269 → 0.3.270

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.
Files changed (72) hide show
  1. package/dist/api/dto/broker.d.ts +11 -2
  2. package/dist/api/dto/broker.js +2 -2
  3. package/dist/api/dto/nodes.d.ts +4 -0
  4. package/dist/clients/attach/viewer.js +406 -406
  5. package/dist/commands/pkg/plugin-inspect.js +2 -2
  6. package/dist/commands/pkg/plugin-manage.js +3 -3
  7. package/dist/commands/sys/doctor.js +4 -3
  8. package/dist/core/__tests__/human-deliver.test.js +1 -1
  9. package/dist/core/__tests__/integration/lifecycle-hooks.test.d.ts +1 -0
  10. package/dist/core/__tests__/integration/lifecycle-hooks.test.js +182 -0
  11. package/dist/core/__tests__/integration/revive.test.js +15 -15
  12. package/dist/core/__tests__/push-final-guard.test.js +1 -1
  13. package/dist/core/__tests__/revive-capacity.test.js +20 -20
  14. package/dist/core/__tests__/revive-parked-fresh.test.js +14 -14
  15. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +2 -2
  16. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
  17. package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +1 -1
  18. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +2 -2
  19. package/dist/core/command-hooks/discovery.d.ts +27 -1
  20. package/dist/core/command-hooks/discovery.js +52 -0
  21. package/dist/core/command-hooks/index.d.ts +4 -3
  22. package/dist/core/command-hooks/index.js +2 -1
  23. package/dist/core/command-hooks/lifecycle-catalog.d.ts +3 -0
  24. package/dist/core/command-hooks/lifecycle-catalog.js +4 -0
  25. package/dist/core/command-hooks/report.d.ts +11 -2
  26. package/dist/core/command-hooks/report.js +10 -1
  27. package/dist/core/command-hooks/schema.d.ts +16 -1
  28. package/dist/core/command-hooks/schema.js +128 -40
  29. package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +12 -0
  30. package/dist/core/command-hooks/transport/exec-lifecycle.js +152 -0
  31. package/dist/core/human/feedback-companion.js +1 -1
  32. package/dist/core/review/realize.js +1 -1
  33. package/dist/core/runtime/broker/engine-drive.d.ts +14 -3
  34. package/dist/core/runtime/broker/engine-drive.js +29 -1
  35. package/dist/core/runtime/broker/frame-client.js +3 -4
  36. package/dist/core/runtime/broker/rebind.js +7 -0
  37. package/dist/core/runtime/broker-protocol.d.ts +11 -2
  38. package/dist/core/runtime/broker.d.ts +1 -1
  39. package/dist/core/runtime/broker.js +13 -3
  40. package/dist/core/runtime/node-read.d.ts +2 -0
  41. package/dist/core/runtime/node-read.js +18 -9
  42. package/dist/core/runtime/revive-all.d.ts +1 -1
  43. package/dist/core/runtime/revive-all.js +2 -2
  44. package/dist/core/runtime/revive.d.ts +2 -2
  45. package/dist/core/runtime/revive.js +49 -13
  46. package/dist/core/runtime/session-visibility.d.ts +10 -14
  47. package/dist/core/runtime/session-visibility.js +43 -30
  48. package/dist/core/runtime/stamp/channel.d.ts +10 -6
  49. package/dist/core/runtime/stamp/channel.js +16 -7
  50. package/dist/core/runtime/stamp/protocol.d.ts +4 -0
  51. package/dist/core/runtime/turn-visibility.d.ts +10 -0
  52. package/dist/core/runtime/turn-visibility.js +31 -0
  53. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
  54. package/dist/daemon/api/bridge.js +1 -1
  55. package/dist/daemon/api/handlers/attach.js +4 -4
  56. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  57. package/dist/daemon/api/handlers/messages.js +2 -2
  58. package/dist/daemon/api/handlers/nodes.js +7 -4
  59. package/dist/daemon/cron/sinks.js +3 -3
  60. package/dist/daemon/fleet.js +1 -1
  61. package/dist/daemon/messaging/node-message.js +1 -1
  62. package/dist/daemon/profile-delete.js +1 -1
  63. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +6 -6
  64. package/dist/hook-authoring.d.ts +31 -1
  65. package/dist/hook-authoring.js +42 -5
  66. package/dist/index.d.ts +1 -0
  67. package/dist/index.js +3 -0
  68. package/dist/pi-extensions/canvas-stamp.js +3 -3
  69. package/dist/shared/generated-context.d.ts +1 -2
  70. package/dist/shared/generated-context.js +3 -4
  71. package/package.json +1 -1
  72. package/runtime.lock.json +2 -2
@@ -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}. Declarations, eligible targets, and replacement collisions are validated statically; hook executables are never run.' },
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 declarations = validation.hooks.map((hook) => `${hook.target} [${hook.phase} ${hook.op}]`).join('; ');
182
- results.push(pass(plugin.scope, `plugin:${plugin.name}:hooks`, `effective hook declaration(s): ${declarations}`));
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',
@@ -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.throws(() => reviveNode(companion, { resume: true, capacity: 'freeze' }), /finalization latch/);
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,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.throws(() => 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');
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.throws(() => reviveNode(M, { resume: true, capacity: 'freeze' }), /refusing to revive/i);
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.throws(() => reviveNode(M, { resume: true, capacity: 'freeze' }), /refusing to revive/i);
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.throws(() => reviveNode(id, { resume: true, capacity: 'freeze' }), /finaliz/i, 'reviveNode\'s own belt-and-suspenders check refuses a now-latched target');
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.throws(() => reviveNode(turned, { resume: true, capacity: 'refuse' }), (err) => {
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
  });
@@ -103,10 +103,10 @@ after(() => {
103
103
  else
104
104
  process.env['CRTR_HOME'] = priorHome;
105
105
  });
106
- test('a parked node with mail waiting re-enters fresh even when the caller asked to resume, and the marker clears', () => {
106
+ test('a parked node with mail waiting re-enters fresh even when the caller asked to resume, and the marker clears', async () => {
107
107
  parkResident('parked', { roadmap: '# Roadmap\n\n- one open item\n' });
108
108
  deliverMail('parked');
109
- const result = reviveNode('parked', { resume: true, capacity: 'freeze' });
109
+ const result = await reviveNode('parked', { resume: true, capacity: 'freeze' });
110
110
  assert.equal(result.resumed, false, 'the concluded transcript is not replayed');
111
111
  assert.deepEqual(placements(), [{ nodeId: 'parked', resuming: false }]);
112
112
  assert.equal(kickedOff(launches[0]), true, 'the fresh window is handed its bearings');
@@ -115,15 +115,15 @@ test('a parked node with mail waiting re-enters fresh even when the caller asked
115
115
  assert.equal(reopened.intent ?? null, null, 'the park marker clears on revival');
116
116
  assert.equal(reopened.cycle_pending, true, 'the fresh cycle is provisional until session_start confirms it');
117
117
  });
118
- test('a parked node grounded only by a goal still re-enters fresh when mail rides the revive', () => {
118
+ test('a parked node grounded only by a goal still re-enters fresh when mail rides the revive', async () => {
119
119
  parkResident('goal-only', { goal: 'Watch the deploy lane.' });
120
120
  deliverMail('goal-only');
121
- assert.equal(reviveNode('goal-only', { resume: true, capacity: 'freeze' }).resumed, false);
121
+ assert.equal((await reviveNode('goal-only', { resume: true, capacity: 'freeze' })).resumed, false);
122
122
  assert.equal(kickedOff(launches[0]), true);
123
123
  });
124
- test('an input-less revival of a parked node quietly resumes and injects no kickoff', () => {
124
+ test('an input-less revival of a parked node quietly resumes and injects no kickoff', async () => {
125
125
  parkResident('quiet', { roadmap: '# Roadmap\n\n- one open item\n' });
126
- const result = reviveNode('quiet', { resume: true, capacity: 'freeze' });
126
+ const result = await reviveNode('quiet', { resume: true, capacity: 'freeze' });
127
127
  assert.equal(result.resumed, true, 'nothing arrived, so there is nothing a fresh window would answer');
128
128
  assert.deepEqual(placements(), [{ nodeId: 'quiet', resuming: true }]);
129
129
  assert.equal(kickedOff(launches[0]), false, 'a quiet resume generates nothing');
@@ -132,29 +132,29 @@ test('an input-less revival of a parked node quietly resumes and injects no kick
132
132
  assert.equal(reopened.status, 'active');
133
133
  assert.equal(reopened.intent ?? null, null, 'the park marker clears on a quiet resume too');
134
134
  });
135
- test('deferred mail alone is not input, so the revival still resumes quietly', () => {
135
+ test('deferred mail alone is not input, so the revival still resumes quietly', async () => {
136
136
  parkResident('deferred', { roadmap: '# Roadmap\n\n- one open item\n' });
137
137
  deliverMail('deferred', 'deferred');
138
- assert.equal(reviveNode('deferred', { resume: true, capacity: 'freeze' }).resumed, true);
138
+ assert.equal((await reviveNode('deferred', { resume: true, capacity: 'freeze' })).resumed, true);
139
139
  assert.equal(kickedOff(launches[0]), false);
140
140
  assert.equal(readCursor('deferred'), undefined, 'the deferred entry rides the next natural cycle, unconsumed');
141
141
  });
142
- test('mail already covered by the cursor is not input, so the revival still resumes quietly', () => {
142
+ test('mail already covered by the cursor is not input, so the revival still resumes quietly', async () => {
143
143
  parkResident('stale', { roadmap: '# Roadmap\n\n- one open item\n' });
144
144
  writeCursor('stale', deliverMail('stale'));
145
- assert.equal(reviveNode('stale', { resume: true, capacity: 'freeze' }).resumed, true, 'only the UNSEEN suffix counts as input');
145
+ assert.equal((await reviveNode('stale', { resume: true, capacity: 'freeze' })).resumed, true, 'only the UNSEEN suffix counts as input');
146
146
  assert.equal(kickedOff(launches[0]), false);
147
147
  });
148
- test('a parked node with no goal and no roadmap resumes its transcript rather than waking amnesiac', () => {
148
+ test('a parked node with no goal and no roadmap resumes its transcript rather than waking amnesiac', async () => {
149
149
  parkResident('bare');
150
150
  deliverMail('bare');
151
- const result = reviveNode('bare', { resume: true, capacity: 'freeze' });
151
+ const result = await reviveNode('bare', { resume: true, capacity: 'freeze' });
152
152
  assert.equal(result.resumed, true, 'the degraded park has nothing on disk to ground a fresh window');
153
153
  assert.deepEqual(placements(), [{ nodeId: 'bare', resuming: true }]);
154
154
  });
155
- test('an ordinary done node with a roadmap still resumes — only the park marker vetoes', () => {
155
+ test('an ordinary done node with a roadmap still resumes — only the park marker vetoes', async () => {
156
156
  parkResident('finished', { roadmap: '# Roadmap\n\n- one open item\n' });
157
157
  transition('finished', 'revive');
158
158
  transition('finished', 'finish');
159
- assert.equal(reviveNode('finished', { resume: true, capacity: 'freeze' }).resumed, true);
159
+ assert.equal((await reviveNode('finished', { resume: true, capacity: 'freeze' })).resumed, true);
160
160
  });
@@ -117,7 +117,7 @@ test('a birth at the cap freezes instead of booting, survives saturated ticks, a
117
117
  const filler = await h.spawnHeadlessChild(root, 'terminal thaw filler');
118
118
  assert.equal(h.fleet.size(), 2, 'the parked target is non-live while the cap remains full');
119
119
  appendInbox(second, { from: root, tier: 'normal', kind: 'message', label: 'durable wake' });
120
- assert.equal(reviveNode(second, { resume: true, capacity: 'freeze' }).outcome, 'frozen');
120
+ assert.equal((await reviveNode(second, { resume: true, capacity: 'freeze' })).outcome, 'frozen');
121
121
  closeDb();
122
122
  const frozenTerminal = h.node(second);
123
123
  assert.equal(frozenTerminal.status, 'done', 'freezing preserves the terminal status');
@@ -144,7 +144,7 @@ test('a birth at the cap freezes instead of booting, survives saturated ticks, a
144
144
  created: new Date().toISOString(),
145
145
  pi_session_id: 'resident-drain-session',
146
146
  });
147
- assert.equal(reviveNode('resident-drain', { resume: true, capacity: 'freeze' }).outcome, 'frozen');
147
+ assert.equal((await reviveNode('resident-drain', { resume: true, capacity: 'freeze' })).outcome, 'frozen');
148
148
  await h.tick();
149
149
  closeDb();
150
150
  const drained = h.node('resident-drain');