Skip to content

feat: durable cloud execution for built-in agent turns - #1208

Closed
ital0 wants to merge 15 commits into
mainfrom
italomenezes/thu-775-cloud-runner-detached-turns
Closed

feat: durable cloud execution for built-in agent turns#1208
ital0 wants to merge 15 commits into
mainfrom
italomenezes/thu-775-cloud-runner-detached-turns

Conversation

@ital0

@ital0 ital0 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds a cloud runner service (cloud-runner/) that executes the built-in agent's turns server-side: a response keeps running when the app is closed or backgrounded, and is replayed when the thread is reopened. Sessions are process-owned and journaled; clients authenticate over WebSocket with their signed session bearer (verified by backend introspection), and all model requests ride the backend inference gateway under the user's own token — the runner holds no model keys.
  • Transparent placement per thread: there is still exactly one Thunderbolt agent. An eligible first send is placed on the runner (authenticated non-anonymous user, non-encrypted thread, gateway model with tool use, no device-only tools/attachments/MCP servers); anything else runs locally. Runner-owned threads never migrate silently — a send that can't be honored fails loudly with a clear message.
  • Detached-turn catch-up: on thread open or app wake, the client resumes the stream, replays the journaled turn (including tool calls) in place of crash-recovery partials, and keeps streaming live if the turn is still running. Auto-regenerate is suppressed for runner-owned threads so a completed detached turn is never re-submitted.
  • Tool parity: the runner serves the same web tools (search, fetch_content) through the backend endpoints, plus render_html with static validation. Source labels and citation-registry entries travel on the tool results themselves, so [N] citation chips render on live, replayed, and reloaded runner turns. Native widget tags (weather, link previews, maps) keep working; a chat-surface section in the runner's system prompt steers models toward them instead of offline artifacts.
  • Backend: advertises the runner endpoint via /v1/config (CLOUD_RUNNER_WS_URL), and account deletion hard-deletes the user's runner sessions. Deleting a thread purges its runner session.
  • Infra: runner Dockerfile, Pulumi stack (ECS Fargate + EFS + ALB + CloudFront), and a CI job for the runner suite.

Spec: THU-775.

Test plan

  • Full suites green: root/frontend, shared, cloud-runner (unit + wire integration), cli, backend
  • Manual E2E on web and iOS simulator: send → kill the app mid-turn → runner completes detached → reopen → full replay with a single user message in the session journal
  • Citations and native widgets verified on runner turns (live, replay, reload)
  • Runner unreachable / ineligible sends fall back to local execution
  • Deploy the runner to a real environment and set CLOUD_RUNNER_WS_URL on the backend

@ital0
ital0 force-pushed the italomenezes/thu-775-cloud-runner-detached-turns branch from 6c9fba3 to b0bb7da Compare August 7, 2026 16:00
@ital0 ital0 changed the title feat(THU-775): durable cloud execution for built-in agent turns feat: durable cloud execution for built-in agent turns Aug 7, 2026
ital0 added 2 commits August 10, 2026 16:05
cloud-runner type-checks cli/ sources in place, so the two node_modules
trees must resolve the same @earendil-works package versions. cli lagged
at 0.80.2 while root and cloud-runner pin 0.80.7, which broke
cloud-runner's typecheck at the cli/shared type boundary.
Comment thread deploy/docker/cloud-runner.Dockerfile Fixed
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

Semgrep Security Scan

No security issues found.

@ital0
ital0 force-pushed the italomenezes/thu-775-cloud-runner-detached-turns branch from f030034 to 4d185ac Compare August 10, 2026 19:13
@ital0

ital0 commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

@thunderbolt-automation-agent review this PR

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

Preview environment destroyed 🧹

Stack preview-pr-1208 and its Cloudflare subdomain have been cleaned up.

@github-actions

Copy link
Copy Markdown

PR Metrics

Metric Value
Lines changed (prod code) +4787 / -354
JS bundle size (gzipped) 🟢 591.4 KB → 595.0 KB (+3.6 KB, +0.6%)
Test coverage 🟢 81.38% → 81.53% (+0.2%)
Performance (preview) Preview not ready — Render deploy may have timed out
Accessibility
Best Practices
SEO

Updated Mon, 10 Aug 2026 19:20:12 GMT · run #2645

@thunderbolt-automation-agent

Copy link
Copy Markdown
Contributor

Review of the cloud runner and detached-turn catch-up at 4d185ac. One critical isolation gap, several high severity lifecycle and delivery gaps, and a handful of medium and low items, listed by severity.

Critical

Concurrent resume bypasses session ownership (cloud-runner/src/session-runtime.ts:491). pending is process-global and keyed only by sessionId, and register returns an existing in-flight promise without checking the requesting user. If user A is rehydrating a session and user B concurrently calls session/resume with that id, B receives A's SessionRuntime: resume returns it directly from buildFor without the ownership check used for an already-live runtime. B's connection can then attach an observer, replay A's updates, and prompt against A's workspace. Key pending work by owner plus session id, or validate runtime.userId after awaiting a shared build.

High

  • Pre-auth buffering has no byte limit (cloud-runner/src/server.ts:217). An unauthenticated public WebSocket may enqueue up to 64 arbitrary-size frames while backend introspection runs for as long as 10 seconds. maxPendingFrames bounds only the count, so many sockets sending large frames can retain hundreds of megabytes before authentication and exhaust the single runner task. Enforce a small cumulative byte cap (ideally per-frame limits too) before appending to pending, and close when it is exceeded.

  • Completed detached-turn output becomes undeliverable once the in-memory journal is gone (cloud-runner/src/session-runtime.ts:38, src/acp/acp-adapter.ts:813). The journal and RunnerTurnRecord live only in process memory; the sweeper discards idle detached runtimes after CLOUD_RUNNER_IDLE_SESSION_TTL_MS, default 30 minutes (cloud-runner/src/config.ts:75), and any runner restart or deploy has the same effect. Reopening after that rehydrates from the disk log with lastTurn === null, replay returns { turn: null }, and the client's reattach hits if (!replay?.turn) { teardown(); return null } at src/acp/acp-adapter.ts:813, a silent no-op. The finished answer, which exists in the runner's session log as model context and which the user was billed for, is not delivered; the thread keeps a dangling user message, auto-regenerate is suppressed for runner-owned threads (src/chats/use-chat-automation.tsx:34) so nothing fills the gap, and the next send continues with model context that includes work the user did not see. Persist the journal, or at least the last turn's updates and record, alongside the session log, or have the client fall back to a local regenerate (or a recoverable terminal error) when catch-up returns no turn for a trailing user message.

  • Erasure ignores runtimes still being created (cloud-runner/src/session-runtime.ts:605). purgeUser disposes only entries already in live; it neither cancels nor awaits matching builds in pending. An account purge racing session/new or rehydration can return 204 and delete the disk tree, after which the pending build completes, register inserts the runtime into live, and subsequent activity recreates the user's session log and workspace. Account deletion promises permanent erasure, so pending builds need to be owner-tracked and blocked, cancelled, or awaited before storage deletion, with registration prevented once the purge begins.

  • iOS release build configuration flipped to the dev bundle identity (src-tauri/gen/apple/thunderbolt.xcodeproj/project.pbxproj:345). The release XCBuildConfiguration now sets PRODUCT_BUNDLE_IDENTIFIER = net.thunderbird.thunderbolt.dev and PRODUCT_NAME = "Thunderbolt Dev" while keeping CODE_SIGN_IDENTITY = "iPhone Distribution" and the "Thunderbolt iOS AppStore" provisioning profile. At the base commit the release config was net.thunderbird.thunderbolt / "Thunderbolt", and src-tauri/gen/apple/project.yml, the xcodegen source of truth, still declares the production identity, so the next regen reverts this. It rides along with the swiftCompatibility OTHER_LDFLAGS fix in commit b0bb7da and looks like a dev-only edit committed by accident. As merged, iOS release or archive builds either fail codesign (dev bundle id against the production App Store profile) or ship with the wrong identity. Restore the production values by regenerating from project.yml.

  • Uncorrelated latest-turn replay can append a stale duplicate answer after a failed send (src/acp/acp-adapter.ts:813). chat-instance.ts persists the outgoing user message before placement and transport (src/chats/chat-instance.ts:362), so a placement refusal (the throw at line 395) or a transport error before the prompt reaches the runner leaves a trailing user message. On next open, shouldCatchUpOnDetachedTurn (src/chats/use-hydrate-chat-store.ts) fires resumeStream, and the runner's attach replays activeTurn?.record ?? lastTurn, the previous turn, since the new prompt started none. The reattach path accepts any non-null replay.turn with no correlation to the client's pending message, so the previous turn's answer streams in as a new assistant message (replaceMessageId is only set for the assistant-partial path) and is persisted via onFinish: a stale duplicate permanently appended to the transcript. Stamp the client message id or a turn nonce into the run spec _meta, record it on RunnerTurnRecord, and skip replay when it does not match the pending message.

Medium

  • Concurrent-turn admission is non-atomic (cloud-runner/src/runner-agent.ts:179). requireTurnSlot counts active turns, but the selected session is not marked active until the later runtime.prompt() call, so concurrent prompt requests for different sessions can all pass the check before any sets activeTurn, exceeding maxConcurrentTurnsPerUser. The session cap has the same check-then-async-build race in create. Reserve the user slot atomically before awaiting or rebuilding, release it in finally, and reserve session capacity across pending creations as well.

  • Auto-regenerate suppression hits all ACP threads with a stored session id (src/chats/use-chat-automation.tsx:34). The gate !chatThread?.acpSessionId && disables the missing-reply auto-regenerate for any thread with a stored ACP session id, but the comment scopes it to runner-owned threads. remote-acp and managed-acp threads also persist acpSessionId, and unlike runner-owned built-in threads they have no detached-turn catch-up (supportsDetachedTurns is false), so for those agents a turn interrupted between user message and assistant reply no longer auto-resumes where it previously regenerated. Narrow the predicate to the runner-owned case (built-in agent, acpSessionId set, runner configured), matching the catch-up gate.

Low

  • Registry delete races an in-flight resume of the same session (cloud-runner/src/session-runtime.ts:586). registry.delete checks only the live map, not the pending map that converges concurrent resumes (line 487). If a resume is in flight when deleteSession arrives, delete finds no live runtime, wipes the disk state, and the pending resume then resolves and registers a runtime whose backing store was deleted, resurrecting the session until the idle sweep. purgeUser has the same shape via ownedBy/live. Await or chain any pending.get(sessionId) entry before deleting, and re-check liveness after.

  • Read-only catch-up mints a fresh runner session when the stored one is unrestorable (src/acp/acp-adapter.ts:617). A reattach with no user action calls resolveThreadSession, which falls through resume, then load, to resolveNew(skills, sessionMeta) when the stored session can no longer be restored (for example after the 30-day retention purge). The reattach then aborts at line 780 on the id mismatch, but the fresh empty session was already created server-side and cached in sessionByThread. Each app open on such a thread creates an orphan runner session; opens within the idle TTL accumulate against the 20-sessions-per-user cap (cloud-runner/src/config.ts:77), after which session/new starts failing for the user. Give resolveThreadSession a resolve-only mode that does not mint, or dispose the fresh session when the mismatch guard aborts.

  • Switching agents clears acpSessionId without purging the runner-side session (src/chats/chat-store.ts:258). selectAgent's threadPatch severs the client's runner pin, but nothing on this path calls the new purgeRunnerSession (src/acp/runner-session-purge.ts) or deleteRunnerSession; the purge helper has no call site for the agent-switch case. The runner-side transcript, journal, and workspace linger until the 30-day retention TTL, holding user data and a slot against the 20-per-user cap that the client can no longer reach. Thread delete and account delete both purge; fire purgeRunnerSession here as well when agentChanged and the old thread had an acpSessionId.

This is comment-only feedback. It does not approve or merge this pull request.

@ital0 ital0 closed this Aug 11, 2026
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.

2 participants