# Follower 202 Cold-Start Retry Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make the channel-pty-bridge JSONL follower survive the cold-spawn `202 {pending:true}` window so a fresh public webchat turn detects `end_turn` and the visitor receives the greeting instead of a 120s timeout.

**Architecture:** Wrap the follower's single `fetch(/log?follow=1)` in a bounded retry loop. `202` triggers a logged retry on a poll interval until a `200` stream arrives or a give-up window elapses; `200` proceeds into the existing read/fan-out loop unchanged; other non-ok statuses keep today's `onError`+`onClose`. Two env vars (give-up window, poll interval) tune the loop, defaulting to cover claude cold-boot. The manager is untouched.

**Tech Stack:** TypeScript, Node fetch + ReadableStream, vitest. Files in `maxy-code/platform/ui/app/lib/channel-pty-bridge/`.

---

### Task 1: Extend the test fetch helper to script a follow-response sequence

The current `installManagerFetch` always answers `/log?follow=1` with a `200` stream. To
test the `202`→`200` path we need to script per-sessionId follow responses: a list of
statuses where `202` returns the pending JSON body and `200` returns the stream.

**Files:**
- Modify: `maxy-code/platform/ui/app/lib/channel-pty-bridge/__tests__/helpers.ts`

- [ ] **Step 1: Add a `followStatuses` option and apply it in the follow branch**

In `ManagerFetchOptions`, add:

```ts
  /** Per-sessionId status sequence for /:id/log?follow=1. Each 202 returns
   *  `{pending:true}` and consumes one entry; the first non-202 entry returns
   *  the stream (200) or an error body. Cycles the last entry. When unset, the
   *  follow branch returns 200 + stream as before. */
  followStatuses?: Record<string, number[]>
```

In `installManagerFetch`, before the `fetchMock` definition:

```ts
  const followStatusSeq = opts.followStatuses ?? {}
  const followStatusIdx: Record<string, number> = {}
```

Replace the existing follow branch body (the `if (followMatch && method === 'GET')` block)
with:

```ts
    const followMatch = url.match(/\/([^/?]+)\/log\?follow=1$/)
    if (followMatch && method === 'GET') {
      const sessionId = followMatch[1]
      followCalls.push(sessionId)
      const seq = followStatusSeq[sessionId]
      if (seq && seq.length > 0) {
        const i = Math.min(followStatusIdx[sessionId] ?? 0, seq.length - 1)
        followStatusIdx[sessionId] = (followStatusIdx[sessionId] ?? 0) + 1
        const status = seq[i]
        if (status === 202) {
          return new Response(JSON.stringify({ jsonlPath: null, exists: false, sizeBytes: 0, pending: true }), {
            status: 202,
            headers: { 'content-type': 'application/json' },
          })
        }
      }
      const stream = perId[sessionId] ?? defaultFollow ?? makeJsonlStream()
      return new Response(stream.body, {
        status: 200,
        headers: { 'content-type': 'application/x-ndjson' },
      })
    }
```

- [ ] **Step 2: Confirm the existing follower suite still compiles and passes**

Run: `cd maxy-code/platform && npx vitest run ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts`
Expected: PASS (4 tests) — the helper change is additive; `followStatuses` is unset in
every existing test so the follow branch behaves exactly as before.

- [ ] **Step 3: Commit**

```bash
git add maxy-code/platform/ui/app/lib/channel-pty-bridge/__tests__/helpers.ts
git commit -m "test(610): scriptable follow-response sequence in manager fetch helper"
```

---

### Task 2: Failing test — follower retries on 202 then fans out on the 200 stream

**Files:**
- Modify: `maxy-code/platform/ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts`

- [ ] **Step 1: Write the failing test**

Add inside the `describe('follower', …)` block (the `makeEntry` helper and imports already
exist at the top of the file):

```ts
  it('retries on 202 pending then fans out once the 200 stream is available', async () => {
    const stream = makeJsonlStream()
    // First follow fetch → 202 pending; second → 200 + stream.
    mgr = installManagerFetch({
      followStream: stream,
      followStatuses: { 'sess-aaaaaaaa': [202, 200] },
    })
    process.env.CHANNEL_PTY_FOLLOWER_RETRY_MS = '5'
    process.env.CHANNEL_PTY_FOLLOWER_PENDING_MAX_MS = '5000'
    const entry = makeEntry()
    const received: string[] = []
    entry.subscribers.add((text) => {
      received.push(text)
    })
    startFollower({ entry, tag: '[test]', onClose: () => {} })
    // Wait until the retry loop has issued the second (200) follow fetch.
    await waitFor(() => mgr.followCalls.length >= 2)
    stream.push(
      JSON.stringify({
        type: 'assistant',
        message: { content: [{ type: 'text', text: 'greeting' }], stop_reason: 'end_turn' },
      }),
    )
    await waitFor(() => received.length > 0)
    expect(received).toEqual(['greeting'])
  })
```

Add to the `afterEach` (or a dedicated cleanup) so the env vars don't leak into other
tests — place these two lines in the existing `afterEach` callback:

```ts
    delete process.env.CHANNEL_PTY_FOLLOWER_RETRY_MS
    delete process.env.CHANNEL_PTY_FOLLOWER_PENDING_MAX_MS
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd maxy-code/platform && npx vitest run ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts -t "retries on 202"`
Expected: FAIL — today the follower treats `202` as success, consumes the pending JSON
body as a single non-event line, reads `done`, and closes. `mgr.followCalls.length` never
reaches 2 (no retry), so `waitFor` throws "predicate never became true".

- [ ] **Step 3: Commit the failing test**

```bash
git add maxy-code/platform/ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts
git commit -m "test(610): failing test for follower 202 retry then fan-out"
```

---

### Task 3: Implement the bounded 202 retry loop in the follower

**Files:**
- Modify: `maxy-code/platform/ui/app/lib/channel-pty-bridge/follower.ts`

- [ ] **Step 1: Add the two config readers**

Below the imports (after the `type JsonlEvent = …` line, before `FollowerOptions`), add:

```ts
function followerPendingMaxMs(): number {
  return Number(process.env.CHANNEL_PTY_FOLLOWER_PENDING_MAX_MS ?? String(120_000))
}

function followerRetryMs(): number {
  return Number(process.env.CHANNEL_PTY_FOLLOWER_RETRY_MS ?? String(1_000))
}
```

- [ ] **Step 2: Replace the single-fetch guard with the retry loop**

Replace this block (currently `follower.ts:42-51`):

```ts
    try {
      const res = await fetch(managerLogFollowUrl(entry.sessionId), {
        signal: abort.signal,
      })
      if (!res.ok || !res.body) {
        opts.onError?.(`follow-status-${res.status}`)
        opts.onClose()
        return
      }
      const reader = res.body.getReader()
```

with:

```ts
    try {
      const sid = entry.sessionId.slice(0, 8)
      const deadline = Date.now() + followerPendingMaxMs()
      let res: Response
      let attempt = 0
      for (;;) {
        res = await fetch(managerLogFollowUrl(entry.sessionId), {
          signal: abort.signal,
        })
        console.error(`${tag} follower-connect sessionId=${sid} status=${res.status}`)
        // 202 {pending:true}: the PTY is live but claude has not flushed its
        // first JSONL line yet (cold-spawn window). Retry on a poll interval
        // until a 200 stream is available or the give-up window elapses.
        if (res.status === 202) {
          attempt += 1
          await res.body?.cancel().catch(() => {})
          if (abort.signal.aborted) {
            opts.onClose()
            return
          }
          if (Date.now() >= deadline) {
            console.error(`${tag} follower-give-up sessionId=${sid} reason=pending-timeout attempts=${attempt}`)
            opts.onError?.('follow-pending-timeout')
            opts.onClose()
            return
          }
          console.error(`${tag} follower-retry sessionId=${sid} attempt=${attempt} reason=pending`)
          await new Promise((r) => setTimeout(r, followerRetryMs()))
          if (abort.signal.aborted) {
            opts.onClose()
            return
          }
          continue
        }
        break
      }
      if (!res.ok || !res.body) {
        opts.onError?.(`follow-status-${res.status}`)
        opts.onClose()
        return
      }
      console.error(`${tag} follower-open sessionId=${sid}`)
      const reader = res.body.getReader()
```

The rest of the function (the `while (!abort.signal.aborted)` read loop, the `catch`, and
the `finally → opts.onClose()`) is unchanged. `onClose` still fires exactly once on every
exit path: each early `return` inside the loop calls `onClose()` directly and returns
before the read loop; the normal stream-end / abort / error paths fall through to the
existing `finally`.

- [ ] **Step 3: Run the new test to verify it passes**

Run: `cd maxy-code/platform && npx vitest run ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts -t "retries on 202"`
Expected: PASS — first fetch returns 202, follower logs `follower-retry`, second fetch
returns the 200 stream, `follower-open` logs, the `end_turn` record fans out `'greeting'`.

- [ ] **Step 4: Commit**

```bash
git add maxy-code/platform/ui/app/lib/channel-pty-bridge/follower.ts
git commit -m "fix(610): follower retries on 202 cold-start, never silently closes"
```

---

### Task 4: Give-up test — persistent 202 ends with onError + onClose, no fan-out

**Files:**
- Modify: `maxy-code/platform/ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts`

- [ ] **Step 1: Write the test**

Add inside the `describe` block:

```ts
  it('gives up after the pending window: onError + onClose, no fan-out', async () => {
    const stream = makeJsonlStream()
    // Every follow fetch returns 202 — the JSONL never appears.
    mgr = installManagerFetch({
      followStream: stream,
      followStatuses: { 'sess-aaaaaaaa': [202] },
    })
    process.env.CHANNEL_PTY_FOLLOWER_RETRY_MS = '2'
    process.env.CHANNEL_PTY_FOLLOWER_PENDING_MAX_MS = '20'
    const entry = makeEntry()
    const received: string[] = []
    entry.subscribers.add((text) => {
      received.push(text)
    })
    const onError = vi.fn()
    const onClose = vi.fn()
    startFollower({ entry, tag: '[test]', onError, onClose })
    await waitFor(() => onClose.mock.calls.length > 0)
    expect(onError).toHaveBeenCalledWith('follow-pending-timeout')
    expect(onClose).toHaveBeenCalledTimes(1)
    expect(received).toEqual([])
  })
```

- [ ] **Step 2: Run it**

Run: `cd maxy-code/platform && npx vitest run ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts -t "gives up"`
Expected: PASS — repeated 202s exceed the 20ms window, follower logs `follower-give-up`,
calls `onError('follow-pending-timeout')` and `onClose()` once, no text delivered.

- [ ] **Step 3: Commit**

```bash
git add maxy-code/platform/ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts
git commit -m "test(610): follower gives up after pending window"
```

---

### Task 5: Full suite + typecheck + lint

**Files:** none (verification only)

- [ ] **Step 1: Run the whole channel-pty-bridge suite**

Run: `cd maxy-code/platform && npx vitest run ui/app/lib/channel-pty-bridge/__tests__/`
Expected: PASS — all follower tests (6 now), plus dispatch-once, reaper, subscribe,
write-chain, public-session-exit, live-memory-eviction, spawn-refused.

- [ ] **Step 2: Typecheck the changed files**

Run: `cd maxy-code/platform && npx tsc --noEmit -p ui/tsconfig.json 2>&1 | grep -E 'follower|helpers' || echo "no type errors in changed files"`
Expected: `no type errors in changed files`.

- [ ] **Step 3: Lint the changed files**

Run: `cd maxy-code/platform && npx eslint ui/app/lib/channel-pty-bridge/follower.ts ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts ui/app/lib/channel-pty-bridge/__tests__/helpers.ts`
Expected: no errors.

---

### Task 6: Documentation — webchat turn lifecycle + new env vars

**Files:**
- Modify or create: the webchat turn lifecycle reference under `maxy-code/platform/plugins/docs/references/` and the matching internal note under `maxy-code/.docs/` (locate the existing channel-pty-bridge / webchat doc first with a grep; edit it rather than creating a duplicate).

- [ ] **Step 1: Locate the existing doc**

Run: `cd maxy-code && grep -rln "channel-pty-bridge\|dispatchOnce\|log-follow-open\|WEBCHAT_TURN_TIMEOUT" .docs/ platform/plugins/docs/references/ 2>/dev/null`
Edit the file(s) that own the webchat turn lifecycle. If none exists, add a short
"Webchat turn lifecycle" section to the most relevant existing reference.

- [ ] **Step 2: Document the cold-start retry and env vars**

Add prose covering: the follower opens `GET /:sessionId/log?follow=1`; a cold spawn
returns `202 {pending:true}` until claude flushes its first JSONL line; the follower
retries every `CHANNEL_PTY_FOLLOWER_RETRY_MS` (default 1000) until a 200 stream arrives or
`CHANNEL_PTY_FOLLOWER_PENDING_MAX_MS` (default 120000) elapses, then gives up with
`follower-give-up`. Note the log signature for diagnosis: `follower-connect` →
`follower-retry`* → `follower-open` → `outbound`, mirroring the manager's `log-follow-open`.

- [ ] **Step 3: Commit**

```bash
git add maxy-code/.docs maxy-code/platform/plugins/docs/references
git commit -m "docs(610): webchat follower cold-start retry + env vars"
```

---

## Self-Review

**Spec coverage:**
- Follower retries on 202 → Task 3. ✓
- Env-tunable bound (give-up window + poll interval) → Task 3 Step 1, defaults 120000 / 1000. ✓
- Repro test (202→200, fan-out) → Task 2. ✓
- Give-up test → Task 4. ✓
- Warm-session regression (existing 4 tests unchanged) → Task 1 Step 2, Task 5 Step 1. ✓
- Observability log points (follower-connect / follower-retry / follower-open / follower-give-up) → Task 3 Step 2. ✓
- Manager untouched → no task modifies http-server.ts. ✓
- Docs → Task 6. ✓
- No behaviour change for other channels → no channel-specific code added; covered by shared-follower note in spec. ✓

**Placeholder scan:** Task 6 requires a grep-locate because the doc's exact path is not yet
known; the content to write is fully specified, only the target file is discovered at
execution. Acceptable — it is not a content placeholder.

**Type consistency:** `followerPendingMaxMs()` / `followerRetryMs()` used consistently;
`followStatuses` option name matches between helper definition (Task 1) and test use
(Tasks 2, 4); `follow-pending-timeout` error string matches between Task 3 and Task 4
assertion.
