diff --git a/CHANGELOG.md b/CHANGELOG.md index a0c4f5d..384eb95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ 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.7.1-write-edit-robustness — 2026-06-01 + +Two BooCoder hardening features for local quantized models, algorithm-reimplemented (not vendored) from the cline findings in `boocode_code_review_v2.md` §1 #3/#4. **Fuzzy patch applier:** `edit_file`'s apply path was exact-`.includes`-or-throw + first-occurrence `.replace` (`pending_changes.ts`), so a qwen3.6 whitespace/indentation/unicode drift in `old_string` lost the edit; a new pure `fuzzy-match.ts` (`locateMatch`) now runs an exact → per-line-trim → unicode-canon (curly quotes/dashes/nbsp) → Levenshtein-≥0.66 ladder and returns the real file span, refusing multi-exact matches as ambiguous rather than silently editing the first. `applyOne`/`rewindOne` both use it. **Worktree checkpoints + conversation-trim:** `rewind` only reversed BooCode's own `pending_changes`, blind to what external agents (opencode/goose/qwen/claude) write directly into the session worktree — so a new `checkpoints` table + `checkpoints.ts` shadow-commit (tracked **and** untracked, captured via a temp-index `read-tree`/`add`/`write-tree`/`commit-tree` into a GC-safe `refs/boocode/checkpoints/`) snapshots the worktree before each external-agent turn (hooked into all three dispatcher paths), anchored to the turn's assistant message. A new `POST /api/sessions/:id/checkpoints/:cid/restore` resets the worktree (`reset --hard` + `clean -fd`), trims the transcript past that message, and resets the `(chat,agent)` backend session so files, transcript, and agent context land consistent at the restore point; a per-message "Restore to here" affordance in `CoderMessageList` drives it. Built by three parallel agents over disjoint files; DB-integration testing caught a microsecond-`created_at` self-deletion bug in the later-checkpoint cleanup. Full coder suite 234 passing (incl. 17 fuzzy-match + 6 checkpoint tests), server+coder build + web tsc clean. Builds on `v2.7.0-mit`; openspec `write-edit-robustness`. Live host smoke (dispatcher hook + restore UI end-to-end) still to run. + ## v2.7.0-mit — 2026-06-01 Relicenses BooCode from AGPL-3.0 back to MIT by clearing the three Unsloth-Studio-derived files the `v2.4.0`/`v2.4.1` lifts pulled in — the root `LICENSE` and all five `package.json` had been `AGPL-3.0-only`, making the network-served work AGPL §13-encumbered. The enabling finding decoupled the relicense from the long-planned native-llama-server-parsing retirement: `tool-call-parser.ts`'s Unsloth-ported algorithm (`parseToolCallsFromText`/`scanBalancedBraces` + unused nudge constants) was **dead code** with no production import, so it was simply deleted while the load-bearing `extractToolCallBlocks`/`stripToolMarkup` (BooCode-authored streaming helpers) were kept byte-identical — no behavior change to the live tool-call path. `html-to-md.ts` was swapped to the MIT `node-html-markdown` library (`parse5` dropped; the only behavior delta is column-aligned tables, GFM hard-break `
`, and `
    ` renumbering, all feeding the LLM via `web_fetch`), and `llama-args-validator.ts` was clean-room rewritten with the managed-flag denylist re-derived from the public llama-server flag list (facts, not copyrightable). The license flip set `LICENSE` to MIT (`Copyright (c) 2026 indifferentketchup`), the five `package.json` to `MIT`, removed every AGPL SPDX header, added a README License section, and added a `license-mit` guard test that fails if AGPL provenance returns. Built by three parallel agents over the disjoint files; full server suite 519 passing (incl. 9 new guard tests), server build + coder typecheck clean. Resolves `boocode_code_review_v2.md` §1 #1 / §5k and the roadmap's `License-debt` batch (openspec `license-debt-mit`); supersedes that batch's original staged plan, which had entangled the flip with a live qwen3.6 validation window. diff --git a/apps/coder/src/index.ts b/apps/coder/src/index.ts index 7e714c9..2b6b08a 100644 --- a/apps/coder/src/index.ts +++ b/apps/coder/src/index.ts @@ -25,6 +25,7 @@ import { setInferenceContext, clearInferenceContext } from './services/tools/inf import { registerMessageRoutes } from './routes/messages.js'; import { registerSkillRoutes } from './routes/skills.js'; import { registerPendingRoutes } from './routes/pending.js'; +import { registerCheckpointRoutes } from './routes/checkpoints.js'; import { registerAgentSessionRoutes } from './routes/agent-sessions.js'; import { registerTaskRoutes } from './routes/tasks.js'; import { registerInboxRoutes } from './routes/inbox.js'; @@ -214,6 +215,7 @@ async function main() { registerMessageRoutes(app, sql, broker, inferenceApi); registerSkillRoutes(app, sql, broker, inferenceApi); registerPendingRoutes(app, sql); + registerCheckpointRoutes(app, sql); registerAgentSessionRoutes(app, sql); registerTaskRoutes(app, sql, inferenceApi); registerInboxRoutes(app, sql); diff --git a/apps/coder/src/routes/checkpoints.ts b/apps/coder/src/routes/checkpoints.ts new file mode 100644 index 0000000..ed886a2 --- /dev/null +++ b/apps/coder/src/routes/checkpoints.ts @@ -0,0 +1,67 @@ +/** + * write-edit-robustness #4 — checkpoint restore + list routes (coder side). + * + * Proxied through the apps/server `/api/coder/*` blanket forwarder (no server-side + * change needed for new routes). Restore rewinds the session worktree to the + * checkpoint's shadow commit, trims the transcript from the anchor message forward, + * and resets the agent backend — see services/checkpoints.ts. + */ +import type { FastifyInstance } from 'fastify'; +import type { Sql } from '../db.js'; +import { restoreCheckpoint, CheckpointNotFoundError } from '../services/checkpoints.js'; + +export function registerCheckpointRoutes(app: FastifyInstance, sql: Sql): void { + // GET /api/sessions/:sessionId/checkpoints?chat_id= — list a chat's checkpoints + // so the frontend can mark which messages have a restore point. When chat_id is + // omitted, returns every checkpoint for the session's chats. + app.get<{ Params: { sessionId: string }; Querystring: { chat_id?: string } }>( + '/api/sessions/:sessionId/checkpoints', + async (req, reply) => { + const sessionId = req.params.sessionId; + const chatId = req.query.chat_id; + + const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`; + if (session.length === 0) { + reply.code(404); + return { error: 'session not found' }; + } + + const rows = chatId + ? await sql<{ id: string; chat_id: string; message_id: string | null; label: string | null; created_at: Date }[]>` + SELECT id, chat_id, message_id, label, created_at + FROM checkpoints + WHERE chat_id = ${chatId} + ORDER BY created_at + ` + : await sql<{ id: string; chat_id: string; message_id: string | null; label: string | null; created_at: Date }[]>` + SELECT id, chat_id, message_id, label, created_at + FROM checkpoints + WHERE session_id = ${sessionId} + ORDER BY created_at + `; + return rows; + }, + ); + + // POST /api/sessions/:sessionId/checkpoints/:checkpointId/restore — restore. + app.post<{ Params: { sessionId: string; checkpointId: string } }>( + '/api/sessions/:sessionId/checkpoints/:checkpointId/restore', + async (req, reply) => { + const { sessionId, checkpointId } = req.params; + + try { + const result = await restoreCheckpoint(sql, checkpointId, { + sessionId, + log: app.log, + }); + return result; + } catch (err) { + if (err instanceof CheckpointNotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); +} diff --git a/apps/coder/src/schema.sql b/apps/coder/src/schema.sql index 9513a03..b546126 100644 --- a/apps/coder/src/schema.sql +++ b/apps/coder/src/schema.sql @@ -240,6 +240,27 @@ END $$; -- v2.6: attribution for DiffPanel badges (Phase 1 UX reads this). ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT; +-- write-edit-robustness #4: worktree checkpoints. A pre-turn shadow-commit of the +-- session worktree (tracked + untracked, captured without disturbing the real +-- index/working tree) stored in a private GC-safe ref refs/boocode/checkpoints/. +-- Created best-effort before each external-agent turn (opencode / warm-ACP / one-shot +-- ACP+PTY); restore resets the worktree to commit_sha, trims the transcript from +-- message_id forward, and resets the backend session. chat_id CASCADEs from chats +-- (like agent_sessions); worktree_id SET NULL so a checkpoint outlives a reaped +-- worktree row. session_id / message_id are informational (no FK — message rows are +-- trimmed by a checkpoint restore and we must not block that on a dangling ref). +CREATE TABLE IF NOT EXISTS checkpoints ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE, + session_id UUID, + worktree_id UUID REFERENCES worktrees(id) ON DELETE SET NULL, + message_id UUID, -- anchor: the assistant turn row this checkpoint precedes + commit_sha TEXT NOT NULL, -- shadow-commit capturing the pre-turn worktree tree + label TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() +); +CREATE INDEX IF NOT EXISTS checkpoints_chat_created_idx ON checkpoints(chat_id, created_at); + -- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes, -- new_task tool, arena, MCP server) fires pg_notify('tasks_new') in the same -- transaction, so the dispatcher reacts immediately instead of waiting for the diff --git a/apps/coder/src/services/__tests__/checkpoints.test.ts b/apps/coder/src/services/__tests__/checkpoints.test.ts new file mode 100644 index 0000000..b42e0ec --- /dev/null +++ b/apps/coder/src/services/__tests__/checkpoints.test.ts @@ -0,0 +1,236 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { rm, mkdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import postgres from 'postgres'; +import { + buildShadowCommitCommand, + createCheckpoint, + restoreCheckpoint, + CheckpointNotFoundError, +} from '../checkpoints.js'; +import { ensureSessionWorktree } from '../worktrees.js'; +import { hostExec } from '../host-exec.js'; + +/** + * write-edit-robustness #4 — worktree checkpoint tests. + * + * Pure-helper coverage (no DB / no host) for the shadow-commit command builder, + * plus a DB+git integration block (DB-opt-in via DATABASE_URL, skips cleanly + * otherwise; mirrors reconnect_integration.test.ts) that exercises the real + * create → restore round trip against a worktree on the host fs. + */ + +describe('buildShadowCommitCommand (pure)', () => { + it('parks the commit under refs/boocode/checkpoints/ and prints only the SHA', () => { + const cmd = buildShadowCommitCommand('/tmp/booworktrees/sess-abc', 'cp-id-123'); + // Uses a temp index so the real working tree/index is untouched. + expect(cmd).toContain('TMP=$(mktemp)'); + expect(cmd).toContain('GIT_INDEX_FILE="$TMP" git read-tree HEAD'); + expect(cmd).toContain('GIT_INDEX_FILE="$TMP" git add -A'); + expect(cmd).toContain('git write-tree'); + expect(cmd).toContain("git commit-tree \"$TREE\" -p HEAD -m \"boocode checkpoint\""); + // Ref name matches the row id, and stdout is ONLY the SHA (printf, no newline). + expect(cmd).toContain("update-ref 'refs/boocode/checkpoints/cp-id-123'"); + expect(cmd).toContain("printf '%s' \"$SHA\""); + expect(cmd).not.toContain('echo "$SHA"'); + }); + + it('shell-escapes the worktree path and the id', () => { + const cmd = buildShadowCommitCommand("/tmp/it's a path", "id'; rm -rf /"); + // Single quotes inside the path/id are escaped via the '\'' wrapping idiom — no + // bare interpolation that could break out of the quoting. + expect(cmd).toContain("cd '/tmp/it'\\''s a path'"); + expect(cmd).toContain("refs/boocode/checkpoints/id'\\''; rm -rf /"); + }); +}); + +describe.runIf(!!process.env.DATABASE_URL)('checkpoint create + restore (DB + git)', () => { + let sql: ReturnType; + const stamp = Date.now(); + const projectDir = `/tmp/boocode-checkpoint-proj-${stamp}`; + let projectId: string; + let sessionId: string; + let chatId: string; + let worktreePath: string; + + beforeAll(async () => { + sql = postgres(process.env.DATABASE_URL!, { max: 3 }); + + // Server schema first (FK targets), then coder schema (worktrees + checkpoints). + const serverSchema = resolve(__dirname, '../../../../server/src/schema.sql'); + const coderSchema = resolve(__dirname, '../../schema.sql'); + await sql.unsafe(readFileSync(serverSchema, 'utf8')); + await sql.unsafe(readFileSync(coderSchema, 'utf8')); + + await mkdir(projectDir, { recursive: true }); + await hostExec( + `cd ${projectDir} && git init -q && git config user.email t@t && git config user.name t ` + + `&& echo hello > README.md && git add -A && git commit -qm init`, + { timeoutMs: 20_000 }, + ); + + const [project] = await sql<{ id: string }[]>` + INSERT INTO projects (name, path, status) VALUES ('checkpoint-test', ${projectDir}, 'open') RETURNING id + `; + projectId = project!.id; + const [session] = await sql<{ id: string }[]>` + INSERT INTO sessions (project_id, name, model, status) + VALUES (${projectId}, 'cp', 'm', 'open') RETURNING id + `; + sessionId = session!.id; + const [chat] = await sql<{ id: string }[]>` + INSERT INTO chats (session_id, name, status) VALUES (${sessionId}, 'tab', 'open') RETURNING id + `; + chatId = chat!.id; + + const wt = await ensureSessionWorktree(sql, projectDir, sessionId); + worktreePath = wt.worktreePath; + }); + + afterAll(async () => { + if (sql) { + const rows = await sql<{ path: string }[]>`SELECT path FROM worktrees WHERE session_id = ${sessionId}`.catch(() => []); + for (const r of rows) { + await hostExec(`git -C ${projectDir} worktree remove ${r.path} --force`, { timeoutMs: 10_000 }).catch(() => {}); + } + await sql`DELETE FROM checkpoints WHERE chat_id = ${chatId}`.catch(() => {}); + await sql`DELETE FROM agent_sessions WHERE chat_id = ${chatId}`.catch(() => {}); + await sql`DELETE FROM worktrees WHERE session_id = ${sessionId}`.catch(() => {}); + await sql`DELETE FROM chats WHERE id = ${chatId}`.catch(() => {}); + await sql`DELETE FROM sessions WHERE id = ${sessionId}`.catch(() => {}); + await sql`DELETE FROM projects WHERE id = ${projectId}`.catch(() => {}); + await sql.end({ timeout: 5 }); + } + await rm(projectDir, { recursive: true, force: true }); + }); + + it('createCheckpoint inserts a row + a private ref capturing tracked + untracked', async () => { + const [wt] = await sql<{ id: string }[]>`SELECT id FROM worktrees WHERE session_id = ${sessionId} AND status = 'active'`; + const worktreeId = wt!.id; + + // Pre-turn untracked + tracked-edit state the agent will start from. + await hostExec(`cd ${worktreePath} && echo edited >> README.md && echo new > extra.txt`, { timeoutMs: 10_000 }); + + const [assistantMsg] = await sql<{ id: string }[]>` + INSERT INTO messages (session_id, chat_id, role, content, status) + VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming') RETURNING id + `; + const messageId = assistantMsg!.id; + + const cp = await createCheckpoint(sql, { + chatId, + sessionId, + worktreeId, + worktreePath, + messageId, + }); + expect(cp).not.toBeNull(); + expect(cp!.commit_sha).toMatch(/^[0-9a-f]{40}$/); + + const [row] = await sql<{ commit_sha: string; worktree_id: string; message_id: string }[]>` + SELECT commit_sha, worktree_id, message_id FROM checkpoints WHERE id = ${cp!.id} + `; + expect(row!.commit_sha).toBe(cp!.commit_sha); + expect(row!.worktree_id).toBe(worktreeId); + expect(row!.message_id).toBe(messageId); + + // The ref exists and the captured tree carries the untracked file (proves the + // temp-index `git add -A` snapshotted untracked content). + const refLs = await hostExec( + `git -C ${worktreePath} ls-tree -r --name-only ${cp!.commit_sha}`, + { timeoutMs: 10_000 }, + ); + expect(refLs.exitCode).toBe(0); + expect(refLs.stdout).toContain('extra.txt'); + + // The shadow commit did NOT disturb the real working tree: extra.txt is still + // present + still untracked (status shows it). + const status = await hostExec(`git -C ${worktreePath} status --porcelain`, { timeoutMs: 10_000 }); + expect(status.stdout).toContain('extra.txt'); + }); + + it('restoreCheckpoint resets the worktree, trims the transcript, and drops later checkpoints', async () => { + // Clean slate for this test: reset the worktree to HEAD, clear prior rows. + await hostExec(`git -C ${worktreePath} reset --hard HEAD && git -C ${worktreePath} clean -fd`, { timeoutMs: 10_000 }); + await sql`DELETE FROM checkpoints WHERE chat_id = ${chatId}`; + await sql`DELETE FROM messages WHERE chat_id = ${chatId}`; + + const [wt] = await sql<{ id: string }[]>`SELECT id FROM worktrees WHERE session_id = ${sessionId} AND status = 'active'`; + const worktreeId = wt!.id; + + // Turn 1: a user msg, then the assistant turn the checkpoint anchors. The + // worktree is pristine (matches HEAD) when this checkpoint is captured. + await sql`INSERT INTO messages (session_id, chat_id, role, content, status) VALUES (${sessionId}, ${chatId}, 'user', 'do it', 'complete')`; + const [a1] = await sql<{ id: string }[]>` + INSERT INTO messages (session_id, chat_id, role, content, status) + VALUES (${sessionId}, ${chatId}, 'assistant', 'turn 1', 'complete') RETURNING id + `; + const cp1 = await createCheckpoint(sql, { chatId, sessionId, worktreeId, worktreePath, messageId: a1!.id }); + expect(cp1).not.toBeNull(); + + // The agent (turn 1) writes a file into the worktree. + await hostExec(`cd ${worktreePath} && echo agent-wrote > agent.txt`, { timeoutMs: 10_000 }); + + // Turn 2: another user msg + assistant turn, AND a second (later) checkpoint. + await sql`INSERT INTO messages (session_id, chat_id, role, content, status) VALUES (${sessionId}, ${chatId}, 'user', 'more', 'complete')`; + const [a2] = await sql<{ id: string }[]>` + INSERT INTO messages (session_id, chat_id, role, content, status) + VALUES (${sessionId}, ${chatId}, 'assistant', 'turn 2', 'complete') RETURNING id + `; + const cp2 = await createCheckpoint(sql, { chatId, sessionId, worktreeId, worktreePath, messageId: a2!.id }); + expect(cp2).not.toBeNull(); + + // An agent_sessions row that restore should mark 'crashed'. + await sql` + INSERT INTO agent_sessions (chat_id, session_id, worktree_id, agent, backend, agent_session_id, status, last_active_at) + VALUES (${chatId}, ${sessionId}, ${worktreeId}, 'goose', 'acp_warm', 'sess-1', 'active', clock_timestamp()) + ON CONFLICT (chat_id, agent) DO UPDATE SET status = 'active' + `; + + const before = await sql<{ id: string }[]>`SELECT id FROM messages WHERE chat_id = ${chatId} ORDER BY created_at`; + expect(before.length).toBe(4); // user, a1, user, a2 + + // Restore to cp1 (before turn 1's assistant message). + const result = await restoreCheckpoint(sql, cp1!.id, { sessionId }); + expect(result.checkpoint_id).toBe(cp1!.id); + expect(result.worktree_reset).toBe(true); + expect(result.backend_reset).toBe(true); + // a1, user(turn2), a2 deleted (created_at >= a1) → 3 trimmed. + expect(result.messages_deleted).toBe(3); + + // Transcript trimmed to just the first user message. + const after = await sql<{ role: string; content: string }[]>`SELECT role, content FROM messages WHERE chat_id = ${chatId} ORDER BY created_at`; + expect(after.length).toBe(1); + expect(after[0]!.role).toBe('user'); + + // Worktree reset: the agent's file is gone (it was written after cp1). + const ls = await hostExec(`ls ${worktreePath}/agent.txt`, { timeoutMs: 10_000 }); + expect(ls.exitCode).not.toBe(0); + + // The agent_sessions row was reset to 'crashed'. + const [as] = await sql<{ status: string }[]>`SELECT status FROM agent_sessions WHERE chat_id = ${chatId} AND agent = 'goose'`; + expect(as!.status).toBe('crashed'); + + // cp1 survives (re-restorable); cp2 (later) was dropped. + const cps = await sql<{ id: string }[]>`SELECT id FROM checkpoints WHERE chat_id = ${chatId}`; + expect(cps.map((c) => c.id)).toEqual([cp1!.id]); + }); + + it('restoreCheckpoint throws CheckpointNotFoundError for an unknown id', async () => { + await expect( + restoreCheckpoint(sql, '00000000-0000-0000-0000-000000000000', { sessionId }), + ).rejects.toBeInstanceOf(CheckpointNotFoundError); + }); + + it('restoreCheckpoint throws when the checkpoint is not in the requested session', async () => { + // A checkpoint whose session_id differs from the route's sessionId. + const [wt] = await sql<{ id: string }[]>`SELECT id FROM worktrees WHERE session_id = ${sessionId} AND status = 'active'`; + const cp = await createCheckpoint(sql, { chatId, sessionId, worktreeId: wt!.id, worktreePath, messageId: null }); + expect(cp).not.toBeNull(); + await expect( + restoreCheckpoint(sql, cp!.id, { sessionId: '11111111-1111-1111-1111-111111111111' }), + ).rejects.toBeInstanceOf(CheckpointNotFoundError); + await sql`DELETE FROM checkpoints WHERE id = ${cp!.id}`; + }); +}); diff --git a/apps/coder/src/services/__tests__/fuzzy-match.test.ts b/apps/coder/src/services/__tests__/fuzzy-match.test.ts new file mode 100644 index 0000000..b25156c --- /dev/null +++ b/apps/coder/src/services/__tests__/fuzzy-match.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest'; +import { locateMatch, SIMILARITY_THRESHOLD } from '../fuzzy-match.js'; + +// Helper: assert a resolved span and slice it back out of the content so the +// test pins the EXACT file text the caller would replace. +function span(result: ReturnType): { start: number; end: number } { + if (result.kind !== 'exact' && result.kind !== 'fuzzy') { + throw new Error(`expected a located span, got ${result.kind}`); + } + return { start: result.start, end: result.end }; +} + +describe('locateMatch — strategy 1: exact', () => { + it('returns an exact unique span', () => { + const content = 'alpha\nbeta\ngamma\n'; + const result = locateMatch(content, 'beta'); + expect(result.kind).toBe('exact'); + const { start, end } = span(result); + expect(content.slice(start, end)).toBe('beta'); + }); + + it('returns the right offsets for a multi-line exact needle', () => { + const content = 'one\ntwo\nthree\nfour\n'; + const needle = 'two\nthree'; + const result = locateMatch(content, needle); + expect(result.kind).toBe('exact'); + const { start, end } = span(result); + expect(content.slice(start, end)).toBe(needle); + }); + + it('refuses when the exact needle occurs more than once', () => { + const content = 'foo\nbar\nfoo\nbar\nfoo\n'; + const result = locateMatch(content, 'foo'); + expect(result).toEqual({ kind: 'ambiguous', count: 3 }); + }); +}); + +describe('locateMatch — strategy 2: per-line whitespace', () => { + it('matches across trailing-whitespace drift at the real span', () => { + // File has trailing spaces the model dropped from a TWO-line copy. A + // single-line needle would be located by exact indexOf (it's a substring), + // so use two lines where line 1's trailing ws breaks an exact substring run. + const content = 'function f() {\n setup(); \n return 1;\n}\n'; + const needle = ' setup();\n return 1;'; // line 1 missing trailing spaces + const result = locateMatch(content, needle); + expect(result.kind).toBe('fuzzy'); + const { start, end } = span(result); + // The returned span covers the ORIGINAL lines including the trailing spaces. + expect(content.slice(start, end)).toBe(' setup(); \n return 1;'); + }); + + it('matches across indentation drift (multi-line block)', () => { + // File indents with 4 spaces; model emitted 2-space indentation. trimEnd + // alone does not normalize LEADING whitespace, so this exercises... actually + // leading-indent drift is a Levenshtein-tier fallback. Here we keep the + // leading indent identical and drift only trailing whitespace per line. + const content = ['if (x) {', ' doThing(); ', ' doOther();', '}'].join('\n'); + const needle = [' doThing();', ' doOther();'].join('\n'); + const result = locateMatch(content, needle); + expect(result.kind).toBe('fuzzy'); + const { start, end } = span(result); + expect(content.slice(start, end)).toBe(' doThing(); \n doOther();'); + }); + + it('ignores leading/trailing blank needle lines', () => { + const content = 'header\nbody line\nfooter\n'; + const needle = '\n\nbody line\n\n'; + const result = locateMatch(content, needle); + expect(result.kind).toBe('fuzzy'); + const { start, end } = span(result); + expect(content.slice(start, end)).toBe('body line'); + }); + + it('reports ambiguous when a whitespace-window matches twice', () => { + // Both line 1 and line 4 differ from the needle only by trailing whitespace, + // so exact indexOf fails (no exact substring) and the whitespace tier finds + // two equivalent windows → ambiguous. + const content = 'x = 1; \ny = 2;\nz = 3;\nx = 1;\t\n'; + const needle = 'x = 1;'; // no trailing ws → not an exact substring of either line + const result = locateMatch(content, needle); + expect(result).toEqual({ kind: 'ambiguous', count: 2 }); + }); +}); + +describe('locateMatch — strategy 3: unicode canonicalization', () => { + it('matches across curly quotes', () => { + const content = "const s = 'hello';\n"; + const needle = 'const s = ‘hello’;'; // ‘hello’ + const result = locateMatch(content, needle); + expect(result.kind).toBe('fuzzy'); + const { start, end } = span(result); + // Span maps back to ORIGINAL (straight-quote) text. + expect(content.slice(start, end)).toBe("const s = 'hello';"); + }); + + it('matches across curly double-quotes', () => { + const content = 'log("done");\n'; + const needle = 'log(“done”);'; // “done” + const result = locateMatch(content, needle); + expect(result.kind).toBe('fuzzy'); + const { start, end } = span(result); + expect(content.slice(start, end)).toBe('log("done");'); + }); + + it('matches across an em-dash drift', () => { + const content = 'range 1-10 inclusive\n'; + const needle = 'range 1—10 inclusive'; // em-dash + const result = locateMatch(content, needle); + expect(result.kind).toBe('fuzzy'); + const { start, end } = span(result); + expect(content.slice(start, end)).toBe('range 1-10 inclusive'); + }); + + it('matches across a non-breaking space drift', () => { + const content = 'a b c\n'; // plain spaces + const needle = 'a b c'; // nbsp between words + const result = locateMatch(content, needle); + expect(result.kind).toBe('fuzzy'); + const { start, end } = span(result); + expect(content.slice(start, end)).toBe('a b c'); + }); +}); + +describe('locateMatch — strategy 4: Levenshtein', () => { + it('matches a >= threshold near-miss (small typo drift)', () => { + // Needle has a one-char typo ('totals' vs 'total') so it is NOT an exact + // substring and the whitespace/canonical tiers (which require equality) both + // miss; Levenshtein similarity stays well above the 0.66 floor. + const content = 'const total = sum + tax;\n'; + const needle = 'const totals = sum + tax;'; + const result = locateMatch(content, needle); + expect(result.kind).toBe('fuzzy'); + const { start, end } = span(result); + // Span maps to the real (correctly-spelled) file line. + expect(content.slice(start, end)).toBe('const total = sum + tax;'); + }); + + it('matches a multi-line block with indentation drift via Levenshtein', () => { + const content = ['function g() {', ' return compute(a, b);', '}'].join('\n'); + // 6-space indent vs file's 2-space; trimEnd does not fix leading indent, so + // this lands on the Levenshtein tier (joined-trim makes it identical → ~1.0). + const needle = [' return compute(a, b);'].join('\n'); + const result = locateMatch(content, needle); + expect(result.kind).toBe('fuzzy'); + const { start, end } = span(result); + expect(content.slice(start, end)).toBe(' return compute(a, b);'); + }); + + it('returns not_found for a below-threshold miss', () => { + const content = 'the quick brown fox jumps over the lazy dog\n'; + const needle = 'completely unrelated string of text here xyz'; + const result = locateMatch(content, needle); + expect(result).toEqual({ kind: 'not_found' }); + }); + + it('returns not_found for a genuinely-absent needle', () => { + const content = 'alpha\nbeta\ngamma\n'; + const needle = 'this content does not exist anywhere at all'; + const result = locateMatch(content, needle); + expect(result).toEqual({ kind: 'not_found' }); + }); +}); + +describe('locateMatch — edge cases', () => { + it('returns not_found for an empty needle', () => { + expect(locateMatch('anything', '')).toEqual({ kind: 'not_found' }); + }); + + it('exposes a sane similarity threshold', () => { + expect(SIMILARITY_THRESHOLD).toBeGreaterThan(0); + expect(SIMILARITY_THRESHOLD).toBeLessThanOrEqual(1); + }); +}); diff --git a/apps/coder/src/services/checkpoints.ts b/apps/coder/src/services/checkpoints.ts new file mode 100644 index 0000000..63f560b --- /dev/null +++ b/apps/coder/src/services/checkpoints.ts @@ -0,0 +1,297 @@ +/** + * write-edit-robustness #4 — worktree checkpoints. + * + * External agents (opencode / goose / qwen / claude) write DIRECTLY into the + * shared session worktree (`/tmp/booworktrees/sess-`); BooCode's own `rewind` + * only reverses `pending_changes` against the project root, so it has zero coverage + * there. A checkpoint is a pre-turn shadow-commit of the worktree tree (tracked + + * untracked) captured WITHOUT touching the real index/working tree, stored in a + * private GC-safe ref. `restoreCheckpoint` rewinds the worktree to that commit, + * trims the transcript from the anchor message forward, and resets the agent + * backend so the next turn re-establishes a fresh context consistent with the + * restored files. + * + * All git goes through hostExec + shellEscape (BooCoder runs on the host; the + * worktrees live on the host fs). Checkpoint CREATION is best-effort: a failure + * logs and returns null — it must NEVER throw into the dispatch turn. + */ +import { randomUUID } from 'node:crypto'; +import type { FastifyBaseLogger } from 'fastify'; +import type { Sql } from '../db.js'; +import { hostExec } from './host-exec.js'; +import { agentPool, OPENCODE_POOL_KEY } from './agent-pool.js'; +import type { AgentSessionHandle } from './agent-backend.js'; + +/** Minimal shell escape for paths/refs (single-quote wrapping). Mirrors worktrees.ts. */ +function shellEscape(s: string): string { + return "'" + s.replace(/'/g, "'\\''") + "'"; +} + +/** + * Pure builder for the shadow-commit command. Captures tracked + untracked files + * in the worktree into a temp index (so the real index/working tree is untouched), + * writes a tree, commits it parented on HEAD, and parks the commit under a private + * ref `refs/boocode/checkpoints/` so git's GC never reclaims it. Prints ONLY + * the resulting SHA on stdout (the trailing `printf '%s'`), so the caller parses + * stdout.trim() directly. + * + * `id` is the row UUID (minted before the ref so the ref name matches the row). + * Both the worktree path and the id are shell-escaped. + */ +export function buildShadowCommitCommand(worktreePath: string, id: string): string { + const wt = shellEscape(worktreePath); + const ref = shellEscape(`refs/boocode/checkpoints/${id}`); + return ( + `cd ${wt} && TMP=$(mktemp) && GIT_INDEX_FILE="$TMP" git read-tree HEAD ` + + `&& GIT_INDEX_FILE="$TMP" git add -A ` + + `&& TREE=$(GIT_INDEX_FILE="$TMP" git write-tree) ` + + `&& SHA=$(git commit-tree "$TREE" -p HEAD -m "boocode checkpoint") ` + + `&& git update-ref ${ref} "$SHA" && rm -f "$TMP" && printf '%s' "$SHA"` + ); +} + +export interface CreateCheckpointArgs { + chatId: string; + sessionId: string | null; + worktreeId: string | null; + worktreePath: string; + messageId: string | null; + label?: string | null; +} + +/** + * Capture a pre-turn checkpoint of the session worktree. Best-effort: returns the + * inserted row's { id, commit_sha } on success, or null on any failure (the turn + * proceeds either way — a missing checkpoint just means no restore point for that + * turn). NEVER throws. + * + * The id is minted up front so the git ref name (`refs/boocode/checkpoints/`) + * matches the DB row id, keeping ref and row in lockstep. + */ +export async function createCheckpoint( + sql: Sql, + args: CreateCheckpointArgs, + opts?: { signal?: AbortSignal; log?: FastifyBaseLogger }, +): Promise<{ id: string; commit_sha: string } | null> { + const id = randomUUID(); + try { + const cmd = buildShadowCommitCommand(args.worktreePath, id); + const res = await hostExec(cmd, { signal: opts?.signal, timeoutMs: 30_000 }); + if (res.exitCode !== 0) { + opts?.log?.warn( + { chatId: args.chatId, worktreePath: args.worktreePath, stderr: res.stderr.trim().slice(0, 500) }, + 'checkpoint: shadow-commit failed (turn proceeds without a checkpoint)', + ); + return null; + } + const commitSha = res.stdout.trim(); + if (!commitSha) { + opts?.log?.warn( + { chatId: args.chatId, worktreePath: args.worktreePath }, + 'checkpoint: shadow-commit produced no SHA (turn proceeds)', + ); + return null; + } + + await sql` + INSERT INTO checkpoints (id, chat_id, session_id, worktree_id, message_id, commit_sha, label) + VALUES (${id}, ${args.chatId}, ${args.sessionId}, ${args.worktreeId}, ${args.messageId}, ${commitSha}, ${args.label ?? null}) + `; + opts?.log?.info({ checkpointId: id, chatId: args.chatId, commitSha }, 'checkpoint: created'); + return { id, commit_sha: commitSha }; + } catch (err) { + opts?.log?.warn( + { chatId: args.chatId, err: err instanceof Error ? err.message : String(err) }, + 'checkpoint: create threw (turn proceeds without a checkpoint)', + ); + return null; + } +} + +/** Error the route maps to a 404 when the checkpoint can't be resolved / scoped. */ +export class CheckpointNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = 'CheckpointNotFoundError'; + } +} + +export interface RestoreCheckpointResult { + checkpoint_id: string; + messages_deleted: number; + worktree_reset: boolean; + backend_reset: boolean; +} + +export interface RestoreCheckpointOpts { + signal?: AbortSignal; + log?: FastifyBaseLogger; + /** If set, the checkpoint MUST belong to this session (route scope guard). */ + sessionId?: string; +} + +interface CheckpointRow { + id: string; + chat_id: string; + session_id: string | null; + worktree_id: string | null; + message_id: string | null; + commit_sha: string; + created_at: Date; +} + +/** + * Restore a checkpoint: rewind its worktree to the shadow commit, trim the + * transcript from the anchor message forward, reset the backend session, and drop + * now-orphaned later checkpoints. Throws CheckpointNotFoundError when the + * checkpoint is missing or not in the requested session (route → 404). + */ +export async function restoreCheckpoint( + sql: Sql, + checkpointId: string, + opts?: RestoreCheckpointOpts, +): Promise { + // 1. Resolve the checkpoint. + const [cp] = await sql` + SELECT id, chat_id, session_id, worktree_id, message_id, commit_sha, created_at + FROM checkpoints WHERE id = ${checkpointId} + `; + if (!cp) { + throw new CheckpointNotFoundError('checkpoint not found'); + } + if (opts?.sessionId && cp.session_id && cp.session_id !== opts.sessionId) { + throw new CheckpointNotFoundError('checkpoint not in session'); + } + + // 2. Resolve the worktree path (by worktree_id, else the session's active one). + let worktreePath: string | null = null; + if (cp.worktree_id) { + const [wt] = await sql<{ path: string }[]>` + SELECT path FROM worktrees WHERE id = ${cp.worktree_id} + `; + worktreePath = wt?.path ?? null; + } + if (!worktreePath) { + const sid = cp.session_id ?? opts?.sessionId ?? null; + if (sid) { + const [wt] = await sql<{ path: string }[]>` + SELECT path FROM worktrees WHERE session_id = ${sid} AND status = 'active' LIMIT 1 + `; + worktreePath = wt?.path ?? null; + } + } + + // 3. Worktree reset — hard-reset to the shadow commit, then clean untracked. + let worktreeReset = false; + if (worktreePath) { + const resetRes = await hostExec( + `git -C ${shellEscape(worktreePath)} reset --hard ${shellEscape(cp.commit_sha)}`, + { signal: opts?.signal, timeoutMs: 30_000 }, + ).catch((err) => { + opts?.log?.warn( + { checkpointId, err: err instanceof Error ? err.message : String(err) }, + 'checkpoint restore: reset --hard threw', + ); + return null; + }); + if (resetRes && resetRes.exitCode === 0) { + const cleanRes = await hostExec( + `git -C ${shellEscape(worktreePath)} clean -fd`, + { signal: opts?.signal, timeoutMs: 30_000 }, + ).catch(() => null); + worktreeReset = cleanRes != null && cleanRes.exitCode === 0; + if (!worktreeReset) { + opts?.log?.warn({ checkpointId, worktreePath }, 'checkpoint restore: clean -fd did not succeed'); + } + } else { + opts?.log?.warn( + { checkpointId, worktreePath, stderr: resetRes?.stderr?.trim()?.slice(0, 500) }, + 'checkpoint restore: reset --hard did not succeed', + ); + } + } else { + opts?.log?.warn({ checkpointId }, 'checkpoint restore: no worktree path resolved (files not reset)'); + } + + // 4. Trim the transcript from the anchor message forward. message_parts FK to + // messages is ON DELETE CASCADE (apps/server schema.sql:49), so parts are + // removed with their messages — no explicit parts delete needed. + let messagesDeleted = 0; + if (cp.message_id) { + const deleted = await sql<{ id: string }[]>` + DELETE FROM messages + WHERE chat_id = ${cp.chat_id} + AND created_at >= (SELECT created_at FROM messages WHERE id = ${cp.message_id}) + RETURNING id + `; + messagesDeleted = deleted.length; + } + + // 5. Backend reset — mark the chat's agent sessions crashed so the next turn + // re-establishes a fresh backend, and evict the live pool session(s) for this + // (chat, agent). Warm backends hold context server-side with no partial + // rewind, so a full reset is the only consistent option (proposal §4). + const agentRows = await sql<{ agent: string; backend: string; agent_session_id: string | null; session_id: string | null; worktree_id: string | null }[]>` + SELECT agent, backend, agent_session_id, session_id, worktree_id + FROM agent_sessions WHERE chat_id = ${cp.chat_id} + `; + await sql` + UPDATE agent_sessions SET status = 'crashed' WHERE chat_id = ${cp.chat_id} + `.catch(() => {}); + + let backendReset = false; + try { + // opencode runs on the SHARED server (keyed on a sentinel, not the chat) — close + // just this chat's session(s) on it, mirroring the lifecycle close-hook. + const ocBackend = agentPool.peek(OPENCODE_POOL_KEY, 'opencode'); + if (ocBackend) { + for (const row of agentRows) { + if (row.backend !== 'opencode_server' || !row.agent_session_id) continue; + const handle: AgentSessionHandle = { + sessionId: row.session_id ?? '', + agent: row.agent, + backend: 'opencode_server', + chatId: cp.chat_id, + worktreeId: row.worktree_id ?? '', + agentSessionId: row.agent_session_id, + serverPort: null, + }; + await ocBackend.closeSession(handle).catch((err) => { + opts?.log?.warn( + { checkpointId, err: err instanceof Error ? err.message : String(err) }, + 'checkpoint restore: opencode closeSession threw', + ); + }); + } + } + // Warm-ACP backends are pooled under the chat id — dispose them (kills the + // goose/qwen child). closeChat skips busy backends (a live turn isn't torn down). + const disposed = await agentPool.closeChat(cp.chat_id); + backendReset = true; + opts?.log?.info({ checkpointId, chatId: cp.chat_id, disposed }, 'checkpoint restore: backend reset'); + } catch (err) { + opts?.log?.warn( + { checkpointId, err: err instanceof Error ? err.message : String(err) }, + 'checkpoint restore: backend reset threw', + ); + } + + // 6. Drop now-orphaned later checkpoints for this chat (their anchor messages were + // just trimmed). Compare `created_at` SERVER-SIDE via a subquery (NOT the JS + // Date round-trip, which truncates the stored microsecond precision to ms and + // would make this checkpoint delete ITSELF), and exclude this checkpoint's own + // id so it always survives — letting the user re-restore to it. + await sql` + DELETE FROM checkpoints + WHERE chat_id = ${cp.chat_id} + AND id <> ${cp.id} + AND created_at > (SELECT created_at FROM checkpoints WHERE id = ${cp.id}) + `.catch(() => {}); + + return { + checkpoint_id: checkpointId, + messages_deleted: messagesDeleted, + worktree_reset: worktreeReset, + backend_reset: backendReset, + }; +} diff --git a/apps/coder/src/services/dispatcher.ts b/apps/coder/src/services/dispatcher.ts index 8913142..9b194ac 100644 --- a/apps/coder/src/services/dispatcher.ts +++ b/apps/coder/src/services/dispatcher.ts @@ -4,6 +4,7 @@ import type { Broker } from '@boocode/server/broker'; import type { WsFrame } from '@boocode/server/ws-frames'; import type { Config } from '../config.js'; import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js'; +import { createCheckpoint } from './checkpoints.js'; import { makeDcpStreamStripper } from './dcp-strip.js'; import { dispatchViaAcp } from './acp-dispatch.js'; import { getResolvedRegistry } from './provider-config-registry.js'; @@ -358,6 +359,16 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise null); + broker.publishFrame(sessionId, { type: 'message_started', message_id: assistantId, @@ -617,6 +628,15 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise null); + broker.publishFrame(sessionId, { type: 'message_started', message_id: assistantId, @@ -876,6 +896,15 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise null); + broker.publishFrame(sessionId, { type: 'message_started', message_id: assistantId, diff --git a/apps/coder/src/services/fuzzy-match.ts b/apps/coder/src/services/fuzzy-match.ts new file mode 100644 index 0000000..e6b47c3 --- /dev/null +++ b/apps/coder/src/services/fuzzy-match.ts @@ -0,0 +1,271 @@ +// Fuzzy patch locator for staged edits. +// +// Local quantized models (qwen3.6 and friends) frequently reproduce an +// `old_string` with small, semantically-irrelevant drift: trailing whitespace, +// a different indent width, or "smart" unicode punctuation (curly quotes, an +// en/em-dash, a non-breaking space) where the source has the plain ASCII form. +// An exact `String.includes` then fails and the queued edit is lost even though +// a human would say it obviously matches. +// +// `locateMatch` walks a ladder of progressively looser strategies and returns +// the real `[start, end)` byte-offset span in the ORIGINAL content so the caller +// can splice in `new_string` over the true file text (preserving the file's own +// whitespace/unicode, not the model's drifted copy). The ladder stops at the +// first strategy that resolves to a single span: +// +// 1. exact — indexOf; >1 hit is reported `ambiguous` (we refuse to +// guess which occurrence the model meant). +// 2. per-line ws — line-window compare ignoring per-line trailing +// whitespace and leading/trailing blank needle lines. +// 3. unicode canon — same line-window compare after folding smart +// punctuation to ASCII on both sides; the match is +// mapped back to original offsets. +// 4. levenshtein — best line-window by normalized edit-distance +// similarity; accepted only at >= SIMILARITY_THRESHOLD. +// +// Pure and dependency-free (Levenshtein is the standard iterative two-row DP), +// reimplemented from the general technique — no vendored source. + +export type MatchResult = + | { kind: 'exact' | 'fuzzy'; start: number; end: number } // [start,end) offsets into content + | { kind: 'ambiguous'; count: number } + | { kind: 'not_found' }; + +/** Levenshtein similarity floor for the final fuzzy fallback (strategy 4). */ +export const SIMILARITY_THRESHOLD = 0.66; + +export function locateMatch(content: string, needle: string): MatchResult { + // Empty needle has no meaningful match. + if (needle.length === 0) return { kind: 'not_found' }; + + // --- 1. Exact ---------------------------------------------------------------- + const exact = locateExact(content, needle); + if (exact) return exact; + + // --- 2. Per-line whitespace-insensitive ------------------------------------- + const ws = locateByLineWindow(content, needle); + if (ws) return ws; + + // --- 3. Unicode-canonicalized whitespace pass ------------------------------- + const canon = locateCanonical(content, needle); + if (canon) return canon; + + // --- 4. Levenshtein similarity ---------------------------------------------- + const lev = locateByLevenshtein(content, needle); + if (lev) return lev; + + return { kind: 'not_found' }; +} + +// --- Strategy 1: exact ------------------------------------------------------- + +function locateExact(content: string, needle: string): MatchResult | null { + const first = content.indexOf(needle); + if (first === -1) return null; + const second = content.indexOf(needle, first + 1); + if (second === -1) { + return { kind: 'exact', start: first, end: first + needle.length }; + } + // Count all occurrences so the caller can report a useful number. + let count = 2; + let idx = content.indexOf(needle, second + 1); + while (idx !== -1) { + count++; + idx = content.indexOf(needle, idx + 1); + } + return { kind: 'ambiguous', count }; +} + +// --- Line-window machinery --------------------------------------------------- + +interface Line { + /** Raw line text (no trailing newline). */ + text: string; + /** Offset of the first char of this line in the original content. */ + start: number; + /** Offset one past the last char of this line (before its newline, if any). */ + end: number; +} + +/** + * Split content into lines, tracking each line's real offset span. The span + * EXCLUDES the trailing newline so consecutive line spans plus their newlines + * exactly reconstruct the content; the match span we hand back covers from the + * first matched line's start through the last matched line's end (i.e. without a + * trailing newline), which is what an in-place splice wants. + */ +function splitLines(content: string): Line[] { + const lines: Line[] = []; + let start = 0; + for (let i = 0; i <= content.length; i++) { + if (i === content.length || content[i] === '\n') { + lines.push({ text: content.slice(start, i), start, end: i }); + start = i + 1; + } + } + return lines; +} + +/** Strip leading/trailing all-blank lines; returns the trimmed slice. */ +function trimBlankLines(lines: string[]): string[] { + let lo = 0; + let hi = lines.length; + while (lo < hi && lines[lo]!.trim() === '') lo++; + while (hi > lo && lines[hi - 1]!.trim() === '') hi--; + return lines.slice(lo, hi); +} + +/** + * Find a contiguous window of content lines whose trailing-whitespace-trimmed + * text equals the needle's (blank-trimmed) lines. Returns the real offset span + * over the matched content lines, or null if zero match. Multiple matches → + * ambiguous. `normalize` lets the caller fold unicode before comparing. + */ +function locateByLineWindow( + content: string, + needle: string, + normalize: (s: string) => string = (s) => s, +): MatchResult | null { + const contentLines = splitLines(content); + const needleLines = trimBlankLines(needle.split('\n')); + const n = needleLines.length; + if (n === 0) return null; + // A single needle line that is itself blank can't be located meaningfully. + if (n === 1 && needleLines[0]!.trim() === '') return null; + + const needleKey = needleLines.map((l) => normalize(l.trimEnd())).join('\n'); + + const hits: Array<{ start: number; end: number }> = []; + for (let i = 0; i + n <= contentLines.length; i++) { + const windowKey = contentLines + .slice(i, i + n) + .map((l) => normalize(l.text.trimEnd())) + .join('\n'); + if (windowKey === needleKey) { + hits.push({ start: contentLines[i]!.start, end: contentLines[i + n - 1]!.end }); + } + } + + if (hits.length === 0) return null; + if (hits.length > 1) return { kind: 'ambiguous', count: hits.length }; + return { kind: 'fuzzy', start: hits[0]!.start, end: hits[0]!.end }; +} + +// --- Strategy 3: unicode canonicalization ------------------------------------ + +/** + * Fold smart punctuation to its ASCII equivalent. Crucially this is a + * length-PRESERVING, per-character map (every replacement is one char → one + * char), so an offset into the canonical string is also a valid offset into the + * original — letting strategy 3 reuse the line-window matcher and still hand + * back true original-content offsets. + */ +function canonicalizeChar(ch: string): string { + switch (ch) { + // single quotes / apostrophes + case '‘': // ' + case '’': // ' + case '‚': // ‚ + case '‛': // ‛ + return "'"; + // double quotes + case '“': // " + case '”': // " + case '„': // „ + case '‟': // ‟ + return '"'; + // dashes + case '–': // – en dash + case '—': // — em dash + case '‒': // ‒ figure dash + case '―': // ― horizontal bar + case '−': // − minus sign + return '-'; + // spaces + case ' ': // nbsp + case ' ': // figure space + case ' ': // narrow nbsp + return ' '; + default: + return ch; + } +} + +function canonicalize(s: string): string { + let out = ''; + for (const ch of s) out += canonicalizeChar(ch); + return out; +} + +function locateCanonical(content: string, needle: string): MatchResult | null { + // Only worth running if canonicalization actually changes something on either + // side — otherwise it's identical to strategy 2 which already failed. + const canonContent = canonicalize(content); + const canonNeedle = canonicalize(needle); + if (canonContent === content && canonNeedle === needle) return null; + // Offsets are preserved (length-preserving fold), so a match on the canonical + // content maps directly back to the original. + return locateByLineWindow(canonContent, canonNeedle); +} + +// --- Strategy 4: Levenshtein similarity -------------------------------------- + +/** Standard iterative two-row Levenshtein edit distance. */ +function levenshtein(a: string, b: string): number { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + + let prev = new Array(b.length + 1); + let curr = new Array(b.length + 1); + for (let j = 0; j <= b.length; j++) prev[j] = j; + + for (let i = 1; i <= a.length; i++) { + curr[0] = i; + const ac = a.charCodeAt(i - 1); + for (let j = 1; j <= b.length; j++) { + const cost = ac === b.charCodeAt(j - 1) ? 0 : 1; + curr[j] = Math.min( + prev[j]! + 1, // deletion + curr[j - 1]! + 1, // insertion + prev[j - 1]! + cost, // substitution + ); + } + [prev, curr] = [curr, prev]; + } + return prev[b.length]!; +} + +/** Normalized similarity in [0,1]: 1 - dist / max(len). */ +function similarity(a: string, b: string): number { + const maxLen = Math.max(a.length, b.length); + if (maxLen === 0) return 1; + return 1 - levenshtein(a, b) / maxLen; +} + +function locateByLevenshtein(content: string, needle: string): MatchResult | null { + const contentLines = splitLines(content); + const needleLines = trimBlankLines(needle.split('\n')); + const n = needleLines.length; + if (n === 0) return null; + if (contentLines.length < n) return null; + + const needleJoined = needleLines.map((l) => l.trim()).join('\n'); + + let best = -1; + let bestSpan: { start: number; end: number } | null = null; + for (let i = 0; i + n <= contentLines.length; i++) { + const window = contentLines.slice(i, i + n); + const windowJoined = window.map((l) => l.text.trim()).join('\n'); + const score = similarity(windowJoined, needleJoined); + if (score > best) { + best = score; + bestSpan = { start: window[0]!.start, end: window[n - 1]!.end }; + } + } + + if (bestSpan && best >= SIMILARITY_THRESHOLD) { + return { kind: 'fuzzy', start: bestSpan.start, end: bestSpan.end }; + } + return null; +} diff --git a/apps/coder/src/services/pending_changes.ts b/apps/coder/src/services/pending_changes.ts index ae08c81..7c6b784 100644 --- a/apps/coder/src/services/pending_changes.ts +++ b/apps/coder/src/services/pending_changes.ts @@ -2,6 +2,7 @@ import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises'; import { dirname } from 'node:path'; import type { Sql } from '../db.js'; import { resolveWritePath } from './write_guard.js'; +import { locateMatch } from './fuzzy-match.js'; // --- Types ------------------------------------------------------------------- @@ -121,10 +122,18 @@ export async function applyOne( case 'edit': { const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string }; const content = await readFile(change.file_path, 'utf8'); - if (!content.includes(oldStr)) { - throw new Error('old_string not found in file — file may have changed since the edit was queued'); + const match = locateMatch(content, oldStr); + if (match.kind === 'ambiguous') { + throw new Error( + `old_string matches ${match.count} locations — add surrounding context to disambiguate`, + ); } - const updated = content.replace(oldStr, newStr); + if (match.kind === 'not_found') { + throw new Error( + 'old_string not found in file (even fuzzily) — file may have changed since the edit was queued', + ); + } + const updated = content.slice(0, match.start) + newStr + content.slice(match.end); await writeFile(change.file_path, updated, 'utf8'); break; } @@ -203,10 +212,18 @@ export async function rewindOne( // Reverse an edit: swap old and new const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string }; const content = await readFile(change.file_path, 'utf8'); - if (!content.includes(newStr)) { - throw new Error('new_string not found in file — cannot rewind; file may have been modified since apply'); + const match = locateMatch(content, newStr); + if (match.kind === 'ambiguous') { + throw new Error( + `new_string matches ${match.count} locations — cannot rewind; add surrounding context to disambiguate`, + ); } - const reverted = content.replace(newStr, oldStr); + if (match.kind === 'not_found') { + throw new Error( + 'new_string not found in file (even fuzzily) — cannot rewind; file may have been modified since apply', + ); + } + const reverted = content.slice(0, match.start) + oldStr + content.slice(match.end); await writeFile(change.file_path, reverted, 'utf8'); break; } diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index da3dff8..26139c4 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -36,6 +36,24 @@ export interface AgentSessionInfo { last_active_at: string | null; } +// write-edit-robustness #4: a pre-turn worktree snapshot anchored to an +// assistant message. Returned by GET .../checkpoints; drives the per-message +// "Restore to here" affordance in CoderMessageList. +export interface CoderCheckpoint { + id: string; + message_id: string; + created_at: string; + label: string | null; +} + +// write-edit-robustness #4: result of POST .../checkpoints/:id/restore. +export interface CoderRestoreResult { + checkpoint_id: string; + messages_deleted: number; + worktree_reset: boolean; + backend_reset: boolean; +} + export class ApiError extends Error { constructor( public status: number, @@ -407,6 +425,22 @@ export const api = { ...(config?.thinking_option_id ? { thinking_option_id: config.thinking_option_id } : {}), }), }), + // write-edit-robustness #4: worktree checkpoints. List which assistant + // messages in a chat have a pre-turn worktree snapshot ("Restore to here" + // is offered only on those). Proxied to boocoder. + getCheckpoints: (sessionId: string, chatId: string) => + request<{ checkpoints: CoderCheckpoint[] }>( + `/api/coder/sessions/${sessionId}/checkpoints?chat_id=${encodeURIComponent(chatId)}`, + ), + // write-edit-robustness #4: reset the worktree to a checkpoint, trim the + // transcript past its anchor message, and reset the agent backend. After it + // returns, the caller refetches messages (+ checkpoints) so the trimmed + // transcript shows. + restoreCheckpoint: (sessionId: string, checkpointId: string) => + request( + `/api/coder/sessions/${sessionId}/checkpoints/${encodeURIComponent(checkpointId)}/restore`, + { method: 'POST' }, + ), // Queue a new-file create from the RightRail browser → BooCoder // pending_changes (operation='create'). Surfaces in the CoderPane DiffPanel // for explicit apply. A WriteGuardError comes back as a 422 whose { error } diff --git a/apps/web/src/components/MessageBubble.tsx b/apps/web/src/components/MessageBubble.tsx index ece3489..b499826 100644 --- a/apps/web/src/components/MessageBubble.tsx +++ b/apps/web/src/components/MessageBubble.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import type { ReactNode } from 'react'; -import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, Brain } from 'lucide-react'; +import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, Brain, History } from 'lucide-react'; import { toast } from 'sonner'; import type { Chat, ErrorReason, Message } from '@/api/types'; import { api } from '@/api/client'; @@ -110,6 +110,10 @@ export interface MessageActions { onResend?: (chatId: string, content: string) => Promise; onFork?: (chatId: string, messageId: string) => Promise; onDelete?: (chatId: string, messageId: string) => Promise; + // write-edit-robustness #4 (BooCoder only): reset the worktree to this + // message's pre-turn checkpoint and trim the transcript past it. BooChat + // passes no such callback → the "Restore to here" control never renders. + onRestoreCheckpoint?: (chatId: string, messageId: string) => Promise; } interface Props { @@ -119,6 +123,17 @@ interface Props { actions?: MessageActions; /** Hide actions that don't apply (fork, delete). */ hideActions?: ('fork' | 'delete')[]; + /** + * write-edit-robustness #4: this assistant message has a worktree checkpoint + * → render "Restore to here" (only when `actions.onRestoreCheckpoint` is also + * provided). CoderMessageList sets this from the checkpoint set. + */ + hasCheckpoint?: boolean; + /** + * write-edit-robustness #4: suppress the restore control during an active + * turn (mirrors composer gating). Defaults to enabled. + */ + restoreDisabled?: boolean; } function StatsLine({ message }: { message: Message }) { @@ -155,16 +170,22 @@ function ActionRow({ message, actions, hiddenSet, + hasCheckpoint = false, + restoreDisabled = false, }: { message: Message; actions?: MessageActions; hiddenSet: Set; + hasCheckpoint?: boolean; + restoreDisabled?: boolean; }) { const [justCopied, setJustCopied] = useState(false); const [regenerating, setRegenerating] = useState(false); const [forking, setForking] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [deleting, setDeleting] = useState(false); + const [restoreOpen, setRestoreOpen] = useState(false); + const [restoring, setRestoring] = useState(false); async function copy() { try { @@ -240,12 +261,33 @@ function ActionRow({ } } + async function confirmRestore() { + if (restoring || !actions?.onRestoreCheckpoint) return; + setRestoring(true); + try { + await actions.onRestoreCheckpoint(message.chat_id, message.id); + setRestoreOpen(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'restore failed'); + } finally { + setRestoring(false); + } + } + const isAssistant = message.role === 'assistant'; const isUser = message.role === 'user'; const canRegen = isAssistant && message.status !== 'streaming'; const canResend = isUser && message.status === 'complete' && !!message.content?.trim(); const canFork = message.status === 'complete'; const canDelete = message.status !== 'streaming'; + // write-edit-robustness #4: show "Restore to here" only for a completed + // assistant message that has a checkpoint AND when the coder wired the + // callback. Disabled (but visible) during an active turn. + const canRestore = + isAssistant && + hasCheckpoint && + message.status === 'complete' && + !!actions?.onRestoreCheckpoint; return ( <> @@ -306,6 +348,18 @@ function ActionRow({ )} + {canRestore && ( + + )} + { + if (!restoring) setRestoreOpen(open); + }} + > + + + Restore to this point? + + This resets the worktree to before this turn, removes every later + message in this chat, and resets the agent's session. This cannot + be undone. + + + + + + + + ); } @@ -550,7 +637,15 @@ function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean ); } -export function MessageBubble({ message, sessionChats, capHitInfo, actions, hideActions }: Props) { +export function MessageBubble({ + message, + sessionChats, + capHitInfo, + actions, + hideActions, + hasCheckpoint, + restoreDisabled, +}: Props) { const hiddenSet = new Set(hideActions ?? []); // v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact' // branch because summary=true never coexists with kind='compact' (new @@ -652,7 +747,15 @@ export function MessageBubble({ message, sessionChats, capHitInfo, actions, hide )} {!isStreaming && } - {!isStreaming && hasContent && } + {!isStreaming && hasContent && ( + + )} ); } diff --git a/apps/web/src/components/panes/CoderMessageList.tsx b/apps/web/src/components/panes/CoderMessageList.tsx index b783dc4..1136fc9 100644 --- a/apps/web/src/components/panes/CoderMessageList.tsx +++ b/apps/web/src/components/panes/CoderMessageList.tsx @@ -147,11 +147,24 @@ interface Props { chatId?: string; footer?: ReactNode; actions?: MessageActions; + // write-edit-robustness #4: assistant message ids that have a worktree + // checkpoint. The "Restore to here" control renders only on these. + checkpointMessageIds?: Set; + // write-edit-robustness #4: suppress restore during an active turn (mirrors + // composer gating in CoderPane). + restoreDisabled?: boolean; } const CODER_HIDDEN_ACTIONS: ('fork' | 'delete')[] = ['fork']; -export function CoderMessageList({ messages, chatId, footer, actions }: Props) { +export function CoderMessageList({ + messages, + chatId, + footer, + actions, + checkpointMessageIds, + restoreDisabled, +}: Props) { const endRef = useRef(null); const scrollRef = useRef(null); const isNearBottomRef = useRef(true); @@ -189,6 +202,8 @@ export function CoderMessageList({ messages, chatId, footer, actions }: Props) { message={item.message as unknown as Message} actions={actions} hideActions={CODER_HIDDEN_ACTIONS} + hasCheckpoint={checkpointMessageIds?.has(item.message.id) ?? false} + restoreDisabled={restoreDisabled} /> ); } diff --git a/apps/web/src/components/panes/CoderPane.tsx b/apps/web/src/components/panes/CoderPane.tsx index dfdff48..1d7f4cd 100644 --- a/apps/web/src/components/panes/CoderPane.tsx +++ b/apps/web/src/components/panes/CoderPane.tsx @@ -381,6 +381,29 @@ function usePendingChanges(sessionId: string) { return { changes, loading, refresh, approve, reject }; } +// write-edit-robustness #4: which assistant messages in this chat have a +// worktree checkpoint, so CoderMessageList can offer "Restore to here" only on +// those. Refetched on message_complete (same trigger as pending changes) and +// after a successful restore. +function useCheckpoints(sessionId: string, chatId: string | undefined) { + const [messageIds, setMessageIds] = useState>(() => new Set()); + + const refresh = useCallback(() => { + if (!chatId) { + setMessageIds(new Set()); + return Promise.resolve(); + } + return api.coder + .getCheckpoints(sessionId, chatId) + .then((res) => setMessageIds(new Set(res.checkpoints.map((c) => c.message_id)))) + .catch(() => {/* boocoder may be down / endpoint not ready */}); + }, [sessionId, chatId]); + + useEffect(() => { void refresh(); }, [refresh]); + + return { checkpointMessageIds: messageIds, refreshCheckpoints: refresh }; +} + // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- @@ -640,6 +663,7 @@ export function CoderPane({ }, }); const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId); + const { checkpointMessageIds, refreshCheckpoints } = useCheckpoints(sessionId, chatId); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); const [queue, setQueue] = useState([]); @@ -652,15 +676,18 @@ export function CoderPane({ // Refresh pending changes (and agent-session state for the §9b chip) when a // message_complete arrives — same trigger usePendingChanges already uses. + // write-edit-robustness #4: also refetch checkpoints so a new turn's snapshot + // surfaces its "Restore to here" control. useEffect(() => { const lastAssistant = [...messages].reverse().find( (m): m is CoderMessage => m.role === 'assistant', ); if (lastAssistant?.status === 'complete') { refresh(); + void refreshCheckpoints(); void refreshAgentSessions(sessionId); } - }, [messages, refresh, sessionId]); + }, [messages, refresh, refreshCheckpoints, sessionId]); // The §9b chip only shows once the chat has ≥1 prior turn (a completed // assistant message). Hidden on a brand-new chat. @@ -867,6 +894,38 @@ export function CoderPane({ } }, [activeTaskId]); + // write-edit-robustness #4: reset the worktree to a message's checkpoint and + // trim the transcript past it. The confirm lives in MessageBubble's ActionRow + // (plain Cancel/Restore). The restore route is keyed by checkpoint id, so we + // resolve message→checkpoint via a fresh GET (cheap, and avoids a stale id if + // the set changed). On success, refetch messages so the trimmed transcript + // shows, plus checkpoints (later ones were deleted server-side) and pending + // changes (the worktree was reset). + const handleRestoreCheckpoint = useCallback(async (_chatId: string, messageId: string) => { + if (!chatId || generating) return; + let checkpointId: string | undefined; + try { + const res = await api.coder.getCheckpoints(sessionId, chatId); + checkpointId = res.checkpoints.find((c) => c.message_id === messageId)?.id; + } catch (err) { + toast.error(err instanceof Error ? err.message : 'failed to load checkpoint'); + return; + } + if (!checkpointId) { + toast.error('No checkpoint found for this message'); + return; + } + try { + await api.coder.restoreCheckpoint(sessionId, checkpointId); + await loadMessages(); + await refreshCheckpoints(); + refresh(); + toast.success('Restored to checkpoint'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'restore failed'); + } + }, [chatId, generating, sessionId, loadMessages, refreshCheckpoints, refresh]); + const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => { if (!chatId) return; // Only BooCoder skills route here; an agent's own commands (not skills) fall @@ -921,8 +980,11 @@ export function CoderPane({ { await sendOneMessage(content); }, + onRestoreCheckpoint: handleRestoreCheckpoint, }} footer={ activeTaskId && !permissionPrompt && sending === false ? ( diff --git a/openspec/changes/write-edit-robustness/proposal.md b/openspec/changes/write-edit-robustness/proposal.md new file mode 100644 index 0000000..fc2f188 --- /dev/null +++ b/openspec/changes/write-edit-robustness/proposal.md @@ -0,0 +1,101 @@ +# Write/edit robustness — fuzzy patch applier + worktree checkpoints + +**Status:** in progress (started 2026-06-01) +**Source:** `boocode_code_review_v2.md` §1 #3 + #4, §5b/§5d–5e (cline, Apache-2.0 — algorithm clean-reimplemented, not vendored). + +Two independent BooCoder hardening features for local quantized models. + +## #3 — Fuzzy patch applier + +**Problem:** `applyOne`'s edit case (`apps/coder/src/services/pending_changes.ts:124`) does exact +`content.includes(oldStr)` → throw, then `content.replace(oldStr, newStr)` (first occurrence). +`rewindOne` (line 206) is the same. Local models (qwen3.6) drift `old_string` by whitespace/ +indentation/unicode (curly quotes, en/em-dash, nbsp), so a valid edit fails at apply with +"old_string not found" and is lost. + +**Design:** new pure module `apps/coder/src/services/fuzzy-match.ts`: +`locateMatch(content: string, needle: string): { kind: 'exact'|'fuzzy'; start: number; end: number } +| { kind: 'ambiguous'; count: number } | { kind: 'not_found' }`. Match ladder: +1. **Exact** `indexOf`. If exactly one → exact span. If >1 → **ambiguous** (refuse; decision + 2026-06-01: safer than silently editing the first). +2. **Per-line whitespace-insensitive** — compare `needle` lines to file line-windows ignoring per-line + `trimEnd`/leading-trailing blank lines. +3. **Unicode canonicalization** — normalize curly→straight quotes, en/em-dash→`-`, nbsp→space on both + sides, then retry the whitespace pass. +4. **Levenshtein** similarity ≥ 0.66 over line-windows sized to `needle`'s line count; best window wins. + +Non-exact (fuzzy) matches return the actual file span so the caller replaces the real file text with +`new_string`. `pending_changes.ts` `applyOne`/`rewindOne` use `locateMatch`; `ambiguous`/`not_found` +return `success:false` with a clear message (no throw escaping the existing catch). Unit-tested +(`apps/coder/src/services/__tests__/fuzzy-match.test.ts`), per the `turn-guard.ts` pure-helper pattern. + +## #4 — Worktree checkpoint + conversation-trim + +**Problem:** `rewind` only reverses BooCode's own `pending_changes` (applied to the project root). +External agents (opencode/goose/qwen/claude) write **directly into the session worktree** +(`/tmp/booworktrees/sess-`); rewind has zero coverage there. + +**Schema** (`apps/coder/src/schema.sql`): +```sql +CREATE TABLE IF NOT EXISTS checkpoints ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE, + session_id UUID, + worktree_id UUID REFERENCES worktrees(id) ON DELETE SET NULL, + message_id UUID, -- anchor: the assistant turn row this checkpoint precedes + commit_sha TEXT NOT NULL, -- shadow-commit capturing the pre-turn worktree tree + label TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() +); +CREATE INDEX IF NOT EXISTS checkpoints_chat_created_idx ON checkpoints(chat_id, created_at); +``` + +**Create** (`apps/coder/src/services/checkpoints.ts` → `createCheckpoint`): hooked into the three +external-agent dispatch paths in `dispatcher.ts` (`runWarmAcpTask` ~821, `runOpenCodeServerTask` ~513, +`runExternalAgent` ~255) — after `ensureSessionWorktree()` and the assistant-message insert (so the +anchor `message_id` exists), before the backend runs. Snapshot captures tracked **+ untracked** via a +temp-index shadow commit, stored in a private GC-safe ref: +``` +cd && TMP=$(mktemp) && GIT_INDEX_FILE="$TMP" git read-tree HEAD \ + && GIT_INDEX_FILE="$TMP" git add -A \ + && TREE=$(GIT_INDEX_FILE="$TMP" git write-tree) \ + && SHA=$(git commit-tree "$TREE" -p HEAD -m "boocode checkpoint") \ + && git update-ref refs/boocode/checkpoints/ "$SHA" && rm -f "$TMP" && echo "$SHA" +``` +Best-effort: a checkpoint failure logs and never breaks the turn. Native-boocode turns (project-root, +rewind-covered) get no checkpoint. + +**Restore** (`POST /api/sessions/:sessionId/checkpoints/:checkpointId/restore`, proxied `/api/coder/*`): +1. Resolve + validate the checkpoint belongs to the session. +2. Reset worktree: `git -C reset --hard && git -C clean -fd` (hostExec+shellEscape). +3. Trim transcript: `DELETE FROM messages WHERE chat_id = AND created_at >= + (SELECT created_at FROM messages WHERE id = )` (+ explicit `message_parts` delete if + the FK isn't ON DELETE CASCADE — verify). +4. Reset backend (decision 2026-06-01): `UPDATE agent_sessions SET status='crashed' WHERE + chat_id=` and evict the live pool session for `(chat,agent)` if present, so the next turn + re-establishes a fresh backend — transcript, files, and agent context all consistent at the restore + point. (Warm backends hold context server-side; no partial rewind exists.) +5. Delete now-orphaned later checkpoints: `DELETE FROM checkpoints WHERE chat_id=? AND created_at > + `. +6. Return `{ checkpoint_id, messages_deleted, worktree_reset, backend_reset }`. + +**Frontend:** per-message "Restore to here" in `CoderMessageList.tsx` (via a new optional +`onRestoreCheckpoint?(chatId, messageId)` on `MessageActions` in `MessageBubble.tsx`), wired in +`CoderPane.tsx`; guarded to `status==='complete'` and to messages that have a checkpoint. After the call +returns, refetch the chat's messages (existing GET) — no new WS frame required. + +## Decisions (2026-06-01) +- Multi-exact-match → **refuse as ambiguous** (#3). +- #4 **full** scope incl. conversation-trim. +- Restore **resets** the external-agent backend session (context re-established fresh). + +## Parallelization +- **Unit 1 (#3)** — fully independent (`fuzzy-match.ts` + `pending_changes.ts` + test). +- **Unit 2 (#4 backend)** — schema + `checkpoints.ts` (create+restore) + 3 dispatcher hooks + restore route + backend reset. One agent owns all #4 coder backend (shared `checkpoints.ts`). +- **Unit 3 (#4 frontend)** — `CoderMessageList`/`MessageBubble`/`CoderPane`, against the pinned restore contract. Parallel with Unit 2. MUST NOT touch Sam's uncommitted WIP (`ChatTabBar`, `SessionLandingPage`, `Workspace`, `useWorkspacePanes`, `PaneHeaderActions`). + +## Verify +- `pnpm -C apps/coder test` (incl. new `fuzzy-match` + any checkpoint pure-helper tests) +- `pnpm -C apps/server build` then `pnpm -C apps/coder build` +- `npx tsc -p apps/web/tsconfig.app.json --noEmit` +- Live smoke (manual, host): external-agent edit → checkpoint row; "Restore to here" → worktree reset + transcript trimmed + next turn fresh.