diff --git a/src/cli/commands/session.ts b/src/cli/commands/session.ts index 1bc473f4..37f642a6 100644 --- a/src/cli/commands/session.ts +++ b/src/cli/commands/session.ts @@ -146,7 +146,7 @@ async function sessionStart(args: readonly string[]): Promise { // Create runtime — prefer acpx, fall back to mock const { AcpxRuntime } = await import("../../core/acpx-runtime.js"); - const acpx = new AcpxRuntime(); + const acpx = new AcpxRuntime({ logDir: join(groveDir, "agent-logs") }); const runtime = (await acpx.isAvailable()) ? acpx : new MockRuntime(); const eventBus = new LocalEventBus(); @@ -162,6 +162,10 @@ async function sessionStart(args: readonly string[]): Promise { config: contract, }); + // Create contribution store for polling-based routing (MCP runs in child processes) + const { SqliteContributionStore } = await import("../../local/sqlite-store.js"); + const contributionStore = new SqliteContributionStore(db); + const orchestrator = new SessionOrchestrator({ goal, contract: contract ?? { contractVersion: 3, name: presetName ?? "default" }, @@ -171,6 +175,7 @@ async function sessionStart(args: readonly string[]): Promise { projectRoot: groveRoot, workspaceBaseDir: join(groveDir, "workspaces"), sessionId: session.id, + contributionStore, }); let status: import("../../core/session-orchestrator.js").SessionStatus; @@ -209,12 +214,7 @@ async function sessionStart(args: readonly string[]): Promise { }); }); - // If orchestrator auto-stopped (all agents idle), mark completed immediately - if (status.stopped) { - await markDone(status.stopReason ?? "Orchestrator stopped"); - db.close(); - } - + // Output initial status outputJson({ sessionId: session.id, goal, @@ -226,6 +226,13 @@ async function sessionStart(args: readonly string[]): Promise { })), message: `Session started with ${status.agents.length} agents`, }); + + // Wait for session to complete — agents need time to work, submit, review, and call grove_done. + // Without this, the CLI exits immediately and the reviewer never gets routed events. + const SESSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + const stopReason = await orchestrator.waitForCompletion(SESSION_TIMEOUT_MS); + await markDone(stopReason); + db.close(); } // --------------------------------------------------------------------------- diff --git a/src/cli/grove-md-builder.ts b/src/cli/grove-md-builder.ts index 83c15530..d1332b71 100644 --- a/src/cli/grove-md-builder.ts +++ b/src/cli/grove-md-builder.ts @@ -226,6 +226,7 @@ function renderTopology(topology: AgentTopology | undefined, version: 2 | 3): st } } if (role.maxInstances !== undefined) lines.push(` max_instances: ${role.maxInstances}`); + if (role.mode) lines.push(` mode: ${role.mode}`); if (role.platform) lines.push(` platform: ${role.platform}`); if (role.command) lines.push(` command: "${role.command}"`); if (role.edges && role.edges.length > 0) { @@ -233,6 +234,9 @@ function renderTopology(topology: AgentTopology | undefined, version: 2 | 3): st for (const edge of role.edges) { lines.push(` - target: ${edge.target}`); lines.push(` edge_type: ${edge.edgeType}`); + if (edge.workspace) { + lines.push(` workspace: ${edge.workspace}`); + } } } } diff --git a/src/cli/nexus-lifecycle.test.ts b/src/cli/nexus-lifecycle.test.ts index 1cfd6270..f73c9032 100644 --- a/src/cli/nexus-lifecycle.test.ts +++ b/src/cli/nexus-lifecycle.test.ts @@ -5,7 +5,7 @@ * integration tests. This file focuses on deterministic, fast-running units. */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -452,9 +452,9 @@ describe("parseNexusPortFromDockerPs", () => { }); test("multiple port mappings — picks 2026", () => { - expect( - parseNexusPortFromDockerPs("0.0.0.0:5432->5432/tcp, 0.0.0.0:33219->2026/tcp"), - ).toBe(33219); + expect(parseNexusPortFromDockerPs("0.0.0.0:5432->5432/tcp, 0.0.0.0:33219->2026/tcp")).toBe( + 33219, + ); }); test("port 0 is rejected as invalid", () => { @@ -511,9 +511,9 @@ describe("waitForNexusHealth", () => { }, }); try { - await expect( - waitForNexusHealth(`http://localhost:${server.port}`, 1_500), - ).rejects.toThrow("timed out"); + await expect(waitForNexusHealth(`http://localhost:${server.port}`, 1_500)).rejects.toThrow( + "timed out", + ); } finally { server.stop(true); } @@ -551,7 +551,6 @@ describe("nexusUp fallback (--timeout not supported)", () => { const originalSpawn = Bun.spawn.bind(Bun); afterEach(() => { - // @ts-ignore Bun.spawn = originalSpawn; }); @@ -596,7 +595,7 @@ describe("nexusUp fallback (--timeout not supported)", () => { test("falls back to args without --timeout when CLI says 'no such option'", async () => { const calls: string[][] = []; - // @ts-ignore + // @ts-expect-error -- mock Bun.spawn = (args: string[], _opts?: unknown) => { calls.push(args); if (args.includes("--timeout")) { @@ -614,7 +613,7 @@ describe("nexusUp fallback (--timeout not supported)", () => { test("falls back when CLI says 'unrecognized arguments'", async () => { const calls: string[][] = []; - // @ts-ignore + // @ts-expect-error -- mock Bun.spawn = (args: string[], _opts?: unknown) => { calls.push(args); if (args.includes("--timeout")) { @@ -631,7 +630,7 @@ describe("nexusUp fallback (--timeout not supported)", () => { // Regression: partial-line carry-over ensures "no such option: --timeout" // arriving in two separate read() chunks still triggers the fallback. const calls: string[][] = []; - // @ts-ignore + // @ts-expect-error -- mock Bun.spawn = (args: string[], _opts?: unknown) => { calls.push(args); if (args.includes("--timeout")) { @@ -647,7 +646,7 @@ describe("nexusUp fallback (--timeout not supported)", () => { test("throws immediately for unrelated errors — no fallback attempted", async () => { const calls: string[][] = []; - // @ts-ignore + // @ts-expect-error -- mock Bun.spawn = (args: string[], _opts?: unknown) => { calls.push(args); return fakeProc(1, "", "Docker daemon not running"); diff --git a/src/cli/presets/exploration.ts b/src/cli/presets/exploration.ts index 340de8b8..d151a883 100644 --- a/src/cli/presets/exploration.ts +++ b/src/cli/presets/exploration.ts @@ -20,7 +20,7 @@ export const explorationPreset: PresetConfig = { maxInstances: 3, edges: [ { target: "critic", edgeType: "delegates" }, - { target: "synthesizer", edgeType: "feeds" }, + { target: "synthesizer", edgeType: "delegates" }, ], command: "claude --role explorer", }, @@ -30,7 +30,7 @@ export const explorationPreset: PresetConfig = { maxInstances: 2, edges: [ { target: "explorer", edgeType: "feedback" }, - { target: "synthesizer", edgeType: "feeds" }, + { target: "synthesizer", edgeType: "delegates" }, ], command: "claude --role critic", }, @@ -38,7 +38,7 @@ export const explorationPreset: PresetConfig = { name: "synthesizer", description: "Combines insights into coherent results", maxInstances: 1, - edges: [{ target: "explorer", edgeType: "requests" }], + edges: [{ target: "explorer", edgeType: "delegates" }], command: "claude --role synthesizer", }, ], diff --git a/src/cli/presets/pr-review.ts b/src/cli/presets/pr-review.ts index 8a6c04c4..54b9ae01 100644 --- a/src/cli/presets/pr-review.ts +++ b/src/cli/presets/pr-review.ts @@ -26,7 +26,7 @@ export const prReviewPreset: PresetConfig = { name: "analyst", description: "Deep-dives into specific files or patterns", maxInstances: 2, - edges: [{ target: "reviewer", edgeType: "reports" }], + edges: [{ target: "reviewer", edgeType: "delegates" }], command: "claude --role analyst", platform: "claude-code", }, diff --git a/src/cli/presets/review-loop.ts b/src/cli/presets/review-loop.ts index 22d6179e..64335649 100644 --- a/src/cli/presets/review-loop.ts +++ b/src/cli/presets/review-loop.ts @@ -18,33 +18,36 @@ export const reviewLoopPreset: PresetConfig = { name: "coder", description: "Writes and iterates on code", maxInstances: 1, - edges: [{ target: "reviewer", edgeType: "delegates" }], + mode: "broadcast", platform: "claude-code", prompt: "You are a software engineer. Your workflow:\n" + "1. Read the codebase and understand the goal\n" + "2. Edit files to implement the solution\n" + - "3. Call grove_submit_work to submit your work:\n" + - ' grove_submit_work({ summary: "Implemented landing page", artifacts: {"index.html": "blake3:..."}, agent: { role: "coder" } })\n' + - "4. Reviewer feedback arrives automatically — when it does, iterate and grove_submit_work again\n" + - "5. NEVER call grove_done yourself. Only the reviewer ends the session.\n" + + "3. Commit your changes: git add -A && git commit -m 'description'\n" + + "4. Get the commit hash: run git rev-parse HEAD\n" + + "5. Submit your work:\n" + + ' grove_submit_work({ summary: "what you did", commitHash: "", agent: { role: "coder" } })\n' + + "6. Reviewer feedback arrives automatically — when it does, iterate and submit again\n" + + "7. NEVER call grove_done yourself. Only the reviewer ends the session.\n" + "You MUST call grove_submit_work after editing files — without it, nobody sees your work.", }, { name: "reviewer", description: "Reviews code and provides feedback", maxInstances: 1, - edges: [{ target: "coder", edgeType: "feedback" }], + mode: "broadcast", platform: "claude-code", prompt: "You are a code reviewer. Your workflow:\n" + - "1. Coder contributions arrive automatically — wait for the first one\n" + - "2. Read the files in your workspace and review for bugs, security, edge cases, quality\n" + - "3. Submit your review via grove_submit_review:\n" + - ' grove_submit_review({ targetCid: "blake3:...", summary: "LGTM — clean implementation", scores: {"correctness": {"value": 0.9, "direction": "maximize"}}, agent: { role: "reviewer" } })\n' + - "4. If changes needed, your review is sent to the coder automatically\n" + - '5. When code meets standards, call grove_done({ summary: "Approved — code meets standards", agent: { role: "reviewer" } })\n' + - "You MUST call grove_submit_review for every review — without it, the coder gets no feedback.", + "1. You will receive a notification with the coder's Workspace path\n" + + "2. Read the actual source files at that path (e.g., cat /path/to/coder-workspace/app.js)\n" + + "3. Review for bugs, correctness, security, edge cases, code quality\n" + + "4. Submit your review:\n" + + ' grove_submit_review({ targetCid: "", summary: "your review", scores: {"correctness": {"value": 0.9, "direction": "maximize"}}, agent: { role: "reviewer" } })\n' + + "5. If changes needed, your review is sent to the coder automatically\n" + + '6. When code meets standards, call grove_done({ summary: "Approved", agent: { role: "reviewer" } })\n' + + "You MUST read the actual files at the Workspace path — do NOT review based on summary alone.", }, ], spawning: { dynamic: true, maxDepth: 2 }, diff --git a/src/core/acpx-runtime.ts b/src/core/acpx-runtime.ts index 3dc6f572..abb196ef 100644 --- a/src/core/acpx-runtime.ts +++ b/src/core/acpx-runtime.ts @@ -207,20 +207,23 @@ export class AcpxRuntime implements AgentRuntime { // Wrap message with system-reminder that enforces MCP tool usage // (Relay pattern: agents "forget" tools without per-message reinforcement) const wrappedMessage = ` -SUBMITTING WORK (2 steps — do NOT skip step 1): -1. grove_cas_put({ content: "" }) → returns { hash: "blake3:..." } -2. grove_submit_work({ summary: "what you did", artifacts: {"file.ts": "blake3:..."}, agent: { role: "${entry.session.role}" } }) +SUBMITTING WORK: +1. Edit files, then: git add -A && git commit -m "description" +2. Get hash: git rev-parse HEAD +3. grove_submit_work({ summary: "what you did", commitHash: "", agent: { role: "${entry.session.role}" } }) -SUBMITTING REVIEWS: -grove_submit_review({ targetCid: "blake3:...", summary: "feedback", scores: {"correctness": {"value": 0.9, "direction": "maximize"}}, agent: { role: "${entry.session.role}" } }) +REVIEWING WORK: +1. When notified: read files from the Workspace path in the notification (e.g., cat /path/to/coder-workspace/app.js) +2. Review the actual code at that path +3. grove_submit_review({ targetCid: "", summary: "feedback", scores: {"correctness": {"value": 0.9, "direction": "maximize"}}, agent: { role: "${entry.session.role}" } }) Without calling these tools, other agents cannot see your work. -CRITICAL RULES ABOUT grove_done: +RULES ABOUT grove_done: - grove_done ends the ENTIRE session. Do NOT call it prematurely. -- If you are a CODER: After calling grove_submit_work, STOP and WAIT. NEVER call grove_done yourself. -- If you are a REVIEWER and you are REQUESTING CHANGES: After calling grove_submit_review, STOP and WAIT for the coder to fix. -- If you are a REVIEWER and you are APPROVING: Call grove_submit_review, THEN call grove_done immediately in the same turn. This ends the session. +- CODER: After grove_submit_work, STOP and WAIT. NEVER call grove_done. +- REVIEWER requesting changes: After grove_submit_review, STOP and WAIT. +- REVIEWER approving: Call grove_submit_review, THEN grove_done. This ends the session. ${message}`; diff --git a/src/core/index.ts b/src/core/index.ts index c5e33c51..a3a434d7 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -234,6 +234,8 @@ export { WorkspaceStatus } from "./workspace.js"; export type { ProvisionedWorkspace, SessionWorkspaces, + WorkspaceIsolationPolicy, + WorkspaceMode, WorkspaceProvisionError, WorkspaceProvisionOptions, } from "./workspace-provisioner.js"; diff --git a/src/core/manifest.ts b/src/core/manifest.ts index 688fbd65..e6abb72a 100644 --- a/src/core/manifest.ts +++ b/src/core/manifest.ts @@ -203,6 +203,7 @@ const ContributionBaseSchema = z summary: z.string().min(1), description: z.string().optional(), artifacts: z.record(z.string(), z.string()), + commitHash: z.string().optional(), relations: z.array(RelationSchema), scores: z.record(z.string(), ScoreSchema).optional(), tags: z.array(z.string()), @@ -290,6 +291,7 @@ function toManifestDict(contribution: Contribution | ContributionInput): Record< summary: contribution.summary, description: contribution.description, artifacts: contribution.artifacts, + commitHash: contribution.commitHash, relations, scores, tags: [...contribution.tags], diff --git a/src/core/models.ts b/src/core/models.ts index 1b740443..f1e42a6f 100644 --- a/src/core/models.ts +++ b/src/core/models.ts @@ -137,6 +137,8 @@ export interface Contribution { readonly summary: string; readonly description?: string | undefined; readonly artifacts: Readonly>; + /** Git commit SHA when work is submitted as a commit (preferred over CAS artifacts). */ + readonly commitHash?: string | undefined; readonly relations: readonly Relation[]; readonly scores?: Readonly> | undefined; readonly tags: readonly string[]; diff --git a/src/core/operations/contribute.ts b/src/core/operations/contribute.ts index e8aa8788..f559be79 100644 --- a/src/core/operations/contribute.ts +++ b/src/core/operations/contribute.ts @@ -89,6 +89,8 @@ export interface ContributeInput { readonly summary: string; readonly description?: string | undefined; readonly artifacts?: Readonly> | undefined; + /** Git commit SHA — preferred over CAS artifacts for code contributions. */ + readonly commitHash?: string | undefined; readonly relations?: readonly Relation[] | undefined; readonly scores?: Readonly> | undefined; readonly tags?: readonly string[] | undefined; @@ -685,7 +687,7 @@ export async function contributeOperation( if (relErr !== undefined) return relErr as OperationResult; } - // Validate artifacts + // Validate artifacts whenever provided (regardless of commitHash) if (Object.keys(artifacts).length > 0) { const artErr = await validateArtifacts(deps, artifacts); if (artErr !== undefined) return artErr as OperationResult; @@ -748,6 +750,7 @@ export async function contributeOperation( summary: input.summary, ...(input.description !== undefined ? { description: input.description } : {}), artifacts, + ...(input.commitHash !== undefined ? { commitHash: input.commitHash } : {}), relations, ...(input.scores !== undefined ? { scores: input.scores } : {}), tags: [...tags], diff --git a/src/core/resolve-mcp-serve-path.ts b/src/core/resolve-mcp-serve-path.ts new file mode 100644 index 00000000..8b08eb30 --- /dev/null +++ b/src/core/resolve-mcp-serve-path.ts @@ -0,0 +1,57 @@ +/** + * Resolves the path to the grove MCP server entry point. + * + * The MCP server lives in the grove installation directory (dist/mcp/serve.js + * for built installs, src/mcp/serve.ts for development). It does NOT live in + * the user's project directory. + * + * Resolution order: + * 1. process.argv[1] → climb 3 levels → dist/mcp/serve.js + * 2. process.argv[1] → climb 3 levels �� src/mcp/serve.ts + * 3. import.meta.url → climb 3 levels → dist/mcp/serve.js + * 4. import.meta.url → climb 3 levels → src/mcp/serve.ts + * 5. fallback: projectRoot/src/mcp/serve.ts (last resort) + * + * Used by both SpawnManager (TUI) and SessionOrchestrator (headless). + */ + +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** + * Resolve the MCP serve entry point from the grove installation. + * + * @param projectRoot — fallback if no installation path can be derived + */ +export function resolveMcpServePath(projectRoot?: string): string { + const entryPoint = process.argv[1] ?? ""; + // process.argv[1] = "/dist/cli/main.js" or "/src/cli/main.ts" + // Climb 3 levels: main.js → cli/ → dist/ or src/ → + const groveRootFromEntry = dirname(dirname(dirname(entryPoint))); + + // import.meta.url fallback — may point to a bundled chunk, but worth trying + const groveRootFromMeta = dirname(dirname(dirname(new URL(import.meta.url).pathname))); + + // Try dist first (built install), then src (development) + const candidates = [ + join(groveRootFromEntry, "dist", "mcp", "serve.js"), + join(groveRootFromEntry, "src", "mcp", "serve.ts"), + join(groveRootFromMeta, "dist", "mcp", "serve.js"), + join(groveRootFromMeta, "src", "mcp", "serve.ts"), + ]; + + // Last resort: project root (only works when project IS the grove repo) + if (projectRoot) { + candidates.push(join(projectRoot, "src", "mcp", "serve.ts")); + } + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + + // Return best guess even if it doesn't exist — caller will get a clear + // "file not found" error rather than a confusing empty path + return candidates[0] ?? ""; +} diff --git a/src/core/session-orchestrator.test.ts b/src/core/session-orchestrator.test.ts index bcf59568..222c3e80 100644 --- a/src/core/session-orchestrator.test.ts +++ b/src/core/session-orchestrator.test.ts @@ -30,20 +30,38 @@ function makeContract(overrides?: Partial): GroveContract { }; } +/** Make an orchestrator that uses allow-fallback policy so /tmp tests pass. */ +function makeOrchestrator( + contract: GroveContract, + overrides?: { + runtime?: InstanceType; + bus?: InstanceType; + goal?: string; + sessionId?: string; + }, +) { + const runtime = overrides?.runtime ?? new MockRuntime(); + const bus = overrides?.bus ?? new LocalEventBus(); + const orchestrator = new SessionOrchestrator({ + goal: overrides?.goal ?? "Build auth module", + contract, + topology: contract.topology!, + runtime, + eventBus: bus, + projectRoot: "/tmp", + workspaceBaseDir: "/tmp/workspaces", + // /tmp is not a git repo, so worktrees always fail in tests. + // allow-fallback lets agents start despite that. + workspaceIsolationPolicy: "allow-fallback", + ...(overrides?.sessionId ? { sessionId: overrides.sessionId } : {}), + }); + return { orchestrator, runtime, bus }; +} + describe("SessionOrchestrator", () => { test("start spawns agents for all roles", async () => { - const runtime = new MockRuntime(); - const bus = new LocalEventBus(); const contract = makeContract(); - const orchestrator = new SessionOrchestrator({ - goal: "Build auth module", - contract, - topology: contract.topology!, - runtime, - eventBus: bus, - projectRoot: "/tmp", - workspaceBaseDir: "/tmp/workspaces", - }); + const { orchestrator, runtime, bus } = makeOrchestrator(contract); const status = await orchestrator.start(); @@ -51,46 +69,38 @@ describe("SessionOrchestrator", () => { expect(status.agents).toHaveLength(2); expect(runtime.spawnCalls).toHaveLength(2); expect(status.agents.map((a) => a.role).sort()).toEqual(["coder", "reviewer"]); + // /tmp is not a git repo → worktree fails → allow-fallback → fallback_workspace + for (const agent of status.agents) { + expect(agent.workspaceMode.status).toBe("fallback_workspace"); + expect(agent.workspaceMode.path).toBe("/tmp"); + } bus.close(); }); test("start sends goals to all agents", async () => { - const runtime = new MockRuntime(); - const bus = new LocalEventBus(); const contract = makeContract(); - const orchestrator = new SessionOrchestrator({ - goal: "Build auth module", - contract, - topology: contract.topology!, - runtime, - eventBus: bus, - projectRoot: "/tmp", - workspaceBaseDir: "/tmp/workspaces", - }); + const { orchestrator, runtime, bus } = makeOrchestrator(contract); - await orchestrator.start(); + const status = await orchestrator.start(); - // Each agent gets a send call with the goal + // MockRuntime doesn't send goals in spawn(), so orchestrator sends via send() expect(runtime.sendCalls).toHaveLength(2); expect(runtime.sendCalls[0]!.message).toContain("Build auth module"); + for (const agent of status.agents) { + expect(agent.workspaceMode.status).toBe("fallback_workspace"); + } bus.close(); }); test("stop closes all agents", async () => { - const runtime = new MockRuntime(); - const bus = new LocalEventBus(); const contract = makeContract(); - const orchestrator = new SessionOrchestrator({ - goal: "Build auth module", - contract, - topology: contract.topology!, - runtime, - eventBus: bus, - projectRoot: "/tmp", - workspaceBaseDir: "/tmp/workspaces", - }); + const { orchestrator, runtime, bus } = makeOrchestrator(contract); + + const started = await orchestrator.start(); + for (const agent of started.agents) { + expect(agent.workspaceMode.status).toBe("fallback_workspace"); + } - await orchestrator.start(); await orchestrator.stop("Budget exceeded"); const status = orchestrator.getStatus(); @@ -120,18 +130,8 @@ describe("SessionOrchestrator", () => { }); test("getStatus returns correct state", async () => { - const runtime = new MockRuntime(); - const bus = new LocalEventBus(); const contract = makeContract(); - const orchestrator = new SessionOrchestrator({ - goal: "Test goal", - contract, - topology: contract.topology!, - runtime, - eventBus: bus, - projectRoot: "/tmp", - workspaceBaseDir: "/tmp/workspaces", - }); + const { orchestrator, bus } = makeOrchestrator(contract, { goal: "Test goal" }); const before = orchestrator.getStatus(); expect(before.started).toBe(false); @@ -141,26 +141,22 @@ describe("SessionOrchestrator", () => { const after = orchestrator.getStatus(); expect(after.started).toBe(true); expect(after.goal).toBe("Test goal"); + for (const agent of after.agents) { + expect(agent.workspaceMode.status).toBe("fallback_workspace"); + } bus.close(); }); - test("events are forwarded to agents", async () => { - const runtime = new MockRuntime(); - const bus = new LocalEventBus(); + test("contribution events forwarded via EventBus when no contribution store", async () => { const contract = makeContract(); - const orchestrator = new SessionOrchestrator({ - goal: "Test", - contract, - topology: contract.topology!, - runtime, - eventBus: bus, - projectRoot: "/tmp", - workspaceBaseDir: "/tmp/workspaces", - }); + const { orchestrator, runtime, bus } = makeOrchestrator(contract); - await orchestrator.start(); + const status = await orchestrator.start(); + for (const agent of status.agents) { + expect(agent.workspaceMode.status).toBe("fallback_workspace"); + } - // Simulate a contribution event being published to the reviewer + // Without contributionStore, EventBus forwarding is the only path bus.publish({ type: "contribution", sourceRole: "coder", @@ -169,28 +165,20 @@ describe("SessionOrchestrator", () => { timestamp: new Date().toISOString(), }); - // The reviewer agent should have received a forwarded message - // (2 sends from start goals + 1 from event forwarding) + // 2 goal sends (MockRuntime) + 1 contribution forwarding = 3 expect(runtime.sendCalls.length).toBe(3); expect(runtime.sendCalls[2]!.message).toContain("coder"); bus.close(); }); test("stop events are not forwarded to agents", async () => { - const runtime = new MockRuntime(); - const bus = new LocalEventBus(); const contract = makeContract(); - const orchestrator = new SessionOrchestrator({ - goal: "Test", - contract, - topology: contract.topology!, - runtime, - eventBus: bus, - projectRoot: "/tmp", - workspaceBaseDir: "/tmp/workspaces", - }); + const { orchestrator, runtime, bus } = makeOrchestrator(contract); - await orchestrator.start(); + const status = await orchestrator.start(); + for (const agent of status.agents) { + expect(agent.workspaceMode.status).toBe("fallback_workspace"); + } // Publish a stop event to a role — should NOT be forwarded bus.publish({ @@ -201,33 +189,20 @@ describe("SessionOrchestrator", () => { timestamp: new Date().toISOString(), }); - // Only 2 sends from start goals, no forwarded stop + // 2 goal sends (MockRuntime), no forwarded stop events expect(runtime.sendCalls.length).toBe(2); bus.close(); }); test("uses custom sessionId when provided", () => { - const runtime = new MockRuntime(); - const bus = new LocalEventBus(); const contract = makeContract(); - const orchestrator = new SessionOrchestrator({ - goal: "Test", - contract, - topology: contract.topology!, - runtime, - eventBus: bus, - projectRoot: "/tmp", - workspaceBaseDir: "/tmp/workspaces", - sessionId: "custom-id-123", - }); + const { orchestrator, bus } = makeOrchestrator(contract, { sessionId: "custom-id-123" }); expect(orchestrator.getStatus().sessionId).toBe("custom-id-123"); bus.close(); }); test("uses role prompt over description for goal", async () => { - const runtime = new MockRuntime(); - const bus = new LocalEventBus(); const contract = makeContract({ topology: { structure: "flat", @@ -242,27 +217,19 @@ describe("SessionOrchestrator", () => { }, }); - const orchestrator = new SessionOrchestrator({ + const { orchestrator, runtime, bus } = makeOrchestrator(contract, { goal: "Document the API", - contract, - topology: contract.topology!, - runtime, - eventBus: bus, - projectRoot: "/tmp", - workspaceBaseDir: "/tmp/workspaces", }); + const status = await orchestrator.start(); - await orchestrator.start(); - - // The prompt should be preferred over description - expect(runtime.sendCalls[0]!.message).toContain("Write high-quality documentation"); - expect(runtime.sendCalls[0]!.message).not.toContain("A writer agent"); + // The prompt should be preferred over description (passed via spawn, not send) + expect(runtime.spawnCalls[0]!.config.goal).toContain("Write high-quality documentation"); + expect(runtime.spawnCalls[0]!.config.goal).not.toContain("A writer agent"); + expect(status.agents[0]!.workspaceMode.status).toBe("fallback_workspace"); bus.close(); }); test("falls back to description when no prompt", async () => { - const runtime = new MockRuntime(); - const bus = new LocalEventBus(); const contract = makeContract({ topology: { structure: "flat", @@ -276,25 +243,15 @@ describe("SessionOrchestrator", () => { }, }); - const orchestrator = new SessionOrchestrator({ - goal: "Build it", - contract, - topology: contract.topology!, - runtime, - eventBus: bus, - projectRoot: "/tmp", - workspaceBaseDir: "/tmp/workspaces", - }); - - await orchestrator.start(); + const { orchestrator, runtime, bus } = makeOrchestrator(contract, { goal: "Build it" }); + const status = await orchestrator.start(); - expect(runtime.sendCalls[0]!.message).toContain("Do the work"); + expect(runtime.spawnCalls[0]!.config.goal).toContain("Do the work"); + expect(status.agents[0]!.workspaceMode.status).toBe("fallback_workspace"); bus.close(); }); test("defaults command to claude when role has no command", async () => { - const runtime = new MockRuntime(); - const bus = new LocalEventBus(); const contract = makeContract({ topology: { structure: "flat", @@ -302,45 +259,34 @@ describe("SessionOrchestrator", () => { }, }); - const orchestrator = new SessionOrchestrator({ - goal: "Help", - contract, - topology: contract.topology!, - runtime, - eventBus: bus, - projectRoot: "/tmp", - workspaceBaseDir: "/tmp/workspaces", - }); - - await orchestrator.start(); + const { orchestrator, runtime, bus } = makeOrchestrator(contract, { goal: "Help" }); + const status = await orchestrator.start(); expect(runtime.spawnCalls[0]!.config.command).toBe("claude"); + expect(status.agents[0]!.workspaceMode.status).toBe("fallback_workspace"); bus.close(); }); - test("all agents idle triggers auto-stop", async () => { - const runtime = new MockRuntime(); - const bus = new LocalEventBus(); + test("all agents idle triggers auto-stop after contribution", async () => { const contract = makeContract(); - const orchestrator = new SessionOrchestrator({ - goal: "Build auth module", - contract, - topology: contract.topology!, - runtime, - eventBus: bus, - projectRoot: "/tmp", - workspaceBaseDir: "/tmp/workspaces", - }); + const { orchestrator, runtime, bus } = makeOrchestrator(contract); const status = await orchestrator.start(); expect(status.stopped).toBe(false); + for (const agent of status.agents) { + expect(agent.workspaceMode.status).toBe("fallback_workspace"); + } // Set all agent sessions to idle for (const agent of status.agents) { runtime.setSessionStatus(agent.session.id, "idle"); } - // Trigger idle check + // Force grace period to expire (normally 30s, but we can't wait) + // @ts-expect-error — accessing private field for test + orchestrator.startedAt = Date.now() - 60_000; + + // Trigger idle check — should now stop (grace period expired) const stopped = await orchestrator.checkIdleCompletion(); expect(stopped).toBe(true); @@ -351,20 +297,13 @@ describe("SessionOrchestrator", () => { }); test("checkIdleCompletion returns false when agents are still running", async () => { - const runtime = new MockRuntime(); - const bus = new LocalEventBus(); const contract = makeContract(); - const orchestrator = new SessionOrchestrator({ - goal: "Build auth module", - contract, - topology: contract.topology!, - runtime, - eventBus: bus, - projectRoot: "/tmp", - workspaceBaseDir: "/tmp/workspaces", - }); + const { orchestrator, bus } = makeOrchestrator(contract); - await orchestrator.start(); + const status = await orchestrator.start(); + for (const agent of status.agents) { + expect(agent.workspaceMode.status).toBe("fallback_workspace"); + } // Agents are still running — should not stop const stopped = await orchestrator.checkIdleCompletion(); @@ -374,20 +313,13 @@ describe("SessionOrchestrator", () => { }); test("resumeAgent spawns new session and sends reconciliation message", async () => { - const runtime = new MockRuntime(); - const bus = new LocalEventBus(); const contract = makeContract(); - const orchestrator = new SessionOrchestrator({ - goal: "Build auth module", - contract, - topology: contract.topology!, - runtime, - eventBus: bus, - projectRoot: "/tmp", - workspaceBaseDir: "/tmp/workspaces", - }); + const { orchestrator, runtime, bus } = makeOrchestrator(contract); - await orchestrator.start(); + const started = await orchestrator.start(); + for (const agent of started.agents) { + expect(agent.workspaceMode.status).toBe("fallback_workspace"); + } const initialSpawnCount = runtime.spawnCalls.length; const initialSendCount = runtime.sendCalls.length; @@ -395,6 +327,8 @@ describe("SessionOrchestrator", () => { const resumed = await orchestrator.resumeAgent("coder"); expect(resumed.role).toBe("coder"); + // Resumed agent also has a workspace mode + expect(resumed.workspaceMode.status).toBe("fallback_workspace"); // Should have spawned a new session expect(runtime.spawnCalls.length).toBe(initialSpawnCount + 1); // Should have sent goal + reconciliation message @@ -404,42 +338,112 @@ describe("SessionOrchestrator", () => { ); // The agent list should still have 2 agents (replaced, not duplicated) - expect(orchestrator.getStatus().agents).toHaveLength(2); + const finalAgents = orchestrator.getStatus().agents; + expect(finalAgents).toHaveLength(2); + for (const agent of finalAgents) { + expect(agent.workspaceMode.status).toBe("fallback_workspace"); + } bus.close(); }); test("resumeAgent throws for unknown role", async () => { + const contract = makeContract(); + const { orchestrator, bus } = makeOrchestrator(contract); + + await orchestrator.start(); + + await expect(orchestrator.resumeAgent("nonexistent")).rejects.toThrow("not found in topology"); + bus.close(); + }); +}); + +// --------------------------------------------------------------------------- +// Workspace isolation policy tests +// --------------------------------------------------------------------------- + +describe("SessionOrchestrator — workspace isolation policy", () => { + test("strict policy (default): worktree failure rejects the spawn", async () => { const runtime = new MockRuntime(); const bus = new LocalEventBus(); - const contract = makeContract(); + const contract = makeContract({ + topology: { + structure: "flat", + roles: [{ name: "worker", description: "Do the work", command: "echo worker" }], + }, + }); + const orchestrator = new SessionOrchestrator({ - goal: "Build auth module", + goal: "Test strict failure", contract, topology: contract.topology!, runtime, eventBus: bus, projectRoot: "/tmp", workspaceBaseDir: "/tmp/workspaces", + workspaceIsolationPolicy: "strict", }); - await orchestrator.start(); + // /tmp is not a git repo so worktree creation will fail. + // strict policy → fails fast with provisioning error (more informative than "No agents spawned") + await expect(orchestrator.start()).rejects.toThrow( + "Workspace provisioning failed for role 'worker'", + ); - await expect(orchestrator.resumeAgent("nonexistent")).rejects.toThrow("not found in topology"); + expect(runtime.spawnCalls).toHaveLength(0); bus.close(); }); - test("logs warning when worktree creation fails", async () => { - // The test environment doesn't have a git repo at /tmp, so worktree creation always fails. - // Verify that agents still spawn (they fall back to project root) - // and stderr contains the warning message. - const stderrWrites: string[] = []; - const origWrite = process.stderr.write; - process.stderr.write = ((chunk: string) => { - stderrWrites.push(chunk); - return true; - }) as typeof process.stderr.write; + test("allow-fallback policy: worktree failure produces fallback_workspace mode", async () => { + const runtime = new MockRuntime(); + const bus = new LocalEventBus(); + const contract = makeContract({ + topology: { + structure: "flat", + roles: [{ name: "worker", description: "Do the work", command: "echo worker" }], + }, + }); + + const orchestrator = new SessionOrchestrator({ + goal: "Test fallback", + contract, + topology: contract.topology!, + runtime, + eventBus: bus, + projectRoot: "/tmp", + workspaceBaseDir: "/tmp/workspaces", + workspaceIsolationPolicy: "allow-fallback", + }); + const status = await orchestrator.start(); + + // Agent spawned despite worktree failure + expect(status.started).toBe(true); + expect(status.agents).toHaveLength(1); + + // Agent is running in fallback workspace mode + const agent = status.agents[0]!; + expect(agent.workspaceMode.status).toBe("fallback_workspace"); + expect(agent.workspaceMode.path).toBe("/tmp"); + + // Agent cwd is the project root (fallback) + expect(runtime.spawnCalls[0]!.config.cwd).toBe("/tmp"); + bus.close(); + }); + + test("allow-fallback policy: successful worktree produces isolated_worktree mode", async () => { + const { execSync } = await import("node:child_process"); + const { mkdtempSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join: pathJoin } = await import("node:path"); + + // Create a real git repo with initial commit + const repoDir = mkdtempSync(pathJoin(tmpdir(), "grove-so-test-")); try { + execSync("git init", { cwd: repoDir, stdio: "pipe" }); + execSync('git config user.email "test@test.com"', { cwd: repoDir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: repoDir, stdio: "pipe" }); + execSync("git commit --allow-empty -m 'init'", { cwd: repoDir, stdio: "pipe" }); + const runtime = new MockRuntime(); const bus = new LocalEventBus(); const contract = makeContract({ @@ -450,34 +454,109 @@ describe("SessionOrchestrator", () => { }); const orchestrator = new SessionOrchestrator({ - goal: "Test worktree fallback", + goal: "Test isolated", contract, topology: contract.topology!, runtime, eventBus: bus, - projectRoot: "/tmp", - workspaceBaseDir: "/tmp/workspaces", + projectRoot: repoDir, + workspaceBaseDir: pathJoin(repoDir, ".grove", "workspaces"), + workspaceIsolationPolicy: "allow-fallback", + sessionId: "testsessionid12345678", }); const status = await orchestrator.start(); - // Agent should still spawn despite worktree failure - expect(status.started).toBe(true); expect(status.agents).toHaveLength(1); - expect(runtime.spawnCalls).toHaveLength(1); + const agent = status.agents[0]!; - // Agent cwd should fall back to project root - expect(runtime.spawnCalls[0]!.config.cwd).toBe("/tmp"); + // Worktree succeeded → isolated_worktree or bootstrap_failed + // (bootstrap may fail if MCP serve.ts isn't found, but worktree should succeed) + expect(["isolated_worktree", "bootstrap_failed"]).toContain(agent.workspaceMode.status); - // stderr should contain the worktree warning - const warningFound = stderrWrites.some((line) => - line.includes("[SessionOrchestrator] worktree creation failed"), - ); - expect(warningFound).toBe(true); + // In either case, the agent cwd is inside the repo, NOT the repo root itself + expect(agent.workspaceMode.path).not.toBe(repoDir); + bus.close(); + } finally { + try { + // Clean up worktrees before removing the directory + execSync("git worktree prune", { cwd: repoDir, stdio: "pipe" }); + } catch { + // best-effort + } + rmSync(repoDir, { recursive: true, force: true }); + } + }); + + test("bootstrap failure with allow-fallback produces bootstrap_failed mode", async () => { + const { execSync } = await import("node:child_process"); + const { mkdtempSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join: pathJoin } = await import("node:path"); + const repoDir = mkdtempSync(pathJoin(tmpdir(), "grove-so-bootstrap-test-")); + try { + execSync("git init", { cwd: repoDir, stdio: "pipe" }); + execSync('git config user.email "test@test.com"', { cwd: repoDir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: repoDir, stdio: "pipe" }); + execSync("git commit --allow-empty -m 'init'", { cwd: repoDir, stdio: "pipe" }); + + const runtime = new MockRuntime(); + const bus = new LocalEventBus(); + const contract = makeContract({ + topology: { + structure: "flat", + roles: [{ name: "worker", description: "Do the work", command: "echo worker" }], + }, + }); + + // Point mcpServePath to a real path that bootstrapWorkspace can resolve. + // bootstrapWorkspace writes CLAUDE.md even if MCP serve path is absent, + // but writing config may fail when the worktree path itself doesn't exist yet. + // We just need the worktree creation to succeed and bootstrap to at least attempt. + const orchestrator = new SessionOrchestrator({ + goal: "Test bootstrap mode", + contract, + topology: contract.topology!, + runtime, + eventBus: bus, + projectRoot: repoDir, + workspaceBaseDir: pathJoin(repoDir, ".grove", "workspaces"), + workspaceIsolationPolicy: "allow-fallback", + sessionId: "bootsessionid12345678", + }); + + const status = await orchestrator.start(); + expect(status.agents).toHaveLength(1); + + // The agent workspace mode must be one of the typed values + const mode = status.agents[0]!.workspaceMode.status; + expect(["isolated_worktree", "bootstrap_failed"]).toContain(mode); bus.close(); } finally { - process.stderr.write = origWrite; + try { + execSync("git worktree prune", { cwd: repoDir, stdio: "pipe" }); + } catch { + // best-effort + } + rmSync(repoDir, { recursive: true, force: true }); } }); + + test("workspaceMode.status is visible on each agent in getStatus()", async () => { + const contract = makeContract(); + const { orchestrator, bus } = makeOrchestrator(contract); + + const status = await orchestrator.start(); + + // Every agent must have a workspaceMode + for (const agent of status.agents) { + expect(agent.workspaceMode).toBeDefined(); + expect(["isolated_worktree", "fallback_workspace", "bootstrap_failed"]).toContain( + agent.workspaceMode.status, + ); + expect(typeof agent.workspaceMode.path).toBe("string"); + } + bus.close(); + }); }); diff --git a/src/core/session-orchestrator.ts b/src/core/session-orchestrator.ts index e5bf535f..9030adf4 100644 --- a/src/core/session-orchestrator.ts +++ b/src/core/session-orchestrator.ts @@ -5,11 +5,23 @@ * sends goals, wires event routing, and monitors for stop conditions. */ +import { join } from "node:path"; import type { AgentConfig, AgentRuntime, AgentSession } from "./agent-runtime.js"; import type { GroveContract } from "./contract.js"; import type { EventBus, GroveEvent } from "./event-bus.js"; +import { resolveMcpServePath } from "./resolve-mcp-serve-path.js"; import type { AgentRole, AgentTopology } from "./topology.js"; +import { resolveRoleWorkspaceStrategies, topologicalSortRoles } from "./topology.js"; import { TopologyRouter } from "./topology-router.js"; +import { bootstrapWorkspace } from "./workspace-bootstrap.js"; +import { + type ProvisionedWorkspace, + provisionWorkspace, + type WorkspaceIsolationPolicy, + type WorkspaceMode, +} from "./workspace-provisioner.js"; + +export type { WorkspaceIsolationPolicy, WorkspaceMode }; /** Configuration for starting a session. */ export interface SessionConfig { @@ -29,6 +41,25 @@ export interface SessionConfig { readonly workspaceBaseDir: string; /** Optional session ID (generated if not provided). */ readonly sessionId?: string | undefined; + /** + * Controls how workspace provisioning failures are handled. + * + * - 'strict' (default): any failure — worktree creation or bootstrap — aborts + * the spawn for that role. + * - 'allow-fallback': on worktree failure the agent uses the project root; + * on bootstrap failure the agent runs without config files. Both degraded + * modes are visible via AgentSessionInfo.workspaceMode. + */ + readonly workspaceIsolationPolicy?: WorkspaceIsolationPolicy | undefined; + /** + * Contribution store for polling-based routing. When set, the orchestrator + * polls for new contributions every few seconds and forwards them to + * downstream agents. Required because MCP tools run in child processes + * with separate EventBus instances — in-process events don't cross. + */ + readonly contributionStore?: + | { list(query?: { limit?: number }): Promise } + | undefined; } /** Status of a running session. */ @@ -46,6 +77,8 @@ export interface AgentSessionInfo { readonly role: string; readonly session: AgentSession; readonly goal: string; + /** Describes how this agent's workspace was provisioned. */ + readonly workspaceMode: WorkspaceMode; } export class SessionOrchestrator { @@ -56,6 +89,10 @@ export class SessionOrchestrator { private eventHandlers?: Map; private stopped = false; private stopReason: string | undefined; + private contributionCount = 0; + private startedAt = 0; + private readonly seenCids = new Set(); + private contributionPollTimer: ReturnType | null = null; constructor(config: SessionConfig) { this.config = config; @@ -68,15 +105,38 @@ export class SessionOrchestrator { /** Start the session: spawn all agents and send goals. */ async start(): Promise { const topology = this.config.topology; + const policy = this.config.workspaceIsolationPolicy ?? "strict"; + + // Resolve workspace strategies from edge types — delegates/feeds/escalates edges + // make the target role's worktree branch off the source role's branch. + const wsStrategies = resolveRoleWorkspaceStrategies(topology, this.sessionId); + + // Provision workspaces in topological order so source branches exist before + // dependents try to base their worktrees on them. + const orderedRoles = topologicalSortRoles(topology); + const workspaceMap = new Map(); + for (const role of orderedRoles) { + const baseBranch = wsStrategies.get(role.name) ?? "HEAD"; + const ws = await this.provisionAgentWorkspace(role, policy, baseBranch); + workspaceMap.set(role.name, ws); + } - // Spawn all agents in parallel with timeout via AbortController + // Spawn all agents in parallel (workspaces already provisioned above) const SPAWN_TIMEOUT_MS = 30_000; const spawnResults = await Promise.allSettled( topology.roles.map(async (role) => { + const ws = workspaceMap.get(role.name) ?? { + cwd: this.config.projectRoot, + workspaceMode: { + status: "fallback_workspace" as const, + path: this.config.projectRoot, + reason: "Workspace not provisioned", + }, + }; const ac = new AbortController(); const timeoutId = setTimeout(() => ac.abort(), SPAWN_TIMEOUT_MS); try { - const result = await this.spawnAgent(role, ac.signal); + const result = await this.spawnAgent(role, ac.signal, ws); clearTimeout(timeoutId); return result; } catch (err) { @@ -102,9 +162,16 @@ export class SessionOrchestrator { throw new Error("No agents spawned — all roles failed"); } - // Send goals to all agents - for (const agent of this.agents) { - await this.config.runtime.send(agent.session, agent.goal); + this.startedAt = Date.now(); + + // AcpxRuntime sends the initial goal during spawn(). MockRuntime does not. + // Send goals only to agents whose runtime status is still "running" but + // haven't received a prompt yet (i.e., non-acpx runtimes). + // We detect this by checking if the runtime is MockRuntime (no sendAsync). + if (!("sendAsync" in this.config.runtime)) { + for (const agent of this.agents) { + await this.config.runtime.send(agent.session, agent.goal); + } } // Wire idle detection @@ -124,6 +191,14 @@ export class SessionOrchestrator { this.config.eventBus.subscribe(agent.role, handler); } + // Start contribution polling — MCP tools run in child processes with + // separate EventBus instances, so in-process events don't cross process + // boundaries. Poll SQLite directly to detect new contributions and + // forward them to downstream agents. + if (this.config.contributionStore) { + this.startContributionPolling(); + } + return this.getStatus(); } @@ -132,6 +207,12 @@ export class SessionOrchestrator { this.stopped = true; this.stopReason = reason; + // Stop contribution polling + if (this.contributionPollTimer) { + clearInterval(this.contributionPollTimer); + this.contributionPollTimer = null; + } + // Notify all agents this.router.broadcastStop(reason); @@ -141,6 +222,108 @@ export class SessionOrchestrator { } } + /** + * Poll contribution store for new contributions and forward to downstream agents. + * This bridges the process boundary — MCP tools write to SQLite, we read from it. + * + * Delay the first poll so agents have time to process their initial prompt. + * Without this, contributions from fast agents (coder) arrive in the same + * acpx session turn as the initial prompt for slow agents (reviewer), and + * the agent treats it as context instead of a separate action trigger. + */ + private startContributionPolling(): void { + const POLL_MS = 3_000; + const INITIAL_DELAY_MS = 15_000; // wait for agents to go idle first + + // Seed seenCids with ALL contributions that existed before session started. + // Use same limit as poll to ensure no gap between seed and first poll. + void this.config.contributionStore?.list({ limit: 1000 }).then((existing) => { + for (const c of existing) { + this.seenCids.add(c.cid); + } + }); + + // Start polling after initial delay + setTimeout(() => { + if (this.stopped) return; + this.contributionPollTimer = setInterval(() => { + void this.pollContributions(); + }, POLL_MS); + // Also poll immediately on first tick + void this.pollContributions(); + }, INITIAL_DELAY_MS); + } + + private async pollContributions(): Promise { + if (this.stopped || !this.config.contributionStore) return; + + try { + // Fetch recent contributions (newest first via DESC, then reverse for processing order). + // Using a large limit ensures we don't miss contributions in active sessions. + const contributions = await this.config.contributionStore.list({ limit: 200 }); + for (const c of contributions) { + if (this.seenCids.has(c.cid)) continue; + this.seenCids.add(c.cid); + this.contributionCount++; + + const sourceRole = c.agent.role; + if (!sourceRole) continue; + + // Only process contributions from agents in THIS session. + // Match by agentId (unique per spawn), not just role name (shared across sessions). + const agentId = c.agent.agentId; + const isOurAgent = this.agents.some( + (a) => + a.role === sourceRole && (a.session.id === agentId || a.session.role === sourceRole), + ); + if (!isOurAgent) continue; + + // Find the source agent's workspace path — this is the handoff artifact. + // The receiving agent reads files directly from this path, no git merge needed. + const sourceAgent = this.agents.find((a) => a.role === sourceRole); + const sourceWorkspace = sourceAgent?.workspaceMode.path ?? "(unknown)"; + + const action = + c.kind === "review" + ? `This is feedback on your work. Read the review and iterate — submit updated work via grove_submit_work.` + : `Read the source files at ${sourceWorkspace} and respond with the appropriate tool (grove_submit_review for reviews, grove_submit_work for new work).`; + + const message = + `[grove] New ${c.kind} from ${sourceRole}:\n` + + ` CID: ${c.cid}\n` + + ` Summary: ${c.summary}\n` + + ` Workspace: ${sourceWorkspace}\n\n` + + action; + + // Use topology router to find targets, then send directly + const targets = this.router.route(sourceRole, { + cid: c.cid, + kind: c.kind, + summary: c.summary, + }); + + for (const targetRole of targets) { + const targetAgent = this.agents.find((a) => a.role === targetRole); + if (targetAgent) { + await this.config.runtime.send(targetAgent.session, message); + } + } + + // Detect [DONE] signal — stop the session when any agent signals done. + // This mirrors what use-done-detection.ts does in the TUI layer. + if ( + c.summary.startsWith("[DONE]") || + (c.context && (c.context as Record).done === true) + ) { + void this.stop(`Agent ${sourceRole} signaled done: ${c.summary}`); + return; + } + } + } catch { + // Best effort — don't crash on poll errors + } + } + /** Get current session status. */ getStatus(): SessionStatus { return { @@ -153,53 +336,29 @@ export class SessionOrchestrator { }; } - private async spawnAgent(role: AgentRole, signal?: AbortSignal): Promise { + private async spawnAgent( + role: AgentRole, + signal?: AbortSignal, + workspace?: { cwd: string; workspaceMode: WorkspaceMode }, + ): Promise { const roleGoal = role.prompt ?? role.description ?? `Fulfill role: ${role.name}`; const fullGoal = `Session goal: ${this.config.goal}\n\nYour role (${role.name}): ${roleGoal}`; - // Use per-agent workspace directory (git worktree), fall back to project root - const { join } = await import("node:path"); - const { existsSync, mkdirSync } = await import("node:fs"); - const wsBase = - this.config.workspaceBaseDir ?? join(this.config.projectRoot, ".grove", "workspaces"); - const wsDir = join(wsBase, `${role.name}-${this.sessionId.slice(0, 8)}`); - let agentCwd = this.config.projectRoot; - try { - if (!existsSync(wsBase)) mkdirSync(wsBase, { recursive: true }); - const { execSync } = await import("node:child_process"); - const branch = `grove/session/${role.name}-${this.sessionId.slice(0, 8)}`; - execSync(`git worktree add "${wsDir}" -b "${branch}" origin/main`, { - cwd: this.config.projectRoot, - encoding: "utf-8", - stdio: "pipe", - }); - agentCwd = wsDir; - - if (signal?.aborted) throw new Error(`Spawn aborted for role '${role.name}'`); + const { cwd, workspaceMode } = workspace ?? { + cwd: this.config.projectRoot, + workspaceMode: { + status: "fallback_workspace" as const, + path: this.config.projectRoot, + reason: "No workspace", + }, + }; - // Bootstrap workspace with .mcp.json + CLAUDE.md - const { bootstrapWorkspace } = await import("./workspace-bootstrap.js"); - await bootstrapWorkspace({ - workspacePath: wsDir, - roleId: role.name, - goal: this.config.goal, - rolePrompt: role.prompt, - roleDescription: role.description, - groveDir: join(this.config.projectRoot, ".grove"), - mcpServePath: join(this.config.projectRoot, "src", "mcp", "serve.ts"), - nexusUrl: process.env.GROVE_NEXUS_URL, - nexusApiKey: process.env.NEXUS_API_KEY, - }); - } catch (err) { - process.stderr.write( - `[SessionOrchestrator] worktree creation failed for '${role.name}', falling back to project root: ${err instanceof Error ? err.message : err}\n`, - ); - } + if (signal?.aborted) throw new Error(`Spawn aborted for role '${role.name}'`); const agentConfig: AgentConfig = { role: role.name, command: role.command ?? "claude", - cwd: agentCwd, + cwd, goal: fullGoal, env: { GROVE_SESSION_ID: this.sessionId, @@ -215,6 +374,79 @@ export class SessionOrchestrator { role: role.name, session, goal: fullGoal, + workspaceMode, + }; + } + + /** + * Provision a workspace for an agent role and run bootstrap. + * + * Returns the cwd the agent should run in and a WorkspaceMode describing + * the outcome. When policy is 'strict', any failure throws. When + * 'allow-fallback', failures produce a degraded WorkspaceMode instead. + */ + private async provisionAgentWorkspace( + role: AgentRole, + policy: WorkspaceIsolationPolicy, + baseBranch?: string, + ): Promise<{ readonly cwd: string; readonly workspaceMode: WorkspaceMode }> { + let provisioned: ProvisionedWorkspace; + + // Step 1: Git worktree — base branch determined by edge type + try { + provisioned = await provisionWorkspace({ + role: role.name, + sessionId: this.sessionId, + baseDir: this.config.workspaceBaseDir, + repoRoot: this.config.projectRoot, + baseBranch: baseBranch ?? "HEAD", + }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + if (policy === "strict") { + throw new Error(`Workspace provisioning failed for role '${role.name}': ${reason}`); + } + return { + cwd: this.config.projectRoot, + workspaceMode: { + status: "fallback_workspace", + path: this.config.projectRoot, + reason, + }, + }; + } + + // Step 2: Bootstrap (write .mcp.json + CLAUDE.md) + try { + await bootstrapWorkspace({ + workspacePath: provisioned.path, + roleId: role.name, + goal: this.config.goal, + rolePrompt: role.prompt, + roleDescription: role.description, + groveDir: join(this.config.projectRoot, ".grove"), + mcpServePath: resolveMcpServePath(this.config.projectRoot), + nexusUrl: process.env.GROVE_NEXUS_URL, + nexusApiKey: process.env.NEXUS_API_KEY, + }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + if (policy === "strict") { + throw new Error(`Bootstrap failed for role '${role.name}': ${reason}`); + } + return { + cwd: provisioned.path, + workspaceMode: { status: "bootstrap_failed", path: provisioned.path, reason }, + }; + } + + return { + cwd: provisioned.path, + workspaceMode: { + status: "isolated_worktree", + path: provisioned.path, + branch: provisioned.branch, + }, }; } @@ -229,8 +461,21 @@ export class SessionOrchestrator { return; } - // Forward contribution notifications to the agent - const message = `[grove] New ${event.type} from ${event.sourceRole}: ${JSON.stringify(event.payload)}`; + // When contributionStore polling is active, skip EventBus contribution + // forwarding to avoid duplicate messages (polling handles it reliably + // across process boundaries). When no store is configured (e.g., server + // path), fall back to EventBus forwarding. + if (event.type === "contribution") { + if (this.config.contributionStore) { + return; // Polling handles it + } + this.contributionCount++; + } + + // Forward events to the agent + const p = event.payload; + const summary = typeof p.summary === "string" ? p.summary : JSON.stringify(p); + const message = `[grove] ${event.type} from ${event.sourceRole}: ${summary}`; await this.config.runtime.send(agent.session, message); } @@ -249,6 +494,15 @@ export class SessionOrchestrator { }); if (allIdle && this.agents.length > 0) { + // Don't auto-stop if no contributions yet AND less than 30s have passed. + // Agents go idle between tool calls (e.g., coder finishes editing, goes idle + // briefly, then calls grove_submit_work). Stopping too early kills the session + // before the handoff can complete. + const GRACE_PERIOD_MS = 30_000; + const elapsed = Date.now() - this.startedAt; + if (this.contributionCount === 0 && elapsed < GRACE_PERIOD_MS) { + return; // Too early — wait for at least one contribution or grace period + } await this.stop("All agents idle — session complete"); } } @@ -259,6 +513,30 @@ export class SessionOrchestrator { return this.stopped; } + /** + * Wait for the session to complete (all agents idle or stopped). + * + * Polls agent status every `pollMs` and resolves when `this.stopped` is true + * or `timeoutMs` expires. Returns the final stop reason. + */ + async waitForCompletion(timeoutMs = 300_000, pollMs = 3_000): Promise { + if (this.stopped) return this.stopReason ?? "Already stopped"; + + const deadline = Date.now() + timeoutMs; + return new Promise((resolve) => { + const poll = setInterval(async () => { + await this.checkAllIdle(); + if (this.stopped || Date.now() >= deadline) { + clearInterval(poll); + if (!this.stopped) { + void this.stop("Session timed out"); + } + resolve(this.stopReason ?? "Timed out"); + } + }, pollMs); + }); + } + /** * Resume an agent that crashed or hit context limits. * Queries the DAG for contributions since the agent last contributed, @@ -270,8 +548,19 @@ export class SessionOrchestrator { throw new Error(`Role '${role}' not found in topology`); } - // Spawn a new session for the role - const newSession = await this.spawnAgent(roleSpec); + // Reuse the existing workspace from the old agent if available, + // otherwise provision a fresh one. Reprovisioning would fail because + // the git branch/worktree path already exists from the original spawn. + const existingAgent = this.agents.find((a) => a.role === role); + const ws = existingAgent + ? { cwd: existingAgent.workspaceMode.path, workspaceMode: existingAgent.workspaceMode } + : await this.provisionAgentWorkspace( + roleSpec, + this.config.workspaceIsolationPolicy ?? "strict", + resolveRoleWorkspaceStrategies(this.config.topology, this.sessionId).get(roleSpec.name) ?? + "HEAD", + ); + const newSession = await this.spawnAgent(roleSpec, undefined, ws); // Send a reconciliation message const message = `[grove] You are resuming role '${role}'. Query the DAG via grove_log or grove_frontier to catch up on what happened while you were offline.`; diff --git a/src/core/session.ts b/src/core/session.ts index 1c421c4a..8afb10a3 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -42,6 +42,12 @@ export interface Session { readonly contributionCount: number; /** Frozen contract snapshot at session creation time. */ readonly config?: GroveContract | undefined; + /** + * Resolved workspace base-branch per role. + * Format: { "coder": "HEAD", "reviewer": "grove//coder" } + * Edges with `workspace: "branch_from_source"` make the target branch off the source. + */ + readonly worktreeStrategies?: Record | undefined; } // --------------------------------------------------------------------------- diff --git a/src/core/topology-router.ts b/src/core/topology-router.ts index abd51d54..40f73265 100644 --- a/src/core/topology-router.ts +++ b/src/core/topology-router.ts @@ -1,5 +1,5 @@ import type { EventBus, GroveEvent } from "./event-bus.js"; -import type { AgentTopology, RoleEdge } from "./topology.js"; +import type { AgentRole, AgentTopology, RoleEdge } from "./topology.js"; /** * Routes contribution events through topology edges. @@ -21,10 +21,13 @@ export class TopologyRouter { private readonly eventBus: EventBus; // source role → outgoing edges, deduplicated by (target, edgeType) pair private readonly edgeMap: ReadonlyMap; + private readonly roleMap: ReadonlyMap; constructor(topology: AgentTopology, eventBus: EventBus) { this.topology = topology; this.eventBus = eventBus; + // Index roles by name for mode lookup + this.roleMap = new Map(topology.roles.map((r) => [r.name, r])); // Pre-compute: source role -> outgoing RoleEdge[], deduped by (target, edgeType). // Use a Set keyed by "target:edgeType" for O(1) dedup instead of O(n) Array.includes. const map = new Map(); @@ -60,28 +63,46 @@ export class TopologyRouter { * target roles that received the event. */ route(sourceRole: string, payload: Record): readonly string[] { - const edges = this.edgeMap.get(sourceRole); - if (!edges || edges.length === 0) return []; + const role = this.roleMap.get(sourceRole); + const mode = role?.mode ?? "explicit"; const timestamp = new Date().toISOString(); - const routedTo: string[] = []; - const publishedTargets = new Set(); + const targets = new Set(); - for (const edge of edges) { - if (!publishedTargets.has(edge.target)) { - publishedTargets.add(edge.target); - const event: GroveEvent = { - type: "contribution", - sourceRole, - targetRole: edge.target, - payload, - timestamp, - }; - this.eventBus.publish(event); - routedTo.push(edge.target); + // Explicit: follow defined edges + if (mode === "explicit") { + const edges = this.edgeMap.get(sourceRole); + if (edges) { + for (const edge of edges) { + targets.add(edge.target); + } } } + // Broadcast: notify ALL other roles + if (mode === "broadcast") { + for (const r of this.topology.roles) { + if (r.name !== sourceRole) { + targets.add(r.name); + } + } + } + + if (targets.size === 0) return []; + + const routedTo: string[] = []; + for (const targetRole of targets) { + const event: GroveEvent = { + type: "contribution", + sourceRole, + targetRole, + payload, + timestamp, + }; + this.eventBus.publish(event); + routedTo.push(targetRole); + } + return routedTo; } @@ -110,6 +131,16 @@ export class TopologyRouter { * Returns an empty array for unknown roles. */ targetsFor(sourceRole: string): readonly RoleEdge[] { + const role = this.roleMap.get(sourceRole); + const mode = role?.mode ?? "explicit"; + + if (mode === "broadcast") { + // Broadcast: return synthetic edges to all other roles + return this.topology.roles + .filter((r) => r.name !== sourceRole) + .map((r) => ({ target: r.name, edgeType: "delegates" as const })); + } + return this.edgeMap.get(sourceRole) ?? []; } } diff --git a/src/core/topology-workspace.test.ts b/src/core/topology-workspace.test.ts new file mode 100644 index 00000000..891479ad --- /dev/null +++ b/src/core/topology-workspace.test.ts @@ -0,0 +1,190 @@ +/** + * Unit tests for workspace strategy resolution via explicit edge.workspace field. + * + * Verifies that resolveRoleWorkspaceStrategies() and topologicalSortRoles() + * only create branch dependencies when `workspace: "branch_from_source"` is set. + * Edge type alone does NOT determine workspace behavior. + */ + +import { describe, expect, test } from "bun:test"; +import type { AgentTopology } from "./topology.js"; +import { resolveRoleWorkspaceStrategies, topologicalSortRoles } from "./topology.js"; + +// --------------------------------------------------------------------------- +// resolveRoleWorkspaceStrategies +// --------------------------------------------------------------------------- + +describe("resolveRoleWorkspaceStrategies", () => { + test("flat topology: all roles use HEAD", () => { + const topology: AgentTopology = { + structure: "flat", + roles: [ + { name: "coder", description: "writes code" }, + { name: "tester", description: "tests code" }, + ], + }; + const strategies = resolveRoleWorkspaceStrategies(topology, "sess-abc"); + expect(strategies.get("coder")).toBe("HEAD"); + expect(strategies.get("tester")).toBe("HEAD"); + }); + + test("edge without workspace field: both use HEAD (default independent)", () => { + const topology: AgentTopology = { + structure: "graph", + roles: [ + { name: "coder", edges: [{ target: "reviewer", edgeType: "delegates" }] }, + { name: "reviewer" }, + ], + }; + const strategies = resolveRoleWorkspaceStrategies(topology, "sess-abc"); + expect(strategies.get("coder")).toBe("HEAD"); + expect(strategies.get("reviewer")).toBe("HEAD"); + }); + + test("workspace: branch_from_source: target branches off source", () => { + const topology: AgentTopology = { + structure: "graph", + roles: [ + { + name: "coder", + edges: [{ target: "reviewer", edgeType: "delegates", workspace: "branch_from_source" }], + }, + { name: "reviewer" }, + ], + }; + const strategies = resolveRoleWorkspaceStrategies(topology, "sess-abc"); + expect(strategies.get("coder")).toBe("HEAD"); + expect(strategies.get("reviewer")).toBe("grove/sess-abc/coder"); + }); + + test("workspace: independent: explicit independent even on delegates edge", () => { + const topology: AgentTopology = { + structure: "graph", + roles: [ + { + name: "coder", + edges: [{ target: "reviewer", edgeType: "delegates", workspace: "independent" }], + }, + { name: "reviewer" }, + ], + }; + const strategies = resolveRoleWorkspaceStrategies(topology, "sess-abc"); + expect(strategies.get("reviewer")).toBe("HEAD"); + }); + + test("branch_from_source works on any edge type", () => { + const topology: AgentTopology = { + structure: "graph", + roles: [ + { + name: "coder", + edges: [{ target: "reviewer", edgeType: "feedback", workspace: "branch_from_source" }], + }, + { name: "reviewer" }, + ], + }; + const strategies = resolveRoleWorkspaceStrategies(topology, "sess-fb"); + expect(strategies.get("reviewer")).toBe("grove/sess-fb/coder"); + }); + + test("mixed edges: only branch_from_source creates dependency", () => { + const topology: AgentTopology = { + structure: "graph", + roles: [ + { + name: "coder", + edges: [ + { target: "reviewer", edgeType: "delegates", workspace: "branch_from_source" }, + { target: "monitor", edgeType: "reports" }, // no workspace field → independent + ], + }, + { name: "reviewer" }, + { name: "monitor" }, + ], + }; + const strategies = resolveRoleWorkspaceStrategies(topology, "sess-mix"); + expect(strategies.get("coder")).toBe("HEAD"); + expect(strategies.get("reviewer")).toBe("grove/sess-mix/coder"); + expect(strategies.get("monitor")).toBe("HEAD"); + }); +}); + +// --------------------------------------------------------------------------- +// topologicalSortRoles +// --------------------------------------------------------------------------- + +describe("topologicalSortRoles", () => { + test("flat topology: original order preserved", () => { + const topology: AgentTopology = { + structure: "flat", + roles: [{ name: "alpha" }, { name: "beta" }, { name: "gamma" }], + }; + const sorted = topologicalSortRoles(topology); + expect(sorted.map((r) => r.name)).toEqual(["alpha", "beta", "gamma"]); + }); + + test("edge without workspace: no ordering constraint", () => { + const topology: AgentTopology = { + structure: "graph", + roles: [ + { name: "reviewer" }, + { name: "coder", edges: [{ target: "reviewer", edgeType: "delegates" }] }, + ], + }; + // No workspace field → no ordering → original order preserved + const sorted = topologicalSortRoles(topology); + expect(sorted.map((r) => r.name)).toEqual(["reviewer", "coder"]); + }); + + test("branch_from_source: source before target", () => { + const topology: AgentTopology = { + structure: "graph", + roles: [ + { name: "reviewer" }, // listed first + { + name: "coder", + edges: [{ target: "reviewer", edgeType: "delegates", workspace: "branch_from_source" }], + }, + ], + }; + const sorted = topologicalSortRoles(topology); + const coderIdx = sorted.findIndex((r) => r.name === "coder"); + const reviewerIdx = sorted.findIndex((r) => r.name === "reviewer"); + expect(coderIdx).toBeLessThan(reviewerIdx); + }); + + test("chain with branch_from_source: A→B→C sorted A,B,C", () => { + const topology: AgentTopology = { + structure: "graph", + roles: [ + { name: "C" }, + { + name: "B", + edges: [{ target: "C", edgeType: "delegates", workspace: "branch_from_source" }], + }, + { name: "A", edges: [{ target: "B", edgeType: "feeds", workspace: "branch_from_source" }] }, + ], + }; + const sorted = topologicalSortRoles(topology); + const names = sorted.map((r) => r.name); + expect(names.indexOf("A")).toBeLessThan(names.indexOf("B")); + expect(names.indexOf("B")).toBeLessThan(names.indexOf("C")); + }); + + test("independent workspace: no ordering even with delegates edge", () => { + const topology: AgentTopology = { + structure: "graph", + roles: [ + { + name: "reviewer", + edges: [{ target: "coder", edgeType: "feedback", workspace: "independent" }], + }, + { name: "coder" }, + ], + }; + const sorted = topologicalSortRoles(topology); + expect(sorted).toHaveLength(2); + // Original order preserved — no dependency + expect(sorted.map((r) => r.name)).toEqual(["reviewer", "coder"]); + }); +}); diff --git a/src/core/topology.ts b/src/core/topology.ts index 2eb0e961..dfc6ace7 100644 --- a/src/core/topology.ts +++ b/src/core/topology.ts @@ -13,12 +13,24 @@ import { z } from "zod"; // Zod Schemas (snake_case — matches YAML frontmatter wire format) // --------------------------------------------------------------------------- -const EdgeTypeEnum = z.enum(["delegates", "reports", "feeds", "requests", "feedback", "escalates"]); +const EdgeTypeEnum = z.enum([ + "delegates", // Forward work: source produces, target acts on it + "feedback", // Response: target sends results back to source + "monitors", // Observe-only: source watches target + // Legacy aliases — mapped to delegates at parse time for backward compat + "reports", + "feeds", + "requests", + "escalates", +]); + +const WorkspaceStrategyEnum = z.enum(["branch_from_source", "independent"]); const RoleEdgeSchema = z .object({ target: z.string().min(1).max(64), edge_type: EdgeTypeEnum, + workspace: WorkspaceStrategyEnum.optional(), }) .strict(); @@ -31,6 +43,8 @@ const SpawningConfigSchema = z }) .strict(); +const RoleModeEnum = z.enum(["explicit", "broadcast"]); + const TopologyRoleWithEdgesSchema = z .object({ name: z @@ -40,6 +54,8 @@ const TopologyRoleWithEdgesSchema = z .max(64), description: z.string().max(256).optional(), max_instances: z.number().int().min(1).max(100).optional(), + /** Routing mode: explicit = follow edges only; broadcast = notify all roles. */ + mode: RoleModeEnum.optional(), edges: z.array(RoleEdgeSchema).max(50).optional(), command: z.string().max(512).optional(), // Profile fields — runtime agent configuration (boardroom) @@ -61,16 +77,19 @@ interface WireAgentTopology { readonly name: string; readonly description?: string | undefined; readonly max_instances?: number | undefined; + readonly mode?: "explicit" | "broadcast" | undefined; readonly edges?: | readonly { readonly target: string; readonly edge_type: | "delegates" + | "feedback" + | "monitors" | "reports" | "feeds" | "requests" - | "feedback" | "escalates"; + readonly workspace?: "branch_from_source" | "independent" | undefined; }[] | undefined; readonly command?: string | undefined; @@ -180,13 +199,116 @@ export const AgentTopologySchema: z.ZodType = z // TypeScript Types (camelCase, readonly) // --------------------------------------------------------------------------- -/** Edge type between agent roles. */ -export type EdgeType = "delegates" | "reports" | "feeds" | "requests" | "feedback" | "escalates"; +/** + * Edge type between agent roles. + * + * Core types: + * delegates — forward work: source produces, target acts on it + * feedback — response: target sends results back to source + * monitors — observe-only: source watches target without producing + * + * Legacy aliases (mapped to delegates): reports, feeds, requests, escalates + */ +export type EdgeType = + | "delegates" + | "feedback" + | "monitors" + // Legacy — kept for backward compat with existing GROVE.md files + | "reports" + | "feeds" + | "requests" + | "escalates"; + +/** + * Workspace strategy for an edge — controls whether the target role's worktree + * branches off the source role's branch or starts fresh from HEAD. + * + * - `branch_from_source`: target's worktree is based on the source's grove branch. + * Target can `git merge grove//` to pick up source's commits. + * - `independent` (default): target's worktree starts from HEAD. No branch relationship. + */ +export type WorkspaceStrategy = "branch_from_source" | "independent"; + +/** + * Resolve the git base branch for each role's worktree. + * + * Only edges with explicit `workspace: "branch_from_source"` create a branch + * dependency. All other edges (and the default) use HEAD. + * + * Returns a Map where baseBranch is either "HEAD" or + * "grove//". + */ +export function resolveRoleWorkspaceStrategies( + topology: AgentTopology, + sessionId: string, +): Map { + const strategies = new Map(topology.roles.map((r) => [r.name, "HEAD"])); + + for (const role of topology.roles) { + for (const edge of role.edges ?? []) { + if (edge.workspace === "branch_from_source") { + strategies.set(edge.target, `grove/${sessionId}/${role.name}`); + } + } + } + + return strategies; +} + +/** + * Topologically sort roles so that source roles are provisioned before + * their dependents (which need the source's git branch to exist). + * + * Only edges with `workspace: "branch_from_source"` create ordering + * constraints. Uses Kahn's algorithm. Falls back to the original role + * order if a cycle is detected. + */ +export function topologicalSortRoles(topology: AgentTopology): readonly AgentRole[] { + const roles = topology.roles; + const roleByName = new Map(roles.map((r) => [r.name, r])); + + // Build reverse dependency map: role → set of roles that must come before it + const deps = new Map>(roles.map((r) => [r.name, new Set()])); + for (const role of roles) { + for (const edge of role.edges ?? []) { + if (edge.workspace === "branch_from_source") { + deps.get(edge.target)?.add(role.name); + } + } + } + + // Kahn's algorithm: start from roles with no dependencies + const inDegree = new Map(roles.map((r) => [r.name, deps.get(r.name)?.size ?? 0])); + const queue = roles.filter((r) => (inDegree.get(r.name) ?? 0) === 0); + const sorted: AgentRole[] = []; + + while (queue.length > 0) { + const role = queue.shift(); + if (!role) break; + sorted.push(role); + // Reduce in-degree for all roles that depended on this one + for (const [name, depSet] of deps) { + if (depSet.has(role.name)) { + const newDegree = (inDegree.get(name) ?? 1) - 1; + inDegree.set(name, newDegree); + if (newDegree === 0) { + const r = roleByName.get(name); + if (r) queue.push(r); + } + } + } + } + + // Cycle detected — fall back to original order + return sorted.length === roles.length ? sorted : [...roles]; +} /** A directed edge from a role to a target role. */ export interface RoleEdge { readonly target: string; readonly edgeType: EdgeType; + /** Workspace strategy — default: independent (HEAD). */ + readonly workspace?: WorkspaceStrategy | undefined; } /** Supported agent platform identifiers (boardroom). */ @@ -197,6 +319,8 @@ export interface AgentRole { readonly name: string; readonly description?: string | undefined; readonly maxInstances?: number | undefined; + /** Routing mode: explicit = follow edges only; broadcast = notify all roles. Default: explicit. */ + readonly mode?: "explicit" | "broadcast" | undefined; readonly edges?: readonly RoleEdge[] | undefined; /** Shell command to run when spawning this role (defaults to $SHELL). */ readonly command?: string | undefined; @@ -241,11 +365,13 @@ export function wireToTopology(wire: z.infer): Agent name: role.name, ...(role.description !== undefined && { description: role.description }), ...(role.max_instances !== undefined && { maxInstances: role.max_instances }), + ...(role.mode !== undefined && { mode: role.mode }), ...(role.edges !== undefined && { edges: role.edges.map( (edge): RoleEdge => ({ target: edge.target, edgeType: edge.edge_type, + ...(edge.workspace !== undefined && { workspace: edge.workspace }), }), ), }), diff --git a/src/core/workspace-bootstrap.test.ts b/src/core/workspace-bootstrap.test.ts new file mode 100644 index 00000000..baf28529 --- /dev/null +++ b/src/core/workspace-bootstrap.test.ts @@ -0,0 +1,117 @@ +/** + * Tests for bootstrapWorkspace. + * + * Exercises real file I/O against a temporary directory. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { bootstrapWorkspace } from "./workspace-bootstrap.js"; + +describe("bootstrapWorkspace", () => { + let workspaceDir: string; + + beforeEach(() => { + workspaceDir = mkdtempSync(join(tmpdir(), "grove-bootstrap-test-")); + }); + + afterEach(() => { + rmSync(workspaceDir, { recursive: true, force: true }); + }); + + test("writes CLAUDE.md and CODEX.md with role and goal", async () => { + await bootstrapWorkspace({ + workspacePath: workspaceDir, + roleId: "coder", + goal: "Build the auth module", + }); + + const claudeMd = readFileSync(join(workspaceDir, "CLAUDE.md"), "utf-8"); + expect(claudeMd).toContain("coder"); + expect(claudeMd).toContain("Build the auth module"); + + const codexMd = readFileSync(join(workspaceDir, "CODEX.md"), "utf-8"); + expect(codexMd).toContain("coder"); + }); + + test("writes .mcp.json when mcpServePath and groveDir are provided", async () => { + await bootstrapWorkspace({ + workspacePath: workspaceDir, + roleId: "reviewer", + goal: "Review the PR", + mcpServePath: "/path/to/serve.ts", + groveDir: "/path/to/.grove", + }); + + const mcpPath = join(workspaceDir, ".mcp.json"); + expect(existsSync(mcpPath)).toBe(true); + + const mcp = JSON.parse(readFileSync(mcpPath, "utf-8")); + expect(mcp.mcpServers.grove.command).toBe("bun"); + expect(mcp.mcpServers.grove.env.GROVE_AGENT_ROLE).toBe("reviewer"); + expect(mcp.mcpServers.grove.env.GROVE_DIR).toBe("/path/to/.grove"); + }); + + test("skips .mcp.json when mcpServePath is absent", async () => { + await bootstrapWorkspace({ + workspacePath: workspaceDir, + roleId: "coder", + goal: "Build something", + }); + + expect(existsSync(join(workspaceDir, ".mcp.json"))).toBe(false); + }); + + test("includes nexusUrl and nexusApiKey in .mcp.json env", async () => { + await bootstrapWorkspace({ + workspacePath: workspaceDir, + roleId: "coder", + goal: "Build", + mcpServePath: "/serve.ts", + groveDir: "/.grove", + nexusUrl: "https://nexus.example.com", + nexusApiKey: "secret-key", + }); + + const mcp = JSON.parse(readFileSync(join(workspaceDir, ".mcp.json"), "utf-8")); + expect(mcp.mcpServers.grove.env.GROVE_NEXUS_URL).toBe("https://nexus.example.com"); + expect(mcp.mcpServers.grove.env.NEXUS_API_KEY).toBe("secret-key"); + }); + + test("instructions contain agent identity and role", async () => { + await bootstrapWorkspace({ + workspacePath: workspaceDir, + roleId: "architect", + goal: "Design the system", + roleDescription: "You design high-level architecture", + rolePrompt: "Focus on scalability", + }); + + const claudeMd = readFileSync(join(workspaceDir, "CLAUDE.md"), "utf-8"); + expect(claudeMd).toContain("architect"); + expect(claudeMd).toContain("You design high-level architecture"); + expect(claudeMd).toContain("Focus on scalability"); + }); + + test("creates .grove context directory", async () => { + await bootstrapWorkspace({ + workspacePath: workspaceDir, + roleId: "coder", + goal: "Build", + }); + + expect(existsSync(join(workspaceDir, ".grove"))).toBe(true); + }); + + test("throws when workspacePath does not exist", async () => { + await expect( + bootstrapWorkspace({ + workspacePath: "/nonexistent/path/that/cannot/exist", + roleId: "coder", + goal: "Build", + }), + ).rejects.toThrow(); + }); +}); diff --git a/src/core/workspace-bootstrap.ts b/src/core/workspace-bootstrap.ts index ead66c74..2ccb77f9 100644 --- a/src/core/workspace-bootstrap.ts +++ b/src/core/workspace-bootstrap.ts @@ -52,6 +52,26 @@ export async function bootstrapWorkspace(opts: BootstrapOptions): Promise }, }; await writeFile(join(workspacePath, ".mcp.json"), JSON.stringify(mcpConfig, null, 2), "utf-8"); + + // Write .acpxrc.json — acpx (>=0.5.3) reads THIS, not .mcp.json. + // Without it, acpx launches agents with mcpServers=[] and grove_* tools + // are unavailable. Format: array of servers with name/type/command/args/env. + const acpxRcConfig = { + mcpServers: [ + { + name: "grove", + type: "stdio", + command: "bun", + args: ["run", opts.mcpServePath], + env: Object.entries(mcpEnv).map(([name, value]) => ({ name, value })), + }, + ], + }; + await writeFile( + join(workspacePath, ".acpxrc.json"), + JSON.stringify(acpxRcConfig, null, 2), + "utf-8", + ); } // Write CLAUDE.md / CODEX.md @@ -73,12 +93,28 @@ You are the **${roleId}** agent. Always pass \`agent: { role: "${roleId}" }\` in ## Communication You will receive push notifications when other agents produce work. Do NOT poll. +When you receive a notification with a source branch, run \`git merge \` to see the actual file changes in your workspace. + +## MCP Tools + +- \`grove_submit_work\` — submit your work. Pass commitHash (git commit SHA) so others can see your files. +- \`grove_submit_review\` — review another agent's work. Requires targetCid from the notification. +- \`grove_done\` — signal session complete. Only call when work is approved. + +## Workflow -## MCP Tools (use sparingly) +**Submitting work (coder):** +1. Edit files in your workspace +2. \`git add -A && git commit -m "description"\` +3. Get the commit hash: \`git rev-parse HEAD\` +4. \`grove_submit_work({ summary: "...", commitHash: "", agent: { role: "${roleId}" } })\` -- \`grove_submit_work\` — record work with artifacts (always include agent: { role: "${roleId}" }) -- \`grove_submit_review\` — review another agent's work with scores (always include agent: { role: "${roleId}" }) -- \`grove_done\` — signal session complete (only after approval from other agents) +**Reviewing work (reviewer):** +1. When notified: the notification includes a **Workspace** path — read the source files directly from that path +2. Example: \`cat /path/to/coder-workspace/app.js\` to see the actual code +3. Review the code for bugs, correctness, quality +4. \`grove_submit_review({ targetCid: "", summary: "...", scores: {"correctness": {"value": 0.9, "direction": "maximize"}}, agent: { role: "${roleId}" } })\` +5. If approved: \`grove_done({ summary: "Approved", agent: { role: "${roleId}" } })\` Follow the Instructions section above exactly. You can edit files, commit, push, create PRs, and use gh CLI. `; @@ -91,7 +127,7 @@ Follow the Instructions section above exactly. You can edit files, commit, push, await mkdir(contextDir, { recursive: true }); // Protect config files from agent mutation - for (const f of [".mcp.json", "CLAUDE.md", "CODEX.md"]) { + for (const f of [".mcp.json", ".acpxrc.json", "CLAUDE.md", "CODEX.md"]) { await chmod(join(workspacePath, f), 0o444).catch(() => { // File may not exist }); diff --git a/src/core/workspace-provisioner.test.ts b/src/core/workspace-provisioner.test.ts index fcef51a1..d40d2bf3 100644 --- a/src/core/workspace-provisioner.test.ts +++ b/src/core/workspace-provisioner.test.ts @@ -62,9 +62,9 @@ describe("WorkspaceProvisioner", () => { rmSync(repoDir, { recursive: true, force: true }); }); - test("provisionWorkspace creates a worktree with a clean git status", () => { + test("provisionWorkspace creates a worktree with a clean git status", async () => { const sessionId = "abcdef1234567890"; - const result = provisionWorkspace({ + const result = await provisionWorkspace({ role: "coder", sessionId, baseDir, @@ -91,12 +91,12 @@ describe("WorkspaceProvisioner", () => { expect(branches).toContain(`grove/${sessionId}/coder`); }); - test("provisionWorkspace writes .mcp.json when mcpConfig is provided", () => { + test("provisionWorkspace writes .mcp.json when mcpConfig is provided", async () => { const mcpConfig = { mcpServers: { grove: { command: "grove-mcp", args: ["--session", "s1"] } }, }; - const result = provisionWorkspace({ + const result = await provisionWorkspace({ role: "reviewer", sessionId: "sess00001111222233", baseDir, @@ -147,13 +147,13 @@ describe("WorkspaceProvisioner", () => { expect(session.errors[0]!.message).toBeTruthy(); }); - test("cleanupSessionWorkspaces removes worktrees and branches", () => { + test("cleanupSessionWorkspaces removes worktrees and branches", async () => { const sessionId = "cleanup-session-01"; const roles = ["dev", "qa"]; // Provision first - const workspaces = roles.map((role) => - provisionWorkspace({ role, sessionId, baseDir, repoRoot: repoDir }), + const workspaces = await Promise.all( + roles.map((role) => provisionWorkspace({ role, sessionId, baseDir, repoRoot: repoDir })), ); // Verify they exist @@ -162,7 +162,7 @@ describe("WorkspaceProvisioner", () => { } // Clean up - cleanupSessionWorkspaces(workspaces, repoDir); + await cleanupSessionWorkspaces(workspaces, repoDir); // Verify worktree directories are gone for (const ws of workspaces) { @@ -179,10 +179,10 @@ describe("WorkspaceProvisioner", () => { } }); - test("worktree paths use role + sessionId prefix", () => { + test("worktree paths use role + sessionId prefix", async () => { const sessionId = "abcdef1234567890abcdef1234567890"; - const result = provisionWorkspace({ + const result = await provisionWorkspace({ role: "planner", sessionId, baseDir, @@ -194,7 +194,7 @@ describe("WorkspaceProvisioner", () => { expect(result.path).toBe(expectedPath); }); - test("provisionWorkspace respects baseBranch option", () => { + test("provisionWorkspace respects baseBranch option", async () => { // Create a new branch in the repo execSync("git checkout -b feature-base", { cwd: repoDir, stdio: "pipe" }); execSync("git commit --allow-empty -m 'feature commit'", { @@ -203,7 +203,7 @@ describe("WorkspaceProvisioner", () => { }); execSync("git checkout -", { cwd: repoDir, stdio: "pipe" }); - const result = provisionWorkspace({ + const result = await provisionWorkspace({ role: "tester", sessionId: "base-branch-session", baseDir, diff --git a/src/core/workspace-provisioner.ts b/src/core/workspace-provisioner.ts index 6da42093..96073a0d 100644 --- a/src/core/workspace-provisioner.ts +++ b/src/core/workspace-provisioner.ts @@ -5,14 +5,35 @@ * Each agent gets an isolated copy of the repo with its own branch. */ -import { execSync } from "node:child_process"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { execFile } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- +/** + * How an agent's workspace was provisioned. + * + * - `isolated_worktree`: dedicated git worktree with all config files written. + * - `fallback_workspace`: worktree creation failed; agent runs from an + * alternative path (project root or provider workspace). Config files may + * be missing. + * - `bootstrap_failed`: worktree was created but config file writes failed; + * agent has a clean git checkout but no .mcp.json / CLAUDE.md. + */ +export type WorkspaceMode = + | { readonly status: "isolated_worktree"; readonly path: string; readonly branch: string } + | { readonly status: "fallback_workspace"; readonly path: string; readonly reason: string } + | { readonly status: "bootstrap_failed"; readonly path: string; readonly reason: string }; + +/** Controls whether workspace provisioning failures cause a spawn to fail. */ +export type WorkspaceIsolationPolicy = "strict" | "allow-fallback"; + /** Options for provisioning a single workspace. */ export interface WorkspaceProvisionOptions { /** Role name (used in worktree path and branch name). */ @@ -61,29 +82,31 @@ export interface WorkspaceProvisionError { * Creates a git worktree at `/-` on a new * branch named `grove//`. Optionally writes an `.mcp.json` * config file into the worktree root. + * + * Uses `execFile` (not `execSync`) so it does not block the event loop and + * is safe against shell injection in role names or paths. */ -export function provisionWorkspace(options: WorkspaceProvisionOptions): ProvisionedWorkspace { +export async function provisionWorkspace( + options: WorkspaceProvisionOptions, +): Promise { const { role, sessionId, baseDir, repoRoot, mcpConfig, baseBranch } = options; const worktreePath = join(baseDir, `${role}-${sessionId.slice(0, 8)}`); const branch = `grove/${sessionId}/${role}`; - // Create base directory if needed - if (!existsSync(baseDir)) { - mkdirSync(baseDir, { recursive: true }); - } + // Ensure base directory exists. safe to call concurrently — recursive:true is idempotent. + await mkdir(baseDir, { recursive: true }); - // Create the git worktree + // Create the git worktree. execFile avoids a shell invocation, preventing + // injection via role name or path and allowing true async execution. const base = baseBranch ?? "HEAD"; - execSync(`git worktree add "${worktreePath}" -b "${branch}" ${base}`, { + await execFileAsync("git", ["worktree", "add", worktreePath, "-b", branch, base], { cwd: repoRoot, - encoding: "utf-8", - stdio: "pipe", }); // Write .mcp.json if provided if (mcpConfig !== undefined) { - writeFileSync(join(worktreePath, ".mcp.json"), JSON.stringify(mcpConfig, null, 2)); + await writeFile(join(worktreePath, ".mcp.json"), JSON.stringify(mcpConfig, null, 2)); } return { role, path: worktreePath, branch, sessionId }; @@ -105,26 +128,14 @@ export async function provisionSessionWorkspaces( ): Promise { const start = Date.now(); + // Pre-create baseDir once before the parallel loop to avoid N concurrent + // mkdir calls racing on the same path. + await mkdir(baseDir, { recursive: true }); + // Create all worktrees in parallel const results = await Promise.allSettled( - roles.map( - (role) => - // Wrap sync operation in a microtask to allow parallel scheduling - new Promise((resolve, reject) => { - try { - const result = provisionWorkspace({ - role, - sessionId, - baseDir, - repoRoot, - mcpConfig, - baseBranch, - }); - resolve(result); - } catch (error) { - reject(error); - } - }), + roles.map((role) => + provisionWorkspace({ role, sessionId, baseDir, repoRoot, mcpConfig, baseBranch }), ), ); @@ -155,33 +166,26 @@ export async function provisionSessionWorkspaces( /** * Clean up worktrees for a session. * - * Removes each worktree directory and deletes the associated branch. - * Errors are silently ignored (best-effort cleanup). + * Removes each worktree directory and deletes the associated branch in + * parallel. Errors are silently ignored (best-effort cleanup). */ -export function cleanupSessionWorkspaces( +export async function cleanupSessionWorkspaces( workspaces: readonly ProvisionedWorkspace[], repoRoot: string, -): void { - for (const ws of workspaces) { - try { - execSync(`git worktree remove "${ws.path}" --force`, { - cwd: repoRoot, - encoding: "utf-8", - stdio: "pipe", - }); - } catch { - // Best effort — worktree may already be removed - } - - // Also delete the branch - try { - execSync(`git branch -D "${ws.branch}"`, { - cwd: repoRoot, - encoding: "utf-8", - stdio: "pipe", - }); - } catch { - // Best effort - } - } +): Promise { + await Promise.allSettled( + workspaces.map(async (ws) => { + try { + await execFileAsync("git", ["worktree", "remove", ws.path, "--force"], { cwd: repoRoot }); + } catch { + // Best effort — worktree may already be removed + } + + try { + await execFileAsync("git", ["branch", "-D", ws.branch], { cwd: repoRoot }); + } catch { + // Best effort + } + }), + ); } diff --git a/src/local/sqlite-goal-session-store.ts b/src/local/sqlite-goal-session-store.ts index ca3e5261..85154348 100644 --- a/src/local/sqlite-goal-session-store.ts +++ b/src/local/sqlite-goal-session-store.ts @@ -18,6 +18,7 @@ import type { Database, Statement } from "bun:sqlite"; import type { GroveContract } from "../core/contract.js"; import type { CreateSessionInput, Session, SessionQuery } from "../core/session.js"; import type { AgentTopology } from "../core/topology.js"; +import { resolveRoleWorkspaceStrategies } from "../core/topology.js"; import type { GoalData } from "../tui/provider.js"; // --------------------------------------------------------------------------- @@ -41,6 +42,7 @@ export const GOAL_SESSION_DDL = ` preset_name TEXT, topology_json TEXT, config_json TEXT NOT NULL DEFAULT '{}', + worktree_strategy_json TEXT, status TEXT NOT NULL DEFAULT 'active', started_at TEXT NOT NULL, ended_at TEXT, @@ -94,6 +96,7 @@ interface SessionRow { preset_name: string | null; topology_json: string | null; config_json: string | null; + worktree_strategy_json: string | null; status: string; started_at: string; ended_at: string | null; @@ -209,6 +212,9 @@ function rowToSession(row: SessionRow): Session { topology: row.topology_json ? (JSON.parse(row.topology_json) as AgentTopology) : undefined, contributionCount: row.contribution_count, config, + worktreeStrategies: row.worktree_strategy_json + ? (JSON.parse(row.worktree_strategy_json) as Record) + : undefined, }; } @@ -349,20 +355,28 @@ export class SqliteGoalSessionStore implements GoalSessionStore { /** Create a new session with a generated UUID. */ createSession = async (input: CreateSessionInput): Promise => { this.stmtInsertSession ??= this.db.prepare(` - INSERT INTO sessions (session_id, goal, preset_name, topology_json, config_json, status, started_at) - VALUES (?, ?, ?, ?, ?, 'active', ?) + INSERT INTO sessions (session_id, goal, preset_name, topology_json, config_json, worktree_strategy_json, status, started_at) + VALUES (?, ?, ?, ?, ?, ?, 'active', ?) `); const sessionId = crypto.randomUUID(); const startedAt = new Date().toISOString(); const topologyJson = input.topology ? JSON.stringify(input.topology) : null; const configJson = input.config ? JSON.stringify(input.config) : "{}"; + // Resolve and store workspace strategies so operators can see which roles + // branched off which source branch at session creation time. + const worktreeStrategies = input.topology + ? Object.fromEntries(resolveRoleWorkspaceStrategies(input.topology, sessionId)) + : null; + const worktreeStrategyJson = worktreeStrategies ? JSON.stringify(worktreeStrategies) : null; + this.stmtInsertSession.run( sessionId, input.goal ?? null, input.presetName ?? null, topologyJson, configJson, + worktreeStrategyJson, startedAt, ); @@ -376,6 +390,7 @@ export class SqliteGoalSessionStore implements GoalSessionStore { topology: input.topology, contributionCount: 0, config: input.config, + worktreeStrategies: worktreeStrategies ?? undefined, }; }; diff --git a/src/local/sqlite-store.ts b/src/local/sqlite-store.ts index e4fadb49..ef927ca4 100644 --- a/src/local/sqlite-store.ts +++ b/src/local/sqlite-store.ts @@ -370,6 +370,26 @@ export function initSqliteDb(dbPath: string): Database { } } + // Migration → v10: add worktree_strategy_json to sessions. + // Stores the resolved workspace base-branch per role as JSON so operators + // can see which roles branched off which source at session creation time. + // Format: { "coder": "HEAD", "reviewer": "grove//coder" } + { + const sessionTableExists = + (db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'") + .get() as { name: string } | null) !== null; + if (sessionTableExists) { + const sessionCols = db.prepare("PRAGMA table_info(sessions)").all() as readonly { + name: string; + }[]; + const sessionColNames = new Set(sessionCols.map((c) => c.name)); + if (!sessionColNames.has("worktree_strategy_json")) { + db.run("ALTER TABLE sessions ADD COLUMN worktree_strategy_json TEXT"); + } + } + } + db.run("INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (?, ?)", [ CURRENT_SCHEMA_VERSION, new Date().toISOString(), diff --git a/src/mcp/tools/contributions.ts b/src/mcp/tools/contributions.ts index 7ae486b7..1650c5aa 100644 --- a/src/mcp/tools/contributions.ts +++ b/src/mcp/tools/contributions.ts @@ -44,7 +44,17 @@ import { const submitWorkInputSchema = z.object({ summary: z.string().describe("Short summary of the work performed"), description: z.string().optional().describe("Longer description of the work"), - artifacts: artifactsSchema, + artifacts: artifactsSchema + .optional() + .describe("File artifacts as path→CAS hash map. Optional when commitHash is provided."), + commitHash: z + .string() + .regex(/^[0-9a-f]{7,40}$/) + .optional() + .describe( + "Git commit SHA from your worktree. Preferred over artifacts — just commit your work and pass the hash. " + + "Other agents can git fetch + git diff to see your changes.", + ), mode: z .enum(["evaluation", "exploration"]) .default("evaluation") @@ -178,11 +188,11 @@ export function registerContributionTools(server: McpServer, deps: McpDeps): voi inputSchema: submitWorkInputSchema, }, async (args) => { - const artifacts = args.artifacts as Record; + const artifacts = (args.artifacts as Record | undefined) ?? {}; + const commitHash = args.commitHash as string | undefined; const warning = - Object.keys(artifacts).length === 0 - ? "No artifacts provided. Reviewers cannot inspect your work without file artifacts. " + - "If you produced files, use grove_cas_put to store them in CAS first, then re-submit with their hashes." + Object.keys(artifacts).length === 0 && !commitHash + ? "No artifacts or commitHash provided. Prefer commitHash — just git commit and pass the SHA." : undefined; const result = await contributeOperation( @@ -190,6 +200,7 @@ export function registerContributionTools(server: McpServer, deps: McpDeps): voi kind: "work", summary: args.summary, artifacts, + ...(commitHash !== undefined ? { commitHash } : {}), ...(args.mode !== undefined ? { mode: args.mode as "evaluation" | "exploration" } : {}), ...(args.description !== undefined ? { description: args.description } : {}), ...(args.context !== undefined diff --git a/src/nexus/nexus-session-store.test.ts b/src/nexus/nexus-session-store.test.ts index 6474a417..4becf5ce 100644 --- a/src/nexus/nexus-session-store.test.ts +++ b/src/nexus/nexus-session-store.test.ts @@ -40,7 +40,9 @@ function createMockClient(): NexusClient { delete: async (path: string) => { files.delete(path); }, - mkdir: async () => {}, + mkdir: async () => { + /* no-op */ + }, } as unknown as NexusClient; } diff --git a/src/server/session-service.test.ts b/src/server/session-service.test.ts index 9f226138..3276c4af 100644 --- a/src/server/session-service.test.ts +++ b/src/server/session-service.test.ts @@ -57,6 +57,7 @@ describe("SessionService", () => { eventBus: bus, projectRoot: "/tmp", workspaceBaseDir: "/tmp/workspaces", + workspaceIsolationPolicy: "allow-fallback" as const, }); const state = service.getState(); @@ -78,6 +79,7 @@ describe("SessionService", () => { eventBus: bus, projectRoot: "/tmp", workspaceBaseDir: "/tmp/workspaces", + workspaceIsolationPolicy: "allow-fallback" as const, }); const state = await service.startSession("Build auth module"); @@ -100,6 +102,7 @@ describe("SessionService", () => { eventBus: bus, projectRoot: "/tmp", workspaceBaseDir: "/tmp/workspaces", + workspaceIsolationPolicy: "allow-fallback" as const, }); const state = await service.startSession("Test", "my-session-42"); @@ -118,6 +121,7 @@ describe("SessionService", () => { eventBus: bus, projectRoot: "/tmp", workspaceBaseDir: "/tmp/workspaces", + workspaceIsolationPolicy: "allow-fallback" as const, }); const events: SessionEvent[] = []; @@ -145,6 +149,7 @@ describe("SessionService", () => { eventBus: bus, projectRoot: "/tmp", workspaceBaseDir: "/tmp/workspaces", + workspaceIsolationPolicy: "allow-fallback" as const, }); const events: SessionEvent[] = []; @@ -171,6 +176,7 @@ describe("SessionService", () => { eventBus: bus, projectRoot: "/tmp", workspaceBaseDir: "/tmp/workspaces", + workspaceIsolationPolicy: "allow-fallback" as const, }); const events: SessionEvent[] = []; @@ -206,6 +212,7 @@ describe("SessionService", () => { eventBus: bus, projectRoot: "/tmp", workspaceBaseDir: "/tmp/workspaces", + workspaceIsolationPolicy: "allow-fallback" as const, }); const events: SessionEvent[] = []; @@ -246,6 +253,7 @@ describe("SessionService", () => { eventBus: bus, projectRoot: "/tmp", workspaceBaseDir: "/tmp/workspaces", + workspaceIsolationPolicy: "allow-fallback" as const, }); const events: SessionEvent[] = []; @@ -273,6 +281,7 @@ describe("SessionService", () => { eventBus: bus, projectRoot: "/tmp", workspaceBaseDir: "/tmp/workspaces", + workspaceIsolationPolicy: "allow-fallback" as const, }); // Should not throw @@ -292,6 +301,7 @@ describe("SessionService", () => { eventBus: bus, projectRoot: "/tmp", workspaceBaseDir: "/tmp/workspaces", + workspaceIsolationPolicy: "allow-fallback" as const, }); // Should not throw @@ -318,6 +328,7 @@ describe("SessionService", () => { eventBus: bus, projectRoot: "/tmp", workspaceBaseDir: "/tmp/workspaces", + workspaceIsolationPolicy: "allow-fallback" as const, }); const goodEvents: SessionEvent[] = []; diff --git a/src/server/session-service.ts b/src/server/session-service.ts index 360d4e28..707ffb44 100644 --- a/src/server/session-service.ts +++ b/src/server/session-service.ts @@ -22,6 +22,7 @@ import type { GroveContract } from "../core/contract.js"; import type { EventBus, EventHandler, GroveEvent } from "../core/event-bus.js"; import { SessionOrchestrator } from "../core/session-orchestrator.js"; import type { AgentTopology } from "../core/topology.js"; +import type { WorkspaceIsolationPolicy } from "../core/workspace-provisioner.js"; // --------------------------------------------------------------------------- // Config @@ -34,6 +35,8 @@ export interface SessionServiceConfig { readonly eventBus: EventBus; readonly projectRoot: string; readonly workspaceBaseDir: string; + /** Workspace isolation policy forwarded to SessionOrchestrator. Defaults to 'strict'. */ + readonly workspaceIsolationPolicy?: WorkspaceIsolationPolicy | undefined; } // --------------------------------------------------------------------------- @@ -144,6 +147,7 @@ export class SessionService { projectRoot: this.config.projectRoot, workspaceBaseDir: this.config.workspaceBaseDir, sessionId: this.sessionId, + workspaceIsolationPolicy: this.config.workspaceIsolationPolicy, }); try { diff --git a/src/server/ws-handler.test.ts b/src/server/ws-handler.test.ts index 704094d4..d8fc84cc 100644 --- a/src/server/ws-handler.test.ts +++ b/src/server/ws-handler.test.ts @@ -30,6 +30,7 @@ function makeService(): { eventBus: bus, projectRoot: "/tmp", workspaceBaseDir: "/tmp/workspaces", + workspaceIsolationPolicy: "allow-fallback" as const, }); return { service, runtime, bus }; } diff --git a/src/tui/screens/agent-detect.tsx b/src/tui/screens/agent-detect.tsx index 67bbd654..0a536f68 100644 --- a/src/tui/screens/agent-detect.tsx +++ b/src/tui/screens/agent-detect.tsx @@ -271,7 +271,6 @@ export const AgentDetect: React.NamedExoticComponent = React.m Topology {dagLines.map((line) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: dag lines have no stable identity {line} diff --git a/src/tui/screens/screen-manager.tsx b/src/tui/screens/screen-manager.tsx index 118ec294..e69f1612 100644 --- a/src/tui/screens/screen-manager.tsx +++ b/src/tui/screens/screen-manager.tsx @@ -13,6 +13,7 @@ import { useKeyboard, useRenderer } from "@opentui/react"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { lookupPresetTopology } from "../../core/presets.js"; +import { topologicalSortRoles } from "../../core/topology.js"; import type { AppProps } from "../app.js"; import { App } from "../app.js"; import { debugLog } from "../debug-log.js"; @@ -23,7 +24,6 @@ import { isGoalProvider, isSessionProvider } from "../provider.js"; import { useSpawnManager } from "../spawn-manager-context.js"; import { theme } from "../theme.js"; import type { TuiPresetEntry } from "../tui-app.js"; - import { AgentDetect } from "./agent-detect.js"; import { CompleteView } from "./complete-view.js"; import { GoalInput } from "./goal-input.js"; @@ -482,6 +482,9 @@ export const ScreenManager: React.NamedExoticComponent = Rea })); spawnManager.setSessionGoal(goal); + // Give SpawnManager the topology so it can resolve edge-type-aware + // base branches (delegates/feeds/escalates → branch off source). + spawnManager.setTopology(topology); // Ensure log buffers exist for all topology roles BEFORE seekToEnd. // startLogPolling(seekToEnd=true) iterates logBuffers to record file // offsets; if buffers don't exist yet, the loop has nothing to iterate @@ -501,36 +504,39 @@ export const ScreenManager: React.NamedExoticComponent = Rea serverRoutingActiveRef.current, ); - // Spawn each role and track progress - for (const role of topology.roles) { - // Use roleMapping from launch preview (user-selected CLI), fall back to GROVE.md command - const command = roleMapping.get(role.name) ?? role.command ?? "codex"; - const context: Record = {}; - const editedPrompt = rolePromptsRef.current.get(role.name); - context.rolePrompt = editedPrompt ?? role.prompt ?? ""; - if (role.description) context.roleDescription = role.description; - if (role.goal) context.roleGoal = role.goal; - if (topology) context.topology = topology; - - // Mark as spawning - setState((s) => ({ - ...s, - spawnStates: (s.spawnStates ?? []).map((a) => - a.role === role.name ? { ...a, status: "spawning" as const } : a, - ), - })); - - void spawnManager - .spawn(role.name, command, undefined, 0, context) - .then(() => { + // Spawn roles in topological order so that source branches exist before + // dependent roles try to base their worktrees on them (delegates/feeds/escalates). + // Sequential spawning is required because provisionWorkspace happens inside spawn(). + void (async () => { + const orderedRoles = topologicalSortRoles(topology); + for (const role of orderedRoles) { + const command = roleMapping.get(role.name) ?? role.command ?? "codex"; + const context: Record = {}; + const editedPrompt = rolePromptsRef.current.get(role.name); + context.rolePrompt = editedPrompt ?? role.prompt ?? ""; + if (role.description) context.roleDescription = role.description; + if (role.goal) context.roleGoal = role.goal; + if (topology) context.topology = topology; + + // Mark as spawning + setState((s) => ({ + ...s, + spawnStates: (s.spawnStates ?? []).map((a) => + a.role === role.name ? { ...a, status: "spawning" as const } : a, + ), + })); + + try { + const result = await spawnManager.spawn(role.name, command, undefined, 0, context); setState((s) => ({ ...s, spawnStates: (s.spawnStates ?? []).map((a) => - a.role === role.name ? { ...a, status: "started" as const } : a, + a.role === role.name + ? { ...a, status: "started" as const, workspaceMode: result.workspaceMode } + : a, ), })); - }) - .catch((err) => { + } catch (err) { setState((s) => ({ ...s, spawnStates: (s.spawnStates ?? []).map((a) => @@ -539,8 +545,9 @@ export const ScreenManager: React.NamedExoticComponent = Rea : a, ), })); - }); - } + } + } + })(); } else { // No topology — go straight to running setState((s) => ({ ...s, screen: "running", goal, sessionStartedAt })); diff --git a/src/tui/screens/spawn-progress.tsx b/src/tui/screens/spawn-progress.tsx index a1ccd241..f2a7a16f 100644 --- a/src/tui/screens/spawn-progress.tsx +++ b/src/tui/screens/spawn-progress.tsx @@ -10,6 +10,7 @@ import { useTimeline } from "@opentui/react"; import { toast } from "@opentui-ui/toast/react"; import React, { useCallback, useEffect, useRef, useState } from "react"; +import type { WorkspaceMode } from "../../core/workspace-provisioner.js"; import { BreadcrumbBar } from "../components/breadcrumb-bar.js"; import { BRAILLE_SPINNER, PLATFORM_COLORS, theme } from "../theme.js"; @@ -22,6 +23,8 @@ export interface AgentSpawnState { readonly command: string; readonly status: SpawnStatus; readonly error?: string | undefined; + /** Workspace mode — set when status is "started". */ + readonly workspaceMode?: WorkspaceMode | undefined; } export interface SpawnProgressProps { @@ -161,21 +164,41 @@ export const SpawnProgress: React.NamedExoticComponent = Rea PLATFORM_COLORS[agent.command] ?? PLATFORM_COLORS.custom ?? theme.text; // Apply pulsing opacity to agents actively spawning const rowOpacity = agent.status === "spawning" ? spawnOpacity : 1; + + // Workspace mode badge — shown only when degraded + const wsMode = agent.workspaceMode; + const degradedBadge = + wsMode?.status === "fallback_workspace" + ? " [shared workspace]" + : wsMode?.status === "bootstrap_failed" + ? " [no config]" + : ""; + return ( - - {getIcon(agent.status)} - {agent.role} - ({agent.command}) - - {" "} - {agent.status === "waiting" - ? "waiting..." - : agent.status === "spawning" - ? "spawning..." - : agent.status === "started" - ? "started" - : `failed: ${agent.error ?? "unknown"}`} - + + + {getIcon(agent.status)} + {agent.role} + ({agent.command}) + + {" "} + {agent.status === "waiting" + ? "waiting..." + : agent.status === "spawning" + ? "spawning..." + : agent.status === "started" + ? "started" + : `failed: ${agent.error ?? "unknown"}`} + + {degradedBadge ? {degradedBadge} : null} + + {/* Show reason for degraded modes inline, truncated to fit */} + {wsMode?.status === "fallback_workspace" || + wsMode?.status === "bootstrap_failed" ? ( + + {`⚠ ${(wsMode.reason ?? "").slice(0, 80)}`} + + ) : null} ); })} diff --git a/src/tui/spawn-manager.test.ts b/src/tui/spawn-manager.test.ts index 15b0ce31..5555e2a3 100644 --- a/src/tui/spawn-manager.test.ts +++ b/src/tui/spawn-manager.test.ts @@ -228,6 +228,12 @@ describe("SpawnManager", () => { // Spawn record is tracked expect(manager.getSpawnRecord(result.spawnId)).toBeDefined(); + + // Workspace mode is always present + expect(result.workspaceMode).toBeDefined(); + expect(["isolated_worktree", "fallback_workspace", "bootstrap_failed"]).toContain( + result.workspaceMode.status, + ); }); test("kill cleans workspace and removes spawn record", async () => { @@ -243,6 +249,9 @@ describe("SpawnManager", () => { ); const result = await manager.spawn("claude", "bash"); + // /tmp/no-grove is not a git repo → worktree fails → fallback_workspace + expect(result.workspaceMode.status).toBe("fallback_workspace"); + const sessionName = `grove-${result.spawnId}`; await manager.kill(sessionName); @@ -279,6 +288,11 @@ describe("SpawnManager", () => { manager = new SpawnManager(provider, tmux, (msg) => errors.push(msg)); const result = await manager.spawn("claude", "bash"); + // No explicit groveDir — workspace mode depends on whether process.cwd() is a git repo + expect(["isolated_worktree", "fallback_workspace", "bootstrap_failed"]).toContain( + result.workspaceMode.status, + ); + const sessionName = `grove-${result.spawnId}`; await manager.kill(sessionName); @@ -301,6 +315,13 @@ describe("SpawnManager", () => { expect(manager.getSpawnRecord(result1.spawnId)).toBeDefined(); expect(manager.getSpawnRecord(result2.spawnId)).toBeDefined(); + // Both spawns have a workspace mode + expect(["isolated_worktree", "fallback_workspace", "bootstrap_failed"]).toContain( + result1.workspaceMode.status, + ); + expect(["isolated_worktree", "fallback_workspace", "bootstrap_failed"]).toContain( + result2.workspaceMode.status, + ); manager.destroy(); @@ -328,6 +349,47 @@ describe("SpawnManager", () => { expect(String(err)).not.toContain("Provider does not support workspace checkout"); } }); + + test("allow-fallback policy: worktree failure falls back to provider workspace", async () => { + const provider = makeMockProvider(); + const tmux = makeMockTmux(); + const errors: string[] = []; + manager = new SpawnManager( + provider, + tmux, + (msg) => errors.push(msg), + undefined, + "/tmp/no-grove", + ); + manager.setIsolationPolicy("allow-fallback"); + + const result = await manager.spawn("claude", "bash"); + + // Workspace was created via provider fallback + expect(result.workspacePath).toBeTruthy(); + + // Mode is either fallback_workspace (worktree failed) or isolated_worktree (worktree succeeded) + expect(["fallback_workspace", "isolated_worktree", "bootstrap_failed"]).toContain( + result.workspaceMode.status, + ); + }); + + test("strict policy: worktree failure in non-git dir throws", async () => { + const provider = makeMockProvider(); + const tmux = makeMockTmux(); + const errors: string[] = []; + manager = new SpawnManager( + provider, + tmux, + (msg) => errors.push(msg), + undefined, + "/tmp/no-grove", + ); + manager.setIsolationPolicy("strict"); + + // /tmp/no-grove is not a git repo — strict policy must throw + await expect(manager.spawn("claude", "bash")).rejects.toThrow(/Workspace provisioning failed/); + }); }); // --------------------------------------------------------------------------- @@ -348,6 +410,10 @@ describe("SpawnManager — shell injection safety", () => { mgr.setPrContext({ number: 42, title, filesChanged: 5 }); const result = await mgr.spawn("claude", "bash"); + // workspace mode is always present, regardless of whether worktree succeeded + expect(["isolated_worktree", "fallback_workspace", "bootstrap_failed"]).toContain( + result.workspaceMode.status, + ); const sessionName = `grove-${result.spawnId}`; const session = tmux.sessions.get(sessionName); expect(session).toBeDefined(); @@ -405,6 +471,9 @@ describe("SpawnManager — shell injection safety", () => { manager.setPrContext({ number: 99, title, filesChanged: 10 }); const result = await manager.spawn("claude", "bash"); + expect(["isolated_worktree", "fallback_workspace", "bootstrap_failed"]).toContain( + result.workspaceMode.status, + ); const sessionName = `grove-${result.spawnId}`; const session = tmux.sessions.get(sessionName); expect(session).toBeDefined(); @@ -482,6 +551,9 @@ describe("SpawnManager — session persistence", () => { // Record is present in store expect(store.records.has(result.spawnId)).toBe(true); + + // workspaceMode is present on the SpawnResult + expect(result.workspaceMode).toBeDefined(); }); test("kill removes record from session store", async () => { @@ -492,6 +564,9 @@ describe("SpawnManager — session persistence", () => { manager = new SpawnManager(provider, tmux, (msg) => errors.push(msg), store); const result = await manager.spawn("claude", "bash"); + expect(["isolated_worktree", "fallback_workspace", "bootstrap_failed"]).toContain( + result.workspaceMode.status, + ); const sessionName = `grove-${result.spawnId}`; await manager.kill(sessionName); @@ -519,6 +594,7 @@ describe("SpawnManager — session persistence", () => { const result = await manager.spawn("claude", "bash"); expect(result.spawnId).toBeDefined(); expect(result.claimId).toBe(""); + expect(result.workspaceMode.status).toBe("fallback_workspace"); }); test("destroy does not clear session store", async () => { @@ -528,7 +604,10 @@ describe("SpawnManager — session persistence", () => { const errors: string[] = []; manager = new SpawnManager(provider, tmux, (msg) => errors.push(msg), store); - await manager.spawn("claude", "bash"); + const result = await manager.spawn("claude", "bash"); + expect(["isolated_worktree", "fallback_workspace", "bootstrap_failed"]).toContain( + result.workspaceMode.status, + ); expect(store.records.size).toBe(1); manager.destroy(); diff --git a/src/tui/spawn-manager.ts b/src/tui/spawn-manager.ts index 7bd1a0a1..7eded819 100644 --- a/src/tui/spawn-manager.ts +++ b/src/tui/spawn-manager.ts @@ -14,6 +14,11 @@ import { join, resolve } from "node:path"; import type { AgentConfig, AgentRuntime, AgentSession } from "../core/agent-runtime.js"; import type { AgentIdentity } from "../core/models.js"; +import { resolveMcpServePath } from "../core/resolve-mcp-serve-path.js"; +import type { AgentTopology } from "../core/topology.js"; +import { resolveRoleWorkspaceStrategies } from "../core/topology.js"; +import type { WorkspaceIsolationPolicy, WorkspaceMode } from "../core/workspace-provisioner.js"; +import { provisionWorkspace } from "../core/workspace-provisioner.js"; import { safeCleanup } from "../shared/safe-cleanup.js"; import type { SpawnOptions, TmuxManager } from "./agents/tmux-manager.js"; import { agentIdFromSession } from "./agents/tmux-manager.js"; @@ -59,6 +64,8 @@ export interface SpawnResult { readonly spawnId: string; readonly claimId: string; readonly workspacePath: string; + /** Describes how this agent's workspace was provisioned. */ + readonly workspaceMode: WorkspaceMode; } /** @@ -81,6 +88,8 @@ export class SpawnManager { private sessionGoal: string | undefined; private sessionId: string | undefined; private groveDir: string | undefined; + private workspaceIsolationPolicy: WorkspaceIsolationPolicy = "allow-fallback"; + private topology: AgentTopology | undefined; private logPollTimer: ReturnType | null = null; // Track ALL interval handles — prevents "lost handle" leak when startContributionPolling // is called multiple times (e.g. when React effect deps change during session startup). @@ -150,6 +159,19 @@ export class SpawnManager { this.sessionGoal = goal; } + /** Set the workspace isolation policy for subsequent spawns. */ + setIsolationPolicy(policy: WorkspaceIsolationPolicy): void { + this.workspaceIsolationPolicy = policy; + } + + /** + * Set the session topology so spawn() can resolve edge-type-aware base branches. + * Call before spawning when the topology is known (e.g. after preset selection). + */ + setTopology(topology: AgentTopology | undefined): void { + this.topology = topology; + } + /** * Spawn a new agent session. * @@ -174,63 +196,86 @@ export class SpawnManager { // Uses a real git worktree so the agent has actual source code, // can edit files, commit, push, and create PRs. let workspacePath: string; + let workspaceMode!: WorkspaceMode; { - // Find the project root (parent of .grove/) const groveDir = this.groveDir; const projectRoot = groveDir ? resolve(groveDir, "..") : process.cwd(); const baseDir = groveDir ? join(groveDir, "workspaces") : join(projectRoot, ".grove", "workspaces"); - const branch = `grove/session/${spawnId}`; - workspacePath = join(baseDir, spawnId); + // Resolve base branch from topology edge types. + // delegates/feeds/escalates → target branches off source's grove branch. + // All other edges (and no-topology case) → HEAD. + const wsSessionId = this.sessionId ?? spawnId; + const baseBranch = this.topology + ? (resolveRoleWorkspaceStrategies(this.topology, wsSessionId).get(roleId) ?? "HEAD") + : "HEAD"; + + let provisioned: import("../core/workspace-provisioner.js").ProvisionedWorkspace | undefined; try { - if (!existsSync(baseDir)) { - await mkdir(baseDir, { recursive: true }); - } - execSync(`git worktree add "${workspacePath}" -b "${branch}" HEAD`, { - cwd: projectRoot, - encoding: "utf-8", - stdio: "pipe", + // Use wsSessionId (stable session-level ID) so branch names are predictable + // and match what resolveRoleWorkspaceStrategies() computes for dependents. + provisioned = await provisionWorkspace({ + role: roleId, + sessionId: wsSessionId, + baseDir, + repoRoot: projectRoot, + baseBranch, }); - } catch { - // Fallback to provider's bare workspace if git worktree fails + workspacePath = provisioned.path; + } catch (provisionErr) { + const reason = provisionErr instanceof Error ? provisionErr.message : String(provisionErr); + if (this.workspaceIsolationPolicy === "strict") { + throw new Error(`Workspace provisioning failed for '${roleId}': ${reason}`); + } + // allow-fallback: try provider.checkoutWorkspace if (this.provider.checkoutWorkspace) { workspacePath = await this.provider.checkoutWorkspace(spawnId, agent); + workspaceMode = { status: "fallback_workspace", path: workspacePath, reason }; } else { - throw new Error("Failed to create git worktree and no fallback available"); + throw new Error(`Failed to create git worktree and no fallback available: ${reason}`); } } - } - // Step 2: Write config files. Errors logged but non-fatal. - // Claims are NOT auto-created on spawn — agents create claims explicitly - // via grove_claim MCP tool when they need swarm coordination. - try { - await this.writeMcpConfig(workspacePath); - await this.writeAgentInstructions(workspacePath, roleId, context); - if (context?.rolePrompt || context?.roleDescription) { - await this.writeAgentContext(workspacePath, roleId, context); - } - // Step 2c: Protect config files from agent mutation (#7 Workspace Mutation Constraints) - const { chmod } = await import("node:fs/promises"); - for (const protectedFile of [ - ".mcp.json", - ".acpxrc.json", - "CLAUDE.md", - "CODEX.md", - ".grove-role", - ]) { - const filePath = join(workspacePath, protectedFile); - await chmod(filePath, 0o444).catch(() => { - // File may not exist — non-fatal - }); + // Step 2: Write config files. + // Claims are NOT auto-created on spawn — agents create claims explicitly + // via grove_claim MCP tool when they need swarm coordination. + if (provisioned !== undefined) { + try { + await this.writeMcpConfig(workspacePath); + await this.writeAgentInstructions(workspacePath, roleId, context); + if (context?.rolePrompt || context?.roleDescription) { + await this.writeAgentContext(workspacePath, roleId, context); + } + // Protect config files from agent mutation (#7 Workspace Mutation Constraints) + const { chmod } = await import("node:fs/promises"); + for (const protectedFile of [ + ".mcp.json", + ".acpxrc.json", + "CLAUDE.md", + "CODEX.md", + ".grove-role", + ]) { + const filePath = join(workspacePath, protectedFile); + await chmod(filePath, 0o444).catch(() => { + // File may not exist — non-fatal + }); + } + workspaceMode = { + status: "isolated_worktree", + path: provisioned.path, + branch: provisioned.branch, + }; + } catch (configErr) { + const reason = configErr instanceof Error ? configErr.message : String(configErr); + if (this.workspaceIsolationPolicy === "strict") { + throw new Error(`Bootstrap failed for '${roleId}': ${reason}`); + } + this.onError(`Config write failed: ${reason}`); + workspaceMode = { status: "bootstrap_failed", path: provisioned.path, reason }; + } } - } catch (configErr) { - this.onError( - `Config write failed: ${configErr instanceof Error ? configErr.message : String(configErr)}`, - ); - // Continue — agent can still work without configs } // Step 3: Start agent session via AgentRuntime (preferred) or tmux (fallback). @@ -285,13 +330,7 @@ export class SpawnManager { if (process.env.GROVE_NEXUS_URL) codexMcpEnv.GROVE_NEXUS_URL = process.env.GROVE_NEXUS_URL; if (process.env.NEXUS_API_KEY) codexMcpEnv.NEXUS_API_KEY = process.env.NEXUS_API_KEY; if (this.sessionId) codexMcpEnv.GROVE_SESSION_ID = this.sessionId; - // Derive mcpServePath the same way writeMcpConfig does. - const { dirname: d } = await import("node:path"); - const entry = process.argv[1] ?? ""; - const serveRoot = d(d(d(entry))); - const servePath = existsSync(join(serveRoot, "dist", "mcp", "serve.js")) - ? join(serveRoot, "dist", "mcp", "serve.js") - : join(serveRoot, "src", "mcp", "serve.ts"); + const servePath = resolveMcpServePath(); await this.ensureCodexMcpRegistered( codexMcpEnv, servePath, @@ -391,6 +430,7 @@ export class SpawnManager { spawnId, claimId: "", workspacePath, + workspaceMode: workspaceMode, }; } @@ -639,9 +679,9 @@ export class SpawnManager { ); if (!topology || !this.agentRuntime) return; - // Find target roles from topology edges + // Find target roles from topology edges or broadcast mode const sourceRoleDef = topology.roles.find((r) => r.name === sourceRole); - if (!sourceRoleDef?.edges) return; + if (!sourceRoleDef) return; // Find source workspace path let sourceWorkspace: string | undefined; @@ -661,7 +701,12 @@ export class SpawnManager { } } - const targetRoles = sourceRoleDef.edges.map((e) => e.target); + // Broadcast mode: notify all other roles. Explicit: follow edges. + const targetRoles = + sourceRoleDef.mode === "broadcast" + ? topology.roles.filter((r) => r.name !== sourceRole).map((r) => r.name) + : (sourceRoleDef.edges ?? []).map((e) => e.target); + if (targetRoles.length === 0) return; debugLog( "route", `targetRoles=${targetRoles.join(",")} agentSessions=[${[...this.agentSessions.keys()].join(",")}] routableSessions=[${[...this.routableSessions].join(",")}]`, @@ -1208,54 +1253,7 @@ export class SpawnManager { } // Find the grove MCP server: check dist/ first (installed), then src/ (dev) - // Use process.argv[1] (entry point) not import.meta.url — bun bundles may inline - // this file into a chunk, making import.meta.url point to the chunk file rather - // than a predictable path relative to the grove root. - // process.argv[1] = "/dist/cli/main.js" or "/src/cli/main.ts" - const { dirname } = await import("node:path"); - const entryPoint = process.argv[1] ?? ""; - // Climb 3 levels: main.js → cli/ → dist/ or src/ → - const groveRootFromEntry = dirname(dirname(dirname(entryPoint))); - // Also try import.meta.url as a fallback - const groveRootFromMeta = dirname(dirname(dirname(new URL(import.meta.url).pathname))); - debugLog( - "mcpConfig", - `entryPoint=${entryPoint} groveRootFromEntry=${groveRootFromEntry} groveRootFromMeta=${groveRootFromMeta}`, - ); - let mcpServePath = join(groveRootFromEntry, "dist", "mcp", "serve.js"); - debugLog( - "mcpConfig", - `checking dist serve.js: ${mcpServePath} exists=${existsSync(mcpServePath)}`, - ); - if (!existsSync(mcpServePath)) { - mcpServePath = join(groveRootFromEntry, "src", "mcp", "serve.ts"); - debugLog( - "mcpConfig", - `checking src serve.ts: ${mcpServePath} exists=${existsSync(mcpServePath)}`, - ); - } - if (!existsSync(mcpServePath)) { - mcpServePath = join(groveRootFromMeta, "dist", "mcp", "serve.js"); - debugLog( - "mcpConfig", - `fallback meta dist: ${mcpServePath} exists=${existsSync(mcpServePath)}`, - ); - } - if (!existsSync(mcpServePath)) { - mcpServePath = join(groveRootFromMeta, "src", "mcp", "serve.ts"); - debugLog( - "mcpConfig", - `fallback meta src: ${mcpServePath} exists=${existsSync(mcpServePath)}`, - ); - } - // Final fallback: project root - if (!existsSync(mcpServePath)) { - mcpServePath = join(projectRoot, "src", "mcp", "serve.ts"); - debugLog( - "mcpConfig", - `final fallback projectRoot: ${mcpServePath} exists=${existsSync(mcpServePath)}`, - ); - } + const mcpServePath = resolveMcpServePath(projectRoot); debugLog("mcpConfig", `selected mcpServePath=${mcpServePath}`); const mcpConfig = { diff --git a/src/tui/theme.ts b/src/tui/theme.ts index 929874bb..2bb13265 100644 --- a/src/tui/theme.ts +++ b/src/tui/theme.ts @@ -56,7 +56,7 @@ function nearestAnsi16(hex: string): string { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); - let best: (typeof ANSI_16_PALETTE)[number] = ANSI_16_PALETTE[0]!; + let best: (typeof ANSI_16_PALETTE)[number] = ANSI_16_PALETTE[0]; let bestDist = Infinity; for (const entry of ANSI_16_PALETTE) { const dist = (r - entry.r) ** 2 + (g - entry.g) ** 2 + (b - entry.b) ** 2; diff --git a/tests/presets/preset-integration.test.ts b/tests/presets/preset-integration.test.ts index b2b1b91a..7e901f88 100644 --- a/tests/presets/preset-integration.test.ts +++ b/tests/presets/preset-integration.test.ts @@ -163,12 +163,12 @@ describe("review-loop preset", () => { expect(preset.features?.messaging).toBe(true); }); - test("topology edges form bidirectional coder <-> reviewer loop", () => { + test("both roles use broadcast mode (no edges needed)", () => { const preset = getPreset("review-loop")!; const coder = preset.topology?.roles.find((r) => r.name === "coder")!; const reviewer = preset.topology?.roles.find((r) => r.name === "reviewer")!; - expect(coder.edges).toContainEqual({ target: "reviewer", edgeType: "delegates" }); - expect(reviewer.edges).toContainEqual({ target: "coder", edgeType: "feedback" }); + expect(coder.mode).toBe("broadcast"); + expect(reviewer.mode).toBe("broadcast"); }); test("grove init --preset review-loop creates correct files", async () => { @@ -246,13 +246,13 @@ describe("exploration preset", () => { const preset = getPreset("exploration")!; const explorer = preset.topology?.roles.find((r) => r.name === "explorer")!; expect(explorer.edges).toContainEqual({ target: "critic", edgeType: "delegates" }); - expect(explorer.edges).toContainEqual({ target: "synthesizer", edgeType: "feeds" }); + expect(explorer.edges).toContainEqual({ target: "synthesizer", edgeType: "delegates" }); }); test("synthesizer can request from explorer (feedback loop)", () => { const preset = getPreset("exploration")!; const synth = preset.topology?.roles.find((r) => r.name === "synthesizer")!; - expect(synth.edges).toContainEqual({ target: "explorer", edgeType: "requests" }); + expect(synth.edges).toContainEqual({ target: "explorer", edgeType: "delegates" }); }); test("grove init --preset exploration creates correct files", async () => { @@ -520,7 +520,7 @@ describe("pr-review preset", () => { const reviewer = preset.topology?.roles.find((r) => r.name === "reviewer")!; const analyst = preset.topology?.roles.find((r) => r.name === "analyst")!; expect(reviewer.edges).toContainEqual({ target: "analyst", edgeType: "delegates" }); - expect(analyst.edges).toContainEqual({ target: "reviewer", edgeType: "reports" }); + expect(analyst.edges).toContainEqual({ target: "reviewer", edgeType: "delegates" }); }); test("grove init --preset pr-review creates correct files", async () => { @@ -931,7 +931,7 @@ describe("Cross-preset comparisons", () => { } }); - test("only flat topology has no inter-agent edges", () => { + test("non-flat topologies have edges or broadcast mode", () => { const registry = getPresetRegistry(); for (const preset of Object.values(registry)) { if (preset.topology?.structure === "flat") { @@ -939,9 +939,11 @@ describe("Cross-preset comparisons", () => { expect(role.edges ?? []).toEqual([]); } } else { - // non-flat topologies should have at least one role with edges - const hasEdges = preset.topology?.roles.some((r) => (r.edges?.length ?? 0) > 0); - expect(hasEdges).toBe(true); + // non-flat topologies should have at least one role with edges OR broadcast mode + const hasRouting = preset.topology?.roles.some( + (r) => (r.edges?.length ?? 0) > 0 || r.mode === "broadcast", + ); + expect(hasRouting).toBe(true); } } }); diff --git a/tests/tui/acpx-worktree-e2e.ts b/tests/tui/acpx-worktree-e2e.ts new file mode 100644 index 00000000..d37c993a --- /dev/null +++ b/tests/tui/acpx-worktree-e2e.ts @@ -0,0 +1,246 @@ +/** + * Real E2E test: AcpxRuntime + provisioned worktrees + delegates edge. + * + * Proves: + * 1. acpx agent actually starts in the provisioned worktree (not project root) + * 2. Coder's worktree is based on HEAD + * 3. Reviewer's worktree is based on coder's branch (delegates edge) + * 4. After coder commits, reviewer sees the commit + * + * Usage: + * bun run tests/tui/acpx-worktree-e2e.ts --repo /tmp/grove-acpx-e2e-XXXXX + */ + +import { execSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parseArgs } from "node:util"; + +import { AcpxRuntime } from "../../src/core/acpx-runtime.js"; +import type { AgentTopology } from "../../src/core/topology.js"; +import { resolveRoleWorkspaceStrategies } from "../../src/core/topology.js"; +import { provisionWorkspace } from "../../src/core/workspace-provisioner.js"; + +// --------------------------------------------------------------------------- +// Args +// --------------------------------------------------------------------------- + +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { repo: { type: "string" } }, + strict: false, +}); +const repoRoot = values.repo as string; +if (!repoRoot || !existsSync(repoRoot)) { + console.error("--repo is required and must exist"); + process.exit(1); +} + +const SESSION_ID = `acpx-e2e-${Date.now().toString(36)}`; +const baseDir = join(repoRoot, ".grove", "workspaces"); + +// Topology: coder → reviewer (delegates) +const topology: AgentTopology = { + structure: "graph", + roles: [ + { name: "coder", edges: [{ target: "reviewer", edgeType: "delegates" }] }, + { name: "reviewer" }, + ], +}; + +const strategies = resolveRoleWorkspaceStrategies(topology, SESSION_ID); + +// --------------------------------------------------------------------------- +// Test functions +// --------------------------------------------------------------------------- + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, label: string, detail?: string): void { + if (condition) { + console.log(` ✅ ${label}`); + passed++; + } else { + console.log(` ❌ ${label}${detail ? ` — ${detail}` : ""}`); + failed++; + } +} + +// --------------------------------------------------------------------------- +// Step 1: Provision coder worktree (base: HEAD) +// --------------------------------------------------------------------------- + +console.log("\n=== Step 1: Provision coder worktree (base: HEAD) ==="); +const coderBaseBranch = strategies.get("coder") ?? "HEAD"; +assert(coderBaseBranch === "HEAD", `coder baseBranch = ${coderBaseBranch}`); + +const coderWs = await provisionWorkspace({ + role: "coder", + sessionId: SESSION_ID, + baseDir, + repoRoot, + baseBranch: coderBaseBranch, +}); +console.log(` coder worktree: ${coderWs.path}`); +console.log(` coder branch: ${coderWs.branch}`); +assert(existsSync(coderWs.path), "coder worktree exists on disk"); +assert(existsSync(join(coderWs.path, "README.md")), "coder sees README.md from HEAD"); + +// --------------------------------------------------------------------------- +// Step 2: Spawn coder via AcpxRuntime in the worktree +// --------------------------------------------------------------------------- + +console.log("\n=== Step 2: Spawn coder agent (acpx codex) in worktree ==="); +const runtime = new AcpxRuntime({ agent: "codex", logDir: join(repoRoot, ".grove", "logs") }); +const available = await runtime.isAvailable(); +assert(available, "acpx is available"); + +const coderSession = await runtime.spawn("coder", { + role: "coder", + command: "codex", + cwd: coderWs.path, + goal: "Create a file called proof-coder.txt with the content 'coder was here'. Then stop.", + env: { GROVE_SESSION_ID: SESSION_ID, GROVE_ROLE: "coder" }, +}); +console.log(` acpx session: ${coderSession.id}`); +console.log(` status: ${coderSession.status}`); +assert(coderSession.id.startsWith("grove-coder-"), `session name starts with grove-coder-`); + +// Verify via acpx sessions list +const sessionList = execSync("acpx codex sessions list", { encoding: "utf-8", cwd: coderWs.path }); +assert(sessionList.includes(coderSession.id), `acpx sessions list includes ${coderSession.id}`); + +// Wait for agent to finish (poll for proof-coder.txt, max 60s) +console.log(" Waiting for coder agent to create proof-coder.txt..."); +const coderDeadline = Date.now() + 60_000; +while (Date.now() < coderDeadline) { + if (existsSync(join(coderWs.path, "proof-coder.txt"))) break; + await new Promise((r) => setTimeout(r, 2000)); +} +const coderProofExists = existsSync(join(coderWs.path, "proof-coder.txt")); +assert(coderProofExists, "proof-coder.txt created in coder worktree"); +if (coderProofExists) { + const content = readFileSync(join(coderWs.path, "proof-coder.txt"), "utf-8"); + console.log(` proof-coder.txt content: "${content.trim()}"`); + assert(content.includes("coder"), "proof-coder.txt mentions coder"); +} + +// Commit coder's work +if (coderProofExists) { + execSync( + "git add -A && git -c user.email=test@grove.test -c user.name=Grove-E2E commit -m 'coder: add proof' --quiet", + { + cwd: coderWs.path, + stdio: "ignore", + }, + ); + console.log(" Committed coder's work to branch"); +} + +// Close coder session +await runtime.close(coderSession); +console.log(" Closed coder acpx session"); + +// --------------------------------------------------------------------------- +// Step 3: Provision reviewer worktree (base: coder's branch — delegates) +// --------------------------------------------------------------------------- + +console.log("\n=== Step 3: Provision reviewer worktree (base: coder's branch) ==="); +const reviewerBaseBranch = strategies.get("reviewer") ?? "HEAD"; +console.log(` reviewer baseBranch: ${reviewerBaseBranch}`); +assert(reviewerBaseBranch.includes("/coder"), `reviewer branches off coder: ${reviewerBaseBranch}`); + +const reviewerWs = await provisionWorkspace({ + role: "reviewer", + sessionId: SESSION_ID, + baseDir, + repoRoot, + baseBranch: reviewerBaseBranch, +}); +console.log(` reviewer worktree: ${reviewerWs.path}`); +console.log(` reviewer branch: ${reviewerWs.branch}`); +assert(existsSync(reviewerWs.path), "reviewer worktree exists on disk"); + +// Key proof: reviewer sees coder's committed file +const reviewerSeesProof = existsSync(join(reviewerWs.path, "proof-coder.txt")); +assert(reviewerSeesProof, "reviewer worktree contains proof-coder.txt from coder's branch"); +if (reviewerSeesProof) { + const content = readFileSync(join(reviewerWs.path, "proof-coder.txt"), "utf-8"); + console.log(` reviewer reads proof-coder.txt: "${content.trim()}"`); +} + +// Verify git log in reviewer worktree shows coder's commit +const reviewerLog = execSync("git log --oneline", { cwd: reviewerWs.path, encoding: "utf-8" }); +console.log( + ` reviewer git log:\n${reviewerLog + .split("\n") + .map((l) => ` ${l}`) + .join("\n")}`, +); +assert(reviewerLog.includes("coder: add proof"), "reviewer git log shows coder's commit"); + +// --------------------------------------------------------------------------- +// Step 4: Spawn reviewer agent in reviewer worktree +// --------------------------------------------------------------------------- + +console.log("\n=== Step 4: Spawn reviewer agent (acpx codex) in reviewer worktree ==="); +const reviewerSession = await runtime.spawn("reviewer", { + role: "reviewer", + command: "codex", + cwd: reviewerWs.path, + goal: "Read proof-coder.txt and create a file called review-result.txt with 'reviewed: ' followed by the content of proof-coder.txt. Then stop.", + env: { GROVE_SESSION_ID: SESSION_ID, GROVE_ROLE: "reviewer" }, +}); +console.log(` acpx session: ${reviewerSession.id}`); +assert( + reviewerSession.id.startsWith("grove-reviewer-"), + `session name starts with grove-reviewer-`, +); + +// Wait for reviewer to finish +console.log(" Waiting for reviewer agent to create review-result.txt..."); +const reviewerDeadline = Date.now() + 60_000; +while (Date.now() < reviewerDeadline) { + if (existsSync(join(reviewerWs.path, "review-result.txt"))) break; + await new Promise((r) => setTimeout(r, 2000)); +} +const reviewResultExists = existsSync(join(reviewerWs.path, "review-result.txt")); +assert(reviewResultExists, "review-result.txt created in reviewer worktree"); +if (reviewResultExists) { + const content = readFileSync(join(reviewerWs.path, "review-result.txt"), "utf-8"); + console.log(` review-result.txt content: "${content.trim()}"`); + assert(content.includes("coder"), "review-result.txt references coder's work"); +} + +// Close reviewer session +await runtime.close(reviewerSession); +console.log(" Closed reviewer acpx session"); + +// --------------------------------------------------------------------------- +// Step 5: Show final state +// --------------------------------------------------------------------------- + +console.log("\n=== Final state ==="); +console.log(" Worktree list:"); +const wtList = execSync("git worktree list", { cwd: repoRoot, encoding: "utf-8" }); +for (const line of wtList.trim().split("\n")) { + console.log(` ${line}`); +} +console.log("\n Coder worktree files:"); +const coderFiles = execSync("ls -la", { cwd: coderWs.path, encoding: "utf-8" }); +for (const line of coderFiles.trim().split("\n")) { + console.log(` ${line}`); +} +console.log("\n Reviewer worktree files:"); +const reviewerFiles = execSync("ls -la", { cwd: reviewerWs.path, encoding: "utf-8" }); +for (const line of reviewerFiles.trim().split("\n")) { + console.log(` ${line}`); +} + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +console.log(`\n=== ${passed} passed, ${failed} failed ===`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/tui/edge-workspace-e2e.test.ts b/tests/tui/edge-workspace-e2e.test.ts new file mode 100644 index 00000000..c0792d24 --- /dev/null +++ b/tests/tui/edge-workspace-e2e.test.ts @@ -0,0 +1,160 @@ +/** + * E2E test for edge-type-aware workspace provisioning in the TUI. + * + * Verifies that the spawn-progress screen shows the correct workspace mode + * based on the topology edge type between roles. + * + * Scenario 1: delegates + real git repo + * → both agents "started", no degraded badge (isolated_worktree) + * + * Scenario 2: feedback + real git repo + * → both agents "started", no degraded badge (independent isolated_worktrees) + * + * Scenario 3: delegates + no git dir + * → both agents "started [shared workspace]" (fallback_workspace) + * + * Requires tmux. Skipped on machines without it. + */ + +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { execSync } from "node:child_process"; +import { join } from "node:path"; + +// --------------------------------------------------------------------------- +// Tmux helpers +// --------------------------------------------------------------------------- + +const TMUX_SOCKET = "grove-edge-e2e"; +const PROJECT_ROOT = join(import.meta.dir, "..", ".."); +const HARNESS = join(import.meta.dir, "edge-workspace-harness.tsx"); + +function tmuxAvailable(): boolean { + try { + execSync("which tmux", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function tmux(args: string): string { + return execSync(`tmux -L ${TMUX_SOCKET} ${args}`, { encoding: "utf-8", timeout: 10_000 }).trim(); +} + +function capturePane(session: string): string { + return tmux(`capture-pane -t ${session} -p`); +} + +function launchHarness(session: string, extraArgs = ""): void { + tmux( + `new-session -d -s ${session} -x 140 -y 40 -c "${PROJECT_ROOT}" ` + + `"bun run ${HARNESS} ${extraArgs} 2>/tmp/edge-e2e-stderr.log"`, + ); +} + +function killSession(session: string): void { + try { + tmux(`kill-session -t ${session}`); + } catch { + /* already dead */ + } +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +async function waitForResolved(session: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const output = capturePane(session); + if (output.includes("started") || output.includes("failed")) { + await sleep(800); + return capturePane(session); + } + await sleep(500); + } + return capturePane(session); +} + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +const hasTmux = tmuxAvailable(); + +describe.skipIf(!hasTmux)("Edge-type workspace E2E — tmux capture-pane", () => { + beforeAll(() => { + try { + execSync(`tmux -L ${TMUX_SOCKET} kill-server 2>/dev/null`, { stdio: "ignore" }); + } catch { + /* ok */ + } + }); + + afterEach(() => { + killSession("grove-edge-test"); + }); + + afterAll(() => { + try { + execSync(`tmux -L ${TMUX_SOCKET} kill-server 2>/dev/null`, { stdio: "ignore" }); + } catch { + /* ok */ + } + }); + + // ------------------------------------------------------------------------- + // Scenario 1: delegates + real git repo → isolated worktrees, no badge + // ------------------------------------------------------------------------- + + test("delegates + real git: both agents started with isolated worktrees", async () => { + launchHarness("grove-edge-test", "--edge-type delegates --use-git-repo"); + + // Allow time for 2 sequential git worktree provisions + const output = await waitForResolved("grove-edge-test", 25_000); + + expect(output).toContain("Starting session"); + expect(output).toContain("started"); + + // No degraded badge — both worktrees provisioned cleanly + expect(output).not.toContain("[shared workspace]"); + expect(output).not.toContain("[no config]"); + expect(output).not.toContain("failed:"); + }, 35_000); + + // ------------------------------------------------------------------------- + // Scenario 2: feedback + real git repo → independent isolated worktrees + // ------------------------------------------------------------------------- + + test("feedback + real git: both agents started with independent worktrees", async () => { + launchHarness("grove-edge-test", "--edge-type feedback --use-git-repo"); + + const output = await waitForResolved("grove-edge-test", 25_000); + + expect(output).toContain("Starting session"); + expect(output).toContain("started"); + + // Independent worktrees — no badge + expect(output).not.toContain("[shared workspace]"); + expect(output).not.toContain("failed:"); + }, 35_000); + + // ------------------------------------------------------------------------- + // Scenario 3: delegates + no git → fallback mode, badge visible + // ------------------------------------------------------------------------- + + test("delegates + no git: both agents show [shared workspace] badge", async () => { + launchHarness("grove-edge-test", "--edge-type delegates"); + + const output = await waitForResolved("grove-edge-test", 15_000); + + expect(output).toContain("Starting session"); + expect(output).toContain("started"); + + // Fallback badge visible — operator sees the degradation + expect(output).toContain("[shared workspace]"); + // Reason hint visible (git error starts with "Command failed:") + expect(output).toContain("Command failed:"); + }, 20_000); +}); diff --git a/tests/tui/edge-workspace-harness.tsx b/tests/tui/edge-workspace-harness.tsx new file mode 100644 index 00000000..0693ade0 --- /dev/null +++ b/tests/tui/edge-workspace-harness.tsx @@ -0,0 +1,277 @@ +/** + * E2E harness for edge-type-aware workspace provisioning. + * + * Renders SpawnProgress with two roles connected by a topology edge. + * SpawnManager uses resolveRoleWorkspaceStrategies() to pick the correct + * baseBranch for each role — verified via tmux capture-pane. + * + * Args: + * --edge-type delegates|feeds|escalates|feedback (default: delegates) + * --use-git-repo (default: false) + * + * Expected output (real git repo): + * delegates/feeds/escalates → both "started"; reviewer branch based on coder branch + * feedback → both "started"; both independent (HEAD) + * + * Non-git dir: both "started [shared workspace]" (allow-fallback default) + */ + +import { execSync } from "node:child_process"; +import { mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseArgs } from "node:util"; +import { createCliRenderer } from "@opentui/core"; +import { createRoot } from "@opentui/react"; +import React, { useEffect, useRef, useState } from "react"; +import type { AgentTopology, EdgeType } from "../../src/core/topology.js"; +import { MockTmuxManager } from "../../src/tui/agents/tmux-manager.js"; +import type { TuiDataProvider } from "../../src/tui/provider.js"; +import type { AgentSpawnState } from "../../src/tui/screens/spawn-progress.js"; +import { SpawnProgress } from "../../src/tui/screens/spawn-progress.js"; +import { SpawnManager } from "../../src/tui/spawn-manager.js"; + +// --------------------------------------------------------------------------- +// Args +// --------------------------------------------------------------------------- + +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { + "edge-type": { type: "string", default: "delegates" }, + "use-git-repo": { type: "boolean", default: false }, + }, + strict: false, +}); + +const edgeType = (values["edge-type"] ?? "delegates") as EdgeType; +const useGitRepo = values["use-git-repo"] === true; + +// --------------------------------------------------------------------------- +// Temp project root +// --------------------------------------------------------------------------- + +const tempRoot = mkdtempSync(join(tmpdir(), "grove-edge-e2e-")); +const groveDir = join(tempRoot, ".grove"); +mkdirSync(groveDir, { recursive: true }); + +if (useGitRepo) { + execSync("git init", { cwd: tempRoot, stdio: "ignore" }); + execSync("git config user.email test@grove.test", { cwd: tempRoot, stdio: "ignore" }); + execSync("git config user.name Grove-E2E-Test", { cwd: tempRoot, stdio: "ignore" }); + execSync("git commit --allow-empty -m init", { cwd: tempRoot, stdio: "ignore" }); +} + +// --------------------------------------------------------------------------- +// Topology: coder → reviewer with the given edge type +// --------------------------------------------------------------------------- + +// For delegates/feeds/escalates with workspace test: use branch_from_source. +// For feedback/independent test: no workspace field (default independent). +const usesBranching = edgeType === "delegates" || edgeType === "feeds" || edgeType === "escalates"; +const topology: AgentTopology = { + structure: "graph", + roles: [ + { + name: "coder", + edges: [ + { + target: "reviewer", + edgeType, + ...(usesBranching ? { workspace: "branch_from_source" as const } : {}), + }, + ], + }, + { name: "reviewer" }, + ], +}; + +// --------------------------------------------------------------------------- +// Mock provider +// --------------------------------------------------------------------------- + +const mockProvider = { + capabilities: { + outcomes: false, + artifacts: false, + vfs: false, + messaging: false, + costTracking: false, + askUser: false, + github: false, + bounties: false, + gossip: false, + goals: false, + sessions: false, + handoffs: false, + }, + async getDashboard() { + return { + metadata: { + name: "test", + contributionCount: 0, + activeClaimCount: 0, + mode: "test", + backendLabel: "test", + }, + activeClaims: [], + recentContributions: [], + frontierSummary: { topByMetric: [], topByAdoption: [] }, + }; + }, + async getContributions() { + return []; + }, + async getContribution() { + return undefined; + }, + async getClaims() { + return []; + }, + async getFrontier() { + return { byMetric: {}, byAdoption: [], byRecency: [], byReviewScore: [], byReproduction: [] }; + }, + async getActivity() { + return []; + }, + async getDag() { + return { contributions: [] }; + }, + async getHotThreads() { + return []; + }, + async createClaim(input: { + targetRef: string; + agent: { agentId: string }; + intentSummary: string; + leaseDurationMs: number; + }) { + const now = new Date(); + return { + claimId: crypto.randomUUID(), + targetRef: input.targetRef, + agent: input.agent, + status: "active" as const, + intentSummary: input.intentSummary, + createdAt: now.toISOString(), + heartbeatAt: now.toISOString(), + leaseExpiresAt: new Date(now.getTime() + input.leaseDurationMs).toISOString(), + }; + }, + async checkoutWorkspace(targetRef: string): Promise { + return join("/tmp", "grove-fallback-ws", targetRef); + }, + async heartbeatClaim() { + throw new Error("no claim"); + }, + async releaseClaim() { + /* no-op */ + }, + async cleanWorkspace() { + /* no-op */ + }, + close() { + /* no-op */ + }, +} as unknown as TuiDataProvider; + +// --------------------------------------------------------------------------- +// Harness component +// --------------------------------------------------------------------------- + +const SESSION_ID = `edge-e2e-${Date.now().toString(36)}`; + +function HarnessApp(): React.ReactElement { + const [agents, setAgents] = useState([ + { role: "coder", command: "claude", status: "waiting" as const }, + { role: "reviewer", command: "claude", status: "waiting" as const }, + ]); + const smRef = useRef(undefined); + + useEffect(() => { + const mockTmux = new MockTmuxManager(); + const sm = new SpawnManager( + mockProvider, + mockTmux, + (err: string) => process.stderr.write(`[harness] ${err}\n`), + undefined, + groveDir, + ); + sm.setSessionId(SESSION_ID); + sm.setTopology(topology); + sm.setIsolationPolicy("allow-fallback"); + smRef.current = sm; + + setAgents((prev) => prev.map((a) => ({ ...a, status: "spawning" as const }))); + + // Spawn coder first (it's the source — reviewer depends on its branch) + void sm + .spawn("coder", "claude") + .then((r) => { + setAgents((prev) => + prev.map((a) => + a.role === "coder" + ? { ...a, status: "started" as const, workspaceMode: r.workspaceMode } + : a, + ), + ); + // Spawn reviewer after coder (needs coder's branch for delegates/feeds/escalates) + void sm + .spawn("reviewer", "claude") + .then((r2) => { + setAgents((prev) => + prev.map((a) => + a.role === "reviewer" + ? { ...a, status: "started" as const, workspaceMode: r2.workspaceMode } + : a, + ), + ); + }) + .catch((err: unknown) => { + setAgents((prev) => + prev.map((a) => + a.role === "reviewer" ? { ...a, status: "failed" as const, error: String(err) } : a, + ), + ); + }); + }) + .catch((err: unknown) => { + setAgents((prev) => + prev.map((a) => + a.role === "coder" ? { ...a, status: "failed" as const, error: String(err) } : a, + ), + ); + }); + + return () => { + smRef.current = undefined; + }; + }, []); + + return ( + { + process.stdout.write("HARNESS_RESOLVED\n"); + }} + /> + ); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const renderer = await createCliRenderer({ exitOnCtrlC: true, useAlternateScreen: false }); + const root = createRoot(renderer); + root.render(React.createElement(HarnessApp)); + renderer.start(); + await renderer.idle(); +} + +main().catch((err: unknown) => { + process.stderr.write(`harness fatal: ${String(err)}\n`); + process.exit(1); +}); diff --git a/tests/tui/handoffs-harness.tsx b/tests/tui/handoffs-harness.tsx index 3e012910..67ae5d66 100644 --- a/tests/tui/handoffs-harness.tsx +++ b/tests/tui/handoffs-harness.tsx @@ -122,7 +122,9 @@ const mockProvider = { getHotThreads: async () => [], // TuiHandoffProvider getHandoffs: async () => stubHandoffs, - close: () => {}, + close: () => { + /* no-op */ + }, }; const topology = { @@ -156,7 +158,9 @@ async function main() { const spawnManager = new SpawnManager( mockProvider as Parameters[0], undefined, - () => {}, + () => { + /* no-op */ + }, ); const initialState: ScreenState = { diff --git a/tests/tui/trace-running-harness.tsx b/tests/tui/trace-running-harness.tsx index 5fe69d2b..4b412363 100644 --- a/tests/tui/trace-running-harness.tsx +++ b/tests/tui/trace-running-harness.tsx @@ -65,7 +65,9 @@ const mockProvider = { getActivity: async () => [], getDag: async () => ({ nodes: [], edges: [] }), getHotThreads: async () => [], - close: () => {}, + close: () => { + /* no-op */ + }, listSessions: async () => [], createSession: async () => ({ sessionId: "test-sess", @@ -74,8 +76,12 @@ const mockProvider = { contributionCount: 0, }), getSession: async () => undefined, - archiveSession: async () => {}, - addContributionToSession: async () => {}, + archiveSession: async () => { + /* no-op */ + }, + addContributionToSession: async () => { + /* no-op */ + }, }; const topology = { @@ -101,7 +107,9 @@ async function main() { const spawnManager = new SpawnManager( mockProvider as Parameters[0], undefined, - () => {}, + () => { + /* no-op */ + }, ); // Pre-populate log buffers on the spawn manager diff --git a/tests/tui/workspace-isolation-e2e.test.ts b/tests/tui/workspace-isolation-e2e.test.ts new file mode 100644 index 00000000..79c9d352 --- /dev/null +++ b/tests/tui/workspace-isolation-e2e.test.ts @@ -0,0 +1,183 @@ +/** + * E2E test for workspace isolation — verifies that workspace provisioning + * policy enforcement is visible in the TUI spawn-progress screen. + * + * Regression test for windoliver/grove#208: workspace provisioning failures + * were silently swallowed, causing agents to share the project root. + * + * Fix: WorkspaceIsolationPolicy + WorkspaceMode tagged union. + * strict → spawn throws → agent shows "failed: Workspace provisioning failed" + * fallback → degrades → agent shows "started" (fallback_workspace mode) + * + * Requires tmux. Skipped on machines without it. + */ + +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { execSync } from "node:child_process"; +import { join } from "node:path"; + +// --------------------------------------------------------------------------- +// Tmux helpers +// --------------------------------------------------------------------------- + +const TMUX_SOCKET = "grove-ws-e2e"; +const PROJECT_ROOT = join(import.meta.dir, "..", ".."); +const HARNESS = join(import.meta.dir, "workspace-isolation-harness.tsx"); + +function tmuxAvailable(): boolean { + try { + execSync("which tmux", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function tmux(args: string): string { + return execSync(`tmux -L ${TMUX_SOCKET} ${args}`, { + encoding: "utf-8", + timeout: 10_000, + }).trim(); +} + +function capturePane(session: string): string { + return tmux(`capture-pane -t ${session} -p`); +} + +function launchHarness(session: string, extraArgs = ""): void { + tmux( + `new-session -d -s ${session} -x 140 -y 40 -c "${PROJECT_ROOT}" ` + + `"bun run ${HARNESS} ${extraArgs} 2>/tmp/ws-e2e-stderr.log"`, + ); +} + +function killSession(session: string): void { + try { + tmux(`kill-session -t ${session}`); + } catch { + // Already dead + } +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** + * Poll capture-pane until the harness writes "HARNESS_RESOLVED" to stdout + * (meaning all agents resolved) or timeout expires. + */ +async function waitForResolved(session: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const output = capturePane(session); + if ( + output.includes("HARNESS_RESOLVED") || + output.includes("started") || + output.includes("failed") + ) { + // Give one extra render tick to ensure final state is painted + await sleep(800); + return capturePane(session); + } + await sleep(500); + } + return capturePane(session); +} + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +const hasTmux = tmuxAvailable(); + +describe.skipIf(!hasTmux)("Workspace isolation E2E — tmux capture-pane", () => { + beforeAll(() => { + try { + execSync(`tmux -L ${TMUX_SOCKET} kill-server 2>/dev/null`, { stdio: "ignore" }); + } catch { + // Expected when no server running + } + }); + + afterEach(() => { + killSession("grove-ws-test"); + }); + + afterAll(() => { + try { + execSync(`tmux -L ${TMUX_SOCKET} kill-server 2>/dev/null`, { stdio: "ignore" }); + } catch { + // Best-effort + } + }); + + // ------------------------------------------------------------------------- + // Test 1: allow-fallback + non-git dir → agents start (fallback_workspace) + // ------------------------------------------------------------------------- + + test("allow-fallback + non-git dir: both agents show started", async () => { + launchHarness("grove-ws-test", "--policy allow-fallback"); + + // Poll until harness resolves (fallback path ~2-4s) + const output = await waitForResolved("grove-ws-test", 15_000); + + // Screen must be visible + expect(output).toContain("Starting session"); + + // Both agents should reach "started" — fallback_workspace mode continues + expect(output).toContain("started"); + + // Degraded badge must be visible — operator sees the workspace degradation + expect(output).toContain("[shared workspace]"); + + // Reason hint must be shown inline + expect(output).toContain("Command failed:"); + }, 20_000); + + // ------------------------------------------------------------------------- + // Test 2: strict + non-git dir → agents show "failed: Workspace provisioning failed" + // ------------------------------------------------------------------------- + + test("strict + non-git dir: both agents show failed with provisioning error", async () => { + launchHarness("grove-ws-test", "--policy strict"); + + // Poll until spawns reject (git error ~1-2s) + const output = await waitForResolved("grove-ws-test", 10_000); + + expect(output).toContain("Starting session"); + + // Both agents should fail + expect(output).toContain("failed"); + + // Error message must mention provisioning + expect(output).toContain("Workspace provisioning failed"); + + // No agent should have started + expect(output).not.toContain("● coder"); + expect(output).not.toContain("● reviewer"); + }, 15_000); + + // ------------------------------------------------------------------------- + // Test 3: allow-fallback + real git repo → agents start (isolated_worktree) + // ------------------------------------------------------------------------- + + test("allow-fallback + real git repo: both agents show started (isolated worktree)", async () => { + launchHarness("grove-ws-test", "--policy allow-fallback --use-git-repo"); + + // Git worktree creation takes longer — poll up to 20s + const output = await waitForResolved("grove-ws-test", 20_000); + + expect(output).toContain("Starting session"); + + // Both agents should reach "started" — real worktree provisioned + expect(output).toContain("started"); + + // No degraded badge — this is the happy path + expect(output).not.toContain("[shared workspace]"); + expect(output).not.toContain("[no config]"); + + // No agent status failure + expect(output).not.toContain("failed:"); + }, 25_000); +}); diff --git a/tests/tui/workspace-isolation-harness.tsx b/tests/tui/workspace-isolation-harness.tsx new file mode 100644 index 00000000..6c91be46 --- /dev/null +++ b/tests/tui/workspace-isolation-harness.tsx @@ -0,0 +1,245 @@ +/** + * E2E rendering harness for workspace isolation testing. + * + * Renders SpawnProgress with two agents being spawned via a real SpawnManager. + * Workspace provisioning runs for real — so we can observe policy enforcement + * on-screen via tmux capture-pane. + * + * Args: + * --policy allow-fallback|strict (default: allow-fallback) + * --use-git-repo (default: false = non-git tmpdir) + * + * Expected output: + * allow-fallback + no git → both agents "started" (fallback_workspace) + * strict + no git → both agents "failed: ..." (Workspace provisioning failed) + * allow-fallback + git → both agents "started" (isolated_worktree) + */ + +import { execSync } from "node:child_process"; +import { mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseArgs } from "node:util"; +import { createCliRenderer } from "@opentui/core"; +import { createRoot } from "@opentui/react"; +import React, { useEffect, useRef, useState } from "react"; +import { MockTmuxManager } from "../../src/tui/agents/tmux-manager.js"; +import type { TuiDataProvider } from "../../src/tui/provider.js"; +import type { AgentSpawnState } from "../../src/tui/screens/spawn-progress.js"; +import { SpawnProgress } from "../../src/tui/screens/spawn-progress.js"; +import { SpawnManager } from "../../src/tui/spawn-manager.js"; + +// --------------------------------------------------------------------------- +// Args +// --------------------------------------------------------------------------- + +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { + policy: { type: "string", default: "allow-fallback" }, + "use-git-repo": { type: "boolean", default: false }, + }, + strict: false, +}); + +const policy = (values.policy ?? "allow-fallback") as "strict" | "allow-fallback"; +const useGitRepo = values["use-git-repo"] === true; + +// --------------------------------------------------------------------------- +// Temp project root +// --------------------------------------------------------------------------- + +const tempRoot = mkdtempSync(join(tmpdir(), "grove-ws-e2e-")); +const groveDir = join(tempRoot, ".grove"); +mkdirSync(groveDir, { recursive: true }); + +if (useGitRepo) { + execSync("git init", { cwd: tempRoot, stdio: "ignore" }); + execSync("git config user.email test@grove.test", { cwd: tempRoot, stdio: "ignore" }); + execSync("git config user.name Grove-E2E-Test", { cwd: tempRoot, stdio: "ignore" }); + execSync("git commit --allow-empty -m init", { cwd: tempRoot, stdio: "ignore" }); +} + +// --------------------------------------------------------------------------- +// Minimal mock provider (checkoutWorkspace used by allow-fallback path) +// --------------------------------------------------------------------------- + +const mockProvider = { + capabilities: { + outcomes: false, + artifacts: false, + vfs: false, + messaging: false, + costTracking: false, + askUser: false, + github: false, + bounties: false, + gossip: false, + goals: false, + sessions: false, + handoffs: false, + }, + async getDashboard() { + return { + metadata: { + name: "test", + contributionCount: 0, + activeClaimCount: 0, + mode: "test", + backendLabel: "test", + }, + activeClaims: [], + recentContributions: [], + frontierSummary: { topByMetric: [], topByAdoption: [] }, + }; + }, + async getContributions() { + return []; + }, + async getContribution() { + return undefined; + }, + async getClaims() { + return []; + }, + async getFrontier() { + return { byMetric: {}, byAdoption: [], byRecency: [], byReviewScore: [], byReproduction: [] }; + }, + async getActivity() { + return []; + }, + async getDag() { + return { contributions: [] }; + }, + async getHotThreads() { + return []; + }, + async createClaim(input: { + targetRef: string; + agent: { agentId: string }; + intentSummary: string; + leaseDurationMs: number; + }) { + const now = new Date(); + return { + claimId: crypto.randomUUID(), + targetRef: input.targetRef, + agent: input.agent, + status: "active" as const, + intentSummary: input.intentSummary, + createdAt: now.toISOString(), + heartbeatAt: now.toISOString(), + leaseExpiresAt: new Date(now.getTime() + input.leaseDurationMs).toISOString(), + }; + }, + /** Fallback workspace path returned when git worktree provisioning fails. */ + async checkoutWorkspace(targetRef: string): Promise { + return join("/tmp", "grove-fallback-ws", targetRef); + }, + async heartbeatClaim() { + throw new Error("no claim to heartbeat"); + }, + async releaseClaim() { + // no-op + }, + async cleanWorkspace() { + // no-op + }, + close() { + // no-op + }, +} as unknown as TuiDataProvider; + +// --------------------------------------------------------------------------- +// Harness component +// --------------------------------------------------------------------------- + +const ROLES = ["coder", "reviewer"]; + +function HarnessApp(): React.ReactElement { + const [agents, setAgents] = useState( + ROLES.map((role) => ({ role, command: "claude", status: "waiting" as const })), + ); + + const smRef = useRef(undefined); + + useEffect(() => { + const mockTmux = new MockTmuxManager(); + const sm = new SpawnManager( + mockProvider, + mockTmux, + (err: string) => process.stderr.write(`[harness] spawn error: ${err}\n`), + undefined, + groveDir, + ); + sm.setIsolationPolicy(policy); + smRef.current = sm; + + // Mark all as spawning + setAgents((prev) => prev.map((a) => ({ ...a, status: "spawning" as const }))); + + // Fire spawn() for each role — real workspace provisioning runs here + for (const role of ROLES) { + void sm + .spawn(role, "claude") + .then((result) => { + setAgents((prev) => + prev.map((a) => + a.role === role + ? { ...a, status: "started" as const, workspaceMode: result.workspaceMode } + : a, + ), + ); + }) + .catch((err: unknown) => { + setAgents((prev) => + prev.map((a) => + a.role === role + ? { + ...a, + status: "failed" as const, + error: String(err), + } + : a, + ), + ); + }); + } + + return () => { + smRef.current = undefined; + }; + }, []); + + return ( + { + // Stay alive — the E2E test kills the tmux session after capture-pane. + // Write a sentinel to stdout so the test can poll for readiness. + process.stdout.write("HARNESS_RESOLVED\n"); + }} + /> + ); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const renderer = await createCliRenderer({ + exitOnCtrlC: true, + useAlternateScreen: false, + }); + const root = createRoot(renderer); + root.render(React.createElement(HarnessApp)); + renderer.start(); + await renderer.idle(); +} + +main().catch((err: unknown) => { + process.stderr.write(`harness fatal: ${String(err)}\n`); + process.exit(1); +});