Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
- Add the README banner showing an OpenCode2 agent executing a task.
5 changes: 5 additions & 0 deletions docs/bot-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 10 additions & 4 deletions src/executor.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -39,14 +39,17 @@ 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; })) {
await this.assertWorktree(directory, task, repo);
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}`);
Expand All @@ -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");
Expand Down
24 changes: 22 additions & 2 deletions test/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 }); }
Expand Down