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:
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}`;
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user