From a16317675711ab45dfca7a2f2605bd65eb19ea2e Mon Sep 17 00:00:00 2001 From: hueyexe Date: Tue, 5 May 2026 15:41:02 +1000 Subject: [PATCH 1/3] fix: skip worktree creation for read-only agents (explore/plan) Read-only agents cannot write files, so worktree isolation is unnecessary overhead. The useWorktree decision now checks agent type before creating a worktree. Closes #6. --- src/system-prompt.ts | 2 ++ src/tools/team-spawn.ts | 4 ++-- test/tools/team-spawn.test.ts | 44 +++++++++++++++++++++++++++++++---- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/system-prompt.ts b/src/system-prompt.ts index 200b380..b043745 100644 --- a/src/system-prompt.ts +++ b/src/system-prompt.ts @@ -135,6 +135,8 @@ export function buildLeadSystemPrompt(db: Database, teamId: string, config?: Req "", "Spawn teammates ONE AT A TIME. Wait for each tool result before spawning the next.", "This avoids git worktree contention. Once all are spawned, wait for their messages.", + "Read-only agents (explore, plan) automatically skip worktree creation.", + "For other agents that only need to read, pass worktree: false to avoid unnecessary isolation.", "", "Teammates work asynchronously and message you when done.", "Do NOT poll team_status or team_tasks_list repeatedly — wait for messages.", diff --git a/src/tools/team-spawn.ts b/src/tools/team-spawn.ts index 5739a46..8cd7a10 100644 --- a/src/tools/team-spawn.ts +++ b/src/tools/team-spawn.ts @@ -85,7 +85,8 @@ export async function executeTeamSpawn( .get(teamInfo.teamId, args.name) if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`) - const useWorktree = args.worktree !== false && !isWorktreeDirectory(deps.directory) + const isReadOnly = args.agent === "plan" || args.agent === "explore" + const useWorktree = args.worktree !== false && !isReadOnly && !isWorktreeDirectory(deps.directory) const usePlanApproval = args.plan_approval === true log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`) @@ -143,7 +144,6 @@ export async function executeTeamSpawn( // Permission rules on session.create are the hard gate (server-enforced). // For read-only agents, deny write tools and explicitly allow team tools. // For all agents with worktrees, allowlist the worktree path for edit/bash. - const isReadOnly = args.agent === "plan" || args.agent === "explore" const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"] as const const permission: PermissionRule[] = [] diff --git a/test/tools/team-spawn.test.ts b/test/tools/team-spawn.test.ts index faef62a..0cbb311 100644 --- a/test/tools/team-spawn.test.ts +++ b/test/tools/team-spawn.test.ts @@ -220,6 +220,44 @@ describe("team_spawn", () => { // --- Worktree tests --- + test("skips worktree for read-only agents (explore) even without explicit worktree: false", async () => { + const result = await executeTeamSpawn(deps, { + name: "researcher", + agent: "explore", + prompt: "Search the codebase", + }, "lead-sess") + + // No worktree.create call — read-only agents don't need file isolation + const wtCalls = deps.client.calls.filter(c => c.method === "worktree.create") + expect(wtCalls).toHaveLength(0) + + // DB should have null worktree columns + const row = deps.db.query("SELECT worktree_dir, worktree_branch FROM team_member WHERE name = ?").get("researcher") as Record + expect(row.worktree_dir).toBeNull() + expect(row.worktree_branch).toBeNull() + + expect(result).toContain("researcher") + expect(result).toContain("spawned") + }) + + test("skips worktree for read-only agents (plan) even without explicit worktree: false", async () => { + const result = await executeTeamSpawn(deps, { + name: "planner", + agent: "plan", + prompt: "Plan the architecture", + }, "lead-sess") + + const wtCalls = deps.client.calls.filter(c => c.method === "worktree.create") + expect(wtCalls).toHaveLength(0) + + const row = deps.db.query("SELECT worktree_dir, worktree_branch FROM team_member WHERE name = ?").get("planner") as Record + expect(row.worktree_dir).toBeNull() + expect(row.worktree_branch).toBeNull() + + expect(result).toContain("planner") + expect(result).toContain("spawned") + }) + test("skips worktree creation when already inside a worktree", async () => { deps.directory = "/home/user/.local/share/opencode/worktree/abc123/some-worktree" const result = await executeTeamSpawn(deps, { @@ -579,26 +617,24 @@ describe("team_spawn — agent mode enforcement", () => { { permission: "team_claim", pattern: "*", action: "allow" }, ] - test("plan agent gets worktree edit allow + deny + team tool allow rules on session.create", async () => { + test("plan agent gets deny rules + team tool allow (no worktree) on session.create", async () => { await executeTeamSpawn(deps, { name: "planner", agent: "plan", prompt: "Plan it" }, "lead-sess") const createCall = deps.client.calls.find(c => c.method === "session.create") const opts = createCall!.args[0] as { permission?: Array<{ permission: string; pattern: string; action: string }> } expect(opts.permission).toEqual([ - { permission: "edit", pattern: "/tmp/worktree-ensemble-my-team-planner/**", action: "allow" }, { permission: "edit", pattern: "*", action: "deny" }, { permission: "bash", pattern: "*", action: "deny" }, ...TEAM_TOOL_PERMISSIONS, ]) }) - test("explore agent gets worktree edit allow + deny + team tool allow rules on session.create", async () => { + test("explore agent gets deny rules + team tool allow (no worktree) on session.create", async () => { await executeTeamSpawn(deps, { name: "explorer", agent: "explore", prompt: "Explore it" }, "lead-sess") const createCall = deps.client.calls.find(c => c.method === "session.create") const opts = createCall!.args[0] as { permission?: Array<{ permission: string; pattern: string; action: string }> } expect(opts.permission).toEqual([ - { permission: "edit", pattern: "/tmp/worktree-ensemble-my-team-explorer/**", action: "allow" }, { permission: "edit", pattern: "*", action: "deny" }, { permission: "bash", pattern: "*", action: "deny" }, ...TEAM_TOOL_PERMISSIONS, From 9c55bdd94b90e560d844516b293fcbf9b8b6d662 Mon Sep 17 00:00:00 2001 From: hueyexe Date: Tue, 5 May 2026 15:43:11 +1000 Subject: [PATCH 2/3] ci: auto-assign issues to hueyexe on creation --- .github/workflows/auto-assign.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/auto-assign.yml diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml new file mode 100644 index 0000000..f42742b --- /dev/null +++ b/.github/workflows/auto-assign.yml @@ -0,0 +1,21 @@ +name: Auto-assign issues + +on: + issues: + types: [opened] + +jobs: + assign: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/github-script@v9 + with: + script: | + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + assignees: ['hueyexe'] + }) From ff3d4e010c3f921fc02206580833364683c49df5 Mon Sep 17 00:00:00 2001 From: hueyexe Date: Tue, 5 May 2026 15:52:29 +1000 Subject: [PATCH 3/3] test: add stress test for read-only agent lifecycle without worktree --- test/stress.test.ts | 69 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/test/stress.test.ts b/test/stress.test.ts index f2ca07a..50ef544 100644 --- a/test/stress.test.ts +++ b/test/stress.test.ts @@ -809,3 +809,72 @@ describe("stress: completion loop prevention (issue #3)", () => { expect(promptCalls).toHaveLength(1) }) }) + +// --- Read-only agent lifecycle (issue #6) --- + +describe("stress: read-only agents skip worktree through full lifecycle", () => { + let deps: Deps + const lead = "lead-sess" + + beforeEach(() => { + deps = setupDeps() + spawnFailures.clear() + }) + + test("explore agent: spawn → message → shutdown → cleanup without worktree", async () => { + await executeTeamCreate(deps, { name: "readonly-team" }, lead) + const teamId = getTeamId(deps, "readonly-team") + + // Spawn without explicit worktree: false — should auto-skip + const result = await executeTeamSpawn(deps, { + name: "researcher", + agent: "explore", + prompt: "Find all usages of the auth module", + }, lead) + expect(result).toContain("spawned") + expect(result).not.toContain("branch:") + + // No worktree.create call + const wtCalls = deps.client.calls.filter(c => c.method === "worktree.create") + expect(wtCalls).toHaveLength(0) + + // DB confirms no worktree + const row = deps.db.query("SELECT worktree_dir, worktree_branch FROM team_member WHERE name = 'researcher'") + .get() as { worktree_dir: string | null; worktree_branch: string | null } + expect(row.worktree_dir).toBeNull() + expect(row.worktree_branch).toBeNull() + + // Researcher messages lead + const sess = getSession(deps, "researcher") + await executeTeamMessage(deps, { to: "lead", text: "Found 12 usages across 4 files" }, sess) + + // Shutdown + deps.db.run("UPDATE team_member SET status = 'ready', execution_status = 'idle' WHERE name = 'researcher'") + await executeTeamShutdown(deps, { member: "researcher", force: true }, lead) + + // Cleanup — no merge needed, no worktree to remove + const result2 = await executeTeamCleanup(deps, { force: false }, lead, undefined, noopMerge, noopDelete, true) + expect(result2).toContain("cleaned up") + + const wtRemoves = deps.client.calls.filter(c => c.method === "worktree.remove") + expect(wtRemoves).toHaveLength(0) + }) + + test("mixed team: build gets worktree, explore does not", async () => { + await executeTeamCreate(deps, { name: "mixed-team" }, lead) + const teamId = getTeamId(deps, "mixed-team") + + await executeTeamSpawn(deps, { name: "builder", agent: "build", prompt: "Implement feature" }, lead) + await executeTeamSpawn(deps, { name: "explorer", agent: "explore", prompt: "Research patterns" }, lead) + + const wtCalls = deps.client.calls.filter(c => c.method === "worktree.create") + expect(wtCalls).toHaveLength(1) // Only builder + + const builder = deps.db.query("SELECT worktree_branch FROM team_member WHERE name = 'builder'") + .get() as { worktree_branch: string | null } + const explorer = deps.db.query("SELECT worktree_branch FROM team_member WHERE name = 'explorer'") + .get() as { worktree_branch: string | null } + expect(builder.worktree_branch).toBeTruthy() + expect(explorer.worktree_branch).toBeNull() + }) +})