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
21 changes: 21 additions & 0 deletions .github/workflows/auto-assign.yml
Original file line number Diff line number Diff line change
@@ -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']
})
2 changes: 2 additions & 0 deletions src/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 2 additions & 2 deletions src/tools/team-spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand Down Expand Up @@ -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[] = []

Expand Down
69 changes: 69 additions & 0 deletions test/stress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
44 changes: 40 additions & 4 deletions test/tools/team-spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | null>
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<string, string | null>
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, {
Expand Down Expand Up @@ -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,
Expand Down