From 5ea8769efe596e758e224388303dc55e210e4d60 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 13 Oct 2026 07:12:22 -0700 Subject: [PATCH] fix: let one call start only one job across its two surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card's form and the deployed page's are now live at the same time, and only the card took the manager's submission claim. The call stays pending until `adopt`, a round trip after the page sends its request, so a Run on the card in that window passed the claim and started a second job — with its own arguments, and unmentioned in the chat, which followed the card's. The page takes the same claim before it sends, and hands it back when no job came of it so a failed start can be retried. A successful start needs no release: adopting the job settles the call. Whichever surface is first owns the call; the other sends nothing and says so. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/lib/components/details/FlowDetail.svelte | 9 +++++++++ frontend/src/lib/components/details/ScriptDetail.svelte | 9 +++++++++ frontend/src/lib/components/details/pendingRun.ts | 9 +++++++++ 3 files changed, 27 insertions(+) diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index 854a18c75d..e92fff0284 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -300,6 +300,12 @@ // pressed under, and a call settled meanwhile — declined, then another proposed for // this same flow — must not be answered with a job it never asked for. const call = pendingRun + // One call is one job, and the card's form is live alongside this one: without the + // claim, a Run there during the round trip below starts a second. + if (call && !call.beginSubmit()) { + sendUserToast('This run is already being started from the chat.', true) + return + } loading = true const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined try { @@ -318,6 +324,9 @@ if (!call?.adopt(run, args, flow?.schema ?? {})) await onNavigate('/run/' + run + '?workspace=' + workspace) } catch (e) { + // No job came of the claim, so hand it back rather than leaving the call unrunnable + // on either surface. + call?.endSubmit() throw e } finally { loading = false diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index a4c028966a..c10f3dc7bd 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -389,6 +389,12 @@ // pressed under, and a call settled meanwhile — declined, then another proposed for // this same script — must not be answered with a job it never asked for. const call = pendingRun + // One call is one job, and the card's form is live alongside this one: without the + // claim, a Run there during the round trip below starts a second. + if (call && !call.beginSubmit()) { + sendUserToast('This run is already being started from the chat.', true) + return + } try { runLoading = true const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined @@ -420,6 +426,9 @@ if (!call?.adopt(run, args, script?.schema ?? {})) await onNavigate('/run/' + run + '?workspace=' + workspace) } catch (err) { + // No job came of the claim, so hand it back rather than leaving the call unrunnable + // on either surface. + call?.endSubmit() sendUserToast(`Could not create job: ${err.body}`, true) } finally { // Not only on failure: a navigation that keeps this component mounted — the run diff --git a/frontend/src/lib/components/details/pendingRun.ts b/frontend/src/lib/components/details/pendingRun.ts index 00e75622fc..df62481f7d 100644 --- a/frontend/src/lib/components/details/pendingRun.ts +++ b/frontend/src/lib/components/details/pendingRun.ts @@ -24,4 +24,13 @@ export type PendingRun = { * alone. */ adopt: (jobId: string, args: Record, schema: Record) => boolean decline: () => void + /** Claim the call's one job, before starting one. The card's form is live at the same + * time as this page's, and the call stays pending until `adopt` — which is a round trip + * later — so without this claim a Run on the card in between starts a second job, with + * its own arguments and no mention in the chat. False when the other surface claimed it + * first; send nothing and say so. */ + beginSubmit: () => boolean + /** Hand the claim back when no job came of it, so the reader can try again. A started + * job needs no release: adopting it settles the call. */ + endSubmit: () => void }