Skip to content

computer: Let host module calls wait for an answer - #251

Merged
scuffi merged 16 commits into
computer/durable-js-execfrom
computer/js-exec-interrupts
Oct 21, 2026
Merged

scuffi merged 16 commits into
computer/durable-js-execfrom
computer/js-exec-interrupts

Conversation

@scuffi

@scuffi scuffi commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

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.

modules: {
  "ws:github": {
    postComment: async ([issue, body], ctx) => {
      const { approved } = await ctx.interrupt({ kind: "approval", issue, body });
      if (!approved) throw new Error("The comment was rejected.");
      return github.comment(issue, body, { idempotencyKey: ctx.callId });
    },
  },
}

// Elsewhere, for example from a request handler:
for (const call of await workspace.runtime.pending()) {
  await workspace.runtime.respond(call.id, call.callId, { approved: true });
}

It ships in the same release as #238, and #238's API doesn't change.

Changes

ctx.interrupt(payload)
  └─ journal: call → waiting, with payload
  └─ bridge: hold new calls, let calls in flight finish and journal
  └─ backend: release the isolate. The run stays "running".

runtime.respond(id, callId, answer)
  └─ journal: call → answered (throws ENOTWAITING if it isn't waiting)
  └─ none left waiting → relaunch, replay to the call
       └─ function runs again with the same callId, interrupt() returns value
  • WorkspaceModuleCallContext.interrupt(), runtime.pending() and runtime.respond(). pending() and respond() are on workspace.stub().runtime too, for a Worker calling the object. Plain data both ways, so nothing to dispose.
  • An onWait(call) backend option, called with the fields pending() lists once a wait is saved, so an app can message whoever answers instead of polling. It's at most once per wait, so pending() stays the source of truth.
  • The journal gets waiting and answered states, with the payload and answer. Each waiting call records when it started waiting, which pending() 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.
  • Recovery leaves waiting runs alone, so a wait survives a deploy. Resuming after an answer doesn't count as an attempt, and starts the run's attempts again.
  • interrupt() throws in a run that isn't replayable, with a message that says to use onInterrupt: "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.
  • A call can interrupt once. The journal keeps one answer per call, so a second 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.
  • Two fixes found along the way. Output a released isolate writes on its way out is now dropped, as it isn't the run's. And unsafeToRepeat still 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; onWait hearing about a wait once across a restart and resume; two calls in flight that both interrupt, answered together; and interrupt() throwing in an onInterrupt: "fail" run. Unit tests for the journal's waiting states, the bridge's unsafeToRepeat check 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 the onWait call 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.

Scenario Result
Approve, reject, answer twice Code gets each answer, second answer throws ENOTWAITING
Crash while waiting (local) Still waiting on the new instance, attempt unchanged
Crash between calls (local) Resumes at attempt 2, no repeats
Redeploy while waiting (production) New version picks it up after ~5 min, completes

Caveats

  • A run is released once each call in flight has finished or is waiting too, and those waits are answered together. A call made after the first interrupt is held until the run resumes, so when a call interrupts straight away, the next call in its Promise.all can be left for a later round. Batching those can come later without an API change.
  • Every answer replays the run from the top, so a run that waits often replays often.
  • Anything a module function does before interrupt() runs again on resume.
  • This edits computer: Restart or replay interrupted isolate JavaScript runs #238's new tables in place rather than migrating them, so the two must ship in the same release.

Follow-ups

  • needsApproval tools in ws:tools/ai-sdk on top of interrupt().
  • A waiting status on getExec(), so a client can tell a waiting run from a working one without pending().

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-bot

changeset-bot Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ba58d1e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@cloudflare/computer Minor
@cloudflare/dofs Minor
@cloudflare/computer-rpc Minor
@cloudflare/computerd Minor

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

@pkg-pr-new

pkg-pr-new Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@cloudflare/computer@251

commit: ba58d1e

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
scuffi added this pull request to stack #253 October 20, 2026 10:34
@scuffi
scuffi marked this pull request as ready for review October 20, 2026 10:34
devin-ai-integration[bot]

This comment was marked as resolved.

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.
@scuffi
scuffi merged commit 5db8dd4 into main Oct 21, 2026
22 checks passed
@scuffi
scuffi deleted the computer/js-exec-interrupts branch October 21, 2026 15:40
@github-actions github-actions Bot mentioned this pull request Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant