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:
61
openspec/changes/archived/agent-status-normalize/proposal.md
Normal file
61
openspec/changes/archived/agent-status-normalize/proposal.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Normalized external-agent status (#10, scoped)
|
||||
|
||||
**Status:** in progress (started 2026-06-01)
|
||||
**Source:** `boocode_code_review_v2.md` §1 #10, §5j (superset, Elastic License 2.0 — PATTERN-ONLY,
|
||||
clean-room; `/opt/forks/superset/.../map-event-type.ts`, `notify-hook.template.sh`, `agent-setup/*`).
|
||||
**Decision (Sam, 2026-06-01):** scoped status-publish now; config-injection notify-hook as a follow-on.
|
||||
|
||||
## Why (corrected premise)
|
||||
BooCoder already *observes* agent lifecycle (warm-acp/opencode/SDK backends know active/idle/crashed;
|
||||
the permission-waiter knows blocked) but never **publishes a normalized per-`(chat,agent)` status** to the
|
||||
UI — so blocked-on-permission is invisible and crash/idle aren't pushed proactively. The `AgentComposerBar`
|
||||
dot only shows WS liveness. This batch publishes the status BooCoder already knows; the heavier
|
||||
config-injection notify-hook (for out-of-band signals) is the documented follow-on.
|
||||
|
||||
## State model (clean-room from superset's `mapEventType`)
|
||||
Superset collapses ~30 vendor event names → 3 signals: **Start** (working), **PermissionRequest**
|
||||
(blocked), **Stop** (done). BooCoder adds idle (after done) + error (crash/fail). Normalized status:
|
||||
`working | blocked | idle | error`.
|
||||
|
||||
## Pinned frame contract (server + web, byte-identical, parity-tested)
|
||||
```ts
|
||||
{ type: 'agent_status_updated', chat_id: Uuid, agent: string,
|
||||
status: 'working' | 'blocked' | 'idle' | 'error', reason?: string, at: IsoTimestamp }
|
||||
```
|
||||
Added to `apps/server/src/types/ws-frames.ts` AND `apps/web/src/api/ws-frames.ts` (the `ws-frames` parity
|
||||
test), plus the web `WsFrame` union in `apps/web/src/api/types.ts`. Published via the coder's
|
||||
`broker.publishFrame` (validated against the server `WsFrameSchema`).
|
||||
|
||||
## Clean-room normalize helper (built now, reused by the injection follow-on)
|
||||
`apps/coder/src/services/normalize-agent-status.ts`:
|
||||
`normalizeAgentEvent(raw: string): 'working' | 'blocked' | 'done' | null` — a clean-room reimplementation
|
||||
of the vendor-event-name → bucket mapping (the event names are facts about each agent's hooks:
|
||||
`SessionStart`/`UserPromptSubmit`/`PostToolUse`→working; `PreToolUse`/`Notification`/`PermissionRequest`/
|
||||
`exec_approval_request`→blocked; `Stop`/`session_end`/`task_complete`→done). The scoped publish points use
|
||||
BooCoder's own already-normalized turn boundaries; this helper exists so the config-injection follow-on
|
||||
(which receives raw vendor event names POSTed from agent hooks) reuses it. Unit-tested.
|
||||
|
||||
## Publish points (BooCoder's existing observation — no per-backend change)
|
||||
- Dispatcher (`dispatcher.ts`) turn boundaries, for every external-agent path (warm-acp/opencode/sdk/pty):
|
||||
`working` at turn start, `idle` on clean completion, `error` on failure.
|
||||
- Permission-waiter (`permission-waiter.ts` / the `setPermissionHooks` publish in `index.ts`): `blocked`
|
||||
when a permission is requested, back to `working` when resolved.
|
||||
A small `publishAgentStatus(broker, chatId, agent, status, reason?)` helper centralizes the frame.
|
||||
|
||||
## Frontend
|
||||
- `CoderPane.tsx` tracks the latest `agent_status_updated` per `(chat, agent)` (a small live map; reset on
|
||||
chat switch).
|
||||
- `AgentComposerBar.tsx` renders a normalized status dot beside the existing session chip (reuse the
|
||||
`StatusDot` visual language: working=spinner/green, blocked=amber, idle=gray, error=red), distinct from
|
||||
the WS-liveness `connected` dot.
|
||||
|
||||
## Follow-on (documented, not built): config-injection notify-hook
|
||||
Clean-room re-derive superset's `agent-setup`: inject a notify hook into each agent's native config
|
||||
(claude `~/.claude/settings.json`, opencode plugin, codex/gemini templates) that POSTs
|
||||
`{agent, chat_id, eventType}` to a new `POST /api/coder/agent-status` endpoint, which runs
|
||||
`normalizeAgentEvent` → publishes the SAME `agent_status_updated` frame. Reuses everything this batch
|
||||
builds. Catches out-of-band signals BooCoder's dispatch can't see.
|
||||
|
||||
## Verify
|
||||
- `pnpm -C apps/coder test` (+ normalize-agent-status tests) + `pnpm -C apps/server test` (ws-frames parity)
|
||||
- `pnpm -C apps/server build && pnpm -C apps/coder build`; `npx tsc -p apps/web/tsconfig.app.json --noEmit`
|
||||
@@ -0,0 +1,68 @@
|
||||
# Claude Agent SDK backend + clean-room PostgresSessionStore (#9)
|
||||
|
||||
**Status:** in progress (started 2026-06-01)
|
||||
**Source:** `boocode_code_review_v2.md` §1 #9, §5h/§5i (happy + SDK `.d.ts`). Decision §6.2: lean SDK.
|
||||
**SDK:** `@anthropic-ai/claude-agent-sdk@0.3.159` (installed, Commercial Terms — runtime dep OK, code
|
||||
reference-only; the store is **clean-room** from the real interface, not vendored).
|
||||
|
||||
Replace BooCoder's one-shot PTY claude dispatch with a warm, resumable Claude-SDK backend. Two parts:
|
||||
the clean-room session store (fully testable here) and the backend + wiring (live pump needs a host
|
||||
smoke against real `claude`).
|
||||
|
||||
## Ground-truth SDK API (from the installed `sdk.d.ts`)
|
||||
- `query({ prompt: string | AsyncIterable<SDKUserMessage>, options?: Options }): Query` where
|
||||
`Query extends AsyncGenerator<SDKMessage, void>`.
|
||||
- `Options`: `sessionStore?: SessionStore`, `resume?: string`, `model?`, `cwd?`,
|
||||
`pathToClaudeCodeExecutable?`, `canUseTool?`, `permissionMode?`, `env?`, `allowedTools?`.
|
||||
- `SessionStore = { append(key, entries): Promise<void>; load(key): Promise<SessionStoreEntry[]|null>;
|
||||
listSessions?(projectKey): Promise<{sessionId,mtime}[]>; delete?(key): Promise<void>;
|
||||
listSubkeys?({projectKey,sessionId}): Promise<string[]> }`.
|
||||
- `SessionKey = { projectKey: string; sessionId: string; subpath?: string }` (undefined subpath = main
|
||||
transcript; empty string invalid — store maps undefined→'' internally).
|
||||
- `SessionStoreEntry = { type: string; uuid?: string; timestamp?: string; [k]: unknown }` (opaque JSONL).
|
||||
- Messages: `SDKSystemMessage{subtype:'init'}` carries `session_id` (+ model/tools); `SDKResultMessage`
|
||||
(success/error) ends a turn with `result`, `usage`, `total_cost_usd`; `SDKPartialAssistantMessage` /
|
||||
`SDKAssistantMessage` carry text/thinking/tool blocks.
|
||||
|
||||
## Part 1 — Clean-room PostgresSessionStore (testable now)
|
||||
- Schema (`apps/coder/src/schema.sql`): a generic append-only entry table
|
||||
`claude_session_entries(id BIGSERIAL PK, project_key TEXT, session_id TEXT, subpath TEXT DEFAULT '',
|
||||
entry JSONB, created_at TIMESTAMPTZ DEFAULT clock_timestamp())` + index `(project_key, session_id,
|
||||
subpath, id)`. (The store is generic per the SDK's key; the chat↔session ownership lives in
|
||||
`agent_sessions`, not here.)
|
||||
- `apps/coder/src/services/backends/claude-session-store.ts`: `PostgresSessionStore` implementing the
|
||||
real `SessionStore` type over `Sql`. `append` = ordered multi-INSERT (id = order); `load` = SELECT
|
||||
ORDER BY id → array or null; `listSessions` = group main-transcript rows, mtime = max(created_at) ms;
|
||||
`delete` = scoped delete (subpath given → that subpath; omitted → whole session); `listSubkeys` =
|
||||
DISTINCT non-'' subpaths. Pure SQL, no SDK import needed beyond the `SessionStore` type.
|
||||
- Tests `__tests__/claude-session-store.test.ts` (DB-opt-in, mirror `checkpoints.test.ts`): append→load
|
||||
round-trip + order, null on unseen key, subpath isolation (main vs subagent), listSessions mtime,
|
||||
delete scoping, listSubkeys.
|
||||
|
||||
## Part 2 — ClaudeSdkBackend + wiring (live pump needs host smoke)
|
||||
- `agent_sessions.backend` CHECK adds `'claude_sdk'`.
|
||||
- `apps/coder/src/services/backends/claude-sdk.ts`: a `ClaudeSdkBackend` implementing `AgentBackend`
|
||||
(mirror `warm-acp.ts`/`opencode-server.ts`). `ensureSession` resolves the resume id from
|
||||
`agent_sessions(chat_id,'claude').agent_session_id`; `prompt` drives one persistent `query()` in
|
||||
streaming-input mode (a pushable `AsyncIterable<SDKUserMessage>` fed per turn) with
|
||||
`{ sessionStore, resume, model, cwd: worktreePath, pathToClaudeCodeExecutable: installPath }`,
|
||||
reads the `AsyncGenerator<SDKMessage>` until `result`, captures `session_id` from the `init` message
|
||||
and persists it to `agent_sessions`. A pure `mapSdkMessage(msg): AgentEvent[]` (unit-tested) maps
|
||||
partial/assistant/tool/thinking → the existing `AgentEvent` union; `result.usage`/`total_cost_usd`
|
||||
accumulate onto `agent_sessions` (like opencode U.6). `isBusy`/`closeSession`/crash mirror the ACP
|
||||
backend.
|
||||
- Routing: add `claude` to the warm path (`warm-acp-routing.ts` or a sibling `shouldUseClaudeSdk`),
|
||||
with the existing PTY `runExternalAgent` kept as the **fallback** (session-less creators + if the SDK
|
||||
backend fails to start). Provider registry: claude stays selectable; transport reflects the SDK path.
|
||||
- Frames + persistence identical to the warm-ACP path (`persistExternalAgentTurn`, broker frames).
|
||||
|
||||
## Verify
|
||||
- Part 1: `pnpm -C apps/coder test` + DB-opt-in store tests against dev postgres; build clean.
|
||||
- Part 2: `pnpm -C apps/coder build` + `npx tsc -p apps/coder/tsconfig.json --noEmit` (typechecks
|
||||
against the REAL SDK types) + pure-mapper unit tests. **Live pump + resume across turns: host smoke
|
||||
against real `claude` (auth required) — cannot run from the dev container.**
|
||||
|
||||
## Open flags
|
||||
- SDK peer-deps want `zod@^4`; workspace is `zod@3.25.76` (installed with a warning) — watch at runtime.
|
||||
- `pathToClaudeCodeExecutable` from `available_agents.install_path`; the SDK spawns the same `claude`
|
||||
binary the PTY path uses. ANTHROPIC auth/env must reach the child (host concern).
|
||||
51
openspec/changes/archived/license-debt-mit/proposal.md
Normal file
51
openspec/changes/archived/license-debt-mit/proposal.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# License-debt — relicense AGPL-3.0 → MIT
|
||||
|
||||
**Status:** in progress (started 2026-06-01)
|
||||
**Decision:** Sam, 2026-05-31 — relicense BooCode back to MIT.
|
||||
**Source:** `boocode_code_review_v2.md` §1 #1, §5k; roadmap `## License-debt` batch.
|
||||
|
||||
## Why
|
||||
|
||||
The tree is **currently AGPL-3.0** — root `LICENSE` is GNU Affero GPL v3 and all five
|
||||
`package.json` declare `"license": "AGPL-3.0-only"`. Cause: the `v2.4.0`/`v2.4.1`
|
||||
Unsloth-Studio lifts pulled in three AGPL-3.0-only files. BooCode is network-served, so
|
||||
AGPL §13 network-copyleft is a live liability. Clearing the three files makes the MIT flip
|
||||
valid; nothing else AGPL remains once they are gone.
|
||||
|
||||
## Core insight (supersedes the roadmap's staged steps)
|
||||
|
||||
The roadmap entangled the relicense with retiring `tool-call-parser.ts` behind a live
|
||||
qwen3.6 validation window. That is **not necessary**: the Unsloth-ported algorithm
|
||||
(`parseToolCallsFromText` / `scanBalancedBraces` + unused constants) is **dead code** —
|
||||
no production consumer imports it (verified: only the file and its test reference it). The
|
||||
load-bearing parser (`extractToolCallBlocks`, under the file's own "BooCode streaming
|
||||
helpers" banner) and `stripToolMarkup` are BooCode-authored. So the relicense **strips
|
||||
provenance, not capability** — zero behavior change, no validation gate. The
|
||||
native-llama-server-parsing retirement remains a separate, optional future optimization.
|
||||
|
||||
## The three AGPL-3.0-only files to clear
|
||||
|
||||
1. `apps/server/src/services/web/html-to-md.ts` (← `_html_to_md.py`) — **swap** to
|
||||
`node-html-markdown` (MIT). A different third-party library, not a rewrite-from-memory
|
||||
(which would still be a derivative). Consumed by `web_fetch` via `web/index.ts`;
|
||||
`htmlToMarkdown(html): string` signature preserved.
|
||||
2. `apps/server/src/services/inference/llama-args-validator.ts` (← `llama_server_args.py`)
|
||||
— **clean-room** re-derive the flag denylist from the public llama-server README (CLI
|
||||
flag names are facts, not copyrightable); the shadowing logic is already BooCode's own.
|
||||
3. `apps/server/src/services/inference/tool-call-parser.ts` (← `tool_call_parser.py`) —
|
||||
**delete** the dead Unsloth-ported code; keep BooCode's streaming helpers +
|
||||
`stripToolMarkup` (re-derive its strip regexes from qwen's wire format); drop the header.
|
||||
No change to the live tool-call path.
|
||||
|
||||
## Decisions (Sam, 2026-06-01)
|
||||
|
||||
- html-to-md library: **node-html-markdown** (single MIT dep, GFM tables built-in).
|
||||
- tool-call-parser: **relicense-only** — defer native-parsing retirement.
|
||||
- MIT copyright line: **`Copyright (c) 2026 indifferentketchup`**.
|
||||
- Leave `boocode_code_review*.md` (point-in-time snapshots) untouched; update the roadmap
|
||||
batch (planned → shipped) and add a README License section.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Retiring `tool-call-parser` patterns 1 & 2 in favour of native llama-server parsing.
|
||||
- Bumping the stale README "Latest release" line / AGENTS.md pointer.
|
||||
51
openspec/changes/archived/license-debt-mit/tasks.md
Normal file
51
openspec/changes/archived/license-debt-mit/tasks.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Tasks — relicense AGPL-3.0 → MIT
|
||||
|
||||
Four units. A/B/C are disjoint files (parallelizable); D is the join (runs after A/B/C).
|
||||
The shared `node-html-markdown` dependency swap + `pnpm install` is done before A so the
|
||||
parallel agents don't race on `apps/server/package.json`.
|
||||
|
||||
## Pre: dependency swap (done by coordinator)
|
||||
- [ ] Add `node-html-markdown` to `apps/server/package.json` dependencies; remove `parse5`
|
||||
(only html-to-md consumed it).
|
||||
- [ ] `pnpm install`.
|
||||
|
||||
## A — html-to-md → node-html-markdown
|
||||
- [ ] Replace `apps/server/src/services/web/html-to-md.ts` with a thin MIT wrapper exporting
|
||||
`htmlToMarkdown(sourceHtml: string): string` over `NodeHtmlMarkdown.translate`.
|
||||
- [ ] Drop the AGPL/Unsloth SPDX header.
|
||||
- [ ] Update `html-to-md.test.ts` to the new library's output (structure-level `.toContain`
|
||||
where whitespace differs; output feeds an LLM so exact format is not load-bearing).
|
||||
- [ ] Keep `web/index.ts` re-export and `web_fetch.ts` untouched.
|
||||
|
||||
## B — llama-args-validator → clean-room
|
||||
- [ ] Rewrite `apps/server/src/services/inference/llama-args-validator.ts`: re-derive the
|
||||
managed-flag denylist from the public llama-server README; keep the BooCode
|
||||
shadowing-flag logic. Same exports (`validateExtraArgs`, `isManagedFlag`,
|
||||
`stripShadowingFlags`, `StripOptions`).
|
||||
- [ ] Drop the AGPL/Unsloth SPDX header.
|
||||
- [ ] Keep `llama-args-validator.test.ts` green (it pins the contract).
|
||||
|
||||
## C — tool-call-parser → minimal clean (relicense-only)
|
||||
- [ ] Delete dead Unsloth-ported exports: `parseToolCallsFromText`, `scanBalancedBraces`,
|
||||
`OpenAiToolCall`, `hasToolSignal`, and the unused nudge constants
|
||||
(`DUPLICATE_CALL_NUDGE`, `TOOL_ERROR_NUDGE`, `TOOL_ERROR_PREFIXES`,
|
||||
`BUDGET_EXHAUSTED_NUDGE`).
|
||||
- [ ] Keep `extractToolCallBlocks` + streaming helpers + `stripToolMarkup` (re-derive its
|
||||
strip regexes from qwen's wire format). Drop the AGPL/Unsloth SPDX header.
|
||||
- [ ] Remove the now-dead tests from `tool-call-parser.test.ts`; keep streaming/strip tests.
|
||||
- [ ] Verify `stream-phase.ts` (`extractToolCallBlocks`) + `tool-phase.ts` / `error-handler.ts`
|
||||
(`stripToolMarkup`) still compile.
|
||||
|
||||
## D — license flip (join)
|
||||
- [ ] `LICENSE`: replace AGPL-3.0 text with MIT, `Copyright (c) 2026 indifferentketchup`.
|
||||
- [ ] Flip `"license"` to `"MIT"` in all 5 `package.json` (root, server, web, coder, booterm).
|
||||
- [ ] Confirm no `SPDX-License-Identifier: AGPL` header survives in the 3 files.
|
||||
- [ ] Roadmap `License-debt` batch: planned → shipped (note the decoupled-from-parser-retirement
|
||||
approach). Add a `## License` section to `README.md` (MIT).
|
||||
- [ ] Optional guard test: assert no `AGPL` SPDX header in `apps/**` and all 5 `package.json`
|
||||
are MIT.
|
||||
|
||||
## Verify
|
||||
- [ ] `pnpm -C apps/server test`
|
||||
- [ ] `pnpm -C apps/server build`
|
||||
- [ ] root `npx tsc --noEmit`
|
||||
@@ -0,0 +1,70 @@
|
||||
# MistakeTracker + file-provenance ledger (#12)
|
||||
|
||||
**Status:** in progress (started 2026-06-01)
|
||||
**Source:** `boocode_code_review_v2.md` §1 #12, §5e (cline — algorithm-reimplemented, not vendored).
|
||||
|
||||
Two native-inference (apps/server) hardening features. One cohesive backend change (they share
|
||||
`TurnArgs` + the tool-phase observation point) + a small frontend sentinel render.
|
||||
|
||||
## Part A — MistakeTracker (heterogeneous-failure recovery)
|
||||
|
||||
Complements the doom-loop guard (`sentinels.ts:detectDoomLoop`, which only catches *identical*
|
||||
repeats) by catching a run of consecutive tool **failures** the model isn't recovering from.
|
||||
|
||||
- New pure `apps/server/src/services/inference/mistake-tracker.ts` (mirrors `detectDoomLoop`):
|
||||
- `FailureKind = 'zod_reject' | 'tool_not_found' | 'exec_error' | 'api_error' | 'permission_denied'`
|
||||
(all already distinguished in `tool-phase.ts:executeToolCall`).
|
||||
- `MISTAKE_THRESHOLD = 3`.
|
||||
- State `{ run: FailureKind[]; nudges: number }` — `run` is the current consecutive-failure streak,
|
||||
reset on ANY successful tool step; `nudges` counts recovery injections not yet cleared by a success.
|
||||
- `recordStep(state, outcome)` where outcome is a failure kind or `'success'`.
|
||||
- `detectMistakePattern(state): 'nudge' | 'escalate' | null` — `run.length >= 3` → `'nudge'` the first
|
||||
time (`nudges === 0`), `'escalate'` if it trips again while `nudges >= 1` (no intervening success).
|
||||
- Lives in `TurnArgs` (loop-local, reset per `runInference`, like `recentToolCalls`).
|
||||
- Integration in `turn.ts` loop: after each tool phase, `recordStep` per tool outcome; then
|
||||
`detectMistakePattern`:
|
||||
- `'nudge'` (decision: soft + escalate): append a transient **model-facing** recovery-guidance system
|
||||
message to the NEXT turn's payload (re-read schemas, verify paths exist before acting, try a
|
||||
different approach — not retry variations), insert a `mistake_recovery` UI sentinel
|
||||
(`escalated:false`), bump `nudges`, reset `run`. Loop continues.
|
||||
- `'escalate'`: stop the turn (break), insert a `mistake_recovery` sentinel (`escalated:true`,
|
||||
`can_continue:true`, cap-hit-style), finalize. Prevents heterogeneous failures from burning the
|
||||
whole step budget.
|
||||
|
||||
## Part B — File-provenance ledger (Read-only)
|
||||
|
||||
- Accumulate file paths read by `view_file`/`grep`/`find_files`/`list_dir` into `TurnArgs.filesRead:
|
||||
Set<string>` (recorded at the tool-phase, like the failure outcomes).
|
||||
- On compaction (`compaction.ts:buildPrompt`), inject a deterministic, sorted `## Files Read` list into
|
||||
the summary prompt context so the summarizer merges it into the rolling summary — **no new
|
||||
table/column**; it propagates as summary text across compactions. `compaction-prompt.ts`'s
|
||||
`SUMMARY_TEMPLATE` already has a `## Relevant Files` section to extend/merge with.
|
||||
- BooChat is **read-only** (no write tools on apps/server) → "Files Modified" is N/A here; only
|
||||
"Files Read". (The apps/coder write side can add "Modified" later.)
|
||||
|
||||
## Sentinel contract (pinned — backend + frontend must match)
|
||||
|
||||
New sentinel kind on `MessageMetadata` in BOTH `apps/server/src/types/api.ts` AND
|
||||
`apps/web/src/api/types.ts`:
|
||||
```
|
||||
{ kind: 'mistake_recovery'; failure_kinds: string[]; count: number; escalated: boolean; can_continue?: boolean }
|
||||
```
|
||||
- `role='system'`, `status='complete'`, stripped from the LLM payload via `isAnySentinel` in
|
||||
`payload.ts` (UI-only) and `compaction.ts:buildHeadPayload`.
|
||||
- Frontend render branch in `apps/web/src/components/MessageBubble.tsx`: `escalated:false` →
|
||||
"Hit repeated different errors — recovery guidance injected, continuing." `escalated:true` →
|
||||
"Repeated errors persisted — stopped the turn." (mirror the doom-loop/cap-hit branches).
|
||||
|
||||
## Decisions (2026-06-01)
|
||||
- MistakeTracker intervention: **soft nudge + escalate**.
|
||||
- **UI sentinel** for recovery (`mistake_recovery`).
|
||||
|
||||
## Files (backend, one agent) / (frontend, one agent)
|
||||
- Backend: `mistake-tracker.ts` (new), `turn.ts`, `tool-phase.ts`, `sentinels.ts`,
|
||||
`sentinel-summaries.ts`, `payload.ts`, `compaction.ts`, `compaction-prompt.ts`, `types/api.ts` +
|
||||
tests (`mistake-tracker.test.ts`, ledger/compaction assertions).
|
||||
- Frontend: `apps/web/src/api/types.ts` (MessageMetadata arm) + `MessageBubble.tsx` (render branch).
|
||||
MUST NOT touch Sam's WIP web files.
|
||||
|
||||
## Verify
|
||||
- `pnpm -C apps/server test`; `pnpm -C apps/server build`; `npx tsc -p apps/web/tsconfig.app.json --noEmit`
|
||||
@@ -0,0 +1,45 @@
|
||||
# Small wins — sampling knobs + PTY stream-json + token UI
|
||||
|
||||
**Status:** in progress (started 2026-06-01)
|
||||
**Source:** `boocode_code_review_v2.md` §1 #11 / #7 / #8 (config-adopt + qwen-code §5g + opencode §3 #4).
|
||||
|
||||
Three independent BooCode improvements, disjoint subsystems (apps/server / apps/coder / apps/web).
|
||||
|
||||
## #11 — New sampling knobs (apps/server)
|
||||
Per-agent `top_n_sigma` + the `dry_*` repetition family help the doom-loop-prone local model.
|
||||
Today the Agent type threads `temperature/top_p/top_k/min_p/presence_penalty` into the inference
|
||||
request (`stream-phase.ts:396–438`). Add `top_n_sigma`, `dry_multiplier`, `dry_base`,
|
||||
`dry_allowed_length`, `dry_penalty_last_n` as first-class Agent fields (`types/api.ts`), parse them in
|
||||
`agents.ts:parseFrontmatter` (same bounded per-field numeric pattern + out-of-range warn), and thread
|
||||
them into the request body **via the same mechanism `top_k`/`min_p` already use** (the agent must
|
||||
confirm whether that's an AI-SDK `providerOptions`/`extraBody` passthrough — these are llama.cpp
|
||||
extensions, not standard OpenAI fields — and ride it; surface it if `top_k`/`min_p` turn out to be
|
||||
silently dropped today). `--reasoning-budget` is a llama-server CLI flag already permitted by the
|
||||
deny-list validator, so it works via `llama_extra_args: ["--reasoning-budget","N"]` now — document it
|
||||
in `data/AGENTS.md`. apps/server only.
|
||||
|
||||
## #7 — Live PTY stream-json NDJSON parsing (apps/coder)
|
||||
qwen/claude PTY dispatch slices stdout opaque (`dispatcher.ts` PTY path; qwen already runs
|
||||
`--output-format stream-json`). Add a parser for the Claude-Code-compatible NDJSON
|
||||
(`system`/`assistant`/`result`/`stream_event` → `content_block_delta` text/thinking/tool deltas +
|
||||
`usage` + `session_id`) that maps to the existing `AgentEvent` union (`agent-backend.ts`). **Live
|
||||
incremental** (decision 2026-06-01): line-buffer the PTY stdout `data` events, parse each complete
|
||||
NDJSON line as it arrives, and emit broker frames live (text/reasoning/tool) like the ACP/opencode
|
||||
paths — plus accumulate for `persistExternalAgentTurn`. claude gets `--output-format stream-json` too.
|
||||
One parser serves both (same schema). apps/coder only (`pty-dispatch.ts`, `dispatcher.ts`, new
|
||||
`stream-json-parser.ts` + test).
|
||||
|
||||
## #8 — Surface opencode token usage (apps/coder route + apps/web)
|
||||
`agent_sessions.input_tokens/output_tokens/cost` are accumulated (v2.6.8) but the
|
||||
`GET /api/sessions/:id/agent-sessions` SELECT + the `AgentSessionInfo` type drop them. Add the 3
|
||||
columns to both, render condensed beside the existing session chip in `AgentComposerBar`
|
||||
(ChatThroughput styling: `tabular-nums`, muted, e.g. "12.4K in / 3.2K out / $0.25"). MUST NOT touch
|
||||
Sam's uncommitted WIP (`ChatTabBar`, `SessionLandingPage`, `Workspace`, `useWorkspacePanes`,
|
||||
`PaneHeaderActions`).
|
||||
|
||||
## Decisions (2026-06-01)
|
||||
- #7 surfacing: **live incremental** streaming (not parse-at-end).
|
||||
|
||||
## Verify
|
||||
- `pnpm -C apps/server test` (+ new agent-parse tests); `pnpm -C apps/coder test` (+ new parser tests)
|
||||
- `pnpm -C apps/server build && pnpm -C apps/coder build`; `npx tsc -p apps/web/tsconfig.app.json --noEmit`
|
||||
728
openspec/changes/archived/v2-3-provider-lifecycle/design.md
Normal file
728
openspec/changes/archived/v2-3-provider-lifecycle/design.md
Normal file
@@ -0,0 +1,728 @@
|
||||
# v2.3 Provider lifecycle — design
|
||||
|
||||
Detailed implementation plan for Paseo-style provider registration, readiness probing, and enable/disable toggles in BooCoder.
|
||||
|
||||
> **✅ Shipped 2026-05-29 across `v2.5.4`–`v2.5.13` (reconciled 2026-05-31).** All 6 phases live. As-built deltas: the diagnostic ships as JSON `{ diagnostic: string }` (§6) rather than a plaintext HTTP body (§8's framing); the provider-management UI landed as a **Settings → Providers tab** (the §7.1 "or section under existing settings" path), not a standalone `ProviderSettingsDrawer`; `AddProviderModal` is at `apps/web/src/components/coder/`. **Deferred** (the §7.1 "optional phase 2" + tasks O.1–O.3): WS `provider_snapshot_updated` frame, `available_agents.enabled` column, diagnostic row-click modal — tracked in `docs/DEFERRED-WORK.md`.
|
||||
|
||||
**Audience:** Sam + future agents implementing the batch.
|
||||
**Paseo reference:** `/opt/forks/paseo/packages/server/src/server/agent/` (registry, snapshot manager, generic ACP), `/opt/forks/paseo/packages/app/src/screens/settings/providers-section.tsx` (UI behavior).
|
||||
|
||||
---
|
||||
|
||||
## 1. Current state vs target
|
||||
|
||||
### 1.1 BooCode today (v2.2)
|
||||
|
||||
```
|
||||
┌─────────────────┐ startup ┌──────────────────┐
|
||||
│ provider- │ ───────────────► │ available_agents │ (which, version, models JSONB)
|
||||
│ registry.ts │ agent-probe │ (Postgres) │
|
||||
│ (7 hardcoded) │ └────────┬─────────┘
|
||||
└────────┬────────┘ │
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ cache miss ┌──────────────────┐
|
||||
│ getProvider │ ──────────────► │ probeAcpProvider │ (full ACP session, 30s)
|
||||
│ Snapshot() │ per agent │ per installed │
|
||||
└────────┬────────┘ └──────────────────┘
|
||||
│
|
||||
▼
|
||||
Omit uninstalled ──► AgentComposerBar never sees them
|
||||
No enabled flag
|
||||
status: ready | error only
|
||||
```
|
||||
|
||||
**Key files:**
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `apps/coder/src/services/provider-registry.ts` | Static `PROVIDERS[]` |
|
||||
| `apps/coder/src/services/agent-probe.ts` | Boot `which` + DB upsert |
|
||||
| `apps/coder/src/services/provider-snapshot.ts` | Cache + cold probe + merge |
|
||||
| `apps/coder/src/services/acp-spawn.ts` | Per-agent argv switch |
|
||||
| `apps/coder/src/routes/providers.ts` | snapshot + refresh |
|
||||
| `apps/web/src/components/AgentComposerBar.tsx` | Picker UI |
|
||||
|
||||
### 1.2 Target (Paseo-aligned, BooCode-native)
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ Built-in registry│──┐
|
||||
│ (provider- │ │
|
||||
│ registry.ts) │ │ merge at boot + on config reload
|
||||
└──────────────────┘ │
|
||||
▼
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ /data/coder- │─►│ ResolvedProvider │
|
||||
│ providers.json │ │ Registry (in-mem) │
|
||||
└──────────────────┘ └────────┬───────────┘
|
||||
│
|
||||
┌─────────────────────┼─────────────────────┐
|
||||
▼ ▼ ▼
|
||||
agent-probe (fast) getProviderSnapshot dispatch
|
||||
which + version tier-1: isAvailable generic ACP for
|
||||
→ available_agents tier-2: cold ACP config entries
|
||||
enabled filter
|
||||
│
|
||||
▼
|
||||
Always emit entry per registered provider
|
||||
loading → ready | unavailable | error
|
||||
```
|
||||
|
||||
**Principles copied from Paseo** (`docs/providers.md` in fork):
|
||||
|
||||
1. **Registration ≠ installation** — config lists what you *want*; probe tells you what’s *ready*.
|
||||
2. **Warm until refresh** — no TTL re-probe on picker open; explicit `POST /api/providers/refresh` only.
|
||||
3. **Disabled skips probe** — `enabled: false` → `unavailable` without spawning.
|
||||
4. **Config reload replaces registry** — no redeploy to add an ACP wrapper.
|
||||
|
||||
---
|
||||
|
||||
## 2. Config file: `/data/coder-providers.json`
|
||||
|
||||
### 2.1 Location and loading
|
||||
|
||||
| Env var | Default | Notes |
|
||||
|---------|---------|-------|
|
||||
| `CODER_PROVIDERS_PATH` | `/data/coder-providers.json` | Same bind-mount pattern as `SKILLS_ROOT`, `MCP_CONFIG_PATH` |
|
||||
|
||||
- BooCoder runs on **host systemd** — path resolves to `/opt/boocode/data/coder-providers.json` in dev (add to repo as `data/coder-providers.json` + `.env.host`).
|
||||
- Missing file → `{}` (built-ins only, all enabled).
|
||||
- Invalid JSON → log error, fall back to `{}` (do not crash boot).
|
||||
- **Reload:** on `POST /api/providers/config` success, or `SIGHUP` optional later; v1: restart `boocoder.service` after manual edit is acceptable for solo use.
|
||||
|
||||
### 2.2 Schema (Zod)
|
||||
|
||||
New file: `apps/coder/src/services/provider-config.ts`
|
||||
|
||||
```typescript
|
||||
const ProviderOverrideSchema = z.object({
|
||||
extends: z.enum(['acp']).optional(), // v2.3: only 'acp' for custom; built-ins omit extends
|
||||
label: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
command: z.array(z.string().min(1)).min(1).optional(), // [binary, ...args]
|
||||
env: z.record(z.string()).optional(),
|
||||
enabled: z.boolean().optional(), // default true
|
||||
order: z.number().int().optional(), // UI sort key
|
||||
models: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
|
||||
additionalModels: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
|
||||
});
|
||||
|
||||
const CoderProvidersFileSchema = z.object({
|
||||
providers: z.record(ProviderOverrideSchema).default({}),
|
||||
});
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
| Case | Behavior |
|
||||
|------|----------|
|
||||
| Built-in id (e.g. `goose`) | Override merges: `enabled`, `label`, `command` (replace spawn), `env` |
|
||||
| New id + `extends: "acp"` | New registry entry; requires `label` + `command` |
|
||||
| New id without `extends` | Reject at load with log (v2.3) |
|
||||
| `enabled: false` on built-in | Stays in registry; snapshot `enabled: false`, status `unavailable` |
|
||||
| Custom id collision with built-in | Config wins for overrides only; cannot redefine `boocode` transport |
|
||||
|
||||
### 2.3 Example file (ship in `data/coder-providers.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"goose": { "enabled": true },
|
||||
"copilot": { "enabled": false },
|
||||
"amp-acp": {
|
||||
"extends": "acp",
|
||||
"label": "Amp",
|
||||
"description": "ACP wrapper for Amp",
|
||||
"command": ["amp-acp"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Paseo parity notes
|
||||
|
||||
Paseo uses `~/.paseo/config.json` under `agents.providers` with the same fields (`extends`, `command`, `enabled`, `models`, …). We intentionally use a **repo-adjacent data file** instead of dotfile — matches `AGENTS.md` / skills layout and survives container/host split (coder reads host path).
|
||||
|
||||
---
|
||||
|
||||
## 3. Resolved provider registry
|
||||
|
||||
### 3.1 New module: `provider-config-registry.ts`
|
||||
|
||||
**Responsibility:** Single in-memory source of truth after merge.
|
||||
|
||||
```typescript
|
||||
export interface ResolvedProviderDef extends ProviderDef {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
isBuiltin: boolean;
|
||||
isCustomAcp: boolean;
|
||||
/** Full argv for spawn: [binary, ...args] */
|
||||
launchCommand: [string, ...string[]] | null;
|
||||
env: Record<string, string> | undefined;
|
||||
configLabel?: string;
|
||||
configDescription?: string;
|
||||
}
|
||||
|
||||
export function buildResolvedRegistry(
|
||||
builtins: ProviderDef[],
|
||||
config: CoderProvidersFile,
|
||||
): Map<string, ResolvedProviderDef>;
|
||||
|
||||
export function loadProviderConfig(path: string): CoderProvidersFile;
|
||||
export function reloadProviderConfig(): void; // called after PATCH
|
||||
```
|
||||
|
||||
**Merge algorithm** (mirror Paseo `buildProviderRegistry` / `addDerivedProviders`):
|
||||
|
||||
1. For each built-in in `PROVIDERS`:
|
||||
- Apply config override if present
|
||||
- `enabled = override.enabled !== false`
|
||||
- `launchCommand` = override.command ?? default from `acp-spawn` + `install_path` at dispatch time
|
||||
2. For each config key not in built-ins:
|
||||
- Require `extends: "acp"`, `label`, `command`
|
||||
- Insert as `isCustomAcp: true`, `transport: 'acp'`, `modelSource: 'probe'`
|
||||
3. **`boocode`** always enabled; ignore `enabled: false` with warn log
|
||||
|
||||
**Consumers:** `agent-probe`, `provider-snapshot`, `dispatcher`, `acp-dispatch`, routes.
|
||||
|
||||
### 3.2 agent-probe changes
|
||||
|
||||
File: `apps/coder/src/services/agent-probe.ts`
|
||||
|
||||
- Iterate **`getResolvedProviderIds()`** instead of `PROBED_AGENT_NAMES` only.
|
||||
- For custom ACP: probe `command[0]` via `which` (not agent name).
|
||||
- Upsert `available_agents` for custom ids (new rows).
|
||||
- Store `label`, `transport: 'acp'` from resolved def.
|
||||
- Skip probe entirely when `enabled: false` (optional: delete row or keep stale — **keep row**, set `install_path null` on disable refresh).
|
||||
|
||||
### 3.3 Schema migration (optional column)
|
||||
|
||||
File: `apps/coder/src/schema.sql`
|
||||
|
||||
```sql
|
||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS source TEXT DEFAULT 'builtin';
|
||||
-- source: 'builtin' | 'config'
|
||||
```
|
||||
|
||||
Mirror `enabled` from config on each probe pass. Custom providers get `source = 'config'`.
|
||||
|
||||
**Alternative (simpler v2.3.0):** don’t add DB column; read `enabled` only from in-memory registry at snapshot time. DB holds install facts only. Prefer this for phase 1; add column if settings page needs to show state after coder restart without re-reading JSON.
|
||||
|
||||
---
|
||||
|
||||
## 4. Snapshot lifecycle
|
||||
|
||||
### 4.1 Type changes
|
||||
|
||||
Files: `apps/coder/src/services/provider-types.ts`, `apps/web/src/api/types.ts`
|
||||
|
||||
```typescript
|
||||
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
|
||||
|
||||
export interface ProviderSnapshotEntry {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
transport: string;
|
||||
status: ProviderSnapshotStatus;
|
||||
enabled: boolean;
|
||||
installed: boolean; // binary found on last fast probe
|
||||
models: ProviderModel[];
|
||||
modes: ProviderMode[];
|
||||
defaultModeId: string | null;
|
||||
commands: AgentCommand[];
|
||||
error?: string;
|
||||
fetchedAt?: string; // ISO — when tier-2 probe completed
|
||||
}
|
||||
```
|
||||
|
||||
Restore `unavailable` (removed in stale cleanup — intentional regression for this batch).
|
||||
|
||||
### 4.2 `buildProviderEntry` rewrite
|
||||
|
||||
File: `apps/coder/src/services/provider-snapshot.ts`
|
||||
|
||||
**Stop returning `null` for uninstalled.** Always return an entry for every resolved registry id.
|
||||
|
||||
```
|
||||
for each resolvedProvider:
|
||||
if !enabled:
|
||||
return { status: 'unavailable', enabled: false, installed: false, models: [], ... }
|
||||
|
||||
if native boocode:
|
||||
return { status: 'ready', enabled: true, installed: true, models: llamaSwap, ... }
|
||||
|
||||
fast = agentRow?.install_path != null // or isCommandAvailable(launchCommand[0])
|
||||
|
||||
if !fast:
|
||||
return { status: 'unavailable', enabled: true, installed: false, models: [], modes: manifest, commands: manifest }
|
||||
|
||||
if tier2_skipped: // see §4.3
|
||||
return { status: 'ready', enabled: true, installed: true, models: from DB, modes: manifest or DB, ... }
|
||||
|
||||
cold ACP probe:
|
||||
ok → ready + models/modes/commands merge
|
||||
fail → error + error message
|
||||
```
|
||||
|
||||
### 4.3 Two-tier probe (implements deferred work §2)
|
||||
|
||||
**Tier 1 — fast (always on cold read if enabled + installed):**
|
||||
|
||||
```typescript
|
||||
async function isProviderAvailable(resolved: ResolvedProviderDef, agentRow: AgentRow): Promise<boolean> {
|
||||
if (resolved.isNative) return true;
|
||||
if (agentRow?.install_path) return true;
|
||||
if (resolved.launchCommand) return isCommandAvailable(resolved.launchCommand[0]);
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
New util: `apps/coder/src/services/command-availability.ts` — `which`-style check (lift idea from Paseo `utils/executable.ts`, ~20 lines, no full port).
|
||||
|
||||
**Tier 2 — slow (ACP session):**
|
||||
|
||||
Run only when:
|
||||
|
||||
| Condition | Action |
|
||||
|-----------|--------|
|
||||
| `force === true` (`POST /refresh`) | Always cold probe installed enabled providers |
|
||||
| `last_probed_at` older than `PROVIDER_PROBE_TTL_MS` (default 24h, env override) | Cold probe |
|
||||
| DB models empty AND installed | Cold probe |
|
||||
| Otherwise | Use `available_agents.models` + manifest modes/commands |
|
||||
|
||||
Env: `PROVIDER_PROBE_TTL_MS` default `86400000` (24h). Paseo uses warm-forever until refresh; 24h is a homelab compromise so stale model lists self-heal.
|
||||
|
||||
**Paseo contract (adopt explicitly):**
|
||||
|
||||
- Opening `AgentComposerBar` does **not** call refresh or force probe.
|
||||
- `POST /api/providers/refresh` clears cache + forces tier-2 for home cwd.
|
||||
- Document in `BOOCODER.md`.
|
||||
|
||||
### 4.4 Loading state
|
||||
|
||||
On cache miss, before async probe completes:
|
||||
|
||||
1. Return entries with `status: 'loading'` immediately (sync).
|
||||
2. Singleflight inflight map (already exists) — on completion, flip to terminal status + emit…
|
||||
|
||||
**Tier 2 optional:** WS frame `provider_snapshot_updated` — defer to follow-up; v2.3 can rely on client polling 2s while any entry `loading` (CoderPane already polls when WS disconnected; extend: poll while snapshot has `loading`).
|
||||
|
||||
### 4.5 Cache keys
|
||||
|
||||
Keep cwd-keyed cache (`resolvedCwd = cwd ?? homedir()`). Settings UI uses snapshot with **no cwd** or explicit `cwd=~` — same as Paseo home-directory snapshot for provider management.
|
||||
|
||||
---
|
||||
|
||||
## 5. Generic ACP dispatch
|
||||
|
||||
### 5.1 Problem
|
||||
|
||||
`acp-spawn.ts` switch grows with every agent. Custom config entries cannot dispatch today.
|
||||
|
||||
### 5.2 Solution
|
||||
|
||||
File: `apps/coder/src/services/acp-spawn.ts`
|
||||
|
||||
```typescript
|
||||
export function resolveLaunchSpec(
|
||||
resolved: ResolvedProviderDef,
|
||||
installPath: string | null,
|
||||
): { binary: string; args: string[]; env?: Record<string, string> } | null {
|
||||
if (resolved.launchCommand) {
|
||||
return {
|
||||
binary: resolved.launchCommand[0],
|
||||
args: resolved.launchCommand.slice(1),
|
||||
env: resolved.env,
|
||||
};
|
||||
}
|
||||
// built-in fallback
|
||||
const args = resolveAcpSpawnArgs(resolved.id);
|
||||
if (!args || !installPath) return null;
|
||||
return { binary: installPath, args, env: resolved.env };
|
||||
}
|
||||
```
|
||||
|
||||
File: `apps/coder/src/services/acp-dispatch.ts`
|
||||
|
||||
- Replace `resolveAcpSpawnArgs(agent)` + `spawn(installPath, args)` with `resolveLaunchSpec(resolved, installPath)`.
|
||||
- Merge `env` into spawn `env: { ...process.env, ...spec.env }`.
|
||||
- Dispatcher loads resolved def by task.agent name.
|
||||
|
||||
**Do not port** Paseo `GenericACPAgentClient` class — keep procedural dispatch + existing `acp-stream.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 6. HTTP API
|
||||
|
||||
File: `apps/coder/src/routes/providers.ts`
|
||||
|
||||
| Method | Path | Body | Response |
|
||||
|--------|------|------|----------|
|
||||
| GET | `/api/providers/snapshot?cwd=` | — | `ProviderSnapshotEntry[]` (unchanged path) |
|
||||
| POST | `/api/providers/refresh` | `{ providers?: string[] }` optional | `{ refreshed: number }` — if `providers` set, refresh subset only (Paseo pattern) |
|
||||
| GET | `/api/providers/config` | — | `{ providers: Record<string, ProviderOverride> }` |
|
||||
| PATCH | `/api/providers/config` | partial providers map | merged file written, registry reload, `{ ok: true }` |
|
||||
| GET | `/api/providers/:id/diagnostic` | — | `{ diagnostic: string }` Tier 2 |
|
||||
|
||||
**PATCH semantics:** shallow merge at top level per provider id (same as Paseo `patchConfig`). Writing `enabled: false` triggers registry reload + snapshot reconcile (mark unavailable without probe).
|
||||
|
||||
**Proxy:** BooChat server may proxy `/api/coder/providers/*` — check `apps/server/src/index.ts` coder proxy prefix; add config routes if missing.
|
||||
|
||||
**Web client:** `apps/web/src/api/client.ts`
|
||||
|
||||
```typescript
|
||||
coder: {
|
||||
snapshot: ...
|
||||
refreshProviders: (providers?: string[]) => ...
|
||||
getProviderConfig: () => ...
|
||||
patchProviderConfig: (patch) => ...
|
||||
getProviderDiagnostic: (id) => ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Web UI
|
||||
|
||||
### 7.1 Settings: Provider management drawer
|
||||
|
||||
New: `apps/web/src/components/coder/ProviderSettingsDrawer.tsx` (or section under existing settings)
|
||||
|
||||
**Behavior lifted from Paseo `providers-section.tsx`:**
|
||||
|
||||
| UI element | Action |
|
||||
|------------|--------|
|
||||
| Row per registered provider | Label, status dot, model count |
|
||||
| Switch | `PATCH config { [id]: { enabled } }` |
|
||||
| Refresh icon | `POST /api/providers/refresh` |
|
||||
| Add provider | Opens catalog modal |
|
||||
| Row click | Diagnostic sheet (optional phase 2) |
|
||||
|
||||
**Status labels:** Disabled · Loading · Available · Not installed · Error
|
||||
|
||||
Entry point: link from `AgentComposerBar` (gear icon) or CoderPane header.
|
||||
|
||||
### 7.2 AgentComposerBar filter
|
||||
|
||||
File: `apps/web/src/components/AgentComposerBar.tsx`
|
||||
|
||||
```typescript
|
||||
const selectable = entries.filter(
|
||||
(e) => e.enabled && e.status === 'ready' && e.models.length > 0
|
||||
);
|
||||
// boocode: allow ready with empty models if llama-swap down? keep current fallback
|
||||
```
|
||||
|
||||
Show subtitle when current provider becomes unavailable (toast + reset to boocode).
|
||||
|
||||
### 7.3 Add provider modal
|
||||
|
||||
New: `apps/web/src/data/acp-provider-catalog.ts`
|
||||
|
||||
Curated entries (start with 5–10 you might install):
|
||||
|
||||
| id | command | installLink |
|
||||
|----|---------|-------------|
|
||||
| amp-acp | `["amp-acp"]` | github amp-acp |
|
||||
| cline | `["npx","-y","cline@…","--acp"]` | cline.bot |
|
||||
| pi-acp | from fork | … |
|
||||
|
||||
Copy **structure** from Paseo `acp-provider-catalog.ts` + `buildAcpProviderConfigPatch` — trim versions aggressively; pin only when you’ve verified on homelab.
|
||||
|
||||
Modal: search, Install → `patchProviderConfig(buildPatch(entry))` → `refreshProviders([entry.id])`.
|
||||
|
||||
**Do not port:** React Native components, remote SVG icon pipeline — use lucide fallback icon.
|
||||
|
||||
### 7.4 Loading UX
|
||||
|
||||
While any entry `status === 'loading'`, show spinner in composer provider dropdown; optional 2s poll until terminal state (reuse CoderPane poll pattern).
|
||||
|
||||
---
|
||||
|
||||
## 8. Diagnostics (Tier 2 in batch — lightweight)
|
||||
|
||||
Paseo `getDiagnostic()` runs version probe + short ACP initialize. For solo debugging:
|
||||
|
||||
File: `apps/coder/src/services/provider-diagnostic.ts`
|
||||
|
||||
```typescript
|
||||
export async function getProviderDiagnostic(
|
||||
resolved: ResolvedProviderDef,
|
||||
agentRow: AgentRow | undefined,
|
||||
cwd: string,
|
||||
): Promise<string> {
|
||||
// Plaintext report:
|
||||
// - enabled, installed, binary path
|
||||
// - last_probed_at, model count from DB
|
||||
// - optional: 8s ACP initialize probe (reuse acp-probe with shorter timeout)
|
||||
}
|
||||
```
|
||||
|
||||
No need for Paseo `diagnostic-utils.ts` formatting library — a template string is fine.
|
||||
|
||||
---
|
||||
|
||||
## 9. Testing strategy
|
||||
|
||||
| Test | File |
|
||||
|------|------|
|
||||
| Config load + merge | `provider-config-registry.test.ts` |
|
||||
| Snapshot: disabled → unavailable, no probe mock call | extend `provider-snapshot.test.ts` |
|
||||
| Snapshot: uninstalled → unavailable, installed true/false | same |
|
||||
| Tier-2 skip when fresh DB models | same |
|
||||
| force refresh calls probe | same |
|
||||
| PATCH config writes file | `routes/providers.test.ts` (optional integration) |
|
||||
| resolveLaunchSpec custom command | `acp-spawn.test.ts` |
|
||||
|
||||
Run: `pnpm -C apps/coder test`, `npx tsc -p apps/web/tsconfig.app.json --noEmit`.
|
||||
|
||||
Smoke:
|
||||
|
||||
```bash
|
||||
curl http://100.114.205.53:9502/api/providers/snapshot
|
||||
curl -X PATCH http://100.114.205.53:9502/api/providers/config -d '{"providers":{"goose":{"enabled":false}}}'
|
||||
curl -X POST http://100.114.205.53:9502/api/providers/refresh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Implementation phases
|
||||
|
||||
### Phase 1 — Config + registry (backend only)
|
||||
|
||||
- `provider-config.ts`, `provider-config-registry.ts`
|
||||
- `data/coder-providers.json` + `CODER_PROVIDERS_PATH`
|
||||
- Wire `agent-probe` to resolved ids
|
||||
- Unit tests
|
||||
|
||||
**Exit:** custom entry in JSON → row in `available_agents` after restart.
|
||||
|
||||
### Phase 2 — Snapshot lifecycle
|
||||
|
||||
- Types: `loading`, `unavailable`, `enabled`
|
||||
- Rewrite `buildProviderEntry` (never omit)
|
||||
- Tier-1 fast availability
|
||||
- Tier-2 skip when DB fresh
|
||||
- Restore warm-cache + force refresh semantics
|
||||
|
||||
**Exit:** disabled goose visible in API as unavailable; picker filters it out.
|
||||
|
||||
### Phase 3 — Generic dispatch
|
||||
|
||||
- `resolveLaunchSpec`
|
||||
- Dispatcher passes resolved def
|
||||
- Smoke: dispatch task for config-only provider (amp-acp if installed)
|
||||
|
||||
### Phase 4 — HTTP config API
|
||||
|
||||
- GET/PATCH config
|
||||
- Reload registry on PATCH
|
||||
- Subset refresh
|
||||
|
||||
### Phase 5 — Web UI
|
||||
|
||||
- Provider settings drawer + toggle
|
||||
- AgentComposerBar filter
|
||||
- Catalog modal (minimal list)
|
||||
|
||||
### Phase 6 — Docs + deploy
|
||||
|
||||
- `BOOCODER.md` section: Provider config
|
||||
- `CHANGELOG.md` entry
|
||||
- `docs/DEFERRED-WORK.md` — mark cold-probe item resolved
|
||||
- `pnpm -C apps/coder build && sudo systemctl restart boocoder`
|
||||
|
||||
---
|
||||
|
||||
## 11. Tier 2 follow-ups (document, don’t build in v2.3)
|
||||
|
||||
| Item | Paseo source | When |
|
||||
|------|--------------|------|
|
||||
| WS `provider_snapshot_updated` | `ProviderSnapshotManager` EventEmitter | When loading poll feels hacky |
|
||||
| MCP `list_providers` / `inspect_provider` | `mcp-server.ts` | When BooCoder MCP orchestration matures |
|
||||
| Profile overrides (`extends: "claude"`) | `provider-registry.ts` derived providers | When you run Z.AI / multi-endpoint |
|
||||
| `order` field UI sort | config schema | When catalog >10 entries |
|
||||
| Per-workspace snapshot in picker | cwd param | Already partial — verify project path passed from CoderPane |
|
||||
|
||||
---
|
||||
|
||||
## 12. Tier 3 reference — what Paseo has and why we don’t port it
|
||||
|
||||
This section is **reference only**. These are large subsystems in `/opt/forks/paseo` that solve problems BooCode doesn’t have at solo scale, or that BooCode already solved differently.
|
||||
|
||||
### 12.1 `ACPAgentClient` base class (~2,800 lines)
|
||||
|
||||
**Path:** `packages/server/src/server/agent/providers/acp-agent.ts`
|
||||
|
||||
**What it does:** Full ACP lifecycle — spawn, initialize, session/new, streaming, permissions, tool calls, MCP injection, revert, persisted agent import, probe sessions.
|
||||
|
||||
**Why Paseo needs it:** Paseo is the primary runtime for dozens of providers; one abstraction reduces duplication across copilot, cursor, generic ACP, etc.
|
||||
|
||||
**Why BooCode skips it:** `acp-dispatch.ts` + `acp-stream.ts` + `acp-probe.ts` already cover dispatch and probe as **scripts** (~400 lines total). Replacing with the class hierarchy is a multi-week rewrite with high regression risk on v2.2 dispatch that works on homelab.
|
||||
|
||||
**What we take instead:** Patterns only — `isAvailable()` = resolve binary; permission waiter (already shipped); derive models/modes (already shipped).
|
||||
|
||||
---
|
||||
|
||||
### 12.2 Per-provider client classes (claude, codex, opencode, pi, copilot, cursor…)
|
||||
|
||||
**Paths:** `packages/server/src/server/agent/providers/*/agent.ts`, `codex-app-server-agent.ts` (5,000+ lines)
|
||||
|
||||
**What they do:** Native SDK/RPC integration — not just CLI spawn. Codex uses app-server RPC; Claude uses Claude Agent SDK; OpenCode manages a sidecar server.
|
||||
|
||||
**Why Paseo needs it:** Deep integration — voice, revert, persisted sessions, feature toggles, OAuth diagnostics.
|
||||
|
||||
**Why BooCode skips it:** BooCode **delegates** to existing CLIs in worktrees. No embedded SDKs. PTY path for claude/qwen is stdin pipe; ACP path uses `@agentclientprotocol/sdk` at dispatch boundary only.
|
||||
|
||||
**Lift risk:** Importing codex-app-server-agent would drag thousands of lines + unknown deps.
|
||||
|
||||
---
|
||||
|
||||
### 12.3 `ProviderSnapshotManager` class (full port)
|
||||
|
||||
**Path:** `packages/server/src/server/agent/provider-snapshot-manager.ts`
|
||||
|
||||
**What it does:** Per-cwd Maps, loading states, singleflight, event emitter, reconcile on registry replace, settings vs workspace refresh split.
|
||||
|
||||
**Why not full port:** BooCode’s `provider-snapshot.ts` is ~250 lines and already has cache + inflight. **Selective lift:** loading status, reconcile on config reload, subset refresh — not a class-for-class rewrite.
|
||||
|
||||
---
|
||||
|
||||
### 12.4 React Native settings app (`packages/app`)
|
||||
|
||||
**Paths:** `providers-section.tsx`, `add-provider-modal.tsx`, `use-providers-snapshot.ts`
|
||||
|
||||
**What it does:** Mobile/desktop cross-platform provider UI with Unistyles, native Switch, adaptive sheets.
|
||||
|
||||
**Why BooCode skips it:** BooChat is React web + Tailwind. Port **interaction design** (toggle, status dots, add flow), not components.
|
||||
|
||||
---
|
||||
|
||||
### 12.5 Daemon config system (`patchConfig`, migrations, Zod wire messages)
|
||||
|
||||
**Path:** `packages/server/src/shared/messages.ts` (4000+ lines), daemon config patch RPC
|
||||
|
||||
**What it does:** Every settings change is a typed WS/HTTP patch to daemon with validation, persistence, broadcast.
|
||||
|
||||
**Why BooCode simplifies:** Single-user — PATCH writes JSON file + reloads in-process Map. No multi-client sync requirement. If BooChat and CLI both edit, last-write-wins on file is acceptable.
|
||||
|
||||
---
|
||||
|
||||
### 12.6 Full ACP catalog (30+ providers, version-pinned npx)
|
||||
|
||||
**Path:** `packages/app/src/data/acp-provider-catalog.ts` (~400 lines)
|
||||
|
||||
**Why trim:** Maintenance burden — every upstream version bump is a PR in Paseo. Solo homelab: 5–10 entries you actually install, update when you install.
|
||||
|
||||
---
|
||||
|
||||
### 12.7 Voice provider stack
|
||||
|
||||
**Path:** `packages/server/src/server/speech/*`
|
||||
|
||||
**Why skip:** BooCode has no voice surface; unrelated to coder provider lifecycle.
|
||||
|
||||
---
|
||||
|
||||
### 12.8 Workspace git service inside agents
|
||||
|
||||
**Path:** Codex client integration with `WorkspaceGitService`
|
||||
|
||||
**Why skip:** BooCode worktrees (`worktrees.ts`) are explicit per-task; agents run in worktree cwd. Different architecture.
|
||||
|
||||
---
|
||||
|
||||
### 12.9 OpenCode server manager sidecar
|
||||
|
||||
**Path:** `packages/server/src/server/agent/providers/opencode/server-manager.ts`
|
||||
|
||||
**What it does:** Manages long-lived OpenCode server process.
|
||||
|
||||
**Why skip:** BooCode spawns `opencode acp` per dispatch — stateless, simpler, good enough for single user.
|
||||
|
||||
---
|
||||
|
||||
### 12.10 Pi RPC agent + session import from JSONL
|
||||
|
||||
**Paths:** `packages/server/src/server/agent/providers/pi/agent.ts` (1,500+ lines)
|
||||
|
||||
**Why skip until needed:** Only lift if you add `pi` as a built-in with import/revert requirements. Otherwise generic ACP + `extends: "acp"` + pi-acp catalog entry suffices.
|
||||
|
||||
---
|
||||
|
||||
### 12.11 Summary table
|
||||
|
||||
| Paseo subsystem | Lines (approx) | BooCode v2.3 approach |
|
||||
|-----------------|----------------|------------------------|
|
||||
| ACPAgentClient | 2,800 | Keep acp-dispatch |
|
||||
| Codex app server agent | 5,500 | Don't import |
|
||||
| Provider registry merge | 700 | New 200-line module |
|
||||
| Snapshot manager | 490 | Extend existing snapshot |
|
||||
| Generic ACP agent | 300 | resolveLaunchSpec only |
|
||||
| RN providers UI | 400 | Web drawer ~200 lines |
|
||||
| MCP list_providers | 200 | Defer |
|
||||
| Config wire protocol | 4,000+ | JSON file PATCH |
|
||||
|
||||
**Rule of thumb for solo project:** Lift **data models and lifecycle rules**, not **class hierarchies**.
|
||||
|
||||
---
|
||||
|
||||
## 13. Risk register
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Custom npx provider slow cold start | Show loading; subset refresh; don’t block picker on whole snapshot |
|
||||
| Config file edit while coder running | PATCH API primary; manual edit requires restart (document) |
|
||||
| `enabled: false` but task in flight | Allow running task to finish; block new sends (picker filter) |
|
||||
| Type drift web/coder | Update both `provider-types.ts` and `api/types.ts`; optional zod parity test |
|
||||
| Security: arbitrary command in config | Single-user trusted path; same trust as `AGENTS.md` — no app-layer auth |
|
||||
| Re-enabling cold probe slowness on refresh | Expected; refresh is explicit user action |
|
||||
|
||||
---
|
||||
|
||||
## 14. File map (new + touched)
|
||||
|
||||
| Action | Path |
|
||||
|--------|------|
|
||||
| **New** | `apps/coder/src/services/provider-config.ts` |
|
||||
| **New** | `apps/coder/src/services/provider-config-registry.ts` |
|
||||
| **New** | `apps/coder/src/services/command-availability.ts` |
|
||||
| **New** | `apps/coder/src/services/provider-diagnostic.ts` |
|
||||
| **New** | `apps/coder/src/services/__tests__/provider-config-registry.test.ts` |
|
||||
| **New** | `data/coder-providers.json` |
|
||||
| **New** | `apps/web/src/data/acp-provider-catalog.ts` |
|
||||
| **New** | `apps/web/src/components/coder/ProviderSettingsDrawer.tsx` |
|
||||
| **New** | `apps/web/src/components/coder/AddProviderModal.tsx` |
|
||||
| **Edit** | `apps/coder/src/services/provider-snapshot.ts` |
|
||||
| **Edit** | `apps/coder/src/services/agent-probe.ts` |
|
||||
| **Edit** | `apps/coder/src/services/acp-spawn.ts` |
|
||||
| **Edit** | `apps/coder/src/services/acp-dispatch.ts` |
|
||||
| **Edit** | `apps/coder/src/services/dispatcher.ts` |
|
||||
| **Edit** | `apps/coder/src/routes/providers.ts` |
|
||||
| **Edit** | `apps/coder/src/config.ts` — `CODER_PROVIDERS_PATH` |
|
||||
| **Edit** | `apps/coder/.env.host` |
|
||||
| **Edit** | `apps/coder/src/services/provider-types.ts` |
|
||||
| **Edit** | `apps/web/src/api/types.ts` |
|
||||
| **Edit** | `apps/web/src/api/client.ts` |
|
||||
| **Edit** | `apps/web/src/components/AgentComposerBar.tsx` |
|
||||
| **Edit** | `BOOCODER.md` |
|
||||
| **Edit** | `docs/DEFERRED-WORK.md` |
|
||||
|
||||
---
|
||||
|
||||
## 15. Attribution
|
||||
|
||||
Design patterns from [Paseo](https://github.com/getpaseo/paseo) (`/opt/forks/paseo`), especially:
|
||||
|
||||
- `provider-registry.ts` — merge built-ins + config + `enabled`
|
||||
- `provider-snapshot-manager.ts` — loading/unavailable/ready lifecycle
|
||||
- `provider-launch-config.ts` — override schema
|
||||
- `providers-section.tsx` — settings UX
|
||||
- `public-docs/custom-providers.md` — config file semantics
|
||||
|
||||
BooCode implementation remains original code — no copy-paste of Paseo sources required; licensing treated as irrelevant per project owner directive.
|
||||
@@ -0,0 +1,63 @@
|
||||
# v2.3 Provider lifecycle (Paseo-style registry)
|
||||
|
||||
**Status:** ✅ **Shipped** across `v2.5.4`–`v2.5.13` (2026-05-29; reconciled 2026-05-31) — all 6 phases live; only the 3 optional Tier-2 items deferred
|
||||
**Depends on:** v2.2 Paseo providers (snapshot, modes, commands, ACP dispatch)
|
||||
**Reference fork:** `/opt/forks/paseo`
|
||||
**Related deferred work:** [`docs/DEFERRED-WORK.md`](../../../docs/DEFERRED-WORK.md) §2 (cold-probe skip)
|
||||
|
||||
> **Shipped mapping (reconciled 2026-05-31):** Phase 1 → `v2.5.4`, Phase 2 → `v2.5.5`, Phase 3 → `v2.5.6`, Phase 4 → `v2.5.12`, Phase 5 → `v2.5.13`, Phase 6 docs → `v2.5.13`/`v2.5.14`. **Deferred (tasks O.1–O.3):** WS `provider_snapshot_updated` frame, `available_agents.enabled` column, diagnostic row-click modal — tracked in `docs/DEFERRED-WORK.md`. (Cursor was retired in `v2.5.3`, so the success-criterion mention below is historical.)
|
||||
|
||||
## Why
|
||||
|
||||
BooCode v2.2 copied Paseo’s **snapshot wire shape** (modes, thinking, commands) but not Paseo’s **provider lifecycle**:
|
||||
|
||||
- Providers are hardcoded in `provider-registry.ts`; adding one requires a code change and redeploy.
|
||||
- Uninstalled agents **disappear** from the picker instead of showing “not installed.”
|
||||
- There is no **enable/disable** toggle — every probed binary appears.
|
||||
- Every snapshot cache miss runs a **full cold ACP probe** for all installed agents (5–30s).
|
||||
|
||||
Paseo’s model (see `/opt/forks/paseo/public-docs/providers.md`) treats providers as **registered entries** in a config-backed registry, then probes the machine for readiness, then lets the user toggle visibility. That fits a one-person homelab: edit JSON, refresh, flip a switch — no TypeScript deploy for each new ACP CLI.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
1. **Config file** `/data/coder-providers.json` — add/disable/custom ACP providers without code changes
|
||||
2. **Merged registry** — built-ins + config overrides at runtime
|
||||
3. **Snapshot lifecycle** — `loading` | `ready` | `unavailable` | `error`; always list registered providers; `enabled` flag
|
||||
4. **Two-tier probe** — fast binary check vs slow ACP session (DB `last_probed_at` gate)
|
||||
5. **Generic ACP dispatch** — config entries spawn via `{ command, env }` without new `acp-spawn` cases
|
||||
6. **HTTP API** — read/patch config, per-provider refresh, optional diagnostic
|
||||
7. **Web UI** — settings drawer: provider list, enable toggle, refresh, add-from-catalog (curated ~5–10 entries)
|
||||
8. **Tests + docs** — snapshot unit tests, `BOOCODER.md` refresh contract
|
||||
|
||||
### Out of scope (this batch)
|
||||
|
||||
- Full Paseo ACP catalog (30+ agents) — curate a small local catalog only
|
||||
- React Native settings app port
|
||||
- Replacing `acp-dispatch.ts` with Paseo’s `ACPAgentClient` hierarchy
|
||||
- Voice provider stack
|
||||
- MCP `list_providers` / `inspect_provider` tools (Tier 2 follow-up)
|
||||
- WS push of snapshot updates (Tier 2 follow-up)
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Multi-user provider prefs (single-user homelab)
|
||||
- Installing CLIs from the UI (link to install instructions only, like Paseo)
|
||||
- Removing `available_agents` table — keep it as probe cache, extend with `enabled` or mirror config
|
||||
|
||||
## Success criteria
|
||||
|
||||
- ✅ Add `amp-acp` via catalog → appears in picker after refresh without coder redeploy *(catalog smoke-test entry; per `boocode_code_review_v2.md` §5m, Amp itself is paid-cloud, not a usable local provider)*
|
||||
- ✅ Disable goose in settings → gone from picker, still visible as “Disabled” in settings
|
||||
- ✅ opencode not on PATH → shows “Not installed” in settings, hidden from picker
|
||||
- ✅ Second snapshot open within warm window completes in <500ms (no ACP spawns)
|
||||
- ✅ `POST /api/providers/refresh` still runs full cold probe
|
||||
- ✅ Existing v2.2 dispatch unchanged for built-ins *(opencode, claude, qwen, goose — cursor + copilot retired `v2.5.3`)*
|
||||
|
||||
## Deliverables
|
||||
|
||||
| Doc | Purpose |
|
||||
|-----|---------|
|
||||
| [`design.md`](./design.md) | Full architecture, schemas, file map, Tier 3 reference |
|
||||
| [`tasks.md`](./tasks.md) | Numbered implementation checklist |
|
||||
73
openspec/changes/archived/v2-3-provider-lifecycle/tasks.md
Normal file
73
openspec/changes/archived/v2-3-provider-lifecycle/tasks.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# v2.3 Provider lifecycle — tasks
|
||||
|
||||
Implement in phase order from [`design.md`](./design.md). Do not commit unless Sam asks.
|
||||
|
||||
> **✅ SHIPPED across `v2.5.4`–`v2.5.13` (reconciled 2026-05-31).** All 6 phases done; the 3 Optional items (O.1–O.3) deferred (tracked in `docs/DEFERRED-WORK.md`). Verified in tree: `provider-config.ts`, `provider-config-registry.ts`, `command-availability.ts`, `provider-diagnostic.ts`, `acp-provider-catalog.ts`, `components/coder/AddProviderModal.tsx`, Settings→Providers tab.
|
||||
|
||||
## Phase 1 — Config + registry — ✅ `v2.5.4-provider-lifecycle-phase1`
|
||||
|
||||
- [x] 1.1 Add `CODER_PROVIDERS_PATH` to `apps/coder/src/config.ts` (default `/data/coder-providers.json`)
|
||||
- [x] 1.2 Add `data/coder-providers.json` example + wire in `apps/coder/.env.host`
|
||||
- [x] 1.3 Implement `provider-config.ts` (Zod schema + load/merge/save)
|
||||
- [x] 1.4 Implement `provider-config-registry.ts` (`buildResolvedRegistry`, module singleton + reload)
|
||||
- [x] 1.5 Unit tests: built-in override, custom ACP add, enabled false, invalid entry skipped
|
||||
- [x] 1.6 Update `agent-probe.ts` to iterate resolved registry (include custom ids, respect enabled)
|
||||
|
||||
## Phase 2 — Snapshot lifecycle — ✅ `v2.5.5-provider-lifecycle-phase2`
|
||||
|
||||
- [x] 2.1 Extend `ProviderSnapshotEntry` / status union in coder + web types (`loading`, `unavailable`, `enabled`)
|
||||
- [x] 2.2 Add `command-availability.ts` (`isCommandAvailable`)
|
||||
- [x] 2.3 Rewrite `buildProviderEntry`: never return null; handle disabled/uninstalled/loading
|
||||
- [x] 2.4 Implement tier-2 skip using `available_agents.last_probed_at` + `PROVIDER_PROBE_TTL_MS`
|
||||
- [x] 2.5 Return `loading` entries synchronously on cache miss; complete via inflight promise *(client-side poll deferred to Phase 5; cache miss returns `loading` then settles)*
|
||||
- [x] 2.6 Extend `provider-snapshot.test.ts` for disabled, uninstalled, fresh DB skip, force refresh
|
||||
- [x] 2.7 Verify warm cache: second snapshot call does not invoke `probeAcpProvider` (mock assert)
|
||||
|
||||
## Phase 3 — Generic dispatch — ✅ `v2.5.6-provider-lifecycle-phase3`
|
||||
|
||||
- [x] 3.1 Add `resolveLaunchSpec()` to `acp-spawn.ts`
|
||||
- [x] 3.2 Wire `acp-dispatch.ts` to use launch spec + env merge
|
||||
- [x] 3.3 Wire `dispatcher.ts` to load resolved def by agent name
|
||||
- [x] 3.4 Unit test: custom command argv reaches spawn (built-in dispatch byte-identical)
|
||||
- [x] 3.5 Smoke: task dispatch for one custom catalog provider (if installed on host)
|
||||
|
||||
## Phase 4 — HTTP API — ✅ `v2.5.12-provider-lifecycle-phase4`
|
||||
|
||||
- [x] 4.1 `GET /api/providers/config`
|
||||
- [x] 4.2 `PATCH /api/providers/config` (merge + write file + reload registry + clear snapshot cache)
|
||||
- [x] 4.3 `POST /api/providers/refresh` optional body `{ providers?: string[] }`
|
||||
- [x] 4.4 `GET /api/providers/:id/diagnostic` *(ships as JSON `{ diagnostic: string }`, not plaintext — see design §8 delta)*
|
||||
- [x] 4.5 Extend `apps/web/src/api/client.ts` coder namespace
|
||||
- [x] 4.6 Confirm BooChat proxy forwards new routes (blanket `/api/coder/*` forward)
|
||||
|
||||
## Phase 5 — Web UI — ✅ `v2.5.13-provider-lifecycle-phase5`
|
||||
|
||||
- [x] 5.1 Create `apps/web/src/data/acp-provider-catalog.ts` (5–10 curated entries)
|
||||
- [x] 5.2 `AddProviderModal.tsx` — search, install → patch + refresh subset *(at `components/coder/`)*
|
||||
- [x] 5.3 Provider management UI *(shipped as a **Settings → Providers tab** in `SettingsPane.tsx`, not a standalone `ProviderSettingsDrawer` — design §7.1 "or section under existing settings")*
|
||||
- [x] 5.4 Entry point from CoderPane / AgentComposerBar (gear or settings link)
|
||||
- [x] 5.5 Filter `AgentComposerBar` selectable providers (`enabled && ready|loading`)
|
||||
- [x] 5.6 Loading state while snapshot entries `loading`
|
||||
- [x] 5.7 `npx tsc -p apps/web/tsconfig.app.json --noEmit`
|
||||
|
||||
## Phase 6 — Docs, deploy, closeout — ✅ `v2.5.13` / docs `v2.5.14`
|
||||
|
||||
- [x] 6.1 `BOOCODER.md` — config file, refresh contract, enable/disable
|
||||
- [x] 6.2 Update `docs/DEFERRED-WORK.md` — tier-2 cold-probe item marked addressed
|
||||
- [x] 6.3 `CHANGELOG.md` entries (per-phase tags, not a single tag)
|
||||
- [x] 6.4 `pnpm -C apps/coder test && pnpm -C apps/coder build`
|
||||
- [x] 6.5 `sudo systemctl restart boocoder`
|
||||
- [x] 6.6 Smoke via Tailscale (snapshot / disable goose / refresh / add-catalog)
|
||||
|
||||
## Optional — ⬜ DEFERRED (tracked in `docs/DEFERRED-WORK.md`)
|
||||
|
||||
- [ ] O.1 WS frame `provider_snapshot_updated` (skip polling) — **deferred**; `AgentComposerBar:219` polls instead (comment notes the absence)
|
||||
- [ ] O.2 `available_agents.enabled` column mirror — **deferred**; `enabled` read from config memory only (no DB column)
|
||||
- [ ] O.3 Diagnostic sheet UI (row click → modal) — **deferred**; the plaintext/JSON diagnostic API + Settings surface shipped, the modal polish did not
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
- Port Paseo `ACPAgentClient` / per-provider SDK clients (see design §12)
|
||||
- Full 30+ ACP catalog
|
||||
- MCP `list_providers` tools
|
||||
- Voice providers
|
||||
@@ -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.
|
||||
101
openspec/changes/archived/write-edit-robustness/proposal.md
Normal file
101
openspec/changes/archived/write-edit-robustness/proposal.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Write/edit robustness — fuzzy patch applier + worktree checkpoints
|
||||
|
||||
**Status:** in progress (started 2026-06-01)
|
||||
**Source:** `boocode_code_review_v2.md` §1 #3 + #4, §5b/§5d–5e (cline, Apache-2.0 — algorithm clean-reimplemented, not vendored).
|
||||
|
||||
Two independent BooCoder hardening features for local quantized models.
|
||||
|
||||
## #3 — Fuzzy patch applier
|
||||
|
||||
**Problem:** `applyOne`'s edit case (`apps/coder/src/services/pending_changes.ts:124`) does exact
|
||||
`content.includes(oldStr)` → throw, then `content.replace(oldStr, newStr)` (first occurrence).
|
||||
`rewindOne` (line 206) is the same. Local models (qwen3.6) drift `old_string` by whitespace/
|
||||
indentation/unicode (curly quotes, en/em-dash, nbsp), so a valid edit fails at apply with
|
||||
"old_string not found" and is lost.
|
||||
|
||||
**Design:** new pure module `apps/coder/src/services/fuzzy-match.ts`:
|
||||
`locateMatch(content: string, needle: string): { kind: 'exact'|'fuzzy'; start: number; end: number }
|
||||
| { kind: 'ambiguous'; count: number } | { kind: 'not_found' }`. Match ladder:
|
||||
1. **Exact** `indexOf`. If exactly one → exact span. If >1 → **ambiguous** (refuse; decision
|
||||
2026-06-01: safer than silently editing the first).
|
||||
2. **Per-line whitespace-insensitive** — compare `needle` lines to file line-windows ignoring per-line
|
||||
`trimEnd`/leading-trailing blank lines.
|
||||
3. **Unicode canonicalization** — normalize curly→straight quotes, en/em-dash→`-`, nbsp→space on both
|
||||
sides, then retry the whitespace pass.
|
||||
4. **Levenshtein** similarity ≥ 0.66 over line-windows sized to `needle`'s line count; best window wins.
|
||||
|
||||
Non-exact (fuzzy) matches return the actual file span so the caller replaces the real file text with
|
||||
`new_string`. `pending_changes.ts` `applyOne`/`rewindOne` use `locateMatch`; `ambiguous`/`not_found`
|
||||
return `success:false` with a clear message (no throw escaping the existing catch). Unit-tested
|
||||
(`apps/coder/src/services/__tests__/fuzzy-match.test.ts`), per the `turn-guard.ts` pure-helper pattern.
|
||||
|
||||
## #4 — Worktree checkpoint + conversation-trim
|
||||
|
||||
**Problem:** `rewind` only reverses BooCode's own `pending_changes` (applied to the project root).
|
||||
External agents (opencode/goose/qwen/claude) write **directly into the session worktree**
|
||||
(`/tmp/booworktrees/sess-<id>`); rewind has zero coverage there.
|
||||
|
||||
**Schema** (`apps/coder/src/schema.sql`):
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
|
||||
session_id UUID,
|
||||
worktree_id UUID REFERENCES worktrees(id) ON DELETE SET NULL,
|
||||
message_id UUID, -- anchor: the assistant turn row this checkpoint precedes
|
||||
commit_sha TEXT NOT NULL, -- shadow-commit capturing the pre-turn worktree tree
|
||||
label TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS checkpoints_chat_created_idx ON checkpoints(chat_id, created_at);
|
||||
```
|
||||
|
||||
**Create** (`apps/coder/src/services/checkpoints.ts` → `createCheckpoint`): hooked into the three
|
||||
external-agent dispatch paths in `dispatcher.ts` (`runWarmAcpTask` ~821, `runOpenCodeServerTask` ~513,
|
||||
`runExternalAgent` ~255) — after `ensureSessionWorktree()` and the assistant-message insert (so the
|
||||
anchor `message_id` exists), before the backend runs. Snapshot captures tracked **+ untracked** via a
|
||||
temp-index shadow commit, stored in a private GC-safe ref:
|
||||
```
|
||||
cd <wt> && TMP=$(mktemp) && GIT_INDEX_FILE="$TMP" git read-tree HEAD \
|
||||
&& GIT_INDEX_FILE="$TMP" git add -A \
|
||||
&& TREE=$(GIT_INDEX_FILE="$TMP" git write-tree) \
|
||||
&& SHA=$(git commit-tree "$TREE" -p HEAD -m "boocode checkpoint") \
|
||||
&& git update-ref refs/boocode/checkpoints/<id> "$SHA" && rm -f "$TMP" && echo "$SHA"
|
||||
```
|
||||
Best-effort: a checkpoint failure logs and never breaks the turn. Native-boocode turns (project-root,
|
||||
rewind-covered) get no checkpoint.
|
||||
|
||||
**Restore** (`POST /api/sessions/:sessionId/checkpoints/:checkpointId/restore`, proxied `/api/coder/*`):
|
||||
1. Resolve + validate the checkpoint belongs to the session.
|
||||
2. Reset worktree: `git -C <wt> reset --hard <commit_sha> && git -C <wt> clean -fd` (hostExec+shellEscape).
|
||||
3. Trim transcript: `DELETE FROM messages WHERE chat_id = <cp.chat_id> AND created_at >=
|
||||
(SELECT created_at FROM messages WHERE id = <cp.message_id>)` (+ explicit `message_parts` delete if
|
||||
the FK isn't ON DELETE CASCADE — verify).
|
||||
4. Reset backend (decision 2026-06-01): `UPDATE agent_sessions SET status='crashed' WHERE
|
||||
chat_id=<cp.chat_id>` and evict the live pool session for `(chat,agent)` if present, so the next turn
|
||||
re-establishes a fresh backend — transcript, files, and agent context all consistent at the restore
|
||||
point. (Warm backends hold context server-side; no partial rewind exists.)
|
||||
5. Delete now-orphaned later checkpoints: `DELETE FROM checkpoints WHERE chat_id=? AND created_at >
|
||||
<cp.created_at>`.
|
||||
6. Return `{ checkpoint_id, messages_deleted, worktree_reset, backend_reset }`.
|
||||
|
||||
**Frontend:** per-message "Restore to here" in `CoderMessageList.tsx` (via a new optional
|
||||
`onRestoreCheckpoint?(chatId, messageId)` on `MessageActions` in `MessageBubble.tsx`), wired in
|
||||
`CoderPane.tsx`; guarded to `status==='complete'` and to messages that have a checkpoint. After the call
|
||||
returns, refetch the chat's messages (existing GET) — no new WS frame required.
|
||||
|
||||
## Decisions (2026-06-01)
|
||||
- Multi-exact-match → **refuse as ambiguous** (#3).
|
||||
- #4 **full** scope incl. conversation-trim.
|
||||
- Restore **resets** the external-agent backend session (context re-established fresh).
|
||||
|
||||
## Parallelization
|
||||
- **Unit 1 (#3)** — fully independent (`fuzzy-match.ts` + `pending_changes.ts` + test).
|
||||
- **Unit 2 (#4 backend)** — schema + `checkpoints.ts` (create+restore) + 3 dispatcher hooks + restore route + backend reset. One agent owns all #4 coder backend (shared `checkpoints.ts`).
|
||||
- **Unit 3 (#4 frontend)** — `CoderMessageList`/`MessageBubble`/`CoderPane`, against the pinned restore contract. Parallel with Unit 2. MUST NOT touch Sam's uncommitted WIP (`ChatTabBar`, `SessionLandingPage`, `Workspace`, `useWorkspacePanes`, `PaneHeaderActions`).
|
||||
|
||||
## Verify
|
||||
- `pnpm -C apps/coder test` (incl. new `fuzzy-match` + any checkpoint pure-helper tests)
|
||||
- `pnpm -C apps/server build` then `pnpm -C apps/coder build`
|
||||
- `npx tsc -p apps/web/tsconfig.app.json --noEmit`
|
||||
- Live smoke (manual, host): external-agent edit → checkpoint row; "Restore to here" → worktree reset + transcript trimmed + next turn fresh.
|
||||
Reference in New Issue
Block a user