Skip to content

Commit b85699c

Browse files
serge-the-devclaude
andcommitted
fix(coding): the single-flight claim covered 1 of 3 paths that start a Pilot (#208)
#208 said "one driver per engine". It put the claim on `POST /sessions/:id/run` and nowhere else. There are THREE paths that start a Pilot against a live session, and the other two were wide open: routes/coding.ts:1332 /sessions/:id/run ✅ claimed routes/coding.ts:1008 the `drive_claude` tool ❌ lib/loop-drivers.ts owner Loop + delegate_goal ❌ So a Lead delegating a goal, or the owner pressing Loop, could put a SECOND Pilot on a session a first was already driving — the two interleaving `tmux send-keys` into one pane, each reasoning over a terminal the other is writing to. That is exactly the failure the claim exists to prevent, and it was reachable by the two most ordinary actions in the product. The route-only fix was a sixth of a guarantee. (The other three `CODING_SESSION.create` sites are `mode: "watch"` finish-watchers — passive, they send no instructions, so they correctly take no claim.) Both paths now claim BEFORE anything observable is written. Order matters: a claim checked after the run row exists strands an `agent_loop_runs` row no workflow will close (the #207C failure), and a refusal after the board write announces work that never started. Each threads its `driverId` into the workflow so the existing release path (`endSession`, #206) frees it. Also removed `delegateToPilot` — 94 lines made unreachable by #210's driver table and still carrying its own unclaimed `CODING_SESSION.create`. Dead code that starts workflows is worse than dead code. And raised the remaining browser-runner timeouts from 60s to 120s. `beforeEach` launches a fresh real Chrome per test — eight per file — so a launch alone can eat most of a 60s budget under load; two different tests there timed out in one full-suite run and the file passed 8/8 in 27s in isolation right after. The heaviest test was already bumped for precisely this; the rest were left to flake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 157fba9 commit b85699c

5 files changed

Lines changed: 87 additions & 111 deletions

File tree

‎packages/browser-runner/src/runner-browser.test.ts‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,16 +76,19 @@ describe("LocalRunner brain-driven browser endpoints", () => {
7676
expect(sub.resume?.filename).toContain("resume");
7777
expect(sub.resume?.size).toBeGreaterThan(0);
7878
expect(result.url).toContain("/success/");
79-
// 120s, not the file's usual 60s: this is the heaviest test here (8 sequential
80-
// real-browser actions, ~22s on a dev machine) and it repeatedly flaked at 60s
81-
// on CI's slower runners while passing on every rerun.
79+
// 120s across this whole file, not 60s. `beforeEach` builds a new LocalRunner and launches
80+
// a FRESH real Chrome for every test — eight launches per file — so under a loaded machine
81+
// (a parallel vitest pool, or an e2e run alongside) a launch alone can eat most of a 60s
82+
// budget. Two different tests here timed out at 60s in one full-suite run and the whole
83+
// file passed 8/8 in 27s immediately after, in isolation. The heaviest test was already
84+
// bumped for exactly this; the rest were left to flake.
8285
}, 120_000);
8386

8487
it("snapshot reports a CAPTCHA challenge so the brain can hand off", async () => {
8588
await runner.browserAct({ action: "navigate", url: `${server.jobUrl}?challenge=1` });
8689
const snap = await runner.browserSnapshot();
8790
expect(snap.challenge).toBe("cloudflare-turnstile");
88-
}, 60_000);
91+
}, 120_000);
8992

9093
it("does NOT hand off for an invisible reCAPTCHA badge, but DOES for a visible checkbox", async () => {
9194
// The Dayforce false-positive: an invisible v3 badge must be ignored.
@@ -94,7 +97,7 @@ describe("LocalRunner brain-driven browser endpoints", () => {
9497
// A visible "I'm not a robot" checkbox IS a real challenge.
9598
await runner.browserAct({ action: "navigate", url: `${server.jobUrl}?recaptcha=1` });
9699
expect((await runner.browserSnapshot()).challenge).toBe("recaptcha");
97-
}, 60_000);
100+
}, 120_000);
98101

99102
it("agent handoff lifecycle: same-session pause → solved → resume → complete", async () => {
100103
// Agent-driven task: created running, never auto-executed by the runner.
@@ -123,7 +126,7 @@ describe("LocalRunner brain-driven browser endpoints", () => {
123126

124127
await runner.browserComplete(task.id, "submitted", "Application received");
125128
expect(runner.store.getTask(task.id)?.status).toBe("completed");
126-
}, 60_000);
129+
}, 120_000);
127130

128131
it("auto-handles a native dialog so the brain isn't wedged (real ATS: alert after résumé upload)", async () => {
129132
// A page that pops a native alert — exactly what Coles does after résumé upload.
@@ -138,7 +141,7 @@ describe("LocalRunner brain-driven browser endpoints", () => {
138141
const after = await runner.browserAct({ action: "key", key: "Enter" });
139142
expect(after.ok).toBe(true);
140143
expect(after.feedback ?? "").toMatch(/dialog was accepted/i);
141-
}, 60_000);
144+
}, 120_000);
142145

143146
it("reads back a masked field's real value so the brain doesn't oscillate", async () => {
144147
// A phone field that transforms input like a real intl mask: "0404…" → "+61404…".
@@ -149,7 +152,7 @@ describe("LocalRunner brain-driven browser endpoints", () => {
149152
// The runner reports what the field ACTUALLY holds now (the transformed value).
150153
expect(res.feedback ?? "").toMatch(/now reads/i);
151154
expect(res.feedback ?? "").toContain("+61404453580");
152-
}, 60_000);
155+
}, 120_000);
153156

154157
it("upload falls back to setInputFiles when the target isn't the chooser trigger (Ashby drop-zone)", async () => {
155158
// Ashby: the labeled 'Resume' control is a drop-zone/button that does NOT open a
@@ -161,13 +164,13 @@ describe("LocalRunner brain-driven browser endpoints", () => {
161164
await runner.browserAct({ action: "upload", ref: ref(snap.snapshot, "button", "Resume"), name: "Resume" }, resume());
162165
// The input's onchange set the title to FILE:<name> → the file really attached.
163166
expect((await runner.browserSnapshot()).title).toContain("FILE:");
164-
}, 60_000);
167+
}, 120_000);
165168

166169
it("a failed action (bad ref) throws so the workflow surfaces it to the brain", async () => {
167170
await runner.browserAct({ action: "navigate", url: server.jobUrl });
168171
await runner.browserSnapshot();
169172
// A ref that doesn't exist must not silently succeed — it throws (→ the
170173
// workflow maps it to `error`, driving the brain's self-correction).
171174
await expect(runner.browserAct({ action: "click", ref: "e99999", name: "ghost" })).rejects.toThrow();
172-
}, 60_000);
175+
}, 120_000);
173176
});

‎workers/api/src/lib/delegate-instance.ts‎

Lines changed: 0 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,6 @@ import { DEFAULT_LOOP_DRIVER, loopDriverFor } from "./loop-drivers.js";
2323
import { capabilitiesForInstance } from "./agent-capabilities.js";
2424
import { sanitizeMaxIterations } from "./agent-loop.js";
2525
import { logEvent } from "./events.js";
26-
import { agentCapabilities } from "./agent-capabilities.js";
27-
import { getActiveSessionForRepo, listRepos } from "./coding-store.js";
28-
import { delegationTaskRecord } from "./delegation.js";
2926
import type { Env } from "../types.js";
3027

3128
export interface DelegateInstanceInput {
@@ -144,98 +141,3 @@ export async function delegateToInstance(env: Env, input: DelegateInstanceInput)
144141

145142
return { ok: true, runId, budgetId, depth };
146143
}
147-
148-
/**
149-
* Delegate to a coding agent's Pilot — the durable loop that drives a real CLI.
150-
*
151-
* Mirrors what the hardcoded Overseer does, with the repo resolved from the subordinate rather
152-
* than named by the caller: a Repo Coder owns exactly one repo, so the supervisor should not have
153-
* to know which. Refusals are explicit, because the alternative — a board card that looks
154-
* delegated and never moves — is the failure #154 was written to kill.
155-
*/
156-
async function delegateToPilot(
157-
env: Env,
158-
input: DelegateInstanceInput,
159-
depth: number,
160-
budgetId: string,
161-
objective: string,
162-
): Promise<DelegateInstanceResult> {
163-
const repos = await listRepos(env, input.subordinateInstanceId, input.userId).catch(() => []);
164-
const repo = repos[0];
165-
if (!repo) {
166-
return {
167-
ok: false,
168-
status: 409,
169-
error: "That coding agent has no repository yet — add one on its Coding tab first.",
170-
};
171-
}
172-
const session = await getActiveSessionForRepo(env, input.subordinateInstanceId, input.userId, repo.id);
173-
if (!session) {
174-
return {
175-
ok: false,
176-
status: 409,
177-
error: `${repo.name} has no live coding session — start one on its Coding tab (and run \`pags up\`), then delegate again.`,
178-
};
179-
}
180-
181-
// Observable board task, so a delegated goal is trackable rather than buried in one repo's
182-
// thread. The Pilot flips it to completed/failed at its terminal state.
183-
const taskId = `deleg-${crypto.randomUUID()}`;
184-
185-
// AND a loop-run row, so `check_delegation` answers for BOTH delegation kinds. Without it
186-
// the supervisor is handed a run id, told to track it, and gets "no run with that id" —
187-
// the tool's own instructions would be a lie.
188-
const runId = crypto.randomUUID();
189-
await createLoopRun(env, {
190-
runId,
191-
userId: input.userId,
192-
instanceId: input.subordinateInstanceId,
193-
objective,
194-
maxIterations: sanitizeMaxIterations(input.maxIterations),
195-
budgetId,
196-
startedAt: Date.now(),
197-
}).catch(() => undefined);
198-
await env.DB.prepare(
199-
`INSERT INTO instance_runtime_tasks (id, instance_id, user_id, type, status, payload, created_at, updated_at)
200-
VALUES (?1, ?2, ?3, 'delegation', 'running', ?4, datetime('now'), datetime('now'))`,
201-
)
202-
.bind(
203-
taskId,
204-
input.subordinateInstanceId,
205-
input.userId,
206-
JSON.stringify(delegationTaskRecord({ id: taskId, targetLabel: repo.name, objective, status: "running", now: new Date().toISOString() })),
207-
)
208-
.run()
209-
.catch(() => undefined);
210-
211-
await env.CODING_SESSION.create({
212-
params: {
213-
instanceId: input.subordinateInstanceId,
214-
userId: input.userId,
215-
sessionId: session.id,
216-
repoId: repo.id,
217-
runnerNode: session.runnerNode ?? null,
218-
cloneUrl: repo.cloneUrl ?? undefined,
219-
branch: repo.branch || undefined,
220-
goal: { objective, repo: repo.name, clientType: session.clientType },
221-
boardTaskId: taskId,
222-
// #184: a DELEGATED coding run draws on the tree's pool. A human driving the same
223-
// Pilot from the Coding tab passes no budget and stays unmetered, as before.
224-
budgetId,
225-
depth,
226-
loopRunId: runId,
227-
},
228-
});
229-
230-
await logEvent(env, {
231-
source: "coding",
232-
event: "delegate",
233-
message: `${input.supervisorInstanceId} → ${repo.name}: ${objective.slice(0, 160)}`,
234-
userId: input.userId,
235-
instanceId: input.supervisorInstanceId,
236-
traceId: input.parentTraceId ?? taskId,
237-
context: { taskId, depth, budgetId, subordinate: input.subordinateInstanceId },
238-
}).catch(() => undefined);
239-
240-
return { ok: true, runId, budgetId, depth };
241-
}

‎workers/api/src/lib/loop-drivers.test.ts‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ describe("loopDriverFor — the ONE Loop dispatches on what the agent DECLARES (
3636
});
3737

3838
/** Env stub recording the writes + which Workflow binding was created. */
39-
function stubEnv(opts: { repos?: unknown[]; session?: unknown } = {}) {
39+
function stubEnv(opts: { repos?: unknown[]; session?: unknown; claimTaken?: boolean } = {}) {
4040
const sql: string[] = [];
4141
const created: Array<{ binding: string; params: Record<string, unknown> }> = [];
4242
const wf = (binding: string) => ({ create: vi.fn(async (a: { params: Record<string, unknown> }) => { created.push({ binding, params: a.params }); return { id: "wf" }; }) });
@@ -47,7 +47,8 @@ function stubEnv(opts: { repos?: unknown[]; session?: unknown } = {}) {
4747
return {
4848
bind() {
4949
return {
50-
async run() { return { meta: { changes: 1 } }; },
50+
// A claim whose predicate matched nothing = somebody else holds it.
51+
async run() { return { meta: { changes: opts.claimTaken && q.includes("driver_id") ? 0 : 1 } }; },
5152
async all() { return { results: opts.repos ?? [] }; },
5253
async first() { return opts.session ?? null; },
5354
};
@@ -127,3 +128,47 @@ describe('the "Delegated:" board card belongs to a supervisor, not to the owner'
127128
expect(String(created[0].params.boardTaskId)).toMatch(/^deleg-/);
128129
});
129130
});
131+
132+
describe("one driver per engine — every path that starts a Pilot claims it (#208 hole)", () => {
133+
const codingEnv = (claimTaken = false) => stubEnv({
134+
repos: [{ id: "r1", name: "fws/platform" }],
135+
session: { id: "s1", client_type: "claude" },
136+
claimTaken,
137+
});
138+
139+
it("claims the session before creating the workflow", async () => {
140+
// #208 put the claim on `/sessions/:id/run` ONLY. This path — the owner's Loop button and
141+
// every `delegate_goal` to a coding agent — reaches the same tmux pane and was left open,
142+
// so two Pilots could interleave `send-keys` into one terminal, each reasoning over output
143+
// the other was writing.
144+
const { env, sql, created } = codingEnv();
145+
const out = await loopDriverFor(caps("CODING_SESSION")).start({ env, ...base });
146+
expect(out.ok).toBe(true);
147+
expect(sql.some((q) => q.includes("driver_id") && q.includes("UPDATE coding_sessions"))).toBe(true);
148+
// The Pilot must carry the claim, or nothing releases it when the run ends.
149+
expect(created[0].params.driverId).toBeTruthy();
150+
});
151+
152+
it("refuses — and starts NOTHING — when another driver already holds the session", async () => {
153+
const { env, sql, created } = codingEnv(true);
154+
const out = await loopDriverFor(caps("CODING_SESSION")).start({ env, ...base, delegated: true });
155+
expect(out).toMatchObject({ ok: false, status: 409 });
156+
if (!out.ok) expect(out.error).toMatch(/already being worked on/i);
157+
// No workflow, no loop-run row, and no "Delegated:" card announcing work that never began.
158+
expect(created).toHaveLength(0);
159+
expect(sql.some((q) => q.includes("INSERT INTO agent_loop_runs"))).toBe(false);
160+
expect(sql.some((q) => q.includes("'delegation'"))).toBe(false);
161+
});
162+
163+
it("claims BEFORE opening the run row, so a refusal leaves no orphan", async () => {
164+
// Order matters: a claim checked after the row is written leaves an `agent_loop_runs` row
165+
// that no workflow will ever close — the stranded-row failure #207C exists to sweep up.
166+
const { env, sql } = codingEnv();
167+
await loopDriverFor(caps("CODING_SESSION")).start({ env, ...base });
168+
const claimAt = sql.findIndex((q) => q.includes("driver_id") && q.includes("UPDATE coding_sessions"));
169+
const runAt = sql.findIndex((q) => q.includes("INSERT INTO agent_loop_runs"));
170+
expect(claimAt).toBeGreaterThanOrEqual(0);
171+
expect(claimAt).toBeLessThan(runAt);
172+
});
173+
});
174+

‎workers/api/src/lib/loop-drivers.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import { createLoopRun } from "./agent-loop-store.js";
2222
import { sanitizeMaxIterations } from "./agent-loop.js";
2323
import { delegationTaskRecord } from "./delegation.js";
24-
import { getActiveSessionForRepo, listRepos } from "./coding-store.js";
24+
import { claimSessionDriver, getActiveSessionForRepo, listRepos } from "./coding-store.js";
2525
import type { AgentCapabilities } from "./agent-capabilities.js";
2626
import type { Env } from "../types.js";
2727

@@ -115,6 +115,20 @@ const codingDriver: LoopDriver = {
115115
};
116116
}
117117

118+
// Single-flight, same as `/sessions/:id/run` (#208). Without it a Lead delegating a goal —
119+
// or the owner pressing Loop — starts a SECOND Pilot on a session that is already being
120+
// driven, and the two interleave `tmux send-keys` into the same pane, each reasoning over
121+
// a terminal the other is writing to. #208 added the claim to the route only; these paths
122+
// reach the same engine and were left open, so the guarantee was a sixth of a guarantee.
123+
const driverId = crypto.randomUUID();
124+
if (!(await claimSessionDriver(env, instanceId, userId, session.id, driverId))) {
125+
return {
126+
ok: false,
127+
status: 409,
128+
error: `${repo.name} is already being worked on — wait for the current run to finish, or stop it first.`,
129+
};
130+
}
131+
118132
const runId = crypto.randomUUID();
119133
const maxIterations = sanitizeMaxIterations(input.maxIterations);
120134
await createLoopRun(env, {
@@ -158,6 +172,7 @@ const codingDriver: LoopDriver = {
158172
budgetId: input.budgetId,
159173
depth: input.depth,
160174
loopRunId: runId,
175+
driverId,
161176
},
162177
});
163178
return { ok: true, runId, driver: codingDriver.id };

‎workers/api/src/routes/coding.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,16 @@ async function delegateToTarget(
991991
return { ok: false, reply: `${targetLabel} has no live session — open it (or tap Start) first, then I can drive it.` };
992992
}
993993

994+
// Single-flight BEFORE anything observable is written (#208). This is the third path that
995+
// starts a Pilot on a real session, and it was the one still left open: the claim went on
996+
// `/sessions/:id/run` only, so `drive_claude` could put a SECOND Pilot on a pane a first was
997+
// already typing into. Claimed first so a refusal doesn't leave a board card + trace event
998+
// announcing work that never started.
999+
const driverId = crypto.randomUUID();
1000+
if (!(await claimSessionDriver(c.env, instanceId, uid, session.id, driverId))) {
1001+
return { ok: false, reply: `${targetLabel} is already being worked on — let the current run finish, or stop it first.` };
1002+
}
1003+
9941004
const taskId = `deleg-${crypto.randomUUID()}`;
9951005
const now = new Date().toISOString();
9961006
const label = objective.length > 120 ? `${objective.slice(0, 117)}…` : objective;
@@ -1010,6 +1020,7 @@ async function delegateToTarget(
10101020
instanceId, userId: uid, sessionId: session.id, repoId: repo.id,
10111021
runnerNode: session.runnerNode ?? null, cloneUrl: repo.cloneUrl ?? undefined,
10121022
branch: repo.branch || undefined, token: token ?? undefined, goal, boardTaskId: taskId,
1023+
driverId,
10131024
},
10141025
});
10151026
return { ok: true, taskId, label: targetLabel, reply: `On it — delegated to ${repo.name}; track it on the board.` };

0 commit comments

Comments
 (0)