Skip to content

Commit 3e5073a

Browse files
neo-opus-adatobiuAda (Claude Opus 4.8)
authored
feat(control-plane): daemon-core lifecycle-write restart actuator — the R3 seam (#14760) (#14792)
* feat(control-plane): daemon-core lifecycle-write restart actuator — the R3 seam (#14760) Graduation leaf of Discussion #14501 (the 3rd control-plane leaf) and #14477 Leaf-2 — now unblocked by the merged ADR-0026 amendment (#14758). Creates the net-new ai/daemons/orchestrator/control-plane/ dir: the folder-domain IS the R3 boundary (control-plane/ = lifecycle-write divide diagnostics/ = read-observe). restartRuntimeTarget({runtimeAccess, serviceKey, reason}) delegates the restart THROUGH the ADR-0026 lifecycle-write envelope (DeploymentRuntimeAccessService.applyLifecycle) — never a direct restart, so the envelope's allowlisted-service-key + anti-thrash + operation-gate guarantees are inherited, not re-derived. runtimeAccess is injected (the Orchestrator holds the L0 instance), keeping the module a pure testable delegation. Fail-safe: an unwired runtimeAccess or a missing serviceKey is REFUSED, never a fabricated success. 4 specs green: envelope delegation, both refusals, and a structural R3 firewall (scans src/ai/fleet + the AgentOS pane + the container healthcheck, asserts NONE import the actuator — physically off-client). Distinct from the client-reachable FM restartAgent (out of scope). Scope: the endpoint + its off-client firewall. The live wiring (an orchestrator control-plane caller invoking this with its DeploymentRuntimeAccessService instance) is the integration point. Co-Authored-By: Ada (Claude Opus 4.8) <ada@neomjs.dev> * fix(agentos): control-plane restart actuator honors lifecycle-envelope refusal (#14760) restartRuntimeTarget wrapped applyLifecycle's outcome as {ok:true} unconditionally. The lifecycle-write envelope signals a refusal (disabled / unsupported mechanism / disallowed op / non-allowlisted service key) by THROWING, so a raw throw leaked to the caller; and an explicit {ok:false} envelope result would have been masked as success. Now the endpoint catches the throw and normalizes an {ok:false} result, returning a clean {ok:false, error} so a false success is impossible on any refusal shape. Two focused specs cover the throw-refusal and the explicit-{ok:false}-refusal paths (6/6 green). Addresses @neo-gpt's review on #14792. --------- Co-authored-by: tobiu <tobiasuhlig78@gmail.com> Co-authored-by: Ada (Claude Opus 4.8) <ada@neomjs.dev>
1 parent 481fd34 commit 3e5073a

2 files changed

Lines changed: 137 additions & 0 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* @module ai/daemons/orchestrator/control-plane/restartActuator
3+
* @summary The daemon-core lifecycle-write restart-actuator endpoint (ticket-ref-ok: #14760 owning-leaf anchor, #14477 parent-epic).
4+
*
5+
* The lifecycle-write half of the R3 boundary. Its **placement is its authority**: living under `control-plane/`
6+
* (lifecycle-write) rather than `diagnostics/` (read-observe) IS the structural R3 seam — one folder per
7+
* envelope. It is **physically absent from every client Bridge / readiness surface** (not on
8+
* `FLEET_WIRE_METHODS`, not on the `registryBridge`, not on a healthcheck): no client RPC may hold or imply a
9+
* restart. Only an orchestrator-internal control-plane caller reaches it, and the actual restart is executed
10+
* **through** the `lifecycle-write` envelope (`DeploymentRuntimeAccessService.applyLifecycle`) — this endpoint
11+
* never restarts a target directly, so the envelope's allowlisted-service-key + anti-thrash + operation-gate
12+
* guarantees are inherited, not re-derived.
13+
*
14+
* Distinct from the existing Fleet Manager `restartAgent` (client-reachable via `createFleetRegistryBridge`),
15+
* which is an already-shipped operator-UI lifecycle control and out of scope here.
16+
*
17+
* `runtimeAccess` is injected (the Orchestrator holds the `DeploymentRuntimeAccessService` instance) rather than
18+
* hard-imported — the module stays a pure, testable delegation with no ambient singleton reach.
19+
*/
20+
21+
/**
22+
* @summary Control-plane restart of a known runtime target, routed through the lifecycle-write
23+
* envelope. Fail-safe: an absent or misconfigured `runtimeAccess`, or a missing `serviceKey`, is **refused** —
24+
* never a fabricated success and never a direct restart that would bypass the envelope's guards.
25+
* @param {Object} options
26+
* @param {Object} options.runtimeAccess The injected `DeploymentRuntimeAccessService` instance (the L0 holder).
27+
* @param {String} options.serviceKey The allowlisted runtime service key to restart (validated by the envelope).
28+
* @param {String} [options.reason='control-plane restart'] Audit reason forwarded to the envelope.
29+
* @returns {Promise<Object>} `{ok:true, result}` on a delegated restart; `{ok:false, error}` on a refusal.
30+
*/
31+
export async function restartRuntimeTarget({runtimeAccess, serviceKey, reason = 'control-plane restart'} = {}) {
32+
if (!runtimeAccess || typeof runtimeAccess.applyLifecycle !== 'function') {
33+
return {ok: false, error: 'control-plane: no lifecycle-write runtime access wired — refusing restart'};
34+
}
35+
36+
if (typeof serviceKey !== 'string' || serviceKey.length === 0) {
37+
return {ok: false, error: 'control-plane: a known service key is required — refusing restart'};
38+
}
39+
40+
// Delegate to the lifecycle-write envelope: it enforces the allowlisted service key, the closed
41+
// action set, and the persisted anti-thrash cap. This endpoint adds the R3 placement boundary, not a
42+
// second restart path. The envelope signals a refusal by THROWING (disabled / unsupported mechanism /
43+
// disallowed op / unknown-or-non-allowlisted service key); catch it so the control-plane endpoint
44+
// returns a clean {ok:false} instead of leaking a raw throw, and defensively treat an explicit
45+
// {ok:false} envelope result as a refusal too — a false {ok:true} must be impossible on any refusal shape.
46+
try {
47+
const result = await runtimeAccess.applyLifecycle({serviceKey, operation: 'restart', reason});
48+
49+
if (result && typeof result === 'object' && result.ok === false) {
50+
return {ok: false, error: result.error ?? 'control-plane: lifecycle-write envelope refused the restart', result};
51+
}
52+
53+
return {ok: true, result};
54+
} catch (error) {
55+
return {ok: false, error: `control-plane: lifecycle-write envelope refused the restart — ${error.message}`};
56+
}
57+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {test, expect} from '@playwright/test';
2+
import {readdirSync, readFileSync, statSync} from 'node:fs';
3+
import {fileURLToPath} from 'node:url';
4+
5+
import {restartRuntimeTarget} from '../../../../../../../ai/daemons/orchestrator/control-plane/restartActuator.mjs';
6+
7+
/** Recursively collect `.mjs` files under a dir (absolute), excluding specs. */
8+
function collectMjs(absDir) {
9+
const out = [];
10+
let entries;
11+
try { entries = readdirSync(absDir); } catch (error) { return out; }
12+
for (const entry of entries) {
13+
const abs = `${absDir}/${entry}`;
14+
if (statSync(abs).isDirectory()) out.push(...collectMjs(abs));
15+
else if (entry.endsWith('.mjs') && !entry.endsWith('.spec.mjs')) out.push(abs);
16+
}
17+
return out;
18+
}
19+
20+
test.describe('control-plane/restartActuator — the R3 lifecycle-write restart endpoint', () => {
21+
test('delegates the restart through the lifecycle-write envelope (applyLifecycle), never a direct restart', async () => {
22+
const calls = [],
23+
runtimeAccess = {applyLifecycle: async args => { calls.push(args); return {serviceKey: args.serviceKey, state: 'restarting'}; }},
24+
outcome = await restartRuntimeTarget({runtimeAccess, serviceKey: 'memory-core', reason: 'stale-source'});
25+
26+
expect(calls).toEqual([{serviceKey: 'memory-core', operation: 'restart', reason: 'stale-source'}]);
27+
expect(outcome).toEqual({ok: true, result: {serviceKey: 'memory-core', state: 'restarting'}});
28+
});
29+
30+
test('propagates a lifecycle-envelope refusal (a throw) as {ok:false} — never a false success', async () => {
31+
// The envelope signals a refusal (disabled / disallowed op / non-allowlisted key / anti-thrash)
32+
// by throwing; the endpoint must return a clean {ok:false}, not leak the throw or claim success.
33+
const runtimeAccess = {applyLifecycle: async () => { throw new Error('runtime-access-disabled'); }},
34+
outcome = await restartRuntimeTarget({runtimeAccess, serviceKey: 'memory-core'});
35+
36+
expect(outcome.ok).toBe(false);
37+
expect(outcome.error).toEqual(expect.stringContaining('envelope refused'));
38+
expect(outcome.error).toEqual(expect.stringContaining('runtime-access-disabled'));
39+
});
40+
41+
test('normalizes an explicit {ok:false} envelope result as a refusal — never wrapped as {ok:true}', async () => {
42+
const runtimeAccess = {applyLifecycle: async () => ({ok: false, error: 'anti-thrash: too soon since last restart'})},
43+
outcome = await restartRuntimeTarget({runtimeAccess, serviceKey: 'memory-core'});
44+
45+
expect(outcome.ok).toBe(false);
46+
expect(outcome.error).toEqual(expect.stringContaining('anti-thrash'));
47+
});
48+
49+
test('REFUSES when no lifecycle-write runtime access is wired — never a fabricated success', async () => {
50+
expect(await restartRuntimeTarget({serviceKey: 'memory-core'})).toEqual({ok: false, error: expect.stringContaining('no lifecycle-write runtime access')});
51+
expect(await restartRuntimeTarget({runtimeAccess: {}, serviceKey: 'memory-core'})).toEqual({ok: false, error: expect.stringContaining('no lifecycle-write runtime access')});
52+
});
53+
54+
test('REFUSES a missing service key — never a direct or unbounded restart', async () => {
55+
const runtimeAccess = {applyLifecycle: async () => { throw new Error('should not be reached'); }};
56+
expect(await restartRuntimeTarget({runtimeAccess})).toEqual({ok: false, error: expect.stringContaining('known service key')});
57+
expect(await restartRuntimeTarget({runtimeAccess, serviceKey: ''})).toEqual({ok: false, error: expect.stringContaining('known service key')});
58+
});
59+
60+
test('R3 FIREWALL: no client Bridge / readiness surface imports the restart actuator (physically off-client)', () => {
61+
const root = fileURLToPath(new URL('../../../../../../../', import.meta.url)),
62+
// The client-reachable surfaces: the app↔fleet wire, the AgentOS pane, and the container healthcheck.
63+
client = [
64+
...collectMjs(`${root}src/ai/fleet`),
65+
...collectMjs(`${root}apps/agentos/view/fleet`),
66+
`${root}ai/scripts/diagnostics/mcpHealthcheck.mjs`
67+
];
68+
69+
const importers = client.filter(file => {
70+
let src;
71+
try { src = readFileSync(file, 'utf8'); } catch (error) { return false; }
72+
return /restartActuator/.test(src);
73+
});
74+
75+
// The lifecycle-write restart endpoint must be reachable only from an orchestrator-internal control-plane
76+
// caller — never a client RPC / readiness surface (the R3 seam). If this fails, a restart leaked onto
77+
// the client boundary.
78+
expect(importers, `client surfaces importing the restart actuator: ${importers.join(', ')}`).toEqual([]);
79+
});
80+
});

0 commit comments

Comments
 (0)