feat(#2584): Phase 3 — scheduler consumer + isolation adapters (#2635)

Final phase of #2584 (ADR-1239 Codex-binding amendment). execute-phase now negotiates dispatch.isolation and dispatches through the matching adapter, so a wave's independent plans run concurrently on six runtimes instead of one — with no runtime=== branch in the scheduler.

harness-worktree passes the host's declared isolation flag (claude, cursor); orchestrator-worktree creates the worktree via the Phase-2 verb and spawns the executor into it with the resolved argv/cwd (codex, opencode, kimi, kimi-code); none stays sequential. Undeclared/unknown/unresolvable isolation degrades to none — never an unisolated parallel run.

Fixes two shipped Phase-2 descriptors that per-host research found would fail at spawn: kimi lacked its headless flag (would launch the interactive TUI and hang the orchestrator), and kimi-code named a non-existent binary (Kimi Code installs as 'kimi'). Adds the worktree-path root confinement Phase 2 deferred here, and leading-dash guards on the resolver's prompt/cwd matching the existing git-argument guard.

Closes #2627

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-25 01:50:20 -04:00
committed by GitHub
parent 461c744c31
commit 6ad30f74b6
62 changed files with 1105 additions and 205 deletions

View File

@@ -1337,7 +1337,7 @@ function cmdWorktreeCreate(cwd: string, args: string[] = [], deps: RecordAgentCm
const manifestPath = flag('--manifest');
if (!manifestPath) {
writeErr('Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>\n');
writeErr('Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> [--root <dir>]\n');
process.exitCode = 2;
return { ok: false, reason: 'usage' };
}
@@ -1410,6 +1410,40 @@ function cmdWorktreeCreate(cwd: string, args: string[] = [], deps: RecordAgentCm
return { ok: false, reason: plan.reason, hint: plan.hint };
}
// 3b. Optional root confinement (#2627, Phase 3 — the confinement Phase 2
// deferred here from planWorktreeCreate's path-traversal guard).
// planWorktreeCreate rejects a literal ".." SEGMENT, but a plain absolute
// path outside the project contains no ".." and passes. Phase 3 makes the
// orchestrator SPAWN executor processes into these paths, so an
// unconfined --path is a write primitive aimed anywhere on the filesystem.
//
// The root is DECLARED by the caller (`--root`) rather than inferred: agent
// worktrees legitimately live outside the orchestrator's own root (a lane
// orchestrator creates siblings under the repo's .claude/worktrees/), so
// there is no layout this module could derive without guessing. Absent
// `--root` the behavior is exactly as shipped in Phase 2 — the
// orchestrator-worktree scheduler path always passes it.
//
// Lexical by design: the worktree does not exist yet, so there is nothing
// to realpath, and resolving only the root would not close a symlinked-leaf
// hole. Pairs with the leading-dash and ".."-segment guards above.
const rootFlag = flag('--root');
if (rootFlag) {
const absRoot = path.resolve(cwd, rootFlag);
const absWorktree = path.resolve(cwd, plan.entry.worktree_path);
const rel = path.relative(absRoot, absWorktree);
// rel === '' → the worktree IS the root (would clobber the checkout)
// rel === '..' / '../…' → escapes the root
// path.isAbsolute(rel) → a different Windows drive or UNC root
if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
const hint = `--path must resolve INSIDE --root (root="${absRoot}", path="${absWorktree}"). A worktree outside the declared root is unreachable by manifest-scoped cleanup and would let a spawned executor write outside the project.`;
writeErr(`[gsd] worktree.create: path_outside_root — ${hint}\n`);
write(`${JSON.stringify({ ok: false, reason: 'path_outside_root', hint }, null, 2)}\n`);
process.exitCode = 1;
return { ok: false, reason: 'path_outside_root', hint };
}
}
// 4. Compute the deduped final manifest STRING in memory now — the ONLY
// manifest work left is the guarded write in step 6, after git succeeds.
// #2584 FIX 2: the on-disk entry is the SAME minimal 4-field shape