@selesai/code 0.13.39 → 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/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-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 +4 -2
- 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,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
|
+
});
|
|
@@ -1,18 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Spawns the agent in RPC mode and provides a typed API for all operations.
|
|
5
|
-
*/
|
|
1
|
+
import type { SubagentRpcSnapshot } from "../../core/subagent-rpc.ts";
|
|
2
|
+
import type { ReleaseReadiness, ReleaseResult, StopAllBackgroundResult } from "../../core/release-rpc.ts";
|
|
6
3
|
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
7
4
|
import type { ImageContent, Model } from "@earendil-works/pi-ai";
|
|
8
|
-
import type {
|
|
5
|
+
import type { SessionStats } from "../../core/agent-session.ts";
|
|
9
6
|
import type { KeybindingsConfig } from "../../core/keybindings.ts";
|
|
10
7
|
import type { Settings } from "../../core/settings-manager.ts";
|
|
11
8
|
import type { BashResult } from "../../core/bash-executor.ts";
|
|
12
9
|
import type { CompactionResult } from "../../core/compaction/index.ts";
|
|
13
10
|
import type { SessionEntry, SessionTreeNode } from "../../core/session-manager.ts";
|
|
14
11
|
import type { ChangelogEntry } from "../../utils/changelog.ts";
|
|
15
|
-
import type { RpcSessionInfo, RpcSessionState, RpcSkillInfo, RpcSlashCommand } from "./rpc-types.ts";
|
|
12
|
+
import type { RpcEvent, IntercomRpcDataMap, IntercomRpcMessageOptions, RpcSessionInfo, RpcSessionState, RpcSkillInfo, RpcSlashCommand } from "./rpc-types.ts";
|
|
16
13
|
export interface RpcClientOptions {
|
|
17
14
|
/** Path to the CLI entry point (default: searches for dist/cli.js) */
|
|
18
15
|
cliPath?: string;
|
|
@@ -33,7 +30,7 @@ export interface ModelInfo {
|
|
|
33
30
|
contextWindow: number;
|
|
34
31
|
reasoning: boolean;
|
|
35
32
|
}
|
|
36
|
-
export type RpcEventListener = (event:
|
|
33
|
+
export type RpcEventListener = (event: RpcEvent) => void;
|
|
37
34
|
export declare class RpcClient {
|
|
38
35
|
private process;
|
|
39
36
|
private stopReadingStdout;
|
|
@@ -97,6 +94,33 @@ export declare class RpcClient {
|
|
|
97
94
|
/**
|
|
98
95
|
* Get current session state.
|
|
99
96
|
*/
|
|
97
|
+
/** Read-only native subagent snapshot; never invokes a model. */
|
|
98
|
+
subagentStatus(): Promise<SubagentRpcSnapshot>;
|
|
99
|
+
/** Authoritative "is it safe to stop this process now" answer; never invokes a model. */
|
|
100
|
+
releaseReadiness(): Promise<ReleaseReadiness>;
|
|
101
|
+
/**
|
|
102
|
+
* Atomic "release if safe". `released: true` means the process has committed to a gentle exit (code 0, no abort) and rejects
|
|
103
|
+
* every further command except `release`/`release_readiness`; `released: false` carries the blockers and the process keeps running.
|
|
104
|
+
* "Nobody answered" (no extension contributors) is never released.
|
|
105
|
+
*/
|
|
106
|
+
release(): Promise<ReleaseResult>;
|
|
107
|
+
/**
|
|
108
|
+
* DESTRUCTIVE kill switch for an explicit user "Quit anyway": aborts the turn (retry, compaction, bash, queued messages), stops every
|
|
109
|
+
* async/detached subagent run of this session (detached runners terminate on their own), disarms this session's session-only
|
|
110
|
+
* schedules (paused, project schedules untouched) and cancels pending outbound intercom asks. Per-item failures are `ok:false`
|
|
111
|
+
* in `stopped`; `remaining` is the readiness computed after the stop. Idempotent; refused while the session is releasing.
|
|
112
|
+
*/
|
|
113
|
+
stopAllBackground(): Promise<StopAllBackgroundResult>;
|
|
114
|
+
intercomStatus(): Promise<IntercomRpcDataMap["intercom_status"]>;
|
|
115
|
+
intercomList(): Promise<IntercomRpcDataMap["intercom_list"]>;
|
|
116
|
+
intercomPending(): Promise<IntercomRpcDataMap["intercom_pending"]>;
|
|
117
|
+
intercomSend(to: string, options: IntercomRpcMessageOptions): Promise<IntercomRpcDataMap["intercom_send"]>;
|
|
118
|
+
intercomAsk(to: string, options: IntercomRpcMessageOptions & {
|
|
119
|
+
timeoutMs?: number;
|
|
120
|
+
}): Promise<IntercomRpcDataMap["intercom_ask"]>;
|
|
121
|
+
intercomReply(options: IntercomRpcMessageOptions & {
|
|
122
|
+
to?: string;
|
|
123
|
+
}): Promise<IntercomRpcDataMap["intercom_reply"]>;
|
|
100
124
|
getState(): Promise<RpcSessionState>;
|
|
101
125
|
/**
|
|
102
126
|
* Set model by provider and ID.
|
|
@@ -425,11 +449,11 @@ export declare class RpcClient {
|
|
|
425
449
|
/**
|
|
426
450
|
* Collect events until agent becomes idle.
|
|
427
451
|
*/
|
|
428
|
-
collectEvents(timeout?: number): Promise<
|
|
452
|
+
collectEvents(timeout?: number): Promise<RpcEvent[]>;
|
|
429
453
|
/**
|
|
430
454
|
* Send prompt and wait for completion, returning all events.
|
|
431
455
|
*/
|
|
432
|
-
promptAndWait(message: string, images?: ImageContent[], timeout?: number): Promise<
|
|
456
|
+
promptAndWait(message: string, images?: ImageContent[], timeout?: number): Promise<RpcEvent[]>;
|
|
433
457
|
private handleLine;
|
|
434
458
|
private createProcessExitError;
|
|
435
459
|
private rejectPendingRequests;
|