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}