feat(task-board): make claude-code threads interactive - #6376
Merged
Conversation
added 3 commits
August 20, 2026 18:38
A sandbox-hosted task run was a dead end: the thread was stamped `read_only`, the messages POST 409'd `claude-code` rows, and the composer said so. You could watch the agent work and never reply to it. Three things had to change together. **The thread accepts follow-ups.** `enqueue-task-run` stops marking sandbox runs read-only, and the messages POST accepts a `claude-code + agent-sandbox` pin instead of normalizing it through the Decopilot-only path that rejected it. The `read_only` flag and its 409 stay for a thread that really is closed; nothing sets it now. **The conversation outlives the pod.** Claude Code already resumed its SDK session between turns, but the transcript lives in the pod and a pod is reclaimed ~15 minutes after the last viewer leaves — so a follow-up sent an hour later met an empty session. The daemon now copies the session (transcript tree + the id pointing at it) onto the org volume when a run ends, and back before the next one starts. Copied, never mounted. Claude Code reads the transcript on its startup path and org-fs is a network filesystem whose reads can block indefinitely — the hazard `readableWithin` already guards skills against, but on a file the run cannot skip. A symlink would turn a wedged backend into "every run produces nothing"; a copy turns it into a slow dispatch. The id is written only after the transcript lands, so the existing "no file means no session" invariant survives. **Runs wait for what they need.** `WaitReady` blocks a dispatch (bounded, 90s) until the home volume is mounted and the org's skills are linked, where every other path in that file fails open. The difference is the failure mode: a missing output link is a tool error somebody sees, a missing skills link is a confident wrong answer nobody can distinguish from a bad one. The harness's MCP preflight gets the same treatment — ~4 minutes instead of 30 seconds, so a run dispatched into a rolling deploy waits it out rather than failing. Batch threads also needed an end-of-turn signal: with no per-thread stream there is no `finish` chunk, so the composer stayed locked at "submitted" after a reply. The org-level `/watch` terminal status now releases it.
…ispatch A sandbox whose sidecar never mounts `home` — a deployment configuring only outputs, a sidecar that died — would stall every dispatch for the full 90s, turning a missing optional volume into a permanent per-run tax. The first dispatch still waits; later ones re-check (so a late mount is still picked up) without sleeping for one.
…er-desktop pin Routing every `claude-code` row through the sandbox assert changed what the messages POST tells a native desktop chat: "pinned to an unsupported desktop runtime" instead of "can only run in the Studio desktop app". Still a 409, but the vaguer of the two — and that row IS the desktop agent, so its sandbox kind is not what is wrong with it. The carve-out goes in `assertHostedSandboxRuntime`, which is the gate all three routes (messages POST, queue GET, /stream) reach, rather than in one caller — the row means the same thing wherever it is read. Caught by the existing e2e (`rejects persisted native and unknown harness rows`), which asserts the string over the wire.
pedrofrxncx
force-pushed
the
claude/interactive-claude-threads
branch
from
August 21, 2026 00:23
4515d43 to
a04faee
Compare
added 7 commits
August 20, 2026 21:33
The new follow-up test asserted 202, which needs a model credential the default e2e org does not have — it got 400 "No model available for tier smart". The suite that does assert 202 is skipped without OPENROUTER_API_KEY for exactly this reason. What this test owns is narrower and has no such dependency: the request is no longer refused for WHAT THE THREAD IS. Assert that (not 409, and no runtime-refusal string in the body) plus pins-survive, and leave dispatch to the suites built for it.
Three ways the interactive-thread path could lose a run rather than a session.
Session I/O had no deadline. `sessions.go` copies instead of symlinking
precisely because an org-fs read can block forever, but it copied with the
unbounded `copyTree` and read the id with a bare `os.ReadFile` — so a wedged
backend parked the dispatch before its first byte instead of degrading to a
slow one. Measured in a live pod: 80s in, no frames, no `done`, nothing. Both
directions now run under a 30s budget and abandon the flight, leaking the
blocked goroutine where it costs a session rather than the run — the bargain
`readableWithin` already makes next door.
Not behind a lock. Serializing the two directions was the first attempt and it
was strictly worse: an abandoned flight never releases the lock, so one wedged
read skipped every session for the life of the pod. Caught in the same pod,
where a healthy saved session stopped restoring after an unrelated hang.
Overlap is made harmless instead — restore only fills gaps, and each save
stages into its own directory.
The readiness wait was silence on the wire. `BeforeRun` waits up to 90s for the
home volume plus a session copy, and it ran before the response was opened,
while Studio calls a pod gone after 90s without a byte
(`DAEMON_SILENCE_TIMEOUT_MS`). A sandbox whose volume was late lost its first
turn to a "the pod died" verdict and had the turn continued elsewhere — on a
pod that was fine. Measured 96s to first byte; the headers and the keepalive
now open before the wait, so it ticks every 15s throughout.
An id was restored on a copy that copied nothing. `copyTree` returns nil for a
tree that lists empty, which is what a backend answering a readdir it cannot
serve looks like. The id then names a session the SDK never had, and that FAILS
the turn ("No conversation found with session ID") — worse than the fresh start
it was standing in for. Verified by the transcript's own presence now.
Also: gate the readiness poll on the mount, so 180 polls cannot each spend
`skillReadBudget` on the wedged mount they are waiting for; and drop the claim
that a follow-up "rebuilds the conversation from history" — the dispatch wire
carries one `userMessage` and no history, which is exactly why the home volume
is a requirement and not an optimization.
The retry budget this PR widens (5 attempts → 8) could not be spent: a run whose preflight found Studio unreachable crashed on its THIRD attempt with `Session ID … is already in use`, which is the failure a user sees instead of the deploy-wait the budget exists for. The session id is held by the `claude` child process, and abandoning the iteration does not end it. So attempt 2 reused an id attempt 1 still held and was refused; the fork-once recovery absorbed that one; attempt 3 was refused again with nothing left to spend, and the whole turn failed. More attempts made it likelier, not safer. So end the session on the way out of a failed preflight, and take a fresh id per retry whenever there is no transcript to resume — a preflight that never reached the model persisted no session, so nothing is lost. A resuming attempt keeps its id, because the transcript is the point, and still has the fork to fall back on. Verified against the real binary in a pod, MCP pointed at a dead endpoint: attempts walk 16s → 32s → 64s with no `already in use`, where the same scenario crashed at attempt 3 before. Not unit-tested: it takes a live `claude` child holding a session, which is e2e by definition (see TESTING.md).
…pped The gate that let a follow-up onto a sandbox-hosted `claude-code` thread opened the door to the wrong room: `validate()` returns a fixed `harnessId: "decopilot"`, and nothing downstream re-reads the thread's pin, so the reply was answered by the in-process Decopilot loop — no pod, no checkout, no resumed session. The dispatch input now carries the row's pin. With the turn startable from the composer, it has to be stoppable there too: cancel/flip/queue-cancel accept a sandbox-hosted run, which reaches the agent through the same abort (the daemon spawns the harness on the dispatch request's context, so the disconnect ends it). Also, in the machinery underneath: - the keepalive now covers `AfterRun`. Studio reads the dispatch body to EOF under the same silence timeout, and settling the workspace — a skill tree plus a session transcript over org-fs — had become a long quiet window after the terminal frame, i.e. a live pod declared dead and its turn continued elsewhere. - `WaitHomeReady` rate-limits the repoint it polls. Until the skills link, each one runs the mount's read probe under `l.mu` and leaks a goroutine when the backend does not answer; on the poll interval that is the self-inflicted outage the wait exists to avoid. - a superseded session save no longer swaps. `withinSessionBudget` does not serialize (a leaked flight would hold any lock forever), so an abandoned save could wake after a later one finished and delete the newer transcript behind a live id. It claims a generation before copying and declines if it is no longer the newest — and re-checks its own staging dir, which the age-based prune may have swept. - the session pointer is spelled the same on both sides. The harness sanitizes the thread id and the daemon did not, so any id outside `[A-Za-z0-9_-]` made persistence a silent no-op in both directions. - a session id that does not resolve here starts fresh instead of failing the run. Verifying the transcript's presence cannot prove the SDK will resolve it (the cwd slug is its own business), so the harness treats "no conversation found" as a reason to run the turn, once, without resume. - the batch composer unlocks after the reply, not before it, and not on the previous turn's terminal status — which would have ended the new turn on screen while it ran.
decocms Bot
pushed a commit
that referenced
this pull request
Aug 21, 2026
PR: #6376 feat(task-board): make claude-code threads interactive Bump type: minor - decocms (apps/api/package.json): 4.259.3 -> 4.260.0 - @decocms/native (apps/native/package.json): 4.259.3 -> 4.260.0 - @decocms/e2e (packages/e2e/package.json): 1.46.1 -> 1.47.0 - @decocms/harness-runner (packages/harness-runner/package.json): 0.4.0 -> 0.5.0 - @decocms/sandbox (packages/sandbox/package.json): 1.55.20 -> 1.56.0 - deploy/helm/sandbox-env (chart 0.16.22) (deploy/helm/sandbox-env/values.yaml deploy/helm/sandbox-env/Chart.yaml): image.tag/appVersion -> 1.56.0 Deploy-Scope: both
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.
Task-board runs dispatch a
claude-codeharness into a sandbox pod. You could watch it work and never reply to it: the thread was stampedread_only, the messages POST 409'd the pin, and the composer said so.This makes those threads interactive, including after the pod is gone.
What was in the way
enqueue-task-run.tsread_only: trueon every sandbox-hosted runroutes.tsvalidate + claimnormalizeHostedSandboxProviderKindis Decopilot-only, so it 409'd the pininput.tsxThe thread accepts follow-ups
read_onlyis no longer set. The flag and its 409 stay for a thread that really is closed — nothing sets it now, and rows stamped before this still honour it.The messages POST gets
normalizeHostedRuntimePin, which acceptsclaude-code + agent-sandboxand passes its kind through untouched. It does not widenassertPersistedHostedRuntime(cancel/flip/queue-cancel): those mutate a run this process owns, and a sandbox-hosted run is not one.claude-code + user-desktop— the native desktop agent — is still refused.The conversation outlives the pod
Claude Code already resumed its SDK session between turns; the transcript just died with the pod. The daemon now copies the session — the
projects/transcript tree plus the id pointing at it — onto the org volume when a run ends, and back before the next one starts (internal/orgfs/sessions.go, on the existingBeforeRun/AfterRunhooks).Copied, never mounted. This is the load-bearing decision. Claude Code reads the transcript on its startup path, and org-fs is a network filesystem whose reads can block indefinitely — the exact hazard
readableWithinalready guards skills against, but on a file the run cannot choose to skip. A symlinked config dir turns a wedged backend into "every run produces nothing"; a copy turns it into a slow dispatch, and at worst a session that starts fresh.Invariants preserved:
sessionFile's "no file means no session" still holds.copyTreewrites in place, so copying straight onto the store could leave a partial transcript that a live id points at — a run-killing resume rather than a fresh start. The swap's own interrupted window leaves no transcript, which restore already treats as "start fresh".Reuses
copyTreefromskillcopy.go(landed on main while this was in flight) rather than adding a second one.Runs wait for what they need
WaitHomeReadyblocks a dispatch until the home volume is attached and the org's skills are linked out of it — sibling toWaitSkillLinks, which waits on the public skill-set copy. Waiting, where the rest of that file fails open, because the failure modes differ: a missingorg/outputlink is a tool error somebody sees; a missing skills link is a confident wrong answer nobody can distinguish from a bad one.Bounded at 90s, and the budget is paid once per pod — a sandbox whose sidecar never mounts
homewould otherwise tax every dispatch forever. Later dispatches still re-check, they just don't sleep.The harness's MCP preflight gets the same treatment: ~4 minutes instead of 30 seconds, so a run dispatched into a rolling deploy waits it out instead of failing. The ceiling stays — refusing beats running an agent that cannot see the org's tools.
Composer unlock
A batch thread has no per-thread stream, so no
finishchunk ever arrives and the composer stayed locked at "submitted" after a reply landed. The org-level/watchterminal status now releases it — batch connections only, since a streaming connection's own finish chunk owns that transition.Testing
normalizeHostedRuntimePinunit tests, and a new e2e (hosted-runtime-boundary.spec.ts) asserting aclaude-code + agent-sandboxthread accepts a follow-up (202, pins unchanged, parts persisted) — the counterpart to the read-only test, which still asserts the 409./dispatchin a live pod against a real org-fs mount point. The harness failed (no model credential there) andAfterRunstill saved the session; wiping local state to simulate a fresh pod and re-dispatching restored both the id and the transcript.daemon-gosuite green;bun run check/lint/fmtclean.Not covered: a UI click-through of the full reply flow — that cluster has no Anthropic credential, so no claude-code turn can actually execute there.
Summary by cubic
Makes sandbox-hosted
claude-codetask-board threads interactive and durable across pod restarts. Previously follow-ups 409’d and threads were read-only; nowclaude-code + agent-sandboxaccepts follow-ups, sessions persist/restore, and the web unlocks the composer correctly for batch threads.API: accept
claude-code + agent-sandboxin messages/queue/stream; keep the desktop agent 409 with the same “Studio desktop app” message; use the row’s pinned harness for dispatch; allow cancel/flip/queue-cancel on sandbox-hostedclaude-code; stop stampingread_only;/streamasserts then returns 204 for batch.Daemon: write headers and start the keepalive before workspace prep, and keep it running through
AfterRun; wait for org HOME + skills once per pod (bounded, with rate‑limited repoints), then restore the session; after the run, adopt stray skills and save the session. Session transfers are copies (not mounts), verified before writing the id, guarded by a generation check, and bounded to 30s per direction; overlapping flights are safe.Runner: MCP preflight retries 8 times; end an attempt’s SDK session before retry; use a fresh session id per retry when not resuming; if a saved session id does not resolve here, restart once without resume.
Web: batch threads unlock after the reply via org‑level
/watchonly after the new run starts; only explicitlyread_onlythreads disable input; i18n copy simplified.Rollout: Required — mount the org HOME volume to persist/restore sessions; without it, runs proceed but start fresh. No migrations.
Written for commit 1ac9678. Summary will update on new commits.