From 06768338c51a49abe3232745b0a3933f5ff59cce Mon Sep 17 00:00:00 2001 From: Koen Lavrijssen Date: Thu, 17 Sep 2026 12:05:19 +0200 Subject: [PATCH 01/12] feat(pool): add pooled-workspace types and persistence A pooled workspace is a fixed set of interchangeable environment directories, each holding the same child git repositories side by side. A task leases one whole environment rather than building a worktree, so its installed dependencies and warm build caches are used in place. This adds the state that shape needs: a fourth git isolation mode, the project's pool configuration, and the leased environment and per-repo branches on a task. Persisted rows are validated on load like every other hand-editable field, since every pool git call is driven from them. Co-Authored-By: Claude Opus 5 --- src/lib/load-task-diff.ts | 6 +++-- src/store/persistence.ts | 56 +++++++++++++++++++++++++++++++++++++++ src/store/types.ts | 40 +++++++++++++++++++++++++++- 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/lib/load-task-diff.ts b/src/lib/load-task-diff.ts index b1adcc36..2cc25f05 100644 --- a/src/lib/load-task-diff.ts +++ b/src/lib/load-task-diff.ts @@ -6,6 +6,7 @@ import { isUncommittedSelection, type CommitSelection, } from '../components/CommitNavBar'; +import type { GitIsolationMode } from '../store/types'; export interface TaskDiffInput { worktreePath: string; @@ -16,9 +17,10 @@ export interface TaskDiffInput { } /** Direct tasks work on their base branch, so naming that branch as the diff base - * would compare HEAD to itself and hide committed work from the All view. */ + * would compare HEAD to itself and hide committed work from the All view. + * Pool tasks branch off their base like worktree tasks do, so they keep it. */ export function getTaskDiffBaseBranch( - gitIsolation: 'worktree' | 'direct' | 'none', + gitIsolation: GitIsolationMode, baseBranch?: string, ): string | undefined { return gitIsolation === 'direct' ? undefined : baseBranch; diff --git a/src/store/persistence.ts b/src/store/persistence.ts index d2b90ddc..0746d038 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -19,7 +19,9 @@ import type { PersistedState, PersistedTask, PersistedWindowState, + PoolConfig, Project, + TaskRepo, } from './types'; import type { AgentDef } from '../ipc/types'; import { inferDockerSource } from '../lib/docker'; @@ -129,6 +131,53 @@ function restoredPromptHistory(value: unknown): Task['promptHistory'] { }); } +/** + * Persisted state is untrusted input: a hand-edited or truncated file must not + * hand the app a repo entry whose path or branch is missing, because every + * pool git call is driven from these rows. + */ +function restoredTaskRepos(value: unknown): Task['repos'] { + if (!Array.isArray(value)) return undefined; + const repos = value.flatMap((entry: unknown): TaskRepo[] => { + if (!entry || typeof entry !== 'object') return []; + const row = entry as Record; + const { name, path, branchName, baseBranch } = row; + if (typeof name !== 'string' || !name.trim()) return []; + if (typeof path !== 'string' || !path.trim()) return []; + if (typeof branchName !== 'string' || !branchName.trim()) return []; + if (typeof baseBranch !== 'string' || !baseBranch.trim()) return []; + return [{ name, path, branchName, baseBranch }]; + }); + return repos.length > 0 ? repos : undefined; +} + +function restoredNumberMap(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const out: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + if (typeof entry === 'number' && Number.isInteger(entry) && entry > 0) out[key] = entry; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function restoredPool(value: unknown): PoolConfig | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const raw = value as Record; + const envPaths = Array.isArray(raw.envPaths) + ? raw.envPaths.filter((entry): entry is string => typeof entry === 'string' && !!entry.trim()) + : []; + if (envPaths.length === 0) return undefined; + const members = Array.isArray(raw.members) + ? raw.members.filter((entry): entry is string => typeof entry === 'string' && !!entry.trim()) + : undefined; + return { + envPaths, + members: members && members.length > 0 ? members : undefined, + portBase: restoredNumberMap(raw.portBase), + portOffsets: restoredNumberMap(raw.portOffsets), + }; +} + function validAgentId(value: unknown, agentIds: string[]): string | undefined { return typeof value === 'string' && agentIds.includes(value) ? value : undefined; } @@ -189,6 +238,8 @@ function toPersistedTask(task: Task, agentDefs: AgentDef[], collapsed?: boolean) selectedAgentId: task.selectedAgentId, aiTerminalLayout: task.aiTerminalLayout, gitIsolation: task.gitIsolation, + envPath: task.envPath, + repos: task.repos, baseBranch: task.baseBranch, externalWorktree: task.externalWorktree, skipPermissions: task.skipPermissions, @@ -527,6 +578,7 @@ export async function loadState(): Promise { p.coverageReportPath = undefined; } p.tasksCollapsed = typeof p.tasksCollapsed === 'boolean' ? p.tasksCollapsed : undefined; + p.pool = restoredPool(p.pool); // Migrate defaultDirectMode -> defaultGitIsolation const legacy = p as Project & { defaultDirectMode?: boolean }; if (legacy.defaultDirectMode !== undefined && p.defaultGitIsolation === undefined) { @@ -783,6 +835,8 @@ export async function loadState(): Promise { promptedAgentIds: restoredPromptedAgentIds(pt, agentIds), initialPrompt: typeof pt.initialPrompt === 'string' ? pt.initialPrompt : undefined, gitIsolation: legacy.gitIsolation ?? (legacy.directMode ? 'direct' : 'worktree'), + envPath: typeof pt.envPath === 'string' ? pt.envPath : undefined, + repos: restoredTaskRepos(pt.repos), baseBranch: legacy.baseBranch || undefined, externalWorktree: pt.externalWorktree, skipPermissions: pt.skipPermissions === true, @@ -896,6 +950,8 @@ export async function loadState(): Promise { initialPrompt: typeof pt.initialPrompt === 'string' ? pt.initialPrompt : undefined, gitIsolation: legacyCollapsed.gitIsolation ?? (legacyCollapsed.directMode ? 'direct' : 'worktree'), + envPath: typeof pt.envPath === 'string' ? pt.envPath : undefined, + repos: restoredTaskRepos(pt.repos), baseBranch: legacyCollapsed.baseBranch || undefined, externalWorktree: pt.externalWorktree, skipPermissions: pt.skipPermissions === true, diff --git a/src/store/types.ts b/src/store/types.ts index 5e86a157..c91b4f43 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -14,7 +14,36 @@ import type { CustomTheme } from '../lib/custom-theme'; /** A user override for a binding: partial key/modifiers to apply, or null to unbind. */ export type KeybindingOverride = Partial> | null; -export type GitIsolationMode = 'worktree' | 'direct' | 'none'; +export type GitIsolationMode = 'worktree' | 'direct' | 'none' | 'pool'; + +/** + * A pooled workspace: a fixed set of interchangeable environment directories, + * each holding the same child git repositories checked out side by side. A + * task leases one whole environment instead of building a worktree, so the + * environment's installed dependencies and build caches are used in place. + */ +export interface PoolConfig { + /** Absolute paths of the environments, in lease-preference order. */ + envPaths: string[]; + /** Child repo names to manage. Empty means "discover them in the env". */ + members?: string[]; + /** Lowest port an environment may serve on, keyed by env path. */ + portBase?: Record; + /** Port added to an environment's base, keyed by member name. */ + portOffsets?: Record; +} + +/** One member repository of a leased environment, and the task's branch in it. */ +export interface TaskRepo { + /** Directory name under the environment root, e.g. "waiter". */ + name: string; + /** Absolute path of the repository. */ + path: string; + /** Branch the task created here; every member repo shares one name. */ + branchName: string; + /** Base branch this repo was on when the task leased the environment. */ + baseBranch: string; +} export interface StagedNotification { batchId: string; @@ -96,6 +125,8 @@ export interface Project { documentOpenPath?: string; /** Preview scale; independent of the app and terminal zoom. */ documentZoom?: number; + /** Pooled workspace configuration; present only on pool projects. */ + pool?: PoolConfig; /** Agent that owns the project's warm main session. */ documentMainAgentId?: string; /** Resumable main sessions per agent id, with the base sha each last saw. */ @@ -171,6 +202,11 @@ export interface Task { closingStatus?: 'closing' | 'removing' | 'error'; closingError?: string; gitIsolation: GitIsolationMode; + /** Environment this pool task leased; its root is also `worktreePath`. */ + envPath?: string; + /** Member repos the task branched, for pool tasks. The git surface of a + * pool task is every entry here, not the single `worktreePath` repo. */ + repos?: TaskRepo[]; baseBranch?: string; /** Worktree branch the user declined to adopt as the task branch (the * adoption banner's Undo). Persisted — auto-adoption must not re-apply a @@ -273,6 +309,8 @@ export interface PersistedTask { selectedAgentId?: string; aiTerminalLayout?: 'split' | 'tabs'; gitIsolation: GitIsolationMode; + envPath?: string; + repos?: TaskRepo[]; baseBranch?: string; externalWorktree?: boolean; skipPermissions?: boolean; From c4a66b8f97d105b9975042bd278d92becd5d3830 Mon Sep 17 00:00:00 2001 From: Koen Lavrijssen Date: Thu, 17 Sep 2026 12:06:09 +0200 Subject: [PATCH 02/12] feat(pool): discover the member repositories of an environment Which repositories a pooled environment holds comes from a manifest the workspace already keeps for its own setup script, or from a scan one level down when there is none. A manifest is preferred because it names repositories that belong to the workspace even while they are missing, which is what lets "you have not run the setup script yet" stay a different answer from "this environment has no repositories". Member names become path segments under the environment root, so they are validated rather than trusted: a manifest is a file inside a checkout, and a checkout is not a trust boundary. Co-Authored-By: Claude Opus 5 --- electron/ipc/pool-members.test.ts | 151 ++++++++++++++++++++++++++++++ electron/ipc/pool-members.ts | 129 +++++++++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 electron/ipc/pool-members.test.ts create mode 100644 electron/ipc/pool-members.ts diff --git a/electron/ipc/pool-members.test.ts b/electron/ipc/pool-members.test.ts new file mode 100644 index 00000000..d0d9d2a1 --- /dev/null +++ b/electron/ipc/pool-members.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + discoverMembers, + isSafeMemberName, + parseRepoManifest, + readManifest, + scanForMembers, +} from './pool-members.js'; + +let envPath: string; + +beforeEach(() => { + envPath = fs.mkdtempSync(path.join(os.tmpdir(), 'pool-members-')); +}); + +afterEach(() => { + fs.rmSync(envPath, { recursive: true, force: true }); +}); + +/** A checked-out member: a directory with a `.git` entry in it. */ +function makeRepo(name: string, gitEntry: 'dir' | 'file' = 'dir'): void { + const repo = path.join(envPath, name); + fs.mkdirSync(repo, { recursive: true }); + if (gitEntry === 'dir') fs.mkdirSync(path.join(repo, '.git')); + else fs.writeFileSync(path.join(repo, '.git'), 'gitdir: ../.git/modules/x'); +} + +describe('isSafeMemberName', () => { + it('accepts an ordinary directory name', () => { + expect(isSafeMemberName('waiter')).toBe(true); + expect(isSafeMemberName('report-service')).toBe(true); + }); + + it('rejects anything that would leave the environment root', () => { + expect(isSafeMemberName('..')).toBe(false); + expect(isSafeMemberName('../etc')).toBe(false); + expect(isSafeMemberName('a/b')).toBe(false); + expect(isSafeMemberName('a\\b')).toBe(false); + }); + + it('rejects dotted, empty, newline and untrimmed names', () => { + expect(isSafeMemberName('.git')).toBe(false); + expect(isSafeMemberName('.worktrees')).toBe(false); + expect(isSafeMemberName('')).toBe(false); + expect(isSafeMemberName('waiter\nrm -rf')).toBe(false); + expect(isSafeMemberName(' waiter')).toBe(false); + }); +}); + +describe('parseRepoManifest', () => { + it('reads name, skips the url, and keeps a branch override', () => { + const members = parseRepoManifest( + ['api\tgit@bitbucket.org:possys/api.git', 'reports\tgit@host:possys/reports.git\tmain'].join( + '\n', + ), + ); + expect(members).toEqual([ + { name: 'api', branch: undefined }, + { name: 'reports', branch: 'main' }, + ]); + }); + + it('splits on any whitespace, not tabs alone', () => { + // The Winston dev-env's own repos.tsv has a space-separated row. + expect(parseRepoManifest('report-service git@host:possys/report-service.git')).toEqual([ + { name: 'report-service', branch: undefined }, + ]); + }); + + it('skips comments and blank lines', () => { + expect(parseRepoManifest('# a comment\n\n \napi\tgit@host:api.git')).toEqual([ + { name: 'api', branch: undefined }, + ]); + }); + + it('keeps the first of a duplicated name', () => { + const members = parseRepoManifest( + 'report-service\tgit@host:a.git\treports\nreport-service git@host:b.git', + ); + expect(members).toEqual([{ name: 'report-service', branch: 'reports' }]); + }); + + it('drops an unsafe row without losing the file', () => { + const members = parseRepoManifest('../escape\tgit@host:a.git\napi\tgit@host:api.git'); + expect(members).toEqual([{ name: 'api', branch: undefined }]); + }); +}); + +describe('readManifest', () => { + it('returns null when the environment has no manifest', () => { + expect(readManifest(envPath)).toBeNull(); + }); + + it('reads repos.tsv from the environment root', () => { + fs.writeFileSync(path.join(envPath, 'repos.tsv'), 'waiter\tgit@host:waiter.git\n'); + expect(readManifest(envPath)).toEqual([{ name: 'waiter', branch: undefined }]); + }); +}); + +describe('scanForMembers', () => { + it('finds child git repositories and ignores everything else', () => { + makeRepo('waiter'); + makeRepo('shared', 'file'); + fs.mkdirSync(path.join(envPath, 'scripts')); + fs.mkdirSync(path.join(envPath, '.worktrees')); + fs.writeFileSync(path.join(envPath, 'README.md'), ''); + expect(scanForMembers(envPath)).toEqual([{ name: 'shared' }, { name: 'waiter' }]); + }); + + it('returns nothing for an unreadable environment', () => { + expect(scanForMembers(path.join(envPath, 'does-not-exist'))).toEqual([]); + }); +}); + +describe('discoverMembers', () => { + it('prefers the manifest and reports declared repos that are not cloned yet', () => { + fs.writeFileSync( + path.join(envPath, 'repos.tsv'), + 'waiter\tgit@host:waiter.git\napi\tgit@host:api.git\n', + ); + makeRepo('waiter'); + expect(discoverMembers(envPath)).toEqual({ + members: [{ name: 'waiter', branch: undefined }], + missing: ['api'], + }); + }); + + it('falls back to a scan when there is no manifest', () => { + makeRepo('waiter'); + expect(discoverMembers(envPath)).toEqual({ members: [{ name: 'waiter' }], missing: [] }); + }); + + it("uses the project's own list ahead of the manifest", () => { + fs.writeFileSync(path.join(envPath, 'repos.tsv'), 'waiter\tgit@host:waiter.git\n'); + makeRepo('waiter'); + makeRepo('api'); + expect(discoverMembers(envPath, ['api'])).toEqual({ members: [{ name: 'api' }], missing: [] }); + }); + + it('drops an unsafe configured name instead of building a path from it', () => { + makeRepo('api'); + expect(discoverMembers(envPath, ['../etc', 'api'])).toEqual({ + members: [{ name: 'api' }], + missing: [], + }); + }); +}); diff --git a/electron/ipc/pool-members.ts b/electron/ipc/pool-members.ts new file mode 100644 index 00000000..23b90b0e --- /dev/null +++ b/electron/ipc/pool-members.ts @@ -0,0 +1,129 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * Member discovery for pooled workspaces. + * + * A pooled environment is a directory holding several independent git + * repositories side by side. Which repositories those are is either stated by + * a manifest the workspace already keeps for its own setup script, or — when + * there is none — discovered by looking one level down for git repositories. + * + * Nothing here runs git or touches the environment beyond reading it, so the + * parsing rules stay unit-testable on fixtures. + */ + +/** Manifest filenames probed at an environment root, in order. */ +export const MANIFEST_FILENAMES = ['repos.tsv'] as const; + +export interface PoolMemberSpec { + /** Directory name under the environment root. */ + name: string; + /** Branch the manifest pins this repository to, when it names one. */ + branch?: string; +} + +/** + * A member name becomes a path segment under the environment root, so it is + * validated rather than trusted: a manifest is a file inside a repository and + * a checkout is not a trust boundary. Leading dots are rejected too, which + * keeps `.git`, `.worktrees` and the pool's own `.parallel-code` out of the + * member list whichever way they were proposed. + */ +export function isSafeMemberName(name: string): boolean { + if (name.length === 0 || name.startsWith('.')) return false; + if (name.includes('/') || name.includes('\\')) return false; + if (name.includes('\n') || name.includes('\r')) return false; + if (name !== name.trim()) return false; + return true; +} + +/** + * Parse a `nameurl[branch]` manifest. + * + * Fields are split on any run of whitespace rather than on tabs alone: the + * Winston dev-env's own `repos.tsv` has a space-separated row, and a manifest + * that a human maintains will keep acquiring them. Blank lines and `#` + * comments are skipped, the first entry wins on a duplicate name, and a row + * whose name would not be a safe path segment is dropped rather than failing + * the whole file — one bad row should not cost the other eleven. + */ +export function parseRepoManifest(text: string): PoolMemberSpec[] { + const seen = new Set(); + const members: PoolMemberSpec[] = []; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const [name, , branch] = line.split(/\s+/); + if (!name || !isSafeMemberName(name) || seen.has(name)) continue; + seen.add(name); + members.push({ name, branch: branch || undefined }); + } + return members; +} + +/** Read the first manifest present at an environment root, if any. */ +export function readManifest(envPath: string): PoolMemberSpec[] | null { + for (const filename of MANIFEST_FILENAMES) { + try { + const text = fs.readFileSync(path.join(envPath, filename), 'utf8'); + return parseRepoManifest(text); + } catch { + // Absent or unreadable — try the next name, then fall back to a scan. + } + } + return null; +} + +/** Whether `dir` is a git repository: `.git` as a directory or a worktree file. */ +export function isGitCheckout(dir: string): boolean { + try { + return fs.existsSync(path.join(dir, '.git')); + } catch { + return false; + } +} + +/** Directories one level under `envPath` that are git repositories. */ +export function scanForMembers(envPath: string): PoolMemberSpec[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(envPath, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((entry) => entry.isDirectory() && isSafeMemberName(entry.name)) + .filter((entry) => isGitCheckout(path.join(envPath, entry.name))) + .map((entry) => ({ name: entry.name })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * The member repositories of one environment. + * + * `configured` is the project's own list and wins outright when set, because a + * user who narrowed the list meant it. Otherwise a manifest is preferred over a + * scan, since it names repositories that belong to the workspace even while + * they are missing — a distinction the readiness check needs and a scan of the + * filesystem cannot make. + * + * Entries are filtered to what is actually checked out; `missing` carries the + * rest, so "you have not run the setup script yet" stays a different answer + * from "this environment has no repositories". + */ +export function discoverMembers( + envPath: string, + configured?: string[], +): { members: PoolMemberSpec[]; missing: string[] } { + const declared = configured?.length + ? configured.filter(isSafeMemberName).map((name) => ({ name })) + : (readManifest(envPath) ?? scanForMembers(envPath)); + const members: PoolMemberSpec[] = []; + const missing: string[] = []; + for (const spec of declared) { + if (isGitCheckout(path.join(envPath, spec.name))) members.push(spec); + else missing.push(spec.name); + } + return { members, missing }; +} From 3e6cee1f9bfdfa5ca7a825d32cfd69d79b61974f Mon Sep 17 00:00:00 2001 From: Koen Lavrijssen Date: Thu, 17 Sep 2026 12:06:59 +0200 Subject: [PATCH 03/12] feat(pool): lease and release a pooled environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leasing checks an environment is ready before it takes it, and reports every blocker at once rather than one per attempt: three round trips to find three dirty repositories is the failure mode worth avoiding. Every member repository gets the task's branch, not just the ones the change turns out to touch — one branch name across every repository a change spans is the convention these workspaces already follow, and an unused branch is deleted again on release. Creation is all-or-nothing, because a half-branched environment would be leased again, found clean, and quietly inherit the leftovers. The lease is written into the environment as well as held in app state, so a second app instance, or a person in a terminal, can see the environment is taken. A lease whose task no longer exists is reclaimed rather than stranding the environment. Co-Authored-By: Claude Opus 5 --- electron/ipc/git.ts | 11 ++ electron/ipc/pool.test.ts | 258 ++++++++++++++++++++++++++++++ electron/ipc/pool.ts | 325 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 594 insertions(+) create mode 100644 electron/ipc/pool.test.ts create mode 100644 electron/ipc/pool.ts diff --git a/electron/ipc/git.ts b/electron/ipc/git.ts index 8b5531f5..663a3b3c 100644 --- a/electron/ipc/git.ts +++ b/electron/ipc/git.ts @@ -202,6 +202,17 @@ const SYMLINK_EXCLUDE_HEADER = '# parallel-code: worktree symlinks'; * `..` is only rejected as a full name: as a substring (`foo..bar`) it is a * legal filename, not a traversal. */ +/** + * Run a git command in `cwd` and return its trimmed stdout. + * + * Exported so the pool module drives git through the same traced, buffered + * path as everything else here rather than reaching for `execFile` itself. + */ +export async function runGit(cwd: string, args: string[]): Promise { + const { stdout } = await exec('git', args, { cwd, maxBuffer: MAX_BUFFER }); + return stdout.trim(); +} + export function isValidSymlinkName(name: string): boolean { if (name.length === 0 || name === '.' || name === '..') return false; if (name.includes('/') || name.includes('\\')) return false; diff --git a/electron/ipc/pool.test.ts b/electron/ipc/pool.test.ts new file mode 100644 index 00000000..33307466 --- /dev/null +++ b/electron/ipc/pool.test.ts @@ -0,0 +1,258 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { acquireEnv, clearLease, envStatus, readLease, releaseEnv } from './pool.js'; + +let root: string; + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { + ...process.env, + GIT_AUTHOR_NAME: 'Test', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test', + GIT_COMMITTER_EMAIL: 'test@example.com', + }, + }).trim(); +} + +/** An environment with a repos.tsv manifest and one committed repo per name. */ +function makeEnv(name: string, repos: string[]): string { + const envPath = path.join(root, name); + fs.mkdirSync(envPath, { recursive: true }); + fs.writeFileSync( + path.join(envPath, 'repos.tsv'), + repos.map((repo) => `${repo}\tgit@example.com:acme/${repo}.git`).join('\n'), + ); + for (const repo of repos) { + const repoPath = path.join(envPath, repo); + fs.mkdirSync(repoPath); + git(repoPath, 'init', '--initial-branch=dev', '--quiet'); + fs.writeFileSync(path.join(repoPath, 'README.md'), `# ${repo}\n`); + git(repoPath, 'add', '.'); + git(repoPath, 'commit', '--quiet', '-m', 'initial'); + } + return envPath; +} + +function branchOf(repoPath: string): string { + return git(repoPath, 'rev-parse', '--abbrev-ref', 'HEAD'); +} + +function branchExists(repoPath: string, branch: string): boolean { + return git(repoPath, 'branch', '--list', branch).length > 0; +} + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'pool-')); +}); + +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('envStatus', () => { + it('reports a ready environment with no blockers', async () => { + const envPath = makeEnv('MRW1', ['waiter', 'api']); + const status = await envStatus(envPath, undefined, new Set()); + expect(status.blockers).toEqual([]); + expect(status.members.map((member) => member.name)).toEqual(['waiter', 'api']); + expect(status.lease).toBeNull(); + }); + + it('blocks on a missing folder', async () => { + const status = await envStatus(path.join(root, 'nope'), undefined, new Set()); + expect(status.exists).toBe(false); + expect(status.blockers).toEqual([{ reason: 'Environment folder not found' }]); + }); + + it('names the repo that is dirty rather than failing the environment', async () => { + const envPath = makeEnv('MRW1', ['waiter', 'api']); + fs.writeFileSync(path.join(envPath, 'waiter', 'scratch.txt'), 'wip'); + const status = await envStatus(envPath, undefined, new Set()); + expect(status.blockers).toEqual([{ repo: 'waiter', reason: 'Has uncommitted changes' }]); + }); + + it('separates "declared but not cloned" from "no repositories at all"', async () => { + const envPath = makeEnv('MRW3', ['waiter']); + fs.appendFileSync(path.join(envPath, 'repos.tsv'), '\napi\tgit@example.com:acme/api.git\n'); + const status = await envStatus(envPath, undefined, new Set()); + expect(status.missing).toEqual(['api']); + expect(status.blockers).toContainEqual({ + repo: 'api', + reason: 'Not cloned yet — run the environment’s setup script', + }); + }); + + it('treats a lease whose task is gone as free', async () => { + const envPath = makeEnv('MRW1', ['waiter']); + await acquireEnv({ + envPaths: [envPath], + taskId: 'task-1', + taskName: 'old work', + branchName: 'task/one', + liveTaskIds: ['task-1'], + }); + git(path.join(envPath, 'waiter'), 'checkout', '--quiet', 'dev'); + git(path.join(envPath, 'waiter'), 'branch', '-D', 'task/one'); + + expect((await envStatus(envPath, undefined, new Set(['task-1']))).lease).not.toBeNull(); + expect((await envStatus(envPath, undefined, new Set())).lease).toBeNull(); + }); +}); + +describe('acquireEnv', () => { + it('leases the first free environment and branches every member repo', async () => { + const one = makeEnv('MRW1', ['waiter', 'api']); + makeEnv('MRW2', ['waiter', 'api']); + + const result = await acquireEnv({ + envPaths: [one, path.join(root, 'MRW2')], + taskId: 'task-1', + taskName: 'ticket work', + branchName: 'task/win-1', + liveTaskIds: [], + }); + + expect(result.envPath).toBe(one); + expect(result.repos.map((repo) => repo.name)).toEqual(['waiter', 'api']); + expect(result.repos.every((repo) => repo.baseBranch === 'dev')).toBe(true); + expect(branchOf(path.join(one, 'waiter'))).toBe('task/win-1'); + expect(branchOf(path.join(one, 'api'))).toBe('task/win-1'); + expect(readLease(one)?.taskId).toBe('task-1'); + }); + + it('skips a leased environment and takes the next one', async () => { + const one = makeEnv('MRW1', ['waiter']); + const two = makeEnv('MRW2', ['waiter']); + await acquireEnv({ + envPaths: [one, two], + taskId: 'task-1', + taskName: 'first', + branchName: 'task/one', + liveTaskIds: [], + }); + + const second = await acquireEnv({ + envPaths: [one, two], + taskId: 'task-2', + taskName: 'second', + branchName: 'task/two', + liveTaskIds: ['task-1'], + }); + expect(second.envPath).toBe(two); + }); + + it('fails with every environment’s reason when the pool is full', async () => { + const one = makeEnv('MRW1', ['waiter']); + const two = makeEnv('MRW2', ['waiter']); + fs.writeFileSync(path.join(two, 'waiter', 'scratch.txt'), 'wip'); + await acquireEnv({ + envPaths: [one, two], + taskId: 'task-1', + taskName: 'first', + branchName: 'task/one', + liveTaskIds: [], + }); + + await expect( + acquireEnv({ + envPaths: [one, two], + taskId: 'task-2', + taskName: 'second', + branchName: 'task/two', + liveTaskIds: ['task-1'], + }), + ).rejects.toThrow(/No pool environment is free[\s\S]*Leased by task "first"[\s\S]*uncommitted/); + }); + + it('rolls the whole environment back when one repo refuses the branch', async () => { + const envPath = makeEnv('MRW1', ['waiter', 'api']); + // `api` already has the branch, so creating it there fails after `waiter` + // has already been switched. + git(path.join(envPath, 'api'), 'branch', 'task/win-1'); + + await expect( + acquireEnv({ + envPaths: [envPath], + taskId: 'task-1', + taskName: 'ticket work', + branchName: 'task/win-1', + liveTaskIds: [], + }), + ).rejects.toThrow(/nothing was changed/); + + expect(branchOf(path.join(envPath, 'waiter'))).toBe('dev'); + expect(branchExists(path.join(envPath, 'waiter'), 'task/win-1')).toBe(false); + expect(readLease(envPath)).toBeNull(); + }); +}); + +describe('releaseEnv', () => { + it('restores every repo and drops an unused branch', async () => { + const envPath = makeEnv('MRW1', ['waiter', 'api']); + const { repos } = await acquireEnv({ + envPaths: [envPath], + taskId: 'task-1', + taskName: 'ticket work', + branchName: 'task/win-1', + liveTaskIds: [], + }); + + const result = await releaseEnv({ envPath, repos }); + + expect(result).toEqual({ keptBranches: [], failures: [] }); + expect(branchOf(path.join(envPath, 'waiter'))).toBe('dev'); + expect(branchExists(path.join(envPath, 'waiter'), 'task/win-1')).toBe(false); + expect(readLease(envPath)).toBeNull(); + }); + + it('keeps a branch that holds commits, and deletes it when forced', async () => { + const envPath = makeEnv('MRW1', ['waiter', 'api']); + const { repos } = await acquireEnv({ + envPaths: [envPath], + taskId: 'task-1', + taskName: 'ticket work', + branchName: 'task/win-1', + liveTaskIds: [], + }); + const waiter = path.join(envPath, 'waiter'); + fs.writeFileSync(path.join(waiter, 'feature.txt'), 'done\n'); + git(waiter, 'add', '.'); + git(waiter, 'commit', '--quiet', '-m', 'WIN-1 | feature'); + + const kept = await releaseEnv({ envPath, repos }); + expect(kept.keptBranches).toEqual(['waiter']); + expect(branchOf(waiter)).toBe('dev'); + expect(branchExists(waiter, 'task/win-1')).toBe(true); + // The unused branch in the sibling repo is gone either way. + expect(branchExists(path.join(envPath, 'api'), 'task/win-1')).toBe(false); + + clearLease(envPath); + const forced = await releaseEnv({ envPath, repos, force: true }); + expect(forced.keptBranches).toEqual([]); + expect(branchExists(waiter, 'task/win-1')).toBe(false); + }); + + it('reports a repo it could not restore and still frees the environment', async () => { + const envPath = makeEnv('MRW1', ['waiter']); + const { repos } = await acquireEnv({ + envPaths: [envPath], + taskId: 'task-1', + taskName: 'ticket work', + branchName: 'task/win-1', + liveTaskIds: [], + }); + fs.rmSync(path.join(envPath, 'waiter'), { recursive: true, force: true }); + + const result = await releaseEnv({ envPath, repos }); + expect(result.failures.map((failure) => failure.repo)).toEqual(['waiter']); + expect(readLease(envPath)).toBeNull(); + }); +}); diff --git a/electron/ipc/pool.ts b/electron/ipc/pool.ts new file mode 100644 index 00000000..672cecf7 --- /dev/null +++ b/electron/ipc/pool.ts @@ -0,0 +1,325 @@ +import fs from 'fs'; +import path from 'path'; + +import { atomicWriteFile } from '../mcp/atomic.js'; +import { runGit } from './git.js'; +import { discoverMembers, type PoolMemberSpec } from './pool-members.js'; + +/** + * Leasing for pooled workspaces. + * + * A pooled environment is not built per task: it is one of a fixed set of + * ready checkouts that a task takes for its lifetime and gives back. That is + * the whole reason the mode exists — the environments already hold installed + * dependencies, initialised submodules and warm build caches, none of which + * survive being copied and none of which a worktree gets for free. + * + * Because an environment is a real directory a person can also `cd` into, the + * lease is written into it as well as held in app state. A colleague, a second + * app instance, or the same user in a terminal can see that MRW3 is taken. + */ + +/** Directory the pool keeps its own files in, inside each environment. */ +const POOL_DIR = '.parallel-code'; +const LEASE_FILE = 'lease.json'; + +export interface PoolLease { + taskId: string; + taskName: string; + branchName: string; + acquiredAt: number; + /** Process that took the lease, for a human reading the file. */ + pid: number; +} + +export interface PoolRepo { + name: string; + path: string; + branchName: string; + baseBranch: string; +} + +/** Why an environment cannot be leased right now. */ +export interface EnvBlocker { + /** Member repository the problem is in, or undefined for the environment. */ + repo?: string; + reason: string; +} + +export interface EnvStatus { + envPath: string; + exists: boolean; + members: PoolMemberSpec[]; + /** Declared by a manifest or the project, but not checked out yet. */ + missing: string[]; + lease: PoolLease | null; + /** Empty when the environment is ready to lease. */ + blockers: EnvBlocker[]; +} + +function leasePath(envPath: string): string { + return path.join(envPath, POOL_DIR, LEASE_FILE); +} + +export function readLease(envPath: string): PoolLease | null { + let raw: unknown; + try { + raw = JSON.parse(fs.readFileSync(leasePath(envPath), 'utf8')); + } catch { + return null; // absent, unreadable, or half-written: treat as free + } + if (!raw || typeof raw !== 'object') return null; + const lease = raw as Record; + if (typeof lease.taskId !== 'string' || !lease.taskId) return null; + return { + taskId: lease.taskId, + taskName: typeof lease.taskName === 'string' ? lease.taskName : '', + branchName: typeof lease.branchName === 'string' ? lease.branchName : '', + acquiredAt: typeof lease.acquiredAt === 'number' ? lease.acquiredAt : 0, + pid: typeof lease.pid === 'number' ? lease.pid : 0, + }; +} + +async function writeLease(envPath: string, lease: PoolLease): Promise { + fs.mkdirSync(path.join(envPath, POOL_DIR), { recursive: true }); + await atomicWriteFile(leasePath(envPath), `${JSON.stringify(lease, null, 2)}\n`); +} + +export function clearLease(envPath: string): void { + try { + fs.unlinkSync(leasePath(envPath)); + } catch { + // Already gone — releasing twice is not an error. + } +} + +/** + * A lease whose task no longer exists is stale: the app was killed, or the + * task was removed while the environment was unreachable. Reclaiming it is + * safe because the environment's own cleanliness is checked separately, and + * refusing would strand the environment until someone deleted a file by hand. + */ +function isStaleLease(lease: PoolLease | null, liveTaskIds: ReadonlySet): boolean { + return lease !== null && !liveTaskIds.has(lease.taskId); +} + +/** Whether a repository has uncommitted changes, including untracked files. */ +async function isDirty(repoPath: string): Promise { + return (await runGit(repoPath, ['status', '--porcelain'])).length > 0; +} + +/** Current branch, or null when HEAD is detached. */ +async function currentBranch(repoPath: string): Promise { + const name = await runGit(repoPath, ['rev-parse', '--abbrev-ref', 'HEAD']); + return name === 'HEAD' ? null : name; +} + +/** + * Inspect one environment: what it holds, who has it, and what stands between + * it and a lease. + * + * Blockers are collected rather than thrown on the first one, because the + * point of the report is to tell someone everything they have to fix before + * this environment is usable — three round trips to find three dirty repos is + * the failure mode this avoids. + */ +export async function envStatus( + envPath: string, + configuredMembers: string[] | undefined, + liveTaskIds: ReadonlySet, +): Promise { + if (!fs.existsSync(envPath)) { + return { + envPath, + exists: false, + members: [], + missing: [], + lease: null, + blockers: [{ reason: 'Environment folder not found' }], + }; + } + + const { members, missing } = discoverMembers(envPath, configuredMembers); + const rawLease = readLease(envPath); + const lease = isStaleLease(rawLease, liveTaskIds) ? null : rawLease; + const blockers: EnvBlocker[] = []; + + if (lease) { + blockers.push({ reason: `Leased by task "${lease.taskName || lease.taskId}"` }); + } + for (const name of missing) { + blockers.push({ repo: name, reason: 'Not cloned yet — run the environment’s setup script' }); + } + if (members.length === 0) { + blockers.push({ reason: 'No git repositories found in this environment' }); + } + + await Promise.all( + members.map(async (member) => { + const repoPath = path.join(envPath, member.name); + try { + if (await isDirty(repoPath)) { + blockers.push({ repo: member.name, reason: 'Has uncommitted changes' }); + } + if ((await currentBranch(repoPath)) === null) { + blockers.push({ repo: member.name, reason: 'HEAD is detached' }); + } + } catch (err) { + blockers.push({ repo: member.name, reason: `Not readable by git: ${String(err)}` }); + } + }), + ); + + return { envPath, exists: true, members, missing, lease, blockers }; +} + +export interface AcquireArgs { + envPaths: string[]; + configuredMembers?: string[]; + taskId: string; + taskName: string; + branchName: string; + /** Task ids the app still holds, so an abandoned lease can be reclaimed. */ + liveTaskIds: string[]; +} + +export interface AcquireResult { + envPath: string; + repos: PoolRepo[]; +} + +/** + * Lease the first ready environment and branch every member repository in it. + * + * All member repos get the branch, not just the ones the task turns out to + * touch: the platform convention is one branch name across every repository a + * change spans, the branch is free to create when HEAD is already at base, and + * an unused one is deleted again on release. Deciding up front also means the + * agent never has to ask permission to start editing a second repo. + * + * Creation is all-or-nothing. A repository that refuses the branch rolls the + * earlier ones back and frees the lease, because a half-branched environment + * is worse than none: the next task would lease it, find it clean, and quietly + * inherit the leftovers. + */ +export async function acquireEnv(args: AcquireArgs): Promise { + const liveTaskIds = new Set(args.liveTaskIds); + const reports: EnvStatus[] = []; + + for (const envPath of args.envPaths) { + const status = await envStatus(envPath, args.configuredMembers, liveTaskIds); + reports.push(status); + if (status.blockers.length > 0) continue; + + await writeLease(envPath, { + taskId: args.taskId, + taskName: args.taskName, + branchName: args.branchName, + acquiredAt: Date.now(), + pid: process.pid, + }); + + const created: PoolRepo[] = []; + try { + for (const member of status.members) { + const repoPath = path.join(envPath, member.name); + const baseBranch = (await currentBranch(repoPath)) ?? member.branch ?? 'HEAD'; + await runGit(repoPath, ['checkout', '-b', args.branchName]); + created.push({ + name: member.name, + path: repoPath, + branchName: args.branchName, + baseBranch, + }); + } + } catch (err) { + await rollbackBranches(created); + clearLease(envPath); + throw new Error( + `Could not branch every repository in ${envPath}, so nothing was changed: ${String(err)}`, + ); + } + return { envPath, repos: created }; + } + + throw new Error(describeUnavailable(reports)); +} + +async function rollbackBranches(repos: PoolRepo[]): Promise { + for (const repo of repos) { + try { + await runGit(repo.path, ['checkout', repo.baseBranch]); + await runGit(repo.path, ['branch', '-D', repo.branchName]); + } catch (err) { + console.warn(`Could not roll back ${repo.branchName} in ${repo.path}:`, err); + } + } +} + +/** One message naming why each environment was passed over. */ +function describeUnavailable(reports: EnvStatus[]): string { + if (reports.length === 0) return 'This project has no pool environments configured.'; + const lines = reports.map((report) => { + const detail = report.blockers + .map((blocker) => (blocker.repo ? `${blocker.repo}: ${blocker.reason}` : blocker.reason)) + .join('; '); + return ` ${report.envPath} — ${detail || 'unavailable'}`; + }); + return `No pool environment is free:\n${lines.join('\n')}`; +} + +export interface ReleaseArgs { + envPath: string; + repos: PoolRepo[]; + /** Delete the task branch even where it holds commits. */ + force?: boolean; +} + +export interface ReleaseResult { + /** Repos whose task branch was kept because it still holds commits. */ + keptBranches: string[]; + /** Repos that could not be restored; the lease is dropped regardless. */ + failures: { repo: string; reason: string }[]; +} + +/** + * Give an environment back: return every repository to the branch it was on, + * drop task branches that never earned a commit, restore the submodule + * checkouts to the pins of the base branch, and remove the lease. + * + * A branch with commits is kept unless the caller forces it. Losing work + * silently is the one outcome worth more than a tidy environment, and the + * readiness check will not be fooled: the next lease sees the environment is + * clean and on base, which it is. + */ +export async function releaseEnv(args: ReleaseArgs): Promise { + const keptBranches: string[] = []; + const failures: { repo: string; reason: string }[] = []; + + for (const repo of args.repos) { + try { + const onTaskBranch = (await currentBranch(repo.path)) === repo.branchName; + if (onTaskBranch) await runGit(repo.path, ['checkout', repo.baseBranch]); + + const unmerged = await runGit(repo.path, [ + 'log', + `${repo.baseBranch}..${repo.branchName}`, + '--oneline', + ]).catch(() => ''); + if (unmerged && !args.force) { + keptBranches.push(repo.name); + } else { + await runGit(repo.path, ['branch', '-D', repo.branchName]); + } + + // Submodule pins move with the branch; without this the environment + // stays on the task's pins and the next lease inherits them. + await runGit(repo.path, ['submodule', 'update', '--init', '--recursive']); + } catch (err) { + failures.push({ repo: repo.name, reason: String(err) }); + } + } + + clearLease(args.envPath); + return { keptBranches, failures }; +} From 9903b2344c6cedf92f68662a17e7c3ac5254461e Mon Sep 17 00:00:00 2001 From: Koen Lavrijssen Date: Thu, 17 Sep 2026 12:12:18 +0200 Subject: [PATCH 04/12] feat(pool): create and close tasks that lease an environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pool task's working directory is the leased environment itself, so every path-keyed feature — shells, canvas, browser preview, verify — points at it exactly as a worktree task points at its worktree, with no change needed. Branch names are derived in the main process beside the worktree ones, so a pool task and a worktree task of the same name get the same branch. Closing a pool task releases the environment instead of removing a worktree: member repos go back to their base branch, unused task branches are dropped, and the lease is cleared. Co-Authored-By: Claude Opus 5 --- electron/ipc/channel-manifest.json | 3 ++ electron/ipc/register.ts | 83 +++++++++++++++++++++++++++++- electron/ipc/shared-types.ts | 21 ++++++++ electron/ipc/tasks.ts | 64 +++++++++++++++++++++++ electron/preload.cjs | 3 ++ src/ipc/types.ts | 3 ++ src/store/tasks.ts | 51 +++++++++++++++++- 7 files changed, 226 insertions(+), 2 deletions(-) diff --git a/electron/ipc/channel-manifest.json b/electron/ipc/channel-manifest.json index 1a4cdca2..f822a317 100644 --- a/electron/ipc/channel-manifest.json +++ b/electron/ipc/channel-manifest.json @@ -10,6 +10,9 @@ "ListAgents": "list_agents", "CreateTask": "create_task", "DeleteTask": "delete_task", + "PoolEnvStatus": "pool_env_status", + "PoolAcquireEnv": "pool_acquire_env", + "PoolReleaseEnv": "pool_release_env", "GetChangedFiles": "get_changed_files", "GetChangedFilesFromBranch": "get_changed_files_from_branch", "GetAllFileDiffs": "get_all_file_diffs", diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 13a9e781..5b458b74 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -76,7 +76,8 @@ import { getUncommittedChangedFiles, getUncommittedFileDiffs, } from './git.js'; -import { createTask, deleteTask } from './tasks.js'; +import { createPoolTask, createTask, deletePoolTask, deleteTask } from './tasks.js'; +import { envStatus, type PoolRepo } from './pool.js'; import { listAgents } from './agents.js'; import { saveAppState, @@ -567,6 +568,86 @@ export function registerAllHandlers(win: BrowserWindow): void { }); return result; }); + /** + * Pool arguments name directories and branches the main process then runs + * git in, so every one is validated here rather than trusted from the + * renderer — the same rule the worktree handlers follow. + */ + function validatedPoolRepos(value: unknown): PoolRepo[] { + if (!Array.isArray(value)) throw new Error('repos must be an array'); + return value.map((entry, index) => { + if (!entry || typeof entry !== 'object') throw new Error(`repos[${index}] must be an object`); + const repo = entry as Record; + assertString(repo.name, `repos[${index}].name`); + validatePath(repo.path, `repos[${index}].path`); + validateBranchName(repo.branchName, `repos[${index}].branchName`); + validateBranchName(repo.baseBranch, `repos[${index}].baseBranch`); + return { + name: repo.name, + path: repo.path as string, + branchName: repo.branchName as string, + baseBranch: repo.baseBranch as string, + }; + }); + } + + function validatedEnvPaths(value: unknown): string[] { + assertStringArray(value, 'envPaths'); + for (const envPath of value) validatePath(envPath, 'envPaths[]'); + return value; + } + + function optionalMembers(value: unknown): string[] | undefined { + if (value === undefined) return undefined; + assertStringArray(value, 'members'); + return value; + } + + function validatedTaskIds(value: unknown): string[] { + assertStringArray(value, 'liveTaskIds'); + return value; + } + + ipcMain.handle(IPC.PoolEnvStatus, (_e, args) => { + const envPaths = validatedEnvPaths(args.envPaths); + const members = optionalMembers(args.members); + const liveTaskIds = new Set(validatedTaskIds(args.liveTaskIds)); + return Promise.all(envPaths.map((envPath) => envStatus(envPath, members, liveTaskIds))); + }); + + ipcMain.handle(IPC.PoolAcquireEnv, (_e, args) => { + const envPaths = validatedEnvPaths(args.envPaths); + assertString(args.name, 'name'); + assertOptionalString(args.branchPrefix, 'branchPrefix'); + const result = createPoolTask({ + name: args.name, + branchPrefix: args.branchPrefix ?? 'task', + envPaths, + members: optionalMembers(args.members), + liveTaskIds: validatedTaskIds(args.liveTaskIds), + }); + result + .then((r: { id: string }) => taskNames.set(r.id, args.name)) + .catch((err: unknown) => { + logWarn('tasks', 'createPoolTask resolution failed', { err: errMessage(err) }); + }); + return result; + }); + + ipcMain.handle(IPC.PoolReleaseEnv, (_e, args) => { + validatePath(args.envPath, 'envPath'); + assertStringArray(args.agentIds, 'agentIds'); + assertOptionalString(args.taskId, 'taskId'); + assertOptionalBoolean(args.force, 'force'); + return deletePoolTask({ + taskId: args.taskId, + agentIds: args.agentIds, + envPath: args.envPath, + repos: validatedPoolRepos(args.repos), + force: args.force, + }); + }); + ipcMain.handle(IPC.DeleteTask, (_e, args) => { assertStringArray(args.agentIds, 'agentIds'); validatePath(args.projectRoot, 'projectRoot'); diff --git a/electron/ipc/shared-types.ts b/electron/ipc/shared-types.ts index b0ae28be..253b0c4a 100644 --- a/electron/ipc/shared-types.ts +++ b/electron/ipc/shared-types.ts @@ -27,6 +27,27 @@ export interface CreateTaskResult { worktree_path: string; } +/** One member repository of a leased environment, as the renderer sees it. */ +export interface PoolTaskRepo { + name: string; + path: string; + branchName: string; + baseBranch: string; +} + +export interface CreatePoolTaskResult { + id: string; + branch_name: string; + env_path: string; + repos: PoolTaskRepo[]; +} + +export interface ReleasePoolEnvResult { + /** Repos whose task branch was kept because it still holds commits. */ + keptBranches: string[]; + failures: { repo: string; reason: string }[]; +} + export interface SymlinkCandidate { name: string; isDefault: boolean; diff --git a/electron/ipc/tasks.ts b/electron/ipc/tasks.ts index d24a7375..f0f77bed 100644 --- a/electron/ipc/tasks.ts +++ b/electron/ipc/tasks.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'crypto'; import { createWorktree, removeWorktree } from './git.js'; +import { acquireEnv, releaseEnv, type PoolRepo } from './pool.js'; import { killAgent, notifyAgentListChanged } from './pty.js'; import { stopPlanWatcher } from './plans.js'; import { stopStepsWatcher } from './steps.js'; @@ -48,6 +49,69 @@ export async function createTask( }; } +export interface CreatePoolTaskArgs { + name: string; + branchPrefix: string; + envPaths: string[]; + members?: string[]; + liveTaskIds: string[]; +} + +/** + * Create a task that leases a pooled environment. + * + * The branch name is derived here rather than in the renderer so a pool task + * and a worktree task of the same name get the same branch: one slug rule, one + * prefix rule, one place to change them. + */ +export async function createPoolTask(args: CreatePoolTaskArgs): Promise<{ + id: string; + branch_name: string; + env_path: string; + repos: PoolRepo[]; +}> { + const id = randomUUID(); + const prefix = sanitizeBranchPrefix(args.branchPrefix); + const branchName = `${prefix}/${slug(args.name)}-${id.slice(0, 6)}`; + const { envPath, repos } = await acquireEnv({ + envPaths: args.envPaths, + configuredMembers: args.members, + taskId: id, + taskName: args.name, + branchName, + liveTaskIds: args.liveTaskIds, + }); + return { id, branch_name: branchName, env_path: envPath, repos }; +} + +export interface DeletePoolTaskOpts { + taskId?: string; + agentIds: string[]; + envPath: string; + repos: PoolRepo[]; + /** Delete the task branch even where it holds commits. */ + force?: boolean; +} + +/** Stop a pool task's agents and hand its environment back to the pool. */ +export async function deletePoolTask(opts: DeletePoolTaskOpts): Promise<{ + keptBranches: string[]; + failures: { repo: string; reason: string }[]; +}> { + if (opts.taskId) stopPlanWatcher(opts.taskId); + if (opts.taskId) stopStepsWatcher(opts.taskId); + for (const agentId of opts.agentIds) { + try { + killAgent(agentId); + } catch { + /* already dead */ + } + } + const result = await releaseEnv({ envPath: opts.envPath, repos: opts.repos, force: opts.force }); + notifyAgentListChanged(); + return result; +} + interface DeleteTaskOpts { taskId?: string; agentIds: string[]; diff --git a/electron/preload.cjs b/electron/preload.cjs index 1cca2d99..dc7f9686 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -17,6 +17,9 @@ const ALLOWED_CHANNELS = new Set([ 'list_agents', 'create_task', 'delete_task', + 'pool_env_status', + 'pool_acquire_env', + 'pool_release_env', 'get_changed_files', 'get_changed_files_from_branch', 'get_all_file_diffs', diff --git a/src/ipc/types.ts b/src/ipc/types.ts index 4b46282d..a6f3dd75 100644 --- a/src/ipc/types.ts +++ b/src/ipc/types.ts @@ -6,6 +6,7 @@ export type { CoverageFileSummary, CoverageMetricSummary, CoverageSummary, + CreatePoolTaskResult, CreateTaskResult, EslintQualityFinding, EslintQualityResult, @@ -18,8 +19,10 @@ export type { PrCheckRun, PrChecksOverall, PrChecksUpdatePayload, + PoolTaskRepo, PrReviewDecision, PtyOutput, + ReleasePoolEnvResult, StepEntry, UsageProvider, UsageResult, diff --git a/src/store/tasks.ts b/src/store/tasks.ts index f1899a5a..cde209f5 100644 --- a/src/store/tasks.ts +++ b/src/store/tasks.ts @@ -23,13 +23,15 @@ import { warn as logWarn } from '../lib/log'; import { cleanTaskName } from '../lib/clean-task-name'; import type { AgentDef, + CreatePoolTaskResult, CreateTaskResult, + ReleasePoolEnvResult, ImportableWorktree, MergeResult, StepEntry, } from '../ipc/types'; import { parseGitHubUrl, taskNameFromGitHubUrl } from '../lib/github-url'; -import type { Agent, Task, GitIsolationMode, AppStore } from './types'; +import type { Agent, Task, TaskRepo, GitIsolationMode, AppStore } from './types'; import type { DockerSource } from '../lib/docker'; import { COORDINATOR_PREAMBLE } from './coordinator-preamble'; import { @@ -72,6 +74,8 @@ function createBaseTaskRecord(args: { worktreePath: string; agentId: string; baseBranch?: string; + envPath?: string; + repos?: TaskRepo[]; }): Task { return { id: args.id, @@ -80,6 +84,8 @@ function createBaseTaskRecord(args: { projectId: args.projectId, gitIsolation: args.gitIsolation, baseBranch: args.baseBranch, + envPath: args.envPath, + repos: args.repos, branchName: args.branchName, worktreePath: args.worktreePath, agentIds: [args.agentId], @@ -258,6 +264,8 @@ export async function createTask(opts: CreateTaskOptions): Promise { let taskId: string; let branchName: string; let worktreePath: string; + let envPath: string | undefined; + let repos: TaskRepo[] | undefined; if (gitIsolation === 'worktree') { const branchPrefix = opts.branchPrefixOverride ?? getProjectBranchPrefix(projectId); @@ -271,6 +279,28 @@ export async function createTask(opts: CreateTaskOptions): Promise { taskId = result.id; branchName = result.branch_name; worktreePath = result.worktree_path; + } else if (gitIsolation === 'pool') { + const pool = getProject(projectId)?.pool; + if (!pool || pool.envPaths.length === 0) { + throw new Error('This project has no pool environments configured'); + } + const result = await invoke(IPC.PoolAcquireEnv, { + name, + branchPrefix: opts.branchPrefixOverride ?? getProjectBranchPrefix(projectId), + envPaths: pool.envPaths, + members: pool.members, + // Leases held by tasks the app no longer has are reclaimable; only the + // live ones may keep an environment out of the pool. + liveTaskIds: [...store.taskOrder, ...store.collapsedTaskOrder], + }); + taskId = result.id; + branchName = result.branch_name; + envPath = result.env_path; + repos = result.repos; + // A pool task's working directory is the leased environment itself, so + // every path-keyed feature — shells, canvas, preview, verify — is pointed + // at it exactly as a worktree task points at its worktree. + worktreePath = result.env_path; } else if (gitIsolation === 'direct') { if (hasDirectTask(projectId)) { throw new Error('This project already has a task on the current branch'); @@ -366,6 +396,8 @@ export async function createTask(opts: CreateTaskOptions): Promise { branchName, worktreePath, agentId, + envPath, + repos, }), initialPrompt: opts.coordinatorMode && effectivePrompt @@ -526,6 +558,23 @@ export async function closeTask(taskId: string): Promise { }); } + // A pool task owns no worktree to remove: it gives its environment back, + // which restores every member repo to its base branch and drops the lease. + // `deleteBranchOnClose` is what forces a branch that still holds commits; + // without it the branch is kept and only the checkout is restored. + if (task.gitIsolation === 'pool' && task.envPath && task.repos) { + const release = await invoke(IPC.PoolReleaseEnv, { + taskId, + agentIds: [...agentIds, ...shellAgentIds], + envPath: task.envPath, + repos: task.repos, + force: deleteBranch, + }); + for (const failure of release.failures) { + console.warn(`Could not restore ${failure.repo} on close:`, failure.reason); + } + } + // Agents are dead — deregister the coordinator so no more MCP tool calls succeed. // Done after kills (not before) so a failed close leaves the backend registered // and the coordinator agent can still make tool calls until it's actually gone. From ad87f93bcdd8f835d894b70749ba659ec559285c Mon Sep 17 00:00:00 2001 From: Koen Lavrijssen Date: Thu, 17 Sep 2026 12:18:34 +0200 Subject: [PATCH 05/12] feat(pool): show one change across every repo it spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pool task's change lives in several repositories at once, so the changed files and the diff are the union of its member repositories rather than one repository's answer. Aggregation happens in the main process, and each path is re-rooted under its repository name — which makes it a real path relative to the environment root, the very path these panels already hold. Opening a file in an editor and resolving it back to the repository that owns it both keep working with no change to the panels themselves. Commit navigation stays off for pool tasks: it is per-repository, and a pool task spans several. Co-Authored-By: Claude Opus 5 --- electron/ipc/channel-manifest.json | 4 + electron/ipc/pool-git.test.ts | 174 +++++++++++++++++++++ electron/ipc/pool-git.ts | 154 ++++++++++++++++++ electron/ipc/register.ts | 14 ++ electron/preload.cjs | 4 + src/components/ChangedFilesList.tsx | 37 ++++- src/components/DiffViewerDialog.tsx | 4 + src/components/TaskChangedFilesSection.tsx | 3 + src/components/TaskPanel.tsx | 1 + src/lib/load-task-diff.ts | 10 ++ 10 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 electron/ipc/pool-git.test.ts create mode 100644 electron/ipc/pool-git.ts diff --git a/electron/ipc/channel-manifest.json b/electron/ipc/channel-manifest.json index f822a317..04843f08 100644 --- a/electron/ipc/channel-manifest.json +++ b/electron/ipc/channel-manifest.json @@ -13,6 +13,10 @@ "PoolEnvStatus": "pool_env_status", "PoolAcquireEnv": "pool_acquire_env", "PoolReleaseEnv": "pool_release_env", + "PoolChangedFiles": "pool_changed_files", + "PoolFileDiff": "pool_file_diff", + "PoolAllDiffs": "pool_all_diffs", + "PoolStatus": "pool_status", "GetChangedFiles": "get_changed_files", "GetChangedFilesFromBranch": "get_changed_files_from_branch", "GetAllFileDiffs": "get_all_file_diffs", diff --git a/electron/ipc/pool-git.test.ts b/electron/ipc/pool-git.test.ts new file mode 100644 index 00000000..fade26b0 --- /dev/null +++ b/electron/ipc/pool-git.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + poolAllDiffs, + poolChangedFiles, + poolFileDiff, + poolStatus, + prefixChangedFiles, + prefixDiffPaths, + splitRepoPath, +} from './pool-git.js'; +import type { PoolRepo } from './pool.js'; +import type { ChangedFile } from './shared-types.js'; + +const repos: PoolRepo[] = [ + { name: 'waiter', path: '/envs/MRW1/waiter', branchName: 'task/win-1', baseBranch: 'dev' }, + { name: 'shared', path: '/envs/MRW1/shared', branchName: 'task/win-1', baseBranch: 'dev' }, +]; + +describe('splitRepoPath', () => { + it('routes a path to the repository that owns it', () => { + expect(splitRepoPath(repos, 'shared/client/index.ts')).toEqual({ + repo: repos[1], + filePath: 'client/index.ts', + }); + }); + + it('returns null for a path outside every member repo', () => { + expect(splitRepoPath(repos, 'repos.tsv')).toBeNull(); + expect(splitRepoPath(repos, 'waiterish/file.ts')).toBeNull(); + }); +}); + +describe('prefixChangedFiles', () => { + it('re-roots paths, including a rename’s previous path', () => { + const files: ChangedFile[] = [ + { + path: 'src/new.ts', + previous_path: 'src/old.ts', + lines_added: 1, + lines_removed: 1, + status: 'R', + committed: true, + }, + ]; + expect(prefixChangedFiles('waiter', files)[0]).toMatchObject({ + path: 'waiter/src/new.ts', + previous_path: 'waiter/src/old.ts', + }); + }); +}); + +describe('prefixDiffPaths', () => { + it('re-roots the path-bearing headers only', () => { + const diff = [ + 'diff --git a/src/a.ts b/src/a.ts', + 'index 111..222 100644', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -1 +1 @@', + '-old', + '+new', + ].join('\n'); + expect(prefixDiffPaths('waiter', diff).split('\n')).toEqual([ + 'diff --git a/waiter/src/a.ts b/waiter/src/a.ts', + 'index 111..222 100644', + '--- a/waiter/src/a.ts', + '+++ b/waiter/src/a.ts', + '@@ -1 +1 @@', + '-old', + '+new', + ]); + }); + + it('re-roots a rename and leaves diff-shaped body lines alone', () => { + const diff = ['rename from src/old.ts', 'rename to src/new.ts', '+--- a/not/a/header'].join( + '\n', + ); + expect(prefixDiffPaths('shared', diff).split('\n')).toEqual([ + 'rename from shared/src/old.ts', + 'rename to shared/src/new.ts', + '+--- a/not/a/header', + ]); + }); + + it('leaves /dev/null in place, so an added file still parses', () => { + const diff = ['--- /dev/null', '+++ b/src/new.ts'].join('\n'); + expect(prefixDiffPaths('waiter', diff).split('\n')).toEqual([ + '--- /dev/null', + '+++ b/waiter/src/new.ts', + ]); + }); +}); + +describe('against real repositories', () => { + let root: string; + let envRepos: PoolRepo[]; + + function git(cwd: string, ...args: string[]): void { + execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { + ...process.env, + GIT_AUTHOR_NAME: 'Test', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test', + GIT_COMMITTER_EMAIL: 'test@example.com', + }, + }); + } + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'pool-git-')); + envRepos = ['waiter', 'shared'].map((name) => { + const repoPath = path.join(root, name); + fs.mkdirSync(repoPath); + git(repoPath, 'init', '--initial-branch=dev', '--quiet'); + fs.writeFileSync(path.join(repoPath, 'index.ts'), 'export const a = 1;\n'); + git(repoPath, 'add', '.'); + git(repoPath, 'commit', '--quiet', '-m', 'initial'); + git(repoPath, 'checkout', '--quiet', '-b', 'task/win-1'); + return { name, path: repoPath, branchName: 'task/win-1', baseBranch: 'dev' }; + }); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('lists changed files from every repo under its own name', async () => { + fs.writeFileSync(path.join(root, 'waiter', 'index.ts'), 'export const a = 2;\n'); + fs.writeFileSync(path.join(root, 'shared', 'added.ts'), 'export const b = 3;\n'); + + const files = await poolChangedFiles(envRepos); + expect(files.map((file) => file.path).sort()).toEqual(['shared/added.ts', 'waiter/index.ts']); + }); + + it('diffs a file in the repo that owns it', async () => { + fs.writeFileSync(path.join(root, 'shared', 'index.ts'), 'export const a = 99;\n'); + const diff = await poolFileDiff(envRepos, 'shared/index.ts'); + expect(diff.newContent).toContain('99'); + }); + + it('concatenates every repo’s diff with paths re-rooted at the environment', async () => { + fs.writeFileSync(path.join(root, 'waiter', 'index.ts'), 'export const a = 2;\n'); + fs.writeFileSync(path.join(root, 'shared', 'index.ts'), 'export const a = 3;\n'); + + const diff = await poolAllDiffs(envRepos); + expect(diff).toContain('a/waiter/index.ts'); + expect(diff).toContain('a/shared/index.ts'); + }); + + it('refuses a path no member repo owns', async () => { + await expect(poolFileDiff(envRepos, 'repos.tsv')).rejects.toThrow(/No pool repository owns/); + }); + + it('rolls per-repo status up, and reports a mixed branch as none', async () => { + fs.writeFileSync(path.join(root, 'waiter', 'index.ts'), 'export const a = 2;\n'); + + const agreed = await poolStatus(envRepos); + expect(agreed.combined.has_uncommitted_changes).toBe(true); + expect(agreed.combined.current_branch).toBe('task/win-1'); + expect(agreed.perRepo.map((status) => status.repo)).toEqual(['waiter', 'shared']); + + git(path.join(root, 'shared'), 'checkout', '--quiet', 'dev'); + const mixed = await poolStatus(envRepos); + expect(mixed.combined.current_branch).toBeNull(); + }); +}); diff --git a/electron/ipc/pool-git.ts b/electron/ipc/pool-git.ts new file mode 100644 index 00000000..b26796f4 --- /dev/null +++ b/electron/ipc/pool-git.ts @@ -0,0 +1,154 @@ +import path from 'path'; + +import { getAllFileDiffs, getChangedFiles, getFileDiff, getWorktreeStatus } from './git.js'; +import type { PoolRepo } from './pool.js'; +import type { ChangedFile, FileDiffResult, WorktreeStatus } from './shared-types.js'; + +/** + * The git surface of a pool task. + * + * A pool task spans every member repository of its leased environment, so + * "the changed files" is the union of several repositories rather than one + * repository's answer. Aggregation happens here rather than in the renderer + * because the existing panels are built around a single path, and the one + * thing that makes them work unchanged is that a member repository sits at a + * fixed subdirectory of the environment: prefix each path with its repository + * name and the result is a real path relative to the environment root, which + * is exactly what those panels already hold. + * + * The same prefix read backwards routes a per-file request to the repository + * that owns it. + */ + +/** Split an environment-relative path into its member repo and the rest. */ +export function splitRepoPath( + repos: PoolRepo[], + envRelativePath: string, +): { repo: PoolRepo; filePath: string } | null { + const normalized = envRelativePath.split(path.sep).join('/'); + for (const repo of repos) { + const prefix = `${repo.name}/`; + if (normalized.startsWith(prefix)) { + return { repo, filePath: normalized.slice(prefix.length) }; + } + } + return null; +} + +/** Re-root one repository's changed files at the environment. */ +export function prefixChangedFiles(repoName: string, files: ChangedFile[]): ChangedFile[] { + return files.map((file) => ({ + ...file, + path: `${repoName}/${file.path}`, + previous_path: file.previous_path ? `${repoName}/${file.previous_path}` : undefined, + })); +} + +/** + * Every member repository's changed files, as one list. + * + * A repository that cannot be read contributes nothing rather than failing the + * call: a task spanning five repositories should still show the four that + * answered, and the environment status is where an unreadable repository is + * meant to surface. + */ +export async function poolChangedFiles(repos: PoolRepo[]): Promise { + const perRepo = await Promise.all( + repos.map(async (repo) => { + try { + return prefixChangedFiles(repo.name, await getChangedFiles(repo.path, repo.baseBranch)); + } catch { + return []; + } + }), + ); + return perRepo.flat(); +} + +/** + * Re-root the paths in one repository's unified diff at the environment. + * + * Only the path-bearing headers are rewritten, and each is anchored to the + * start of its line, so a `+++ b/x` that appears inside an added line of the + * body — a diff of a diff, or of a patch fixture — is left alone. + */ +export function prefixDiffPaths(repoName: string, diff: string): string { + return diff + .split('\n') + .map((line) => { + if (line.startsWith('diff --git ')) { + return line.replace(/ a\/(.*) b\/(.*)$/, ` a/${repoName}/$1 b/${repoName}/$2`); + } + if (line.startsWith('--- a/')) return `--- a/${repoName}/${line.slice(6)}`; + if (line.startsWith('+++ b/')) return `+++ b/${repoName}/${line.slice(6)}`; + if (line.startsWith('rename from ')) return `rename from ${repoName}/${line.slice(12)}`; + if (line.startsWith('rename to ')) return `rename to ${repoName}/${line.slice(10)}`; + return line; + }) + .join('\n'); +} + +/** + * Every member repository's diff, concatenated, with its paths re-rooted at + * the environment so the viewer shows one change spanning several + * repositories rather than several unrelated ones. + */ +export async function poolAllDiffs(repos: PoolRepo[]): Promise { + const perRepo = await Promise.all( + repos.map(async (repo) => { + try { + const diff = await getAllFileDiffs(repo.path, repo.baseBranch); + return diff.trim() ? prefixDiffPaths(repo.name, diff) : ''; + } catch { + return ''; + } + }), + ); + return perRepo.filter(Boolean).join('\n'); +} + +/** The diff of one file, routed to the member repository that owns it. */ +export async function poolFileDiff( + repos: PoolRepo[], + envRelativePath: string, +): Promise { + const owner = splitRepoPath(repos, envRelativePath); + if (!owner) throw new Error(`No pool repository owns "${envRelativePath}"`); + return getFileDiff(owner.repo.path, owner.filePath, owner.repo.baseBranch); +} + +export interface PoolRepoStatus extends WorktreeStatus { + repo: string; +} + +/** + * Per-repository status plus the rolled-up answer the task header shows. + * + * The roll-up is a disjunction — any repository with changes makes the task + * changed — and the branch is reported only when every repository agrees on + * it, so a member left behind on its base branch is visible as "mixed" rather + * than hidden behind the majority. + */ +export async function poolStatus(repos: PoolRepo[]): Promise<{ + perRepo: PoolRepoStatus[]; + combined: WorktreeStatus; +}> { + const perRepo = await Promise.all( + repos.map(async (repo) => ({ + repo: repo.name, + ...(await getWorktreeStatus(repo.path, repo.baseBranch)), + })), + ); + + const branches = new Set(perRepo.map((status) => status.current_branch)); + const baseBranches = new Set(perRepo.map((status) => status.base_branch)); + return { + perRepo, + combined: { + has_committed_changes: perRepo.some((status) => status.has_committed_changes), + has_uncommitted_changes: perRepo.some((status) => status.has_uncommitted_changes), + current_branch: branches.size === 1 ? (perRepo[0]?.current_branch ?? null) : null, + base_branch: baseBranches.size === 1 ? (perRepo[0]?.base_branch ?? null) : null, + }, + }; +} diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 5b458b74..843253cc 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -78,6 +78,7 @@ import { } from './git.js'; import { createPoolTask, createTask, deletePoolTask, deleteTask } from './tasks.js'; import { envStatus, type PoolRepo } from './pool.js'; +import { poolAllDiffs, poolChangedFiles, poolFileDiff, poolStatus } from './pool-git.js'; import { listAgents } from './agents.js'; import { saveAppState, @@ -648,6 +649,19 @@ export function registerAllHandlers(win: BrowserWindow): void { }); }); + ipcMain.handle(IPC.PoolChangedFiles, (_e, args) => + poolChangedFiles(validatedPoolRepos(args.repos)), + ); + + ipcMain.handle(IPC.PoolAllDiffs, (_e, args) => poolAllDiffs(validatedPoolRepos(args.repos))); + + ipcMain.handle(IPC.PoolFileDiff, (_e, args) => { + assertString(args.filePath, 'filePath'); + return poolFileDiff(validatedPoolRepos(args.repos), args.filePath); + }); + + ipcMain.handle(IPC.PoolStatus, (_e, args) => poolStatus(validatedPoolRepos(args.repos))); + ipcMain.handle(IPC.DeleteTask, (_e, args) => { assertStringArray(args.agentIds, 'agentIds'); validatePath(args.projectRoot, 'projectRoot'); diff --git a/electron/preload.cjs b/electron/preload.cjs index dc7f9686..d41f6815 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -20,6 +20,10 @@ const ALLOWED_CHANNELS = new Set([ 'pool_env_status', 'pool_acquire_env', 'pool_release_env', + 'pool_changed_files', + 'pool_file_diff', + 'pool_all_diffs', + 'pool_status', 'get_changed_files', 'get_changed_files_from_branch', 'get_all_file_diffs', diff --git a/src/components/ChangedFilesList.tsx b/src/components/ChangedFilesList.tsx index c951db0e..8318ea1d 100644 --- a/src/components/ChangedFilesList.tsx +++ b/src/components/ChangedFilesList.tsx @@ -19,7 +19,7 @@ import { isCommitHashSelection, isUncommittedSelection, } from './CommitNavBar'; -import type { ChangedFile, CoverageFileSummary, CoverageSummary } from '../ipc/types'; +import type { ChangedFile, CoverageFileSummary, CoverageSummary, PoolTaskRepo } from '../ipc/types'; interface ChangedFilesListProps { worktreePath: string; @@ -36,6 +36,8 @@ interface ChangedFilesListProps { coverageReportPath?: string; /** Project root for branch-based fallback when worktree doesn't exist */ projectRoot?: string; + /** Member repos of a leased environment; set only for pool tasks. */ + poolRepos?: PoolTaskRepo[]; /** Branch name for branch-based fallback when worktree doesn't exist */ branchName?: string | null; /** Base branch for diff comparison (e.g. 'main', 'develop'). Undefined = auto-detect. */ @@ -620,6 +622,7 @@ export function ChangedFilesList(props: ChangedFilesListProps) { createEffect(() => { void props.worktreePath; void props.projectRoot; + void props.poolRepos; void props.branchName; void props.baseBranch; void props.selectedCommit; @@ -653,6 +656,38 @@ export function ChangedFilesList(props: ChangedFilesListProps) { if (inFlight) return; inFlight = true; try { + // A pool task's changes are spread over its environment's member + // repositories, so the list comes from the fan-out. Paths arrive + // prefixed with the repo name, which makes them relative to the + // environment root — the very path this panel already holds, so + // opening a file in an editor keeps working unchanged. + const poolRepos = props.poolRepos; + if (poolRepos && poolRepos.length > 0) { + try { + const result = await invoke(IPC.PoolChangedFiles, { repos: poolRepos }); + if (!cancelled) { + batch(() => { + setFiles((current) => (sameChangedFiles(current, result) ? current : result)); + setComparisonFiles((current) => + sameChangedFiles(current, result) ? current : result, + ); + setComparisonInventoryState('available'); + setCanOpenFilesInEditor(true); + }); + } + } catch { + if (!cancelled) { + batch(() => { + setFiles([]); + setComparisonFiles(null); + setComparisonInventoryState('failed'); + setCanOpenFilesInEditor(false); + }); + } + } + return; + } + // Single-commit mode: fetch files for that commit only if (singleCommitHash && path) { try { diff --git a/src/components/DiffViewerDialog.tsx b/src/components/DiffViewerDialog.tsx index b4ff4832..4fbfee64 100644 --- a/src/components/DiffViewerDialog.tsx +++ b/src/components/DiffViewerDialog.tsx @@ -36,6 +36,7 @@ import type { FileDiff } from '../lib/unified-diff-parser'; import type { ReviewAnnotation } from './review-types'; import type { CommitInfo } from '../ipc/types'; import type { GitIsolationMode } from '../store/types'; +import type { PoolTaskRepo } from '../ipc/types'; import { ChangeTour } from './ChangeTour'; import { createChangeTour, type ChangeTourController } from '../lib/create-change-tour'; @@ -66,6 +67,8 @@ interface DiffViewerDialogProps { onCommitNavigate?: (selection: CommitSelection) => void; /** Git isolation mode — CommitNavBar is only shown for worktree-isolated tasks */ gitIsolation?: GitIsolationMode; + /** Member repos of a leased environment; set only for pool tasks. */ + poolRepos?: PoolTaskRepo[]; /** Optional structured-finding source. Providers capture their own repository context. */ findingProvider?: QualityFindingProvider; } @@ -266,6 +269,7 @@ function DiffViewerContent(props: DiffViewerDialogProps & { tour: ChangeTourCont branchName, baseBranch, selectedCommit: selection, + poolRepos: props.poolRepos, }).then(({ rawDiff }) => rawDiff); diffPromise diff --git a/src/components/TaskChangedFilesSection.tsx b/src/components/TaskChangedFilesSection.tsx index fb97b3e9..487fa8fc 100644 --- a/src/components/TaskChangedFilesSection.tsx +++ b/src/components/TaskChangedFilesSection.tsx @@ -36,6 +36,8 @@ export function TaskChangedFilesSection(props: TaskChangedFilesSectionProps) { const coverageReportPath = () => getProject(props.task.projectId)?.coverageReportPath; const diffBaseBranch = () => getTaskDiffBaseBranch(props.task.gitIsolation, props.task.baseBranch); + // Commit navigation is per-repository and a pool task spans several, so it + // stays off there until the per-repo commit surface lands. const hasCommitNav = () => props.task.gitIsolation === 'worktree' || props.task.gitIsolation === 'direct'; // The tree button only earns its place once a branch has history to graph — a @@ -173,6 +175,7 @@ export function TaskChangedFilesSection(props: TaskChangedFilesSectionProps) { diff --git a/src/lib/load-task-diff.ts b/src/lib/load-task-diff.ts index 2cc25f05..a8173d68 100644 --- a/src/lib/load-task-diff.ts +++ b/src/lib/load-task-diff.ts @@ -7,6 +7,7 @@ import { type CommitSelection, } from '../components/CommitNavBar'; import type { GitIsolationMode } from '../store/types'; +import type { PoolTaskRepo } from '../ipc/types'; export interface TaskDiffInput { worktreePath: string; @@ -14,6 +15,8 @@ export interface TaskDiffInput { branchName?: string | null; baseBranch?: string; selectedCommit?: CommitSelection; + /** Member repos of a leased environment; set only for pool tasks. */ + poolRepos?: PoolTaskRepo[]; } /** Direct tasks work on their base branch, so naming that branch as the diff base @@ -31,6 +34,13 @@ export async function loadTaskDiff( input: TaskDiffInput, ): Promise<{ rawDiff: string; cwd: string }> { const { worktreePath, projectRoot, branchName, baseBranch, selectedCommit } = input; + // A pool task's change spans several repositories, so its diff is theirs + // concatenated with each path re-rooted at the environment. The cwd stays + // the environment root, which is what those prefixed paths are relative to. + if (input.poolRepos && input.poolRepos.length > 0) { + const rawDiff = await invoke(IPC.PoolAllDiffs, { repos: input.poolRepos }); + return { rawDiff, cwd: worktreePath }; + } if (isCommitHashSelection(selectedCommit) && worktreePath) { const rawDiff = await invoke(IPC.GetCommitDiffs, { worktreePath, From cd47a606acb24a096e75c4d7431ea80c1031616a Mon Sep 17 00:00:00 2001 From: Koen Lavrijssen Date: Thu, 17 Sep 2026 12:22:01 +0200 Subject: [PATCH 06/12] feat(pool): give each environment its own ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tasks in a pool run two copies of the same applications, so they cannot both serve on an application's default port — today the Winston environments all resolve the till to 3501, which is why only one of them can serve at a time. The port belongs to the environment rather than to the task: a lease comes and goes, but someone who learns that MRW2 serves the till on 3511 should keep being right. A task's terminals get PORT for the common single-application case and PARALLEL_CODE_PORT_ for the whole map, and a project that configured no ports is handed nothing rather than a misleading PORT. Co-Authored-By: Claude Opus 5 --- src/components/TaskAITerminal.tsx | 4 ++ src/components/TaskShellSection.tsx | 3 ++ src/lib/pool-ports.test.ts | 63 +++++++++++++++++++++++++++++ src/lib/pool-ports.ts | 62 ++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+) create mode 100644 src/lib/pool-ports.test.ts create mode 100644 src/lib/pool-ports.ts diff --git a/src/components/TaskAITerminal.tsx b/src/components/TaskAITerminal.tsx index 0f1901ff..152be421 100644 --- a/src/components/TaskAITerminal.tsx +++ b/src/components/TaskAITerminal.tsx @@ -18,7 +18,9 @@ import { closeAgentInTask, showNotification, toggleAITerminalLayout, + getProject, } from '../store/store'; +import { poolPortEnv } from '../lib/pool-ports'; import { markDirty } from '../lib/terminalFitManager'; import { isAgentAskingQuestion } from '../store/taskStatus'; import { warn as logWarn } from '../lib/log'; @@ -575,6 +577,7 @@ function AgentTerminalPane(props: { }) { onCleanup(() => props.onUnmount(props.agentId)); + const poolEnv = () => poolPortEnv(getProject(props.task.projectId)?.pool, props.task); const dockerOverlayLabel = () => getTaskDockerOverlayLabel(props.task.dockerSource); const agent = () => store.agents[props.agentId]; @@ -687,6 +690,7 @@ function AgentTerminalPane(props: { command={a().def.command} args={buildTaskAgentArgs(a().def, props.task, a().resumed)} cwd={props.task.worktreePath} + env={poolEnv()} envFile={store.agentEnvFiles[a().def.id]} stepsEnabled={props.task.stepsEnabled} dockerMode={ diff --git a/src/components/TaskShellSection.tsx b/src/components/TaskShellSection.tsx index 1001a22a..db2f4353 100644 --- a/src/components/TaskShellSection.tsx +++ b/src/components/TaskShellSection.tsx @@ -17,6 +17,7 @@ import { isPanelFocused, isPanelFocusedPrefix, } from '../store/store'; +import { poolPortEnv } from '../lib/pool-ports'; import { TerminalView } from './TerminalView'; import { CloseIcon } from './icons'; import { theme } from '../lib/theme'; @@ -51,6 +52,7 @@ interface TaskShellSectionProps { } export function TaskShellSection(props: TaskShellSectionProps) { + const poolEnv = () => poolPortEnv(getProject(props.task.projectId)?.pool, props.task); const [shellToolbarIdx, setShellToolbarIdx] = createSignal(0); const [shellToolbarFocused, setShellToolbarFocused] = createSignal(false); const [shellExits, setShellExits] = createStore< @@ -300,6 +302,7 @@ export function TaskShellSection(props: TaskShellSectionProps) { command={''} args={['-l']} cwd={props.task.worktreePath} + env={poolEnv()} dockerMode={props.task.dockerMode} dockerImage={props.task.dockerImage} initialCommand={initialCommand} diff --git a/src/lib/pool-ports.test.ts b/src/lib/pool-ports.test.ts new file mode 100644 index 00000000..7763985c --- /dev/null +++ b/src/lib/pool-ports.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { memberPort, poolPortEnv, portVarName } from './pool-ports'; +import type { PoolConfig, Task } from '../store/types'; + +const pool: PoolConfig = { + envPaths: ['/projects/MRW1', '/projects/MRW2'], + portBase: { '/projects/MRW1': 3500, '/projects/MRW2': 3510 }, + portOffsets: { waiter: 1, backoffice: 2 }, +}; + +const task = { + gitIsolation: 'pool', + envPath: '/projects/MRW2', + repos: [ + { name: 'waiter', path: '/projects/MRW2/waiter', branchName: 'task/a', baseBranch: 'dev' }, + { + name: 'backoffice', + path: '/projects/MRW2/backoffice', + branchName: 'task/a', + baseBranch: 'dev', + }, + { name: 'shared', path: '/projects/MRW2/shared', branchName: 'task/a', baseBranch: 'dev' }, + ], +} satisfies Pick; + +describe('memberPort', () => { + it('adds the member offset to the environment base', () => { + expect(memberPort(pool, '/projects/MRW1', 'waiter')).toBe(3501); + expect(memberPort(pool, '/projects/MRW2', 'waiter')).toBe(3511); + }); + + it('is undefined when either half is unconfigured', () => { + expect(memberPort(pool, '/projects/MRW3', 'waiter')).toBeUndefined(); + expect(memberPort(pool, '/projects/MRW1', 'shared')).toBeUndefined(); + }); +}); + +describe('portVarName', () => { + it('folds a repo name into a legal shell identifier', () => { + expect(portVarName('waiter-app')).toBe('PARALLEL_CODE_PORT_WAITER_APP'); + expect(portVarName('report.service')).toBe('PARALLEL_CODE_PORT_REPORT_SERVICE'); + }); +}); + +describe('poolPortEnv', () => { + it('exports a variable per configured repo and points PORT at the lowest', () => { + expect(poolPortEnv(pool, task)).toEqual({ + PARALLEL_CODE_PORT_WAITER: '3511', + PARALLEL_CODE_PORT_BACKOFFICE: '3512', + PORT: '3511', + PARALLEL_CODE_ENV_PATH: '/projects/MRW2', + }); + }); + + it('exports nothing when the project configured no ports', () => { + expect(poolPortEnv({ envPaths: ['/projects/MRW1'] }, task)).toEqual({}); + }); + + it('exports nothing for a task that is not a pool task', () => { + expect(poolPortEnv(pool, { ...task, gitIsolation: 'worktree' })).toEqual({}); + }); +}); diff --git a/src/lib/pool-ports.ts b/src/lib/pool-ports.ts new file mode 100644 index 00000000..20194c4d --- /dev/null +++ b/src/lib/pool-ports.ts @@ -0,0 +1,62 @@ +import type { PoolConfig, Task } from '../store/types'; + +/** + * Ports for a leased environment. + * + * Two tasks in a pool run two copies of the same applications, so they cannot + * both serve on an application's default port. The port belongs to the + * environment rather than to the task: a lease comes and goes, but a person + * who learns that MRW2 serves the till on 3511 should keep being right. + * + * `PORT` is what a member repository's own start script reads first, so one + * variable per task shell covers the common case of running one application. + * `PARALLEL_CODE_PORT_` carries the whole map for anything that starts + * more than one, with the repository name upper-cased and non-alphanumerics + * folded to `_` so it is a legal shell identifier. + */ + +/** A member repo's port: the environment's base plus that repo's offset. */ +export function memberPort( + pool: PoolConfig, + envPath: string, + memberName: string, +): number | undefined { + const base = pool.portBase?.[envPath]; + const offset = pool.portOffsets?.[memberName]; + if (base === undefined || offset === undefined) return undefined; + return base + offset; +} + +/** `waiter-app` → `PARALLEL_CODE_PORT_WAITER_APP`. */ +export function portVarName(memberName: string): string { + return `PARALLEL_CODE_PORT_${memberName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`; +} + +/** + * Environment variables for a pool task's terminals. + * + * Returns nothing at all when the project configured no ports, so a workspace + * that does not serve anything is not handed a misleading `PORT`. + */ +export function poolPortEnv( + pool: PoolConfig | undefined, + task: Pick, +): Record { + if (!pool || task.gitIsolation !== 'pool' || !task.envPath || !task.repos) return {}; + const env: Record = {}; + for (const repo of task.repos) { + const port = memberPort(pool, task.envPath, repo.name); + if (port !== undefined) env[portVarName(repo.name)] = String(port); + } + if (Object.keys(env).length === 0) return {}; + + // `PORT` is the one an unconfigured start script picks up, so it names the + // lowest-numbered member: whichever application the environment's own port + // layout puts first, rather than whichever repo happened to be listed first. + const lowest = Object.values(env) + .map(Number) + .sort((a, b) => a - b)[0]; + env.PORT = String(lowest); + env.PARALLEL_CODE_ENV_PATH = task.envPath; + return env; +} From 6e71969455d6fdb89608e51689cedf8c46682fb1 Mon Sep 17 00:00:00 2001 From: Koen Lavrijssen Date: Thu, 17 Sep 2026 12:27:09 +0200 Subject: [PATCH 07/12] feat(pool): configure a pool and pick it when starting a task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pooled environments are typed once and changed rarely, so the project dialog edits them as plain lists rather than as repeating row widgets — a textarea someone can paste five paths into beats five pickers. Clearing the paths is what turns a pooled workspace back into an ordinary project, so there is no separate "is a pool" switch to leave inconsistent. The new-task panel offers the mode only where a pool exists, and says how many environments are free before the create button rather than as the error the attempt would otherwise be. The close dialog says what handing an environment back actually does. Co-Authored-By: Claude Opus 5 --- src/components/CloseTaskDialog.tsx | 9 ++- src/components/EditProjectDialog.tsx | 76 ++++++++++++++++++++ src/components/NewTaskPanel.tsx | 36 ++++++++++ src/lib/pool-config.test.ts | 102 +++++++++++++++++++++++++++ src/lib/pool-config.ts | 95 +++++++++++++++++++++++++ src/store/projects.ts | 1 + 6 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 src/lib/pool-config.test.ts create mode 100644 src/lib/pool-config.ts diff --git a/src/components/CloseTaskDialog.tsx b/src/components/CloseTaskDialog.tsx index 342a103b..c7d84a55 100644 --- a/src/components/CloseTaskDialog.tsx +++ b/src/components/CloseTaskDialog.tsx @@ -55,12 +55,19 @@ export function CloseTaskDialog(props: CloseTaskDialogProps) { )} - +

This will stop all running agents and shells for this task. No git operations will be performed.

+ +

+ This will stop all running agents and shells, then hand the environment back to the + pool: every repository returns to its base branch and the lease is released. A task + branch that still holds commits is kept unless the project deletes branches on close. +

+
([]); const [newCommand, setNewCommand] = createSignal(''); + const [poolForm, setPoolForm] = createStore({ + envPaths: '', + members: '', + portBase: '', + portOffsets: '', + }); const [showImportDialog, setShowImportDialog] = createSignal(false); const [confirmRemove, setConfirmRemove] = createSignal(false); /** Branch and worktree settings only mean something where tasks run. */ @@ -51,6 +59,7 @@ export function EditProjectDialog(props: EditProjectDialogProps) { setCoverageReportPath(p.coverageReportPath ?? ''); setVerifyCommand(p.verifyCommand ?? ''); setBookmarks(p.terminalBookmarks ? [...p.terminalBookmarks] : []); + setPoolForm(poolToForm(p.pool)); setNewCommand(''); setConfirmRemove(false); requestAnimationFrame(() => nameRef?.focus()); @@ -87,6 +96,7 @@ export function EditProjectDialog(props: EditProjectDialogProps) { coverageReportPath: coverageReportPath().trim() || undefined, verifyCommand: verifyCommand().trim() || undefined, terminalBookmarks: bookmarks(), + pool: poolFromForm(poolForm), }); props.onClose(); } @@ -361,12 +371,78 @@ export function EditProjectDialog(props: EditProjectDialogProps) { options={[ { value: 'worktree', label: 'Worktree' }, { value: 'direct', label: 'Current Branch' }, + ...(poolForm.envPaths.trim() ? [{ value: 'pool', label: 'Pooled Env' }] : []), ]} value={defaultGitIsolation()} onChange={setDefaultGitIsolation} /> + {/* Pooled environments */} +
+ +