Skip to content

feat(core): add a temporal durable-execution mode - #2

Draft
moetemp wants to merge 113 commits into
2026/08/opencode-temporal-tuifrom
2026/08/opencode-temporal
Draft

moetemp wants to merge 113 commits into
2026/08/opencode-temporal-tuifrom
2026/08/opencode-temporal

Conversation

@moetemp

@moetemp moetemp commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Issue for this PR

Closes anomalyco#42678

Status: WIP. Review continues, but the correctness issues independent review (Codex, several rounds) found are folded in as of 82e1b69ca5: local mode runs on the proven SessionRunCoordinator; the Temporal supervisor was redesigned (interrupt cancels only the turn and keeps serving, resume joins the in-flight drain, the timed-condition scope leak is gone); the event-log owner token is unique per activity execution; interrupt delivery failures surface as defects. A deterministic harness (fake-runtime plus @temporalio/testing) and the two-driver contract suite back it. Fine to share internally as WIP; not ready for external delivery while review is open.

Type of change

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

What does this PR do?

This PR adds a Temporal durable-execution layer to opencode (a fork of anomalyco/opencode dev at 4643e65).

It makes an opencode session a durable Temporal workflow: a coding session survives worker loss, runs detached, and resumes on any worker from a shared store. The shape is a plugin: core carries the SessionExecution seam, the built-in local executor (the proven SessionRunCoordinator), the executor-agnostic toolkit (step runner, event fencing, error codec, worktree materializer), and a conformance suite that defines the seam executably; everything Temporal lives in @opencode-ai/temporal, one dependency the server wires in, selected by OPENCODE_SESSION_EXECUTION=temporal with one activity per step. On top of that: a shared libSQL event store with verified atomic writes, standalone workers (OPENCODE_TEMPORAL_ROLE), cross-process migrations, log-based crash resume with per-tool idempotency, durable permission asks replyable from any process, worktree materialization from snapshot packs, and turn loop bounds. The serve-wrapper increment lives in its own PR.

The base branch 2026/08/opencode-temporal-tui carries the TUI bridge (upstream anomalyco#42658) on the upstream commit this work sits on, so the diff shows only the Temporal work. Start at packages/temporal/README.md for run recipes, verified claims, and the honest limits (the question tool, remote streaming throughput).

How did you verify your code works?

Typecheck passes on core, server, and cli. The driver-contract suite passes against both drivers (the Temporal run is opt-in against a dev server); the worktree-materializer and runner suites pass. The runnable reproductions (crash test, failover, worker smoke, demo) live in the stacked #4.

Screenshots / recordings

Not a UI change.

Checklist

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

Since this description was written

The branch now sits on moe/event-tick (#13) and moe/fork-ci (#12), so the live event tick and the fork's CI workflow are their own PRs below this one, and this diff shows only the executor. The supervisor gained a step ceiling and rolls a run over on the server's advice (continueAsNewSuggested), because a stepped turn is thousands of history events and a drain count cannot see that. The event tick is wired in this package's composition root (EventV2.pollingNode), which is where a standalone worker's events have to reach a serve process's tail.

Everything above this PR in the stack adds one thing each: #14 brings a stale worktree forward, #7 splits a step into three activities, #15 to #18 route and pin work and pick a deployment, #8 and #19 give a session a client-free start and a follower, #20 to #22 add the container check, the worktree quarantine and the turn budget.

@CLAassistant

CLAassistant commented Aug 15, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@github-actions

Copy link
Copy Markdown

Hey! Your PR title Added a Temporal durable-execution layer to opencode. 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.

@moetemp moetemp changed the title Added a Temporal durable-execution layer to opencode. feat(core): add a temporal durable-execution mode Aug 15, 2026
@github-actions

Copy link
Copy Markdown

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@moetemp
moetemp marked this pull request as draft August 15, 2026 08:00
@moetemp
moetemp force-pushed the 2026/08/opencode-temporal branch from 15eef17 to f85e59a Compare August 17, 2026 08:30
@moetemp
moetemp force-pushed the 2026/08/opencode-temporal-tui branch from 809d705 to b2a9f42 Compare September 7, 2026 23:27
@moetemp
moetemp force-pushed the 2026/08/opencode-temporal branch from 289f93b to 3434d80 Compare September 7, 2026 23:27
@moetemp
moetemp force-pushed the 2026/08/opencode-temporal-tui branch from b2a9f42 to 63a6949 Compare September 14, 2026 18:11
@moetemp
moetemp force-pushed the 2026/08/opencode-temporal branch 2 times, most recently from abca188 to 542f4c2 Compare September 14, 2026 22:34
@moetemp
moetemp force-pushed the 2026/08/opencode-temporal-tui branch from 496fafe to 7459656 Compare September 15, 2026 00:44
@moetemp
moetemp force-pushed the 2026/08/opencode-temporal branch from 542f4c2 to 1e2f3e7 Compare September 15, 2026 00:44
Phase 1 of a drop-in durability layer: an opencode session becomes a durable Temporal
workflow that owns the conversation and prompt queue and drives each turn against the
shipping opencode server over HTTP. The turn runs server-side (prompt_async), so it
survives a worker crash; recovery re-attaches and is idempotent on the user-message
count. A crash demo kills the worker mid-turn and the session still completes: the
runTurn activity re-drives on a fresh worker (attempt 2) with a single prompt sent.

opencode's loop, tools, model, storage, and API are untouched. Phase 2 (a durable
SessionExecution on the v2 engine) is scoped in the package README.
Phase 2: make each v2 session a durable Temporal workflow. SessionExecutionTemporal
implements the substitutable SessionExecution service (wake -> signalWithStart, resume
-> forced signalWithStart, interrupt -> cancel signal), with the local coordinator's
drain (SessionRunner.run for the whole turn) moved into a runContinuation activity that
runs against the durable event log. The Temporal client and an embedded worker are
co-hosted inside the server process (both run under bun). It is opt-in via
OPENCODE_SESSION_EXECUTION=temporal; the one-line swap is at routes.ts, and the loop,
tools, model, storage, and HTTP API are untouched.

Verified: with it enabled, prompting a v2 session drove a full turn to completion
(step.ended) and Temporal recorded a completed per-session workflow (session-exec-<id>).
Added the engine-level crash-recovery harness (kill the whole server mid-turn, restart,
the turn continues from the event log) and updated the README: Phase 2 is built and
verified, with the run recipe, the code layout, and the known limits (resume does not
yet return the typed RunError; active is process-local; startup needs Temporal reachable).
resume previously drove a forced run fire-and-forget and returned void, so a run
failure was swallowed. It now drives the run through a Temporal Update-with-Start and
awaits the result: a genuine run error is thrown non-retryable by the activity (so only
crashes/timeouts still retry), rejects the update, and the layer maps it to a RunError
(carried as ContextSnapshotDecodeError with the original text in details). The per-session
workflow is now long-lived with an idle timeout so update-with-start can always reach it.

Verified (scripts/resume-check.ts): resume resolves on a healthy session and rejects on a
failing one. Full typecheck stays green (30/30).
active listed a process-local set of sessions this process had started, so it was
empty after a restart. It now queries Temporal for the open per-session workflows
(WorkflowType 'sessionExecution', Running) and maps their ids back to session ids,
so it reflects durable state and survives a restart. The process-local set is gone.
resume previously surfaced run failures as a generic ContextSnapshotDecodeError carrier.
The activity now encodes the error through a Schema.Union of every RunError member
(run-error-codec.ts) into the non-retryable failure details, and the layer walks the
failure chain and decodes it back into the exact tagged instance (e.g. LLMError with its
reason), falling back to the carrier only if decoding fails.

Verified: a unit round-trip reconstructs an LLMError faithfully, and scripts/resume-check.ts
shows a failing resume rejects with the encoded _tag = LLM.Error reaching the caller.
The v2 engine event-sources to SQLite, so a session was resumable only on the host with
the file. Point every worker at one shared store and any worker resumes: a new libSQL
SqlClient backend (sqlite.libsql.ts, over @libsql/client) selected by OPENCODE_DB_URL
gives a networked SQLite (sqld/Turso) across hosts, and OPENCODE_DB already shares a file
same-host. Same SQLite dialect, so the schema and all migrations are unchanged; the
local-only PRAGMAs are skipped for the shared backend.

Verified: a full turn runs against a libSQL file: store (migrations + event log), and
scripts/shared-store-failover.sh shows turn 1 on worker A, A killed, then a fresh worker
B recalls turn 1's code word (two distinct worker identities) purely from the shared
store. Remote-URL transaction atomicity is documented as the remaining networked-writer
step. Full typecheck 30/30.
jssmith and others added 28 commits September 14, 2026 18:01
…ot a quirk.

The 0-activities symptom was the condition-timeout scope leak, not the delivery
mode. A default (steer) prompt drives a turn to completion once the leak is fixed.
The supervisor redesign reverted the OPENCODE_SESSION_IDLE_TIMEOUT
forwarding and removed the file the contract lib typed against. The
override rides as a third workflow argument now (continue-as-new keeps
it), the lib types against the Temporal node, and the interrupt
scenario expects idle retirement since an interrupted supervisor keeps
serving.
The suite is the executable definition of the executor seam, so it
lives where any driver package can import it; the local run is now a
one-line conformance call. The old test-lib path re-exports the moved
harness so the rest of the test tree keeps its import.
Core now carries only the executor seam, the built-in local executor,
and the executor-agnostic toolkit (step runner, event fencing, error
codec, worktree materializer); everything Temporal, including the
per-step activity drain, lives in @opencode-ai/temporal, which the
server wires in as one dependency. The step contract types moved into
the drain where they belonged.
protocol.ts is the one source of truth for the workflow type, signal
and update names, the workflow-id scheme, and the connection defaults;
config.ts is a service read at layer build, so an embedder or a test
injects settings instead of racing module-load env reads.
Positional arguments turn signature evolution into a breaking change
mid-history; a single record lets new settings ride along, and a
continue-as-new run carries it forward unchanged.
workflow-core became supervisor.ts when local mode stopped driving it;
SupervisorRuntime and makeSupervisor say what the interface and the
factory actually build.
The package reads as what it is now: one executor behind core's seam,
held to the conformance suite, with a porting recipe for engines that
want the same shape.
The suite mocks the LLM client, so the model descriptor's endpoint and
token are never used for I/O; a real-looking OpenAI URL invited the
wrong conclusion. An RFC 2606 .invalid host cannot be mistaken for a
live dependency and cannot resolve if the mock is ever removed.
The resume scenarios left a durable executor's session running forever:
the test's task queue dies with the process, so nothing ever processes
the idle timer's task. Each scenario now waits out the retirement while
its worker still exists.
With OPENCODE_TEMPORAL_ROLE=client the turn runs in a separate worker
process, and its session.next.* events live on that process's bus; the
TUI only observes its own daemon's admission. A reply therefore
rendered one prompt late, when the next admission triggered a re-read.
A followed session now re-reads until its latest assistant message
settles.
The read model can show an assistant's completed flag before the
settled row's rewritten content, so a follow that trusted one read
stopped with the reply's tail missing. It now stops only after two
identical settled reads, and it emits part removals when the settled
rewrite coalesces streamed parts under new ids, so no stale text
lingers.
A question raised inside an activity on one worker was parked on an
in-memory deferred, so no other process could list or answer it and the
step stayed blocked. Deterministic ids let a re-driven attempt adopt the
same row or return answers that already landed, which also makes the
question tool safe to mark idempotent for crash-resume.
The sync store only knew the v1 question event names, so the v2 engine's
question.v2.* events populated nothing and the dialog never opened. The
reply path also called a v1 route that 404s on the v2 daemon.

In temporal mode the question tool runs inside a worker activity, so its
asked event never reaches the daemon's stream. The session projector now
polls the durable question list during a follow and projects the ask and
answer lifecycle, so a worker-raised question opens the dialog and the
reply routes back to unblock the turn.
The rebase kept dev's lockfile wherever it conflicted, which left this branch's own dependencies out of it.
Every file under packages/core/src is a public entry point, and two of these imported bun:test. The codec has no reader outside the Temporal package.
The install tool re-sorted the whole file for one added dependency, which turned a one-line change into a twenty-line diff.
Nothing branched on ran: the supervisor reads continue and nothing else. The continuation tail was the same seven lines in the crash-resume path and the step path.
… comments.

Three files stood up the same environment, worker and workflow by hand. The comments they and workflow.ts carried described which fix a test caught rather than what it asserts.
Three files carried the same thirteen-node graph, the same fixtures and the same session seed, and each drifted from the others by a node or two for no reason.
…eates it.

Both migrations are new here, so no store has applied one without the other.
… notes.

The README narrated verification runs, a file layout that had moved and another branch's design. The two docs files were notes to self from review rounds and named files that no longer exist.
The branch was wrapped by hand at a narrower width than the repository formats at, so a maintainer's formatter would have rewritten every file it touched.
… read the durable tail on a tick.

A turn that never stops stepping is a bug only the supervisor can see, since every step is its own activity and each one succeeds. The drain count alone misses a long stepped turn's history, so the server's own suggestion is read too. The serve process now subscribes with the polling event layer, because standalone workers append to a log its wake cannot see.
@moetemp
moetemp force-pushed the 2026/08/opencode-temporal-tui branch from 7459656 to b43650c Compare September 15, 2026 01:01
@moetemp
moetemp force-pushed the 2026/08/opencode-temporal branch from 1e2f3e7 to d04430c Compare September 15, 2026 01:01
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.

3 participants