@selesai/code 0.13.38 → 0.13.40
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/CHANGELOG.md +7 -0
- package/dist/core/intercom-rpc.d.ts +135 -0
- package/dist/core/intercom-rpc.js +64 -0
- package/dist/core/release-rpc.d.ts +137 -0
- package/dist/core/release-rpc.js +77 -0
- package/dist/core/resource-loader.d.ts +3 -0
- package/dist/core/resource-loader.js +3 -0
- package/dist/core/subagent-rpc.d.ts +110 -0
- package/dist/core/subagent-rpc.js +60 -0
- package/dist/extensions/agent-browser.ts +9 -0
- package/dist/extensions/pi-graft/cli.test.ts +5 -5
- package/dist/extensions/pi-graft/cli.ts +10 -5
- package/dist/extensions/pi-graft/commands.test.ts +5 -5
- package/dist/extensions/pi-graft/graft.integration.test.ts +1 -1
- package/dist/extensions/pi-graft/lifecycle.test.ts +26 -26
- package/dist/extensions/pi-graft/prompt.test.ts +1 -1
- package/dist/extensions/pi-graft/state.test.ts +9 -9
- package/dist/extensions/pi-graft/test-support.ts +1 -1
- package/dist/extensions/pi-graft/tools.test.ts +1 -1
- package/dist/extensions/pi-intercom/README.md +15 -0
- package/dist/extensions/pi-intercom/broker/broker.ts +277 -13
- package/dist/extensions/pi-intercom/broker/client.ts +6 -2
- package/dist/extensions/pi-intercom/broker/mailbox-persistence.test.ts +523 -0
- package/dist/extensions/pi-intercom/broker/paths.test.ts +4 -4
- package/dist/extensions/pi-intercom/broker/paths.ts +4 -2
- package/dist/extensions/pi-intercom/broker/spawn.test.ts +1 -3
- package/dist/extensions/pi-intercom/extension-api.ts +10 -0
- package/dist/extensions/pi-intercom/index.ts +188 -8
- package/dist/extensions/pi-intercom/intercom.integration.test.ts +280 -0
- package/dist/extensions/pi-intercom/package.json +1 -1
- package/dist/extensions/pi-intercom/release-readiness.test.ts +116 -0
- package/dist/extensions/pi-intercom/release-readiness.ts +93 -0
- package/dist/extensions/pi-subagents/docs/extension-api.md +8 -0
- package/dist/extensions/pi-subagents/docs/observability.md +8 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +51 -1
- package/dist/extensions/pi-subagents/src/extension/observability.ts +164 -0
- package/dist/extensions/pi-subagents/src/extension/release-readiness.ts +367 -0
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +8 -1
- package/dist/extensions/pi-subagents/src/integrations/pi-web-session-liveness.ts +7 -1
- package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +1 -44
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +6 -0
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +13 -3
- package/dist/extensions/pi-subagents/src/runs/background/result-delivery-ownership.ts +269 -7
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +11 -10
- package/dist/extensions/pi-subagents/src/runs/background/retained-nested-route-tracker.ts +15 -3
- package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +64 -0
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +69 -6
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +14 -1
- package/dist/extensions/pi-subagents/src/shared/owner-record.ts +107 -0
- package/dist/extensions/pi-subagents/src/shared/process-identity.ts +87 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +9 -0
- package/dist/extensions/pi-subagents/test/integration/async-attention-clearing.test.ts +191 -0
- package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +63 -0
- package/dist/extensions/pi-subagents/test/integration/stop-all-background.test.ts +142 -0
- package/dist/extensions/pi-subagents/test/support/fake-child-session.ts +8 -0
- package/dist/extensions/pi-subagents/test/unit/observability.test.ts +198 -0
- package/dist/extensions/pi-subagents/test/unit/process-identity.test.ts +75 -0
- package/dist/extensions/pi-subagents/test/unit/release-readiness.test.ts +715 -0
- package/dist/extensions/pi-subagents/test/unit/result-takeover.test.ts +332 -0
- package/dist/extensions/tokenin-onboarding.ts +142 -3
- package/dist/index.d.ts +6 -1
- package/dist/index.js +7 -0
- package/dist/main.d.ts +6 -0
- package/dist/main.js +1 -1
- package/dist/modes/index.d.ts +1 -1
- package/dist/modes/rpc/intercom-bridge.d.ts +14 -0
- package/dist/modes/rpc/intercom-bridge.js +75 -0
- package/dist/modes/rpc/intercom-bridge.test.d.ts +1 -0
- package/dist/modes/rpc/intercom-bridge.test.js +65 -0
- package/dist/modes/rpc/release-bridge.d.ts +35 -0
- package/dist/modes/rpc/release-bridge.js +159 -0
- package/dist/modes/rpc/release-bridge.test.d.ts +1 -0
- package/dist/modes/rpc/release-bridge.test.js +347 -0
- package/dist/modes/rpc/rpc-client.d.ts +34 -10
- package/dist/modes/rpc/rpc-client.js +45 -2
- package/dist/modes/rpc/rpc-intercom-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-intercom-transport.test.js +85 -0
- package/dist/modes/rpc/rpc-mode.d.ts +1 -13
- package/dist/modes/rpc/rpc-mode.js +239 -8
- package/dist/modes/rpc/rpc-release-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-release-transport.test.js +329 -0
- package/dist/modes/rpc/rpc-subagent-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-subagent-transport.test.js +60 -0
- package/dist/modes/rpc/rpc-types.d.ts +12 -2
- package/dist/modes/rpc/rpc-types.js +0 -6
- package/dist/modes/rpc/subagent-bridge.d.ts +23 -0
- package/dist/modes/rpc/subagent-bridge.js +93 -0
- package/dist/modes/rpc/test-fixtures/intercom-host.d.ts +1 -0
- package/dist/modes/rpc/test-fixtures/intercom-host.js +13 -0
- package/docs/rpc-subagent.schema.json +491 -0
- package/docs/rpc.md +210 -1
- package/package.json +2 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createEventBus } from "../../core/event-bus.js";
|
|
3
|
+
import { INTERCOM_RPC_EVENT, INTERCOM_RPC_REQUEST_EVENT, INTERCOM_RPC_RESULT_EVENT } from "../../core/intercom-rpc.js";
|
|
4
|
+
import { RpcIntercomBridge } from "./intercom-bridge.js";
|
|
5
|
+
describe("RpcIntercomBridge", () => {
|
|
6
|
+
it("fails promptly for absent backend and invalid timeout", async () => {
|
|
7
|
+
const bridge = new RpcIntercomBridge(createEventBus(), () => { }, "self");
|
|
8
|
+
expect(await bridge.request({ id: "missing", type: "intercom_list" })).toMatchObject({ id: "missing", success: false, error: "Intercom extension unavailable" });
|
|
9
|
+
expect(await bridge.request({ type: "intercom_ask", to: "peer", message: "?", timeoutMs: 0 })).toMatchObject({ success: false, error: expect.stringContaining("timeoutMs") });
|
|
10
|
+
bridge.dispose();
|
|
11
|
+
expect(await new RpcIntercomBridge(undefined, () => { }, "self").request({ type: "intercom_status" })).toMatchObject({ success: false });
|
|
12
|
+
});
|
|
13
|
+
it("correlates concurrent commands independently of external ids", async () => {
|
|
14
|
+
const bus = createEventBus();
|
|
15
|
+
const requests = [];
|
|
16
|
+
bus.on(INTERCOM_RPC_REQUEST_EVENT, payload => { const request = payload; request.accept(); requests.push(request); });
|
|
17
|
+
const bridge = new RpcIntercomBridge(bus, () => { }, "self");
|
|
18
|
+
const first = bridge.request({ id: "same", type: "intercom_list" });
|
|
19
|
+
const second = bridge.request({ id: "same", type: "intercom_status" });
|
|
20
|
+
bus.emit(INTERCOM_RPC_RESULT_EVENT, { requestId: requests[1].requestId, success: true, data: { enabled: true, connected: false, sessionId: "self" } });
|
|
21
|
+
bus.emit(INTERCOM_RPC_RESULT_EVENT, { requestId: requests[0].requestId, success: true, data: { sessionId: "self", sessions: [] } });
|
|
22
|
+
expect(await first).toMatchObject({ id: "same", command: "intercom_list", data: { sessions: [] } });
|
|
23
|
+
expect(await second).toMatchObject({ command: "intercom_status", data: { connected: false, scope: { version: 1, sessionId: "self", epoch: expect.any(String) } } });
|
|
24
|
+
bridge.dispose();
|
|
25
|
+
});
|
|
26
|
+
it("bounds requests and aborts the extension operation", async () => {
|
|
27
|
+
vi.useFakeTimers();
|
|
28
|
+
try {
|
|
29
|
+
const bus = createEventBus();
|
|
30
|
+
let signal;
|
|
31
|
+
bus.on(INTERCOM_RPC_REQUEST_EVENT, payload => { const request = payload; request.accept(); signal = request.signal; });
|
|
32
|
+
const bridge = new RpcIntercomBridge(bus, () => { }, "self");
|
|
33
|
+
const response = bridge.request({ type: "intercom_ask", to: "peer", message: "?", timeoutMs: 10 });
|
|
34
|
+
await vi.advanceTimersByTimeAsync(1010);
|
|
35
|
+
expect(await response).toMatchObject({ success: false, error: expect.stringContaining("timed out") });
|
|
36
|
+
expect(signal?.aborted).toBe(true);
|
|
37
|
+
bridge.dispose();
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
vi.useRealTimers();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
it("rejects replacement requests and detaches old event/result listeners", async () => {
|
|
44
|
+
const bus = createEventBus();
|
|
45
|
+
let request;
|
|
46
|
+
bus.on(INTERCOM_RPC_REQUEST_EVENT, payload => { request = payload; request.accept(); });
|
|
47
|
+
const oldOutput = vi.fn();
|
|
48
|
+
const bridge = new RpcIntercomBridge(bus, oldOutput, "self");
|
|
49
|
+
const pending = bridge.request({ type: "intercom_list" });
|
|
50
|
+
bridge.dispose();
|
|
51
|
+
expect(await pending).toMatchObject({ success: false, error: "Intercom session ended" });
|
|
52
|
+
expect(request?.signal.aborted).toBe(true);
|
|
53
|
+
const output = vi.fn();
|
|
54
|
+
const replacement = new RpcIntercomBridge(bus, output, "new", "new-epoch");
|
|
55
|
+
bus.emit(INTERCOM_RPC_EVENT, { sessionId: "new", event: { type: "intercom_connection", enabled: true, connected: true, sessionId: "new" } });
|
|
56
|
+
bus.emit(INTERCOM_RPC_RESULT_EVENT, { requestId: request?.requestId, success: true, data: {} });
|
|
57
|
+
expect(oldOutput).not.toHaveBeenCalled();
|
|
58
|
+
bus.emit(INTERCOM_RPC_EVENT, { sessionId: "old", event: { type: "intercom_connection", enabled: true, connected: true, sessionId: "old" } });
|
|
59
|
+
bus.emit(INTERCOM_RPC_EVENT, { sessionId: "new", event: { type: "arbitrary", payload: "must not forward" } });
|
|
60
|
+
bus.emit(INTERCOM_RPC_EVENT, null);
|
|
61
|
+
expect(output).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(output).toHaveBeenCalledWith({ type: "intercom_connection", enabled: true, connected: true, sessionId: "new", scope: { version: 1, sessionId: "new", epoch: "new-epoch" } });
|
|
63
|
+
replacement.dispose();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { EventBus } from "../../core/event-bus.ts";
|
|
2
|
+
import { type ReleaseBlocker, type ReleaseCommand, type ReleaseReadiness, type ReleaseReadinessCommand, type StopAllBackgroundCommand, type StopAllBackgroundItem } from "../../core/release-rpc.ts";
|
|
3
|
+
import type { RpcObservationScope } from "../../core/subagent-rpc.ts";
|
|
4
|
+
import type { RpcResponse } from "./rpc-types.ts";
|
|
5
|
+
/** Shared by `release_readiness`, `release` and `stop_all_background`: version 1, no unknown keys, bounded single-line id. Returns the error text, if any. */
|
|
6
|
+
export declare function validateReleaseCommand(command: ReleaseReadinessCommand | ReleaseCommand | StopAllBackgroundCommand): string | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* Collects release blockers from every extension listener on one host binding. Synchronous and fail closed:
|
|
9
|
+
* malformed or throwing contributors become `contributor_error` blockers, never silent "safe".
|
|
10
|
+
*/
|
|
11
|
+
export declare class RpcReleaseBridge {
|
|
12
|
+
private disposed;
|
|
13
|
+
private bus;
|
|
14
|
+
private scope;
|
|
15
|
+
constructor(bus: EventBus | undefined, scope: RpcObservationScope);
|
|
16
|
+
request(command: ReleaseReadinessCommand, coreBlockers?: ReleaseBlocker[]): RpcResponse;
|
|
17
|
+
/**
|
|
18
|
+
* Atomic release decision (`release`): validates like `request`, collects readiness and, only when it permits release
|
|
19
|
+
* (`decideRelease`), calls `commit` before returning. Everything here is synchronous, so nothing can interleave between the
|
|
20
|
+
* check and the commitment; the caller's `commit` must therefore also be synchronous (set the "releasing" flag).
|
|
21
|
+
* Not releasable: nothing is committed and the process keeps running.
|
|
22
|
+
*/
|
|
23
|
+
release(command: ReleaseCommand, coreBlockers: ReleaseBlocker[], commit: (readiness: ReleaseReadiness) => void): RpcResponse;
|
|
24
|
+
/**
|
|
25
|
+
* Kill switch (`stop_all_background`). Validates like `request`, then starts the core stop and broadcasts
|
|
26
|
+
* `STOP_ALL_BACKGROUND_EVENT` so every extension starts its own stop synchronously, and awaits all of them under ONE total
|
|
27
|
+
* budget (`timeoutMs`). A contributor that throws, rejects or is still running at the deadline becomes an `ok:false` item;
|
|
28
|
+
* it never prevents the others from running or the response from being sent. `remaining` is the readiness collected AFTER
|
|
29
|
+
* the stop. `success:false` only for a malformed request or a disposed binding.
|
|
30
|
+
*/
|
|
31
|
+
stopAll(command: StopAllBackgroundCommand, coreStop: () => StopAllBackgroundItem[] | PromiseLike<StopAllBackgroundItem[]>, coreBlockers: () => ReleaseBlocker[], timeoutMs?: number): Promise<RpcResponse>;
|
|
32
|
+
/** Validation error text, or the collected readiness. */
|
|
33
|
+
private collect;
|
|
34
|
+
dispose(): void;
|
|
35
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { RELEASE_READINESS_EVENT, STOP_ALL_BACKGROUND_EVENT, STOP_ALL_BACKGROUND_TIMEOUT_MS, decideRelease, isReleaseBlocker, isStopAllBackgroundItem, stopAllContributorError, } from "../../core/release-rpc.js";
|
|
2
|
+
const MAX_BLOCKERS_PER_CONTRIBUTOR = 64;
|
|
3
|
+
const MAX_BLOCKERS = 256;
|
|
4
|
+
const MAX_STOPPED_PER_CONTRIBUTOR = 128;
|
|
5
|
+
const MAX_STOPPED = 512;
|
|
6
|
+
/** Shared by `release_readiness`, `release` and `stop_all_background`: version 1, no unknown keys, bounded single-line id. Returns the error text, if any. */
|
|
7
|
+
export function validateReleaseCommand(command) {
|
|
8
|
+
const type = command.type;
|
|
9
|
+
if (command.version !== 1)
|
|
10
|
+
return `Unsupported ${type} version; expected 1`;
|
|
11
|
+
if (Object.keys(command).some(key => !["type", "version", "id"].includes(key))
|
|
12
|
+
|| command.id !== undefined && (typeof command.id !== "string" || command.id.length > 128 || /[\r\n]/.test(command.id)))
|
|
13
|
+
return `Malformed ${type} command`;
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Collects release blockers from every extension listener on one host binding. Synchronous and fail closed:
|
|
18
|
+
* malformed or throwing contributors become `contributor_error` blockers, never silent "safe".
|
|
19
|
+
*/
|
|
20
|
+
export class RpcReleaseBridge {
|
|
21
|
+
disposed = false;
|
|
22
|
+
bus;
|
|
23
|
+
scope;
|
|
24
|
+
constructor(bus, scope) { this.bus = bus; this.scope = scope; }
|
|
25
|
+
request(command, coreBlockers = []) {
|
|
26
|
+
const collected = this.collect(command, coreBlockers);
|
|
27
|
+
if (typeof collected === "string")
|
|
28
|
+
return { id: command.id, type: "response", command: command.type, success: false, error: collected };
|
|
29
|
+
return { id: command.id, type: "response", command: "release_readiness", success: true, data: collected };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Atomic release decision (`release`): validates like `request`, collects readiness and, only when it permits release
|
|
33
|
+
* (`decideRelease`), calls `commit` before returning. Everything here is synchronous, so nothing can interleave between the
|
|
34
|
+
* check and the commitment; the caller's `commit` must therefore also be synchronous (set the "releasing" flag).
|
|
35
|
+
* Not releasable: nothing is committed and the process keeps running.
|
|
36
|
+
*/
|
|
37
|
+
release(command, coreBlockers, commit) {
|
|
38
|
+
const collected = this.collect(command, coreBlockers);
|
|
39
|
+
if (typeof collected === "string")
|
|
40
|
+
return { id: command.id, type: "response", command: command.type, success: false, error: collected };
|
|
41
|
+
const released = decideRelease(collected);
|
|
42
|
+
if (released)
|
|
43
|
+
commit(collected);
|
|
44
|
+
return { id: command.id, type: "response", command: "release", success: true, data: { released, readiness: collected } };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Kill switch (`stop_all_background`). Validates like `request`, then starts the core stop and broadcasts
|
|
48
|
+
* `STOP_ALL_BACKGROUND_EVENT` so every extension starts its own stop synchronously, and awaits all of them under ONE total
|
|
49
|
+
* budget (`timeoutMs`). A contributor that throws, rejects or is still running at the deadline becomes an `ok:false` item;
|
|
50
|
+
* it never prevents the others from running or the response from being sent. `remaining` is the readiness collected AFTER
|
|
51
|
+
* the stop. `success:false` only for a malformed request or a disposed binding.
|
|
52
|
+
*/
|
|
53
|
+
async stopAll(command, coreStop, coreBlockers, timeoutMs = STOP_ALL_BACKGROUND_TIMEOUT_MS) {
|
|
54
|
+
const invalid = validateReleaseCommand(command);
|
|
55
|
+
if (invalid)
|
|
56
|
+
return { id: command.id, type: "response", command: command.type, success: false, error: invalid };
|
|
57
|
+
if (this.disposed)
|
|
58
|
+
return { id: command.id, type: "response", command: command.type, success: false, error: "Release readiness session ended" };
|
|
59
|
+
const deadline = Date.now() + timeoutMs;
|
|
60
|
+
const slots = [];
|
|
61
|
+
let open = true;
|
|
62
|
+
const add = (name, work) => {
|
|
63
|
+
if (!open)
|
|
64
|
+
return; // late (async) registrations cannot cross this request
|
|
65
|
+
const who = typeof name === "string" && name.length > 0 && name.length <= 64 ? name : "unknown";
|
|
66
|
+
const slot = { who, settled: Promise.resolve() };
|
|
67
|
+
const accept = (items) => {
|
|
68
|
+
if (!open)
|
|
69
|
+
return;
|
|
70
|
+
if (!Array.isArray(items)) {
|
|
71
|
+
slot.items = [{ source: who, kind: "contributor", detail: "Malformed stop contribution", ok: false, error: "malformed" }];
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const valid = [];
|
|
75
|
+
for (const item of items.slice(0, MAX_STOPPED_PER_CONTRIBUTOR)) {
|
|
76
|
+
valid.push(isStopAllBackgroundItem(item) ? item : { source: who, kind: "contributor", detail: "Malformed stop item", ok: false, error: "malformed" });
|
|
77
|
+
}
|
|
78
|
+
if (items.length > MAX_STOPPED_PER_CONTRIBUTOR)
|
|
79
|
+
valid.push({ source: who, kind: "contributor", detail: `${items.length - MAX_STOPPED_PER_CONTRIBUTOR} further items omitted`, ok: false, error: "truncated" });
|
|
80
|
+
slot.items = valid;
|
|
81
|
+
};
|
|
82
|
+
slot.settled = Promise.resolve(work).then(accept, (error) => { if (open)
|
|
83
|
+
slot.items = [stopAllContributorError(who, error)]; });
|
|
84
|
+
slots.push(slot);
|
|
85
|
+
};
|
|
86
|
+
try {
|
|
87
|
+
add("core", coreStop());
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
add("core", [stopAllContributorError("core", error)]);
|
|
91
|
+
}
|
|
92
|
+
const request = { version: 1, sessionId: this.scope.sessionId, deadline, contribute: add };
|
|
93
|
+
try {
|
|
94
|
+
this.bus?.emit(STOP_ALL_BACKGROUND_EVENT, request);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
add("unknown", [stopAllContributorError("unknown", error)]);
|
|
98
|
+
}
|
|
99
|
+
let timer;
|
|
100
|
+
const expired = new Promise((resolve) => { timer = setTimeout(resolve, Math.max(0, deadline - Date.now()) + 250); });
|
|
101
|
+
try {
|
|
102
|
+
await Promise.race([Promise.all(slots.map((slot) => slot.settled)), expired]);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
}
|
|
107
|
+
open = false;
|
|
108
|
+
const stopped = [];
|
|
109
|
+
for (const slot of slots) {
|
|
110
|
+
stopped.push(...(slot.items ?? [{ source: slot.who, kind: "contributor", detail: `${slot.who} did not finish stopping within ${timeoutMs}ms`, ok: false, error: "timeout" }]));
|
|
111
|
+
}
|
|
112
|
+
const bounded = stopped.slice(0, MAX_STOPPED);
|
|
113
|
+
if (stopped.length > MAX_STOPPED)
|
|
114
|
+
bounded.push({ source: "core", kind: "contributor", detail: `${stopped.length - MAX_STOPPED} further items omitted`, ok: false, error: "truncated" });
|
|
115
|
+
const remaining = this.collect(command, coreBlockers());
|
|
116
|
+
if (typeof remaining === "string")
|
|
117
|
+
return { id: command.id, type: "response", command: command.type, success: false, error: remaining };
|
|
118
|
+
return { id: command.id, type: "response", command: "stop_all_background", success: true, data: { ...this.scope, stopped: bounded, remaining } };
|
|
119
|
+
}
|
|
120
|
+
/** Validation error text, or the collected readiness. */
|
|
121
|
+
collect(command, coreBlockers) {
|
|
122
|
+
const invalid = validateReleaseCommand(command);
|
|
123
|
+
if (invalid)
|
|
124
|
+
return invalid;
|
|
125
|
+
if (this.disposed)
|
|
126
|
+
return "Release readiness session ended";
|
|
127
|
+
const blockers = [...coreBlockers];
|
|
128
|
+
const contributors = [];
|
|
129
|
+
let open = true;
|
|
130
|
+
const request = { version: 1, sessionId: this.scope.sessionId, contribute: (name, contributed) => {
|
|
131
|
+
if (!open)
|
|
132
|
+
return; // late (async) answers cannot cross this request
|
|
133
|
+
const who = typeof name === "string" && name.length > 0 && name.length <= 64 ? name : "unknown";
|
|
134
|
+
if (!contributors.includes(who))
|
|
135
|
+
contributors.push(who);
|
|
136
|
+
if (!Array.isArray(contributed)) {
|
|
137
|
+
blockers.push({ source: who, kind: "contributor_error", detail: "Malformed release readiness contribution" });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
for (const blocker of contributed.slice(0, MAX_BLOCKERS_PER_CONTRIBUTOR)) {
|
|
141
|
+
blockers.push(isReleaseBlocker(blocker) ? blocker : { source: who, kind: "contributor_error", detail: "Malformed release blocker" });
|
|
142
|
+
}
|
|
143
|
+
if (contributed.length > MAX_BLOCKERS_PER_CONTRIBUTOR)
|
|
144
|
+
blockers.push({ source: who, kind: "contributor_error", detail: `${contributed.length - MAX_BLOCKERS_PER_CONTRIBUTOR} further blockers omitted` });
|
|
145
|
+
} };
|
|
146
|
+
try {
|
|
147
|
+
this.bus?.emit(RELEASE_READINESS_EVENT, request);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
blockers.push({ source: "unknown", kind: "contributor_error", detail: error?.message?.slice(0, 512) || "release readiness broadcast failed" });
|
|
151
|
+
}
|
|
152
|
+
open = false;
|
|
153
|
+
const bounded = blockers.slice(0, MAX_BLOCKERS);
|
|
154
|
+
if (blockers.length > MAX_BLOCKERS)
|
|
155
|
+
bounded.push({ source: "core", kind: "contributor_error", detail: `${blockers.length - MAX_BLOCKERS} further blockers omitted` });
|
|
156
|
+
return { ...this.scope, safe: bounded.length === 0, blockers: bounded, contributors };
|
|
157
|
+
}
|
|
158
|
+
dispose() { this.disposed = true; this.bus = undefined; }
|
|
159
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createEventBus } from "../../core/event-bus.js";
|
|
3
|
+
import { RELEASE_READINESS_EVENT, STOP_ALL_BACKGROUND_EVENT, answerReleaseReadiness, answerStopAllBackground, decideRelease, isReleaseBlocker, isStopAllBackgroundItem, } from "../../core/release-rpc.js";
|
|
4
|
+
import { RpcReleaseBridge, validateReleaseCommand } from "./release-bridge.js";
|
|
5
|
+
const scope = { version: 1, sessionId: "host", epoch: "bind-1" };
|
|
6
|
+
const command = { type: "release_readiness", version: 1 };
|
|
7
|
+
const blocker = (source, kind, extra = {}) => ({ source, kind, detail: `${kind} detail`, ...extra });
|
|
8
|
+
function data(reply) {
|
|
9
|
+
if (!reply.success || reply.command !== "release_readiness")
|
|
10
|
+
throw new Error(JSON.stringify(reply));
|
|
11
|
+
return reply.data;
|
|
12
|
+
}
|
|
13
|
+
/** Registers a contributor that follows the documented gate (same helper the extensions use). */
|
|
14
|
+
function contributor(bus, name, boundSessionId, compute) {
|
|
15
|
+
return bus.on(RELEASE_READINESS_EVENT, raw => answerReleaseReadiness(raw, name, boundSessionId, compute));
|
|
16
|
+
}
|
|
17
|
+
describe("RpcReleaseBridge", () => {
|
|
18
|
+
it("is safe with no contributors and reports who answered", () => {
|
|
19
|
+
const bridge = new RpcReleaseBridge(undefined, scope);
|
|
20
|
+
expect(data(bridge.request({ ...command, id: "r" }))).toEqual({ ...scope, safe: true, blockers: [], contributors: [] });
|
|
21
|
+
expect(bridge.request({ ...command, id: "r" })).toMatchObject({ id: "r", type: "response", command: "release_readiness", success: true });
|
|
22
|
+
});
|
|
23
|
+
it("merges core blockers and many contributors, and answers safe when all are empty", () => {
|
|
24
|
+
const bus = createEventBus();
|
|
25
|
+
contributor(bus, "subagents", "host", () => [blocker("subagents", "async_run", { id: "run-1", since: 5 })]);
|
|
26
|
+
contributor(bus, "intercom", "host", () => [blocker("intercom", "inbound_ask", { id: "m1" }), blocker("intercom", "outbound_ask")]);
|
|
27
|
+
contributor(bus, "scheduler", "host", () => []);
|
|
28
|
+
const bridge = new RpcReleaseBridge(bus, scope);
|
|
29
|
+
const busy = data(bridge.request(command, [blocker("core", "streaming")]));
|
|
30
|
+
expect(busy.safe).toBe(false);
|
|
31
|
+
expect(busy.contributors).toEqual(["subagents", "intercom", "scheduler"]);
|
|
32
|
+
expect(busy.blockers.map(b => `${b.source}:${b.kind}`)).toEqual(["core:streaming", "subagents:async_run", "intercom:inbound_ask", "intercom:outbound_ask"]);
|
|
33
|
+
expect(busy).toMatchObject({ ...scope });
|
|
34
|
+
const idleBus = createEventBus();
|
|
35
|
+
contributor(idleBus, "subagents", "host", () => []);
|
|
36
|
+
contributor(idleBus, "scheduler", "host", () => []);
|
|
37
|
+
expect(data(new RpcReleaseBridge(idleBus, scope).request(command))).toMatchObject({ safe: true, blockers: [], contributors: ["subagents", "scheduler"] });
|
|
38
|
+
});
|
|
39
|
+
it("turns a throwing contributor into a contributor_error blocker without crashing", async () => {
|
|
40
|
+
const bus = createEventBus();
|
|
41
|
+
const quiet = () => undefined;
|
|
42
|
+
const original = console.error;
|
|
43
|
+
console.error = quiet;
|
|
44
|
+
try {
|
|
45
|
+
contributor(bus, "subagents", "host", () => { throw new Error("status.json is corrupt"); });
|
|
46
|
+
// A raw listener that bypasses the helper: the bus swallows its exception, but it must not make the result safe-by-accident
|
|
47
|
+
// when other contributors answered; here it simply does not contribute.
|
|
48
|
+
bus.on(RELEASE_READINESS_EVENT, () => { throw new Error("raw listener exploded"); });
|
|
49
|
+
const result = data(new RpcReleaseBridge(bus, scope).request(command));
|
|
50
|
+
expect(result.safe).toBe(false);
|
|
51
|
+
expect(result.contributors).toEqual(["subagents"]);
|
|
52
|
+
expect(result.blockers).toEqual([{ source: "subagents", kind: "contributor_error", detail: "status.json is corrupt" }]);
|
|
53
|
+
await new Promise(done => setTimeout(done, 0)); // let the bus log the raw listener's swallowed error while console.error is muted
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
console.error = original;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
it("fails closed when a bus emit throws or a contribution is malformed", () => {
|
|
60
|
+
const throwing = { emit() { throw new Error("bus down"); }, on: () => () => undefined };
|
|
61
|
+
expect(data(new RpcReleaseBridge(throwing, scope).request(command))).toMatchObject({ safe: false, blockers: [{ source: "unknown", kind: "contributor_error", detail: "bus down" }] });
|
|
62
|
+
const bus = createEventBus();
|
|
63
|
+
bus.on(RELEASE_READINESS_EVENT, raw => {
|
|
64
|
+
const request = raw;
|
|
65
|
+
request.contribute("bad-shape", "nope");
|
|
66
|
+
request.contribute("bad-blocker", [{ source: "x", kind: "", detail: "" }, blocker("bad-blocker", "ok")]);
|
|
67
|
+
request.contribute("", []);
|
|
68
|
+
});
|
|
69
|
+
const result = data(new RpcReleaseBridge(bus, scope).request(command));
|
|
70
|
+
expect(result.safe).toBe(false);
|
|
71
|
+
expect(result.contributors).toEqual(["bad-shape", "bad-blocker", "unknown"]);
|
|
72
|
+
expect(result.blockers.filter(b => b.kind === "contributor_error").map(b => b.source)).toEqual(["bad-shape", "bad-blocker"]);
|
|
73
|
+
expect(result.blockers.some(b => b.kind === "ok")).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
it("bounds the number of blockers", () => {
|
|
76
|
+
const bus = createEventBus();
|
|
77
|
+
bus.on(RELEASE_READINESS_EVENT, raw => raw.contribute("many", Array.from({ length: 100 }, (_, i) => blocker("many", "async_run", { id: `r${i}` }))));
|
|
78
|
+
const result = data(new RpcReleaseBridge(bus, scope).request(command));
|
|
79
|
+
expect(result.blockers).toHaveLength(65);
|
|
80
|
+
expect(result.blockers.at(-1)).toMatchObject({ kind: "contributor_error", detail: expect.stringContaining("36 further") });
|
|
81
|
+
});
|
|
82
|
+
it("ignores requests for another session and late asynchronous contributions", async () => {
|
|
83
|
+
const bus = createEventBus();
|
|
84
|
+
contributor(bus, "subagents", "some-other-session", () => [blocker("subagents", "async_run")]);
|
|
85
|
+
let late;
|
|
86
|
+
bus.on(RELEASE_READINESS_EVENT, raw => { late = raw; });
|
|
87
|
+
const bridge = new RpcReleaseBridge(bus, scope);
|
|
88
|
+
const result = data(bridge.request(command));
|
|
89
|
+
expect(result).toMatchObject({ safe: true, blockers: [], contributors: [] });
|
|
90
|
+
late.contribute("late", [blocker("late", "async_run")]);
|
|
91
|
+
expect(data(bridge.request(command)).blockers).toEqual([]);
|
|
92
|
+
expect(late).toMatchObject({ version: 1, sessionId: "host" });
|
|
93
|
+
});
|
|
94
|
+
it("rejects malformed commands", () => {
|
|
95
|
+
const bridge = new RpcReleaseBridge(undefined, scope);
|
|
96
|
+
expect(bridge.request({ type: "release_readiness", version: 2 })).toMatchObject({ success: false, error: expect.stringContaining("version") });
|
|
97
|
+
expect(bridge.request({ type: "release_readiness" })).toMatchObject({ success: false });
|
|
98
|
+
expect(bridge.request({ ...command, id: "x".repeat(129) })).toMatchObject({ success: false });
|
|
99
|
+
expect(bridge.request({ ...command, id: "a\nb" })).toMatchObject({ success: false });
|
|
100
|
+
expect(bridge.request({ ...command, method: "spawn" })).toMatchObject({ success: false, error: expect.stringContaining("Malformed") });
|
|
101
|
+
});
|
|
102
|
+
it("fails after dispose and does not call contributors", () => {
|
|
103
|
+
const bus = createEventBus();
|
|
104
|
+
let calls = 0;
|
|
105
|
+
contributor(bus, "subagents", "host", () => { calls++; return []; });
|
|
106
|
+
const bridge = new RpcReleaseBridge(bus, scope);
|
|
107
|
+
bridge.dispose();
|
|
108
|
+
expect(bridge.request({ ...command, id: "late" })).toEqual({ id: "late", type: "response", command: "release_readiness", success: false, error: "Release readiness session ended" });
|
|
109
|
+
expect(calls).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
describe("RpcReleaseBridge.release (atomic release decision)", () => {
|
|
113
|
+
const releaseCommand = { type: "release", version: 1 };
|
|
114
|
+
const result = (reply) => {
|
|
115
|
+
if (!reply.success || reply.command !== "release")
|
|
116
|
+
throw new Error(JSON.stringify(reply));
|
|
117
|
+
return reply.data;
|
|
118
|
+
};
|
|
119
|
+
const idleBus = () => {
|
|
120
|
+
const bus = createEventBus();
|
|
121
|
+
contributor(bus, "subagents", "host", () => []);
|
|
122
|
+
contributor(bus, "scheduler", "host", () => []);
|
|
123
|
+
return bus;
|
|
124
|
+
};
|
|
125
|
+
it("commits synchronously, before returning, only when safe and somebody answered", () => {
|
|
126
|
+
const commits = [];
|
|
127
|
+
const bridge = new RpcReleaseBridge(idleBus(), scope);
|
|
128
|
+
const reply = bridge.release({ ...releaseCommand, id: "r1" }, [], readiness => commits.push(readiness));
|
|
129
|
+
expect(reply).toMatchObject({ id: "r1", type: "response", command: "release", success: true });
|
|
130
|
+
expect(result(reply)).toEqual({ released: true, readiness: { ...scope, safe: true, blockers: [], contributors: ["subagents", "scheduler"] } });
|
|
131
|
+
expect(commits).toEqual([result(reply).readiness]);
|
|
132
|
+
});
|
|
133
|
+
it("does not commit and returns the blockers when a core or extension blocker exists", () => {
|
|
134
|
+
let commits = 0;
|
|
135
|
+
const core = result(new RpcReleaseBridge(idleBus(), scope).release(releaseCommand, [blocker("core", "streaming")], () => { commits++; }));
|
|
136
|
+
expect(core).toMatchObject({ released: false, readiness: { safe: false, blockers: [{ source: "core", kind: "streaming" }] } });
|
|
137
|
+
const bus = createEventBus();
|
|
138
|
+
contributor(bus, "scheduler", "host", () => [blocker("scheduler", "armed_schedule", { id: "s1" })]);
|
|
139
|
+
const extension = result(new RpcReleaseBridge(bus, scope).release(releaseCommand, [], () => { commits++; }));
|
|
140
|
+
expect(extension).toMatchObject({ released: false, readiness: { safe: false, contributors: ["scheduler"], blockers: [{ kind: "armed_schedule", id: "s1" }] } });
|
|
141
|
+
expect(commits).toBe(0);
|
|
142
|
+
});
|
|
143
|
+
it("treats nobody answering (no contributors) as not releasable even though readiness is safe", () => {
|
|
144
|
+
let commits = 0;
|
|
145
|
+
const none = result(new RpcReleaseBridge(undefined, scope).release(releaseCommand, [], () => { commits++; }));
|
|
146
|
+
expect(none).toEqual({ released: false, readiness: { ...scope, safe: true, blockers: [], contributors: [] } });
|
|
147
|
+
// Another session's contributor does not count as an answer either.
|
|
148
|
+
const foreign = createEventBus();
|
|
149
|
+
contributor(foreign, "subagents", "another-session", () => []);
|
|
150
|
+
expect(result(new RpcReleaseBridge(foreign, scope).release(releaseCommand, [], () => { commits++; })).released).toBe(false);
|
|
151
|
+
expect(commits).toBe(0);
|
|
152
|
+
});
|
|
153
|
+
it("fails closed on a throwing contributor", () => {
|
|
154
|
+
const bus = createEventBus();
|
|
155
|
+
contributor(bus, "subagents", "host", () => { throw new Error("status.json is corrupt"); });
|
|
156
|
+
let commits = 0;
|
|
157
|
+
expect(result(new RpcReleaseBridge(bus, scope).release(releaseCommand, [], () => { commits++; }))).toMatchObject({ released: false, readiness: { blockers: [{ kind: "contributor_error" }] } });
|
|
158
|
+
expect(commits).toBe(0);
|
|
159
|
+
});
|
|
160
|
+
it("validates exactly like release_readiness and never commits an invalid command", () => {
|
|
161
|
+
const bridge = new RpcReleaseBridge(idleBus(), scope);
|
|
162
|
+
let commits = 0;
|
|
163
|
+
const commit = () => { commits++; };
|
|
164
|
+
expect(bridge.release({ type: "release", version: 2 }, [], commit)).toEqual({ id: undefined, type: "response", command: "release", success: false, error: "Unsupported release version; expected 1" });
|
|
165
|
+
expect(bridge.release({ type: "release" }, [], commit)).toMatchObject({ success: false });
|
|
166
|
+
expect(bridge.release({ ...releaseCommand, id: "x".repeat(129) }, [], commit)).toMatchObject({ success: false });
|
|
167
|
+
expect(bridge.release({ ...releaseCommand, id: "a\nb" }, [], commit)).toMatchObject({ success: false });
|
|
168
|
+
expect(bridge.release({ ...releaseCommand, method: "spawn" }, [], commit)).toMatchObject({ success: false, error: "Malformed release command" });
|
|
169
|
+
expect(commits).toBe(0);
|
|
170
|
+
expect(validateReleaseCommand(releaseCommand)).toBeUndefined();
|
|
171
|
+
expect(validateReleaseCommand(command)).toBeUndefined();
|
|
172
|
+
expect(validateReleaseCommand({ type: "release", version: 1, extra: true })).toBe("Malformed release command");
|
|
173
|
+
});
|
|
174
|
+
it("fails after dispose without calling contributors or committing", () => {
|
|
175
|
+
const bridge = new RpcReleaseBridge(idleBus(), scope);
|
|
176
|
+
bridge.dispose();
|
|
177
|
+
let commits = 0;
|
|
178
|
+
expect(bridge.release({ ...releaseCommand, id: "late" }, [], () => { commits++; })).toEqual({ id: "late", type: "response", command: "release", success: false, error: "Release readiness session ended" });
|
|
179
|
+
expect(commits).toBe(0);
|
|
180
|
+
});
|
|
181
|
+
it("decideRelease requires a clean answer from at least one contributor", () => {
|
|
182
|
+
expect(decideRelease({ safe: true, blockers: [], contributors: ["subagents"] })).toBe(true);
|
|
183
|
+
expect(decideRelease({ safe: true, blockers: [], contributors: [] })).toBe(false);
|
|
184
|
+
expect(decideRelease({ safe: false, blockers: [blocker("core", "streaming")], contributors: ["subagents"] })).toBe(false);
|
|
185
|
+
expect(decideRelease({ safe: true, blockers: [blocker("core", "streaming")], contributors: ["subagents"] })).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
describe("answerReleaseReadiness / isReleaseBlocker", () => {
|
|
189
|
+
it("requires a bound session, a valid request and a matching session id", () => {
|
|
190
|
+
const seen = [];
|
|
191
|
+
const request = (sessionId) => ({ version: 1, sessionId, contribute: name => { seen.push(name); } });
|
|
192
|
+
answerReleaseReadiness(request("a"), "x", "a", () => []);
|
|
193
|
+
answerReleaseReadiness(request("a"), "x", "b", () => []);
|
|
194
|
+
answerReleaseReadiness(request("a"), "x", null, () => []);
|
|
195
|
+
answerReleaseReadiness(undefined, "x", "a", () => []);
|
|
196
|
+
answerReleaseReadiness({ version: 2, sessionId: "a", contribute: () => seen.push("v2") }, "x", "a", () => []);
|
|
197
|
+
expect(seen).toEqual(["x"]);
|
|
198
|
+
});
|
|
199
|
+
it("validates blocker shape and bounds", () => {
|
|
200
|
+
expect(isReleaseBlocker({ source: "core", kind: "streaming", detail: "d" })).toBe(true);
|
|
201
|
+
expect(isReleaseBlocker({ source: "core", kind: "streaming", detail: "d", id: "i", since: 1 })).toBe(true);
|
|
202
|
+
expect(isReleaseBlocker({ source: "core", kind: "streaming", detail: "" })).toBe(false);
|
|
203
|
+
expect(isReleaseBlocker({ source: "core", kind: "streaming", detail: "d", extra: 1 })).toBe(false);
|
|
204
|
+
expect(isReleaseBlocker({ source: "core", kind: "streaming", detail: "x".repeat(513) })).toBe(false);
|
|
205
|
+
expect(isReleaseBlocker({ source: "core", kind: "streaming", detail: "d", since: -1 })).toBe(false);
|
|
206
|
+
expect(isReleaseBlocker({ source: "core", kind: "streaming", detail: "d", since: 1.5 })).toBe(false);
|
|
207
|
+
expect(isReleaseBlocker(null)).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
describe("RpcReleaseBridge.stopAll (stop_all_background kill switch)", () => {
|
|
211
|
+
const stopCommand = { type: "stop_all_background", version: 1 };
|
|
212
|
+
const item = (source, kind, extra = {}) => ({ source, kind, detail: `${kind} stopped`, ok: true, ...extra });
|
|
213
|
+
const result = (reply) => {
|
|
214
|
+
if (!reply.success || reply.command !== "stop_all_background")
|
|
215
|
+
throw new Error(JSON.stringify(reply));
|
|
216
|
+
return reply.data;
|
|
217
|
+
};
|
|
218
|
+
const idleCore = () => [];
|
|
219
|
+
const noBlockers = () => [];
|
|
220
|
+
/** Registers a stop handler that follows the documented gate (same helper the extensions use). */
|
|
221
|
+
const stopper = (bus, name, boundSessionId, compute) => bus.on(STOP_ALL_BACKGROUND_EVENT, raw => answerStopAllBackground(raw, name, boundSessionId, compute));
|
|
222
|
+
it("answers with an empty stopped list and the post-stop readiness on an idle session", async () => {
|
|
223
|
+
const bus = createEventBus();
|
|
224
|
+
contributor(bus, "subagents", "host", () => []);
|
|
225
|
+
stopper(bus, "subagents", "host", () => []);
|
|
226
|
+
const reply = await new RpcReleaseBridge(bus, scope).stopAll({ ...stopCommand, id: "k1" }, idleCore, noBlockers);
|
|
227
|
+
expect(reply).toMatchObject({ id: "k1", type: "response", command: "stop_all_background", success: true });
|
|
228
|
+
expect(result(reply)).toEqual({ ...scope, stopped: [], remaining: { ...scope, safe: true, blockers: [], contributors: ["subagents"] } });
|
|
229
|
+
});
|
|
230
|
+
it("runs the core stop and every contributor, in order, and reports remaining AFTER the stop", async () => {
|
|
231
|
+
const bus = createEventBus();
|
|
232
|
+
let running = true; // flips when the stop handler runs; the readiness contributor reflects it
|
|
233
|
+
contributor(bus, "subagents", "host", () => running ? [blocker("subagents", "async_run", { id: "run-1" })] : []);
|
|
234
|
+
stopper(bus, "scheduler", "host", () => [item("scheduler", "schedule", { id: "s1" })]);
|
|
235
|
+
stopper(bus, "subagents", "host", () => { running = false; return [item("subagents", "async_run", { id: "run-1" })]; });
|
|
236
|
+
const events = [];
|
|
237
|
+
const reply = await new RpcReleaseBridge(bus, scope).stopAll(stopCommand, () => { events.push("core"); return [item("core", "turn")]; }, () => { events.push("remaining"); return []; });
|
|
238
|
+
const data = result(reply);
|
|
239
|
+
expect(data.stopped.map(i => `${i.source}:${i.kind}`)).toEqual(["core:turn", "scheduler:schedule", "subagents:async_run"]);
|
|
240
|
+
expect(data.stopped.every(i => i.ok)).toBe(true);
|
|
241
|
+
expect(data.remaining).toMatchObject({ safe: true, blockers: [] });
|
|
242
|
+
expect(events).toEqual(["core", "remaining"]);
|
|
243
|
+
// Idempotent: nothing left to stop the second time.
|
|
244
|
+
const again = result(await new RpcReleaseBridge(bus, scope).stopAll(stopCommand, idleCore, noBlockers));
|
|
245
|
+
expect(again.remaining.safe).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
it("isolates failures: a throwing, rejecting or malformed contributor becomes ok:false and the others still run", async () => {
|
|
248
|
+
const bus = createEventBus();
|
|
249
|
+
const ran = [];
|
|
250
|
+
stopper(bus, "boom", "host", () => { throw new Error("status.json is corrupt"); });
|
|
251
|
+
stopper(bus, "rejects", "host", async () => { throw new Error("runner vanished"); });
|
|
252
|
+
bus.on(STOP_ALL_BACKGROUND_EVENT, raw => {
|
|
253
|
+
const request = raw;
|
|
254
|
+
request.contribute("bad-shape", "nope");
|
|
255
|
+
request.contribute("bad-item", [{ source: "x", kind: "", detail: "", ok: true }, item("bad-item", "fine")]);
|
|
256
|
+
});
|
|
257
|
+
stopper(bus, "healthy", "host", () => { ran.push("healthy"); return [item("healthy", "async_run", { id: "run-9" })]; });
|
|
258
|
+
const data = result(await new RpcReleaseBridge(bus, scope).stopAll(stopCommand, () => { throw new Error("abort exploded"); }, noBlockers));
|
|
259
|
+
expect(ran).toEqual(["healthy"]);
|
|
260
|
+
expect(data.stopped).toEqual([
|
|
261
|
+
{ source: "core", kind: "contributor", detail: "Stop failed: abort exploded", ok: false, error: "abort exploded" },
|
|
262
|
+
{ source: "boom", kind: "contributor", detail: "Stop failed: status.json is corrupt", ok: false, error: "status.json is corrupt" },
|
|
263
|
+
{ source: "rejects", kind: "contributor", detail: "Stop failed: runner vanished", ok: false, error: "runner vanished" },
|
|
264
|
+
{ source: "bad-shape", kind: "contributor", detail: "Malformed stop contribution", ok: false, error: "malformed" },
|
|
265
|
+
{ source: "bad-item", kind: "contributor", detail: "Malformed stop item", ok: false, error: "malformed" },
|
|
266
|
+
{ source: "bad-item", kind: "fine", detail: "fine stopped", ok: true },
|
|
267
|
+
{ source: "healthy", kind: "async_run", id: "run-9", detail: "async_run stopped", ok: true },
|
|
268
|
+
]);
|
|
269
|
+
});
|
|
270
|
+
it("bounds the total wait: a contributor that never settles is reported ok:false error timeout, the rest are kept", async () => {
|
|
271
|
+
const bus = createEventBus();
|
|
272
|
+
stopper(bus, "fast", "host", () => [item("fast", "async_run")]);
|
|
273
|
+
stopper(bus, "stuck", "host", () => new Promise(() => undefined));
|
|
274
|
+
const startedAt = Date.now();
|
|
275
|
+
const data = result(await new RpcReleaseBridge(bus, scope).stopAll(stopCommand, () => new Promise(() => undefined), noBlockers, 60));
|
|
276
|
+
expect(Date.now() - startedAt).toBeLessThan(2000);
|
|
277
|
+
expect(data.stopped.map(i => [i.source, i.ok, i.error])).toEqual([["core", false, "timeout"], ["fast", true, undefined], ["stuck", false, "timeout"]]);
|
|
278
|
+
});
|
|
279
|
+
it("hands each contributor the shared deadline so it can bound its own waiting", async () => {
|
|
280
|
+
const bus = createEventBus();
|
|
281
|
+
let seen = 0;
|
|
282
|
+
stopper(bus, "waiter", "host", ({ deadline }) => { seen = deadline; return []; });
|
|
283
|
+
const before = Date.now();
|
|
284
|
+
await new RpcReleaseBridge(bus, scope).stopAll(stopCommand, idleCore, noBlockers, 1234);
|
|
285
|
+
expect(seen).toBeGreaterThanOrEqual(before + 1234);
|
|
286
|
+
expect(seen).toBeLessThanOrEqual(Date.now() + 1234);
|
|
287
|
+
});
|
|
288
|
+
it("ignores other sessions' handlers and late contributions, and bounds the item count", async () => {
|
|
289
|
+
const bus = createEventBus();
|
|
290
|
+
stopper(bus, "other", "another-session", () => [item("other", "async_run")]);
|
|
291
|
+
let late;
|
|
292
|
+
bus.on(STOP_ALL_BACKGROUND_EVENT, raw => { late = raw; });
|
|
293
|
+
stopper(bus, "many", "host", () => Array.from({ length: 200 }, (_, i) => item("many", "async_run", { id: `r${i}` })));
|
|
294
|
+
const bridge = new RpcReleaseBridge(bus, scope);
|
|
295
|
+
const data = result(await bridge.stopAll(stopCommand, idleCore, noBlockers));
|
|
296
|
+
expect(data.stopped).toHaveLength(129);
|
|
297
|
+
expect(data.stopped.at(-1)).toMatchObject({ ok: false, error: "truncated", detail: expect.stringContaining("72 further") });
|
|
298
|
+
expect(data.stopped.some(i => i.source === "other")).toBe(false);
|
|
299
|
+
late.contribute("late", [item("late", "async_run")]);
|
|
300
|
+
expect(result(await bridge.stopAll(stopCommand, idleCore, noBlockers)).stopped.some(i => i.source === "late")).toBe(false);
|
|
301
|
+
});
|
|
302
|
+
it("fails closed when the bus emit throws", async () => {
|
|
303
|
+
const throwing = { emit() { throw new Error("bus down"); }, on: () => () => undefined };
|
|
304
|
+
const data = result(await new RpcReleaseBridge(throwing, scope).stopAll(stopCommand, idleCore, noBlockers));
|
|
305
|
+
expect(data.stopped).toEqual([{ source: "unknown", kind: "contributor", detail: "Stop failed: bus down", ok: false, error: "bus down" }]);
|
|
306
|
+
});
|
|
307
|
+
it("rejects malformed requests with success:false and never calls the core stop or contributors", async () => {
|
|
308
|
+
const bus = createEventBus();
|
|
309
|
+
let calls = 0;
|
|
310
|
+
stopper(bus, "subagents", "host", () => { calls++; return []; });
|
|
311
|
+
const bridge = new RpcReleaseBridge(bus, scope);
|
|
312
|
+
const core = () => { calls++; return []; };
|
|
313
|
+
expect(await bridge.stopAll({ type: "stop_all_background", version: 2 }, core, noBlockers)).toEqual({ id: undefined, type: "response", command: "stop_all_background", success: false, error: "Unsupported stop_all_background version; expected 1" });
|
|
314
|
+
expect(await bridge.stopAll({ type: "stop_all_background" }, core, noBlockers)).toMatchObject({ success: false });
|
|
315
|
+
expect(await bridge.stopAll({ ...stopCommand, id: "x".repeat(129) }, core, noBlockers)).toMatchObject({ success: false });
|
|
316
|
+
expect(await bridge.stopAll({ ...stopCommand, id: "a\nb" }, core, noBlockers)).toMatchObject({ success: false });
|
|
317
|
+
expect(await bridge.stopAll({ ...stopCommand, method: "spawn" }, core, noBlockers)).toMatchObject({ success: false, error: "Malformed stop_all_background command" });
|
|
318
|
+
expect(calls).toBe(0);
|
|
319
|
+
expect(validateReleaseCommand(stopCommand)).toBeUndefined();
|
|
320
|
+
});
|
|
321
|
+
it("fails after dispose without calling anything", async () => {
|
|
322
|
+
const bus = createEventBus();
|
|
323
|
+
let calls = 0;
|
|
324
|
+
stopper(bus, "subagents", "host", () => { calls++; return []; });
|
|
325
|
+
const bridge = new RpcReleaseBridge(bus, scope);
|
|
326
|
+
bridge.dispose();
|
|
327
|
+
expect(await bridge.stopAll({ ...stopCommand, id: "late" }, () => { calls++; return []; }, noBlockers)).toEqual({ id: "late", type: "response", command: "stop_all_background", success: false, error: "Release readiness session ended" });
|
|
328
|
+
expect(calls).toBe(0);
|
|
329
|
+
});
|
|
330
|
+
it("validates stop item shape and bounds, and the handler gate", () => {
|
|
331
|
+
expect(isStopAllBackgroundItem({ source: "core", kind: "turn", detail: "d", ok: true })).toBe(true);
|
|
332
|
+
expect(isStopAllBackgroundItem({ source: "core", kind: "turn", detail: "d", ok: false, error: "timeout", id: "i" })).toBe(true);
|
|
333
|
+
expect(isStopAllBackgroundItem({ source: "core", kind: "turn", detail: "d" })).toBe(false);
|
|
334
|
+
expect(isStopAllBackgroundItem({ source: "core", kind: "turn", detail: "d", ok: "yes" })).toBe(false);
|
|
335
|
+
expect(isStopAllBackgroundItem({ source: "core", kind: "turn", detail: "d", ok: true, extra: 1 })).toBe(false);
|
|
336
|
+
expect(isStopAllBackgroundItem({ source: "core", kind: "turn", detail: "x".repeat(513), ok: true })).toBe(false);
|
|
337
|
+
expect(isStopAllBackgroundItem(null)).toBe(false);
|
|
338
|
+
const seen = [];
|
|
339
|
+
const request = (sessionId) => ({ version: 1, sessionId, deadline: Date.now(), contribute: name => { seen.push(name); } });
|
|
340
|
+
answerStopAllBackground(request("a"), "x", "a", () => []);
|
|
341
|
+
answerStopAllBackground(request("a"), "x", "b", () => []);
|
|
342
|
+
answerStopAllBackground(request("a"), "x", null, () => []);
|
|
343
|
+
answerStopAllBackground(undefined, "x", "a", () => []);
|
|
344
|
+
answerStopAllBackground({ version: 2, sessionId: "a", contribute: () => seen.push("v2") }, "x", "a", () => []);
|
|
345
|
+
expect(seen).toEqual(["x"]);
|
|
346
|
+
});
|
|
347
|
+
});
|