From ddb8a496a0f70c5e4aa9d79933099a6ac0cde5d6 Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Sat, 10 Oct 2026 17:06:58 +0300 Subject: [PATCH 1/3] a-change-runs-in-its-own-worktree: a change in its own worktree runs there Run Change... on the card of a change worked in its own worktree failed at its first stage: "cwd .../.worktrees/HppMCP/mcp-platform-foundation is outside the workspace c:\Prog\HppMCP". Every agent was bound to the open workspace, and the chain, a single stage and a card's run controls chose an agent by its id alone. - core: runRootOf and agentsByRunRoot - a run inside one of the repository's worktrees gets that worktree's own agents, made once; any other run the workspace's, whose sandbox still refuses it. - The chain runner asks for a stage's agent with its cwd; the editor's AI panel and run controls, and the standalone server's chain and socket runs, do the same. - The editor's run dialog reads and writes the change's harness where the change is worked. Co-Authored-By: Claude Opus 5.5 --- .../a-change-runs-in-its-own-worktree.md | 7 ++ .../.openspec.yaml | 2 + .../design.md | 33 ++++++++++ .../proposal.md | 49 ++++++++++++++ .../specs/execution-core/spec.md | 28 ++++++++ .../tasks.md | 42 ++++++++++++ packages/core/src/harness-chain-runner.ts | 8 ++- packages/core/src/index.ts | 1 + packages/core/src/run-root.test.ts | 43 ++++++++++++ packages/core/src/run-root.ts | 65 +++++++++++++++++++ packages/extension/src/commands.ts | 7 +- packages/extension/src/extension.ts | 45 ++++++++++--- .../src/pipeline-run-control.test.ts | 2 +- .../extension/src/pipeline-run-control.ts | 4 +- .../extension/src/webview/ai-panel.test.ts | 6 +- packages/extension/src/webview/ai-panel.ts | 6 +- packages/server/src/server.ts | 14 +++- packages/server/src/websocket.ts | 5 +- 18 files changed, 342 insertions(+), 25 deletions(-) create mode 100644 .changeset/a-change-runs-in-its-own-worktree.md create mode 100644 openspec/changes/a-change-runs-in-its-own-worktree/.openspec.yaml create mode 100644 openspec/changes/a-change-runs-in-its-own-worktree/design.md create mode 100644 openspec/changes/a-change-runs-in-its-own-worktree/proposal.md create mode 100644 openspec/changes/a-change-runs-in-its-own-worktree/specs/execution-core/spec.md create mode 100644 openspec/changes/a-change-runs-in-its-own-worktree/tasks.md create mode 100644 packages/core/src/run-root.test.ts create mode 100644 packages/core/src/run-root.ts diff --git a/.changeset/a-change-runs-in-its-own-worktree.md b/.changeset/a-change-runs-in-its-own-worktree.md new file mode 100644 index 00000000..6bec71c8 --- /dev/null +++ b/.changeset/a-change-runs-in-its-own-worktree.md @@ -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//" 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. diff --git a/openspec/changes/a-change-runs-in-its-own-worktree/.openspec.yaml b/openspec/changes/a-change-runs-in-its-own-worktree/.openspec.yaml new file mode 100644 index 00000000..53f62d70 --- /dev/null +++ b/openspec/changes/a-change-runs-in-its-own-worktree/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-10 diff --git a/openspec/changes/a-change-runs-in-its-own-worktree/design.md b/openspec/changes/a-change-runs-in-its-own-worktree/design.md new file mode 100644 index 00000000..bc709e49 --- /dev/null +++ b/openspec/changes/a-change-runs-in-its-own-worktree/design.md @@ -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` (`/`) 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. diff --git a/openspec/changes/a-change-runs-in-its-own-worktree/proposal.md b/openspec/changes/a-change-runs-in-its-own-worktree/proposal.md new file mode 100644 index 00000000..43f1b931 --- /dev/null +++ b/openspec/changes/a-change-runs-in-its-own-worktree/proposal.md @@ -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 `//`, 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. diff --git a/openspec/changes/a-change-runs-in-its-own-worktree/specs/execution-core/spec.md b/openspec/changes/a-change-runs-in-its-own-worktree/specs/execution-core/spec.md new file mode 100644 index 00000000..006c5bb4 --- /dev/null +++ b/openspec/changes/a-change-runs-in-its-own-worktree/specs/execution-core/spec.md @@ -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 `//`, 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 diff --git a/openspec/changes/a-change-runs-in-its-own-worktree/tasks.md b/openspec/changes/a-change-runs-in-its-own-worktree/tasks.md new file mode 100644 index 00000000..d6956383 --- /dev/null +++ b/openspec/changes/a-change-runs-in-its-own-worktree/tasks.md @@ -0,0 +1,42 @@ +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. +- [ ] 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". diff --git a/packages/core/src/harness-chain-runner.ts b/packages/core/src/harness-chain-runner.ts index ba047f10..d99899c5 100644 --- a/packages/core/src/harness-chain-runner.ts +++ b/packages/core/src/harness-chain-runner.ts @@ -93,8 +93,10 @@ export interface HarnessChainDeps { * resolveRunner`), typically `default-runners.ts`'s `resolveRunner` * curried over that host's `Map`. 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/ @@ -1832,7 +1834,7 @@ export class HarnessChainRunner { : 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"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 24b05f15..c782af23 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/run-root.test.ts b/packages/core/src/run-root.test.ts new file mode 100644 index 00000000..cad9f063 --- /dev/null +++ b/packages/core/src/run-root.test.ts @@ -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(); + const made: string[] = []; + const runnersFor = vi.fn((root: string) => { made.push(root); return new Map(); }); + 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); + }); +}); diff --git a/packages/core/src/run-root.ts b/packages/core/src/run-root.ts new file mode 100644 index 00000000..9ce7a891 --- /dev/null +++ b/packages/core/src/run-root.ts @@ -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 `//`, +// 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` + * (`/`); `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; + /** `/`, 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; +}): (cwd: string | undefined) => Map { + const made = new Map>(); + 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; + }; +} diff --git a/packages/extension/src/commands.ts b/packages/extension/src/commands.ts index ec0f077f..1bb95d57 100644 --- a/packages/extension/src/commands.ts +++ b/packages/extension/src/commands.ts @@ -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 { - 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; diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 176680b3..82d56d0f 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -47,6 +47,8 @@ import { resolveRunner as resolveAgentRunner, runDelegatedItem, shortDelegatedItemOutcome, + agentsByRunRoot, + resolveWorktreeRoot, } from "@openspec-ui/core"; import { AnswerWatcher, describeAnswer } from "./answers-watcher.js"; import { buildChainRunnerAuditDeps } from "./chain-runner-audit-deps.js"; @@ -93,6 +95,17 @@ let runners: Map | undefined; * `runners` is, for that root: a change's own worktree, where a card runs a * delegated task (a-card-works-its-own-tasks). */ let runnersFor: ((root: string) => Map) | undefined; +/** Where this repository's worktrees are made, `/`, + * once read (ADR 0043). */ +let worktreeContainer: string | undefined; +/** The agents of the place a run runs in: the workspace, or one of its + * repository's worktrees (a-change-runs-in-its-own-worktree). */ +let agentsAt: ((cwd: string | undefined) => Map) | undefined; +/** The agent a run in `cwd` runs on. */ +function runnerAt(agentId: string | undefined, cwd: string | undefined): AgentRunner | undefined { + const agents = agentsAt?.(cwd) ?? runners; + return agents ? resolveAgentRunner(agents, agentId) : undefined; +} let auditLog: FileAuditLog | undefined; let optionalServer: OptionalServerManager | undefined; /** The Workspace view, told which checks resolve whenever the check @@ -584,13 +597,27 @@ export async function activate(context: vscode.ExtensionContext): Promise buildDefaultAgentRunners({ - workspaceRoot: root, - auditLog: new FileAuditLog(auditLogPath(root)), - runLogs: createFileRunLogs(root), - ...localLlmSettings, - ...readAgentSwitches(), + const workspaceAgents = runners; + const agentsFor = agentsByRunRoot({ + workspaceRoot, + runners: workspaceAgents, + container: () => worktreeContainer, + runnersFor: (root) => buildDefaultAgentRunners({ + workspaceRoot: root, + auditLog: new FileAuditLog(auditLogPath(root)), + runLogs: createFileRunLogs(root), + ...localLlmSettings, + ...readAgentSwitches(), + }), }); + runnersFor = (root) => agentsFor(root); + // A run of a change in its own worktree is given that worktree's agents, + // whose sandbox is that worktree (a-change-runs-in-its-own-worktree). + // The worktrees' place is read once; until it is, only the workspace's. + void resolveWorktreeRoot(workspaceRoot).then(({ root }) => { + worktreeContainer = path.join(root, path.basename(workspaceRoot)); + }, () => undefined); + agentsAt = agentsFor; context.subscriptions.push( vscode.commands.registerCommand("openspec-ui.setLlmKey", async () => { const key = await vscode.window.showInputBox({ @@ -794,7 +821,7 @@ export async function activate(context: vscode.ExtensionContext): Promise (runners ? resolveAgentRunner(runners, agentId) : undefined), + resolveRunner: (agentId, cwd) => runnerAt(agentId, cwd), // Both audit dependencies come from one place, so that "the chain // writes its spend but reads nothing back" cannot be introduced by // editing one of two lines. See chain-runner-audit-deps.ts. @@ -837,7 +864,7 @@ export async function activate(context: vscode.ExtensionContext): Promise (runners ? resolveAgentRunner(runners, agentId) : undefined), + resolveRunner: (agentId, cwd) => runnerAt(agentId, cwd), chainRunner, getLocalServerUrl: () => optionalServer?.launchUrl, scheduler, @@ -892,7 +919,7 @@ export async function activate(context: vscode.ExtensionContext): Promise sendPipelineRunControl(control, { liveRuns, chainRunner, - resolveRunner: (agentId) => (runners ? resolveAgentRunner(runners, agentId) : undefined), + resolveRunner: (agentId, cwd) => runnerAt(agentId, cwd), }), // As `openspec-ui.showChange` reveals a row: an item built from // the change the host found, never from the message. diff --git a/packages/extension/src/pipeline-run-control.test.ts b/packages/extension/src/pipeline-run-control.test.ts index d0e36b71..ef34303f 100644 --- a/packages/extension/src/pipeline-run-control.test.ts +++ b/packages/extension/src/pipeline-run-control.test.ts @@ -79,7 +79,7 @@ describe("sendPipelineRunControl", () => { sendPipelineRunControl({ changeName: "alpha", runId: "r1", kind: "cancel" }, deps); await vi.waitFor(() => expect(received).toEqual([expect.objectContaining({ kind: "cancel", runId: "r1", agentId: "claude-cli" })])); - expect(resolveRunner).toHaveBeenCalledWith("claude-cli"); + expect(resolveRunner).toHaveBeenCalledWith("claude-cli", expect.any(String)); }); it("sends nothing for a run this host does not hold", () => { diff --git a/packages/extension/src/pipeline-run-control.ts b/packages/extension/src/pipeline-run-control.ts index 7a0a3c6b..0d2d0af1 100644 --- a/packages/extension/src/pipeline-run-control.ts +++ b/packages/extension/src/pipeline-run-control.ts @@ -10,7 +10,7 @@ export interface PipelineRunControlDeps { liveRuns: Pick; chainRunner: Pick; /** The runner a single-stage run was started on, by its agent. */ - resolveRunner: (agentId: string | undefined) => AgentRunner | undefined; + resolveRunner: (agentId: string | undefined, cwd?: string) => AgentRunner | undefined; } /** Sends a card's control to the run it names. @@ -46,7 +46,7 @@ export function sendPipelineRunControl(control: PipelineRunControl, deps: Pipeli return; } - const runner = chain ? deps.chainRunner.asAgentRunner() : deps.resolveRunner(held.agentId); + const runner = chain ? deps.chainRunner.asAgentRunner() : deps.resolveRunner(held.agentId, held.cwd); if (runner === undefined) return; void (async () => { try { diff --git a/packages/extension/src/webview/ai-panel.test.ts b/packages/extension/src/webview/ai-panel.test.ts index 49ce47c7..b9b104e4 100644 --- a/packages/extension/src/webview/ai-panel.test.ts +++ b/packages/extension/src/webview/ai-panel.test.ts @@ -424,7 +424,7 @@ describe("AiPanel harness process tracking", () => { // DEFAULT_AGENT_ID, handing the cancel to claude-cli's runner — // whose `activeRuns` has never heard of this runId. The cancel // reported "nothing to cancel" and the real agent kept working. - expect(resolveRunner).toHaveBeenCalledWith("copilot-cli-acp"); + expect(resolveRunner).toHaveBeenCalledWith("copilot-cli-acp", expect.any(String)); }); it("routes a resolvePermission to the runner that owns the run, not to the default agent", () => { @@ -480,7 +480,7 @@ describe("AiPanel harness process tracking", () => { }, }); - expect(resolveRunner).toHaveBeenCalledWith("local-llm-acp"); + expect(resolveRunner).toHaveBeenCalledWith("local-llm-acp", expect.any(String)); }); it("does not register a process when no scheduler is supplied", () => { @@ -875,7 +875,7 @@ describe("AiPanel vscode-chat stage dispatch", () => { sendImplementCommand(receiveMessage); - expect(resolveRunner).toHaveBeenCalledWith("claude-cli"); + expect(resolveRunner).toHaveBeenCalledWith("claude-cli", expect.any(String)); expect(runController.run).toHaveBeenCalledWith( expect.objectContaining({ name: "claude-cli" }), expect.objectContaining({ kind: "implement" }), diff --git a/packages/extension/src/webview/ai-panel.ts b/packages/extension/src/webview/ai-panel.ts index cb06711f..4d6b3a45 100644 --- a/packages/extension/src/webview/ai-panel.ts +++ b/packages/extension/src/webview/ai-panel.ts @@ -187,7 +187,9 @@ const STAGE_FOR_COMMAND_KIND: Partial> = { export interface AiPanelDeps { extensionUri: vscode.Uri; runController: RunController; - resolveRunner: (agentId: string | undefined) => AgentRunner | undefined; + /** `cwd` is where the run runs: a change's own worktree has its own + * agents (a-change-runs-in-its-own-worktree). */ + resolveRunner: (agentId: string | undefined, cwd?: string) => AgentRunner | undefined; /** Drives `"chain"`/`"confirmCheckpoint"` commands — see * docs/adr/0012-agentic-harness-chain-execution-protocol.md. Reused * across every message (a paused chain's state lives between them), not @@ -383,7 +385,7 @@ export class AiPanel { const agentId = CARRIES_NO_OWN_AGENT_ID.has(command.kind) ? this.runAgentIds.get(command.runId) ?? command.agentId : command.agentId; - const runner = this.deps.resolveRunner(agentId); + const runner = this.deps.resolveRunner(agentId, command.cwd); if (!runner) { this.postEventMessage(panel, { kind: "failed", diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 67e79efc..e24577d7 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -9,6 +9,7 @@ import path from "node:path"; import { WebSocketServer } from "ws"; import { getCoreVersion, + agentsByRunRoot, resolveRunner, FileAuditLog, HarnessChainRunner, @@ -158,6 +159,15 @@ export function createServer(options: ServerOptions): OpenSpecUiServer { worktreeContainer = path.join(root, path.basename(workspaceRoot)); }, () => undefined); const within = (resolved: string, root: string) => resolved === root || resolved.startsWith(`${root}${path.sep}`); + // A run in one of those worktrees is given that worktree's agents, whose + // sandbox is that worktree; every other run, this workspace's + // (a-change-runs-in-its-own-worktree). + const agentsAt = agentsByRunRoot({ + workspaceRoot, + runners, + container: () => worktreeContainer, + ...(options.runnersFor !== undefined ? { runnersFor: options.runnersFor } : {}), + }); const requestPolicy = { maxPayloadBytes, isCwdAllowed(cwd: string): boolean { @@ -173,7 +183,7 @@ export function createServer(options: ServerOptions): OpenSpecUiServer { // not construct a fresh one per message. const fileAuditLog = options.auditLog instanceof FileAuditLog ? options.auditLog : undefined; const chainRunner = new HarnessChainRunner({ - resolveRunner: (agentId) => resolveRunner(runners, agentId), + resolveRunner: (agentId, cwd) => resolveRunner(agentsAt(cwd), agentId), listAuditEntries: fileAuditLog ? () => fileAuditLog.readEntries() : undefined, auditLog: options.auditLog, }); @@ -461,7 +471,7 @@ export function createServer(options: ServerOptions): OpenSpecUiServer { socket.close(); }); socket.on("message", (raw) => { - handleSocketMessage(socket, raw.toString(), runners, resolveRecoveryService, chainRunner, liveRuns, options.auditLog); + handleSocketMessage(socket, raw.toString(), runners, resolveRecoveryService, chainRunner, liveRuns, options.auditLog, agentsAt); }); }); diff --git a/packages/server/src/websocket.ts b/packages/server/src/websocket.ts index 26303326..67109ca4 100644 --- a/packages/server/src/websocket.ts +++ b/packages/server/src/websocket.ts @@ -90,6 +90,9 @@ export function handleSocketMessage( /** Where a run records a request to stop it that it refused * (a-run-elsewhere-can-be-asked-to-stop). */ auditLog?: AuditLog, + /** The agents of the place a run runs in: a change's own worktree has + * its own (a-change-runs-in-its-own-worktree). Absent, `runners`. */ + agentsAt?: (cwd: string | undefined) => Map, ): void { let parsed: unknown; try { @@ -143,7 +146,7 @@ export function handleSocketMessage( ? liveRuns.get(command.runId) : undefined; const routed = held?.agentId !== undefined && command.agentId === undefined ? { ...command, agentId: held.agentId } : command; - void dispatchSingleStage(socket, routed, runners, resolveRecoveryService, liveRuns, auditLog); + void dispatchSingleStage(socket, routed, agentsAt?.(routed.cwd) ?? runners, resolveRecoveryService, liveRuns, auditLog); } async function streamToSocket(socket: WebSocket, events: AsyncIterable): Promise { From a4df200694ad070d94a3e7f13e307baedb0fdc0b Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Sat, 10 Oct 2026 17:19:29 +0300 Subject: [PATCH 2/3] a-change-runs-in-its-own-worktree: the owner's check (4.3) Co-Authored-By: Claude Opus 5.5 --- openspec/changes/a-change-runs-in-its-own-worktree/tasks.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openspec/changes/a-change-runs-in-its-own-worktree/tasks.md b/openspec/changes/a-change-runs-in-its-own-worktree/tasks.md index d6956383..6f4dacd5 100644 --- a/openspec/changes/a-change-runs-in-its-own-worktree/tasks.md +++ b/openspec/changes/a-change-runs-in-its-own-worktree/tasks.md @@ -37,6 +37,8 @@ refused as outside the workspace. 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. -- [ ] 4.3 **Human-only**: in `HppMCP`, on the extension built from this +- [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 ("Чендж запустился и работает"). From 33289d0712fdc82ce705496313e32d2190081dea Mon Sep 17 00:00:00 2001 From: Alexander Ivanov Date: Sat, 10 Oct 2026 17:51:53 +0300 Subject: [PATCH 3/3] Fix English policy violation in task evidence --- openspec/changes/a-change-runs-in-its-own-worktree/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/a-change-runs-in-its-own-worktree/tasks.md b/openspec/changes/a-change-runs-in-its-own-worktree/tasks.md index 6f4dacd5..8f120723 100644 --- a/openspec/changes/a-change-runs-in-its-own-worktree/tasks.md +++ b/openspec/changes/a-change-runs-in-its-own-worktree/tasks.md @@ -41,4 +41,4 @@ refused as outside the workspace. 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 ("The change started and is working").