Skip to content

feat: split a step into model-call, per-tool, and seal activities. - #7

Draft
moedash wants to merge 33 commits into
2026/08/opencode-temporalfrom
moe/l2-tool-activities
Draft

feat: split a step into model-call, per-tool, and seal activities.#7
moedash wants to merge 33 commits into
2026/08/opencode-temporalfrom
moe/l2-tool-activities

Conversation

@moedash

@moedash moedash commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Issue for this PR

Issues are disabled on this fork, so there is nothing to link. This is fork-internal work on the Temporal executor (packages/temporal), not a change aimed at upstream.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

It splits one step of a session turn into three activities instead of one, behind OPENCODE_TEMPORAL_STEPPED=1. Off by default.

Until now the smallest durable unit was a step: one model call plus every tool it asked for, in a single activity. Nothing could sit between the model asking for a tool and the tool running, so per-tool retry policies, per-tool timeouts, approval gates and budgets had nowhere to live.

runModelCall  ->  runToolCall (one per call, concurrent)  ->  sealStep

SessionRunner.runModelCall performs the attempt, records each call as Tool.Called, and hands the calls back instead of running them. runToolCall settles one call. sealStep takes the end snapshot, diffs it against the start, and publishes Step.Ended. The loop between them is workflow code.

The supervisor is untouched. It only ever called one runTurnStep, so the stepped mode supplies a different one and reuses wake, interrupt, idle self-termination and continue-as-new as they are. The mode rides the workflow input, so a session that rolls over keeps it.

Two things are load-bearing and were easy to get wrong:

One owner token per step, not per activity. The event log fences a publish behind the current owner, so a step's writers have to share one token. Only runModelCall claims; the other two publish under the token it returns. Minting per activity execution (correct when the activity is the whole step) would make them fence each other out.

A call that already started is not silently repeated. Whether a side effect may have happened is read off the log: runToolCall publishes Tool.Called before it runs the tool, so a call the log shows as running is one a dispatch was already inside. Then only a tool declaring idempotent runs again; anything else reaches the model as an unknown outcome. Re-running a git push that may already have landed is the worse failure.

The attempt number would answer the same question far less precisely: it counts every way a dispatch can die, including the ones that never reached the tool, so a call nobody had touched came back as an unknown outcome. Publishing the call at dispatch also puts the fence in front of the side effect, where it used to sit behind it.

Two smaller changes ride along because the work exposed them:

  • event.ts: a durable tail can now also re-read on a tick. The wake is published in-process, so a standalone worker's whole turn used to be invisible to a tail on the HTTP server. It is off unless a composition root asks for it, and only the durable executor's does: one process wakes its own subscribers, so a tick there would be a query per second per subscribed session for events that cannot exist. OPENCODE_EVENT_POLL_MS overrides either way.
  • .github/workflows/test-fork.yml: upstream's test workflow asks for Blacksmith runners this fork cannot schedule, so every run of it here is cancelled with no runner, on every branch including dev. Nothing in this fork had a CI signal.

Three review rounds found things the log can't reconstruct, fixed here.

A provider error used to leave needsContinuation behind, so the seal re-derived "keep going" from the tool parts and the supervisor called a provider that had just failed.

A declined permission needed fixing on both sides. The runner now raises it as an interrupt, and the workflow tells that failure apart from a tool that merely failed. A halt crosses the activity boundary as an ApplicationFailure, so isCancellation is false for it and allSettled used to swallow it. The step sealed and the agent kept going past the refusal.

A tool whose output could not be stored had already run. Failing the dispatch retried the side effect and the seal finally reported the call as interrupted, a reason the model cannot act on. The real reason reaches the model now, the way the whole-step path already reported it.

An interrupted turn left its tool call recorded as running until the next prompt, where a whole step closes the tools it opened on its way out. The dispatch closes its own call now, unless the cancellation means this attempt is being handed to another one, which has to find the call as it was left.

A declined permission crossed the boundary as a bare interrupt, so the boundary read every interrupt with nothing cancelling it as a refusal, including a runner that stopped itself for another reason. The refusal is named now, and the whole-step path, which still halts its local loop with an interrupt, says so where the classification happens.

A worktree was only rebuilt when it was missing, so a worker that ran an earlier step served files from that step. The store's newest capture is the target now. Two rules bound what that may touch: a host-local note says whether this tree is behind the store or holds a capture that never shipped, and only a tree built from packs is moved, so a developer's own checkout is logged and left alone. Two workers running tools of the SAME step on two hosts still cannot see each other's writes; affinity is what keeps a step's tools on one tree.

How did you verify your code works?

Live against a Temporal dev server and gpt-5-mini, plus unit and contract tests.

Live:

  • A one-tool turn recorded five activities: runModelCall, runToolCall, sealStep, then runModelCall, sealStep. The tool ran once.
  • Four read calls in one step became four concurrent runToolCall activities, started within 2ms of each other and overlapping, all four results durable, zero activity failures. A fenced write would have died and shown as a failed activity, so this is the shared owner token holding under real concurrency.
  • Crash mid-tool: echo >> counter.txt in one step and sleep 60 in the next, then the serve process killed with the sleep in flight. On a fresh worker the interrupted call came back as attempt 2, counter.txt still held one line, the model was told "The outcome of this tool call is unknown", ran the sleep itself and answered. Re-run after the dispatch evidence moved from the attempt number to the log, with the same result.
  • Interrupt: with sleep 90 in flight, POST /interrupt returned 204, the child died, the call was closed as "Tool execution interrupted" by the interrupt itself rather than by the next turn's entry check, the workflow stayed running, and the next prompt answered normally.
  • What the stop does not wait for is the tools. With a tool that never finishes, history recorded ActivityTaskCancelRequested and the workflow moved on in the same second, completing two seconds later with that activity still running. The stop costs one workflow task, not the slowest tool's cleanup.
  • Approvals: reading a *.env parks on the default agent's ask rule. While it waited, runModelCall was already completed and runToolCall was the only outstanding activity. Under the whole-step mode the model call sits in that same activity for the whole human wait.
  • Worktree rebuild: deleted a live session's entire working directory between two turns; the next turn's tool activity rebuilt it from snapshot packs and read the file back.
  • Split role=client serve plus a standalone worker: with the event.ts tick, a live subscriber now sees the worker's step.started, tool.called, tool.success and step.ended. Before it saw one event.

Tests:

  • packages/core and packages/temporal suites pass, bun typecheck clean across every package.
  • The SessionExecution conformance suite passes in both Temporal modes and in the local coordinator. Worth calling out: stepped mode failed two scenarios the first time it ran, on a provider turn that publishes no content at all, so no assistant message was ever minted and the seal left the turn open. That bug is fixed here and has its own unit test.
  • The suite gained the two scenarios this split actually changes: a turn that asks for a tool, runs it, and goes back to the model with the result, and a turn interrupted with a tool in flight, which must leave no call running. Everything else in it settles without a tool, so the per-call activity had nothing holding it to the whole-step behavior. The interrupt one earned its place: stepped mode failed it until the dispatch learned to close its own call.
  • The seal's "already closed" test checks the target message, so a retried seal can't publish a second Step.Ended. runToolCall does a point read instead of decoding the whole session. The durable tail keeps the wake as what ends it. The worktree queue key no longer depends on whether a path resolves locally, which differed between client and worker.
  • Cost measured rather than argued. The overlap a split gives up is min(stream tail after the first tool call, tool duration); against the live API that tail is 0-77ms across gpt-4o-mini, gpt-5-mini and gpt-5, and no model emitted any text after asking for its first tool.

Screenshots / recordings

Not a UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

L2 splits one step into a model activity plus one activity per tool call,
all appending to the same aggregate. The fence has to admit every writer
holding the token the model attempt claimed, and still reject the attempt
that one superseded.
The model call and the tools it asks for were one unit, so a durable
executor could not put a retry policy, an approval or a budget between
them. runModelCall performs the attempt, records each call as Tool.Called
and hands it back with the provider's own callID, leaving the step open
for whoever runs it.

The prologue and the continuation decision are extracted rather than
copied, so the whole-step path and the model-only path cannot drift.
runToolCall settles a single call from what the log already holds, so a
dispatcher can give each one its own retry policy, timeout and approval
gate instead of sharing the provider attempt's.

A duplicate dispatch sees the settled result and does nothing. A retry
whose side effect may already have run is reported to the model as an
unknown outcome unless the tool declares itself repeatable, which is the
rule the crash-resume path already follows.
The end snapshot and the file diff have to be taken after the tools have
run, which is no longer the process that called the model. sealStep does
that from the log: it closes any call the dispatcher never settled, then
publishes Step.Ended with the settlement the attempt handed over.

A repeat sees the step already closed and returns the same loop decision
without publishing again, so a retry cannot end the turn a step early.
The crash-resume path now shares the continuation tail rather than
keeping its own copy of it.
OPENCODE_TEMPORAL_STEPPED=1 runs each step as the provider attempt, one
activity per tool call, and a seal. The model-to-tools loop is workflow
code now, so a retry policy, a timeout or an approval can sit between the
model asking for a tool and the tool running. Each call also gets its own
bounds, rather than sharing one timeout with the attempt and every other
tool of the step.

The supervisor is untouched: wake, interrupt, idle and continue-as-new
only ever saw one runTurnStep, so the stepped mode supplies a different
one. The mode rides the workflow input so a rollover keeps it.

Only the model call claims the event log; the tool and seal activities
publish under the token it hands back, or a step's writers would fence
each other out.
Records the owner-token rule and the retry rule, since both are easy to
get wrong and neither is visible from the call sites, and states the cost
plainly: the model no longer overlaps with its own tools.
@github-actions

Copy link
Copy Markdown

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

Please edit this PR description to address the above within 2 hours, or it will be automatically closed.

If you believe this was flagged incorrectly, please let a maintainer know.

@github-actions

Copy link
Copy Markdown

Hey! Your PR title Split a step into model-call, per-tool, and seal activities. doesn't follow conventional commit format.

Please update it to start with one of:

  • feat: or feat(scope): new feature
  • fix: or fix(scope): bug fix
  • docs: or docs(scope): documentation changes
  • chore: or chore(scope): maintenance tasks
  • refactor: or refactor(scope): code refactoring
  • test: or test(scope): adding or updating tests

Where scope is the package name (e.g., app, desktop, opencode).

See CONTRIBUTING.md for details.

The overlap loss is min(stream tail after the first tool call, tool
duration), within about 10ms across a five-point sweep, and zero when the
tool call is the last thing in the stream. The extra hand-offs cost about
5ms each on loopback, so roughly 10ms per step, and that grows with the
distance between worker and namespace.

The bench dials a mock model and a sleeping tool so the number is the
overlap rather than provider variance. It is opt-in because it sleeps,
and it runs on the harness's live clock: under the TestClock both the
tool and the stream tail would wait forever.
@github-actions

Copy link
Copy Markdown

This pull request has been automatically closed because it was not updated to meet our contributing guidelines within the 2-hour window.

Feel free to open a new pull request that follows our guidelines.

The bench showed the overlap loss is min(tail, tool duration) but left
the tail itself dialled by hand. Against the live API it is 0 to 77ms
across gpt-4o-mini, gpt-5-mini and gpt-5, and no model emitted any text
after asking for its first tool. A single tool call is the end of the
stream, so there is nothing to overlap; a tail shows up only when several
tools are asked for at once.

Timed from the point the runner actually forks. Measuring from when a
call's id first appears rather than when its arguments finish overstated
the tail by two to five times.
Fan-out, interrupt and approvals are now verified live rather than
asserted: four concurrent tool activities in one step sharing the log
owner, an interrupt that stops the turn and leaves the session serving,
and an approval that holds only the tool waiting on it while the model
call is already done.

Compaction is not covered and says so. A regression test for it passed
with the bug deliberately put back, so it was proving nothing and is
gone rather than left as a false assurance.

Also records that live streaming does not cross a process boundary. That
is a property of running a standalone worker, not of stepped mode: the
wake is published in-process, so a tail cannot see another process's
commits. The durable log is still complete and replay returns everything.
@moedash moedash reopened this Aug 26, 2026
@moedash moedash changed the title Split a step into model-call, per-tool, and seal activities. feat: split a step into model-call, per-tool, and seal activities. Aug 26, 2026
@github-actions

Copy link
Copy Markdown

This pull request has been automatically closed because it was not updated to meet our contributing guidelines within the 2-hour window.

Feel free to open a new pull request that follows our guidelines.

Running the conformance suite against stepped mode for the first time
failed 2 of 4. A provider turn that publishes no content at all never
mints an assistant message, and the whole-step path only survives that
because it mints one inside Step.Ended. The seal runs in another process
with no publisher, so it searched the projection, found nothing, and left
the turn open. The attempt now mints the message and carries its id, the
same rule the tool call ids already followed.

Also adds a fork-local CI workflow. Upstream's test workflow asks for
Blacksmith runners this fork cannot schedule, so every run of it here has
been cancelled with no runner, on every branch including dev. Nothing in
this fork has ever had a CI signal. The new one is Linux-only and skips
the whole-monorepo test task, because @opencode-ai/app fails a locale
assertion on the untouched base branch and a job that is red on arrival
is worth nothing.
@github-actions

Copy link
Copy Markdown

This pull request has been automatically closed because it was not updated to meet our contributing guidelines within the 2-hour window.

Feel free to open a new pull request that follows our guidelines.

The conformance suite catches this, but it needs a dev server and an
opt-in variable, so nothing in CI would have noticed the fix being
reverted. Verified by putting the bug back: the test fails, and passes
again once the mint returns.
The wake is published in-process and subscribers register in that same
process's map, so a standalone worker's whole turn was invisible to a
tail on the HTTP server: a live subscriber saw one event, the only one
serve writes itself, and then silence.

The tail now also re-reads on a tick, default one second and settable to
0. In-process commits still wake it instantly, so nothing that already
worked got slower, and a tick with nothing new reads no rows.

The test builds a second service over the same database, which is what a
worker is to the server: its commits cannot reach this process's wake
map, so the tail only sees them because of the tick.
A compacting step re-enters the attempt through a transition defect, and
that recursion has to carry the defer flag or the step quietly runs its
tools inline. Verified by dropping the flag again: the test fails.

Getting compaction to fire at all took three tries, so the setup is
commented. The epoch has to be created before the history is seeded (it
records the sequence it was made at and the runner reads only past it),
and the seeded turn has to sit in a band: over the request headroom so
the attempt compacts, under context minus the summary output so
compaction does not bail on its own guard.
Deleting a live session's whole working directory between two turns and
watching the next turn's tool activity rebuild it from snapshot packs is
the cross-host mechanism actually working, rather than asserted. Affinity
stays an unimplemented optimization on top of it.

The zombie window was described as a mitigation rather than a guarantee.
That understated it: the settled check really is a read then a write, but
a retry refuses to run a non-idempotent tool at all, so the case that
would matter cannot happen. What remains is a race over which truthful
outcome the model is told.
@moedash moedash reopened this Aug 26, 2026
moedash added 18 commits August 26, 2026 16:05
…y is.

The queue name is derived from the session's directory and only workers
serving that directory poll it, so a step does not pay to rebuild a tree
a worker already holds. Keyed on location.directory rather than the
project root, because that is the tree that has to be present, and
resolved through realpath so two spellings of one tree cannot put the
client and the worker on separate queues.

Off by default, because it gives up the reconstruction fallback: with it
on, a session whose tree has no worker waits instead of being served
elsewhere. Verified all three ways, including that the work waits and
then drains when the right worker comes back.
A review caught that stepped mode drops two things the log can't
reconstruct. A provider error left `needsContinuation` behind, so the
seal re-derived "keep going" from tool parts and the supervisor called a
provider that had just failed, up to the step ceiling. And a declined
permission came out of `runToolCall` as an ordinary failure, which the
dispatcher swallows, so the agent carried on past a refusal.

Both now travel with the result, the same rule the assistant message id
already followed. The seal's "already closed" test also checks the target
message rather than any in-flight one, so a retried seal can't publish a
second `Step.Ended`.

Other fixes from the same review: the durable tail keeps the wake as what
ends it, `runToolCall` does a point read instead of decoding the whole
session, the emit is uninterruptible so an interrupt can't drop a result
for a tool that ran, a deleted session is a no-op rather than a run
error, and the worktree queue key no longer depends on whether the path
resolves locally, which differed between client and worker.
The runner raised a decline as an interrupt, but the dispatcher only
rethrew on cancellation. A halt crosses the boundary as an
ApplicationFailure, so isCancellation is false for it, allSettled
swallowed it and the step sealed anyway. The agent kept going past a
refusal, and resume resolved instead of surfacing the tagged error.

The durable tail's termination is now tested. It needs the layer in its
own scope and the consumer forked into an outer one, or closing the test
scope kills the consumer first and a hung tail looks like a passing one.

Also scopes the tool point read to its session, and stops the README
claiming three unit-tested behaviours were verified live.
The bug was a mismatch between the failure `boundary.ts` raises and what
the dispatcher recognises, so a test that injects its own predicate can't
catch it coming back. The positive case now runs the real boundary and
feeds the result to the real predicate. Change either side and it fails.
Comments and prose reflowed, code wrapped by hand in the shared files so
the review fixes stay readable in the diff. Running prettier at 100 over
`llm.ts` would have moved 231 lines that have nothing to do with this
work. Seven long lines are left, all string literals where a break would
hurt more than help.
A second review found the queue keyed on the session's directory while
`worktrees.ensure` rebuilds the project tree. One tree split into a queue
per subfolder, so a session started from a subdirectory waited on a queue
nobody polls. Keys on the project worktree now.

`step.ended` was the one event with no projector guard, and a step's
writers share an owner token, so a late seal attempt could overwrite the
end snapshot and file diff with a different instant.

Also drops `toolPartOf`, dead since the point read, tightens a type guard
that asserted an assistant message from an id match alone, and fixes the
README where it described `realpath` resolution the code doesn't do.
A tool whose output could not be stored had already run, so retrying repeated the side effect and the seal finally reported the call as interrupted. The whole-step path reports the real reason and keeps going.
The attempt number counts every way a dispatch can die, including the ones that never reached the tool, so a call nobody had touched was reported to the model as an unknown outcome. Publishing Tool.Called at dispatch makes a running call mean exactly that, and a fenced dispatch now dies before the tool runs rather than after.
A tree was only rebuilt when it was missing, so a worker that ran an earlier step served files from that step. The store's newest capture is the target now, and a host-local note says whether this tree is behind it or holds work nothing else has.
The contract only covered turns with no tools, so the piece the split actually changes, one activity per call instead of one for the step, had no scenario in the suite that both modes have to pass.
A full-width turbo run had several tsgo processes killed for memory on a hosted runner, which reads as a code failure.
The tick was on for every deployment, so a single process paid one query per second per subscribed session for events that cannot exist. The durable executor's composition root asks for it now, and OPENCODE_EVENT_POLL_MS overrides either way.
The boundary read any interrupt with no cancellation as the user declining, so a runner that stopped itself for another reason reported a decision nobody made. A dispatch raises the refusal under its own type now, and the whole-step path states that its interrupt means the same thing.
A whole step closes the tools it opened on its way out; a call that is its own activity had nobody to do it, so an interrupted turn left the call recorded as running until the next prompt. A cancellation that hands the call to another attempt still leaves it alone.
A dispatch that decided not to run a tool, or lost its result, read as an ordinary success everywhere an operator looks. The outcome each one already returns says which call it was.
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