feat: write/edit robustness — fuzzy patch applier + worktree checkpoints (v2.7.1)
#3 Fuzzy patch applier: new pure fuzzy-match.ts (locateMatch, exact→trim→ unicode-canon→Levenshtein≥0.66, refuse-on-ambiguous) wired into pending_changes applyOne/rewindOne so local-model whitespace/unicode drift in old_string no longer loses the edit. #4 Worktree checkpoint + conversation-trim: checkpoints table + checkpoints.ts (shadow-commit of tracked+untracked into refs/boocode/checkpoints, hooked into the 3 external-agent dispatcher paths) + POST restore route (reset --hard + clean -fd -> transcript trim -> backend-session reset) + "Restore to here" UI. Built by 3 parallel agents; DB-integration testing caught a created_at self-deletion bug. Coder suite 234 passing; server+coder build + web tsc clean. Builds on v2.7.0-mit. openspec write-edit-robustness. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
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/<id>`) 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
|
## 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 `<br>`, and `<ol start>` 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.
|
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 `<br>`, and `<ol start>` 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.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { setInferenceContext, clearInferenceContext } from './services/tools/inf
|
|||||||
import { registerMessageRoutes } from './routes/messages.js';
|
import { registerMessageRoutes } from './routes/messages.js';
|
||||||
import { registerSkillRoutes } from './routes/skills.js';
|
import { registerSkillRoutes } from './routes/skills.js';
|
||||||
import { registerPendingRoutes } from './routes/pending.js';
|
import { registerPendingRoutes } from './routes/pending.js';
|
||||||
|
import { registerCheckpointRoutes } from './routes/checkpoints.js';
|
||||||
import { registerAgentSessionRoutes } from './routes/agent-sessions.js';
|
import { registerAgentSessionRoutes } from './routes/agent-sessions.js';
|
||||||
import { registerTaskRoutes } from './routes/tasks.js';
|
import { registerTaskRoutes } from './routes/tasks.js';
|
||||||
import { registerInboxRoutes } from './routes/inbox.js';
|
import { registerInboxRoutes } from './routes/inbox.js';
|
||||||
@@ -214,6 +215,7 @@ async function main() {
|
|||||||
registerMessageRoutes(app, sql, broker, inferenceApi);
|
registerMessageRoutes(app, sql, broker, inferenceApi);
|
||||||
registerSkillRoutes(app, sql, broker, inferenceApi);
|
registerSkillRoutes(app, sql, broker, inferenceApi);
|
||||||
registerPendingRoutes(app, sql);
|
registerPendingRoutes(app, sql);
|
||||||
|
registerCheckpointRoutes(app, sql);
|
||||||
registerAgentSessionRoutes(app, sql);
|
registerAgentSessionRoutes(app, sql);
|
||||||
registerTaskRoutes(app, sql, inferenceApi);
|
registerTaskRoutes(app, sql, inferenceApi);
|
||||||
registerInboxRoutes(app, sql);
|
registerInboxRoutes(app, sql);
|
||||||
|
|||||||
67
apps/coder/src/routes/checkpoints.ts
Normal file
67
apps/coder/src/routes/checkpoints.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -240,6 +240,27 @@ 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;
|
||||||
|
|
||||||
|
-- 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/<id>.
|
||||||
|
-- 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,
|
-- 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
|
-- 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
|
-- transaction, so the dispatcher reacts immediately instead of waiting for the
|
||||||
|
|||||||
236
apps/coder/src/services/__tests__/checkpoints.test.ts
Normal file
236
apps/coder/src/services/__tests__/checkpoints.test.ts
Normal file
@@ -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/<id> 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<typeof postgres>;
|
||||||
|
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}`;
|
||||||
|
});
|
||||||
|
});
|
||||||
173
apps/coder/src/services/__tests__/fuzzy-match.test.ts
Normal file
173
apps/coder/src/services/__tests__/fuzzy-match.test.ts
Normal file
@@ -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<typeof locateMatch>): { 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
297
apps/coder/src/services/checkpoints.ts
Normal file
297
apps/coder/src/services/checkpoints.ts
Normal file
@@ -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-<id>`); 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/<id>` 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/<id>`)
|
||||||
|
* 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<RestoreCheckpointResult> {
|
||||||
|
// 1. Resolve the checkpoint.
|
||||||
|
const [cp] = await sql<CheckpointRow[]>`
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 { createCheckpoint } from './checkpoints.js';
|
||||||
import { makeDcpStreamStripper } from './dcp-strip.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';
|
||||||
@@ -358,6 +359,16 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
`;
|
`;
|
||||||
const assistantId = assistantMsg!.id;
|
const assistantId = assistantMsg!.id;
|
||||||
|
|
||||||
|
// write-edit-robustness #4: pre-turn worktree checkpoint (best-effort; a
|
||||||
|
// failure logs and never breaks dispatch). This path uses a per-task worktree
|
||||||
|
// (createWorktree, not the session worktree), so there's no worktrees-table id
|
||||||
|
// — pass null for worktreeId, the path is enough for restore's reset.
|
||||||
|
await createCheckpoint(
|
||||||
|
sql,
|
||||||
|
{ chatId, sessionId, worktreeId: null, worktreePath, messageId: assistantId },
|
||||||
|
{ signal: ac.signal, log },
|
||||||
|
).catch(() => null);
|
||||||
|
|
||||||
broker.publishFrame(sessionId, {
|
broker.publishFrame(sessionId, {
|
||||||
type: 'message_started',
|
type: 'message_started',
|
||||||
message_id: assistantId,
|
message_id: assistantId,
|
||||||
@@ -617,6 +628,15 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
`;
|
`;
|
||||||
const assistantId = assistantMsg!.id;
|
const assistantId = assistantMsg!.id;
|
||||||
|
|
||||||
|
// write-edit-robustness #4: pre-turn checkpoint of the persistent session
|
||||||
|
// worktree (best-effort; never breaks dispatch). worktreeId comes from the
|
||||||
|
// worktrees table (ensureSessionWorktree above).
|
||||||
|
await createCheckpoint(
|
||||||
|
sql,
|
||||||
|
{ chatId, sessionId, worktreeId, worktreePath, messageId: assistantId },
|
||||||
|
{ signal: ac.signal, log },
|
||||||
|
).catch(() => null);
|
||||||
|
|
||||||
broker.publishFrame(sessionId, {
|
broker.publishFrame(sessionId, {
|
||||||
type: 'message_started',
|
type: 'message_started',
|
||||||
message_id: assistantId,
|
message_id: assistantId,
|
||||||
@@ -876,6 +896,15 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
`;
|
`;
|
||||||
const assistantId = assistantMsg!.id;
|
const assistantId = assistantMsg!.id;
|
||||||
|
|
||||||
|
// write-edit-robustness #4: pre-turn checkpoint of the persistent session
|
||||||
|
// worktree (best-effort; never breaks dispatch). Same worktree the opencode
|
||||||
|
// path uses — a chat that switches opencode↔goose↔qwen shares one worktree.
|
||||||
|
await createCheckpoint(
|
||||||
|
sql,
|
||||||
|
{ chatId, sessionId, worktreeId, worktreePath, messageId: assistantId },
|
||||||
|
{ signal: ac.signal, log },
|
||||||
|
).catch(() => null);
|
||||||
|
|
||||||
broker.publishFrame(sessionId, {
|
broker.publishFrame(sessionId, {
|
||||||
type: 'message_started',
|
type: 'message_started',
|
||||||
message_id: assistantId,
|
message_id: assistantId,
|
||||||
|
|||||||
271
apps/coder/src/services/fuzzy-match.ts
Normal file
271
apps/coder/src/services/fuzzy-match.ts
Normal file
@@ -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<number>(b.length + 1);
|
||||||
|
let curr = new Array<number>(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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
|
|||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import { resolveWritePath } from './write_guard.js';
|
import { resolveWritePath } from './write_guard.js';
|
||||||
|
import { locateMatch } from './fuzzy-match.js';
|
||||||
|
|
||||||
// --- Types -------------------------------------------------------------------
|
// --- Types -------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -121,10 +122,18 @@ export async function applyOne(
|
|||||||
case 'edit': {
|
case 'edit': {
|
||||||
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
||||||
const content = await readFile(change.file_path, 'utf8');
|
const content = await readFile(change.file_path, 'utf8');
|
||||||
if (!content.includes(oldStr)) {
|
const match = locateMatch(content, oldStr);
|
||||||
throw new Error('old_string not found in file — file may have changed since the edit was queued');
|
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');
|
await writeFile(change.file_path, updated, 'utf8');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -203,10 +212,18 @@ export async function rewindOne(
|
|||||||
// Reverse an edit: swap old and new
|
// Reverse an edit: swap old and new
|
||||||
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
||||||
const content = await readFile(change.file_path, 'utf8');
|
const content = await readFile(change.file_path, 'utf8');
|
||||||
if (!content.includes(newStr)) {
|
const match = locateMatch(content, newStr);
|
||||||
throw new Error('new_string not found in file — cannot rewind; file may have been modified since apply');
|
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');
|
await writeFile(change.file_path, reverted, 'utf8');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,24 @@ export interface AgentSessionInfo {
|
|||||||
last_active_at: string | null;
|
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 {
|
export class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public status: number,
|
public status: number,
|
||||||
@@ -407,6 +425,22 @@ export const api = {
|
|||||||
...(config?.thinking_option_id ? { thinking_option_id: config.thinking_option_id } : {}),
|
...(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<CoderRestoreResult>(
|
||||||
|
`/api/coder/sessions/${sessionId}/checkpoints/${encodeURIComponent(checkpointId)}/restore`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
),
|
||||||
// Queue a new-file create from the RightRail browser → BooCoder
|
// Queue a new-file create from the RightRail browser → BooCoder
|
||||||
// pending_changes (operation='create'). Surfaces in the CoderPane DiffPanel
|
// pending_changes (operation='create'). Surfaces in the CoderPane DiffPanel
|
||||||
// for explicit apply. A WriteGuardError comes back as a 422 whose { error }
|
// for explicit apply. A WriteGuardError comes back as a 422 whose { error }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { ReactNode } 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 { toast } from 'sonner';
|
||||||
import type { Chat, ErrorReason, Message } from '@/api/types';
|
import type { Chat, ErrorReason, Message } from '@/api/types';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
@@ -110,6 +110,10 @@ export interface MessageActions {
|
|||||||
onResend?: (chatId: string, content: string) => Promise<void>;
|
onResend?: (chatId: string, content: string) => Promise<void>;
|
||||||
onFork?: (chatId: string, messageId: string) => Promise<void>;
|
onFork?: (chatId: string, messageId: string) => Promise<void>;
|
||||||
onDelete?: (chatId: string, messageId: string) => Promise<void>;
|
onDelete?: (chatId: string, messageId: string) => Promise<void>;
|
||||||
|
// 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<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -119,6 +123,17 @@ interface Props {
|
|||||||
actions?: MessageActions;
|
actions?: MessageActions;
|
||||||
/** Hide actions that don't apply (fork, delete). */
|
/** Hide actions that don't apply (fork, delete). */
|
||||||
hideActions?: ('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 }) {
|
function StatsLine({ message }: { message: Message }) {
|
||||||
@@ -155,16 +170,22 @@ function ActionRow({
|
|||||||
message,
|
message,
|
||||||
actions,
|
actions,
|
||||||
hiddenSet,
|
hiddenSet,
|
||||||
|
hasCheckpoint = false,
|
||||||
|
restoreDisabled = false,
|
||||||
}: {
|
}: {
|
||||||
message: Message;
|
message: Message;
|
||||||
actions?: MessageActions;
|
actions?: MessageActions;
|
||||||
hiddenSet: Set<string>;
|
hiddenSet: Set<string>;
|
||||||
|
hasCheckpoint?: boolean;
|
||||||
|
restoreDisabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [justCopied, setJustCopied] = useState(false);
|
const [justCopied, setJustCopied] = useState(false);
|
||||||
const [regenerating, setRegenerating] = useState(false);
|
const [regenerating, setRegenerating] = useState(false);
|
||||||
const [forking, setForking] = useState(false);
|
const [forking, setForking] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [restoreOpen, setRestoreOpen] = useState(false);
|
||||||
|
const [restoring, setRestoring] = useState(false);
|
||||||
|
|
||||||
async function copy() {
|
async function copy() {
|
||||||
try {
|
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 isAssistant = message.role === 'assistant';
|
||||||
const isUser = message.role === 'user';
|
const isUser = message.role === 'user';
|
||||||
const canRegen = isAssistant && message.status !== 'streaming';
|
const canRegen = isAssistant && message.status !== 'streaming';
|
||||||
const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
|
const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
|
||||||
const canFork = message.status === 'complete';
|
const canFork = message.status === 'complete';
|
||||||
const canDelete = message.status !== 'streaming';
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -306,6 +348,18 @@ function ActionRow({
|
|||||||
<Trash2 className="size-3" />
|
<Trash2 className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canRestore && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRestoreOpen(true)}
|
||||||
|
disabled={restoreDisabled || restoring}
|
||||||
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
|
aria-label="Restore to here"
|
||||||
|
title="Restore worktree to this point"
|
||||||
|
>
|
||||||
|
<History className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Dialog
|
<Dialog
|
||||||
open={deleteOpen}
|
open={deleteOpen}
|
||||||
@@ -338,6 +392,39 @@ function ActionRow({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<Dialog
|
||||||
|
open={restoreOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!restoring) setRestoreOpen(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Restore to this point?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
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.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setRestoreOpen(false)}
|
||||||
|
disabled={restoring}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => void confirmRestore()}
|
||||||
|
disabled={restoring}
|
||||||
|
>
|
||||||
|
{restoring ? 'Restoring…' : 'Restore'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 ?? []);
|
const hiddenSet = new Set(hideActions ?? []);
|
||||||
// v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact'
|
// v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact'
|
||||||
// branch because summary=true never coexists with kind='compact' (new
|
// branch because summary=true never coexists with kind='compact' (new
|
||||||
@@ -652,7 +747,15 @@ export function MessageBubble({ message, sessionChats, capHitInfo, actions, hide
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isStreaming && <StatsLine message={message} />}
|
{!isStreaming && <StatsLine message={message} />}
|
||||||
{!isStreaming && hasContent && <ActionRow message={message} actions={actions} hiddenSet={hiddenSet} />}
|
{!isStreaming && hasContent && (
|
||||||
|
<ActionRow
|
||||||
|
message={message}
|
||||||
|
actions={actions}
|
||||||
|
hiddenSet={hiddenSet}
|
||||||
|
hasCheckpoint={hasCheckpoint}
|
||||||
|
restoreDisabled={restoreDisabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,11 +147,24 @@ interface Props {
|
|||||||
chatId?: string;
|
chatId?: string;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
actions?: MessageActions;
|
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<string>;
|
||||||
|
// write-edit-robustness #4: suppress restore during an active turn (mirrors
|
||||||
|
// composer gating in CoderPane).
|
||||||
|
restoreDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CODER_HIDDEN_ACTIONS: ('fork' | 'delete')[] = ['fork'];
|
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<HTMLDivElement>(null);
|
const endRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const isNearBottomRef = useRef(true);
|
const isNearBottomRef = useRef(true);
|
||||||
@@ -189,6 +202,8 @@ export function CoderMessageList({ messages, chatId, footer, actions }: Props) {
|
|||||||
message={item.message as unknown as Message}
|
message={item.message as unknown as Message}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
hideActions={CODER_HIDDEN_ACTIONS}
|
hideActions={CODER_HIDDEN_ACTIONS}
|
||||||
|
hasCheckpoint={checkpointMessageIds?.has(item.message.id) ?? false}
|
||||||
|
restoreDisabled={restoreDisabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,6 +381,29 @@ function usePendingChanges(sessionId: string) {
|
|||||||
return { changes, loading, refresh, approve, reject };
|
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<Set<string>>(() => 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
|
// Sub-components
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -640,6 +663,7 @@ export function CoderPane({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
|
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
|
||||||
|
const { checkpointMessageIds, refreshCheckpoints } = useCheckpoints(sessionId, chatId);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [queue, setQueue] = useState<string[]>([]);
|
const [queue, setQueue] = useState<string[]>([]);
|
||||||
@@ -652,15 +676,18 @@ export function CoderPane({
|
|||||||
|
|
||||||
// Refresh pending changes (and agent-session state for the §9b chip) when a
|
// Refresh pending changes (and agent-session state for the §9b chip) when a
|
||||||
// message_complete arrives — same trigger usePendingChanges already uses.
|
// 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(() => {
|
useEffect(() => {
|
||||||
const lastAssistant = [...messages].reverse().find(
|
const lastAssistant = [...messages].reverse().find(
|
||||||
(m): m is CoderMessage => m.role === 'assistant',
|
(m): m is CoderMessage => m.role === 'assistant',
|
||||||
);
|
);
|
||||||
if (lastAssistant?.status === 'complete') {
|
if (lastAssistant?.status === 'complete') {
|
||||||
refresh();
|
refresh();
|
||||||
|
void refreshCheckpoints();
|
||||||
void refreshAgentSessions(sessionId);
|
void refreshAgentSessions(sessionId);
|
||||||
}
|
}
|
||||||
}, [messages, refresh, sessionId]);
|
}, [messages, refresh, refreshCheckpoints, sessionId]);
|
||||||
|
|
||||||
// The §9b chip only shows once the chat has ≥1 prior turn (a completed
|
// The §9b chip only shows once the chat has ≥1 prior turn (a completed
|
||||||
// assistant message). Hidden on a brand-new chat.
|
// assistant message). Hidden on a brand-new chat.
|
||||||
@@ -867,6 +894,38 @@ export function CoderPane({
|
|||||||
}
|
}
|
||||||
}, [activeTaskId]);
|
}, [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) => {
|
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
// Only BooCoder skills route here; an agent's own commands (not skills) fall
|
// Only BooCoder skills route here; an agent's own commands (not skills) fall
|
||||||
@@ -921,8 +980,11 @@ export function CoderPane({
|
|||||||
<CoderMessageList
|
<CoderMessageList
|
||||||
messages={messages as CoderTimelineWire[]}
|
messages={messages as CoderTimelineWire[]}
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
|
checkpointMessageIds={checkpointMessageIds}
|
||||||
|
restoreDisabled={generating}
|
||||||
actions={{
|
actions={{
|
||||||
onResend: async (_chatId, content) => { await sendOneMessage(content); },
|
onResend: async (_chatId, content) => { await sendOneMessage(content); },
|
||||||
|
onRestoreCheckpoint: handleRestoreCheckpoint,
|
||||||
}}
|
}}
|
||||||
footer={
|
footer={
|
||||||
activeTaskId && !permissionPrompt && sending === false ? (
|
activeTaskId && !permissionPrompt && sending === false ? (
|
||||||
|
|||||||
101
openspec/changes/write-edit-robustness/proposal.md
Normal file
101
openspec/changes/write-edit-robustness/proposal.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Write/edit robustness — fuzzy patch applier + worktree checkpoints
|
||||||
|
|
||||||
|
**Status:** in progress (started 2026-06-01)
|
||||||
|
**Source:** `boocode_code_review_v2.md` §1 #3 + #4, §5b/§5d–5e (cline, Apache-2.0 — algorithm clean-reimplemented, not vendored).
|
||||||
|
|
||||||
|
Two independent BooCoder hardening features for local quantized models.
|
||||||
|
|
||||||
|
## #3 — Fuzzy patch applier
|
||||||
|
|
||||||
|
**Problem:** `applyOne`'s edit case (`apps/coder/src/services/pending_changes.ts:124`) does exact
|
||||||
|
`content.includes(oldStr)` → throw, then `content.replace(oldStr, newStr)` (first occurrence).
|
||||||
|
`rewindOne` (line 206) is the same. Local models (qwen3.6) drift `old_string` by whitespace/
|
||||||
|
indentation/unicode (curly quotes, en/em-dash, nbsp), so a valid edit fails at apply with
|
||||||
|
"old_string not found" and is lost.
|
||||||
|
|
||||||
|
**Design:** new pure module `apps/coder/src/services/fuzzy-match.ts`:
|
||||||
|
`locateMatch(content: string, needle: string): { kind: 'exact'|'fuzzy'; start: number; end: number }
|
||||||
|
| { kind: 'ambiguous'; count: number } | { kind: 'not_found' }`. Match ladder:
|
||||||
|
1. **Exact** `indexOf`. If exactly one → exact span. If >1 → **ambiguous** (refuse; decision
|
||||||
|
2026-06-01: safer than silently editing the first).
|
||||||
|
2. **Per-line whitespace-insensitive** — compare `needle` lines to file line-windows ignoring per-line
|
||||||
|
`trimEnd`/leading-trailing blank lines.
|
||||||
|
3. **Unicode canonicalization** — normalize curly→straight quotes, en/em-dash→`-`, nbsp→space on both
|
||||||
|
sides, then retry the whitespace pass.
|
||||||
|
4. **Levenshtein** similarity ≥ 0.66 over line-windows sized to `needle`'s line count; best window wins.
|
||||||
|
|
||||||
|
Non-exact (fuzzy) matches return the actual file span so the caller replaces the real file text with
|
||||||
|
`new_string`. `pending_changes.ts` `applyOne`/`rewindOne` use `locateMatch`; `ambiguous`/`not_found`
|
||||||
|
return `success:false` with a clear message (no throw escaping the existing catch). Unit-tested
|
||||||
|
(`apps/coder/src/services/__tests__/fuzzy-match.test.ts`), per the `turn-guard.ts` pure-helper pattern.
|
||||||
|
|
||||||
|
## #4 — Worktree checkpoint + conversation-trim
|
||||||
|
|
||||||
|
**Problem:** `rewind` only reverses BooCode's own `pending_changes` (applied to the project root).
|
||||||
|
External agents (opencode/goose/qwen/claude) write **directly into the session worktree**
|
||||||
|
(`/tmp/booworktrees/sess-<id>`); rewind has zero coverage there.
|
||||||
|
|
||||||
|
**Schema** (`apps/coder/src/schema.sql`):
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
|
||||||
|
session_id UUID,
|
||||||
|
worktree_id UUID REFERENCES worktrees(id) ON DELETE SET NULL,
|
||||||
|
message_id UUID, -- anchor: the assistant turn row this checkpoint precedes
|
||||||
|
commit_sha TEXT NOT NULL, -- shadow-commit capturing the pre-turn worktree tree
|
||||||
|
label TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS checkpoints_chat_created_idx ON checkpoints(chat_id, created_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create** (`apps/coder/src/services/checkpoints.ts` → `createCheckpoint`): hooked into the three
|
||||||
|
external-agent dispatch paths in `dispatcher.ts` (`runWarmAcpTask` ~821, `runOpenCodeServerTask` ~513,
|
||||||
|
`runExternalAgent` ~255) — after `ensureSessionWorktree()` and the assistant-message insert (so the
|
||||||
|
anchor `message_id` exists), before the backend runs. Snapshot captures tracked **+ untracked** via a
|
||||||
|
temp-index shadow commit, stored in a private GC-safe ref:
|
||||||
|
```
|
||||||
|
cd <wt> && TMP=$(mktemp) && GIT_INDEX_FILE="$TMP" git read-tree HEAD \
|
||||||
|
&& GIT_INDEX_FILE="$TMP" git add -A \
|
||||||
|
&& TREE=$(GIT_INDEX_FILE="$TMP" git write-tree) \
|
||||||
|
&& SHA=$(git commit-tree "$TREE" -p HEAD -m "boocode checkpoint") \
|
||||||
|
&& git update-ref refs/boocode/checkpoints/<id> "$SHA" && rm -f "$TMP" && echo "$SHA"
|
||||||
|
```
|
||||||
|
Best-effort: a checkpoint failure logs and never breaks the turn. Native-boocode turns (project-root,
|
||||||
|
rewind-covered) get no checkpoint.
|
||||||
|
|
||||||
|
**Restore** (`POST /api/sessions/:sessionId/checkpoints/:checkpointId/restore`, proxied `/api/coder/*`):
|
||||||
|
1. Resolve + validate the checkpoint belongs to the session.
|
||||||
|
2. Reset worktree: `git -C <wt> reset --hard <commit_sha> && git -C <wt> clean -fd` (hostExec+shellEscape).
|
||||||
|
3. Trim transcript: `DELETE FROM messages WHERE chat_id = <cp.chat_id> AND created_at >=
|
||||||
|
(SELECT created_at FROM messages WHERE id = <cp.message_id>)` (+ explicit `message_parts` delete if
|
||||||
|
the FK isn't ON DELETE CASCADE — verify).
|
||||||
|
4. Reset backend (decision 2026-06-01): `UPDATE agent_sessions SET status='crashed' WHERE
|
||||||
|
chat_id=<cp.chat_id>` and evict the live pool session for `(chat,agent)` if present, so the next turn
|
||||||
|
re-establishes a fresh backend — transcript, files, and agent context all consistent at the restore
|
||||||
|
point. (Warm backends hold context server-side; no partial rewind exists.)
|
||||||
|
5. Delete now-orphaned later checkpoints: `DELETE FROM checkpoints WHERE chat_id=? AND created_at >
|
||||||
|
<cp.created_at>`.
|
||||||
|
6. Return `{ checkpoint_id, messages_deleted, worktree_reset, backend_reset }`.
|
||||||
|
|
||||||
|
**Frontend:** per-message "Restore to here" in `CoderMessageList.tsx` (via a new optional
|
||||||
|
`onRestoreCheckpoint?(chatId, messageId)` on `MessageActions` in `MessageBubble.tsx`), wired in
|
||||||
|
`CoderPane.tsx`; guarded to `status==='complete'` and to messages that have a checkpoint. After the call
|
||||||
|
returns, refetch the chat's messages (existing GET) — no new WS frame required.
|
||||||
|
|
||||||
|
## Decisions (2026-06-01)
|
||||||
|
- Multi-exact-match → **refuse as ambiguous** (#3).
|
||||||
|
- #4 **full** scope incl. conversation-trim.
|
||||||
|
- Restore **resets** the external-agent backend session (context re-established fresh).
|
||||||
|
|
||||||
|
## Parallelization
|
||||||
|
- **Unit 1 (#3)** — fully independent (`fuzzy-match.ts` + `pending_changes.ts` + test).
|
||||||
|
- **Unit 2 (#4 backend)** — schema + `checkpoints.ts` (create+restore) + 3 dispatcher hooks + restore route + backend reset. One agent owns all #4 coder backend (shared `checkpoints.ts`).
|
||||||
|
- **Unit 3 (#4 frontend)** — `CoderMessageList`/`MessageBubble`/`CoderPane`, against the pinned restore contract. Parallel with Unit 2. MUST NOT touch Sam's uncommitted WIP (`ChatTabBar`, `SessionLandingPage`, `Workspace`, `useWorkspacePanes`, `PaneHeaderActions`).
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
- `pnpm -C apps/coder test` (incl. new `fuzzy-match` + any checkpoint pure-helper tests)
|
||||||
|
- `pnpm -C apps/server build` then `pnpm -C apps/coder build`
|
||||||
|
- `npx tsc -p apps/web/tsconfig.app.json --noEmit`
|
||||||
|
- Live smoke (manual, host): external-agent edit → checkpoint row; "Restore to here" → worktree reset + transcript trimmed + next turn fresh.
|
||||||
Reference in New Issue
Block a user