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 }