Skip to content

feat(temporal): Made a session outlive the client that started it. - #8

Draft
moedash wants to merge 6 commits into
moe/l2-tool-activitiesfrom
moe/l3-detached-sessions
Draft

feat(temporal): Made a session outlive the client that started it.#8
moedash wants to merge 6 commits into
moe/l2-tool-activitiesfrom
moe/l3-detached-sessions

Conversation

@moedash

@moedash moedash commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

This PR makes a coding session belong to the deployment instead of to whoever started it.

The durable executor already had every piece needed for this, which is the interesting part. A session is a workflow rather than a process, the running set comes from Temporal visibility, the store is shared, and a live tail re-reads so a subscriber sees work another process is doing. Put together, that means a session can outlive its client. Nothing said so, and nothing on the command line could use it.

So this is a client surface, not new machinery. It adds no dependency on Temporal: the commands are plain HTTP against any serve in the deployment, because the serves are interchangeable.

What is new

opencode session start "<prompt>" hands a prompt over and returns the session id. It holds no terminal, so a turn can be started by something that is not a person at a keyboard.

opencode session running lists what the deployment is executing right now. It reads the executor's own answer, so it survives a restart of whichever process happens to serve the call.

opencode session watch <id> follows a session from a machine that has never seen it, and stops when the turn stops. It ends on the model's own finish reason. The running-session set cannot answer that question, because a session stays in it while its supervisor waits out the idle timeout with nothing left to do.

All three take --attach <url> (or $OPENCODE_SERVER). For an interactive terminal rather than a follower, opencode attach <url> --session <id> already existed.

What is not here

A turn started from a schedule or a webhook still needs its own entry point; session start is a command, so something has to run it. And the deployment is still a set of environment variables rather than a supported mode, so defaults, migration-on-deploy and credential distribution remain the operator's problem. Both are called out in the README.

Issue for this PR

Issues are disabled on this fork, so there is no number to link. This continues the durable-executor work in #2 and #7.

Type of change

  • New feature
  • Bug fix
  • Breaking change
  • Documentation

What does this PR do?

Adds session start, session running and session watch under the existing session command, so the detached lifecycle the durable executor already supports can be driven from a command line. Adds packages/temporal/scripts/detached-session-check.sh, which proves the claim against real processes. Documents both in the temporal README, along with the deployment shape and what is still missing.

The commands are thin HTTP clients on purpose. In a durable deployment any serve reads the shared store and signals the same workflows, so a client needs an endpoint and a session id, never a particular host.

One fix fell out of writing the check: machine-readable output was going to stderr with everything else, so --json was not pipeable. It goes to stdout now.

How did you verify your code works?

detached-session-check.sh runs the whole story against real processes, with a Temporal dev server, one standalone worker, two serve processes and one shared store. Serve A starts a turn and is killed with a tool still running. The turn finishes on the worker. Serve B, which never saw the session, reports it running and replays the transcript including the work done while no client existed. Then session start returns without waiting, session running lists it, and session watch follows it live and exits when the turn ends. All assertions pass from a clean run.

The assertions were mutation-checked rather than trusted. Giving serve B its own OPENCODE_DB makes exactly the cross-process ones fail (active returns {}, the replay is empty, the follower hangs) while the serve-A-and-worker ones still pass, which is what shows the shared store is load-bearing and that the check is testing it. The watch exit condition was found the same way: an earlier version asked the running-session set instead of reading the finish reason, and it hung, because a session stays running while its supervisor idles.

Typecheck is clean for the changed files. Pre-existing errors in packages/tui/src/component/dialog-move-session.tsx are untouched by this branch.

Checklist

  • I have performed a self-review of my code
  • I have added a check that proves the change works
  • I have updated the documentation
  • My changes generate no new warnings

Across two machines (added after the first pass)

packages/temporal/scripts/cross-host-check.sh runs the claim against containers: each worker has its own filesystem and hostname, and the store is a real libSQL server. A session writes a file on worker A, worker A's host is killed, and worker B, whose project volume is empty, continues the same session and reads that file back.

That found a bug a single host cannot show, and it is the reason this PR now touches packages/core.

WorktreeMaterializer.ensure treated any existing directory as somebody's working copy. A fresh host has no tip note, so behind returned false and the tree was never built. The tools then ran in an empty directory and the model was told a wrong answer, which is worse than a failure. Turn 1 wrote /project/note.txt and read it back; turn 2 of the same session on the other host got cat: /project/note.txt: No such file or directory, with the packs sitting in the store the whole time.

On one host the case never appears. Worker B either already has the project, or has no directory at all, and an absent directory materializes fine, which is exactly what the existing unit test covers. A mounted directory that exists and is empty is the ordinary shape of a machine that has never seen the session.

The fix is to treat an existing but empty directory as absent, which keeps the protection for a real checkout: an empty directory has no work to lose. packages/core/test/worktree-materialize.test.ts gains that case so CI catches it rather than only the container check.

Verified. With the fix, the container check passes end to end and the worker logs materialized worktree from snapshot packs. Reverting the fix fails it with the original symptom. The unit test goes red on the revert and green with it. bun test test/worktree-materialize.test.ts is 4/4.

Two things I got wrong first, both caught by mutation rather than by reading: the first version of the fix changed only the outer guard, and the re-check inside the lock still bailed on an empty directory, so nothing materialized. And the check's own completion poll used history?limit=400, which the endpoint rejects at 100, so a rejected request looked exactly like a turn that never finished, and its transcript assertion matched turn 1's output for turn 2's result. Both are fixed; the poll now counts step endings rather than matching one, and the outcome is read off worker B's own disk rather than the shared transcript.

Still open. A turn started from a schedule or a webhook needs an entry point of its own. The deployment is a set of environment variables rather than a supported mode. And this is one libSQL server, so it shows a shared store over a network rather than one that survives losing a node.

A durable session outlives the process that started it, but nothing on the
command line could start one that way, ask what is still running, or follow one
from a machine that never had it. These are thin HTTP clients because any serve
in the deployment can answer for any session.
Kills the serve that started a turn while a tool is still running, then asks a
second serve that never saw the session to report it and replay it. The claim
only shows up across processes, so it needs processes.
A fresh host has no tip note, so the check that protects somebody's working
copy also refused to build a tree that was never there. The path being present
is not the same as the project being present, and a mounted empty directory is
the ordinary shape of a machine that has never seen this session.
Each worker is a container with its own filesystem, so a session that moves has
to bring its worktree with it out of the shared store. That is the half a single
host cannot exercise: there the tree is already on the disk the other process
reads.
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