# v2.6 Tasks — Persistent agent sessions Phased so each phase is independently shippable and smoke-testable. Phase 1 (OpenCode server) delivers the most value on the cleanest API; goose/qwen warm ACP follows; hardening last. ## Phase 0 — Foundations (no behavior change) — ✅ SHIPPED `v2.6.0-phase0-foundations` - [x] 0.1 Tables added to `apps/coder/src/schema.sql` (idempotent) + `pending_changes.agent` column. *Later re-keyed to `(chat_id, agent)` + `worktrees` table in P1.5-b.* - [x] 0.2 `AgentBackend` / `AgentSessionHandle` interface + normalized `AgentEvent` union — `apps/coder/src/services/agent-backend.ts`. - [x] 0.3 `agent-pool.ts` scaffolded (lazy get-or-create, health, `dispose()`, `onClose` hook). ## Phase 1 — OpenCode server backend (multi-turn, warm) — ✅ SHIPPED `v2.6.1-phase1-opencode` (Smoke 1 verified) - [x] 1.1 `@opencode-ai/sdk` added to `apps/coder/package.json`. - [x] 1.2 `backends/opencode-server.ts`: spawn `opencode serve`, allocated port, wait for ready line. *`OPENCODE_SERVER_PASSWORD` deferred — loopback-unsecured.* - [x] 1.3 SSE read loop + demux + text/reasoning/tool mapping. *Superseded by per-session SSE (P1.5-a); events are `session.next.*`, not `message.part.*`.* - [x] 1.4 Paseo `streamedPartKeys` reasoning dedup (delta vs final part). - [x] 1.5 `ensureSession` reuse/resume. *Re-keyed `(chat_id, agent)` in P1.5-b.* - [x] 1.6 `prompt` via SDK with worktree `directory` + `model`. - [x] 1.7 Dispatcher routes `agent==='opencode'` to the pool backend; broker frames + `persistExternalAgentTurn` identical. - [x] 1.8 Persistent worktree, chat-keyed, base commit captured, reused across turns/agents. *Now the first-class `worktrees` table (P1.5-b).* - [x] 1.9 Per-session concurrency: `Map`; `poll()` skips in-flight sessions. - [x] 1.10 Per-turn diff supersedes prior `pending_changes` row (latest-wins). - [x] **Smoke 1** — verified end-to-end (two turns, same session + worktree, turn 2 ~9× faster, reasoning once). ## Phase 1.5 — concurrency + chat-keying follow-ups (added during impl, not in original plan) — ✅ SHIPPED - [x] P1.5-a **Per-session SSE** (`v2.6.2-delete-guard-and-sse`): one `event.subscribe({directory})` per live opencode session, each with an `AbortController`; `sessionID` demux guard + zombie-loop fix — replaces task 1.3's single global loop. Bundled: session-delete work-loss guard (`/worktree-risk`). - [x] P1.5-b **Re-key `agent_sessions` → `(chat_id, agent)`** + first-class `worktrees` table (`v2.6.3-chatkey-and-skills`); `tasks.chat_id` threaded; `runOpenCodeServerTask` resolve-or-creates a chat for session-less creators; cross-chunk dcp-strip. FK convergence to `SET NULL` (`v2.6.4-agent-sessions-fk`). ## Phase 1 (UX) — Attribution & switch affordances (design §9) — ✅ SHIPPED `v2.6.8-agent-attribution` (Smoke U pending live frontend deploy) - [x] U.1 Stamp `pending_changes.agent` at queue time — native tools default `'boocode'`, dispatched external → `task.agent`, manual RightRail → `NULL` (`pending_changes.ts`, `dispatcher.ts`). - [x] U.2 `agent` flows through `listPending` + backend & frontend `PendingChange` types. - [x] U.3 Shared `components/coder/providerIcons.tsx`; DiffPanel per-row agent badge + "Changes from X, Y" multi-agent note (§9a). - [x] U.4 `GET /api/sessions/:id/agent-sessions` route + `api.coder.agentSessions` + `useAgentSessions` hook (refetch on message-complete) (§9b). - [x] U.5 `AgentComposerBar` optional `sessionId` prop → resumed/history/new-session chip; hidden on fresh chats + other callers (§9b). - [x] U.6 Consume opencode `session.next.step.ended` → accumulate `input_tokens`/`output_tokens`/`cost` on `agent_sessions` (new cols). Backend persist only; UI surfacing deferred. - [ ] **Smoke U:** stage edits with opencode then boocode → DiffPanel badges each row to the right agent; composer shows "resumed" when re-selecting opencode, "new session" for goose. *(pending live frontend deploy — Docker container rebuild)* ## Phase 2 — Warm ACP backend (goose, qwen) — ✅ SHIPPED `v2.6.9-warm-acp` (Smoke 2/2b pending live) > **Lift (design §10):** `qwen --acp` is a validated reference (real stdio multi-session, `loadSession`/resume) — wire qwen into the existing `acp-dispatch.ts` stack. **goose ACP has no `loadSession`/resume** → cross-restart resume needs a different design (re-`session/new` + accept memory loss, or replay). Cross-check qwen `@agentclientprotocol/sdk@^0.14` vs BooCode `^0.22` before relying on `unstable_resumeSession`. Do **qwen first** to de-risk. - [x] 2.1 `backends/warm-acp.ts` `WarmAcpBackend` — persistent spawn + `ClientSideConnection`; `initialize` + `session/new` once per `(chat,agent)`. `handleSessionUpdate` extracted to a shared pure `acp-event-map.ts` (one-shot path byte-identical). - [x] 2.2 `prompt`: `session/prompt` on the warm connection per turn; abort = `session/cancel` the prompt only (never kills the child). - [x] 2.3 Child supervision: pool-owned lifetime; `exit` marks `agent_sessions.status='crashed'` → re-spawn next turn. - [x] 2.4 Dispatcher routes `goose`/`qwen` chat-tab tasks to the warm backend via pure `shouldUseWarmBackend(task)` (needs `session_id`+`chat_id`); one-shot `runExternalAgent` fallback kept for arena/MCP/`new_task`. *(SDK note resolved: installed `@agentclientprotocol/sdk@^0.22.1` has stable `resumeSession`/`loadSession`; resume moot in the warm hot path, deferred to Phase 3.)* - [ ] **Smoke 2:** two messages in a goose chat reuse the same process + ACP session + worktree; reasoning still renders; no per-turn respawn. - [ ] **Smoke 2b (switch round-trip):** opencode → boocode → opencode in one chat — opencode resumes the SAME `agent_session_id` (memory intact), boocode saw opencode's turns as history, all three shared the one worktree, and no agent was locked to the chat. ## Phase 3 — Lifecycle hardening — ✅ COMPLETE (`v2.6.10` 3.1–3.6; `v2.6.11` closed 3.7 + the apps/server close-hook caller) > **Lift (design §10):** hardening from **openchamber** (MIT, same warm-opencode-server architecture) — health-monitor + crash auto-restart + busy-aware restart + port reclaim (`killProcessOnPort`/`waitForPortRelease`) + stall-SSE = a concrete state machine for 3.1/3.2/3.6. Reaper (3.3/3.4): Paseo worktree-archive cascade + superset destroy-saga (preflight dirty/unpushed inspect) + LRU cap on warm-server Maps. Do crash-recovery + reaper together (shared supervision loop). - [x] 3.1 Idle TTL eviction per `(chat, agent)` (`AGENT_POOL_IDLE_TTL_MS`=30min) + LRU cap (`AGENT_POOL_MAX_LIVE`=10), busy never evicted; reattach next turn. Pure `lifecycle-decisions.ts` (TDD). - [x] 3.2 Crash recovery: openchamber health-monitor + busy-aware-restart + stale-grace state machine in `opencode-server.ts` (+ port reclaim) + `warm-acp.ts`. opencode → fresh sessions; ACP → re-`session/new`. F.1 guard + U.6 usage preserved. - [x] 3.3 Close hooks (`/api/chats/:id/close`, `/api/sessions/:id/close`) → `closeChat` evicts backends + archives the `worktrees` row + removes the worktree. **apps/server caller wired in `v2.6.11`** (`coder-notify.ts`, fire-and-forget on session-delete + chat archive/delete). - [x] 3.4 Orphan worktree reaper (periodic, 1h grace, superset-style dirty/unpushed preflight, Paseo soft-delete) + LRU cap on the pool. - [x] 3.5 Re-baseline `worktrees.base_commit` after a successful `apply_pending` (both apply routes). - [x] 3.6 Reconnect integration test (DB-opt-in): restart mid-session → next turn reattaches/recreates from `agent_sessions`/`worktrees`. - [x] 3.7 Staging-boundary hint in DiffPanel (§9c) — `v2.6.11`: muted one-liner when the selected provider can't see another agent's unapplied worktree edits (derived from per-change `agent` + current provider; no new state). ## Tests — ⬜ REMAINING (none of T.1–T.3 exist yet) - [ ] T.1 `agent-pool` unit: get-or-create, idle evict, dispose drains in-flight (DB-opt-in pattern). - [ ] T.2 opencode SSE demux + reasoning dedup unit (fixture event stream). *Fold in an F.1 interrupt-bug regression case.* - [ ] T.3 per-session concurrency: two sessions run concurrently, one session serializes. ## Docs - [~] D.1 `CLAUDE.md` BooCoder-dispatch section **done** (v2.6.1 / v2.6.4 doc-syncs); **`BOOCODER.md` health/contract still pending** (no v2.6 warm-server mentions). - [~] D.2 `@opencode-ai/sdk` dep noted; `OPENCODE_SERVER_PASSWORD` env n/a (deferred — loopback-unsecured). - [x] D.3 `CHANGELOG.md` entries per tag (`v2.6.0`–`v2.6.4`) — shipped as 5 tags, not the single planned `-persistent-agent-sessions`. ## Build / deploy gate — ✅ (per shipped tags; re-run per remaining batch) - [x] B.1 `pnpm -C apps/server build && pnpm -C apps/coder build` clean. - [x] B.2 `pnpm -C apps/server test` green. *(v2.6-specific T.1–T.3 units still unwritten.)* - [x] B.3 Deployed (`sudo systemctl restart boocoder`; `curl :9502/api/health`). ----- ## Fix-next (before Phase 2) — ✅ SHIPPED `v2.6.7-interrupt-guard` - [x] F.1 **Post-interrupt stale-terminal guard.** opencode emits one trailing `session.idle`/`session.error` for a cancelled turn (sessionID only, no turn id) which settled the *next* turn early. Fixed with a pure per-session guard (`backends/turn-guard.ts`: `armAbortGuard`/`noteTurnActivity`/`consumeTerminal` over `swallowNextTerminal`) wired into `opencode-server.ts` (arm on abort, swallow the orphan terminal, self-heal on next-turn activity). 3 regression tests (`turn-guard.test.ts`), TDD. Paseo parallel: `1d38aac`. ## Remaining — recommended order (implementation plan, 2026-05-31) 1. ~~**F.1 interrupt-bug fix**~~ — ✅ shipped `v2.6.7-interrupt-guard` (3 regression tests, TDD). 2. ~~**Phase 1-UX** (U.1–U.6)~~ — ✅ shipped `v2.6.8-agent-attribution` (3 parallel agents, disjoint files; 9 new tests). Smoke U pending the frontend Docker rebuild. 3. ~~**Phase 2 — warm ACP, qwen first then goose**~~ — ✅ shipped `v2.6.9-warm-acp` (15 new tests; one-shot path preserved). Smoke 2 + 2b pending live exercise post-deploy. 4. **Phase 3 — lifecycle hardening** — lift openchamber's state machine; do crash-recovery (3.1/3.2/3.6) + worktree reaper (3.3/3.4 + LRU) together (shared supervision loop). Closes the two ⬜ success criteria (server-crash recovery, close→cleanup). 5. **Tests T.1–T.3 + `BOOCODER.md` (D.1 remainder)** — backfill alongside each phase, not at the end. Each phase stays independently shippable + smoke-testable (original phasing holds). Tag monotonically from `v2.6.7`, one batch per phase.