From dca4c790a3c3e4dd4057add0f36f7045a05e007a Mon Sep 17 00:00:00 2001 From: d3cker Date: Tue, 15 Sep 2026 00:34:08 +0200 Subject: [PATCH] fix: reuse saved worktree after branch recovery --- CHANGELOG.md | 10 +++++++++- docs/bot-workflow.md | 5 +++++ docs/runtime.md | 6 ++++++ src/executor.ts | 14 ++++++++++---- test/executor.test.ts | 24 ++++++++++++++++++++++-- 5 files changed, 52 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad90690..7265a1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ include the full version, for example `## 0.7.0-beta.1`. ## Unreleased +### Fixed + +- Reuse the saved worktree path when resuming recovered tasks or processing PR + feedback, including after a branch rename. Preserve existing changes and the + pinned base while validating the managed directory, Git root, branch, and + repository before preparation, verification, and publication. A missing saved + worktree blocks recovery instead of creating a replacement. + ## 0.6.4 ### Fixed @@ -64,4 +72,4 @@ include the full version, for example `## 0.7.0-beta.1`. - Add detailed bot workflow diagrams and a documentation map in `AGENTS.md`. - Expand bundled bot instructions for planning, delegation, verification, and handing publication back to the dispatcher. -- Add the README banner showing an OpenCode2 agent executing a task. \ No newline at end of file +- Add the README banner showing an OpenCode2 agent executing a task. diff --git a/docs/bot-workflow.md b/docs/bot-workflow.md index d20810e..053d1bc 100644 --- a/docs/bot-workflow.md +++ b/docs/bot-workflow.md @@ -366,6 +366,11 @@ can reconcile a previously lost merge response. Follow-up rounds reset analysis, question, current session, checks, and commit; they retain the branch, worktree, pinned base, and previous session reference. +Preparation reuses the saved worktree path rather than deriving a new path from +the branch name. A renamed branch can therefore retain its original directory. +Preparation, verification, and push all check the managed path, exact Git root, +branch, and shared repository. A missing checkpoint directory blocks the task +without creating a replacement worktree. They create a new main session, whereas an implementation-question reply resumes the current one. Comments received while working stay queued for a later round. Feedback after closure can still be queued, but the next round's guards block it. diff --git a/docs/runtime.md b/docs/runtime.md index 7f5bfe0..f0e13b5 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -180,6 +180,12 @@ acknowledgement, implementation, and a push to the same open PR. The mention doe not need to be repeated. Comments received during execution wait for the next round. A mention in an authorized comment can also start work on an untracked issue. +Follow-up rounds reuse the worktree path saved in the queue, even if recovery +renamed its branch. Preparation, verification, and push validate that path as a +worktree root directly inside the managed worktree directory, attached to the +expected branch and repository. If the saved directory is missing, restore it +before retrying; the bot does not create a replacement or discard existing work. + Edits to existing comments and PR review comments are not supported. Closing the issue or closing/merging the PR blocks further rounds. diff --git a/src/executor.ts b/src/executor.ts index f264e04..def8bf1 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -1,7 +1,7 @@ import type { Plugin } from "@opencode/plugin"; import { execFile } from "node:child_process"; import { mkdir, realpath, stat } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { randomUUID, createHash } from "node:crypto"; import type { GithubOptions, Repository } from "./config.js"; import { botPrompt } from "./prompt.js"; @@ -39,7 +39,9 @@ export class GitWorkspace { } async prepare(task: Task, repo: Repository) { await this.validate(repo); - const directory = join(this.stateDirectory, "worktrees", task.branch.replaceAll("/", "-")); + // A recovered task can have a renamed branch while retaining its original + // worktree. The checkpoint, not the current branch spelling, owns its path. + const directory = task.worktree ?? join(this.stateDirectory, "worktrees", task.branch.replaceAll("/", "-")); await mkdir(join(this.stateDirectory, "worktrees"), { recursive: true }); // Existing worktrees are reused only after checking their exact branch and shared repository. if (await stat(directory).then(() => true, e => { if (e.code === "ENOENT") return false; throw e; })) { @@ -47,6 +49,7 @@ export class GitWorkspace { const baseSha = task.baseSha ?? await this.git(directory, "merge-base", "HEAD", `refs/remotes/origin/${repo.baseBranch}`); return { worktree: await realpath(directory), baseSha }; } + if (task.worktree) throw new Blocked("Saved task worktree is missing; restore it before retrying"); try { await this.git(repo.directory, "fetch", "origin", `refs/heads/${repo.baseBranch}:refs/remotes/origin/${repo.baseBranch}`); } catch (cause) { throw new Error(`Could not fetch base branch ${repo.baseBranch} from origin; check that it exists and Git authentication works`, { cause }); } const baseSha = await this.git(repo.directory, "rev-parse", `refs/remotes/origin/${repo.baseBranch}`); @@ -62,8 +65,11 @@ export class GitWorkspace { return result.split(/\r?\n/).some(line => line.split(/\s+/)[1] === ref); } private async assertWorktree(directory: string, task: Task, repo: Repository) { - const expected = join(await realpath(this.stateDirectory), "worktrees", task.branch.replaceAll("/", "-")); - if (await realpath(directory) !== resolve(expected)) throw new Blocked("Unexpected worktree path"); + const managed = join(await realpath(this.stateDirectory), "worktrees"); + const expected = task.worktree ? resolve(task.worktree) : join(managed, task.branch.replaceAll("/", "-")); + const actual = await realpath(directory); + if (actual !== expected || dirname(actual) !== managed) throw new Blocked("Unexpected worktree path"); + if (await realpath(await this.git(directory, "rev-parse", "--show-toplevel")) !== actual) throw new Blocked("Task directory is not the worktree root"); if (await this.git(directory, "branch", "--show-current") !== task.branch) throw new Blocked("Worktree branch changed"); const common = await this.git(directory, "rev-parse", "--path-format=absolute", "--git-common-dir"); const original = await this.git(repo.directory, "rev-parse", "--path-format=absolute", "--git-common-dir"); diff --git a/test/executor.test.ts b/test/executor.test.ts index f0bb269..83181ad 100644 --- a/test/executor.test.ts +++ b/test/executor.test.ts @@ -107,7 +107,7 @@ test("real git worktree isolates a fix, verifies, commits and pushes to a local assert.equal(await git.hasBranch(repo, "release/next"), true); assert.equal(await git.hasBranch(repo, "release"), false); assert.equal(await git.hasBranch(repo, "missing"), false); - const t = task(); Object.assign(t, await git.prepare(t, repo)); + const t: Task = { ...task(), worktree: undefined }; Object.assign(t, await git.prepare(t, repo)); assert.equal(t.baseSha, selectedBase); assert.match(await readFile(join(t.worktree!, "release.txt"), "utf8"), /release-only/); await installWorkerPlugin(t.worktree!, options, run); @@ -124,10 +124,30 @@ test("real git worktree isolates a fix, verifies, commits and pushes to a local const withoutTests = await git.verify(t, { ...repo, checks: [] }); assert.deepEqual(withoutTests.checks, []); assert.equal(withoutTests.commit, t.commit); + // Recovery can rename a published branch without moving its worktree. + // Follow-up prepare, verification, and push must all honor the checkpoint. + const savedPath = t.worktree!, savedBase = t.baseSha; + await run(savedPath, ["git", "branch", "-m", "recovered-feature"]); + t.branch = "recovered-feature"; + await writeFile(join(savedPath, "followup.txt"), "preserve this uncommitted work\n"); + Object.assign(t, await git.prepare(t, repo)); + assert.equal(t.worktree, savedPath); + assert.equal(t.baseSha, savedBase); + assert.equal(await readFile(join(savedPath, "followup.txt"), "utf8"), "preserve this uncommitted work\n"); + Object.assign(t, await git.verify(t, repo)); + await git.push(t, repo); + assert.equal(await run(dir, ["git", "--git-dir", remote, "rev-parse", "refs/heads/recovered-feature"]), t.commit); + await assert.rejects(git.prepare({ ...t, worktree: join(state, "worktrees", "missing-checkpoint") }, repo), /Saved task worktree is missing/); + await assert.rejects(git.prepare({ ...t, worktree: checkout }, repo), /Unexpected worktree path/); + await assert.rejects(git.prepare({ ...t, branch: "wrong-branch" }, repo), /Worktree branch changed/); + const foreign = join(savedPath, "..", "foreign"); + await mkdir(foreign); + await run(foreign, ["git", "init", "-b", t.branch]); + await assert.rejects(git.prepare({ ...t, worktree: foreign }, repo), /another repository/); assert.equal(await run(t.worktree!, ["git", "ls-files", "--", ".opencode/plugins/automation-runtime/index.js"]), ""); await writeFile(runtimePath, "// User customization\n"); await assert.rejects(installWorkerPlugin(t.worktree!, options, run), /customized/); - await assert.rejects(git.prepare({ ...task(), branch: "automation/missing-base" }, { ...repo, baseBranch: "missing" }), /Could not fetch base branch missing/); + await assert.rejects(git.prepare({ ...task(), worktree: undefined, branch: "automation/missing-base" }, { ...repo, baseBranch: "missing" }), /Could not fetch base branch missing/); await writeFile(join(t.worktree!, "counter.txt"), "changed after verification\n"); await assert.rejects(git.push(t, repo), /changed after verification/); } finally { await rm(dir, { recursive: true, force: true }); }