Skip to content

feat(helix-org): session-layer transcript mirror + fire-and-forget worker sessions - #2566

Merged
philwinder merged 3 commits into
mainfrom
fix/session-layer-transcript-mirror
Jun 9, 2026
Merged

feat(helix-org): session-layer transcript mirror + fire-and-forget worker sessions#2566
philwinder merged 3 commits into
mainfrom
fix/session-layer-transcript-mirror

Conversation

@philwinder

@philwinder philwinder commented Jun 9, 2026

Copy link
Copy Markdown
Member

Summary

Two related fixes to how helix-org drives worker (Zed external-agent) sessions, found in one investigation:

1. Session-layer transcript mirror (churn-proof, two-sided)

Worker transcripts now reach s-activations-<worker> for every turn — spawner activation or human inline chat — via one persistent per-worker Mirror that follows the worker's current session (polls the project's exploratory session, re-points on change). Captures both user: prompts and assistant:/tool_* replies. Replaces the per-activation bridge that missed inline-chat turns and went silent on session changes.

2. Fire-and-forget worker sessions (ends session churn)

The spawner drove turns through the blocking OpenAI-compat path (/sessions/chatRunExternalAgent, 180s cap). Long worker turns exceeded it → misread as "stale session" → fresh session opened → churn. Now it uses the same canonical, non-blocking primitives every other autonomous flow uses:

  • StartSessionStartExternalAgentSession (the cron trigger's interface) — first activation.
  • SendMessagePOST /sessions/{id}/messages (frontend + spec tasks) — every subsequent turn.

Stale detection deleted: a worker keeps one durable session; Helix already auto-resumes a downed desktop on the same session (autoStartDevContainerForSession + pickupWaitingInteraction), preserving conversation continuity. The shared RunExternalAgent 180s cap is untouched (correct for real OpenAI-compat callers).

Verification

  • Inline chat → user: + assistant: on s-activations-w-owner/-aaa, captured across a session change (mirror re-pointed).
  • After the fire-and-forget switch: aaa's session pointer stable (no churn), zero external agent response timeout / open fresh helix session errors, downed desktops auto-resume on the same session.
  • Full helix/controller/lifecycle/server suites green. (Full spawner-activation E2E was blocked by this dev box's pre-existing agent-timeout flakiness — a separate issue, documented.)

Design docs

  • design/2026-06-09-activation-stream-transcript-still-empty.md
  • design/2026-06-09-helix-org-session-churn-fix.md

🤖 Generated with Claude Code

philwinder and others added 2 commits June 9, 2026 14:28
…prompts

Fixes #2557 follow-up: the activation stream was still empty for inline-chat
turns, and spawner activations on churned sessions were silently orphaned.

## The problem
- Transcripts came only from per-activation spawner bridges, missing all
  inline-chat turns (no spawner = no mirror).
- Even for activations, bridges subscribed too late (after the turn streamed).
- Worker sessions churn (stale resume → fresh session), but the old mirror
  design pinned a fixed session ID, so the stream went silent on churn.

## The fix: session-layer Mirror that *follows* the worker
- One persistent per-worker tracker (not per-activation bridge).
- Polls the worker's current session (project's most-recent exploratory
  session — exactly what the inline chat follows).
- Re-points the subscription when the session changes, so the stream never
  goes silent on churn.
- **Captures both sides**: user prompts (PromptMessage from WebsocketEvent
  frames) + agent replies (EntryStream), recorded as `user:` + `assistant:`
  lines on s-activations-<worker>, deduped once per interaction.

## Architecture
- Mirror.Ensure(org, worker) starts a long-lived per-worker tracker; idempotent,
  persists across activations.
- Spawner calls Ensure on each activation; inline chat needs nothing.
- ensureBootstrap calls EnsureAll per org (once at startup) so pre-existing
  workers are mirrored before any activation.
- lifecycle.Fire calls Stop to avoid leaking the subscription on delete.
- ExploratorySession resolver (wired to store.GetProjectExploratorySession)
  gives the mirror the stable "current session" to follow; no fixed session IDs.
- Poll interval: 5s; stream can lag up to one interval on real session change,
  then catches up. Proportionate to the churn: no firehose, stays per-worker.

## Testing
New tests: TestMirrorCapturesTurnWithoutSpawner (inline chat without spawner),
TestMirrorRepointsOnSessionChurn (core fix — mirror follows session change),
TestMirrorCapturesUserPrompt (dedup user lines), TestMirrorEnsureIsIdempotent,
TestMirrorStop. All helix/lifecycle/server suites green.

## Live verification
Inline chat to w-owner landed on s-activations-w-owner (no activation).
Inline chat to aaa (on a churned session) landed correctly; mirror followed
the session change via re-point polling.

## Known gaps
- Multi-part prompts (images) produce no `user:` line; text only (the common
  case) is covered.
- First fresh-session turn (hire) streams before the mirror attaches; every
  subsequent turn is captured.
- aaa's session churn itself (`exit: error: … external agent timeout`) is
  separate and pre-existing — worth a separate look.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… end churn

helix-org worker activations went through the blocking OpenAI-compat chat
path (POST /sessions/chat → RunExternalAgent), which waits up to 180s for
the whole turn. Real worker turns (git pull specs, read role/identity,
commit, push) routinely exceed that, so they were killed mid-turn; the
spawner misread the timeout as a "stale session" and opened a fresh one,
which also timed out → endless session churn and lost conversation
continuity.

Fix: use the same canonical, non-blocking primitives every other
autonomous flow uses — the cron trigger, spec tasks, the frontend:

- StartSession → StartExternalAgentSession (creates session + starts
  desktop + queues the prompt). For a worker's first activation.
- SendMessage → POST /sessions/{id}/messages (fire-and-forget). For every
  subsequent turn.

Neither blocks on the turn, so neither hits the response timeout. The
spawner observes completion via pollUntilDone + the transcript mirror.

Stale-session detection is deleted, not preserved: a worker keeps ONE
durable session, and Helix already recovers a downed desktop transparently
(sendCommandToExternalAgent → autoStartDevContainerForSession +
pickupWaitingInteraction) on the SAME session — preserving the Zed thread,
strictly better than the old "open a fresh session" behaviour.

Deleted: in-proc StartChatWithStatus + sseCapture/parseSSE machinery;
runtimehelix.StartChatRequest/SessionChatMessage/MessageContent/
NewTextMessage; EnsureAndSend's resume-vs-fresh branching, hadStreamErr,
cold-start retry, sendToSession.

The shared RunExternalAgent 180s cap is untouched (correct for genuine
OpenAI-compat callers). Tests: StartSession (no session) + SendMessage
(follow-up) + no-churn-on-down-desktop; removed the cold-start/stale
tests for behaviour that no longer exists. Full suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@philwinder philwinder changed the title feat(helix-org): session-layer transcript mirror, churn-proof + user prompts feat(helix-org): session-layer transcript mirror + fire-and-forget worker sessions Jun 9, 2026
Condense the doc/inline comments added in the two prior commits — keep the
essential "why", drop the restated mechanics. No behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@philwinder
philwinder merged commit 21452c0 into main Jun 9, 2026
1 check passed
@philwinder
philwinder deleted the fix/session-layer-transcript-mirror branch June 9, 2026 14:58
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