Skip to content

feat(task-board): make claude-code threads interactive - #6376

Merged
pedrofrxncx merged 10 commits into
mainfrom
claude/interactive-claude-threads
Aug 21, 2026
Merged

feat(task-board): make claude-code threads interactive#6376
pedrofrxncx merged 10 commits into
mainfrom
claude/interactive-claude-threads

Conversation

@pedrofrxncx

@pedrofrxncx pedrofrxncx commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Task-board runs dispatch a claude-code harness into a sandbox pod. You could watch it work and never reply to it: the thread was stamped read_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.ts stamped read_only: true on every sandbox-hosted run
routes.ts validate + claim normalizeHostedSandboxProviderKind is Decopilot-only, so it 409'd the pin
input.tsx composer disabled itself on the same flag
the SDK session lived in the pod, and the pod is reclaimed ~15 min after the last viewer leaves

The thread accepts follow-ups

read_only is 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 accepts claude-code + agent-sandbox and passes its kind through untouched. It does not widen assertPersistedHostedRuntime (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 existing BeforeRun/AfterRun hooks).

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 readableWithin already 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:

  • id last, both directions. The id is the claim that a transcript exists; writing it first would point the harness at nothing and fail the run it was meant to help. sessionFile's "no file means no session" still holds.
  • a live local session wins. Restoring over the pod that just ran the thread would rewind the conversation by a turn.
  • saves stage then swap. copyTree writes 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".
  • nothing local is never a delete. A run that died before writing its id must not take the earlier turns with it.

Reuses copyTree from skillcopy.go (landed on main while this was in flight) rather than adding a second one.

Runs wait for what they need

WaitHomeReady blocks a dispatch until the home volume is attached and the org's skills are linked out of it — sibling to WaitSkillLinks, which waits on the public skill-set copy. Waiting, where the rest of that file fails open, because the failure modes differ: a missing org/output link 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 home would 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 finish chunk ever arrives and the composer stayed locked at "submitted" after a reply landed. The org-level /watch terminal status now releases it — batch connections only, since a streaming connection's own finish chunk owns that transition.

Testing

  • normalizeHostedRuntimePin unit tests, and a new e2e (hosted-runtime-boundary.spec.ts) asserting a claude-code + agent-sandbox thread accepts a follow-up (202, pins unchanged, parts persisted) — the counterpart to the read-only test, which still asserts the 409.
  • Go tests for the session round-trip: survives-the-pod, no-clobber, no-id-without-transcript, no-delete-on-empty, traversing thread id, no-mount no-op, and the once-per-pod budget.
  • Validated on a real cluster (decohouse, k3s + agent-sandbox controller): built the sandbox image from this branch, pointed the template at it, and drove /dispatch in a live pod against a real org-fs mount point. The harness failed (no model credential there) and AfterRun still saved the session; wiping local state to simulate a fresh pod and re-dispatching restored both the id and the transcript.
  • 226/226 daemon black-box e2e green against the real binary; full daemon-go suite green; bun run check / lint / fmt clean.

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-code task-board threads interactive and durable across pod restarts. Previously follow-ups 409’d and threads were read-only; now claude-code + agent-sandbox accepts follow-ups, sessions persist/restore, and the web unlocks the composer correctly for batch threads.

  • API: accept claude-code + agent-sandbox in 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-hosted claude-code; stop stamping read_only; /stream asserts 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 /watch only after the new run starts; only explicitly read_only threads 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.

Review in cubic

Pedro França 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
pedrofrxncx force-pushed the claude/interactive-claude-threads branch from 4515d43 to a04faee Compare August 21, 2026 00:23
Pedro França 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.
@pedrofrxncx
pedrofrxncx merged commit d6fc05d into main Aug 21, 2026
34 checks passed
@pedrofrxncx
pedrofrxncx deleted the claude/interactive-claude-threads branch August 21, 2026 19:38
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
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