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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/a-change-runs-in-its-own-worktree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@openspec-ui/core": minor
"openspec-ui-vscode": patch
"@openspec-ui/server": patch
---

**A change in its own worktree runs there** (a-change-runs-in-its-own-worktree). Run Change... on the card of a change worked in its own worktree failed at its first stage with `cwd "…/.worktrees/<repo>/<change>" is outside the workspace`: every agent was bound to the workspace that was open. A run inside one of the repository's worktrees is now given that worktree's own agents, whose sandbox is that worktree - for a chain, a single stage and a card's controls of a run, in the editor and in the standalone app; a directory that is neither the workspace nor one of its worktrees is still refused. The editor's run dialog also applies a named configuration to the worktree's copy of the change's harness, not the checkout's.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-10
33 changes: 33 additions & 0 deletions openspec/changes/a-change-runs-in-its-own-worktree/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
## Context

`buildDefaultAgentRunners({ workspaceRoot })` makes a host's agents, each
with a sandbox (`checkCwdSandbox`) that refuses a `cwd` outside
`workspaceRoot`. Both hosts already make a second set for a worktree
(`runnersFor`), for a card's delegated task (a-card-works-its-own-tasks);
nothing else used it.

## Decisions

- **Choose the agents by where the run runs.** `runRootOf(cwd, workspace,
container)` says the root a `cwd` is bound to: the workspace, the one
worktree under `container` (`<worktree root>/<repository>`) it is in, or
nothing. `agentsByRunRoot` gives the workspace's agents for the first and
the third, and a worktree's own agents, made once and kept, for the
second - so a permission's answer or a cancel reaches the same runner
the run started on.
- **The sandbox stays.** A `cwd` outside both still gets the workspace's
agents, and their sandbox refuses it, with the words it always had. The
container is the one the standalone server already admits a page's `cwd`
by (ADR 0043 decision 5), read once at start the same way.
- **One parameter, not a new interface.** `resolveRunner(agentId, cwd?)`:
every existing caller keeps compiling, and the ones that know the run's
directory pass it.
- **The run dialog's answers act where the change is.** Its context's
`cwd` is set by the host from where the change is worked, so a
configuration applied there writes that worktree's `harness.json`.

## Risks / Trade-offs

- A run started before the container is read gets the workspace's agents
and is refused as before → the container is read at activation, well
before a person opens a run dialog.
49 changes: 49 additions & 0 deletions openspec/changes/a-change-runs-in-its-own-worktree/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
## Why

The owner, on 2026-10-10, ran a change of `HppMCP` from its card, where
a-change-is-committed-where-it-is-made had just put Run Change...:

> stage started: apply (local-llm-acp)
> failed: cwd "C:/Prog/.worktrees/HppMCP/mcp-platform-foundation" is outside the workspace "c:\Prog\HppMCP"

A host binds its agents to one root, its workspace, and every agent's
sandbox refuses a run whose `cwd` is outside it. A change is made in a
worktree of its own (ADR 0043), under `<worktree root>/<repository>/`, which
is not inside the workspace. The chain, a single stage, and a card's
controls of a run all resolved their agent by its id alone, so every run in
a worktree was refused - in the editor and in the standalone app alike. The
CLI never met it: `openspec-ui-cli run change --cwd` builds its agents for
that directory. The run dialog's answers had the same blind spot: applying
a named configuration wrote the checkout's copy of the change's
`harness.json`, not the worktree's.

## What Changes

- A run inside one of the repository's worktrees is given that worktree's
own agents, made once per worktree, whose sandbox is that worktree. Any
other run gets the workspace's, whose sandbox still refuses a directory
that is neither. `runRootOf` and `agentsByRunRoot` in core decide it.
- The chain runner asks for its agent with the stage's `cwd`; the editor's
AI panel, its card controls and the standalone server's chain and socket
runs do the same.
- The run dialog's answers in the editor read and write the change's
harness where the change is worked.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `execution-core`: a run in a change's own worktree runs on that
worktree's agents.

## Impact

- `packages/core`: `run-root.ts` (new), `harness-chain-runner.ts`.
- `packages/extension`: `extension.ts`, `webview/ai-panel.ts`,
`pipeline-run-control.ts`, `commands.ts` (the run dialog's answers).
- `packages/server`: `server.ts`, `websocket.ts`.
- A changeset: core minor (a new export), extension and server patch.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## ADDED Requirements

### Requirement: A run in a change's own worktree runs on that worktree's agents

A host SHALL give a run whose `cwd` is inside one of its repository's
worktrees, under `<worktree root>/<repository>/`, that worktree's own
agents, whose sandbox is that worktree, made once and kept for every later
run and request there. A run in the workspace SHALL get the workspace's
agents. A run anywhere else SHALL get the workspace's agents, whose sandbox
refuses it as before. This SHALL hold for a chain's stages, a single stage,
and a card's controls of a run, in the editor and in the standalone app.

Where a change is worked in its own worktree, the editor's run dialog SHALL
read and write that worktree's copy of the change's harness.

#### Scenario: A change run from the card of its own worktree

- **WHEN** a chain of `mcp-platform-foundation` runs in
`../.worktrees/HppMCP/mcp-platform-foundation`, the workspace being
`HppMCP`
- **THEN** its stages run on that worktree's agents, and none is refused
as outside the workspace

#### Scenario: A directory of no worktree

- **WHEN** a run names a `cwd` outside the workspace and outside its
repository's worktrees
- **THEN** it is refused as outside the workspace
44 changes: 44 additions & 0 deletions openspec/changes/a-change-runs-in-its-own-worktree/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
From the owner's run of `HppMCP`'s `mcp-platform-foundation` on 2026-10-10,
refused as outside the workspace.

## 1. Core

- [x] 1.1 `run-root.ts`: `runRootOf` and `agentsByRunRoot`. Tests.
`run-root.test.ts`, 3 tests (2026-10-10).
- [x] 1.2 The chain runner asks for a stage's agent with its `cwd`.
`harness-chain-runner.ts`; its tests pass (2026-10-10).

## 2. Hosts

- [x] 2.1 The editor: the chain, the AI panel's single stages and a card's
controls of a run take the agents of the run's directory; the worktrees'
container is read at activation.
`extension.ts`, `ai-panel.ts`, `pipeline-run-control.ts`; their tests
expect the run's directory beside the agent; all of the extension's unit
tests pass (2026-10-10).
- [x] 2.2 The editor's run dialog reads and writes the change's harness
where the change is worked.
`commands.ts`, `createRunChoiceHandler` (2026-10-10).
- [x] 2.3 The standalone server: the chain and the socket's single stages
take the agents of the run's directory.
`server.ts`, `websocket.ts`; `server.test.ts` 110 pass (2026-10-10).

## 3. Documents

- [x] 3.1 A changeset: core minor, extension and server patch.

## 4. Checks

- [x] 4.1 `npm run typecheck && npm run lint`, with the affected tests.
2026-10-10: typecheck clean; lint 0 errors (4 warnings, none in files
this change touched). Core run-root and chain runner; the extension's
unit tests; the server's 110 - with two workers.
- [x] 4.2 `openspec validate a-change-runs-in-its-own-worktree --strict`,
and the merge gate with the worktree's absolute path as `--cwd`.
2026-10-10: valid under `--strict`; the gate named only 4.1, 4.2 and 4.3
as open.
- [x] 4.3 **Human-only**: in `HppMCP`, on the extension built from this
branch, Run Change... on the card of a change in its own worktree runs
its stages without "outside the workspace".
2026-10-10, the owner, on the extension 0.103.0 built from this branch:
the change started and is working ("The change started and is working").
8 changes: 5 additions & 3 deletions packages/core/src/harness-chain-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
buildGhPrCreateInvocation,
buildGhPrMergeInvocation,
buildGitPushInvocation,
createPullRequestGateway,

Check warning on line 29 in packages/core/src/harness-chain-runner.ts

View workflow job for this annotation

GitHub Actions / Typecheck, lint, test, and build

'createPullRequestGateway' is defined but never used. Allowed unused vars must match /^_/u
type PullRequestGateway,
} from "./gh-pr-gateway.js";
import { pullRequestGatewayFor } from "./forge.js";
Expand Down Expand Up @@ -93,8 +93,10 @@
* resolveRunner`), typically `default-runners.ts`'s `resolveRunner`
* curried over that host's `Map<string, AgentRunner>`. Falling back to
* the default agent for an unset `stepAgents` entry is this function's
* responsibility, not the chain runner's. */
resolveRunner: (agentId: string | undefined) => AgentRunner | undefined;
* responsibility, not the chain runner's. `cwd` is where the stage runs:
* a host gives a change's own worktree that worktree's agents, whose
* sandbox is that worktree (a-change-runs-in-its-own-worktree). */
resolveRunner: (agentId: string | undefined, cwd?: string) => AgentRunner | undefined;
/** Best-effort accessor for this workspace's recorded audit entries —
* used only to sum recorded usage against `harnessConfig.budget` before
* starting each stage (see openspec/changes/agent-usage-accounting/
Expand Down Expand Up @@ -1832,7 +1834,7 @@
: stepAgent === undefined
? { agent: undefined, model: undefined, effort: undefined, budget: undefined, customAgent: undefined }
: normalizeStepAgent(stepAgent);
const runner = this.deps.resolveRunner(agentId);
const runner = this.deps.resolveRunner(agentId, cwd);
if (!runner) {
yield failedEvent(runId, `no agent available to run the "${stage}" stage`);
return "failed";
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export * from "./failure-diagnosis.js";
export * from "./supervisor.js";
export * from "./own-worktree-tasks.js";
export * from "./change-commit.js";
export * from "./run-root.js";
export * from "./change-layout.js";
export * from "./board-columns-facts.js";
export * from "./board-columns.js";
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/run-root.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { AgentRunner } from "./agent-runner.js";
import { agentsByRunRoot, runRootOf } from "./run-root.js";

// a-change-runs-in-its-own-worktree: a run of a change in its own worktree
// is given that worktree's agents, whose sandbox is that worktree.

const workspace = path.resolve("/prog/HppMCP");
const container = path.resolve("/prog/.worktrees/HppMCP");

describe("runRootOf", () => {
it("binds a run in the workspace to the workspace, and one in a worktree to that worktree", () => {
expect(runRootOf(path.join(workspace, "packages"), workspace, container)).toBe(workspace);
expect(runRootOf(path.join(container, "mcp-platform-foundation"), workspace, container)).toBe(path.join(container, "mcp-platform-foundation"));
expect(runRootOf(path.join(container, "fresh", "openspec"), workspace, container)).toBe(path.join(container, "fresh"));
});

it("binds nothing outside both, nor the worktrees' container itself, nor before the container is read", () => {
expect(runRootOf(path.resolve("/elsewhere/repo"), workspace, container)).toBeUndefined();
expect(runRootOf(container, workspace, container)).toBeUndefined();
expect(runRootOf(path.resolve("/prog/.worktrees/Other/fresh"), workspace, container)).toBeUndefined();
expect(runRootOf(path.join(container, "fresh"), workspace, undefined)).toBeUndefined();
});
});

describe("agentsByRunRoot", () => {
it("gives a worktree its own agents, made once, and every other place the workspace's", () => {
const own = new Map<string, AgentRunner>();
const made: string[] = [];
const runnersFor = vi.fn((root: string) => { made.push(root); return new Map<string, AgentRunner>(); });
const agentsFor = agentsByRunRoot({ workspaceRoot: workspace, runners: own, container: () => container, runnersFor });

const fresh = agentsFor(path.join(container, "fresh"));
expect(agentsFor(path.join(container, "fresh", "src"))).toBe(fresh);
expect(fresh).not.toBe(own);
expect(made).toEqual([path.join(container, "fresh")]);

expect(agentsFor(workspace)).toBe(own);
expect(agentsFor(path.resolve("/elsewhere/repo"))).toBe(own);
expect(agentsFor(undefined)).toBe(own);
});
});
65 changes: 65 additions & 0 deletions packages/core/src/run-root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// The agents a run is given, by where it runs (a-change-runs-in-its-own-
// worktree).
//
// A host binds its agents to one root, its workspace, and each agent's
// sandbox refuses a run whose `cwd` is outside it. A change is made in a
// worktree of its own (ADR 0043), under `<worktree root>/<repository>/`,
// which is not inside the workspace: a run of such a change from its card
// failed with "cwd ... is outside the workspace". The CLI never met this:
// `openspec-ui-cli run change --cwd` builds its agents for that directory.
//
// So a host gives a run inside one of its repository's worktrees that
// worktree's own agents, made once per worktree, and every other run the
// workspace's - whose sandbox still refuses a directory that is neither.

import path from "node:path";
import type { AgentRunner } from "./agent-runner.js";

function key(directory: string): string {
const resolved = path.resolve(directory);
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
}

function within(child: string, parent: string): boolean {
const c = key(child);
const p = key(parent);
return c === p || c.startsWith(p.endsWith(path.sep) ? p : `${p}${path.sep}`);
}

/** The root a run in `cwd` is bound to: the workspace for a run inside it;
* the worktree's own directory for a run inside one under `container`
* (`<worktree root>/<repository>`); `undefined` for anywhere else. */
export function runRootOf(cwd: string, workspaceRoot: string, container: string | undefined): string | undefined {
if (within(cwd, workspaceRoot)) return path.resolve(workspaceRoot);
if (container === undefined || !within(cwd, container) || key(cwd) === key(container)) return undefined;
const first = path.relative(path.resolve(container), path.resolve(cwd)).split(path.sep)[0];
return first === undefined || first.length === 0 ? undefined : path.join(path.resolve(container), first);
}

/** A host's agents by where a run runs: the workspace's own for a run in
* the workspace or anywhere unknown (whose sandbox then refuses it), and a
* worktree's own, made once, for a run in one of the repository's
* worktrees. */
export function agentsByRunRoot(options: {
workspaceRoot: string;
/** The workspace's agents. */
runners: Map<string, AgentRunner>;
/** `<worktree root>/<repository>`, once read; `undefined` until then. */
container: () => string | undefined;
/** Builds the agents of another root, as `runners` was built. Absent,
* every run gets `runners`. */
runnersFor?: (root: string) => Map<string, AgentRunner>;
}): (cwd: string | undefined) => Map<string, AgentRunner> {
const made = new Map<string, Map<string, AgentRunner>>();
return (cwd) => {
if (cwd === undefined || options.runnersFor === undefined) return options.runners;
const root = runRootOf(cwd, options.workspaceRoot, options.container());
if (root === undefined || key(root) === key(options.workspaceRoot)) return options.runners;
let agents = made.get(key(root));
if (agents === undefined) {
agents = options.runnersFor(root);
made.set(key(root), agents);
}
return agents;
};
}
7 changes: 5 additions & 2 deletions packages/extension/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,8 +624,11 @@ function currentAgentFor(stepAgents: HarnessConfig["stepAgents"], stage: Harness
* `extension.ts` on the panel. See run-dialog-in-the-panel. */
export function createRunChoiceHandler(deps: CommandsDeps) {
return async function handleRunChoice(choice: RunChoice, context: RunChoiceContext): Promise<void> {
const workspaceRoot = deps.getWorkspaceRoot();
if (!workspaceRoot) return;
if (!deps.getWorkspaceRoot()) return;
// Where the dialog's change is worked: its own worktree, as the host
// set the panel's context when it opened the dialog, or this checkout.
// Its harness is read and written there (a-change-runs-in-its-own-worktree).
const workspaceRoot = context.cwd.length > 0 ? context.cwd : deps.getWorkspaceRoot() as string;
const changeName = context.changeName
?? context.changeDir.split(/[\\/]+/).filter((segment) => segment.length > 0).pop();
if (!changeName) return;
Expand Down
Loading
Loading