@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,715 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { afterEach, describe, it } from "node:test";
|
|
6
|
+
import type { ExtensionContext } from "@selesai/code";
|
|
7
|
+
import { createEventBus } from "../../../../core/event-bus.ts";
|
|
8
|
+
import { RpcReleaseBridge } from "../../../../modes/rpc/release-bridge.ts";
|
|
9
|
+
import {
|
|
10
|
+
RELEASE_READINESS_EVENT,
|
|
11
|
+
STOP_ALL_BACKGROUND_EVENT,
|
|
12
|
+
findUndeliveredResults,
|
|
13
|
+
registerReleaseReadiness,
|
|
14
|
+
schedulerReleaseBlockers,
|
|
15
|
+
stopSchedules,
|
|
16
|
+
stopSubagentWork,
|
|
17
|
+
subagentReleaseBlockers,
|
|
18
|
+
type SubagentReleaseDeps,
|
|
19
|
+
type SubagentStopDeps,
|
|
20
|
+
} from "../../src/extension/release-readiness.ts";
|
|
21
|
+
import { reconcileAsyncRun } from "../../src/runs/background/stale-run-reconciler.ts";
|
|
22
|
+
import { writeAsyncResultFile } from "../../src/runs/background/result-files.ts";
|
|
23
|
+
import { createResultDeliveryOwnership } from "../../src/runs/background/result-delivery-ownership.ts";
|
|
24
|
+
import { ownerRecordPath } from "../../src/shared/owner-record.ts";
|
|
25
|
+
import { createScheduledRunManager } from "../../src/runs/background/scheduled-runs.ts";
|
|
26
|
+
import type { AsyncJobState, SubagentState } from "../../src/shared/types.ts";
|
|
27
|
+
|
|
28
|
+
const roots: string[] = [];
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
const tempRoot = (): string => {
|
|
33
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-release-test-"));
|
|
34
|
+
roots.push(root);
|
|
35
|
+
return root;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function makeState(jobs: Array<Partial<AsyncJobState> & { asyncId: string }> = []): SubagentState {
|
|
39
|
+
return {
|
|
40
|
+
currentSessionId: "/managed/session",
|
|
41
|
+
supervisorOwnerSessionId: "host",
|
|
42
|
+
asyncJobs: new Map(jobs.map((job) => [job.asyncId, { asyncDir: `/nowhere/${job.asyncId}`, status: "running", startedAt: 1000, updatedAt: 1000, ...job } as AsyncJobState])),
|
|
43
|
+
foregroundControls: new Map(),
|
|
44
|
+
} as unknown as SubagentState;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function deps(state: SubagentState, overrides: Partial<SubagentReleaseDeps> = {}): SubagentReleaseDeps {
|
|
48
|
+
return { state, hasPendingDelivery: () => false, listUndeliveredResults: () => [], ...overrides };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const dead = () => { throw Object.assign(new Error("no such process"), { code: "ESRCH" }); };
|
|
52
|
+
const alive = () => true;
|
|
53
|
+
|
|
54
|
+
describe("subagent release blockers", () => {
|
|
55
|
+
it("is empty for an idle session", () => {
|
|
56
|
+
assert.deepEqual(subagentReleaseBlockers(deps(makeState())), []);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("reports a live running job as async_run with its id and start time", () => {
|
|
60
|
+
const state = makeState([{ asyncId: "run-live", status: "running", startedAt: 1234, mode: "chain", agents: ["scout", "worker"] }]);
|
|
61
|
+
const blockers = subagentReleaseBlockers(deps(state, {
|
|
62
|
+
reconcile: () => ({ status: { runId: "run-live", state: "running" } as never, repaired: false }),
|
|
63
|
+
}));
|
|
64
|
+
assert.deepEqual(blockers, [{ source: "subagents", kind: "async_run", id: "run-live", detail: "Async chain running: scout, worker", since: 1234 }]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("treats a job whose status is not yet written (within grace) as live", () => {
|
|
68
|
+
const state = makeState([{ asyncId: "run-new", status: "queued" }]);
|
|
69
|
+
const blockers = subagentReleaseBlockers(deps(state, { reconcile: () => ({ status: null, repaired: false }) }));
|
|
70
|
+
assert.deepEqual(blockers.map((b) => [b.kind, b.id]), [["async_run", "run-new"]]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("reconciles a dead-pid running job first and reports its repaired result as undelivered_result, not async_run", () => {
|
|
74
|
+
const state = makeState([{ asyncId: "run-dead", status: "running", pid: 999_999 }]);
|
|
75
|
+
const seen: string[] = [];
|
|
76
|
+
const blockers = subagentReleaseBlockers(deps(state, {
|
|
77
|
+
reconcile: (asyncDir) => {
|
|
78
|
+
seen.push(asyncDir);
|
|
79
|
+
return { status: { runId: "run-dead", state: "failed", endedAt: 2000 } as never, repaired: true };
|
|
80
|
+
},
|
|
81
|
+
}));
|
|
82
|
+
assert.deepEqual(seen, ["/nowhere/run-dead"]);
|
|
83
|
+
assert.equal(blockers.some((b) => b.kind === "async_run"), false);
|
|
84
|
+
assert.deepEqual(blockers.map((b) => [b.source, b.kind, b.id, b.since]), [["subagents", "undelivered_result", "run-dead", 2000]]);
|
|
85
|
+
assert.match(blockers[0]!.detail, /without a live runner/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("runs the real reconciler against a dead runner pid and surfaces the persisted failure result once", () => {
|
|
89
|
+
const root = tempRoot();
|
|
90
|
+
const asyncDir = path.join(root, "async", "run-dead");
|
|
91
|
+
const resultsDir = path.join(root, "results");
|
|
92
|
+
fs.mkdirSync(asyncDir, { recursive: true });
|
|
93
|
+
fs.mkdirSync(resultsDir, { recursive: true });
|
|
94
|
+
fs.writeFileSync(path.join(asyncDir, "status.json"), JSON.stringify({
|
|
95
|
+
runId: "run-dead", sessionId: "/managed/session", completionOwnerId: "owner-1", mode: "single", state: "running",
|
|
96
|
+
pid: 999_999, startedAt: 1000, lastUpdate: 1000, currentStep: 0, steps: [{ agent: "worker", status: "running", startedAt: 1000 }],
|
|
97
|
+
}));
|
|
98
|
+
const state = makeState([{ asyncId: "run-dead", asyncDir, status: "running", pid: 999_999, sessionId: "/managed/session", completionOwnerId: "owner-1" }]);
|
|
99
|
+
const real = (dir: string, options: Parameters<typeof reconcileAsyncRun>[1]) => reconcileAsyncRun(dir, { ...options, kill: dead });
|
|
100
|
+
const owns = (sessionId: string, owner: unknown) => sessionId === "/managed/session" && owner === "owner-1";
|
|
101
|
+
const blockers = subagentReleaseBlockers(deps(state, {
|
|
102
|
+
resultsDir, reconcile: real,
|
|
103
|
+
listUndeliveredResults: () => findUndeliveredResults({ resultsDir, sessionIds: ["/managed/session"], owns }),
|
|
104
|
+
}));
|
|
105
|
+
assert.deepEqual(blockers.map((b) => [b.kind, b.id]), [["undelivered_result", "run-dead"]]);
|
|
106
|
+
// The repair is durable: a second call still reports it (until delivered), and the status is terminal on disk.
|
|
107
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")).state, "failed");
|
|
108
|
+
assert.deepEqual(findUndeliveredResults({ resultsDir, sessionIds: ["/managed/session"], owns }), ["run-dead"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("keeps a run with a live pid as async_run through the real reconciler", () => {
|
|
112
|
+
const root = tempRoot();
|
|
113
|
+
const asyncDir = path.join(root, "async", "run-alive");
|
|
114
|
+
fs.mkdirSync(asyncDir, { recursive: true });
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
fs.writeFileSync(path.join(asyncDir, "status.json"), JSON.stringify({
|
|
117
|
+
runId: "run-alive", sessionId: "/managed/session", mode: "single", state: "running",
|
|
118
|
+
pid: process.pid, startedAt: now, lastUpdate: now, currentStep: 0, steps: [{ agent: "worker", status: "running", startedAt: now }],
|
|
119
|
+
}));
|
|
120
|
+
const state = makeState([{ asyncId: "run-alive", asyncDir, status: "running", pid: process.pid, startedAt: now }]);
|
|
121
|
+
const blockers = subagentReleaseBlockers(deps(state, {
|
|
122
|
+
resultsDir: path.join(root, "results"),
|
|
123
|
+
reconcile: (dir, options) => reconcileAsyncRun(dir, { ...options, kill: alive }),
|
|
124
|
+
}));
|
|
125
|
+
assert.deepEqual(blockers.map((b) => [b.kind, b.id, b.since]), [["async_run", "run-alive", now]]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("fails closed when reconciliation throws: contributor_error plus the job stays an async_run", () => {
|
|
129
|
+
const state = makeState([{ asyncId: "run-bad" }]);
|
|
130
|
+
const blockers = subagentReleaseBlockers(deps(state, { reconcile: () => { throw new Error("status.json is corrupt"); } }));
|
|
131
|
+
assert.deepEqual(blockers.map((b) => [b.kind, b.id]), [["contributor_error", "run-bad"], ["async_run", "run-bad"]]);
|
|
132
|
+
assert.match(blockers[0]!.detail, /status\.json is corrupt/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("does not reconcile jobs that are already terminal in memory", () => {
|
|
136
|
+
const state = makeState([{ asyncId: "run-done", status: "complete" }]);
|
|
137
|
+
const blockers = subagentReleaseBlockers(deps(state, { reconcile: () => { throw new Error("must not be called"); } }));
|
|
138
|
+
assert.deepEqual(blockers, []);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("reports foreground runs, retained nested routes, nested descendants of finished jobs and pending notifications", () => {
|
|
142
|
+
const state = makeState([{ asyncId: "run-nested", status: "complete", nestedChildren: [{ id: "child", state: "running" }] as never }]);
|
|
143
|
+
state.foregroundControls.set("fg-1", { runId: "fg-1", mode: "single", startedAt: 77, updatedAt: 77, currentAgent: "scout" } as never);
|
|
144
|
+
state.retainedForegroundNestedRoutes = new Map([["root-1", {} as never]]);
|
|
145
|
+
const blockers = subagentReleaseBlockers(deps(state, { hasPendingDelivery: () => true }));
|
|
146
|
+
assert.deepEqual(blockers.map((b) => [b.kind, b.id]), [
|
|
147
|
+
["async_run", "run-nested"], ["foreground_run", "fg-1"], ["async_run", "root-1"], ["undelivered_result", undefined],
|
|
148
|
+
]);
|
|
149
|
+
assert.equal(blockers.find((b) => b.kind === "foreground_run")!.since, 77);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("reports persisted undelivered results once and does not double-count a reconciled run", () => {
|
|
153
|
+
const state = makeState([{ asyncId: "run-dead" }]);
|
|
154
|
+
const blockers = subagentReleaseBlockers(deps(state, {
|
|
155
|
+
reconcile: () => ({ status: { state: "failed" } as never, repaired: true }),
|
|
156
|
+
listUndeliveredResults: () => ["run-dead", "run-old"],
|
|
157
|
+
}));
|
|
158
|
+
assert.deepEqual(blockers.map((b) => [b.kind, b.id]), [["undelivered_result", "run-dead"], ["undelivered_result", "run-old"]]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("uses delivery demand only as a catch-all", () => {
|
|
162
|
+
assert.deepEqual(subagentReleaseBlockers(deps(makeState(), { hasDeliveryDemand: () => false })), []);
|
|
163
|
+
const blockers = subagentReleaseBlockers(deps(makeState(), { hasDeliveryDemand: () => true }));
|
|
164
|
+
assert.deepEqual(blockers.map((b) => b.kind), ["undelivered_result"]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("reports a probe failure instead of dropping it", () => {
|
|
168
|
+
const blockers = subagentReleaseBlockers(deps(makeState(), { hasPendingDelivery: () => { throw new Error("notifier gone"); } }));
|
|
169
|
+
assert.deepEqual(blockers.map((b) => b.kind), ["contributor_error"]);
|
|
170
|
+
assert.match(blockers[0]!.detail, /notifier gone/);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("undelivered result discovery", () => {
|
|
175
|
+
const write = (resultsDir: string, runId: string, extra: Record<string, unknown> = {}) => writeAsyncResultFile(path.join(resultsDir, `${runId}.json`), {
|
|
176
|
+
id: runId, runId, sessionId: "/managed/session", completionOwnerId: "owner-1", success: false, state: "failed", ...extra,
|
|
177
|
+
});
|
|
178
|
+
const owns = (sessionId: string, owner: unknown) => sessionId === "/managed/session" && owner === "owner-1";
|
|
179
|
+
|
|
180
|
+
it("lists owned undelivered results and skips delivered or foreign-owned ones", () => {
|
|
181
|
+
const resultsDir = path.join(tempRoot(), "results");
|
|
182
|
+
fs.mkdirSync(resultsDir, { recursive: true });
|
|
183
|
+
write(resultsDir, "mine");
|
|
184
|
+
write(resultsDir, "delivered", { notificationDeliveredAt: 5 });
|
|
185
|
+
write(resultsDir, "foreign", { completionOwnerId: "someone-else" });
|
|
186
|
+
assert.deepEqual(findUndeliveredResults({ resultsDir, sessionIds: ["/managed/session", null, undefined], owns }), ["mine"]);
|
|
187
|
+
assert.deepEqual(findUndeliveredResults({ resultsDir, sessionIds: [], owns }), []);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("reports a foreign result this process will take over, but not one with a live owner, and never claims", () => {
|
|
191
|
+
const root = tempRoot();
|
|
192
|
+
const resultsDir = path.join(root, "results");
|
|
193
|
+
const ownersDir = path.join(root, "owners");
|
|
194
|
+
fs.mkdirSync(resultsDir, { recursive: true });
|
|
195
|
+
fs.mkdirSync(ownersDir, { recursive: true });
|
|
196
|
+
const record = (id: string, pid: number) => fs.writeFileSync(ownerRecordPath(ownersDir, id)!, JSON.stringify({ version: 1, completionOwnerId: id, pid, hostname: "host-a", createdAt: 1 }));
|
|
197
|
+
record("dead-owner", 100);
|
|
198
|
+
record("live-owner", 200);
|
|
199
|
+
write(resultsDir, "mine", { completionOwnerId: "me" });
|
|
200
|
+
write(resultsDir, "orphan", { completionOwnerId: "dead-owner" });
|
|
201
|
+
write(resultsDir, "orphan-delivered", { completionOwnerId: "dead-owner", notificationDeliveredAt: 5 });
|
|
202
|
+
write(resultsDir, "busy", { completionOwnerId: "live-owner" });
|
|
203
|
+
write(resultsDir, "unknown-owner", { completionOwnerId: "no-record" });
|
|
204
|
+
write(resultsDir, "other-session", { completionOwnerId: "dead-owner", sessionId: "/other/session" });
|
|
205
|
+
const ownership = createResultDeliveryOwnership({ currentSessionId: "/managed/session", completionOwnerId: "me" }, {
|
|
206
|
+
ownersDir,
|
|
207
|
+
hostname: () => "host-a",
|
|
208
|
+
processLiveness: (pid) => (pid === 100 ? "dead" : "alive"),
|
|
209
|
+
processStartKey: () => undefined,
|
|
210
|
+
});
|
|
211
|
+
const options = {
|
|
212
|
+
resultsDir,
|
|
213
|
+
sessionIds: ["/managed/session", "/other/session"],
|
|
214
|
+
owns: (sessionId: string, owner: unknown) => ownership.owns(sessionId, owner),
|
|
215
|
+
canTakeOver: (sessionId: string, owner: unknown, runKey: string) => ownership.canTakeOver(sessionId, owner, runKey),
|
|
216
|
+
};
|
|
217
|
+
// "other-session" is only scanned because the caller listed it; ownership still refuses to take it over.
|
|
218
|
+
assert.deepEqual(findUndeliveredResults(options).sort(), ["mine", "orphan"]);
|
|
219
|
+
assert.equal(fs.existsSync(path.join(ownersDir, "claims")), false, "the readiness probe is read-only");
|
|
220
|
+
// Once the watcher has actually claimed it, the result is still reported until it is delivered.
|
|
221
|
+
assert.equal(ownership.owns("/managed/session", "dead-owner", "orphan"), true);
|
|
222
|
+
assert.deepEqual(findUndeliveredResults(options).sort(), ["mine", "orphan"]);
|
|
223
|
+
// Without the takeover hook the old behaviour holds: foreign results are skipped.
|
|
224
|
+
assert.deepEqual(findUndeliveredResults({ ...options, canTakeOver: undefined }).sort(), ["mine"]);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("scheduler release blockers", () => {
|
|
229
|
+
it("blocks on session-only and unreadable armed schedules and on pending scheduled runs, but not on project schedules", () => {
|
|
230
|
+
const next = Date.parse("2030-01-01T00:10:00Z");
|
|
231
|
+
const blockers = schedulerReleaseBlockers({
|
|
232
|
+
armedSchedules: () => [
|
|
233
|
+
{ id: "nightly", nextRunAt: next, sessionOnly: true },
|
|
234
|
+
{ id: "project", nextRunAt: next, sessionOnly: false },
|
|
235
|
+
{ id: "unreadable" },
|
|
236
|
+
],
|
|
237
|
+
observedCompletionRunIds: () => ["run-9"],
|
|
238
|
+
});
|
|
239
|
+
assert.deepEqual(blockers, [
|
|
240
|
+
{ source: "scheduler", kind: "armed_schedule", id: "nightly", detail: "Armed session-only schedule, next run 2030-01-01T00:10:00.000Z", since: next },
|
|
241
|
+
{ source: "scheduler", kind: "armed_schedule", id: "unreadable", detail: "Armed unreadable schedule" },
|
|
242
|
+
{ source: "scheduler", kind: "scheduled_run_pending", id: "run-9", detail: "Scheduled run run-9 is awaiting completion" },
|
|
243
|
+
]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("does not block on a project schedule alone, yet still blocks on its pending run (live work)", () => {
|
|
247
|
+
assert.deepEqual(schedulerReleaseBlockers({ armedSchedules: () => [{ id: "p", sessionOnly: false }], observedCompletionRunIds: () => [] }), []);
|
|
248
|
+
assert.deepEqual(
|
|
249
|
+
schedulerReleaseBlockers({ armedSchedules: () => [{ id: "p", sessionOnly: false }], observedCompletionRunIds: () => ["run-1"] }).map((b) => b.kind),
|
|
250
|
+
["scheduled_run_pending"],
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("fails closed when a real manager cannot read an armed schedule's record", async () => {
|
|
255
|
+
const root = tempRoot();
|
|
256
|
+
const project = path.join(root, "project");
|
|
257
|
+
fs.mkdirSync(project, { recursive: true });
|
|
258
|
+
const ctx = { cwd: project, sessionManager: { getSessionId: () => "session-a", getSessionFile: () => path.join(project, "session-a.jsonl") } } as unknown as ExtensionContext;
|
|
259
|
+
const manager = createScheduledRunManager({
|
|
260
|
+
config: { scheduledRuns: { enabled: true } },
|
|
261
|
+
storeRoot: path.join(root, "stores"),
|
|
262
|
+
now: () => Date.parse("2030-01-01T00:00:00Z"),
|
|
263
|
+
timers: { setTimeout: (() => 1) as never, clearTimeout: (() => undefined) as never },
|
|
264
|
+
launch: () => new Promise(() => undefined) as never,
|
|
265
|
+
});
|
|
266
|
+
manager.bindSession(ctx);
|
|
267
|
+
const script = "return runs.run('main', { agent: 'reviewer', task: 'Review the diff' })";
|
|
268
|
+
await manager.handleToolCall({ action: "schedule.create", id: "project-one", at: "+10m", workflowScript: script }, ctx);
|
|
269
|
+
await manager.handleToolCall({ action: "schedule.create", id: "session-one", at: "+20m", sessionOnly: true, workflowScript: script }, ctx);
|
|
270
|
+
const blockers = (armed = manager.armedSchedules()) => schedulerReleaseBlockers({ armedSchedules: () => armed, observedCompletionRunIds: () => [] });
|
|
271
|
+
assert.deepEqual(blockers().map((b) => b.id), ["session-one"]);
|
|
272
|
+
// Break the stores so `find` throws: both armed timers now have an unknown kind and must block.
|
|
273
|
+
const stores = (manager as unknown as { stores: Map<string, { find(id: string): unknown }> }).stores;
|
|
274
|
+
for (const store of stores.values()) store.find = () => { throw new Error("corrupt record"); };
|
|
275
|
+
assert.deepEqual(blockers().map((b) => b.id).sort(), ["project-one", "session-one"]);
|
|
276
|
+
manager.stop();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("reads armed schedules from a real ScheduledRunManager and clears them when paused or stopped", async () => {
|
|
280
|
+
const root = tempRoot();
|
|
281
|
+
const project = path.join(root, "project");
|
|
282
|
+
fs.mkdirSync(project, { recursive: true });
|
|
283
|
+
const timers = new Map<number, () => void>();
|
|
284
|
+
let nextTimer = 0;
|
|
285
|
+
const clock = { now: Date.parse("2030-01-01T00:00:00Z") };
|
|
286
|
+
const ctx = { cwd: project, sessionManager: { getSessionId: () => "session-a", getSessionFile: () => path.join(project, "session-a.jsonl") } } as unknown as ExtensionContext;
|
|
287
|
+
const manager = createScheduledRunManager({
|
|
288
|
+
config: { scheduledRuns: { enabled: true } },
|
|
289
|
+
storeRoot: path.join(root, "stores"),
|
|
290
|
+
now: () => clock.now,
|
|
291
|
+
timers: {
|
|
292
|
+
setTimeout: ((callback: () => void) => { const id = ++nextTimer; timers.set(id, callback); return id; }) as never,
|
|
293
|
+
clearTimeout: ((id: number) => void timers.delete(id)) as never,
|
|
294
|
+
},
|
|
295
|
+
launch: () => new Promise(() => undefined) as never,
|
|
296
|
+
});
|
|
297
|
+
manager.bindSession(ctx);
|
|
298
|
+
assert.deepEqual(manager.armedSchedules(), []);
|
|
299
|
+
const script = "return runs.run('main', { agent: 'reviewer', task: 'Review the diff' })";
|
|
300
|
+
await manager.handleToolCall({ action: "schedule.create", id: "project-one", at: "+10m", workflowScript: script }, ctx);
|
|
301
|
+
await manager.handleToolCall({ action: "schedule.create", id: "session-one", at: "+20m", sessionOnly: true, workflowScript: script }, ctx);
|
|
302
|
+
const armed = manager.armedSchedules().sort((a, b) => a.id.localeCompare(b.id));
|
|
303
|
+
assert.deepEqual(armed, [
|
|
304
|
+
{ id: "project-one", nextRunAt: clock.now + 600_000, sessionOnly: false },
|
|
305
|
+
{ id: "session-one", nextRunAt: clock.now + 1_200_000, sessionOnly: true },
|
|
306
|
+
]);
|
|
307
|
+
await manager.handleToolCall({ action: "schedule.pause", id: "project-one" }, ctx);
|
|
308
|
+
assert.deepEqual(manager.armedSchedules().map((s) => s.id), ["session-one"]);
|
|
309
|
+
manager.stop();
|
|
310
|
+
assert.deepEqual(manager.armedSchedules(), []);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe("registerReleaseReadiness", () => {
|
|
315
|
+
function wire(state: SubagentState, hostSessionId: () => string | null, overrides: Partial<SubagentReleaseDeps> = {}, armed: Array<{ id: string }> = []) {
|
|
316
|
+
const bus = createEventBus();
|
|
317
|
+
const registration = registerReleaseReadiness({
|
|
318
|
+
events: bus,
|
|
319
|
+
getHostSessionId: hostSessionId,
|
|
320
|
+
subagents: deps(state, overrides),
|
|
321
|
+
scheduler: { armedSchedules: () => armed, observedCompletionRunIds: () => [] },
|
|
322
|
+
});
|
|
323
|
+
return { bus, registration };
|
|
324
|
+
}
|
|
325
|
+
const scope = (sessionId: string) => ({ version: 1 as const, sessionId, epoch: "e1" });
|
|
326
|
+
const command = { type: "release_readiness" as const, version: 1 as const };
|
|
327
|
+
const read = (bridge: RpcReleaseBridge) => {
|
|
328
|
+
const reply = bridge.request(command);
|
|
329
|
+
if (!reply.success || reply.command !== "release_readiness") throw new Error(JSON.stringify(reply));
|
|
330
|
+
return reply.data;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
it("uses the same event name as the host package", () => {
|
|
334
|
+
assert.equal(RELEASE_READINESS_EVENT, "release:readiness:v1");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("answers as subagents and scheduler for the host session (supervisorOwnerSessionId, not currentSessionId)", () => {
|
|
338
|
+
const state = makeState([{ asyncId: "run-live" }]);
|
|
339
|
+
const { bus } = wire(state, () => state.supervisorOwnerSessionId ?? null, { reconcile: () => ({ status: null, repaired: false }) }, [{ id: "armed-1" }]);
|
|
340
|
+
const result = read(new RpcReleaseBridge(bus, scope("host")));
|
|
341
|
+
assert.deepEqual(result.contributors, ["subagents", "scheduler"]);
|
|
342
|
+
assert.equal(result.safe, false);
|
|
343
|
+
assert.deepEqual(result.blockers.map((b) => `${b.source}:${b.kind}:${b.id}`), ["subagents:async_run:run-live", "scheduler:armed_schedule:armed-1"]);
|
|
344
|
+
// The runtime session id (a different value) must NOT be used as the identity.
|
|
345
|
+
assert.deepEqual(read(new RpcReleaseBridge(bus, scope("/managed/session"))).contributors, []);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("is safe but still lists contributors when idle", () => {
|
|
349
|
+
const state = makeState();
|
|
350
|
+
const { bus } = wire(state, () => "host");
|
|
351
|
+
assert.deepEqual(read(new RpcReleaseBridge(bus, scope("host"))), { ...scope("host"), safe: true, blockers: [], contributors: ["subagents", "scheduler"] });
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("ignores requests for other sessions or before a host session is bound", () => {
|
|
355
|
+
const state = makeState([{ asyncId: "run-live" }]);
|
|
356
|
+
let host: string | null = null;
|
|
357
|
+
const { bus } = wire(state, () => host);
|
|
358
|
+
assert.deepEqual(read(new RpcReleaseBridge(bus, scope("host"))).contributors, []);
|
|
359
|
+
host = "someone-else";
|
|
360
|
+
assert.deepEqual(read(new RpcReleaseBridge(bus, scope("host"))).contributors, []);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("turns a throwing contributor into contributor_error through the bridge and stops answering after dispose", () => {
|
|
364
|
+
const state = makeState();
|
|
365
|
+
(state as { asyncJobs: unknown }).asyncJobs = { values() { throw new Error("state exploded"); } };
|
|
366
|
+
const { bus, registration } = wire(state, () => "host");
|
|
367
|
+
const failed = read(new RpcReleaseBridge(bus, scope("host")));
|
|
368
|
+
assert.equal(failed.safe, false);
|
|
369
|
+
assert.equal(failed.blockers.some((b) => b.kind === "contributor_error" && b.source === "subagents" && /state exploded/.test(b.detail)), true);
|
|
370
|
+
registration.dispose();
|
|
371
|
+
assert.deepEqual(read(new RpcReleaseBridge(bus, scope("host"))).contributors, []);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe("stop_all_background: subagents handler", () => {
|
|
376
|
+
const SESSION = "/managed/session";
|
|
377
|
+
const farDeadline = () => Date.now() + 5_000;
|
|
378
|
+
|
|
379
|
+
/** A run directory with a live-looking status (this process's pid is alive), like a detached runner would leave. */
|
|
380
|
+
function writeRun(root: string, runId: string, overrides: Record<string, unknown> = {}): string {
|
|
381
|
+
const asyncDir = path.join(root, "async", runId);
|
|
382
|
+
fs.mkdirSync(asyncDir, { recursive: true });
|
|
383
|
+
const now = Date.now();
|
|
384
|
+
fs.writeFileSync(path.join(asyncDir, "status.json"), JSON.stringify({
|
|
385
|
+
runId, sessionId: SESSION, mode: "single", state: "running", pid: process.pid, startedAt: now, lastUpdate: now,
|
|
386
|
+
currentStep: 0, steps: [{ agent: "worker", status: "running", startedAt: now }], ...overrides,
|
|
387
|
+
}));
|
|
388
|
+
return asyncDir;
|
|
389
|
+
}
|
|
390
|
+
/** What the detached runner does when it consumes the stop request: ends with status stopped. */
|
|
391
|
+
function runnerStops(asyncDir: string): void {
|
|
392
|
+
const file = path.join(asyncDir, "status.json");
|
|
393
|
+
const status = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
394
|
+
fs.writeFileSync(file, JSON.stringify({ ...status, state: "stopped", endedAt: Date.now(), lastUpdate: Date.now(), steps: status.steps.map((step: object) => ({ ...step, status: "stopped" })) }));
|
|
395
|
+
}
|
|
396
|
+
function stopDeps(root: string, state: SubagentState, overrides: Partial<SubagentStopDeps> = {}): SubagentStopDeps & { stopped: string[] } {
|
|
397
|
+
const stopped: string[] = [];
|
|
398
|
+
return {
|
|
399
|
+
state, resultsDir: path.join(root, "results"), asyncDirRoot: path.join(root, "async"), observedCompletionRunIds: () => [],
|
|
400
|
+
stopRun: ({ dir }) => { stopped.push(path.basename(dir)); runnerStops(dir); },
|
|
401
|
+
sleep: () => new Promise((resolve) => setTimeout(resolve, 1)), pollIntervalMs: 1,
|
|
402
|
+
...overrides, stopped,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
const job = (root: string, runId: string, overrides: Partial<AsyncJobState> = {}) => ({ asyncId: runId, asyncDir: path.join(root, "async", runId), sessionId: SESSION, pid: process.pid, ...overrides });
|
|
406
|
+
|
|
407
|
+
it("uses the same event name as the host package", () => {
|
|
408
|
+
assert.equal(STOP_ALL_BACKGROUND_EVENT, "release:stop-all:v1");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("is empty (and calls nothing) for an idle session", async () => {
|
|
412
|
+
const root = tempRoot();
|
|
413
|
+
const d = stopDeps(root, makeState());
|
|
414
|
+
assert.deepEqual(await stopSubagentWork(d, { deadline: farDeadline() }), []);
|
|
415
|
+
assert.deepEqual(d.stopped, []);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("stops every live async run of the session through stopRun and reports each once its runner is terminal", async () => {
|
|
419
|
+
const root = tempRoot();
|
|
420
|
+
const one = writeRun(root, "run-1");
|
|
421
|
+
const two = writeRun(root, "run-2", { mode: "chain" });
|
|
422
|
+
const state = makeState([job(root, "run-1", { agents: ["worker"] }), job(root, "run-2", { mode: "chain" })]);
|
|
423
|
+
const d = stopDeps(root, state);
|
|
424
|
+
const items = await stopSubagentWork(d, { deadline: farDeadline() });
|
|
425
|
+
assert.deepEqual(d.stopped.sort(), ["run-1", "run-2"]);
|
|
426
|
+
assert.deepEqual(items.map((i) => [i.source, i.kind, i.id, i.ok]), [["subagents", "async_run", "run-1", true], ["subagents", "async_run", "run-2", true]]);
|
|
427
|
+
assert.match(items[0]!.detail, /^Stopped Async run \(worker\) run-1/);
|
|
428
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(one, "status.json"), "utf-8")).state, "stopped");
|
|
429
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(two, "status.json"), "utf-8")).state, "stopped");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("delivers every stop request synchronously, before the first await (the process may exit right after)", () => {
|
|
433
|
+
const root = tempRoot();
|
|
434
|
+
writeRun(root, "run-1");
|
|
435
|
+
const state = makeState([job(root, "run-1")]);
|
|
436
|
+
const d = stopDeps(root, state, { stopRun: ({ dir }) => { d.stopped.push(path.basename(dir)); } });
|
|
437
|
+
const pending = stopSubagentWork(d, { deadline: Date.now() + 30 });
|
|
438
|
+
assert.deepEqual(d.stopped, ["run-1"], "the request was written during the synchronous call");
|
|
439
|
+
return pending.then((items) => assert.deepEqual(items.map((i) => [i.id, i.ok, i.error]), [["run-1", false, "timeout"]]));
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("leaves a run of ANOTHER session untouched and says so", async () => {
|
|
443
|
+
const root = tempRoot();
|
|
444
|
+
writeRun(root, "mine");
|
|
445
|
+
writeRun(root, "theirs", { sessionId: "/other/session" });
|
|
446
|
+
const state = makeState([job(root, "mine"), job(root, "theirs", { sessionId: "/other/session" })]);
|
|
447
|
+
const d = stopDeps(root, state);
|
|
448
|
+
const items = await stopSubagentWork(d, { deadline: farDeadline() });
|
|
449
|
+
assert.deepEqual(d.stopped, ["mine"]);
|
|
450
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(root, "async", "theirs", "status.json"), "utf-8")).state, "running");
|
|
451
|
+
assert.deepEqual(items.map((i) => [i.id, i.ok, i.error]), [["theirs", false, "foreign_session"], ["mine", true, undefined]]);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("does not stop a run whose stopRun refuses (foreign session on disk) and reports the refusal as ok:false", async () => {
|
|
455
|
+
const root = tempRoot();
|
|
456
|
+
writeRun(root, "run-1");
|
|
457
|
+
const state = makeState([job(root, "run-1")]);
|
|
458
|
+
const d = stopDeps(root, state, { stopRun: () => { throw new Error("Async run 'run-1' was not found in the active session."); } });
|
|
459
|
+
const items = await stopSubagentWork(d, { deadline: farDeadline() });
|
|
460
|
+
assert.deepEqual(items.map((i) => [i.kind, i.id, i.ok, i.error]), [["async_run", "run-1", false, "Async run 'run-1' was not found in the active session."]]);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("skips runs that already ended, including a dead runner (repaired to a failed result) and is idempotent", async () => {
|
|
464
|
+
const root = tempRoot();
|
|
465
|
+
writeRun(root, "run-dead", { pid: 999_999 });
|
|
466
|
+
writeRun(root, "run-done", { state: "complete" });
|
|
467
|
+
writeRun(root, "run-live");
|
|
468
|
+
const state = makeState([job(root, "run-dead", { pid: 999_999 }), job(root, "run-done"), job(root, "run-live")]);
|
|
469
|
+
(state.asyncJobs.get("run-done") as AsyncJobState).status = "running"; // in-memory state lags the disk
|
|
470
|
+
const d = stopDeps(root, state, { reconcile: (dir, options) => reconcileAsyncRun(dir, { ...options, kill: (pid) => { if (pid === 999_999) dead(); return true; } }) });
|
|
471
|
+
const first = await stopSubagentWork(d, { deadline: farDeadline() });
|
|
472
|
+
assert.deepEqual(d.stopped, ["run-live"]);
|
|
473
|
+
assert.deepEqual(first.map((i) => [i.id, i.ok]), [["run-live", true]]);
|
|
474
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(root, "async", "run-dead", "status.json"), "utf-8")).state, "failed");
|
|
475
|
+
const second = await stopSubagentWork(d, { deadline: farDeadline() });
|
|
476
|
+
assert.deepEqual(second, []);
|
|
477
|
+
assert.deepEqual(d.stopped, ["run-live"]);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("stops runs a scheduled run is awaiting (scheduled_run_pending) even when they are not tracked in memory, and ignores finished ones", async () => {
|
|
481
|
+
const root = tempRoot();
|
|
482
|
+
writeRun(root, "sched-live");
|
|
483
|
+
writeRun(root, "sched-done", { state: "complete" });
|
|
484
|
+
const d = stopDeps(root, makeState(), { observedCompletionRunIds: () => ["sched-live", "sched-done", "sched-gone"] });
|
|
485
|
+
const items = await stopSubagentWork(d, { deadline: farDeadline() });
|
|
486
|
+
assert.deepEqual(d.stopped, ["sched-live"]);
|
|
487
|
+
assert.deepEqual(items.map((i) => [i.kind, i.id, i.ok]), [["scheduled_run", "sched-live", true]]);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("stops live nested descendants that have their own run directory", async () => {
|
|
491
|
+
const root = tempRoot();
|
|
492
|
+
writeRun(root, "nested-1");
|
|
493
|
+
const state = makeState([{ ...job(root, "run-parent"), status: "complete", nestedChildren: [{ id: "nested-1", asyncDir: path.join(root, "async", "nested-1"), state: "running", children: [] }, { id: "nested-done", asyncDir: path.join(root, "async", "nested-done"), state: "complete" }] as never }]);
|
|
494
|
+
const d = stopDeps(root, state);
|
|
495
|
+
const items = await stopSubagentWork(d, { deadline: farDeadline() });
|
|
496
|
+
assert.deepEqual(d.stopped, ["nested-1"]);
|
|
497
|
+
assert.deepEqual(items.map((i) => [i.kind, i.id, i.ok]), [["nested_run", "nested-1", true]]);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("reports a runner that never acknowledges as ok:false error timeout, bounded by the deadline", async () => {
|
|
501
|
+
const root = tempRoot();
|
|
502
|
+
writeRun(root, "run-wedged");
|
|
503
|
+
const state = makeState([job(root, "run-wedged")]);
|
|
504
|
+
const d = stopDeps(root, state, { stopRun: ({ dir }) => { d.stopped.push(path.basename(dir)); } });
|
|
505
|
+
const startedAt = Date.now();
|
|
506
|
+
const items = await stopSubagentWork(d, { deadline: Date.now() + 80 });
|
|
507
|
+
assert.ok(Date.now() - startedAt < 2_000);
|
|
508
|
+
assert.deepEqual(items.map((i) => [i.id, i.ok, i.error]), [["run-wedged", false, "timeout"]]);
|
|
509
|
+
assert.match(items[0]!.detail, /runner has not acknowledged/);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("one failing run does not stop the others", async () => {
|
|
513
|
+
const root = tempRoot();
|
|
514
|
+
writeRun(root, "bad");
|
|
515
|
+
writeRun(root, "good");
|
|
516
|
+
const state = makeState([job(root, "bad"), job(root, "good")]);
|
|
517
|
+
const d = stopDeps(root, state, { stopRun: ({ dir }) => { if (path.basename(dir) === "bad") throw new Error("disk full"); d.stopped.push(path.basename(dir)); runnerStops(dir); } });
|
|
518
|
+
const items = await stopSubagentWork(d, { deadline: farDeadline() });
|
|
519
|
+
assert.deepEqual(items.map((i) => [i.id, i.ok]), [["bad", false], ["good", true]]);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("waits for foreground runs to end with the aborted turn, and reports one that does not", async () => {
|
|
523
|
+
const root = tempRoot();
|
|
524
|
+
const state = makeState();
|
|
525
|
+
state.foregroundControls.set("fg-ends", { runId: "fg-ends", mode: "single", startedAt: 1, updatedAt: 1, currentAgent: "scout" } as never);
|
|
526
|
+
state.foregroundControls.set("fg-stuck", { runId: "fg-stuck", mode: "chain", startedAt: 1, updatedAt: 1 } as never);
|
|
527
|
+
setTimeout(() => state.foregroundControls.delete("fg-ends"), 10);
|
|
528
|
+
const items = await stopSubagentWork(stopDeps(root, state), { deadline: Date.now() + 120 });
|
|
529
|
+
assert.deepEqual(items.map((i) => [i.kind, i.id, i.ok, i.error]), [["foreground_run", "fg-ends", true, undefined], ["foreground_run", "fg-stuck", false, "timeout"]]);
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
describe("stop_all_background: session-only schedules", () => {
|
|
534
|
+
const script = "return runs.run('main', { agent: 'reviewer', task: 'Review the diff' })";
|
|
535
|
+
function setup() {
|
|
536
|
+
const root = tempRoot();
|
|
537
|
+
const project = path.join(root, "project");
|
|
538
|
+
fs.mkdirSync(project, { recursive: true });
|
|
539
|
+
const timers = new Map<number, () => void>();
|
|
540
|
+
let nextTimer = 0;
|
|
541
|
+
const clock = { now: Date.parse("2030-01-01T00:00:00Z") };
|
|
542
|
+
const ctx = { cwd: project, sessionManager: { getSessionId: () => "session-a", getSessionFile: () => path.join(project, "session-a.jsonl") } } as unknown as ExtensionContext;
|
|
543
|
+
const make = () => createScheduledRunManager({
|
|
544
|
+
config: { scheduledRuns: { enabled: true } },
|
|
545
|
+
storeRoot: path.join(root, "stores"),
|
|
546
|
+
now: () => clock.now,
|
|
547
|
+
timers: {
|
|
548
|
+
setTimeout: ((callback: () => void) => { const id = ++nextTimer; timers.set(id, callback); return id; }) as never,
|
|
549
|
+
clearTimeout: ((id: number) => void timers.delete(id)) as never,
|
|
550
|
+
},
|
|
551
|
+
launch: () => new Promise(() => undefined) as never,
|
|
552
|
+
});
|
|
553
|
+
const manager = make();
|
|
554
|
+
manager.bindSession(ctx);
|
|
555
|
+
return { root, ctx, manager, make, timers, clock };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
it("pauses session-only schedules (persisted) and clears their timers; project schedules are untouched", async () => {
|
|
559
|
+
const { ctx, manager, timers } = setup();
|
|
560
|
+
await manager.handleToolCall({ action: "schedule.create", id: "project-one", at: "+10m", workflowScript: script }, ctx);
|
|
561
|
+
await manager.handleToolCall({ action: "schedule.create", id: "session-one", at: "+20m", sessionOnly: true, workflowScript: script }, ctx);
|
|
562
|
+
await manager.handleToolCall({ action: "schedule.create", id: "session-two", every: "1h", sessionOnly: true, workflowScript: script }, ctx);
|
|
563
|
+
assert.equal(timers.size, 3);
|
|
564
|
+
const results = manager.disarmSessionOnlySchedules().sort((a, b) => a.id.localeCompare(b.id));
|
|
565
|
+
assert.deepEqual(results.map((r) => [r.id, r.ok]), [["session-one", true], ["session-two", true]]);
|
|
566
|
+
assert.deepEqual(manager.armedSchedules().map((s) => s.id), ["project-one"]);
|
|
567
|
+
assert.equal(timers.size, 1);
|
|
568
|
+
// Persisted: the records are paused, the project schedule is not.
|
|
569
|
+
const show = async (id: string) => (await manager.handleToolCall({ action: "schedule.show", id }, ctx)).content[0]!.text;
|
|
570
|
+
assert.match(await show("session-one"), /State: paused/);
|
|
571
|
+
assert.match(await show("session-two"), /State: paused/);
|
|
572
|
+
assert.match(await show("project-one"), /State: scheduled/);
|
|
573
|
+
// Readiness has no armed_schedule blocker for them (and none for the project schedule either, per D11).
|
|
574
|
+
assert.deepEqual(schedulerReleaseBlockers({ armedSchedules: () => manager.armedSchedules(), observedCompletionRunIds: () => [] }), []);
|
|
575
|
+
// Idempotent.
|
|
576
|
+
assert.deepEqual(manager.disarmSessionOnlySchedules(), []);
|
|
577
|
+
manager.stop();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("does not fire or re-arm on the next launch, even when the planned time has passed (catchUp latest)", async () => {
|
|
581
|
+
const { ctx, manager, make, timers, clock } = setup();
|
|
582
|
+
await manager.handleToolCall({ action: "schedule.create", id: "session-one", at: "+5m", sessionOnly: true, workflowScript: script }, ctx);
|
|
583
|
+
manager.disarmSessionOnlySchedules();
|
|
584
|
+
manager.stop();
|
|
585
|
+
clock.now += 3_600_000; // the process was gone for an hour: the schedule is overdue
|
|
586
|
+
const next = make();
|
|
587
|
+
next.bindSession(ctx);
|
|
588
|
+
assert.deepEqual(next.armedSchedules(), []);
|
|
589
|
+
assert.equal(timers.size, 0);
|
|
590
|
+
const due = await next.handleToolCall({ action: "schedule.run-due" }, ctx);
|
|
591
|
+
assert.match(due.content[0]!.text, /No schedules are due/);
|
|
592
|
+
// Reversible: resuming re-arms it.
|
|
593
|
+
await next.handleToolCall({ action: "schedule.resume", id: "session-one" }, ctx);
|
|
594
|
+
assert.deepEqual(next.armedSchedules().map((s) => s.id), ["session-one"]);
|
|
595
|
+
next.stop();
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("leaves an armed timer alone when its record cannot be read (it may be a project schedule) and reports it", async () => {
|
|
599
|
+
const { ctx, manager, timers } = setup();
|
|
600
|
+
await manager.handleToolCall({ action: "schedule.create", id: "who-knows", at: "+10m", sessionOnly: true, workflowScript: script }, ctx);
|
|
601
|
+
const stores = (manager as unknown as { stores: Map<string, { find(id: string): unknown }> }).stores;
|
|
602
|
+
for (const store of stores.values()) store.find = () => { throw new Error("corrupt record"); };
|
|
603
|
+
const results = manager.disarmSessionOnlySchedules();
|
|
604
|
+
assert.deepEqual(results.map((r) => [r.id, r.ok, r.error]), [["who-knows", false, "corrupt record"]]);
|
|
605
|
+
assert.equal(timers.size, 1);
|
|
606
|
+
manager.stop();
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("reports ok:false when the pause cannot be persisted, but still clears the in-memory timer", async () => {
|
|
610
|
+
const { ctx, manager, timers } = setup();
|
|
611
|
+
await manager.handleToolCall({ action: "schedule.create", id: "session-one", at: "+10m", sessionOnly: true, workflowScript: script }, ctx);
|
|
612
|
+
const stores = (manager as unknown as { stores: Map<string, { write(record: unknown): void }> }).stores;
|
|
613
|
+
for (const store of stores.values()) store.write = () => { throw new Error("read-only file system"); };
|
|
614
|
+
const results = manager.disarmSessionOnlySchedules();
|
|
615
|
+
assert.deepEqual(results.map((r) => [r.id, r.ok, r.error]), [["session-one", false, "read-only file system"]]);
|
|
616
|
+
assert.match(results[0]!.detail, /may fire on the next launch/);
|
|
617
|
+
assert.equal(timers.size, 0);
|
|
618
|
+
manager.stop();
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("maps results to scheduler stop items", () => {
|
|
622
|
+
assert.deepEqual(stopSchedules({ disarmSessionSchedules: () => [{ id: "a", ok: true, detail: "Paused" }, { id: "b", ok: false, detail: "nope", error: "boom" }] }), [
|
|
623
|
+
{ source: "scheduler", kind: "schedule", id: "a", detail: "Paused", ok: true },
|
|
624
|
+
{ source: "scheduler", kind: "schedule", id: "b", detail: "nope", ok: false, error: "boom" },
|
|
625
|
+
]);
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
describe("stop_all_background: wired through the RPC bridge", () => {
|
|
630
|
+
const script = "return runs.run('main', { agent: 'reviewer', task: 'Review the diff' })";
|
|
631
|
+
const stopCommand = { type: "stop_all_background" as const, version: 1 as const };
|
|
632
|
+
const scope = { version: 1 as const, sessionId: "host", epoch: "e1" };
|
|
633
|
+
|
|
634
|
+
it("stops this session's runs and session-only schedules in one command, leaving readiness without async_run or armed_schedule blockers", async () => {
|
|
635
|
+
const root = tempRoot();
|
|
636
|
+
const project = path.join(root, "project");
|
|
637
|
+
fs.mkdirSync(project, { recursive: true });
|
|
638
|
+
const ctx = { cwd: project, sessionManager: { getSessionId: () => "session-a", getSessionFile: () => path.join(project, "session-a.jsonl") } } as unknown as ExtensionContext;
|
|
639
|
+
const manager = createScheduledRunManager({
|
|
640
|
+
config: { scheduledRuns: { enabled: true } }, storeRoot: path.join(root, "stores"), now: () => Date.parse("2030-01-01T00:00:00Z"),
|
|
641
|
+
timers: { setTimeout: (() => 1) as never, clearTimeout: (() => undefined) as never }, launch: () => new Promise(() => undefined) as never,
|
|
642
|
+
});
|
|
643
|
+
manager.bindSession(ctx);
|
|
644
|
+
await manager.handleToolCall({ action: "schedule.create", id: "project-one", at: "+10m", workflowScript: script }, ctx);
|
|
645
|
+
await manager.handleToolCall({ action: "schedule.create", id: "session-one", at: "+20m", sessionOnly: true, workflowScript: script }, ctx);
|
|
646
|
+
|
|
647
|
+
const asyncRoot = path.join(root, "async");
|
|
648
|
+
const run = (id: string, sessionId: string) => {
|
|
649
|
+
const asyncDir = path.join(asyncRoot, id);
|
|
650
|
+
fs.mkdirSync(asyncDir, { recursive: true });
|
|
651
|
+
const now = Date.now();
|
|
652
|
+
fs.writeFileSync(path.join(asyncDir, "status.json"), JSON.stringify({ runId: id, sessionId, mode: "single", state: "running", pid: process.pid, startedAt: now, lastUpdate: now, currentStep: 0, steps: [{ agent: "worker", status: "running", startedAt: now }] }));
|
|
653
|
+
return asyncDir;
|
|
654
|
+
};
|
|
655
|
+
const mine = run("mine", "/managed/session");
|
|
656
|
+
run("theirs", "/other/session"); // on disk, not tracked by this session
|
|
657
|
+
const state = makeState([{ asyncId: "mine", asyncDir: mine, sessionId: "/managed/session", pid: process.pid }]);
|
|
658
|
+
const bus = createEventBus();
|
|
659
|
+
const reconcile: SubagentReleaseDeps["reconcile"] = (dir, options) => reconcileAsyncRun(dir, options);
|
|
660
|
+
const stoppedDirs: string[] = [];
|
|
661
|
+
registerReleaseReadiness({
|
|
662
|
+
events: bus,
|
|
663
|
+
getHostSessionId: () => "host",
|
|
664
|
+
subagents: deps(state, { resultsDir: path.join(root, "results"), reconcile }),
|
|
665
|
+
scheduler: { armedSchedules: () => manager.armedSchedules(), observedCompletionRunIds: () => manager.observedCompletionRunIds() },
|
|
666
|
+
stopAll: {
|
|
667
|
+
subagents: {
|
|
668
|
+
state, resultsDir: path.join(root, "results"), asyncDirRoot: asyncRoot, observedCompletionRunIds: () => manager.observedCompletionRunIds(),
|
|
669
|
+
stopRun: ({ dir }) => {
|
|
670
|
+
stoppedDirs.push(path.basename(dir));
|
|
671
|
+
const file = path.join(dir, "status.json");
|
|
672
|
+
setTimeout(() => fs.writeFileSync(file, JSON.stringify({ ...JSON.parse(fs.readFileSync(file, "utf-8")), state: "stopped", endedAt: Date.now() })), 5);
|
|
673
|
+
},
|
|
674
|
+
pollIntervalMs: 5,
|
|
675
|
+
},
|
|
676
|
+
scheduler: { disarmSessionSchedules: () => manager.disarmSessionOnlySchedules() },
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
const bridge = new RpcReleaseBridge(bus, scope);
|
|
680
|
+
const before = bridge.request({ type: "release_readiness", version: 1 });
|
|
681
|
+
assert.ok(before.success && before.command === "release_readiness");
|
|
682
|
+
assert.deepEqual(before.data.blockers.map((b) => `${b.kind}:${b.id}`), ["async_run:mine", "armed_schedule:session-one"]);
|
|
683
|
+
|
|
684
|
+
const reply = await bridge.stopAll(stopCommand, () => [], () => []);
|
|
685
|
+
assert.ok(reply.success && reply.command === "stop_all_background");
|
|
686
|
+
assert.deepEqual(reply.data.stopped.map((i) => `${i.source}:${i.kind}:${i.id}:${i.ok}`), ["scheduler:schedule:session-one:true", "subagents:async_run:mine:true"]);
|
|
687
|
+
assert.deepEqual(stoppedDirs, ["mine"], "the run of another session is never touched");
|
|
688
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(asyncRoot, "theirs", "status.json"), "utf-8")).state, "running");
|
|
689
|
+
assert.deepEqual(reply.data.remaining.blockers.filter((b) => b.kind === "async_run" || b.kind === "armed_schedule"), []);
|
|
690
|
+
assert.deepEqual(manager.armedSchedules().map((s) => s.id), ["project-one"], "the project schedule stays armed");
|
|
691
|
+
|
|
692
|
+
const again = await bridge.stopAll(stopCommand, () => [], () => []);
|
|
693
|
+
assert.ok(again.success && again.command === "stop_all_background");
|
|
694
|
+
assert.deepEqual(again.data.stopped, []);
|
|
695
|
+
manager.stop();
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("ignores stop requests for another session and after dispose", async () => {
|
|
699
|
+
const bus = createEventBus();
|
|
700
|
+
let calls = 0;
|
|
701
|
+
const registration = registerReleaseReadiness({
|
|
702
|
+
events: bus, getHostSessionId: () => "host",
|
|
703
|
+
subagents: deps(makeState()), scheduler: { armedSchedules: () => [], observedCompletionRunIds: () => [] },
|
|
704
|
+
stopAll: {
|
|
705
|
+
subagents: { state: makeState(), observedCompletionRunIds: () => { calls++; return []; }, stopRun: () => undefined },
|
|
706
|
+
scheduler: { disarmSessionSchedules: () => { calls++; return []; } },
|
|
707
|
+
},
|
|
708
|
+
});
|
|
709
|
+
await new RpcReleaseBridge(bus, { ...scope, sessionId: "someone-else" }).stopAll(stopCommand, () => [], () => []);
|
|
710
|
+
assert.equal(calls, 0);
|
|
711
|
+
registration.dispose();
|
|
712
|
+
await new RpcReleaseBridge(bus, scope).stopAll(stopCommand, () => [], () => []);
|
|
713
|
+
assert.equal(calls, 0);
|
|
714
|
+
});
|
|
715
|
+
});
|