From 6c2ad4bbf62ab72a2e08547bc154e42a0b206bf7 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 13 Oct 2026 07:42:13 -0700 Subject: [PATCH] fix: claim a run before the form mints its secrets, not after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claim sat in `runAction`, which `RunForm.run()` reaches only after `processSecretArgs` has written a variable per `password` argument. So a press that lost the race still minted — and worse, it read the pending call after that round trip, by which point the winner had settled it: with no call to claim, the losing press started a job of its own. `runRefusal` already documents this boundary, saying a caller cannot gate in `runAction` because it comes after the mint. The claim belongs on the same side, so the form takes it through `claimRun` just before the first write and hands it back through `releaseRun` when the mint fails. The pages keep the call they claimed rather than re-reading it, so a claimed press either answers that call or does nothing. The chat card's own form already worked this way, taking the claim ahead of its `processSecretArgs`; the page was the asymmetric surface. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/lib/components/RunForm.svelte | 19 +++++++++ .../lib/components/details/FlowDetail.svelte | 39 +++++++++++++++---- .../components/details/ScriptDetail.svelte | 39 +++++++++++++++---- 3 files changed, 83 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/components/RunForm.svelte b/frontend/src/lib/components/RunForm.svelte index c01f7598ca..4fa2b94ad8 100644 --- a/frontend/src/lib/components/RunForm.svelte +++ b/frontend/src/lib/components/RunForm.svelte @@ -91,6 +91,10 @@ if (blockedByUnparseable) { return } + // Last gate before the first write: everything below either mints or sends. + if (claimRun && !claimRun()) { + return + } let processedArgs: Record const { args: withDefaults, resetKeys } = enforceDisabledDefaults(args ?? {}, runnable?.schema) if (resetKeys.length > 0) { @@ -99,6 +103,7 @@ try { processedArgs = await processSecretArgs(withDefaults, runnable?.schema, $operatingWorkspace) } catch (e) { + releaseRun?.() sendUserToast('Failed to process sensitive args: ' + e, true) return } @@ -143,6 +148,18 @@ * for JSON or back mounts a `password` widget, which mints from the literal it opens * on regardless of being disabled. */ runRefusal?: string + /** Take whatever this press has to own before anything is written, and return false to + * drop it. Behind the same boundary as `runRefusal` and for the same reason: the + * secret processing mints a variable per `password` argument, so a claim made in + * `runAction` comes after that write. For a caller whose run can be started from a + * second surface — the chat card holding the same tool call — where one press winning + * has to stop the other from minting, not just from starting a job. Called once per + * press that reaches the mint, and says itself why a press was dropped. */ + claimRun?: () => boolean + /** Hand back what `claimRun` took, for a press that claimed and then never reached + * `runAction` — the secret processing in between can still fail. A press that does + * reach it releases through the caller's own start path instead. */ + releaseRun?: () => void runAction: ( scheduledForStr: string | undefined, args: Record, @@ -177,6 +194,8 @@ runnable, runAction, runRefusal = undefined, + claimRun = undefined, + releaseRun = undefined, buttonText = 'Run', schedulable = true, detailed = true, diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index e92fff0284..00c1beb459 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -290,6 +290,30 @@ } } + /** Held between `claimRun` and `runFlow`: the form mints its `password` arguments in + * between, so the claim has to be taken before that write while the job is started after + * it. Cleared as it is used, so a press that never claimed cannot inherit it. */ + let claimedCall: PendingRun | undefined = undefined + + /** One call is one job, and the chat card's form is live alongside this page's: the first + * of the two to claim owns the call, and the other stops here — before it mints a + * variable per `password` argument for a run it will not start. */ + function claimRun(): boolean { + const call = pendingRun + if (call && !call.beginSubmit()) { + sendUserToast('This run is already being started from the chat.', true) + return false + } + claimedCall = call + return true + } + + /** The form claimed and then never reached the run — its secret processing failed. */ + function releaseRun() { + claimedCall?.endSubmit() + claimedCall = undefined + } + async function runFlow( scheduledForStr: string | undefined, args: Record, @@ -299,13 +323,12 @@ // Taken before the round trip below: the call this press answers is the one it was // 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 - } + // The call this press claimed, not whatever is pending now: the form mints its secrets + // between the claim and here, and a call that settled across that round trip would + // leave this press looking like a page run of its own — starting a second job for a + // call the chat has already answered. + const call = claimedCall + claimedCall = undefined loading = true const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined try { @@ -930,6 +953,8 @@ bind:isValid runnable={flow} runAction={runFlow} + {claimRun} + {releaseRun} runRefusal={pendingRun?.planModeActive ? PLAN_MODE_MESSAGES.runFormRefused : undefined} diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index c10f3dc7bd..68e3319221 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -379,6 +379,30 @@ let isValid = $state(true) let runLoading = $state(false) + /** Held between `claimRun` and `runScript`: the form mints its `password` arguments in + * between, so the claim has to be taken before that write while the job is started after + * it. Cleared as it is used, so a press that never claimed cannot inherit it. */ + let claimedCall: PendingRun | undefined = undefined + + /** One call is one job, and the chat card's form is live alongside this page's: the first + * of the two to claim owns the call, and the other stops here — before it mints a + * variable per `password` argument for a run it will not start. */ + function claimRun(): boolean { + const call = pendingRun + if (call && !call.beginSubmit()) { + sendUserToast('This run is already being started from the chat.', true) + return false + } + claimedCall = call + return true + } + + /** The form claimed and then never reached the run — its secret processing failed. */ + function releaseRun() { + claimedCall?.endSubmit() + claimedCall = undefined + } + async function runScript( scheduledForStr: string | undefined, args: Record, @@ -388,13 +412,12 @@ // Taken before the round trip below: the call this press answers is the one it was // 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 - } + // The call this press claimed, not whatever is pending now: the form mints its secrets + // between the claim and here, and a call that settled across that round trip would + // leave this press looking like a page run of its own — starting a second job for a + // call the chat has already answered. + const call = claimedCall + claimedCall = undefined try { runLoading = true const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined @@ -1145,6 +1168,8 @@ bind:isValid runnable={script} runAction={runScript} + {claimRun} + {releaseRun} runRefusal={pendingRun?.planModeActive ? PLAN_MODE_MESSAGES.runFormRefused : undefined}