Skip to content

Commit 5c4f41f

Browse files
stainluclaude
andcommitted
test(orchestrator+pool): unit coverage for the decision tree + container lifecycle
Router (14 cases) — createSession happy path, agent_not_found, agent_archived, maxSubagentDepth inheritance and override; runEvent's pre-dispatch gates (session_not_found, deleted-template safety net, queue-on-running, model override in queued entry); cancel's pre-abort gates (not_found, not_running, no_active_container, happy-path queue drain + WS abort with canonical session key). Pool (14 cases) — acquireForSession across all three sources (cold spawn, live reuse, warm claim with background replenish, pending-spawn dedup); warmForAgent idempotence, cap + oldest-first LRU eviction, readyz-failure cleanup; evictSession stops + clears; reapIdle honors isBusy, invokes cleanupOnReap, and reaps warm containers past warmIdleTimeoutMs; shutdown stops both active and warm buckets. WebSocket client is module-mocked so the pool's unit tests don't open real sockets; ContainerRuntime is implemented as a FakeRuntime that records calls and supports simulating spawnDelayMs (for pending-dedup) and readyShouldFail (for ready-failure cleanup). Full suite 119 passed (was 91). This closes the gap the TS SDK tests flagged: before this, the orchestrator's core orchestration logic had no unit coverage beyond e2e. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7825495 commit 5c4f41f

2 files changed

Lines changed: 651 additions & 0 deletions

File tree

src/orchestrator/router.test.ts

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import type { GatewayWebSocketClient } from "../runtime/gateway-ws.js";
4+
import { ParentTokenMinter } from "../runtime/parent-token.js";
5+
import type { SessionContainerPool } from "../runtime/pool.js";
6+
import { InMemoryStore } from "../store/memory.js";
7+
import { PiJsonlEventReader } from "../store/pi-jsonl.js";
8+
import { SessionEventQueue } from "./event-queue.js";
9+
import { AgentRouter, RouterError, type RouterConfig } from "./router.js";
10+
11+
// These tests cover the decision-tree logic that doesn't require a live
12+
// container: createSession, runEvent's pre-dispatch checks, and cancel's
13+
// pre-abort checks. Paths that reach the pool / WS / chat.completions
14+
// call are out of scope for unit tests and are covered by e2e.
15+
16+
function makeRouter(opts: {
17+
poolStub?: Partial<SessionContainerPool>;
18+
} = {}): {
19+
router: AgentRouter;
20+
store: InMemoryStore;
21+
queue: SessionEventQueue;
22+
pool: Partial<SessionContainerPool>;
23+
} {
24+
const store = new InMemoryStore();
25+
const queue = new SessionEventQueue();
26+
// Minimal pool stub: in tests that shouldn't reach the pool we leave
27+
// methods undefined so any accidental call throws TypeError and fails
28+
// loudly. Tests that DO want to exercise a pool interaction provide
29+
// their own shaped stub.
30+
const pool = (opts.poolStub ?? {}) as SessionContainerPool;
31+
const eventReader = new PiJsonlEventReader("/tmp/does-not-exist");
32+
const cfg: RouterConfig = {
33+
runtimeImage: "test-image",
34+
hostStateRoot: "/tmp/test-state",
35+
network: "test-net",
36+
gatewayPort: 18789,
37+
passthroughEnv: {},
38+
runTimeoutMs: 60_000,
39+
orchestratorUrl: "http://orchestrator-test:8080",
40+
tokenMinter: new ParentTokenMinter(),
41+
};
42+
const router = new AgentRouter(
43+
store.agents,
44+
store.environments,
45+
store.sessions,
46+
eventReader,
47+
pool as SessionContainerPool,
48+
queue,
49+
cfg,
50+
);
51+
return { router, store, queue, pool };
52+
}
53+
54+
describe("AgentRouter.createSession", () => {
55+
it("creates a session bound to an existing agent", () => {
56+
const { router, store } = makeRouter();
57+
const agent = store.agents.create({
58+
model: "moonshot/kimi-k2.5",
59+
tools: [],
60+
instructions: "",
61+
permissionPolicy: { type: "always_allow" },
62+
callableAgents: [],
63+
maxSubagentDepth: 0,
64+
});
65+
const session = router.createSession(agent.agentId);
66+
expect(session.agentId).toBe(agent.agentId);
67+
expect(session.status).toBe("idle");
68+
expect(store.sessions.get(session.sessionId)).toBeDefined();
69+
});
70+
71+
it("throws agent_not_found when agent does not exist", () => {
72+
const { router } = makeRouter();
73+
try {
74+
router.createSession("agt_missing");
75+
expect.fail("should have thrown");
76+
} catch (err) {
77+
expect(err).toBeInstanceOf(RouterError);
78+
expect((err as RouterError).code).toBe("agent_not_found");
79+
}
80+
});
81+
82+
it("throws agent_archived once the agent is archived", () => {
83+
const { router, store } = makeRouter();
84+
const agent = store.agents.create({
85+
model: "m",
86+
tools: [],
87+
instructions: "",
88+
permissionPolicy: { type: "always_allow" },
89+
callableAgents: [],
90+
maxSubagentDepth: 0,
91+
});
92+
store.agents.archive(agent.agentId);
93+
try {
94+
router.createSession(agent.agentId);
95+
expect.fail("should have thrown");
96+
} catch (err) {
97+
expect(err).toBeInstanceOf(RouterError);
98+
expect((err as RouterError).code).toBe("agent_archived");
99+
}
100+
});
101+
102+
it("inherits maxSubagentDepth from the agent template by default", () => {
103+
const { router, store } = makeRouter();
104+
const agent = store.agents.create({
105+
model: "m",
106+
tools: [],
107+
instructions: "",
108+
permissionPolicy: { type: "always_allow" },
109+
callableAgents: ["agt_worker"],
110+
maxSubagentDepth: 3,
111+
});
112+
const session = router.createSession(agent.agentId);
113+
expect(session.remainingSubagentDepth).toBe(3);
114+
});
115+
116+
it("honors an explicit remainingSubagentDepth override (subagent spawn path)", () => {
117+
const { router, store } = makeRouter();
118+
const agent = store.agents.create({
119+
model: "m",
120+
tools: [],
121+
instructions: "",
122+
permissionPolicy: { type: "always_allow" },
123+
callableAgents: ["agt_x"],
124+
maxSubagentDepth: 5,
125+
});
126+
const session = router.createSession(agent.agentId, {
127+
remainingSubagentDepth: 2,
128+
});
129+
// Override wins over the agent template's 5 — child sessions inherit
130+
// parent.remaining_depth - 1, not the child agent's own max.
131+
expect(session.remainingSubagentDepth).toBe(2);
132+
});
133+
});
134+
135+
describe("AgentRouter.runEvent — decision tree", () => {
136+
it("throws session_not_found for an unknown session", async () => {
137+
const { router } = makeRouter();
138+
await expect(
139+
router.runEvent({ sessionId: "ses_missing", content: "hi" }),
140+
).rejects.toMatchObject({
141+
name: "RouterError",
142+
code: "session_not_found",
143+
});
144+
});
145+
146+
it("throws agent_not_found when the agent was deleted but session lingers", async () => {
147+
// This path is a safety net: sessions outlive their template by design,
148+
// but if the template was deleted we can't spawn a container. Reject
149+
// explicitly rather than trying to spawn.
150+
const { router, store } = makeRouter();
151+
const agent = store.agents.create({
152+
model: "m",
153+
tools: [],
154+
instructions: "",
155+
permissionPolicy: { type: "always_allow" },
156+
callableAgents: [],
157+
maxSubagentDepth: 0,
158+
});
159+
const session = router.createSession(agent.agentId);
160+
store.agents.delete(agent.agentId);
161+
await expect(
162+
router.runEvent({ sessionId: session.sessionId, content: "hi" }),
163+
).rejects.toMatchObject({
164+
name: "RouterError",
165+
code: "agent_not_found",
166+
});
167+
});
168+
169+
it("queues the event when the session is currently running (no new run started)", async () => {
170+
// Session in "running" state → the event should land in the queue for
171+
// the in-flight run to pick up on completion. runEvent must return
172+
// queued=true and must NOT touch the pool (which would spawn a second
173+
// container).
174+
const { router, store, queue } = makeRouter();
175+
const agent = store.agents.create({
176+
model: "m",
177+
tools: [],
178+
instructions: "",
179+
permissionPolicy: { type: "always_allow" },
180+
callableAgents: [],
181+
maxSubagentDepth: 0,
182+
});
183+
const session = router.createSession(agent.agentId);
184+
// Simulate a run already in flight.
185+
store.sessions.beginRun(session.sessionId);
186+
expect(store.sessions.get(session.sessionId)?.status).toBe("running");
187+
188+
const result = await router.runEvent({
189+
sessionId: session.sessionId,
190+
content: "second message while first is running",
191+
});
192+
expect(result.queued).toBe(true);
193+
expect(result.session.status).toBe("running");
194+
// Queue now has the one event we pushed.
195+
const next = queue.shift(session.sessionId);
196+
expect(next?.content).toBe("second message while first is running");
197+
// No more events queued.
198+
expect(queue.shift(session.sessionId)).toBeUndefined();
199+
});
200+
201+
it("includes an optional `model` override in the queued entry", async () => {
202+
const { router, store, queue } = makeRouter();
203+
const agent = store.agents.create({
204+
model: "moonshot/kimi-k2.5",
205+
tools: [],
206+
instructions: "",
207+
permissionPolicy: { type: "always_allow" },
208+
callableAgents: [],
209+
maxSubagentDepth: 0,
210+
});
211+
const session = router.createSession(agent.agentId);
212+
store.sessions.beginRun(session.sessionId);
213+
214+
await router.runEvent({
215+
sessionId: session.sessionId,
216+
content: "upgrade this turn",
217+
model: "anthropic/claude-sonnet-4-6",
218+
});
219+
const next = queue.shift(session.sessionId);
220+
expect(next?.model).toBe("anthropic/claude-sonnet-4-6");
221+
});
222+
});
223+
224+
describe("AgentRouter.cancel — pre-abort checks", () => {
225+
it("throws session_not_found for an unknown session", async () => {
226+
const { router } = makeRouter();
227+
await expect(router.cancel("ses_missing")).rejects.toMatchObject({
228+
name: "RouterError",
229+
code: "session_not_found",
230+
});
231+
});
232+
233+
it("throws session_not_running when the session is idle", async () => {
234+
const { router, store } = makeRouter();
235+
const agent = store.agents.create({
236+
model: "m",
237+
tools: [],
238+
instructions: "",
239+
permissionPolicy: { type: "always_allow" },
240+
callableAgents: [],
241+
maxSubagentDepth: 0,
242+
});
243+
const session = router.createSession(agent.agentId);
244+
// Session is idle (never called beginRun).
245+
await expect(router.cancel(session.sessionId)).rejects.toMatchObject({
246+
name: "RouterError",
247+
code: "session_not_running",
248+
});
249+
});
250+
251+
it("throws no_active_container when running session has no pool entry", async () => {
252+
// Cancel path requires a live WS to abort. If the container was
253+
// already torn down (eg. it crashed right before cancel), we surface
254+
// the error rather than silently no-op the abort.
255+
const pool = {
256+
getWsClient: (_id: string): GatewayWebSocketClient | undefined => undefined,
257+
};
258+
const { router, store } = makeRouter({ poolStub: pool });
259+
const agent = store.agents.create({
260+
model: "m",
261+
tools: [],
262+
instructions: "",
263+
permissionPolicy: { type: "always_allow" },
264+
callableAgents: [],
265+
maxSubagentDepth: 0,
266+
});
267+
const session = router.createSession(agent.agentId);
268+
store.sessions.beginRun(session.sessionId);
269+
270+
await expect(router.cancel(session.sessionId)).rejects.toMatchObject({
271+
name: "RouterError",
272+
code: "no_active_container",
273+
});
274+
});
275+
276+
it("drains the queue and pending approvals then marks the session idle", async () => {
277+
// Happy-path cancel: WS abort succeeds, router clears per-session
278+
// bookkeeping. We use a fake ws that records the abort call and
279+
// resolves successfully.
280+
let abortedKey: string | undefined;
281+
const fakeWs = {
282+
abort: async (key: string) => {
283+
abortedKey = key;
284+
},
285+
close: async () => {},
286+
} as unknown as GatewayWebSocketClient;
287+
const pool = {
288+
getWsClient: (_id: string) => fakeWs,
289+
};
290+
const { router, store, queue } = makeRouter({ poolStub: pool });
291+
const agent = store.agents.create({
292+
model: "m",
293+
tools: [],
294+
instructions: "",
295+
permissionPolicy: { type: "always_allow" },
296+
callableAgents: [],
297+
maxSubagentDepth: 0,
298+
});
299+
const session = router.createSession(agent.agentId);
300+
store.sessions.beginRun(session.sessionId);
301+
queue.enqueue(session.sessionId, {
302+
content: "pending work",
303+
enqueuedAt: Date.now(),
304+
});
305+
306+
const cancelled = await router.cancel(session.sessionId);
307+
expect(cancelled.status).toBe("idle");
308+
// Canonical session key is what OpenClaw's orphan-key migration
309+
// rewrites non-canonical forms to on startup, so using it directly
310+
// keeps our abort idempotent across OpenClaw restarts.
311+
expect(abortedKey).toBe(`agent:main:${session.sessionId}`);
312+
expect(queue.shift(session.sessionId)).toBeUndefined();
313+
});
314+
});
315+
316+
describe("AgentRouter.getPendingApprovals", () => {
317+
it("returns an empty array for a session with no pending approvals", () => {
318+
const { router } = makeRouter();
319+
expect(router.getPendingApprovals("ses_whatever")).toEqual([]);
320+
});
321+
});

0 commit comments

Comments
 (0)