Repository navigation
computer: Let host module calls wait for an answer - #251
Merged
Merged
Conversation
A host module function can now call ctx.interrupt(payload) to pause a replayable run until someone answers, for example to approve an action the run is about to take. The code being run just calls the function and gets its result, however long the answer takes. The interrupted call is journaled as waiting. The bridge holds new calls, lets the ones in flight finish and be journaled, and the backend then releases the isolate while the run stays running. Waiting runs are left alone by recovery, so they survive a deploy, and their wait doesn't count toward the timeout. workspace.runtime.pending() lists the waiting calls and respond() answers one. Once none are waiting, the run replays to the call, which runs again with the same callId and gets its answer from interrupt() this time. Only a replayable run can wait, since a pause needs the journal, and interrupt() throws elsewhere. Calls that interrupt after the run starts releasing wait once it resumes, so approvals made together are asked one after another. A released attempt's late output is dropped, as it isn't the run's.
🦋 Changeset detectedLatest commit: ba58d1e The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
commit: |
The journal keeps one answer per call. A function that called ctx.interrupt() twice joined its first wait the second time, losing the second payload, and on resume both calls returned the first answer, so one approval could stand for two. A second interrupt() in the same call now throws.
The run resumes only once its isolate is released, so an answer that lands while the run's other calls are still finishing has to wait for the release to end. Nothing checked that it does, and if it didn't the run would stay running without ever resuming.
ctx.interrupt(), runtime.pending(), runtime.respond() and the pending call type now say in their JSDoc that they may change in any release, as restart and replay already do, and the docs section says so too.
# Conflicts: # packages/computer/src/backends/worker-javascript/journal.ts # packages/computer/src/backends/worker-javascript/worker-javascript.ts
An app had to poll runtime.pending() to learn that a run was waiting, and had to know which object to ask. The JavaScript backend now takes an onWait function, called with the fields pending() lists once a wait is saved, so the app can message whoever answers or index the wait itself. It's called at most once per wait. A crash between saving the wait and calling it loses the call, so pending() stays the source of truth. It runs on its own rather than inside the interrupted call, so a slow or failing hook can't hold up the run's release, and what it throws is logged.
Approval UIs usually live in a Worker that calls the Durable Object, so without these every app wrote its own RPC methods to list and answer waiting calls. Both pass plain data, so the stub hands out nothing that needs disposing.
scuffi
added this pull request to stack #253
October 20, 2026 10:34
scuffi
marked this pull request as ready for review
October 20, 2026 10:34
Releasing a run for an interrupt waited for every call that was in flight when the first one interrupted. A call among them that went on to interrupt itself never settled, so the run was never released, its answers never resumed it, and it sat until its timeout failed it. Two host module calls in one Promise.all that each await anything before interrupting did this. quiesce() now waits until each call in flight has been answered or is waiting too, looking again whenever another call starts waiting, so those waits are answered together.
A run resumed after its last wait is answered records its first attempt, since the answer is progress. Without that, a run whose earlier interruptions had used up its attempts could fail on the next one before the answered call was journaled.
Brings in the renamed replay tables and columns, and renames this branch's own uses to match. The journal's payload column is now interrupt_payload, since it holds what ctx.interrupt() was given rather than the call's request or any output.
pending() and onWait gave every waiting call its run's waiting_since, which is when the run's first unanswered call started waiting, so a second call that started waiting later showed the first one's time. The journal now keeps waiting_since for each call, and both report that. The run's own waiting_since still decides how much waiting its timeout leaves out.
The docs, the journal column and the ENOTWAITING message all call what respond() is given an answer, so the parameter says so too. It's positional, so callers don't change.
Merged
Merged
1 of 2 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stacked on #238. Lets a host module function pause a replayable run until someone answers, for approvals and the like. The code being run doesn't change: it calls
postComment()and gets a result, however long the answer takes.It ships in the same release as #238, and #238's API doesn't change.
Changes
WorkspaceModuleCallContext.interrupt(),runtime.pending()andruntime.respond().pending()andrespond()are onworkspace.stub().runtimetoo, for a Worker calling the object. Plain data both ways, so nothing to dispose.onWait(call)backend option, called with the fieldspending()lists once a wait is saved, so an app can message whoever answers instead of polling. It's at most once per wait, sopending()stays the source of truth.waitingandansweredstates, with the payload and answer. Each waiting call records when it started waiting, whichpending()reports. The run's resume row tracks when its first unanswered call started waiting and how long it has waited in total, so waiting doesn't count toward the timeout.interrupt()throws in a run that isn't replayable, with a message that says to useonInterrupt: "replay". That's in the JSDoc and the docs too, so switching a backend off replay is a visible break for modules that interrupt, not a silent one.interrupt()throws rather than getting the first one's answer.interrupt(),pending(),respond()and the pending call type are marked experimental in their JSDoc, like restart and replay.unsafeToRepeatstill applies to a call cut off after it got its answer.Testing
End-to-end in workerd: answering resumes the run; a restart while a call waits; two interrupts in one
Promise.all; a call in flight finishes and is journaled before release; waiting doesn't count toward the timeout; killing a waiting run; an answer that arrives before the run is released;onWaithearing about a wait once across a restart and resume; two calls in flight that both interrupt, answered together; andinterrupt()throwing in anonInterrupt: "fail"run. Unit tests for the journal's waiting states, the bridge'sunsafeToRepeatcheck and the one-interrupt limit, an answer starting the run's attempts again, plus a stub pass-through test. I disabled the quiesce wait, the timeout adjustment, the frame guard, the limit, the resume after release and theonWaitcall in turn, and each one failed its test.In production, on the personal account, I redeployed while a call waited. The old instance kept serving for about five minutes, then the new version took over with the call still waiting. Answering it resumed the run there, and every comment was posted once.
ENOTWAITINGCaveats
Promise.allcan be left for a later round. Batching those can come later without an API change.interrupt()runs again on resume.Follow-ups
needsApprovaltools inws:tools/ai-sdkon top ofinterrupt().getExec(), so a client can tell a waiting run from a working one withoutpending().