Repository navigation
feat(workflows): let a synchronous run continue in the background after a wait timeout - #9464
Conversation
…er a wait timeout A synchronous run, including a manual draft run (--manual, --trigger, --from-block, --stop-after), could only be waited on until it settled or hit the sync execution limit. Manual runs refuse async, so a caller had no way to get the run id back from a long or stuck draft run and decide to keep waiting, watch it, or cancel it. The v2 execute body takes waitTimeoutSeconds. Past it the response answers 202 with the run receipt (NDJSON: a `detached` frame) and the run keeps going in this process exactly as it started: same captured draft state, entry point and pause persistence. Such a run is admitted like an async one (async rate bucket, reservation and timeout), takes no request abort signal so a dropped connection never cancels it, and registers an in-process aborter so runs cancel reaches it without Redis. Without the field nothing changes. The CLI exposes it as `workflows run --wait-timeout <seconds>`. A run that outlasts it is read back once and reported the way `workflows runs wait` reports an elapsed bound: the run record on stdout and exit 4 while it is still moving, so `workflows runs wait <runId>` continues it.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 15 files
Reply with feedback, questions, or to request a fix.
Turn on auto-fix | Re-trigger cubic
[Medium impact] The PR appears safe to merge; no new blocking issues were found. SummaryAdds a bounded wait for synchronous workflow runs. Runs that outlast the wait continue in the background.
Both earlier, unnumbered findings are fixed: receipts wait for the log row, and
|
…an be read A detach could fire before the run's log row existed, so the runs resource answered 404 for the receipt just handed out. The response now waits for the logging session to start before detaching, and the wait timeout docs say how each transport carries the receipt.
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
…to the worker Sim now sends the workflow-run-wait-timeout client capability on chat turns, so the worker waits on a synchronous workflows run in slices only on a Sim whose CLI takes --wait-timeout there; an older Sim keeps its held run. The protocol copy is synced from the mothership contract that defines the token.
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
…l controller (#9475) * test(mothership): drive a draft run past a wait slice through the real controller A live case for the worker's sliced synchronous `workflows run` (#9464, #9469): a draft with Wait blocks totalling 500 s runs through runCopilotLifecycle, the sim_cli handler, the embedded CLI and the v2 execute and runs routes against the worker's scripted draft-run probe. With workflow-run-wait-timeout declared it checks the detached first slice, the `workflows runs wait` slices, the output read-back, that no call holds the run and no slice row is failed, and that the model receives the run's output. Without the capability it checks one held call as before, and a model's own --wait-timeout 5 reaches the model as exit 4 with the running receipt. The controller fixture now pins direct Sim transport, since self-hosted defaults to checkpoint delivery and its run control went unanswered, and the fixture plan's execution timeout is per case so a run can outlast a slice. * test(mothership): keep the draft-run case inside the repo's audits Uses sleep from @sim/utils/helpers, guards the last wait slice instead of a non-null assertion, gives the probe's startup its own ten-second deadline like the neighbouring controller case, and tells knip the probe path lives in the worker checkout.
… worker's wait A run_workflow* call held the turn until its run settled, up to the execution limit, while a synchronous workflows run can already detach after --wait-timeout (#9464). Sim now declares workflow-tool-wait-timeout and honors the waitTimeoutMs a workflow tool frame names (synced from the mothership contract): - Server-run call: the handler gives the run its execution id up front and, once the wait passes, answers {status: running, executionId, workflowId}. The run goes on in-process; the tool execution keeps holding it, so its lease and a Stop through the call's abort signal still reach it. - Browser-run call: once the wait passes and a browser holds the call's execution claim, the call is settled background, the detach a browser sends on pagehide. The browser's run goes on, still bound to the call, so a Stop cancels it. - A background workflow completion now carries status: running beside its ids, the shape the worker waits on. A frame without waitTimeoutMs, or one with a malformed value, holds the call as before.
…ast a call A synchronous workflows run the worker waits on in slices detaches after its first --wait-timeout (#9464, mothership#872), and from then on no call's abort signal reaches it. Stop delivery cancelled only browser workflow runs bound to open calls, so a Stop mid-slice ended the chat while the draft run went on to completion. Driven through the real controller and the Stop route (live case in the next commit), the run was still running a minute after the Stop. Stop delivery now also cancels the runs this chat run's calls started and left running: a workflows run the CLI answered still running (exit 4 and its run record), and a workflow tool answered still running. A run the chat only waited on (workflows runs wait) is not one, since something else started it. The cancel is the same in-process abort and durable cancel flag a browser run gets; a detached run registers its in-process aborter. Autopsy: the slice tests on both sides covered waits that settle, lapse or lose an answer, and none stopped the chat mid-slice; the Stop tests seeded only open browser-run calls. stop-delivery.test.ts and the live declared-stop case now cover it.
… worker's wait A run_workflow* call held the turn until its run settled, up to the execution limit, while a synchronous workflows run can already detach after --wait-timeout (#9464). Sim now declares workflow-tool-wait-timeout and honors the waitTimeoutMs a workflow tool frame names (synced from the mothership contract): - Server-run call: the handler gives the run its execution id up front and, once the wait passes, answers {status: running, executionId, workflowId}. The run goes on in-process; the tool execution keeps holding it, so its lease and a Stop through the call's abort signal still reach it. - Browser-run call: once the wait passes and a browser holds the call's execution claim, the call is settled background, the detach a browser sends on pagehide. The browser's run goes on, still bound to the call, so a Stop cancels it. - A background workflow completion now carries status: running beside its ids, the shape the worker waits on. A frame without waitTimeoutMs, or one with a malformed value, holds the call as before.
…ast a call A synchronous workflows run the worker waits on in slices detaches after its first --wait-timeout (#9464, mothership#872), and from then on no call's abort signal reaches it. Stop delivery cancelled only browser workflow runs bound to open calls, so a Stop mid-slice ended the chat while the draft run went on to completion. Driven through the real controller and the Stop route (live case in the next commit), the run was still running a minute after the Stop. Stop delivery now also cancels the runs this chat run's calls started and left running: a workflows run the CLI answered still running (exit 4 and its run record), and a workflow tool answered still running. A run the chat only waited on (workflows runs wait) is not one, since something else started it. The cancel is the same in-process abort and durable cancel flag a browser run gets; a detached run registers its in-process aborter. Autopsy: the slice tests on both sides covered waits that settle, lapse or lose an answer, and none stopped the chat mid-slice; the Stop tests seeded only open browser-run calls. stop-delivery.test.ts and the live declared-stop case now cover it.
… worker's wait A run_workflow* call held the turn until its run settled, up to the execution limit, while a synchronous workflows run can already detach after --wait-timeout (#9464). Sim now declares workflow-tool-wait-timeout and honors the waitTimeoutMs a workflow tool frame names (synced from the mothership contract): - Server-run call: the handler gives the run its execution id up front and, once the wait passes, answers {status: running, executionId, workflowId}. The run goes on in-process; the tool execution keeps holding it, so its lease and a Stop through the call's abort signal still reach it. - Browser-run call: once the wait passes and a browser holds the call's execution claim, the call is settled background, the detach a browser sends on pagehide. The browser's run goes on, still bound to the call, so a Stop cancels it. - A background workflow completion now carries status: running beside its ids, the shape the worker waits on. A frame without waitTimeoutMs, or one with a malformed value, holds the call as before.
…ast a call A synchronous workflows run the worker waits on in slices detaches after its first --wait-timeout (#9464, mothership#872), and from then on no call's abort signal reaches it. Stop delivery cancelled only browser workflow runs bound to open calls, so a Stop mid-slice ended the chat while the draft run went on to completion. Driven through the real controller and the Stop route (live case in the next commit), the run was still running a minute after the Stop. Stop delivery now also cancels the runs this chat run's calls started and left running: a workflows run the CLI answered still running (exit 4 and its run record), and a workflow tool answered still running. A run the chat only waited on (workflows runs wait) is not one, since something else started it. The cancel is the same in-process abort and durable cancel flag a browser run gets; a detached run registers its in-process aborter. Autopsy: the slice tests on both sides covered waits that settle, lapse or lose an answer, and none stopped the chat mid-slice; the Stop tests seeded only open browser-run calls. stop-delivery.test.ts and the live declared-stop case now cover it.
… worker's wait A run_workflow* call held the turn until its run settled, up to the execution limit, while a synchronous workflows run can already detach after --wait-timeout (#9464). Sim now declares workflow-tool-wait-timeout and honors the waitTimeoutMs a workflow tool frame names (synced from the mothership contract): - Server-run call: the handler gives the run its execution id up front and, once the wait passes, answers {status: running, executionId, workflowId}. The run goes on in-process; the tool execution keeps holding it, so its lease and a Stop through the call's abort signal still reach it. - Browser-run call: once the wait passes and a browser holds the call's execution claim, the call is settled background, the detach a browser sends on pagehide. The browser's run goes on, still bound to the call, so a Stop cancels it. - A background workflow completion now carries status: running beside its ids, the shape the worker waits on. A frame without waitTimeoutMs, or one with a malformed value, holds the call as before.
…ast a call A synchronous workflows run the worker waits on in slices detaches after its first --wait-timeout (#9464, mothership#872), and from then on no call's abort signal reaches it. Stop delivery cancelled only browser workflow runs bound to open calls, so a Stop mid-slice ended the chat while the draft run went on to completion. Driven through the real controller and the Stop route (live case in the next commit), the run was still running a minute after the Stop. Stop delivery now also cancels the runs this chat run's calls started and left running: a workflows run the CLI answered still running (exit 4 and its run record), and a workflow tool answered still running. A run the chat only waited on (workflows runs wait) is not one, since something else started it. The cancel is the same in-process abort and durable cancel flag a browser run gets; a detached run registers its in-process aborter. Autopsy: the slice tests on both sides covered waits that settle, lapse or lose an answer, and none stopped the chat mid-slice; the Stop tests seeded only open browser-run calls. stop-delivery.test.ts and the live declared-stop case now cover it.
… worker's wait A run_workflow* call held the turn until its run settled, up to the execution limit, while a synchronous workflows run can already detach after --wait-timeout (#9464). Sim now declares workflow-tool-wait-timeout and honors the waitTimeoutMs a workflow tool frame names (synced from the mothership contract): - Server-run call: the handler gives the run its execution id up front and, once the wait passes, answers {status: running, executionId, workflowId}. The run goes on in-process; the tool execution keeps holding it, so its lease and a Stop through the call's abort signal still reach it. - Browser-run call: once the wait passes and a browser holds the call's execution claim, the call is settled background, the detach a browser sends on pagehide. The browser's run goes on, still bound to the call, so a Stop cancels it. - A background workflow completion now carries status: running beside its ids, the shape the worker waits on. A frame without waitTimeoutMs, or one with a malformed value, holds the call as before.
…ast a call A synchronous workflows run the worker waits on in slices detaches after its first --wait-timeout (#9464, mothership#872), and from then on no call's abort signal reaches it. Stop delivery cancelled only browser workflow runs bound to open calls, so a Stop mid-slice ended the chat while the draft run went on to completion. Driven through the real controller and the Stop route (live case in the next commit), the run was still running a minute after the Stop. Stop delivery now also cancels the runs this chat run's calls started and left running: a workflows run the CLI answered still running (exit 4 and its run record), and a workflow tool answered still running. A run the chat only waited on (workflows runs wait) is not one, since something else started it. The cancel is the same in-process abort and durable cancel flag a browser run gets; a detached run registers its in-process aborter. Autopsy: the slice tests on both sides covered waits that settle, lapse or lose an answer, and none stopped the chat mid-slice; the Stop tests seeded only open browser-run calls. stop-delivery.test.ts and the live declared-stop case now cover it.
…stop detached runs on Stop (#9507) * feat(mothership): answer a workflow tool's run still running past the worker's wait A run_workflow* call held the turn until its run settled, up to the execution limit, while a synchronous workflows run can already detach after --wait-timeout (#9464). Sim now declares workflow-tool-wait-timeout and honors the waitTimeoutMs a workflow tool frame names (synced from the mothership contract): - Server-run call: the handler gives the run its execution id up front and, once the wait passes, answers {status: running, executionId, workflowId}. The run goes on in-process; the tool execution keeps holding it, so its lease and a Stop through the call's abort signal still reach it. - Browser-run call: once the wait passes and a browser holds the call's execution claim, the call is settled background, the detach a browser sends on pagehide. The browser's run goes on, still bound to the call, so a Stop cancels it. - A background workflow completion now carries status: running beside its ids, the shape the worker waits on. A frame without waitTimeoutMs, or one with a malformed value, holds the call as before. * fix(mothership): stop the workflow runs a stopped chat left running past a call A synchronous workflows run the worker waits on in slices detaches after its first --wait-timeout (#9464, mothership#872), and from then on no call's abort signal reaches it. Stop delivery cancelled only browser workflow runs bound to open calls, so a Stop mid-slice ended the chat while the draft run went on to completion. Driven through the real controller and the Stop route (live case in the next commit), the run was still running a minute after the Stop. Stop delivery now also cancels the runs this chat run's calls started and left running: a workflows run the CLI answered still running (exit 4 and its run record), and a workflow tool answered still running. A run the chat only waited on (workflows runs wait) is not one, since something else started it. The cancel is the same in-process abort and durable cancel flag a browser run gets; a detached run registers its in-process aborter. Autopsy: the slice tests on both sides covered waits that settle, lapse or lose an answer, and none stopped the chat mid-slice; the Stop tests seeded only open browser-run calls. stop-delivery.test.ts and the live declared-stop case now cover it. * test(mothership): drive a workflow tool past its wait and a stop mid-slice through the real controller Two live cases beside the sliced draft run (opt-in, SIM_HELPERS_SMOKE and MSHIP_WORKER_ROOT): - tool: run_workflow on a Sim declaring workflow-tool-wait-timeout answers its 500 s run still running after 240 s, the worker waits on it as run-wait slices and reads it back, and the model sees the settled run's output. - declared-stop: the Stop button's request while the worker waits on a detached draft run ends the turn and cancels the run. The harness now marks the turn's tool execution and billing as the chat route does, registers and wires the turn's controller as lifecycle/start.ts does, gives the tool case a real AES key to seal its provenance, and keeps logged errors' messages in the evidence. * test(mothership): type the stopped run fixture and keep logged errors without a JSON round trip * fix(mothership): answer a workflow tool still running only once its run is dispatched Review of the wait: - A run still in authorization or admission when the wait passes is not answered running under an id nothing has used yet; the call waits until the run is handed to the executor (onDispatch) or settles. - The browser-run detacher sleeps interruptibly and stops on either outcome of its waiter, so no derived promise rejects unhandled. - The live case takes its AES key from generateRandomHex. - The mock-only Stop delivery test is dropped: the live declared-stop case drives the real Stop route, storage and cancellation, and fails without the fix (the run stays running); detachedWorkflowExecutionId keeps its unit test. * test(mothership): prove the browser detach and the Stop read against real Postgres and Redis The browser-run detach and the detached-run read now run through the real claim, settlement, published confirmation, waiter and stored tool rows (workflow-client-settlement.integration.ts), replacing unit tests that asserted only on mocks. * refactor(mothership): wait on a workflow tool's run with the shared interruptible sleep * fix(mothership): find a detached workflows run behind global CLI flags * fix(mothership): answer a workflow tool still running only once its run is readable A run past the call's wait was answered running as soon as it was handed to the executor, before the executor wrote its run log, so an immediate workflows runs wait could find no run. The call now answers running only once the run's log exists, re-reading it each second until then or until the run settles; the dispatch hook is gone. * fix(mothership): keep waiting on a workflow tool's run when its log cannot be read * fix(mothership): answer a browser run still running only once its log exists, and never discard a run that settled meanwhile * fix(mothership): retry a failed call read in the browser-run detacher * fix(mothership): keep watching a browser-run call that is still pending * fix(mothership): detach only a browser run still going, and retry a failed detach
Summary
A synchronous
workflows runcould only be waited on until it settled or hit the sync execution limit. That includes the manual draft runs (--manual,--trigger,--mock-payload,--from-block/--source-run,--stop-after), and manual runs refuse--async. So a caller testing a long or stuck draft run had no way to get its run id back and decide whether to keep waiting, watch it, or cancel it. Published runs already had a way to do this:--asyncplusworkflows runs wait.This PR adds a wait bound to the synchronous run:
POST /api/v2/workflows/{id}/executetakes a newwaitTimeoutSecondsfield.202with the existing run receipt{ runId, statusUrl }. On the NDJSON result transport this arrives as a{ type: "detached", data: receipt }frame.0waits until the run settles. The field can't be combined withasyncorstream, and anonymous public-API callers get a400.executionTimeoutSecondsis now also accepted alongsidewaitTimeoutSeconds.workflows runs cancelreaches it without Redis as well as through the usual Redis signal.sim workflows run ... --wait-timeout <seconds>, parsed like every other--wait-timeout.workflows runs waitreports an elapsed bound: the run record on stdout and exit4while the run is still moving, plus a stderr line namingworkflows runs waitandworkflows runs cancel.--wait-timeoutwith--asyncor--followis refused locally. A manual--asyncnow points at--wait-timeout.waitTimeoutSeconds/--wait-timeout, request handling, output and exit codes are unchanged.workflow-run-wait-timeoutclient capability on chat turns, so the mothership worker only uses--wait-timeouton a Sim that supports it. Older Sims keep the held synchronous run. The protocol copy is synced from the mothership contract; the worker side is a companion change that is not yet pushed.Why not
--asyncfor manual runsAn async manual run would have to carry the whole draft state, and for
--from-blockthe source run's snapshot too, through the job queue payload. That would be a durable-payload change that queued jobs and rollbacks must tolerate. Async also refuses result shaping (--select-output, file inlining) and adds a queue hop to every draft test, though most finish in seconds.--wait-timeoutkeeps the fast path inline and returns a run id only when the run is actually long. Accepting--asyncfor manual runs could be added later on top of this, but nothing here needs it.Type of Change
Testing
execute/route.test.ts). Each new test failed against the code before this change, and the cancel and disconnect guards were checked by reverting each one and watching its test fail:detachedframe arrives at the deadline and the run keeps going (ticket and claim held until it settles)202with the receiptruns cancelstops itasync,streamand anonymous combinations are refusedexecutionTimeoutSecondsis accepted only on runs that can outlive the responsesync-result-stream+detachablewith no request signalworkflow-run-follow.test.ts): the request body, unchanged output when the run settles, exit 4 plus the run record when it detaches, a run that settled after detaching, a202on the JSON transport, rounding a fractional wait up and0, and refused combinations. Theworkflows runs waittests still pass after its loop was extracted intowaitForRun.bun run lint,type-check(apps/sim, sim-cli),check:audits,check:api-validation:strict,check:test-types, the sim-cli suite, and the apps/sim suites underapp/api/v2/workflows,lib/workflows,lib/execution,lib/mothership/agent-cli,app/api/workflows.generate:cli-api,generate:openapi,generate:cli-docs.Reviewers: please focus on
streamPendingRun(who closes the stream, and that only a non-detachable run is cancelled when the reader goes away) and on the admission and timeout class inexecuteWorkflowService.Checklist