Compare commits
11 Commits
937920df06
...
v2.6.4-age
| Author | SHA1 | Date | |
|---|---|---|---|
| 12d31a81a0 | |||
| 5da6eb2447 | |||
| 7f6c4780e2 | |||
| 30b6f70f95 | |||
| c2b3e0a013 | |||
| cb1846c0d5 | |||
| f1a85627e4 | |||
| c65daba5dd | |||
| c9e302da37 | |||
| f69ea5f494 | |||
| 3a26563be2 |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||||
|
|
||||||
|
## v2.6.4-agent-sessions-fk — 2026-05-31
|
||||||
|
|
||||||
|
Follow-up to `v2.6.3-chatkey-and-skills` (P1.5-b): the live `agent_sessions.session_id` foreign key is converged from `ON DELETE CASCADE` to `ON DELETE SET NULL`, matching the schema's stated intent. The P1.5-b re-key block re-adds `session_id_fkey` as `SET NULL`, but the whole block is guarded on `chat_id_fkey`'s absence — so a database already re-keyed to `(chat_id, agent)` while `session_id_fkey` was still `CASCADE` never re-enters it, leaving the live FK at `CASCADE` and diverging from both `worktree_id` (already `SET NULL`) and the `v2.6.3` changelog's own claim that `session_id` is informational `SET NULL`. The fix adds a standalone `confdeltype`-guarded `DO` block (mirroring the `session_worktrees` defang) that flips `session_id_fkey` `CASCADE → SET NULL` independently of the re-key gate; it is idempotent — fires only while the FK is still `'c'`, a no-op on a fresh deploy (already `'n'`) and on every re-run. The live DB was converged by hand with the identical statements, so `applySchema` and the hand-applied state match (`\d agent_sessions` now shows `session_id ... ON DELETE SET NULL`). Also bundles a CLAUDE.md doc-sync (committed separately): per-session SSE (P1.5-a) and the `(chat_id, agent)` re-key reflected in the engineering notes, the stale root `AGENTS.md` navigation pointer dropped, and new conventions for `data/AGENTS.md` parsing and the `data/skills/<vendor>/` layout.
|
||||||
|
|
||||||
|
## v2.6.3-chatkey-and-skills — 2026-05-31
|
||||||
|
|
||||||
|
Three threads. **agent_sessions re-keyed to `(chat_id, agent)` (P1.5-b):** the tab (a chat) is now the agent-context unit, so two opencode tabs in one BooCode session are two independent contexts that share one worktree. `chat_id` is threaded end-to-end — `tasks.chat_id` added, stamped by the coder message + skills routes from the frontend tab, read by `runOpenCodeServerTask` which falls back to resolve-or-create a chat for session-less creators (arena/MCP/new_task/generic `/api/tasks`) so `ensureSession` never receives a degenerate `(null, agent)` key. A new first-class `worktrees` table (one-per-session, survives session delete via `session_id ON DELETE SET NULL`) supersedes `session_worktrees`, which is defanged (CASCADE dropped, not yet removed); `agent_sessions.chat_id` CASCADEs from `chats` (closing a tab ends its context) while `worktree_id`/`session_id` are informational `SET NULL`. The migration is idempotent with a backfill-verify gate; the live re-key was applied against an empty table after the 35-chat test session `20d28876` was deleted (backed up first). This corrects and supersedes an earlier draft that wrongly keyed on `(worktree_id, agent)`; the delete-guard from `v2.6.2-delete-guard-and-sse` is repointed here from `session_worktrees` to `worktrees` (`worktree_path`→`path`). **dcp-strip cross-chunk fix:** the `<dcp-message-id>` tag streams split across SSE deltas, which the per-chunk strip from `v2.6.1-phase1-opencode` missed — a stateful `makeDcpStreamStripper` at the dispatcher boundary holds back partial-tag tails so neither live frames nor persisted content carry the tag (11 unit tests). **Agent-judgment skills:** `committing-changes` (segment by concern, stage explicitly, present-and-stop, never push) and `using-worktrees` (the when-to-isolate heuristic, autonomous-when-clear vs committing's command-gate) land in `data/skills/boocode/` with eval.yamls, plus a parser-safe `data/AGENTS.md` preamble pointing at both.
|
||||||
|
|
||||||
|
## v2.6.2-delete-guard-and-sse — 2026-05-30
|
||||||
|
|
||||||
|
Two coder-side batches under one tag. **Session-delete work-loss guard:** deleting a BooChat session CASCADE-wipes its `session_worktrees` row, which would silently orphan uncommitted/unpushed/unmerged work — so the server's `DELETE /api/sessions/:id` now gates before the delete. It reads `session_worktrees` from the shared DB first (no row → chat-only session → delete immediately, zero round-trip), and for worktree-backed sessions calls a new BooCoder endpoint (`/worktree-risk`) that runs git on the host, since the container can't see `/tmp/booworktrees` — only the host systemd service can. `checkWorktreeWorkAtRisk` reports dirty/unpushed/unmerged via the audited `hostExec`+`shellEscape` path, default branch detected from `refs/remotes/origin/HEAD` (never the worktree's own branch, never hardcoded); any at-risk worktree returns 409 with per-worktree `RiskReport[]`, `force=true` bypasses, and the check is fail-closed (BooCoder unreachable also blocks — force still escapes). The sidebar renders a block dialog distinguishing work-at-risk (Commit/Stash/Force; stash uses `-u` and re-blocks on remaining commits) from couldn't-verify (Cancel/Force), and Commit never auto-commits. A follow-up fix gates the `unpushed` arm behind an actual upstream (`atRisk = dirty || unmerged > 0 || (hasUpstream && unpushed > 0)`) so the no-upstream `session-<id>` branches stop flagging every pristine worktree-backed session — no protection lost, since real local work always also surfaces as `unmerged > 0`. **Per-session SSE (P1.5-a):** replaces the single global SSE loop scoped to the most-recent worktree directory — the known limit flagged in `v2.6.1-phase1-opencode` — with one `event.subscribe({directory})` per live opencode session, so sessions in different worktrees stream concurrently instead of the second silently dropping the first's events. Each session owns an `AbortController` wired into `subscribe(…, {signal})`, which also fixes a latent Phase-1 bug where switching directories left the old loop parked forever in its `for await` (zombie loops); a `sessionID` demux guard drops cross-session events so two sessions sharing a worktree (possible after P1.5-b) don't double-process deltas. The opencode SDK was confirmed to open an independent SSE connection per `subscribe()` call, so N concurrent dir-scoped streams are supported.
|
||||||
|
|
||||||
## v2.6.1-phase1-opencode — 2026-05-30
|
## v2.6.1-phase1-opencode — 2026-05-30
|
||||||
|
|
||||||
v2.6 Phase 1: opencode runs as a warm HTTP server (`apps/coder/src/services/backends/opencode-server.ts`) — one `opencode serve` per BooCoder process, one opencode session per BooCode session resumed across turns via the new `agent_sessions` table, with a single SSE read loop, reasoning dedup ported from Paseo, an inactivity watchdog, and a stale-session guard (crashed-not-resumed + a `config_hash` fingerprint over `opencode_server|<model>`, deliberately excluding the ephemeral server port so cross-restart resume survives). Builds on the `v2.6.0-phase0-foundations` schema/interface scaffold. The batch's hard-won fixes: opencode streams `session.next.*` events (not `message.part.*`), and `event.subscribe()` must pass the session's worktree `directory` or events route to the server CWD and turns come back empty; model strings must be `llama-swap/`-prefixed and present in opencode's own config, with `agent-probe` now populating `available_agents.models` via `mergeLlamaSwap` so the frontend stops sending an empty model; `session_worktrees`/`agent_sessions` FKs are `ON DELETE CASCADE` so session deletion no longer 500s. Also bundled: dcp-message-id tag stripping from opencode text output, a reopen-closed-pane control, the `[+]`/split-pane button separation, auto-name using the session's loaded model, and a `systematic-debugging` slash command. Smoke 1 verified end-to-end (two turns, session reuse, turn 2 ~9x faster). Known Phase 1 limit: one SSE stream scoped to the most-recent session's directory — concurrent opencode sessions in different worktrees collide (warns; per-session SSE is Phase 2).
|
v2.6 Phase 1: opencode runs as a warm HTTP server (`apps/coder/src/services/backends/opencode-server.ts`) — one `opencode serve` per BooCoder process, one opencode session per BooCode session resumed across turns via the new `agent_sessions` table, with a single SSE read loop, reasoning dedup ported from Paseo, an inactivity watchdog, and a stale-session guard (crashed-not-resumed + a `config_hash` fingerprint over `opencode_server|<model>`, deliberately excluding the ephemeral server port so cross-restart resume survives). Builds on the `v2.6.0-phase0-foundations` schema/interface scaffold. The batch's hard-won fixes: opencode streams `session.next.*` events (not `message.part.*`), and `event.subscribe()` must pass the session's worktree `directory` or events route to the server CWD and turns come back empty; model strings must be `llama-swap/`-prefixed and present in opencode's own config, with `agent-probe` now populating `available_agents.models` via `mergeLlamaSwap` so the frontend stops sending an empty model; `session_worktrees`/`agent_sessions` FKs are `ON DELETE CASCADE` so session deletion no longer 500s. Also bundled: dcp-message-id tag stripping from opencode text output, a reopen-closed-pane control, the `[+]`/split-pane button separation, auto-name using the session's loaded model, and a `systematic-debugging` slash command. Smoke 1 verified end-to-end (two turns, session reuse, turn 2 ~9x faster). Known Phase 1 limit: one SSE stream scoped to the most-recent session's directory — concurrent opencode sessions in different worktrees collide (warns; per-session SSE is Phase 2).
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
**Cursor agents:** start with `AGENTS.md` (navigation) and `docs/ARCHITECTURE.md` (diagram). This file is the deep engineering reference.
|
**Cursor agents:** start with `docs/ARCHITECTURE.md` (diagram). This file is the deep engineering reference. (Note: the root navigation `AGENTS.md` was removed in v1.12; `data/AGENTS.md` is the agent *registry*, not navigation.)
|
||||||
|
|
||||||
## What is BooCode
|
## What is BooCode
|
||||||
|
|
||||||
@@ -90,9 +90,9 @@ Route registration: all routes registered in `index.ts` via `register*Routes(app
|
|||||||
- **Provider snapshot lifecycle** (`apps/coder/src/services/`): `provider-config.ts` (Zod config, never-throws on bad input) → `provider-config-registry.ts` (`buildResolvedRegistry`, singleton) → `provider-snapshot.ts` (two-tier probe: tier-1 fast presence, tier-2 cold ACP probe skipped unless force / stale `PROVIDER_PROBE_TTL_MS` 24h / dbEmpty; cached). Verify live: `curl http://100.114.205.53:9502/api/providers/snapshot` — returns providers + models + commands, the exact shape `AgentComposerBar` renders.
|
- **Provider snapshot lifecycle** (`apps/coder/src/services/`): `provider-config.ts` (Zod config, never-throws on bad input) → `provider-config-registry.ts` (`buildResolvedRegistry`, singleton) → `provider-snapshot.ts` (two-tier probe: tier-1 fast presence, tier-2 cold ACP probe skipped unless force / stale `PROVIDER_PROBE_TTL_MS` 24h / dbEmpty; cached). Verify live: `curl http://100.114.205.53:9502/api/providers/snapshot` — returns providers + models + commands, the exact shape `AgentComposerBar` renders.
|
||||||
- `PATCH /api/providers/config` replaces a provider id's override object **wholesale** (per-id shallow merge) — to flip one field send `{...existing, enabled}`, or a custom ACP entry's `command`/`label` is wiped and it drops out of the resolved registry. `data/coder-providers.json` is **gitignored** (it's live runtime config — the coder reads AND writes it on UI toggles); the tracked reference is `data/coder-providers.example.json`. The loader falls back to `{providers:{}}` (built-ins only) when the live file is absent, so a fresh checkout needs no copy.
|
- `PATCH /api/providers/config` replaces a provider id's override object **wholesale** (per-id shallow merge) — to flip one field send `{...existing, enabled}`, or a custom ACP entry's `command`/`label` is wiped and it drops out of the resolved registry. `data/coder-providers.json` is **gitignored** (it's live runtime config — the coder reads AND writes it on UI toggles); the tracked reference is `data/coder-providers.example.json`. The loader falls back to `{providers:{}}` (built-ins only) when the live file is absent, so a fresh checkout needs no copy.
|
||||||
- **opencode** runs as a warm HTTP server (v2.6 Phase 1, `services/backends/opencode-server.ts` — `opencode serve` per BooCoder process, one opencode session per BooCode session, resumed via `agent_sessions`). goose/qwen/claude still dispatch **one-shot** ACP/PTY with no ctx/token usage; only native `boocode` (llama-swap engine) tracks ctx. Paseo's per-provider native clients (design §12) deliberately not ported.
|
- **opencode** runs as a warm HTTP server (v2.6 Phase 1, `services/backends/opencode-server.ts` — `opencode serve` per BooCoder process, one opencode session per BooCode session, resumed via `agent_sessions`). goose/qwen/claude still dispatch **one-shot** ACP/PTY with no ctx/token usage; only native `boocode` (llama-swap engine) tracks ctx. Paseo's per-provider native clients (design §12) deliberately not ported.
|
||||||
- **opencode SSE** (`opencode-server.ts`): live streaming arrives as `session.next.text.delta` / `session.next.reasoning.delta` / `session.next.tool.{called,success,failed}` — NOT `message.part.*` (those are terminal/post-hoc). `client.event.subscribe({ directory })` MUST pass the session's worktree directory; omit it and opencode scopes events to the server's `process.cwd()` → zero session events (empty turns, 180s watchdog timeout). One SSE stream at a time scoped to the last session's dir — concurrent opencode sessions in different worktrees collide (known Phase 1 limit, warns). Turn completes on `session.idle`; `promptAsync` is fire-and-forget (204).
|
- **opencode SSE** (`opencode-server.ts`): live streaming arrives as `session.next.text.delta` / `session.next.reasoning.delta` / `session.next.tool.{called,success,failed}` — NOT `message.part.*` (those are terminal/post-hoc). `client.event.subscribe({ directory })` MUST pass the session's worktree directory; omit it and opencode scopes events to the server's `process.cwd()` → zero session events (empty turns, 180s watchdog timeout). Per-session SSE (P1.5-a): each live session owns its own `event.subscribe({directory})` loop + AbortController, so concurrent sessions in different worktrees stream independently; a `sessionID` demux guard drops cross-session events when two share a dir. Turn completes on `session.idle`; `promptAsync` is fire-and-forget (204).
|
||||||
- **opencode model strings** must be provider-prefixed (`llama-swap/<model>`) AND exist in `~/.config/opencode/opencode.json` `provider.llama-swap.models` — not merely loadable by llama-swap. `parseModel` infers `llama-swap/` for a bare id; the dispatcher coalesces empty→DEFAULT_MODEL then prefixes. `agent-probe` populates opencode's `available_agents.models` via `mergeLlamaSwap` (fetches `/v1/models`); empty model list → frontend sends `''` → no inference (`input:0`, empty turn).
|
- **opencode model strings** must be provider-prefixed (`llama-swap/<model>`) AND exist in `~/.config/opencode/opencode.json` `provider.llama-swap.models` — not merely loadable by llama-swap. `parseModel` infers `llama-swap/` for a bare id; the dispatcher coalesces empty→DEFAULT_MODEL then prefixes. `agent-probe` populates opencode's `available_agents.models` via `mergeLlamaSwap` (fetches `/v1/models`); empty model list → frontend sends `''` → no inference (`input:0`, empty turn).
|
||||||
- **agent_sessions resume**: `config_hash = sha256('opencode_server|<model>')` — must NOT include the server port (random per boot; including it breaks cross-restart resume). `session_worktrees` + `agent_sessions` FKs to `sessions(id)` are `ON DELETE CASCADE` (else DELETE /api/sessions/:id 500s on FK violation). The `@opencode-ai/sdk` v2 client takes flattened params (`{sessionID, directory, parts, model:{providerID,modelID}}`), imports `createOpencodeClient` from `@opencode-ai/sdk/v2/client`.
|
- **agent_sessions resume**: `config_hash = sha256('opencode_server|<model>')` — must NOT include the server port (random per boot; including it breaks cross-restart resume). P1.5-b: `agent_sessions` is keyed `(chat_id, agent)` — the tab/chat is the context unit (two opencode tabs in one session = two contexts sharing one worktree). `chat_id` CASCADEs from `chats`; `session_id`/`worktree_id` are informational `SET NULL`. The `worktrees` table (one-per-session, `session_id` SET NULL so it survives session delete) supersedes the defanged `session_worktrees`. `tasks.chat_id` threads the tab id to the dispatcher; `runOpenCodeServerTask` falls back to resolve-or-create a chat when it's null (arena/MCP/new_task). The `@opencode-ai/sdk` v2 client takes flattened params (`{sessionID, directory, parts, model:{providerID,modelID}}`), imports `createOpencodeClient` from `@opencode-ai/sdk/v2/client`.
|
||||||
|
|
||||||
### Frontend (`apps/web/src/`)
|
### Frontend (`apps/web/src/`)
|
||||||
|
|
||||||
@@ -192,6 +192,8 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
|||||||
- **ReadableStream test stubs** use `pull()` (not `start()`) so chunks are produced lazily — `start()` enqueues everything and calls `controller.close()` before the consumer reads, so a subsequent `reader.cancel()` finds the stream already closed and the `cancel()` callback never fires. Also provide MORE chunks than the test will consume so the source stays in 'readable' state when cancel runs (e.g. cap test reads ~6 chunks, stub provides 10).
|
- **ReadableStream test stubs** use `pull()` (not `start()`) so chunks are produced lazily — `start()` enqueues everything and calls `controller.close()` before the consumer reads, so a subsequent `reader.cancel()` finds the stream already closed and the `cancel()` callback never fires. Also provide MORE chunks than the test will consume so the source stays in 'readable' state when cancel runs (e.g. cap test reads ~6 chunks, stub provides 10).
|
||||||
- Tool-name whitelists must derive from `ALL_TOOLS` in `services/tools.ts`, never hardcoded. `services/agents.ts` `ALL_TOOL_NAMES` had this drift class until v1.12 — same pattern applies to any future tool-aware code.
|
- Tool-name whitelists must derive from `ALL_TOOLS` in `services/tools.ts`, never hardcoded. `services/agents.ts` `ALL_TOOL_NAMES` had this drift class until v1.12 — same pattern applies to any future tool-aware code.
|
||||||
- Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The `getAgentsForProject` per-project override mechanism remains for *other* projects.
|
- Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The `getAgentsForProject` per-project override mechanism remains for *other* projects.
|
||||||
|
- `data/AGENTS.md` is PARSED (`agents.ts` `splitSections`/`parseAgentSection`): each `## <Name>` is one agent and must be followed by a `---` frontmatter fence or the block throws; content before the first `## ` is discarded. Do NOT add free-form `## ` rule sections — they break the registry. Cross-cutting agent rules go in CLAUDE.md or a parser-ignored preamble.
|
||||||
|
- Skills live in `data/skills/<vendor>/`; Sam's own namespace is `boocode/` (`committing-changes`, `using-worktrees`, `improving-boocode-guidance`) — `SKILL.md` + optional `eval.yaml` (gerund names; eval = `skill:` + `tasks:` of `prompt`+`grader`, incl. a negative-trigger task). `data/skills/` is canonical; a divergent mirror at `/opt/skills/` exists.
|
||||||
- MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. The `codecontext/shim.go` framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports).
|
- MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. The `codecontext/shim.go` framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports).
|
||||||
- **Workspace dependency pattern** (`apps/coder` → `@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
|
- **Workspace dependency pattern** (`apps/coder` → `@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
|
||||||
- **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of a JSON object/array). Pattern established in `parts.ts`, `settings.ts`.
|
- **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of a JSON object/array). Pattern established in `parts.ts`, `settings.ts`.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { registerInboxRoutes } from './routes/inbox.js';
|
|||||||
import { registerStatsRoutes } from './routes/stats.js';
|
import { registerStatsRoutes } from './routes/stats.js';
|
||||||
import { registerArenaRoutes } from './routes/arena.js';
|
import { registerArenaRoutes } from './routes/arena.js';
|
||||||
import { registerProviderRoutes } from './routes/providers.js';
|
import { registerProviderRoutes } from './routes/providers.js';
|
||||||
|
import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js';
|
||||||
import { registerWebSocket } from './routes/ws.js';
|
import { registerWebSocket } from './routes/ws.js';
|
||||||
// Phase 4: dispatcher + agent probe
|
// Phase 4: dispatcher + agent probe
|
||||||
import { createDispatcher } from './services/dispatcher.js';
|
import { createDispatcher } from './services/dispatcher.js';
|
||||||
@@ -195,6 +196,7 @@ async function main() {
|
|||||||
registerStatsRoutes(app, sql);
|
registerStatsRoutes(app, sql);
|
||||||
registerArenaRoutes(app, sql);
|
registerArenaRoutes(app, sql);
|
||||||
registerProviderRoutes(app, sql, config);
|
registerProviderRoutes(app, sql, config);
|
||||||
|
registerWorktreeSafetyRoutes(app, sql);
|
||||||
registerWebSocket(app, sql, broker);
|
registerWebSocket(app, sql, broker);
|
||||||
|
|
||||||
// Serve static frontend (built web app). In production, the dist/ is
|
// Serve static frontend (built web app). In production, the dist/ is
|
||||||
|
|||||||
@@ -224,8 +224,8 @@ export function registerMessageRoutes(
|
|||||||
// External provider: create a task for the dispatcher
|
// External provider: create a task for the dispatcher
|
||||||
const projectId = sessionRows[0]!.project_id;
|
const projectId = sessionRows[0]!.project_id;
|
||||||
const [task] = await sql<{ id: string; state: string }[]>`
|
const [task] = await sql<{ id: string; state: string }[]>`
|
||||||
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
|
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id, chat_id)
|
||||||
VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
|
VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId}, ${chatId})
|
||||||
RETURNING id, state
|
RETURNING id, state
|
||||||
`;
|
`;
|
||||||
reply.code(202);
|
reply.code(202);
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ export function registerSkillRoutes(
|
|||||||
|
|
||||||
const taskInput = `${body}\n\n---\n\n${userText}`;
|
const taskInput = `${body}\n\n---\n\n${userText}`;
|
||||||
const [task] = await sql<{ id: string; state: string }[]>`
|
const [task] = await sql<{ id: string; state: string }[]>`
|
||||||
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
|
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id, chat_id)
|
||||||
VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
|
VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId}, ${chatId})
|
||||||
RETURNING id, state
|
RETURNING id, state
|
||||||
`;
|
`;
|
||||||
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||||
|
|||||||
45
apps/coder/src/routes/worktree-safety.ts
Normal file
45
apps/coder/src/routes/worktree-safety.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Session-delete work-loss guard (coder side).
|
||||||
|
*
|
||||||
|
* Session delete itself lives in apps/server (Docker), which CANNOT see the
|
||||||
|
* host worktree dirs (/tmp/booworktrees) or run git on them. Only BooCoder
|
||||||
|
* (host systemd) can. So the server's DELETE route calls these endpoints
|
||||||
|
* pre-delete to learn whether a session's worktree holds work at risk, and to
|
||||||
|
* stash it. The server owns the gate; coder owns the git truth.
|
||||||
|
*/
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import { checkWorktreeWorkAtRisk, stashWorktree } from '../services/worktrees.js';
|
||||||
|
|
||||||
|
export function registerWorktreeSafetyRoutes(app: FastifyInstance, sql: Sql): void {
|
||||||
|
// GET risk for a session's worktree(s). One row per session today (PK on
|
||||||
|
// session_id); the loop already handles the Phase-1.5 multi-worktree case.
|
||||||
|
app.get<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/sessions/:sessionId/worktree-risk',
|
||||||
|
async (req) => {
|
||||||
|
const rows = await sql<{ worktree_path: string }[]>`
|
||||||
|
SELECT path AS worktree_path FROM worktrees WHERE session_id = ${req.params.sessionId}
|
||||||
|
`;
|
||||||
|
const reports = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
reports.push(await checkWorktreeWorkAtRisk(row.worktree_path));
|
||||||
|
}
|
||||||
|
return { reports };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stash a session's worktree(s) — clears the dirty risk; recoverable.
|
||||||
|
app.post<{ Params: { sessionId: string } }>(
|
||||||
|
'/api/sessions/:sessionId/worktree-stash',
|
||||||
|
async (req) => {
|
||||||
|
const rows = await sql<{ worktree_path: string }[]>`
|
||||||
|
SELECT path AS worktree_path FROM worktrees WHERE session_id = ${req.params.sessionId}
|
||||||
|
`;
|
||||||
|
const results = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
results.push({ worktreePath: row.worktree_path, ...(await stashWorktree(row.worktree_path)) });
|
||||||
|
}
|
||||||
|
return { results };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -83,16 +83,20 @@ CREATE TABLE IF NOT EXISTS session_worktrees (
|
|||||||
base_commit TEXT,
|
base_commit TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||||
);
|
);
|
||||||
-- Migrate existing FK to CASCADE (idempotent: drops the old constraint if present).
|
-- P1.5-b: DEFANG the CASCADE — a session delete must no longer wipe its worktree
|
||||||
|
-- row. This table is SUPERSEDED by `worktrees` below; all readers are repointed
|
||||||
|
-- this phase, so the row just persists (dead) on session delete until a later
|
||||||
|
-- cleanup drops the table. session_id is this table's PRIMARY KEY, so it cannot be
|
||||||
|
-- nullable → SET NULL is invalid and NO ACTION/RESTRICT would block deletes; the
|
||||||
|
-- only valid defang is to drop the FK with no replacement. Idempotent: only fires
|
||||||
|
-- while the FK is still ON DELETE CASCADE ('c').
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
IF EXISTS (
|
IF EXISTS (
|
||||||
SELECT 1 FROM pg_constraint
|
SELECT 1 FROM pg_constraint
|
||||||
WHERE conname = 'session_worktrees_session_id_fkey'
|
WHERE conname = 'session_worktrees_session_id_fkey'
|
||||||
AND confdeltype <> 'c'
|
AND confdeltype = 'c'
|
||||||
) THEN
|
) THEN
|
||||||
ALTER TABLE session_worktrees DROP CONSTRAINT session_worktrees_session_id_fkey;
|
ALTER TABLE session_worktrees DROP CONSTRAINT session_worktrees_session_id_fkey;
|
||||||
ALTER TABLE session_worktrees ADD CONSTRAINT session_worktrees_session_id_fkey
|
|
||||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
|
|
||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
@@ -127,6 +131,101 @@ END $$;
|
|||||||
-- v2.6: config fingerprint for stale-session detection (auto-recover on model change).
|
-- v2.6: config fingerprint for stale-session detection (auto-recover on model change).
|
||||||
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS config_hash TEXT;
|
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS config_hash TEXT;
|
||||||
|
|
||||||
|
-- ─── P1.5-b (corrected): worktrees entity + re-key agent_sessions to (chat_id, agent) ───
|
||||||
|
-- The TAB (a chat) is the context unit: two opencode tabs in one session = two
|
||||||
|
-- independent contexts sharing one worktree. So agent_sessions keys on
|
||||||
|
-- (chat_id, agent), NOT (worktree_id, agent) or (session_id, agent). The
|
||||||
|
-- `worktrees` table is one-per-session (selectable later) and only referenced
|
||||||
|
-- informationally by agent_sessions.worktree_id (SET NULL); chat_id is the key.
|
||||||
|
--
|
||||||
|
-- PREREQUISITE: the unmigratable test session (35 chats, 1 agent_sessions row that
|
||||||
|
-- maps to no single chat) is DELETED before this runs, so agent_sessions is empty
|
||||||
|
-- and the chat_id backfill is N/A. If a row with NULL chat_id remains, the verify
|
||||||
|
-- gate below RAISEs and aborts — delete the offending session first.
|
||||||
|
|
||||||
|
-- worktree as a first-class entity; survives session delete (session_id SET NULL).
|
||||||
|
CREATE TABLE IF NOT EXISTS worktrees (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
session_id UUID REFERENCES sessions(id) ON DELETE SET NULL,
|
||||||
|
project_id UUID,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
branch TEXT,
|
||||||
|
base_commit TEXT,
|
||||||
|
slug TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS worktrees_active_path_uidx ON worktrees(path) WHERE status='active';
|
||||||
|
|
||||||
|
-- Migrate any surviving session_worktrees rows → worktrees (idempotent; 0 rows
|
||||||
|
-- after the test-session delete, kept for generality / fresh-DB safety).
|
||||||
|
INSERT INTO worktrees (session_id, path, branch, base_commit, status)
|
||||||
|
SELECT sw.session_id, sw.worktree_path, 'session-' || sw.session_id, sw.base_commit, 'active'
|
||||||
|
FROM session_worktrees sw
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM worktrees w WHERE w.session_id = sw.session_id AND w.status='active');
|
||||||
|
|
||||||
|
-- Dispatch hint: which chat (tab) a task belongs to. The coder message route and
|
||||||
|
-- skills route set it from the frontend tab; session-less creators (arena, MCP,
|
||||||
|
-- new_task, generic /api/tasks) leave it NULL and the dispatcher creates a chat.
|
||||||
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Re-key columns on agent_sessions.
|
||||||
|
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS chat_id UUID;
|
||||||
|
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS worktree_id UUID;
|
||||||
|
|
||||||
|
-- BACKFILL-VERIFY GATE: the new PK is (chat_id, agent), so chat_id must be
|
||||||
|
-- non-null on every row before the swap. With the test session deleted this is a
|
||||||
|
-- 0-row assertion; if any row has NULL chat_id (an unmigratable pre-existing row),
|
||||||
|
-- abort loudly rather than create a degenerate (NULL, agent) key.
|
||||||
|
DO $$
|
||||||
|
DECLARE n int;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO n FROM agent_sessions WHERE chat_id IS NULL;
|
||||||
|
IF n > 0 THEN
|
||||||
|
RAISE EXCEPTION 'P1.5-b: % agent_sessions row(s) have NULL chat_id — delete the unmigratable session(s) before applying', n;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Swap PK (session_id,agent) → (chat_id,agent) + FKs (run-once, guarded on the new
|
||||||
|
-- FK's absence). chat_id CASCADEs from chats (closing a tab ends its context);
|
||||||
|
-- worktree_id is informational SET NULL; session_id defanged to nullable SET NULL.
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agent_sessions_chat_id_fkey') THEN
|
||||||
|
ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_pkey;
|
||||||
|
ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_session_id_fkey;
|
||||||
|
ALTER TABLE agent_sessions ALTER COLUMN session_id DROP NOT NULL;
|
||||||
|
ALTER TABLE agent_sessions ALTER COLUMN chat_id SET NOT NULL;
|
||||||
|
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_pkey PRIMARY KEY (chat_id, agent);
|
||||||
|
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_chat_id_fkey
|
||||||
|
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_session_id_fkey
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_worktree_id_fkey
|
||||||
|
FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- P1.5-b follow-up: converge agent_sessions.session_id FK CASCADE → SET NULL.
|
||||||
|
-- The re-key block above re-adds session_id_fkey as SET NULL, but it is guarded on
|
||||||
|
-- chat_id_fkey's ABSENCE — so a DB already re-keyed to (chat_id, agent) while
|
||||||
|
-- session_id_fkey was still ON DELETE CASCADE never re-enters that block and stays
|
||||||
|
-- 'c'. This standalone guard flips it to SET NULL ('n'), matching worktree_id.
|
||||||
|
-- Idempotent (mirrors the session_worktrees defang's confdeltype check): only fires
|
||||||
|
-- while the FK is still CASCADE — a no-op on a fresh deploy (already 'n' from the
|
||||||
|
-- re-key block) and on every re-run thereafter.
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'agent_sessions_session_id_fkey'
|
||||||
|
AND confdeltype = 'c'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE agent_sessions ALTER COLUMN session_id DROP NOT NULL;
|
||||||
|
ALTER TABLE agent_sessions DROP CONSTRAINT agent_sessions_session_id_fkey;
|
||||||
|
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_session_id_fkey
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
-- v2.6: attribution for DiffPanel badges (Phase 1 UX reads this).
|
-- v2.6: attribution for DiffPanel badges (Phase 1 UX reads this).
|
||||||
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;
|
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;
|
||||||
|
|
||||||
|
|||||||
73
apps/coder/src/services/__tests__/dcp-strip.test.ts
Normal file
73
apps/coder/src/services/__tests__/dcp-strip.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { stripDcpTags, makeDcpStreamStripper } from '../dcp-strip.js';
|
||||||
|
|
||||||
|
// Feed chunks through a fresh stripper and return the fully reassembled output
|
||||||
|
// (everything emitted during streaming + the final flush) — i.e. what the
|
||||||
|
// dispatcher would accumulate into the persisted message content.
|
||||||
|
function run(chunks: string[]): string {
|
||||||
|
const s = makeDcpStreamStripper();
|
||||||
|
let out = '';
|
||||||
|
for (const c of chunks) out += s.push(c);
|
||||||
|
out += s.flush();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('stripDcpTags (one-shot)', () => {
|
||||||
|
it('removes a complete tag', () => {
|
||||||
|
expect(stripDcpTags('Yes — "Test".\n\n<dcp-message-id>m0019</dcp-message-id>')).toBe(
|
||||||
|
'Yes — "Test".\n\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('leaves text without a tag untouched', () => {
|
||||||
|
expect(stripDcpTags('no tag here')).toBe('no tag here');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('per-chunk strip is INSUFFICIENT (documents the bug)', () => {
|
||||||
|
it('a tag split across chunks survives a naive per-chunk .replace()', () => {
|
||||||
|
const chunks = ['Yes.\n\n<dcp', '-message', '-id>m0019</dcp', '-message-id>'];
|
||||||
|
const naive = chunks.map(stripDcpTags).join('');
|
||||||
|
// The reassembled content still contains the tag — this is the screenshot bug.
|
||||||
|
expect(naive).toContain('<dcp-message-id>m0019</dcp-message-id>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('makeDcpStreamStripper (cross-chunk fix)', () => {
|
||||||
|
it('strips a tag split across chunks (the real opencode case)', () => {
|
||||||
|
expect(run(['Yes.\n\n<dcp', '-message', '-id>m0019</dcp', '-message-id>'])).toBe('Yes.\n\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a tag split at EVERY character boundary', () => {
|
||||||
|
const full = 'Answer.<dcp-message-id>m0019</dcp-message-id>';
|
||||||
|
expect(run([...full])).toBe('Answer.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a tag delivered whole in one chunk', () => {
|
||||||
|
expect(run(['Answer.<dcp-message-id>m0019</dcp-message-id>'])).toBe('Answer.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through text with no tag', () => {
|
||||||
|
expect(run(['hello ', 'world'])).toBe('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT swallow legitimate < content (code/HTML/generics)', () => {
|
||||||
|
expect(run(['use ', '<div>', ' and ', 'Array<', 'string>'])).toBe('use <div> and Array<string>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a lone < that is not a dcp tag, split across chunks', () => {
|
||||||
|
expect(run(['a <', 'b c'])).toBe('a <b c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits surrounding text and strips a mid-text tag', () => {
|
||||||
|
expect(run(['before ', '<dcp-message-id>', 'm1', '</dcp-message-id>', ' after'])).toBe(
|
||||||
|
'before after',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushes a truncated/never-closed partial tag without leaking it as a complete tag', () => {
|
||||||
|
// If the stream ends mid-tag, flush strips complete tags; an incomplete
|
||||||
|
// remnant is returned as-is (no complete tag ever existed to render).
|
||||||
|
const out = run(['done.<dcp-message-id>m00']);
|
||||||
|
expect(out).not.toContain('</dcp-message-id>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,8 +37,15 @@ export interface EnsureSessionOpts {
|
|||||||
agent: string;
|
agent: string;
|
||||||
/** Resolved model id. */
|
/** Resolved model id. */
|
||||||
model: string;
|
model: string;
|
||||||
|
/** P1.5-b: the chat (tab) this turn belongs to. agent_sessions is keyed
|
||||||
|
* (chat_id, agent) — the tab/chat is the context unit. Always non-null:
|
||||||
|
* the dispatcher creates a chat for session-less tasks before calling. */
|
||||||
|
chatId: string;
|
||||||
/** Shared per-session worktree (one per `sessions.id`, not per pane). */
|
/** Shared per-session worktree (one per `sessions.id`, not per pane). */
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
|
/** P1.5-b: the `worktrees.id` for this session's worktree — stored on the
|
||||||
|
* agent_sessions row informationally (NOT the key). */
|
||||||
|
worktreeId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +54,10 @@ export interface AgentSessionHandle {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
agent: string;
|
agent: string;
|
||||||
backend: AgentBackendKind;
|
backend: AgentBackendKind;
|
||||||
|
/** P1.5-b: the chat (tab) this session is keyed on (with agent). */
|
||||||
|
chatId: string;
|
||||||
|
/** P1.5-b: the worktree this session's chat runs in (informational link). */
|
||||||
|
worktreeId: string;
|
||||||
/** Provider's own session id (resume token); null until the backend assigns one. */
|
/** Provider's own session id (resume token); null until the backend assigns one. */
|
||||||
agentSessionId: string | null;
|
agentSessionId: string | null;
|
||||||
/** opencode HTTP server port; null for ACP backends. */
|
/** opencode HTTP server port; null for ACP backends. */
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
*
|
*
|
||||||
* Warm, multi-turn backend for the `opencode` agent. One `opencode serve` HTTP
|
* Warm, multi-turn backend for the `opencode` agent. One `opencode serve` HTTP
|
||||||
* server per BooCoder process; one opencode session per BooCode session (resumed
|
* server per BooCoder process; one opencode session per BooCode session (resumed
|
||||||
* on switch-back); a single SSE read loop demuxes all sessions' events.
|
* on switch-back); one SSE read loop PER session, each scoped to that session's
|
||||||
|
* worktree directory so sessions in different directories stream concurrently
|
||||||
|
* (P1.5-a — replaced the Phase-1 single-stream-last-directory model).
|
||||||
*
|
*
|
||||||
* Implements the Phase 0 `AgentBackend` interface. Emits transport-agnostic
|
* Implements the Phase 0 `AgentBackend` interface. Emits transport-agnostic
|
||||||
* `AgentEvent`s — the dispatcher (Phase 1.7, NOT wired in this batch) maps them
|
* `AgentEvent`s — the dispatcher (Phase 1.7, NOT wired in this batch) maps them
|
||||||
@@ -73,6 +75,9 @@ interface SessionState {
|
|||||||
activeTurn: TurnState | null;
|
activeTurn: TurnState | null;
|
||||||
/** Inactivity backstop timer for the active turn; null when no turn in flight. */
|
/** Inactivity backstop timer for the active turn; null when no turn in flight. */
|
||||||
watchdog: ReturnType<typeof setTimeout> | null;
|
watchdog: ReturnType<typeof setTimeout> | null;
|
||||||
|
/** Per-session SSE subscription handle. Non-null while the loop is running;
|
||||||
|
* aborting it tears down the underlying fetch and exits the loop. */
|
||||||
|
sseAbort: AbortController | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeServerBackendDeps {
|
export interface OpenCodeServerBackendDeps {
|
||||||
@@ -94,7 +99,6 @@ export class OpenCodeServerBackend implements AgentBackend {
|
|||||||
private port: number | null = null;
|
private port: number | null = null;
|
||||||
private up = false;
|
private up = false;
|
||||||
private serverStarting: Promise<void> | null = null;
|
private serverStarting: Promise<void> | null = null;
|
||||||
private sseRunning = false;
|
|
||||||
|
|
||||||
/** opencode session id → demux state. Maintained by ensureSession; read by the SSE loop. */
|
/** opencode session id → demux state. Maintained by ensureSession; read by the SSE loop. */
|
||||||
private readonly byOpencodeId = new Map<string, SessionState>();
|
private readonly byOpencodeId = new Map<string, SessionState>();
|
||||||
@@ -150,37 +154,58 @@ export class OpenCodeServerBackend implements AgentBackend {
|
|||||||
|
|
||||||
// ─── SSE read loop + demux + translate (1.3) + dedup (1.4) ───────────────────
|
// ─── SSE read loop + demux + translate (1.3) + dedup (1.4) ───────────────────
|
||||||
|
|
||||||
/** Per-directory SSE subscription. opencode scopes events by directory (defaults
|
/** Per-session SSE subscription, scoped to the session's worktree directory.
|
||||||
* to process.cwd if omitted) — so we must subscribe with the same directory used
|
* opencode scopes events by the `directory` query param (defaults to the
|
||||||
* to create the session. Called from ensureSession; reconnects while up. */
|
* server's cwd if omitted), so two sessions in different worktrees each get
|
||||||
private startEventLoop(directory: string): void {
|
* their own dir-scoped stream and never drop each other's events. Idempotent:
|
||||||
if (this.sseRunning) return;
|
* a no-op if this session's loop is already running. Started from ensureSession
|
||||||
this.sseRunning = true;
|
* (and defensively from prompt) once worktreePath is known. */
|
||||||
this.sseDirectory = directory;
|
private startSessionEventLoop(state: SessionState): void {
|
||||||
void this.runEventLoop(directory);
|
if (state.sseAbort) return; // already running
|
||||||
|
const abort = new AbortController();
|
||||||
|
state.sseAbort = abort;
|
||||||
|
void this.runSessionEventLoop(state, abort).finally(() => {
|
||||||
|
// Only clear if this controller is still the live one (a later restart may
|
||||||
|
// have already installed a new one).
|
||||||
|
if (state.sseAbort === abort) state.sseAbort = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private sseDirectory: string | null = null;
|
private async runSessionEventLoop(state: SessionState, abort: AbortController): Promise<void> {
|
||||||
|
const signal = abort.signal;
|
||||||
private async runEventLoop(directory: string): Promise<void> {
|
while (this.up && this.client && !signal.aborted) {
|
||||||
while (this.up && this.client) {
|
|
||||||
try {
|
try {
|
||||||
const sub = await this.client.event.subscribe({ directory });
|
// Re-read worktreePath each (re)subscribe so a directory refresh is picked
|
||||||
|
// up on reconnect. Passing `signal` lets close/dispose tear down a stream
|
||||||
|
// that's parked in `for await` between events.
|
||||||
|
const sub = await this.client.event.subscribe(
|
||||||
|
{ directory: state.worktreePath },
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
for await (const ev of sub.stream) {
|
for await (const ev of sub.stream) {
|
||||||
|
if (signal.aborted) break;
|
||||||
|
// Dir-scoped streams should only carry this session's events, but two
|
||||||
|
// sessions sharing a worktree (possible post-P1.5-b) each receive BOTH
|
||||||
|
// sessions' events — so drop anything that isn't ours, else the other
|
||||||
|
// session's deltas get processed twice (once per loop).
|
||||||
|
const sid = eventSessionId(ev);
|
||||||
|
if (sid != null && sid !== state.agentSessionId) continue;
|
||||||
this.dispatchEvent(ev);
|
this.dispatchEvent(ev);
|
||||||
}
|
}
|
||||||
if (this.up) {
|
if (this.up && !signal.aborted) {
|
||||||
await this.reconcileInFlight();
|
await this.reconcile(state); // recover an idle/error lost during the gap
|
||||||
await sleep(SSE_RECONNECT_DELAY_MS);
|
await sleep(SSE_RECONNECT_DELAY_MS);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!this.up) break;
|
if (!this.up || signal.aborted) break;
|
||||||
this.log.warn({ err: errMsg(err) }, 'opencode-server: event loop error; reconnecting');
|
this.log.warn(
|
||||||
await this.reconcileInFlight();
|
{ err: errMsg(err), agentSessionId: state.agentSessionId },
|
||||||
|
'opencode-server: session event loop error; reconnecting',
|
||||||
|
);
|
||||||
|
await this.reconcile(state);
|
||||||
await sleep(SSE_RECONNECT_DELAY_MS);
|
await sleep(SSE_RECONNECT_DELAY_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.sseRunning = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Demux one event to the owning session's active turn. Unknown/between-turns → drop. */
|
/** Demux one event to the owning session's active turn. Unknown/between-turns → drop. */
|
||||||
@@ -354,13 +379,6 @@ export class OpenCodeServerBackend implements AgentBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reconcile every in-flight turn against the server (called after an SSE drop). */
|
|
||||||
private async reconcileInFlight(): Promise<void> {
|
|
||||||
const states = [...this.byOpencodeId.values()].filter((s) => s.activeTurn);
|
|
||||||
if (states.length === 0) return;
|
|
||||||
await Promise.allSettled(states.map((s) => this.reconcile(s)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask the server whether this session's turn already finished — recovers a
|
* Ask the server whether this session's turn already finished — recovers a
|
||||||
* session.idle/error lost during an SSE gap. Returns true if it settled the turn.
|
* session.idle/error lost during an SSE gap. Returns true if it settled the turn.
|
||||||
@@ -405,9 +423,12 @@ export class OpenCodeServerBackend implements AgentBackend {
|
|||||||
if (!this.client) throw new Error('opencode-server: client not ready after ensureServer');
|
if (!this.client) throw new Error('opencode-server: client not ready after ensureServer');
|
||||||
|
|
||||||
const configHash = sessionConfigHash(opts.model);
|
const configHash = sessionConfigHash(opts.model);
|
||||||
|
// P1.5-b: agent_sessions is keyed (chat_id, agent) — the tab/chat is the
|
||||||
|
// context unit (two tabs in one session = two contexts sharing one worktree).
|
||||||
|
// session_id + worktree_id are retained as informational (SET NULL) columns.
|
||||||
const [row] = await this.sql<{ agent_session_id: string | null; status: string; config_hash: string | null }[]>`
|
const [row] = await this.sql<{ agent_session_id: string | null; status: string; config_hash: string | null }[]>`
|
||||||
SELECT agent_session_id, status, config_hash FROM agent_sessions
|
SELECT agent_session_id, status, config_hash FROM agent_sessions
|
||||||
WHERE session_id = ${sessionId} AND agent = ${opts.agent}
|
WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent}
|
||||||
`;
|
`;
|
||||||
let agentSessionId = row?.agent_session_id ?? null;
|
let agentSessionId = row?.agent_session_id ?? null;
|
||||||
|
|
||||||
@@ -429,10 +450,12 @@ export class OpenCodeServerBackend implements AgentBackend {
|
|||||||
agentSessionId = created.data.id;
|
agentSessionId = created.data.id;
|
||||||
await this.sql`
|
await this.sql`
|
||||||
INSERT INTO agent_sessions
|
INSERT INTO agent_sessions
|
||||||
(session_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash)
|
(chat_id, session_id, worktree_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash)
|
||||||
VALUES
|
VALUES
|
||||||
(${sessionId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash})
|
(${opts.chatId}, ${sessionId}, ${opts.worktreeId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash})
|
||||||
ON CONFLICT (session_id, agent) DO UPDATE SET
|
ON CONFLICT (chat_id, agent) DO UPDATE SET
|
||||||
|
session_id = EXCLUDED.session_id,
|
||||||
|
worktree_id = EXCLUDED.worktree_id,
|
||||||
backend = 'opencode_server',
|
backend = 'opencode_server',
|
||||||
agent_session_id = EXCLUDED.agent_session_id,
|
agent_session_id = EXCLUDED.agent_session_id,
|
||||||
server_port = EXCLUDED.server_port,
|
server_port = EXCLUDED.server_port,
|
||||||
@@ -444,42 +467,21 @@ export class OpenCodeServerBackend implements AgentBackend {
|
|||||||
await this.sql`
|
await this.sql`
|
||||||
UPDATE agent_sessions
|
UPDATE agent_sessions
|
||||||
SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.port}, config_hash = ${configHash}
|
SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.port}, config_hash = ${configHash}
|
||||||
WHERE session_id = ${sessionId} AND agent = ${opts.agent}
|
WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both branches above guarantee agentSessionId is non-null.
|
// Both branches above guarantee agentSessionId is non-null.
|
||||||
const ocSessionId = agentSessionId!;
|
const ocSessionId = agentSessionId!;
|
||||||
|
|
||||||
// Start (or re-start) the SSE event loop scoped to this session's directory.
|
|
||||||
// opencode scopes events by the `directory` query param; without it events
|
|
||||||
// default to the server's CWD which doesn't match our worktree paths.
|
|
||||||
//
|
|
||||||
// KNOWN Phase 1 LIMITATION: one SSE stream at a time, scoped to a single
|
|
||||||
// directory. Under 1.9 concurrency, if two opencode sessions use different
|
|
||||||
// worktree directories simultaneously, re-subscribing for the second drops
|
|
||||||
// the first session's events (the watchdog backstop prevents a full hang,
|
|
||||||
// but streamed content is lost). Phase 2 should move to per-session SSE
|
|
||||||
// subscriptions or a directory-agnostic event path.
|
|
||||||
if (!this.sseRunning || this.sseDirectory !== opts.worktreePath) {
|
|
||||||
if (this.sseRunning && this.sseDirectory && this.sseDirectory !== opts.worktreePath) {
|
|
||||||
this.log.warn(
|
|
||||||
{ prev: this.sseDirectory, next: opts.worktreePath },
|
|
||||||
'opencode-server: SSE directory changed — concurrent sessions will lose events from the previous directory',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.sseRunning = false;
|
|
||||||
this.startEventLoop(opts.worktreePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register / refresh the demux entry the SSE loop keys on. Preserve an existing
|
// Register / refresh the demux entry the SSE loop keys on. Preserve an existing
|
||||||
// entry (and any in-flight turn) — just refresh the routing fields.
|
// entry (and any in-flight turn) — just refresh the routing fields.
|
||||||
const existing = this.byOpencodeId.get(ocSessionId);
|
let state = this.byOpencodeId.get(ocSessionId);
|
||||||
if (existing) {
|
if (state) {
|
||||||
existing.boocodeSessionId = sessionId;
|
state.boocodeSessionId = sessionId;
|
||||||
existing.worktreePath = opts.worktreePath;
|
state.worktreePath = opts.worktreePath;
|
||||||
} else {
|
} else {
|
||||||
this.byOpencodeId.set(ocSessionId, {
|
state = {
|
||||||
boocodeSessionId: sessionId,
|
boocodeSessionId: sessionId,
|
||||||
agentSessionId: ocSessionId,
|
agentSessionId: ocSessionId,
|
||||||
worktreePath: opts.worktreePath,
|
worktreePath: opts.worktreePath,
|
||||||
@@ -487,13 +489,22 @@ export class OpenCodeServerBackend implements AgentBackend {
|
|||||||
partTypeById: new Map(),
|
partTypeById: new Map(),
|
||||||
activeTurn: null,
|
activeTurn: null,
|
||||||
watchdog: null,
|
watchdog: null,
|
||||||
});
|
sseAbort: null,
|
||||||
|
};
|
||||||
|
this.byOpencodeId.set(ocSessionId, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start this session's own SSE loop, scoped to its worktree directory. Both
|
||||||
|
// fresh-create and resume reach here; idempotent, so a re-ensure (e.g. a
|
||||||
|
// second turn) won't spawn a duplicate loop.
|
||||||
|
this.startSessionEventLoop(state);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
agent: opts.agent,
|
agent: opts.agent,
|
||||||
backend: 'opencode_server',
|
backend: 'opencode_server',
|
||||||
|
chatId: opts.chatId,
|
||||||
|
worktreeId: opts.worktreeId,
|
||||||
agentSessionId: ocSessionId,
|
agentSessionId: ocSessionId,
|
||||||
serverPort: this.port,
|
serverPort: this.port,
|
||||||
};
|
};
|
||||||
@@ -516,12 +527,17 @@ export class OpenCodeServerBackend implements AgentBackend {
|
|||||||
partTypeById: new Map(),
|
partTypeById: new Map(),
|
||||||
activeTurn: null,
|
activeTurn: null,
|
||||||
watchdog: null,
|
watchdog: null,
|
||||||
|
sseAbort: null,
|
||||||
};
|
};
|
||||||
this.byOpencodeId.set(oc, state);
|
this.byOpencodeId.set(oc, state);
|
||||||
}
|
}
|
||||||
const session = state;
|
const session = state;
|
||||||
// Authoritative per-turn directory for SDK routing + reconcile.
|
// Authoritative per-turn directory for SDK routing + reconcile.
|
||||||
session.worktreePath = ctx.worktreePath;
|
session.worktreePath = ctx.worktreePath;
|
||||||
|
// Defensive: ensureSession normally starts the loop, but if prompt is reached
|
||||||
|
// with a freshly-created state (no loop yet), start it so the turn streams.
|
||||||
|
// Idempotent when ensureSession already started one.
|
||||||
|
this.startSessionEventLoop(session);
|
||||||
const client = this.client;
|
const client = this.client;
|
||||||
|
|
||||||
return await new Promise<TurnResult>((resolve) => {
|
return await new Promise<TurnResult>((resolve) => {
|
||||||
@@ -577,15 +593,21 @@ export class OpenCodeServerBackend implements AgentBackend {
|
|||||||
// ─── teardown ────────────────────────────────────────────────────────────────
|
// ─── teardown ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async closeSession(handle: AgentSessionHandle): Promise<void> {
|
async closeSession(handle: AgentSessionHandle): Promise<void> {
|
||||||
if (handle.agentSessionId) this.byOpencodeId.delete(handle.agentSessionId);
|
if (handle.agentSessionId) {
|
||||||
|
// Stop this session's SSE loop before dropping its demux entry.
|
||||||
|
this.byOpencodeId.get(handle.agentSessionId)?.sseAbort?.abort();
|
||||||
|
this.byOpencodeId.delete(handle.agentSessionId);
|
||||||
|
}
|
||||||
await this.sql`
|
await this.sql`
|
||||||
UPDATE agent_sessions SET status = 'closed'
|
UPDATE agent_sessions SET status = 'closed'
|
||||||
WHERE session_id = ${handle.sessionId} AND agent = ${handle.agent}
|
WHERE chat_id = ${handle.chatId} AND agent = ${handle.agent}
|
||||||
`.catch(() => {});
|
`.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispose(): Promise<void> {
|
async dispose(): Promise<void> {
|
||||||
this.up = false;
|
this.up = false;
|
||||||
|
// Abort every per-session SSE loop so none survive the teardown.
|
||||||
|
for (const st of this.byOpencodeId.values()) st.sseAbort?.abort();
|
||||||
const child = this.child;
|
const child = this.child;
|
||||||
this.child = null;
|
this.child = null;
|
||||||
this.client = null;
|
this.client = null;
|
||||||
@@ -602,6 +624,20 @@ export class OpenCodeServerBackend implements AgentBackend {
|
|||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Extract the opencode sessionID an event belongs to, across event shapes.
|
||||||
|
* Most carry `properties.sessionID`; `message.part.updated` nests it under
|
||||||
|
* `properties.part.sessionID`. Returns null when the event has no session
|
||||||
|
* (the per-session loop then leaves it to dispatchEvent, which drops it). */
|
||||||
|
function eventSessionId(ev: Event): string | null {
|
||||||
|
const props = (ev as { properties?: unknown }).properties;
|
||||||
|
if (!props || typeof props !== 'object') return null;
|
||||||
|
if (ev.type === 'message.part.updated') {
|
||||||
|
const part = (props as { part?: { sessionID?: string } }).part;
|
||||||
|
return part?.sessionID ?? null;
|
||||||
|
}
|
||||||
|
return (props as { sessionID?: string }).sessionID ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** BooCoder model string "provider/model" → opencode's structured {providerID, modelID}. */
|
/** BooCoder model string "provider/model" → opencode's structured {providerID, modelID}. */
|
||||||
function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined {
|
function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined {
|
||||||
if (!model || !model.trim()) return undefined;
|
if (!model || !model.trim()) return undefined;
|
||||||
|
|||||||
77
apps/coder/src/services/dcp-strip.ts
Normal file
77
apps/coder/src/services/dcp-strip.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Strip opencode-dcp plugin tags (`<dcp-message-id>mNNNN</dcp-message-id>`) that
|
||||||
|
* the @tarquinen/opencode-dcp plugin appends to assistant text and which
|
||||||
|
* otherwise render as literal text in the UI.
|
||||||
|
*
|
||||||
|
* Why a streaming stripper and not a per-chunk `.replace()`: opencode streams
|
||||||
|
* assistant text token-by-token, so the tag arrives SPLIT across many SSE deltas
|
||||||
|
* (`<dcp`, `-message`, `-id>`, `m0019`, `</dcp`, …). A per-chunk regex never sees
|
||||||
|
* a complete tag in any single fragment, so the fragments pass through and the
|
||||||
|
* dispatcher reassembles the full tag in the persisted/displayed content. The
|
||||||
|
* stripper below buffers across chunks: it emits everything that cannot be part
|
||||||
|
* of a forming tag and holds back only a trailing partial-tag prefix until the
|
||||||
|
* next chunk resolves it — without holding back legitimate `<…>` content.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DCP_TAG_RE = /<dcp-message-id>[^<]*<\/dcp-message-id>/g;
|
||||||
|
const OPEN = '<dcp-message-id>';
|
||||||
|
const CLOSE = '</dcp-message-id>';
|
||||||
|
|
||||||
|
/** One-shot strip of COMPLETE tags. Safe for non-streaming / final content. */
|
||||||
|
export function stripDcpTags(s: string): string {
|
||||||
|
return s.replace(DCP_TAG_RE, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Could `tail` (a substring starting at a `<`) still grow into a complete dcp
|
||||||
|
* tag on a future chunk? If so the caller must hold it back rather than emit it.
|
||||||
|
* Returns false for unrelated `<` content (`<div>`, `<T>`, …) so those stream
|
||||||
|
* normally.
|
||||||
|
*/
|
||||||
|
function isPartialDcp(tail: string): boolean {
|
||||||
|
// A prefix of the opening marker: '<', '<d', …, '<dcp-message-id'.
|
||||||
|
if (OPEN.startsWith(tail)) return true;
|
||||||
|
// Opening marker fully seen — content (and maybe a forming close) still streaming.
|
||||||
|
if (tail.startsWith(OPEN)) {
|
||||||
|
const rest = tail.slice(OPEN.length);
|
||||||
|
const lt = rest.indexOf('<');
|
||||||
|
if (lt === -1) return true; // still inside the [^<]* content run
|
||||||
|
return CLOSE.startsWith(rest.slice(lt)); // a partial close marker forming
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DcpStreamStripper {
|
||||||
|
/** Feed one text chunk; returns the portion safe to emit now (may be ''). */
|
||||||
|
push(chunk: string): string;
|
||||||
|
/** Stream end: returns whatever was held back, with complete tags stripped. */
|
||||||
|
flush(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stateful, cross-chunk-safe dcp stripper. One instance per turn. */
|
||||||
|
export function makeDcpStreamStripper(): DcpStreamStripper {
|
||||||
|
let buf = '';
|
||||||
|
return {
|
||||||
|
push(chunk: string): string {
|
||||||
|
buf += chunk;
|
||||||
|
buf = buf.replace(DCP_TAG_RE, ''); // drop any now-complete tags
|
||||||
|
// Find the earliest `<` whose suffix is a forming dcp tag; hold from there,
|
||||||
|
// emit everything before it (real text, including unrelated `<…>`).
|
||||||
|
for (let i = buf.indexOf('<'); i !== -1; i = buf.indexOf('<', i + 1)) {
|
||||||
|
if (isPartialDcp(buf.slice(i))) {
|
||||||
|
const emit = buf.slice(0, i);
|
||||||
|
buf = buf.slice(i);
|
||||||
|
return emit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const emit = buf;
|
||||||
|
buf = '';
|
||||||
|
return emit;
|
||||||
|
},
|
||||||
|
flush(): string {
|
||||||
|
const out = stripDcpTags(buf);
|
||||||
|
buf = '';
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { Broker } from '@boocode/server/broker';
|
|||||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
|
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
|
||||||
|
import { makeDcpStreamStripper } from './dcp-strip.js';
|
||||||
import { dispatchViaAcp } from './acp-dispatch.js';
|
import { dispatchViaAcp } from './acp-dispatch.js';
|
||||||
import { getResolvedRegistry } from './provider-config-registry.js';
|
import { getResolvedRegistry } from './provider-config-registry.js';
|
||||||
import { dispatchViaPty } from './pty-dispatch.js';
|
import { dispatchViaPty } from './pty-dispatch.js';
|
||||||
@@ -77,8 +78,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
mode_id: string | null;
|
mode_id: string | null;
|
||||||
thinking_option_id: string | null;
|
thinking_option_id: string | null;
|
||||||
session_id: string | null;
|
session_id: string | null;
|
||||||
|
chat_id: string | null;
|
||||||
}[]>`
|
}[]>`
|
||||||
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id
|
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id, chat_id
|
||||||
FROM tasks
|
FROM tasks
|
||||||
WHERE state = 'pending'
|
WHERE state = 'pending'
|
||||||
ORDER BY created_at
|
ORDER BY created_at
|
||||||
@@ -109,6 +111,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
mode_id: string | null;
|
mode_id: string | null;
|
||||||
thinking_option_id: string | null;
|
thinking_option_id: string | null;
|
||||||
session_id: string | null;
|
session_id: string | null;
|
||||||
|
chat_id: string | null;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const taskId = task.id;
|
const taskId = task.id;
|
||||||
|
|
||||||
@@ -510,6 +513,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
mode_id: string | null;
|
mode_id: string | null;
|
||||||
thinking_option_id: string | null;
|
thinking_option_id: string | null;
|
||||||
session_id: string | null;
|
session_id: string | null;
|
||||||
|
chat_id: string | null;
|
||||||
},
|
},
|
||||||
installPath: string | null,
|
installPath: string | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -542,10 +546,18 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Resolve session + chat (mirrors runExternalAgent).
|
// Resolve session + chat. P1.5-b: the chat (tab) is the context key, so the
|
||||||
|
// chat_id MUST be non-null and stable before ensureSession. The coder message
|
||||||
|
// route + skills route stamp task.chat_id with the frontend tab's chat — use
|
||||||
|
// it directly. Session-less creators (arena, MCP, new_task, generic
|
||||||
|
// /api/tasks) leave it null; fall back to resolving/creating a real chat so
|
||||||
|
// ensureSession never receives a degenerate (null, agent) key.
|
||||||
let sessionId: string;
|
let sessionId: string;
|
||||||
let chatId: string;
|
let chatId: string;
|
||||||
if (task.session_id) {
|
if (task.chat_id && task.session_id) {
|
||||||
|
sessionId = task.session_id;
|
||||||
|
chatId = task.chat_id;
|
||||||
|
} else if (task.session_id) {
|
||||||
sessionId = task.session_id;
|
sessionId = task.session_id;
|
||||||
const chats = await sql<{ id: string }[]>`
|
const chats = await sql<{ id: string }[]>`
|
||||||
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
|
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
|
||||||
@@ -586,7 +598,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
|
|
||||||
// Persistent, session-keyed worktree (shared across turns; NOT torn down
|
// Persistent, session-keyed worktree (shared across turns; NOT torn down
|
||||||
// per turn — Phase 3 reaps it). Captures base_commit for a stable diff.
|
// per turn — Phase 3 reaps it). Captures base_commit for a stable diff.
|
||||||
const { worktreePath, baseCommit } = await ensureSessionWorktree(sql, projectPath, sessionId, {
|
const { worktreeId, worktreePath, baseCommit } = await ensureSessionWorktree(sql, projectPath, sessionId, {
|
||||||
signal: ac.signal,
|
signal: ac.signal,
|
||||||
});
|
});
|
||||||
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready');
|
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready');
|
||||||
@@ -620,21 +632,30 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
const textChunks: string[] = [];
|
const textChunks: string[] = [];
|
||||||
const reasoningChunks: string[] = [];
|
const reasoningChunks: string[] = [];
|
||||||
const toolSnaps = new Map<string, AcpToolSnapshot>();
|
const toolSnaps = new Map<string, AcpToolSnapshot>();
|
||||||
|
// opencode's dcp plugin appends <dcp-message-id>…</dcp-message-id> to the
|
||||||
|
// text, streamed split across deltas — a per-chunk regex misses it (see
|
||||||
|
// dcp-strip.ts). Buffer text through a cross-chunk stripper so neither the
|
||||||
|
// live `delta` frames nor the persisted content ever carry the tag.
|
||||||
|
const dcp = makeDcpStreamStripper();
|
||||||
|
|
||||||
// Map transport-agnostic AgentEvents → the SAME WS frames the ACP path emits.
|
// Map transport-agnostic AgentEvents → the SAME WS frames the ACP path emits.
|
||||||
// This boundary is where message_id/chat_id get attached (the backend never
|
// This boundary is where message_id/chat_id get attached (the backend never
|
||||||
// owns them).
|
// owns them).
|
||||||
const onEvent = (e: AgentEvent): void => {
|
const onEvent = (e: AgentEvent): void => {
|
||||||
switch (e.type) {
|
switch (e.type) {
|
||||||
case 'text':
|
case 'text': {
|
||||||
textChunks.push(e.text);
|
const safe = dcp.push(e.text);
|
||||||
|
if (safe) {
|
||||||
|
textChunks.push(safe);
|
||||||
broker.publishFrame(sessionId, {
|
broker.publishFrame(sessionId, {
|
||||||
type: 'delta',
|
type: 'delta',
|
||||||
message_id: assistantId,
|
message_id: assistantId,
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
content: e.text,
|
content: safe,
|
||||||
} as WsFrame);
|
} as WsFrame);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'reasoning':
|
case 'reasoning':
|
||||||
reasoningChunks.push(e.text);
|
reasoningChunks.push(e.text);
|
||||||
broker.publishFrame(sessionId, {
|
broker.publishFrame(sessionId, {
|
||||||
@@ -670,7 +691,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
const handle = await backend.ensureSession(sessionId, {
|
const handle = await backend.ensureSession(sessionId, {
|
||||||
agent,
|
agent,
|
||||||
model,
|
model,
|
||||||
|
chatId,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
|
worktreeId,
|
||||||
projectId: task.project_id,
|
projectId: task.project_id,
|
||||||
});
|
});
|
||||||
const result = await backend.prompt(handle, task.input, {
|
const result = await backend.prompt(handle, task.input, {
|
||||||
@@ -680,6 +703,18 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
onEvent,
|
onEvent,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Flush any text held back mid-tag at stream end (complete tags stripped).
|
||||||
|
const dcpTail = dcp.flush();
|
||||||
|
if (dcpTail) {
|
||||||
|
textChunks.push(dcpTail);
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'delta',
|
||||||
|
message_id: assistantId,
|
||||||
|
chat_id: chatId,
|
||||||
|
content: dcpTail,
|
||||||
|
} as WsFrame);
|
||||||
|
}
|
||||||
|
|
||||||
const assistantContent = textChunks.join('').slice(0, 50_000);
|
const assistantContent = textChunks.join('').slice(0, 50_000);
|
||||||
const reasoningText = reasoningChunks.join('').slice(0, 200_000);
|
const reasoningText = reasoningChunks.join('').slice(0, 200_000);
|
||||||
const outputSummary = (result.ok ? textChunks.join('') : result.error ?? 'opencode turn failed').slice(0, 500);
|
const outputSummary = (result.ok ? textChunks.join('') : result.error ?? 'opencode turn failed').slice(0, 500);
|
||||||
|
|||||||
@@ -119,16 +119,18 @@ export async function cleanupWorktree(
|
|||||||
// ─── v2.6: session-keyed persistent worktree ────────────────────────────────
|
// ─── v2.6: session-keyed persistent worktree ────────────────────────────────
|
||||||
|
|
||||||
export interface SessionWorktree {
|
export interface SessionWorktree {
|
||||||
|
/** P1.5-b: the `worktrees.id` — stored on agent_sessions informationally. */
|
||||||
|
worktreeId: string;
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
baseCommit: string | null;
|
baseCommit: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v2.6: create-or-reuse ONE worktree per BooCode session (shared across all
|
* v2.6 / P1.5-b: create-or-reuse ONE worktree per BooCode session (shared across
|
||||||
* agents/turns in the session), recorded in `session_worktrees`. Unlike the
|
* all tabs/agents in the session), recorded in `worktrees` (was the superseded
|
||||||
* per-task `createWorktree`, this persists — it is NOT torn down per turn
|
* `session_worktrees`). Persists — NOT torn down per turn (cleanup is Phase 3) —
|
||||||
* (cleanup is Phase 3). Captures the project's current HEAD as `base_commit`
|
* and now survives session delete (`worktrees.session_id` is ON DELETE SET NULL).
|
||||||
* so the accumulating diff has a stable baseline across turns.
|
* Captures the project's current HEAD as `base_commit` for a stable diff baseline.
|
||||||
*
|
*
|
||||||
* Distinct path namespace (`session-<id>` branch, `/sess-<id>` dir) so it never
|
* Distinct path namespace (`session-<id>` branch, `/sess-<id>` dir) so it never
|
||||||
* collides with the per-task worktrees that arena/new_task/MCP still use.
|
* collides with the per-task worktrees that arena/new_task/MCP still use.
|
||||||
@@ -139,11 +141,13 @@ export async function ensureSessionWorktree(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
opts?: { signal?: AbortSignal },
|
opts?: { signal?: AbortSignal },
|
||||||
): Promise<SessionWorktree> {
|
): Promise<SessionWorktree> {
|
||||||
const [existing] = await sql<{ worktree_path: string; base_commit: string | null }[]>`
|
const [existing] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
|
||||||
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId}
|
SELECT id, path, base_commit FROM worktrees
|
||||||
|
WHERE session_id = ${sessionId} AND status = 'active'
|
||||||
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return { worktreePath: existing.worktree_path, baseCommit: existing.base_commit };
|
return { worktreeId: existing.id, worktreePath: existing.path, baseCommit: existing.base_commit };
|
||||||
}
|
}
|
||||||
|
|
||||||
const worktreePath = `${WORKTREE_BASE}/sess-${sessionId}`;
|
const worktreePath = `${WORKTREE_BASE}/sess-${sessionId}`;
|
||||||
@@ -167,21 +171,191 @@ export async function ensureSessionWorktree(
|
|||||||
throw new Error(`Failed to create session worktree: ${result.stderr.trim() || result.stdout.trim()}`);
|
throw new Error(`Failed to create session worktree: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist. ON CONFLICT keeps the first writer's row if two turns race the create.
|
// Insert-or-get: WHERE NOT EXISTS keeps the first writer's row if two turns race
|
||||||
await sql`
|
// the create (the partial unique on active path also backstops it).
|
||||||
INSERT INTO session_worktrees (session_id, worktree_path, base_commit)
|
const [inserted] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
|
||||||
VALUES (${sessionId}, ${worktreePath}, ${baseCommit})
|
INSERT INTO worktrees (session_id, path, branch, base_commit, status)
|
||||||
ON CONFLICT (session_id) DO NOTHING
|
SELECT ${sessionId}, ${worktreePath}, ${branchName}, ${baseCommit}, 'active'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM worktrees WHERE session_id = ${sessionId} AND status = 'active'
|
||||||
|
)
|
||||||
|
RETURNING id, path, base_commit
|
||||||
`;
|
`;
|
||||||
const [row] = await sql<{ worktree_path: string; base_commit: string | null }[]>`
|
if (inserted) {
|
||||||
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId}
|
return { worktreeId: inserted.id, worktreePath: inserted.path, baseCommit: inserted.base_commit };
|
||||||
|
}
|
||||||
|
// Lost the race — another turn inserted first; read its row.
|
||||||
|
const [row] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
|
||||||
|
SELECT id, path, base_commit FROM worktrees
|
||||||
|
WHERE session_id = ${sessionId} AND status = 'active'
|
||||||
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
return {
|
return {
|
||||||
worktreePath: row?.worktree_path ?? worktreePath,
|
worktreeId: row!.id,
|
||||||
|
worktreePath: row?.path ?? worktreePath,
|
||||||
baseCommit: row?.base_commit ?? baseCommit,
|
baseCommit: row?.base_commit ?? baseCommit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Session-delete work-loss guard ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Risk report for a single worktree, returned by checkWorktreeWorkAtRisk.
|
||||||
|
* `atRisk` is the gate the server reads before allowing a session delete.
|
||||||
|
* A git error never silently passes — it forces `atRisk` true and surfaces
|
||||||
|
* the message in `error` (fail-closed).
|
||||||
|
*/
|
||||||
|
export interface RiskReport {
|
||||||
|
worktreePath: string;
|
||||||
|
branch: string;
|
||||||
|
dirty: boolean; // uncommitted working-tree changes (incl. untracked)
|
||||||
|
unpushed: number; // commits ahead of upstream, or -1 if no upstream is set
|
||||||
|
unmerged: number; // commits on this branch not in the project default branch
|
||||||
|
atRisk: boolean; // dirty || unmerged > 0 || (upstream && unpushed > 0) || git error
|
||||||
|
error?: string; // populated on a git failure; presence forces atRisk
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the project's default branch as a git-usable ref (e.g. "origin/main").
|
||||||
|
*
|
||||||
|
* `refs/remotes/origin/HEAD` lives in the repo's COMMON git dir and is shared
|
||||||
|
* across every linked worktree, so reading it from the session worktree returns
|
||||||
|
* the REMOTE's default branch — never this worktree's own `session-<id>` branch
|
||||||
|
* (that would be `symbolic-ref HEAD`, a different ref). Falls back to probing
|
||||||
|
* common defaults by verified existence when origin/HEAD isn't set (e.g. a repo
|
||||||
|
* that never ran `git remote set-head`). Returns null if none resolve, in which
|
||||||
|
* case the unmerged check is skipped (dirty + unpushed still protect the work).
|
||||||
|
*/
|
||||||
|
async function detectDefaultBranchRef(
|
||||||
|
worktreePath: string,
|
||||||
|
opts?: { signal?: AbortSignal },
|
||||||
|
): Promise<string | null> {
|
||||||
|
const head = await hostExec(
|
||||||
|
`git -C ${shellEscape(worktreePath)} symbolic-ref --short refs/remotes/origin/HEAD`,
|
||||||
|
{ signal: opts?.signal, timeoutMs: 10_000 },
|
||||||
|
);
|
||||||
|
if (head.exitCode === 0) {
|
||||||
|
const ref = head.stdout.trim(); // e.g. "origin/main"
|
||||||
|
if (ref) {
|
||||||
|
const verify = await hostExec(
|
||||||
|
`git -C ${shellEscape(worktreePath)} rev-parse --verify --quiet ${shellEscape(ref + '^{commit}')}`,
|
||||||
|
{ signal: opts?.signal, timeoutMs: 10_000 },
|
||||||
|
);
|
||||||
|
if (verify.exitCode === 0 && verify.stdout.trim()) return ref;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// origin/HEAD unset or unresolvable — probe common defaults. Prefer the
|
||||||
|
// remote-tracking ref (always resolvable in a fresh worktree) over the local
|
||||||
|
// head, which may not exist if the default branch lives only in the main tree.
|
||||||
|
for (const cand of ['origin/main', 'origin/master', 'main', 'master']) {
|
||||||
|
const verify = await hostExec(
|
||||||
|
`git -C ${shellEscape(worktreePath)} rev-parse --verify --quiet ${shellEscape(cand + '^{commit}')}`,
|
||||||
|
{ signal: opts?.signal, timeoutMs: 10_000 },
|
||||||
|
);
|
||||||
|
if (verify.exitCode === 0 && verify.stdout.trim()) return cand;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect a worktree for work that would be lost if its session were deleted.
|
||||||
|
* Three checks, all via the audited hostExec + shellEscape path (every
|
||||||
|
* interpolated value — paths, refs — is single-quote-escaped; no bare
|
||||||
|
* interpolation). Any unexpected git failure is treated as at-risk, never a
|
||||||
|
* silent pass.
|
||||||
|
*/
|
||||||
|
export async function checkWorktreeWorkAtRisk(
|
||||||
|
worktreePath: string,
|
||||||
|
opts?: { signal?: AbortSignal },
|
||||||
|
): Promise<RiskReport> {
|
||||||
|
// Branch name — also doubles as the "is this still a git worktree?" probe.
|
||||||
|
const br = await hostExec(
|
||||||
|
`git -C ${shellEscape(worktreePath)} rev-parse --abbrev-ref HEAD`,
|
||||||
|
{ signal: opts?.signal, timeoutMs: 10_000 },
|
||||||
|
);
|
||||||
|
if (br.exitCode !== 0) {
|
||||||
|
return {
|
||||||
|
worktreePath,
|
||||||
|
branch: '',
|
||||||
|
dirty: false,
|
||||||
|
unpushed: 0,
|
||||||
|
unmerged: 0,
|
||||||
|
atRisk: true,
|
||||||
|
error: `git rev-parse failed: ${br.stderr.trim() || 'not a git worktree'}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const branch = br.stdout.trim();
|
||||||
|
|
||||||
|
// (a) Uncommitted (dirty working tree, including untracked files).
|
||||||
|
const st = await hostExec(
|
||||||
|
`git -C ${shellEscape(worktreePath)} status --porcelain`,
|
||||||
|
{ signal: opts?.signal, timeoutMs: 15_000 },
|
||||||
|
);
|
||||||
|
if (st.exitCode !== 0) {
|
||||||
|
return {
|
||||||
|
worktreePath,
|
||||||
|
branch,
|
||||||
|
dirty: false,
|
||||||
|
unpushed: 0,
|
||||||
|
unmerged: 0,
|
||||||
|
atRisk: true,
|
||||||
|
error: `git status failed: ${st.stderr.trim()}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const dirty = st.stdout.trim().length > 0;
|
||||||
|
|
||||||
|
// (b) Unpushed commits. No upstream configured => work exists only locally;
|
||||||
|
// treat as unpushed-by-definition (-1) rather than an error.
|
||||||
|
const up = await hostExec(
|
||||||
|
`git -C ${shellEscape(worktreePath)} rev-list --count ${shellEscape('@{u}..HEAD')}`,
|
||||||
|
{ signal: opts?.signal, timeoutMs: 15_000 },
|
||||||
|
);
|
||||||
|
const unpushed = up.exitCode === 0 ? (parseInt(up.stdout.trim() || '0', 10) || 0) : -1;
|
||||||
|
|
||||||
|
// (c) Unmerged commits — on this branch but not in the project default branch.
|
||||||
|
const defaultRef = await detectDefaultBranchRef(worktreePath, opts);
|
||||||
|
let unmerged = 0;
|
||||||
|
if (defaultRef) {
|
||||||
|
const rl = await hostExec(
|
||||||
|
`git -C ${shellEscape(worktreePath)} rev-list --count ${shellEscape(defaultRef + '..HEAD')}`,
|
||||||
|
{ signal: opts?.signal, timeoutMs: 15_000 },
|
||||||
|
);
|
||||||
|
if (rl.exitCode === 0) unmerged = parseInt(rl.stdout.trim() || '0', 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// unpushed only contributes when an upstream actually exists. Session branches
|
||||||
|
// (session-<id>) never have one (unpushed === -1), and any real local-only work
|
||||||
|
// there already surfaces as unmerged > 0 — so the no-upstream case adds no
|
||||||
|
// protection, only friction (it flagged every pristine worktree-backed session).
|
||||||
|
// The unpushed > 0 arm stays forward-compatible with P1.5 pushable branches.
|
||||||
|
const hasUpstream = unpushed !== -1;
|
||||||
|
const atRisk = dirty || unmerged > 0 || (hasUpstream && unpushed > 0);
|
||||||
|
return { worktreePath, branch, dirty, unpushed, unmerged, atRisk };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stash a worktree's uncommitted changes (including untracked, via -u) so the
|
||||||
|
* working tree is clean. Stash entries live in the repo's common git dir, so
|
||||||
|
* they survive worktree-dir removal — this is the recoverable, safe-by-default
|
||||||
|
* escape. Note it only clears the *dirty* risk; unpushed/unmerged commits
|
||||||
|
* remain on the branch, so a re-attempted delete may still block on those.
|
||||||
|
*/
|
||||||
|
export async function stashWorktree(
|
||||||
|
worktreePath: string,
|
||||||
|
opts?: { signal?: AbortSignal },
|
||||||
|
): Promise<{ stashed: boolean; error?: string }> {
|
||||||
|
const r = await hostExec(
|
||||||
|
`git -C ${shellEscape(worktreePath)} stash push -u -m ${shellEscape('boocode: pre-delete stash')}`,
|
||||||
|
{ signal: opts?.signal, timeoutMs: 30_000 },
|
||||||
|
);
|
||||||
|
if (r.exitCode !== 0) {
|
||||||
|
return { stashed: false, error: r.stderr.trim() || r.stdout.trim() };
|
||||||
|
}
|
||||||
|
// "No local changes to save" => exit 0, nothing stashed — not an error.
|
||||||
|
const stashed = !/no local changes to save/i.test(r.stdout);
|
||||||
|
return { stashed };
|
||||||
|
}
|
||||||
|
|
||||||
/** Minimal shell escape for paths (single-quote wrapping). */
|
/** Minimal shell escape for paths (single-quote wrapping). */
|
||||||
function shellEscape(s: string): string {
|
function shellEscape(s: string): string {
|
||||||
// Replace single quotes with escaped version, wrap in single quotes
|
// Replace single quotes with escaped version, wrap in single quotes
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import type { Broker } from '../services/broker.js';
|
import type { Broker } from '../services/broker.js';
|
||||||
import type { Session } from '../types/api.js';
|
import type { Session, WorktreeRiskReport } from '../types/api.js';
|
||||||
import { getSetting } from './settings.js';
|
import { getSetting } from './settings.js';
|
||||||
|
|
||||||
const CreateBody = z.object({
|
const CreateBody = z.object({
|
||||||
@@ -426,10 +426,55 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
app.delete<{ Params: { id: string } }>(
|
app.delete<{ Params: { id: string }; Querystring: { force?: string } }>(
|
||||||
'/api/sessions/:id',
|
'/api/sessions/:id',
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
|
const force = req.query.force === 'true' || req.query.force === '1';
|
||||||
|
|
||||||
|
// Session-delete work-loss guard. The check MUST run BEFORE the DELETE:
|
||||||
|
// worktrees.session_id is ON DELETE SET NULL (P1.5-b), so once the session
|
||||||
|
// is gone the worktree rows no longer point back to it — read them while
|
||||||
|
// the link still exists.
|
||||||
|
//
|
||||||
|
// Optimization: read worktrees (P1.5-b — was session_worktrees) from our
|
||||||
|
// own (shared) DB first. No row => chat-only session => nothing on disk =>
|
||||||
|
// delete immediately, zero round-trip. Only worktree-backed sessions pay
|
||||||
|
// the host git check.
|
||||||
|
if (!force) {
|
||||||
|
const worktrees = await sql<{ path: string }[]>`
|
||||||
|
SELECT path FROM worktrees WHERE session_id = ${id}
|
||||||
|
`;
|
||||||
|
if (worktrees.length > 0) {
|
||||||
|
// Worktree dirs live on the host; only BooCoder can run git on them.
|
||||||
|
const origin = process.env.BOOCODER_URL ?? 'http://boocoder:3000';
|
||||||
|
let reports: WorktreeRiskReport[];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${origin}/api/sessions/${id}/worktree-risk`);
|
||||||
|
if (!res.ok) {
|
||||||
|
// Fail-closed: can't verify => don't risk silent loss. Force escapes.
|
||||||
|
reply.code(409);
|
||||||
|
return {
|
||||||
|
error: 'could not verify worktree safety (BooCoder check failed). Use force to delete anyway.',
|
||||||
|
reports: [] as WorktreeRiskReport[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
reports = ((await res.json()) as { reports?: WorktreeRiskReport[] }).reports ?? [];
|
||||||
|
} catch {
|
||||||
|
// Fail-closed: BooCoder unreachable. Force bypasses this path entirely.
|
||||||
|
reply.code(409);
|
||||||
|
return {
|
||||||
|
error: 'BooCoder unreachable; cannot verify worktree safety. Use force to delete anyway.',
|
||||||
|
reports: [] as WorktreeRiskReport[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (reports.some((r) => r.atRisk)) {
|
||||||
|
reply.code(409);
|
||||||
|
return { error: 'This session has work at risk in its worktree.', reports };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const deleted = await sql<{ project_id: string }[]>`
|
const deleted = await sql<{ project_id: string }[]>`
|
||||||
DELETE FROM sessions WHERE id = ${id} RETURNING project_id
|
DELETE FROM sessions WHERE id = ${id} RETURNING project_id
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -25,6 +25,20 @@ export interface AvailableProject {
|
|||||||
|
|
||||||
export type SessionStatus = 'open' | 'archived';
|
export type SessionStatus = 'open' | 'archived';
|
||||||
|
|
||||||
|
// Session-delete work-loss guard. Returned (as `reports`) in the 409 body when
|
||||||
|
// a delete is blocked because the session's worktree holds work at risk. The
|
||||||
|
// shape is produced by BooCoder's checkWorktreeWorkAtRisk and passed through
|
||||||
|
// verbatim; mirrored byte-for-byte in apps/web/src/api/types.ts for the dialog.
|
||||||
|
export interface WorktreeRiskReport {
|
||||||
|
worktreePath: string;
|
||||||
|
branch: string;
|
||||||
|
dirty: boolean;
|
||||||
|
unpushed: number; // commits ahead of upstream, or -1 if no upstream
|
||||||
|
unmerged: number; // commits not in the project default branch
|
||||||
|
atRisk: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
project_id: string;
|
project_id: string;
|
||||||
|
|||||||
@@ -151,8 +151,17 @@ export const api = {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
remove: (id: string) =>
|
// force=true bypasses the server-side worktree work-loss guard. A blocked
|
||||||
request<void>(`/api/sessions/${id}`, { method: 'DELETE' }),
|
// delete throws ApiError(409) whose body carries { error, reports }.
|
||||||
|
remove: (id: string, force = false) =>
|
||||||
|
request<void>(`/api/sessions/${id}${force ? '?force=true' : ''}`, { method: 'DELETE' }),
|
||||||
|
// Stash the session's worktree (uncommitted changes) on the host, via the
|
||||||
|
// BooCoder proxy. Recoverable escape from the work-at-risk dialog.
|
||||||
|
worktreeStash: (id: string) =>
|
||||||
|
request<{ results: { worktreePath: string; stashed: boolean; error?: string }[] }>(
|
||||||
|
`/api/coder/sessions/${id}/worktree-stash`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
),
|
||||||
archive: (id: string) =>
|
archive: (id: string) =>
|
||||||
request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }),
|
request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }),
|
||||||
unarchive: (id: string) =>
|
unarchive: (id: string) =>
|
||||||
|
|||||||
@@ -34,6 +34,19 @@ export interface AvailableProject {
|
|||||||
|
|
||||||
export type SessionStatus = 'open' | 'archived';
|
export type SessionStatus = 'open' | 'archived';
|
||||||
|
|
||||||
|
// Session-delete work-loss guard. Mirror of WorktreeRiskReport in
|
||||||
|
// apps/server/src/types/api.ts — edit both copies together. Arrives as the
|
||||||
|
// `reports` field of the 409 body when a delete is blocked.
|
||||||
|
export interface WorktreeRiskReport {
|
||||||
|
worktreePath: string;
|
||||||
|
branch: string;
|
||||||
|
dirty: boolean;
|
||||||
|
unpushed: number; // commits ahead of upstream, or -1 if no upstream
|
||||||
|
unmerged: number; // commits not in the project default branch
|
||||||
|
atRisk: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
project_id: string;
|
project_id: string;
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ import {
|
|||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { AddProjectModal } from './AddProjectModal';
|
import { AddProjectModal } from './AddProjectModal';
|
||||||
import { api } from '@/api/client';
|
import { api, ApiError } from '@/api/client';
|
||||||
import { useSidebar } from '@/hooks/useSidebar';
|
import { useSidebar } from '@/hooks/useSidebar';
|
||||||
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
|
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
|
||||||
import type { SidebarProject } from '@/api/types';
|
import type { SidebarProject, WorktreeRiskReport } from '@/api/types';
|
||||||
import { giteaUrlFor } from '@/lib/projectUrls';
|
import { giteaUrlFor } from '@/lib/projectUrls';
|
||||||
import { isCoderSessionName } from '@/lib/coder-session';
|
import { isCoderSessionName } from '@/lib/coder-session';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -110,6 +110,16 @@ export function ProjectSidebar() {
|
|||||||
const [renamingProject, setRenamingProject] = useState<string | null>(null);
|
const [renamingProject, setRenamingProject] = useState<string | null>(null);
|
||||||
const [renameProjectValue, setRenameProjectValue] = useState('');
|
const [renameProjectValue, setRenameProjectValue] = useState('');
|
||||||
const [archiveProjectConfirm, setArchiveProjectConfirm] = useState<{ id: string; name: string } | null>(null);
|
const [archiveProjectConfirm, setArchiveProjectConfirm] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
// Work-at-risk dialog: shown when a delete is blocked (409) because the
|
||||||
|
// session's worktree holds uncommitted/unpushed/unmerged work.
|
||||||
|
const [riskState, setRiskState] = useState<{
|
||||||
|
sessionId: string;
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
message: string;
|
||||||
|
reports: WorktreeRiskReport[];
|
||||||
|
} | null>(null);
|
||||||
|
const [riskBusy, setRiskBusy] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const lastToastedError = useRef<string | null>(null);
|
const lastToastedError = useRef<string | null>(null);
|
||||||
@@ -174,16 +184,81 @@ export function ProjectSidebar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteSession(sessionId: string, projectId: string) {
|
async function handleDeleteSession(
|
||||||
|
sessionId: string,
|
||||||
|
projectId: string,
|
||||||
|
name: string,
|
||||||
|
force = false,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
await api.sessions.remove(sessionId);
|
await api.sessions.remove(sessionId, force);
|
||||||
// Server publishes session_deleted via WS; useUserEvents delivers it.
|
// Server publishes session_deleted via WS; useUserEvents delivers it.
|
||||||
|
setRiskState(null);
|
||||||
if (activeSession === sessionId) navigate(`/project/${projectId}`);
|
if (activeSession === sessionId) navigate(`/project/${projectId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 409 => the server's work-loss guard blocked the delete. Open the
|
||||||
|
// work-at-risk dialog with the per-worktree reports instead of toasting.
|
||||||
|
if (
|
||||||
|
err instanceof ApiError &&
|
||||||
|
err.status === 409 &&
|
||||||
|
err.body && typeof err.body === 'object' && 'reports' in err.body
|
||||||
|
) {
|
||||||
|
const body = err.body as { error?: string; reports?: WorktreeRiskReport[] };
|
||||||
|
setRiskState({
|
||||||
|
sessionId,
|
||||||
|
projectId,
|
||||||
|
name,
|
||||||
|
message: body.error ?? 'This session has work at risk.',
|
||||||
|
reports: body.reports ?? [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.error(err instanceof Error ? err.message : 'failed to delete session');
|
toast.error(err instanceof Error ? err.message : 'failed to delete session');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stash the worktree's uncommitted changes (recoverable), then re-attempt the
|
||||||
|
// delete. If unpushed/unmerged commits remain, the retry 409s again and the
|
||||||
|
// dialog re-renders with the narrowed risk.
|
||||||
|
async function handleStashAndRetry() {
|
||||||
|
if (!riskState || riskBusy) return;
|
||||||
|
setRiskBusy(true);
|
||||||
|
try {
|
||||||
|
const { results } = await api.sessions.worktreeStash(riskState.sessionId);
|
||||||
|
const failed = results.find((r) => r.error);
|
||||||
|
if (failed) {
|
||||||
|
toast.error(`stash failed: ${failed.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleDeleteSession(riskState.sessionId, riskState.projectId, riskState.name, false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'stash failed');
|
||||||
|
} finally {
|
||||||
|
setRiskBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit, destructive override — deletes despite work at risk.
|
||||||
|
async function handleForceDelete() {
|
||||||
|
if (!riskState || riskBusy) return;
|
||||||
|
setRiskBusy(true);
|
||||||
|
try {
|
||||||
|
await handleDeleteSession(riskState.sessionId, riskState.projectId, riskState.name, true);
|
||||||
|
} finally {
|
||||||
|
setRiskBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route the user to commit it themselves — never auto-commit. Opens the
|
||||||
|
// session workspace where they can use a terminal or agent pane.
|
||||||
|
function handleGoCommit() {
|
||||||
|
if (!riskState) return;
|
||||||
|
const sessionId = riskState.sessionId;
|
||||||
|
setRiskState(null);
|
||||||
|
navigate(`/session/${sessionId}`);
|
||||||
|
toast.info('Open a terminal or agent in this session, commit and push your work, then delete again.');
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRenameSession(sessionId: string) {
|
async function handleRenameSession(sessionId: string) {
|
||||||
const trimmed = renameValue.trim();
|
const trimmed = renameValue.trim();
|
||||||
setRenamingSession(null);
|
setRenamingSession(null);
|
||||||
@@ -216,6 +291,20 @@ export function ProjectSidebar() {
|
|||||||
)
|
)
|
||||||
: 'w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen';
|
: 'w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen';
|
||||||
|
|
||||||
|
// Work-at-risk dialog framing. The server returns 409 in two distinct
|
||||||
|
// situations: (1) work genuinely at risk (reports has ≥1 atRisk entry), or
|
||||||
|
// (2) it couldn't verify (BooCoder down/errored → reports is empty). These
|
||||||
|
// are different user stories — "your work is in danger" vs "the checker is
|
||||||
|
// offline" — so the dialog must not show one generic message for both.
|
||||||
|
const atRiskReports = riskState?.reports.filter((r) => r.atRisk) ?? [];
|
||||||
|
const verifyFailed = riskState !== null && atRiskReports.length === 0;
|
||||||
|
const anyDirty = atRiskReports.some((r) => r.dirty);
|
||||||
|
// Commit-based risk (unpushed/unmerged) that stash can NOT clear. When this is
|
||||||
|
// all that remains (e.g. after a stash cleared the dirty changes), the dialog
|
||||||
|
// explains why it re-blocked and hides the Stash button so it doesn't look
|
||||||
|
// like stash "didn't work".
|
||||||
|
const anyCommits = atRiskReports.some((r) => r.unpushed !== 0 || r.unmerged > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={asideCls}>
|
<aside className={asideCls}>
|
||||||
<div className="px-4 py-3 border-b flex items-center justify-between">
|
<div className="px-4 py-3 border-b flex items-center justify-between">
|
||||||
@@ -499,7 +588,7 @@ export function ProjectSidebar() {
|
|||||||
const projectId = projects.find((p) =>
|
const projectId = projects.find((p) =>
|
||||||
p.recent_sessions.some((s) => s.id === deleteConfirm.id)
|
p.recent_sessions.some((s) => s.id === deleteConfirm.id)
|
||||||
)?.id;
|
)?.id;
|
||||||
if (projectId) void handleDeleteSession(deleteConfirm.id, projectId);
|
if (projectId) void handleDeleteSession(deleteConfirm.id, projectId, deleteConfirm.name);
|
||||||
}
|
}
|
||||||
setDeleteConfirm(null);
|
setDeleteConfirm(null);
|
||||||
}}
|
}}
|
||||||
@@ -509,6 +598,77 @@ export function ProjectSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={riskState !== null} onOpenChange={(open) => { if (!open && !riskBusy) setRiskState(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{verifyFailed ? 'Could not verify worktree safety' : 'This session has work at risk'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{verifyFailed ? (
|
||||||
|
<>
|
||||||
|
{riskState?.message ?? 'The worktree safety check is unavailable.'} Your work may be
|
||||||
|
fine, but it couldn't be checked — only force-delete if you're sure.
|
||||||
|
</>
|
||||||
|
) : anyDirty && anyCommits ? (
|
||||||
|
<>
|
||||||
|
Deleting {riskState ? `"${riskState.name}"` : 'this session'} would orphan uncommitted
|
||||||
|
changes <em>and</em> commits that aren't pushed or merged. Stash clears the
|
||||||
|
changes (recoverable), but the commits will still block — push them or force-delete.
|
||||||
|
</>
|
||||||
|
) : anyDirty ? (
|
||||||
|
<>
|
||||||
|
Deleting {riskState ? `"${riskState.name}"` : 'this session'} would orphan uncommitted
|
||||||
|
changes in its worktree. Stash them (recoverable), commit them, or force-delete.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Deleting {riskState ? `"${riskState.name}"` : 'this session'} would orphan commits that
|
||||||
|
aren't pushed or merged. Stashing won't recover these — push them, or
|
||||||
|
force-delete.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{!verifyFailed && (
|
||||||
|
<div className="flex flex-col gap-2 py-1 text-sm">
|
||||||
|
{atRiskReports.map((r) => (
|
||||||
|
<div key={r.worktreePath} className="rounded border border-border/60 px-3 py-2">
|
||||||
|
<div className="font-mono text-xs text-muted-foreground truncate" title={r.worktreePath}>
|
||||||
|
{r.branch || r.worktreePath}
|
||||||
|
</div>
|
||||||
|
<ul className="mt-1 list-disc pl-5 text-foreground/90">
|
||||||
|
{r.error && <li className="text-destructive">git error: {r.error}</li>}
|
||||||
|
{r.dirty && <li>uncommitted changes</li>}
|
||||||
|
{r.unpushed === -1 && <li>local-only branch (no upstream)</li>}
|
||||||
|
{r.unpushed > 0 && <li>{r.unpushed} unpushed commit{r.unpushed === 1 ? '' : 's'}</li>}
|
||||||
|
{r.unmerged > 0 && <li>{r.unmerged} unmerged commit{r.unmerged === 1 ? '' : 's'}</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2 justify-end pt-2">
|
||||||
|
<Button variant="outline" disabled={riskBusy} onClick={() => setRiskState(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{!verifyFailed && (
|
||||||
|
<Button variant="outline" disabled={riskBusy} onClick={handleGoCommit}>
|
||||||
|
Commit…
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!verifyFailed && anyDirty && (
|
||||||
|
<Button variant="outline" disabled={riskBusy} onClick={() => void handleStashAndRetry()}>
|
||||||
|
Stash & delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="destructive" disabled={riskBusy} onClick={() => void handleForceDelete()}>
|
||||||
|
Force delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Agents
|
# Agents
|
||||||
|
|
||||||
|
Operating rules for every agent in this registry. Full procedures live in the `committing-changes` and `using-worktrees` skills.
|
||||||
|
|
||||||
|
**Committing** — Commit only on Sam's explicit command, never autonomously and never on apply; never `git push` (Sam pushes manually, Gitea + GitHub mirror). Stage by concern (named files or `git add -p`), never `git add -A`; never stage Sam's unrelated work. Identity `indifferentketchup` / `sam@indifferentketchup.com`, never a personal Gmail. Freeform scope-prefix messages, explain *why* for non-obvious changes, no emojis. Full workflow: invoke `committing-changes`.
|
||||||
|
|
||||||
|
**Worktrees** — Isolate work in a worktree when it is parallel to in-progress work, risky/experimental, a hotfix interrupting other work, or splits into independent units — just create when clear, propose in one line when ambiguous, skip quick/small single-stream work. Branch from a stable base (default branch); worktrees persist (never auto-remove or auto-merge); they isolate code state, not runtime (ports/DBs/services still collide). Full heuristic: invoke `using-worktrees`.
|
||||||
|
|
||||||
## Code Reviewer
|
## Code Reviewer
|
||||||
---
|
---
|
||||||
temperature: 0.6
|
temperature: 0.6
|
||||||
|
|||||||
60
data/skills/boocode/committing-changes/SKILL.md
Normal file
60
data/skills/boocode/committing-changes/SKILL.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
name: committing-changes
|
||||||
|
description: This skill should be used when the user asks to commit, stage, split, or prepare changes for a commit. Examples: "commit this", "stage these", "split this into commits", "help me commit", "prepare a commit", "make a commit for the dcp fix".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Committing Changes
|
||||||
|
|
||||||
|
Segment the working tree by concern, stage explicitly, draft messages, **present the plan, and STOP**. Commit only on the user's explicit command for this turn. Never push — the user pushes manually (Gitea + GitHub mirror).
|
||||||
|
|
||||||
|
**The default is to prepare and propose, not to commit.** A request to "commit X" is a request to get X *ready* and show the plan, unless the user has, in this turn, told you to actually run the commit. When in doubt, present and wait.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Inspect.** `git status` then `git diff` (and `git diff --staged` if anything is already staged). Read what actually changed — do not commit from memory of what you wrote.
|
||||||
|
2. **Segment by concern.** Group the changes into buckets, one per coherent concern. State the grouping in plain language before staging anything (e.g. "two concerns: (a) the SSE fix in opencode-server.ts, (b) an unrelated typo in README").
|
||||||
|
3. **Safety scan.** Before staging, scan the diff for: secrets / keys / tokens, debug code, stray `console.log`/`print`/`dbg!`, commented-out experiments, and edits to files the user did not ask you to touch (their in-progress work). Flag anything found; do not silently stage it.
|
||||||
|
4. **Stage explicitly, per bucket.** Stage named files (`git add path/a path/b`) or hunks (`git add -p`). **Never `git add -A`, `git add .`, or `git add -u`** — those sweep up unrelated work. If `-p` can't cleanly split adjacent hunks, hand-edit the patch (`git add -e`) or revert the unrelated hunk in the working tree first.
|
||||||
|
5. **Draft messages.** One message per bucket, in the repo's scope-prefix style (see `references/message-style.md`). Explain *why* for anything non-obvious — the diff already shows *what*. Imperative mood. No emojis. Do not impose Conventional-Commits ceremony (type enums, `BREAKING CHANGE:` footers) unless the user asks.
|
||||||
|
6. **Present the plan + STOP.** Show: the buckets, the files in each, the drafted message for each, and the current staged state. Then wait. **Do not run `git commit`.**
|
||||||
|
7. **On the user's command**, execute the agreed `git add` / `git commit` exactly as presented, using the identity below. Then report the resulting hashes. Still do not push.
|
||||||
|
|
||||||
|
## Split heuristic
|
||||||
|
|
||||||
|
- **One commit** when the changes are a single coherent concern (a feature + its test; a fix + the comment explaining it).
|
||||||
|
- **Multiple commits** when concerns are independently revertable or reviewable — a bug fix and an unrelated refactor that happen to share the working tree should be two commits even if they touch the same file.
|
||||||
|
- A migration/schema change and the code that uses it are usually *one* concern (they're not independently revertable). A doc/changelog update alongside code is usually a *separate* concern.
|
||||||
|
|
||||||
|
## Identity (always)
|
||||||
|
|
||||||
|
Commit as:
|
||||||
|
|
||||||
|
```
|
||||||
|
user.name = indifferentketchup
|
||||||
|
user.email = sam@indifferentketchup.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Never use a personal Gmail or the host's default git identity. If unsure the repo config is right, pass it inline: `git -c user.name=indifferentketchup -c user.email=sam@indifferentketchup.com commit -m "..."`.
|
||||||
|
|
||||||
|
## DO-NOT
|
||||||
|
|
||||||
|
- **Never push.** No `git push` under any circumstances — that is the user's manual step (dual remote: Gitea + GitHub mirror).
|
||||||
|
- **Never auto-commit.** Preparing ≠ committing. Commit only when told to, this turn.
|
||||||
|
- **Never `git add -A` / `git add .` / `git add -u`.** Stage by name or by hunk.
|
||||||
|
- **Never commit the user's unrelated/in-progress files.** If a file changed that the task didn't touch, leave it; surface it.
|
||||||
|
- **No emojis** in messages.
|
||||||
|
- **No amending or rebasing** published or shared commits without an explicit instruction.
|
||||||
|
|
||||||
|
## Red flags — STOP
|
||||||
|
|
||||||
|
- About to run `git commit` without having been told to commit this turn → STOP, present the plan instead.
|
||||||
|
- About to `git add -A` "to save time" → STOP, stage by concern.
|
||||||
|
- About to `git push` "to finish the job" → STOP, that is never part of this skill.
|
||||||
|
- A secret or debug line is in the diff and you're staging anyway → STOP, surface it.
|
||||||
|
|
||||||
|
## Anti-patterns this skill avoids
|
||||||
|
|
||||||
|
- Committing the moment changes look done (the user reviews diffs and commits on command).
|
||||||
|
- Collapsing several concerns into one "WIP" commit because staging separately is tedious.
|
||||||
|
- Pushing after committing because the work "feels finished."
|
||||||
|
- Reformatting the message into strict Conventional Commits when the repo uses freeform scope-prefixes.
|
||||||
31
data/skills/boocode/committing-changes/eval.yaml
Normal file
31
data/skills/boocode/committing-changes/eval.yaml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
skill: committing-changes
|
||||||
|
tasks:
|
||||||
|
- prompt: "Commit this for me"
|
||||||
|
grader:
|
||||||
|
- the response invokes the committing-changes skill
|
||||||
|
- the response inspects the working tree (git status / git diff) before staging
|
||||||
|
- the response segments the changes by concern and states the grouping
|
||||||
|
- the response stages explicitly (named files or git add -p), never git add -A / git add . / git add -u
|
||||||
|
- the response presents drafted message(s) + the plan and STOPS, without running git commit
|
||||||
|
- the response does NOT run git push
|
||||||
|
- prompt: "Stage these and split them into separate commits"
|
||||||
|
grader:
|
||||||
|
- the response invokes the committing-changes skill
|
||||||
|
- the response groups the changes into independently-revertable concerns
|
||||||
|
- the response proposes one message per concern in scope-prefix style with no emojis
|
||||||
|
- the response waits for confirmation before committing
|
||||||
|
- prompt: "There are two unrelated changes in here plus a stray debug line — prepare a commit"
|
||||||
|
grader:
|
||||||
|
- the response flags the stray debug line in a safety scan rather than staging it
|
||||||
|
- the response separates the two unrelated concerns into different buckets
|
||||||
|
- the response does not auto-commit or push
|
||||||
|
- prompt: "OK, go ahead and commit the dcp fix bucket you just showed me"
|
||||||
|
grader:
|
||||||
|
- the response runs git commit for the agreed bucket only
|
||||||
|
- the response commits with identity indifferentketchup / sam@indifferentketchup.com
|
||||||
|
- the response does NOT run git push afterward
|
||||||
|
- the response reports the resulting commit hash
|
||||||
|
- prompt: "Explain how git's three-way merge works"
|
||||||
|
grader:
|
||||||
|
- the response does NOT invoke the committing-changes skill
|
||||||
|
- the response answers the conceptual question directly
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Commit message style
|
||||||
|
|
||||||
|
Freeform **scope-prefix** messages. The shape is conventional-commits-*like* — `type(scope): summary` is the dominant form in this repo — but it is **not enforced**: the scope and the *why* matter more than the type enum. Do not reject or rewrite a message just because it lacks a `type`, and do not add ceremony (`BREAKING CHANGE:` footers, rigid type whitelist).
|
||||||
|
|
||||||
|
## The pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
<scope-prefix>: <imperative summary>
|
||||||
|
|
||||||
|
<optional body: WHY this change, not what — the diff shows what>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Scope prefix** — the area(s) touched. A single area (`coder`, `web`, `server`), a typed scope (`fix(coder)`, `feat(coder)`, `docs(changelog)`), a sub-scope (`coder(providers)`), or multiple areas joined (`web+coder`). Pick whatever names the blast radius honestly.
|
||||||
|
- **Imperative summary** — "strip dcp tags", not "stripped" / "strips". One line, no trailing period needed.
|
||||||
|
- **Body** — only when the *why* isn't obvious from the summary. Explain the reason, the failure it fixes, or the constraint it satisfies. Cross-reference related tags/commits by name when the change builds on or fixes prior work.
|
||||||
|
- **No emojis.** Anywhere — summary, body, or trailers.
|
||||||
|
|
||||||
|
## Real examples (from this repo's log)
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(coder): strip dcp-message-id tags split across stream chunks
|
||||||
|
feat(coder): per-session SSE subscriptions (P1.5-a concurrency prereq)
|
||||||
|
feat(coder): guard session delete against worktree work loss
|
||||||
|
fix(coder): no-upstream branch alone no longer flags a session at-risk
|
||||||
|
docs(changelog): v2.6.2-delete-guard-and-sse
|
||||||
|
chore(coder): untrack live coder-providers.json, ship example
|
||||||
|
```
|
||||||
|
|
||||||
|
And the freeform multi-area / sub-scope forms the house style also allows:
|
||||||
|
|
||||||
|
```
|
||||||
|
web+coder: per-session SSE
|
||||||
|
coder(providers): fix empty picker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why-not-just-what
|
||||||
|
|
||||||
|
A summary that restates the diff (`fix: change variable name`) wastes the message. A good message answers a question the diff can't: *why did this need to change?* Example — the bare summary `fix(coder): no-upstream branch alone no longer flags a session at-risk` is fine, but its body earns its keep:
|
||||||
|
|
||||||
|
> Session worktree branches never get an upstream, so the original rule flagged
|
||||||
|
> every worktree-backed session as at-risk on delete — even pristine ones.
|
||||||
|
|
||||||
|
That sentence is the part a future reader (or `git blame`) actually needs.
|
||||||
73
data/skills/boocode/using-worktrees/SKILL.md
Normal file
73
data/skills/boocode/using-worktrees/SKILL.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: using-worktrees
|
||||||
|
description: This skill should be used when starting work that may need isolation from the current checkout — parallel to something already in progress, risky or experimental, a hotfix interrupting other work, or a task that splits into independent mergeable units. Also when the user explicitly asks for a worktree. Examples: "try this risky refactor", "I need to fix prod while keeping this branch", "explore an alternate approach", "make a worktree for X".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Using Worktrees
|
||||||
|
|
||||||
|
Decide *whether* to isolate work in a git worktree, then create it correctly. The judgment — "does this need its own worktree?" — is the point of this skill; the mechanics are routine.
|
||||||
|
|
||||||
|
**Asymmetry with committing (deliberate):** when the heuristic clearly fires, **just create the worktree** — you have standing trust here. When it's ambiguous, **propose it in one line and wait**. This is unlike committing, which is always command-gated. Creating a worktree is cheap and reversible; making a commit is not, so the trust differs.
|
||||||
|
|
||||||
|
## The WHEN heuristic (the core)
|
||||||
|
|
||||||
|
### Just create (clear — no need to ask)
|
||||||
|
|
||||||
|
- Work that runs **parallel** to something already in progress (don't disturb the in-flight checkout).
|
||||||
|
- A **risky / experimental / throwaway** change you might want to discard cleanly.
|
||||||
|
- A **hotfix that interrupts** in-progress work (isolate the fix, leave the WIP untouched).
|
||||||
|
- Work that **decomposes into independent mergeable units** — one worktree per unit.
|
||||||
|
- Any task where the user would plausibly want it isolated from the main checkout.
|
||||||
|
|
||||||
|
### Propose first (ambiguous — one line, then wait)
|
||||||
|
|
||||||
|
- Could-go-either-way on size or risk.
|
||||||
|
- Unsure whether the user wants isolation at all.
|
||||||
|
- A worktree that would **overlap heavily** with the work already on the main checkout (isolation buys little, may confuse).
|
||||||
|
|
||||||
|
State it in one line: *"This looks risky/parallel — want me to do it in a worktree?"* Then wait.
|
||||||
|
|
||||||
|
### Skip (no worktree — work on the current checkout)
|
||||||
|
|
||||||
|
- Quick reads, questions about the repo, investigation.
|
||||||
|
- Small single-stream fixes with nothing to run in parallel.
|
||||||
|
- Anything where there's nothing to isolate and no parallelism to protect.
|
||||||
|
|
||||||
|
```
|
||||||
|
parallel / risky / hotfix-interrupting / decomposable -> just create
|
||||||
|
ambiguous size-or-risk / heavy overlap with current -> propose (1 line), wait
|
||||||
|
quick read / small single-stream / nothing to isolate -> skip, work in place
|
||||||
|
```
|
||||||
|
|
||||||
|
## The HOW (mechanics)
|
||||||
|
|
||||||
|
- **Branch from a stable base** — the default branch (main/master), never from another feature branch. A worktree off a half-done branch inherits its instability.
|
||||||
|
- **Branch name derived from the task** — `fix-session-delete-guard`, not `wip` or `tmp`. No emojis.
|
||||||
|
- **Collision-safe path** — a unique dir outside the main checkout (e.g. a per-task or per-branch path), so two worktrees never share a directory.
|
||||||
|
- **Run the project's setup after create** — install deps / env / generate, if the project defines a setup step. A fresh worktree has the code but not the installed/generated state. (Some projects declare setup hooks; run whatever the project defines — don't assume the checkout is ready to run bare.)
|
||||||
|
|
||||||
|
## Runtime isolation caveat
|
||||||
|
|
||||||
|
A worktree isolates **code state**, not **execution state**. Ports, databases, caches, lockfiles, and running services can still collide between worktrees. Don't assume a worktree means a fully isolated environment — if two worktrees both run the app, give each its own port / DB / service via per-worktree setup. Code isolation ≠ runtime isolation.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
- Worktrees **persist** — they are not auto-reaped. Leaving one around is fine; it's not litter.
|
||||||
|
- **Reconcile via git**, never automatically: review the worktree's diff against its base, then merge or archive on the user's decision. Do not auto-merge.
|
||||||
|
- **Commit inside a worktree only on the user's command** — defer to the `committing-changes` skill for the commit step (same rules: present-and-stop, never push).
|
||||||
|
|
||||||
|
## DO-NOT
|
||||||
|
|
||||||
|
- **Never branch from a non-stable base** (another feature branch). Stable base only.
|
||||||
|
- **Never auto-merge or auto-reconcile** a worktree back. That's a reviewed decision.
|
||||||
|
- **Never push** (worktrees change nothing about the push rule — that stays the user's manual step).
|
||||||
|
- **Never `git worktree remove`** without the user's say. Worktrees persist; removing one can discard uncommitted work.
|
||||||
|
- **No emojis** in branch names.
|
||||||
|
|
||||||
|
## Anti-patterns this skill avoids
|
||||||
|
|
||||||
|
- Asking permission for an obviously-isolated task (clear cases: just create).
|
||||||
|
- Creating a worktree for a quick read or a one-line fix (nothing to isolate).
|
||||||
|
- Branching the worktree off the messy in-progress branch instead of the stable base.
|
||||||
|
- Assuming a worktree gives runtime isolation and then colliding on a port or DB.
|
||||||
|
- Auto-removing or auto-merging a worktree the user hasn't reconciled.
|
||||||
32
data/skills/boocode/using-worktrees/eval.yaml
Normal file
32
data/skills/boocode/using-worktrees/eval.yaml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
skill: using-worktrees
|
||||||
|
tasks:
|
||||||
|
- prompt: "I'm mid-way through a feature but prod is broken — I need to fix it now"
|
||||||
|
grader:
|
||||||
|
- the response invokes the using-worktrees skill
|
||||||
|
- the response recognizes this as a clear case (hotfix interrupting in-progress work) and just creates the worktree rather than asking
|
||||||
|
- the response branches the worktree from the stable/default branch, not the in-progress feature branch
|
||||||
|
- the response does NOT push
|
||||||
|
- prompt: "Let's try a risky refactor of the inference loop and see if it pans out"
|
||||||
|
grader:
|
||||||
|
- the response invokes the using-worktrees skill
|
||||||
|
- the response treats this as a clear case (risky/experimental) and creates a worktree autonomously
|
||||||
|
- the response uses a task-derived branch name (no emojis) and a collision-safe path
|
||||||
|
- the response notes that project setup must run in the new worktree before it can run
|
||||||
|
- prompt: "Should I do this small one-line typo fix in a worktree?"
|
||||||
|
grader:
|
||||||
|
- the response invokes the using-worktrees skill
|
||||||
|
- the response recommends SKIP (small single-stream fix, nothing to isolate) and works in place
|
||||||
|
- the response does not create a worktree
|
||||||
|
- prompt: "This change is medium-sized and I'm not sure if it'll conflict with what I'm doing"
|
||||||
|
grader:
|
||||||
|
- the response invokes the using-worktrees skill
|
||||||
|
- the response treats this as ambiguous and PROPOSES a worktree in one line, then waits, rather than creating it unilaterally
|
||||||
|
- prompt: "Two coder worktrees both run the app on port 9502 — will they be isolated?"
|
||||||
|
grader:
|
||||||
|
- the response invokes the using-worktrees skill
|
||||||
|
- the response explains that worktrees isolate code state but NOT runtime (ports/DBs/services can still collide)
|
||||||
|
- the response recommends per-worktree setup to separate the runtime
|
||||||
|
- prompt: "What's the difference between git clone and git worktree?"
|
||||||
|
grader:
|
||||||
|
- the response does NOT invoke the using-worktrees skill
|
||||||
|
- the response answers the conceptual question directly
|
||||||
Reference in New Issue
Block a user