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:
@@ -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 2–3 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.)
|
||||
@@ -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 |
|
||||
@@ -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.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.
|
||||
Reference in New Issue
Block a user