# Task 610 — Webchat follower 202 cold-start retry — design

**Date:** 2026-06-02
**Task:** `maxy-code/.tasks/610-webchat-follower-202-cold-start-race-never-detects-end-turn.md`
**Status:** Approved, pre-implementation

## Problem

Every public webchat turn returns "Sorry, something went wrong" even when the agent
completes successfully. Proven live on the realagent-code Pi: session `2a2a6239`
completed its turn (assistant `text` greeting, `stop_reason:"end_turn"`) at T+52s, yet
`dispatchOnce` returned `reject reason=turn-timeout` at the 120s `WEBCHAT_TURN_TIMEOUT_MS`.

Root cause, confirmed in code: the JSONL follower's first fetch of
`GET /:sessionId/log?follow=1` hits the pre-first-JSONL window of a cold-spawned PTY.
The manager returns `202 {pending:true}` (`http-server.ts:1468-1471`) when the PTY is
live but no JSONL exists yet. The follower guard is `if (!res.ok || !res.body)`
(`follower.ts:46`). **`202` satisfies `res.ok`** (200–299), so the follower falls
through, reads the pending JSON body as one non-event line (`type` undefined → skipped),
reads stream `done`, `break`s, runs `finally → onClose()`, and **never retries**. No
`follower-error`, no `fanOut`, no `outbound` — silent death. Public sessions idle-reap,
so every greeting is the first turn of a fresh spawn → every turn hits the race → every
turn times out.

## Decision

Fix the follower, not the manager. The follower retries on `202` with a bounded poll
loop until it gets a `200` stream or the give-up window elapses. The manager's `202`
branch is left unchanged — `202 {pending:true}` is the correct signal; the follower's
handling of it was the defect. This matches the task's observability spec, which is
entirely follower-centric (`follower-connect` / `follower-retry` / `follower-open` /
`follower-give-up`), and keeps the change in one file with a unit-testable fetch seam.

## The change — `follower.ts`

Wrap the single `fetch` in a bounded retry loop. Per attempt:

- `fetch(managerLogFollowUrl)`, then log `follower-connect sessionId=… status=<code>`.
- **`status === 202`**: log `follower-retry sessionId=… attempt=N reason=pending`,
  cancel the pending response body, check `abort.signal.aborted` (return cleanly via
  `finally → onClose` if aborted), check elapsed against the give-up window. If exceeded:
  log `follower-give-up sessionId=… reason=pending-timeout attempts=N`, call
  `onError('follow-pending-timeout')` + `onClose()`, return. Otherwise `sleep(interval)`
  and retry.
- **`200` + body**: log `follower-open sessionId=…`, break into the existing
  read/parse/accumulate/fan-out loop, unchanged.
- **any other non-ok (404/410/5xx)**: unchanged — `onError('follow-status-<code>')` +
  `onClose()`. Session-gone respawn is handled by `writeInput`'s 410/404 path in
  `bridge.ts`, not the follower.

Abort during a retry sleep ends the loop with no `onError` (just `onClose` via the outer
`finally`), matching today's abort-during-stream behaviour. The retry loop sits inside the
existing `try/finally`, so `onClose` fires exactly once on every exit path.

## Config

Two env vars, mirroring `CHANNEL_PTY_IDLE_MS` in `bridge.ts`:

- `CHANNEL_PTY_FOLLOWER_PENDING_MAX_MS` — give-up window, default `120_000`. Covers
  claude cold-boot and matches the webchat 120s turn window. If the follower gives up
  before the JSONL appears, the turn times out anyway, so the bound need not exceed the
  turn window.
- `CHANNEL_PTY_FOLLOWER_RETRY_MS` — poll interval between `202` retries, default `1_000`.

Both read inside `follower.ts` at loop entry.

## No behaviour change elsewhere

- **Warm sessions**: first fetch returns `200`, logs `follower-connect status=200` +
  `follower-open`, streams as today. The four existing follower tests cover this path
  unchanged.
- **whatsapp / email**: same shared follower. They only see new behaviour if they ever
  cold-start into the `202` window — the same bug, fixed once. No channel-specific code.
- **Manager**: untouched. `202`/`200`/`404`/`410` contract is unchanged.

## Tests (ephemeral)

In `__tests__/follower.test.ts`, with a small `helpers.ts` extension to script a follow
response sequence (`202` then `200`-stream) per sessionId:

1. **Repro**: first follow fetch returns `202 {pending:true}`, second returns a `200`
   stream carrying an `end_turn`+text record → subscriber receives the text. Fails today
   (silent close, no fan-out).
2. **Give-up**: follow keeps returning `202` past `CHANNEL_PTY_FOLLOWER_PENDING_MAX_MS`
   (set small in the test) → `onError` + `onClose` fire, no fan-out.
3. **Regression**: the four existing follower tests pass unchanged (warm 200 path,
   user-event reset, malformed-line skip, multi-subscriber fan-out, abort→onClose).

Tests are run to prove the fix during the sprint and discarded unless they belong in the
existing committed suite — this suite is already tracked, so the new cases stay.

## Observability (per task spec)

- Success signal: `follower-open` + `outbound bytes>0` per webchat turn; the manager's
  `log-follow-open` for that sessionId now appears (absent today for every timed-out turn).
- Failure signal: `reject reason=turn-timeout` with no preceding `follower-open` =
  follower never connected. With the fix, a persistent cold session emits
  `follower-give-up` instead of dying silently.

## Out of scope (separate tasks)

- Task 609 — fail-open public `--allowed-tools` / `memory-search` strip. Independent.
- WhatsApp/email turn-timeout tuning — no change beyond incidental shared-follower coverage.
- Session reuse / idle-reap tuning (why each turn cold-spawns) — separate concern.
- Manager-side connection hold — explicitly rejected in favour of the follower retry.

## Touches

- `platform/ui/app/lib/channel-pty-bridge/follower.ts`
- `platform/ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts`
- `platform/ui/app/lib/channel-pty-bridge/__tests__/helpers.ts`
- `.docs/` + `platform/plugins/docs/references/` (webchat turn lifecycle + new env vars)
- `.tasks/LANES.md`, archive task file

Installer publish required — `platform/ui/**` ships in the bundled `create-realagent-code` /
`create-maxy-code` payload.
