Skip to content

Commit 07341ff

Browse files
chrisleekrclaude
andcommitted
test(runner): cover the registered "completed" recovery branch
`readyMessage` was the only `workflow-runner:registered` payload the suite built, so `handleRegistered`'s `completed` branch never ran. Dropping its pending hand-off loop would leave a runner that reconnects after the controller persisted its terminal payload hanging until the Pod deadline, with the suite still green. Two cases, split so each assertion exercises the path it names: - A pending `hand-off-child` resolves with the command id as `childRunId`. That is the invariant tying the runner to `queueHandOffChild`, which mints `{ childRunId: commandId }`. - A `completed` reply before any job was handed out settles `waitForJob()` with `null`. On the reconnect-after-hand-off path the job is already settled, so `resolveJob(null)` is a no-op there and asserting it would prove nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KUPpJPtxAaHWrBsjytRGyM
1 parent 269926e commit 07341ff

1 file changed

Lines changed: 65 additions & 0 deletions

File tree

test/runner/ws-client.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,20 @@ function readyMessage(clientFenceMs = 25, heartbeatIntervalMs = 10, includeJob =
106106
};
107107
}
108108

109+
/**
110+
* The controller's answer when it has already persisted this attempt's terminal
111+
* payload: the runner reconnected after the result landed, so there is no job
112+
* and no session to install.
113+
*/
114+
function completedMessage(): unknown {
115+
return {
116+
type: "workflow-runner:registered",
117+
id: crypto.randomUUID(),
118+
timestamp: Date.now(),
119+
payload: { state: "completed" },
120+
};
121+
}
122+
109123
function sentMessage(
110124
socket: FakeWebSocket,
111125
type: string,
@@ -165,6 +179,57 @@ describe("WorkflowRunnerClient attempt fence", () => {
165179
expect(client.signal.aborted).toBe(true);
166180
});
167181

182+
it("settles a pending hand-off and the job wait when the controller reports the attempt completed", async () => {
183+
jest.useFakeTimers();
184+
const client = makeClient();
185+
client.connect();
186+
const first = FakeWebSocket.instances[0];
187+
if (first === undefined) throw new Error("Expected first workflow runner socket");
188+
first.fireOpen();
189+
first.fireMessage(readyMessage(2_500, 500));
190+
await client.waitForJob();
191+
192+
const handOffPromise = client.command({
193+
type: "hand-off-child",
194+
workflowName: "implement",
195+
target: { type: "issue", owner: "acme", repo: "widgets", number: 16 },
196+
parentStepIndex: 0,
197+
state: { phase: "handing-off" },
198+
humanMessage: "Handing off to the child run.",
199+
});
200+
const handOff = sentMessage(first, "workflow-runner:command");
201+
first.fireClose();
202+
jest.advanceTimersByTime(1_000);
203+
204+
const second = FakeWebSocket.instances[1];
205+
if (second === undefined) throw new Error("Expected reconnected workflow runner socket");
206+
second.fireOpen();
207+
// The controller answers `completed`: it already stored the terminal payload
208+
// for this attempt, so it hands back no job and installs no session.
209+
second.fireMessage(completedMessage());
210+
211+
// The command id IS the child run id. `queueHandOffChild` mints
212+
// `{ childRunId: commandId }` (`workflow-runner-controller.ts:500`), so the
213+
// runner can settle the promise without the controller replaying a receipt.
214+
expect(await handOffPromise).toEqual({ childRunId: handOff.id });
215+
expect(client.signal.aborted).toBe(false);
216+
});
217+
218+
it("settles the job wait with null when the attempt completed before the runner registered", async () => {
219+
const client = makeClient();
220+
client.connect();
221+
const socket = FakeWebSocket.instances[0];
222+
if (socket === undefined) throw new Error("Expected first workflow runner socket");
223+
socket.fireOpen();
224+
// No job was ever handed out, so this is the branch that actually settles
225+
// `waitForJob`. Resolving it with `null` is what lets `main.ts` exit rather
226+
// than waiting out the Pod's activeDeadlineSeconds.
227+
socket.fireMessage(completedMessage());
228+
229+
expect(await client.waitForJob()).toBeNull();
230+
expect(client.signal.aborted).toBe(false);
231+
});
232+
168233
it("replays stable command and result messages after reconnecting before the fence", async () => {
169234
jest.useFakeTimers();
170235
const client = makeClient();

0 commit comments

Comments
 (0)