docs: archive shipped openspec batches; add feature/plan/research notes

Move 13 shipped openspec change docs under openspec/changes/archived/.
Add docs/features/git-diff-panel, docs/plans/post-review-backlog, and
docs/research/cross-app-contract-ssot.md (the research behind the
@boocode/contracts SSOT work). Update BOOCHAT.md, BOOCODER.md, and
boocode_roadmap.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 21:20:33 +00:00
parent e5ce01ae72
commit 2a05d2f9fe
27 changed files with 2210 additions and 17 deletions

View File

@@ -0,0 +1,298 @@
# v2.6 Design — Persistent agent sessions
Reference implementations: `/opt/forks/opencode` (server + SDK),
`/opt/forks/paseo` (warm ACP + opencode server-manager + reasoning dedup).
> **⚠️ Reconciled 2026-05-31 — read the proposal's Reconciliation note first.** §2a and §3 describe the *original* design; four details were revised during implementation (per-session SSE; `(chat_id, agent)` key + `worktrees` table; `session.next.*` events; password deferred) — flagged inline. **Phases 23 and the Phase-1 UX (§2b, §6, §9) are not yet built**; updated lift sources for them are in new **§10**.
## 1. Architecture overview
```
BooCoder (systemd host service)
┌─────────────────────────────────────────────────────────────────┐
│ dispatcher (per-turn unit = tasks row) │
│ │ resolve backend + worktree + agent-session for the chat │
│ ▼ │
│ agent-pool ──────────────────────────────────────────────────┐ │
│ ├─ OpenCodeServerBackend (1 process, N sessions) │ │
│ │ `opencode serve` ◄── @opencode-ai/sdk ──► /event SSE │ │
│ └─ WarmAcpBackend[session] (1 stdio process per session) │ │
│ `goose acp` / `qwen --acp` ◄── ClientSideConnection │ │
└──────────────────────────────────────────────────────────────┘ │
│ broker.publishFrame (delta / reasoning_delta / tool_call) │
▼ │
web (CoderPane) — unchanged │
```
The **task row stays the per-turn unit**. What changes: instead of building a
fresh world per task, the dispatcher resolves the chat's *persistent* backend,
worktree, and agent-session, sends one prompt, streams events, diffs, and leaves
everything warm.
## 2. Backends
Common interface (`AgentBackend`):
```
interface AgentBackend {
ensureSession(sessionId, opts): Promise<AgentSessionHandle> // create-or-reuse
prompt(handle, input, { worktreePath, model, signal, onEvent }): Promise<TurnResult>
closeSession(handle): Promise<void>
dispose(): Promise<void> // backend teardown
health(): 'up' | 'down'
}
```
`onEvent` emits the same normalized events the current `acp-dispatch.ts` produces
(`text`, `reasoning`, `tool_call`, `tool_update`) so the broker-frame publishing and
`persistExternalAgentTurn` paths are reused unchanged.
### 2a. OpenCodeServerBackend (shared HTTP server)
> **⚠️ Shipped deltas vs the bullets below:** (a) **per-session SSE** — one `event.subscribe({directory})` per live opencode session (P1.5-a, `v2.6.2`), NOT one global `/event` loop; (b) events are **`session.next.*`** (`text.delta`/`reasoning.delta`/`tool.{called,success,failed}`), NOT `message.part.*`; (c) **`OPENCODE_SERVER_PASSWORD` deferred** — server binds loopback unsecured.
- **Spawn once per BooCoder process:** `opencode serve --hostname 127.0.0.1 --port <p>`
with `OPENCODE_SERVER_PASSWORD=<random-at-boot>` (verified: `serve.ts`, `network.ts`;
default port 4096, prints `opencode server listening on http://…`). Use the official
`@opencode-ai/sdk` (`createOpencodeServer` / `createOpencodeClient`) rather than
hand-rolling HTTP — it already parses the ready line and wraps routes.
- **One SSE subscription** to `GET /event`, consumed in a single read loop; events
demuxed by `properties.sessionID` → BooCode session. Reasoning arrives as
`message.part.delta` (`field: "reasoning"`) and `message.part.updated`
(`part.type: "reasoning"`); text as the `text` field; tool calls as tool parts.
- **One opencode session per BooCode chat.** `client.session.create()` once, store the
returned `id` in `agent_sessions.agent_session_id`. Per-turn: `client.session.prompt({
path:{id}, body:{ parts:[{type:'text',text}], model:"provider/model" }})`. Worktree
routing via the `x-opencode-directory` header (set to the session's persistent
worktree) so the agent operates inside it.
- **Reasoning dedup (port from Paseo `opencode-agent.ts`):** track
`streamedPartKeys` of `reasoning:${partID}`; when a `message.part.updated` reasoning
part arrives whose key was already streamed via delta, drop it. Prevents the
double-thought bug (covered by Paseo's `opencode-reasoning-dedup` e2e test).
### 2b. WarmAcpBackend (goose, qwen — stdio)
- **One persistent process + ACP connection per (chat, agent)** (Paseo's
`SpawnedACPProcess`): spawn `goose acp` / `qwen --acp` once, NDJSON over stdio,
`initialize``session/new` once; store the ACP session id in the
`agent_sessions` row. Each turn calls `session/prompt` on the same connection;
switching away and back resumes this same connection/session. Reuses the existing `acp-dispatch.ts`
`handleSessionUpdate` switch verbatim for `agent_message_chunk` /
`agent_thought_chunk` / `tool_call*`.
- **Child lifetime is the pool's, not a request's.** Spawn detached/managed; do not
tie the process to a single dispatch's abort signal (only the in-flight `prompt`
gets the per-turn signal). Mirrors the codecontext shim rule (CLAUDE.md): supervise
the child and react to its exit, don't let a request scope kill it.
## 3. Data model
> **⚠️ Shipped (P1.5-b, `v2.6.3``v2.6.4`):** `agent_sessions` is keyed **`(chat_id, agent)`** (the tab/chat is the agent-context unit; `chat_id` CASCADEs from `chats`), and a first-class **`worktrees`** table (one-per-session, survives session delete via `session_id` `SET NULL`) replaced `session_worktrees`. `tasks.chat_id` threads the tab id to the dispatcher. The SQL below is the original `(session_id, agent)` / `session_worktrees` shape — see `apps/coder/src/schema.sql` for the live DDL.
Agent switching is **free** within a chat (the picker is per-turn, not locked), so
the worktree is shared across agents but each agent keeps its own backend session.
That splits into two tables: one **shared worktree per chat**, and one **backend
session per (chat, agent)** pair.
```sql
-- One shared worktree per BooCode chat. All agents used in the chat operate in it.
CREATE TABLE IF NOT EXISTS session_worktrees (
session_id UUID PRIMARY KEY REFERENCES sessions(id),
worktree_path TEXT NOT NULL,
base_commit TEXT, -- project HEAD captured at create (diff baseline)
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
-- One backend session per (chat, agent). Resumed when the user switches back to
-- that agent, so each agent retains its own conversation memory across switches.
CREATE TABLE IF NOT EXISTS agent_sessions (
session_id UUID NOT NULL REFERENCES sessions(id),
agent TEXT NOT NULL, -- opencode | goose | qwen (native boocode needs no row)
backend TEXT NOT NULL, -- opencode_server | acp_warm
agent_session_id TEXT, -- opencode/ACP native session id (the memory handle)
server_port INTEGER, -- opencode server port (nullable)
status TEXT NOT NULL DEFAULT 'idle', -- idle | active | crashed | closed
last_active_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (session_id, agent),
CONSTRAINT agent_sessions_backend_chk CHECK (backend IN ('opencode_server','acp_warm')),
CONSTRAINT agent_sessions_status_chk CHECK (status IN ('idle','active','crashed','closed'))
);
```
Plus one column for attribution (drives the DiffPanel badges in §9):
```sql
-- Which agent staged each pending change. Stamped at queue time:
-- worktree-diff path → the task's agent; native boocode write tools → 'boocode';
-- manual RightRail create (v2.5.x) → NULL (renders as "manual").
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;
```
`tasks.worktree_path` already exists but was per-task; the persistent worktree now
lives on `session_worktrees`. `tasks` stays the per-turn record (state machine
unchanged) and gains nothing required. **Native boocode** keeps no `agent_sessions`
row — it has no warm backend; it reconstructs conversation context from the chat's
`messages` rows each turn (so it transparently sees every other agent's prior turns).
DB is the source of truth for reconnect after a BooCoder restart (the in-memory pool
rebuilds lazily from these tables on the next turn).
## 3a. Agent switching & continuity (the decided model)
Per the design review: **free switch, per-agent memory.** Concretely:
- **Picker is per-turn.** The message route already sends `provider`/`model` per
message; nothing locks a chat to one agent. v2.6 keeps that.
- **Worktree is shared.** All agents in a chat resolve the same `session_worktrees`
row, so file state carries across switches — *once applied*. (See the staging
boundary caveat below.)
- **Each agent resumes its own session.** Switching opencode → boocode → opencode
reuses opencode's stored `agent_session_id` (its memory intact), not a fresh one.
Lazy-create on first use of an agent in the chat; resume thereafter.
- **Native boocode is the universal reader.** It rebuilds from the `messages` table,
so it always sees the full transcript including other agents' turns.
- **Gap turns are NOT auto-replayed** into a resumed agent. When you return to
opencode, it sees the shared worktree + your new prompt, but did not "hear" the
boocode/goose turns in between. (A future refinement could inject a short
"changes since you last ran" preamble; out of scope for v2.6.)
- **Staging-boundary caveat (must be documented in the UI):** external agents edit
*inside the worktree*; native boocode reads/writes the *project root* via
`pending_changes`. So unapplied edits do **not** cross between a worktree agent and
native boocode — file continuity between the two only exists after apply. This is
an inherent consequence of v2.5's review-before-apply model, not a v2.6 bug.
- **No mid-turn switch.** Per-chat turns are serialized (§5); the agent is fixed for
the duration of an in-flight turn. The user can switch the picker for the *next*
turn while one is running, but it won't retarget the running turn.
## 4. Persistent worktree + incremental diff
- **Create** on the first turn of a chat (`createWorktree(projectPath, sessionId)`
— keyed by chat, not task), capturing project HEAD as `base_commit`. Persist the
`session_worktrees` row; all agents in the chat share it.
- **Reuse** every subsequent turn — no new worktree, no cleanup between turns.
- **Diff strategy (per turn):** diff the worktree against the **project HEAD baseline**
captured when the worktree was created. Each turn supersedes the prior
`pending_changes` row for that session (one accumulating unified diff, latest wins) —
mirrors how the anchored rolling summary supersedes itself. Avoids stacking N partial
diffs the user must reason about; the pending change always reflects the full current
delta of the worktree.
- **Apply** merges the worktree delta back to the project (existing `apply_pending`
path); after apply, re-baseline so the next turn's diff is relative to applied state.
- **Cleanup** on chat close/archive (new hook) and on `dispose()`; removes the
`session_worktrees` row + all `agent_sessions` rows for the chat. Orphan reaper
sweeps worktrees with no live `session_worktrees` row (extends the periodic sweeper).
## 5. Concurrency
Current dispatcher: global `running` boolean → strictly one task at a time.
Target: **per-session serialization, cross-session concurrency.**
- Replace the single `running` flag with a `Map<sessionId, Promise>` in-flight registry.
- `poll()` selects the oldest pending task whose **session has no in-flight turn**, so
two different chats run concurrently but a chat never has two turns at once (the agent
holds conversational state — overlapping prompts would corrupt it).
- The LISTEN/NOTIFY `tasks_new` fast path (v2.5.x) already triggers immediate polls;
the registry replaces the boolean guard there too.
## 6. Lifecycle & failure
- **Lazy spawn:** backend/worktree/agent-session created on first turn for a session.
- **Idle eviction:** pool evicts a backend/session after an idle TTL (e.g. 30 min);
worktree persists (DB-backed); next turn re-spawns and reattaches via stored
`agent_session_id` (opencode persists sessions on disk; ACP re-`session/new` if the
native id is gone).
- **Crash recovery:** supervise children; on exit mark `agent_sessions.status='crashed'`,
publish `chat_status='error'`, and rebuild on the next turn. opencode server crash
takes all opencode sessions down → restart server, recreate sessions.
- **Shutdown drain:** `app.addHook('onClose')` disposes the pool (close opencode server,
kill warm ACP children) after in-flight turns settle — extends the existing
dispatcher `stop()`.
- **systemd:** BooCoder already spawns agent children under `NoNewPrivileges`; long-lived
pool children are fine. Use `context.Background`-equivalent detachment so children
outlive the dispatch that created them.
## 7. Risks / open questions
- **opencode single-server blast radius:** one crash drops all opencode sessions. Mitigated
by on-disk session persistence + lazy re-create. Could later shard one server per project
if it bites.
- **Worktree disk growth:** persistent worktrees per session accumulate; the close-hook +
orphan reaper must be reliable or disk leaks. Add a max-live-worktrees cap with LRU evict.
- **SDK version coupling:** `@opencode-ai/sdk` is a new workspace dep pinned to the installed
opencode (1.15.x). Probe-time version check should warn on major drift.
- **Incremental-diff baseline correctness:** re-baselining after apply must handle the user
editing the project out-of-band; diff vs a stored base commit, not vs a moving target.
- **Reconnect fidelity:** after BooCoder restart, reattaching to a stored opencode session id
assumes the server (also restarted) still has it on disk — verify the SDK reattach path.
- **Cross-agent staging gap:** worktree agents and native boocode don't see each other's
*unapplied* edits (worktree vs project root). The UI must make this legible (e.g. show
which agent staged a pending change) so a switch doesn't look like lost work. A resumed
agent also won't have heard other agents' in-between turns — acceptable per the decided
model, but worth a small "N turns by other agents since you last ran" hint later.
- **Per-(chat,agent) session sprawl:** a chat that cycles through many agents accumulates
warm backends/worktree co-tenants; idle eviction (§6) must key on (chat,agent), and the
opencode server's session count is bounded by eviction, not per-chat.
## 8. File map (anticipated)
| File | Change |
|------|--------|
| `apps/coder/src/services/agent-pool.ts` | NEW — pool + backend interface |
| `apps/coder/src/services/backends/opencode-server.ts` | NEW — SDK + SSE demux + dedup |
| `apps/coder/src/services/backends/warm-acp.ts` | NEW — persistent ACP connection |
| `apps/coder/src/services/dispatcher.ts` | per-chat concurrency; resolve-or-create shared worktree + per-(chat,agent) backend session; no per-turn teardown |
| `apps/coder/src/services/worktrees.ts` | chat-keyed create; baseline capture; re-baseline-on-apply |
| `apps/coder/src/services/agent-turn-persist.ts` | reused as-is |
| `apps/coder/src/schema.sql` | `session_worktrees` + `agent_sessions` (per (chat,agent)) + `pending_changes.agent` column |
| `apps/coder/src/routes/sessions|tasks` | chat-close cleanup hook |
| `apps/coder/src/routes/pending.ts` | `agent` on `listPending` response; stamp `agent` in queue paths |
| `apps/coder/src/routes/agent-sessions.ts` | NEW — `GET /api/sessions/:id/agent-sessions` (§9b) |
| `apps/coder/package.json` | add `@opencode-ai/sdk` dep |
| `apps/web/src/components/panes/CoderPane.tsx` | `PendingChange.agent`; DiffPanel badges + staging hint; pass `sessionId` to composer |
| `apps/web/src/components/AgentComposerBar.tsx` | optional `sessionId` prop; resumed/new chip; export `providerIcon` |
| `apps/web/src/hooks/useAgentSessions.ts` | NEW — chat-scoped agent-session fetch |
| `apps/web/src/api/client.ts` | `api.coder.agentSessions(sessionId)` |
## 9. Frontend UX — agent attribution & switch affordances
The switching model (§3a) is only good if it's **legible**: the user must see which
agent did what, and whether switching back resumes or starts fresh. Pure read+display
over the new `agent` column and `agent_sessions` — no dispatch-logic change.
### 9a. Per-change agent attribution (DiffPanel) — Phase 1
- **Wire:** `listPending` returns the row; add `agent` to the response and to the
frontend `PendingChange` type (`CoderPane.tsx`, today `{id, file_path, operation, diff?, status}`).
- **UI:** each DiffPanel row gains a small agent badge before the file path — reuse the
`providerIcon()` switch from `AgentComposerBar` (extract to a shared helper / the new
`icons/ProviderIcons` module) + the provider label; `agent === null` → a neutral
"manual" chip. When the pending set spans >1 distinct agent, a one-line header note
("Changes from opencode, boocode") makes mixed provenance obvious.
### 9b. "Resumed" vs "new session" indicator (AgentComposerBar) — Phase 1
- **API:** `GET /api/sessions/:id/agent-sessions``[{ agent, status, has_session, last_active_at }]`
(reads `agent_sessions` for the chat). Chat-scoped, so it is NOT foldable into the
project-level provider snapshot.
- **Hook:** `useAgentSessions(sessionId)` — fetch on mount, refetch on `message_complete`
(same trigger `usePendingChanges` already uses).
- **UI:** a subtle chip right of the Provider picker:
- current provider has a live row → muted **"resumed"** (title: "Resuming <agent> · last active <relative>").
- native boocode (never has a row) → **"history"** (it reconstructs from the transcript).
- otherwise → **"new session"**.
- Render only when connected and the chat has ≥1 prior turn; hidden on a fresh chat.
- `AgentComposerBar` gains an optional `sessionId?: string` prop (CoderPane has it);
absent → render nothing, so BooChat and other callers are unaffected.
### 9c. Staging-boundary hint (DiffPanel) — Phase 3 polish
- When the selected provider is **native boocode** and pending changes were staged by a
**worktree agent** (or vice-versa), show a one-line muted caveat:
"opencode's edits live in its worktree — boocode won't see them until applied."
Derived purely from per-change `agent` + current `value.provider`; no new state.
Keeps the §3a staging caveat from biting silently.
## 10. Lift sources for the remaining phases (added 2026-05-31)
From the second external review (`boocode_code_review_v2.md`). These supersede/augment §2b, §6, §9 for the unbuilt work:
- **Phase 2 (warm ACP, goose/qwen) — `qwen --acp` is a validated reference.** qwen-code ships a real stdio multi-session ACP agent (`Map<sessionID,Session>`, `loadSession`/`unstable_resumeSession`, mid-session model/mode switch), so `warm-acp.ts` (§2b) wires qwen into the existing `acp-dispatch.ts` stack as planned. **Caveat:** goose ACP exposes **no `loadSession`/resume** → its cross-restart resume needs a different design than opencode's (re-`session/new` + accept memory loss, or replay). Cross-check qwen's `@agentclientprotocol/sdk@^0.14` vs BooCode's `^0.22` handshake before relying on `unstable_resumeSession`. (`boocode_code_review_v2.md` §5f, §5n.)
- **Phase 3 (lifecycle hardening) — lift from `openchamber` (MIT, same warm-opencode-server architecture), not Paseo.** Health-monitor + crash auto-restart + busy-aware restart (skip-while-busy + stale-grace) + port reclaim (`killProcessOnPort`/`waitForPortRelease`) + stall-detecting SSE reader — a concrete state machine for §6's "supervise children / rebuild on next turn" sketch. Worktree reaper: Paseo's worktree-archive cascade (soft-delete + `Promise.allSettled` fan-out) + superset's destroy-saga (preflight dirty/unpushed inspect + ordered failure semantics). Bound the warm server's per-session Maps (LRU) — long-lived-daemon leak class. (`boocode_code_review_v2.md` §5c, §5b, §5j.)
- **Fix-next (Phase 1/2) — the post-interrupt stale-terminal bug (confirmed live).** `opencode-server.ts:~307` settles any `session.idle` onto whatever `activeTurn` holds the session slot, with **no turn-identity guard** → after abort + new prompt, a stale `session.idle` from the cancelled turn settles the *new* turn early as success. Paseo fix `1d38aac` (suppress-terminal-until-next-user-message). **Now one-click reachable** since `v2.6.5` shipped the Send→Stop composer. (`boocode_code_review_v2.md` §1 #6, §3.)
- **Phase 1 UX (§9) — opencode already streams token/ctx usage.** `session.next.step.ended` carries `{tokens, cost}` on the wire (SDK already installed) → consume it to fill ctx/token usage for opencode sessions, closing the "no usage for external agents" gap; surfaces beside the §9b chip. (`boocode_code_review_v2.md` §1 #8, §3.)

View File

@@ -0,0 +1,122 @@
# v2.6 Persistent agent sessions (warm processes + OpenCode server)
**Status:** Phase 0 + Phase 1 + P1.5-a/b **shipped** (`v2.6.0``v2.6.4`); Phase 1-UX, Phase 2, Phase 3, and unit tests **remaining.** (Reconciled 2026-05-31.)
**Depends on:** v2.2 Paseo providers (ACP dispatch), v2.3 provider lifecycle (registry/snapshot)
**Reference fork:** `/opt/forks/paseo`, `/opt/forks/opencode`; **remaining-phase lift sources in `boocode_code_review_v2.md`** (openchamber → Phase 3, qwen-code → Phase 2).
**Pairs with:** the v2.5.x MessageBubble "Thinking" render fix — reasoning already flows; this batch is about persistence, not capability.
> **Reconciliation note (2026-05-31).** Four design details below were revised *during* implementation; the original prose/SQL is now superseded:
> 1. **Per-session SSE** — one `event.subscribe({directory})` per live opencode session (P1.5-a, `v2.6.2`) replaced the single global `/event` read loop (design §2a).
> 2. **`agent_sessions` is keyed `(chat_id, agent)`**, and a first-class **`worktrees`** table replaced `session_worktrees` (P1.5-b, `v2.6.3`); `session_id`/`worktree_id` are informational `SET NULL` (`v2.6.4`). The design §3 SQL is the *original* shape.
> 3. **opencode streams `session.next.*` events**, not `message.part.*` (design §2a's event names were wrong).
> 4. **`OPENCODE_SERVER_PASSWORD` was deferred** — the warm server binds loopback unsecured (design §2a specified a random password). Basic-auth scheme since confirmed (openchamber, `boocode_code_review_v2.md` §5c) if ever wanted.
## Why
BooCode dispatches external agents (opencode, goose, qwen) **one-shot per task**:
per task the dispatcher cuts a fresh worktree (`createWorktree(projectPath, taskId)`),
spawns `opencode acp` / `goose acp` / `qwen --acp`, runs **one** turn, then tears
down the process *and* the worktree (`dispatcher.ts:runExternalAgent`). Consequences:
- **No session continuity.** A follow-up message in the same chat creates a new
task with a new worktree and a new agent process. The agent has no memory of
the prior turn beyond what BooCode replays as chat history, and it cannot see
the files it edited last turn (fresh worktree every time).
- **Cold start every turn.** Each turn pays the process spawn + ACP `initialize`
handshake (and, for some agents, model load) before any work happens.
- **Diverges from Paseo.** Paseo runs **OpenCode as a long-lived HTTP server**
(`opencode serve` + `@opencode-ai/sdk`, SSE `/event` stream) and keeps **goose /
qwen as warm stdio-ACP processes** (`SpawnedACPProcess`: one ACP connection,
`newSession()` once, many `prompt()`s). BooCode rebuilds the world per turn.
This batch makes a BooCode chat map to a **persistent agent backend + a persistent
worktree** that live for the whole conversation, so turns are warm and the agent
sees its own accumulating edits. Reasoning passthrough is **already solved** (ACP
`agent_thought_chunk``reasoning_delta` → the new MessageBubble Thinking block);
this batch does not touch it beyond porting OpenCode's reasoning-dedup.
## Decisions locked (from design review)
- **Worktree model:** *Persistent worktree per session.* A chat owns one worktree
for the whole conversation; each turn the agent sees prior edits; pending_changes
accumulate; worktree is cleaned on session close, not per turn.
- **Agent switching:** *Free switch, per-agent memory.* The picker stays per-turn
(not locked to a chat). The worktree is shared across agents; each agent keeps its
own backend session, resumed when you switch back to it. Native boocode reconstructs
from chat history (so it sees every agent's turns); a resumed agent does not auto-
ingest the gap turns. Data model: one shared worktree per chat + one backend session
per `(chat, agent)` pair. Caveat: unapplied edits don't cross the worktree↔project
boundary between external agents and native boocode (a v2.5 review-model consequence).
- **Transport per agent (matches Paseo exactly):**
- **OpenCode** → one shared `opencode serve` HTTP server, driven via
`@opencode-ai/sdk`; one opencode *session* per BooCode chat (multi-session,
directory-routed via `x-opencode-directory`).
- **Goose / Qwen** → warm **stdio** ACP process per live session. Their HTTP
"server" modes are just ACP-over-HTTP wrappers (goose: undocumented/internal;
qwen `serve`: an HTTP bridge around a single `qwen --acp` child) — no gain over
stdio, so we keep stdio ACP like Paseo does.
## Scope
### In scope
1. **Agent process pool** (`apps/coder/src/services/agent-pool.ts`) — owns long-lived
backends, lazy spawn, idle eviction, crash restart, shutdown drain.
2. **OpenCode server backend** — spawn `opencode serve`, hold SDK client + single
SSE subscription demuxed by opencode `sessionID` → BooCode session; port +
`OPENCODE_SERVER_PASSWORD` managed at boot.
3. **Warm ACP backend** — persistent `SpawnedACPProcess`-style connection for
goose/qwen reused across turns (one `newSession()`, many prompts).
4. **Persistent worktree lifecycle** — worktree created on first turn of a session,
reused, diffed incrementally into `pending_changes`, cleaned on session close.
5. **Session ↔ backend ↔ worktree mapping** — new `agent_sessions` table.
6. **Per-session concurrency** — replace the dispatcher's global single-flight
`running` guard with per-session serialization (different sessions run
concurrently; one turn at a time within a session).
7. **OpenCode reasoning dedup** — port Paseo's `streamedPartKeys` partID dedup so
reasoning isn't double-emitted (delta + final part).
8. **Switch-aware UI** (design §9) — per-change agent attribution in the DiffPanel
(`pending_changes.agent` column + badges), a resumed/new-session chip on the
AgentComposerBar (chat-scoped `agent-sessions` endpoint), and a staging-boundary
hint so the worktree↔project gap is legible.
9. **Tests + smoke** — pool lifecycle unit tests; multi-turn opencode smoke; switch
round-trip smoke; attribution/indicator smoke.
### Out of scope (this batch)
- Claude PTY→structured transport (separate deferred work — claude stays PTY here).
- Goose/qwen HTTP server modes (intentionally not used).
- Frontend redesign — existing CoderPane multi-turn chat UI already supports
follow-ups; only backend continuity changes.
- Replacing `acp-dispatch.ts` wholesale — warm backend reuses its event handlers.
- Cross-host agent servers (opencode server stays local to the BooCoder host).
## Non-goals
- Multi-user session sharing (single-user homelab).
- Multiple concurrent turns within one agent session (the agent holds conversational
state; turns within a session are serialized).
## Success criteria
(Status reconciled 2026-05-31: ✅ met · 🟡 partial · ⬜ remaining)
- ✅ Send two messages in one external-agent chat → second turn reuses the same agent
session **and** the same worktree (verified: no second `createWorktree`, agent
references files it edited in turn 1). *(opencode; Smoke 1, `v2.6.1`)*
- ✅ Warm-start latency for turn 2 materially below turn 1 (no spawn/handshake). *(turn 2 ~9× faster, `v2.6.1`)*
- ✅ opencode reasoning shows once per thought (no dupes) in the Thinking block.
- ⬜ Killing the opencode server mid-session → pool restarts it and the next turn
recovers (opencode persists sessions on disk). *(Phase 3 — `opencode-server.ts` still comments "recovery is Phase 3")*
- 🟡 Switch opencode → boocode → opencode in one chat → opencode resumes its *same*
session (its memory intact), boocode saw opencode's turns as history, and all three
shared the one worktree. No agent is locked to the chat. *(opencode↔boocode works; goose/qwen warm side is Phase 2 → full round-trip = Smoke 2b, unshipped)*
- ⬜ Closing/archiving a session removes its worktree; BooCoder restart drains cleanly. *(delete-guard shipped `v2.6.2`, but the close→cleanup hook + orphan reaper are Phase 3)*
- ✅ Existing one-shot paths (arena, `new_task` tool, MCP create-task) still work. *(dispatcher resolve-or-create fallback)*
## Deliverables
| Doc | Purpose |
|-----|---------|
| [`design.md`](./design.md) | Architecture, backends, data model, worktree/diff strategy, lifecycle, risks |
| [`tasks.md`](./tasks.md) | Phased implementation checklist |

View File

@@ -0,0 +1,101 @@
# 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<sessionId,Promise>`; `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.13.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.1T.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.1T.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.1U.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.1T.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.