Skip to content

feat(pool): pooled workspaces — lease a ready environment instead of building a worktree - #279

Draft
KoenLav wants to merge 12 commits into
johannesjo:mainfrom
KoenLav:feat/pooled-workspaces
Draft

KoenLav wants to merge 12 commits into
johannesjo:mainfrom
KoenLav:feat/pooled-workspaces

Conversation

@KoenLav

@KoenLav KoenLav commented Sep 17, 2026

Copy link
Copy Markdown

What this adds

A fourth git isolation mode, pool. Instead of building a worktree per task, a task leases one of a fixed set of ready environments, branches every repository inside it, and hands it back on close.

It exists for projects that are not one git repository but a directory holding several side by side, where getting one ready is expensive enough that copying it per task is not worth it — installed dependencies, initialised submodules, warm build caches. git worktree cannot express that shape: a worktree of the container is an empty container, git worktree add does not populate submodules, and a change spanning three repositories wants the same branch name in each while a Task holds one.

Written generically: a pooled workspace is any directory of interchangeable environments, each containing child git repositories, discovered from a manifest (name<TAB>url[<TAB>branch]) or by scanning one level down.

How it stays small

task.worktreePath becomes the leased environment root, so shells, the canvas, the browser preview and the verify command are unchanged — a pool task is direct mode generalised. Only the git surface had to learn that a task spans several repositories, and that is additive: every function in electron/ipc/git.ts is keyed on a path, so the fan-out is a loop plus aggregation.

Changed files and diffs are the union of the member repositories with each path re-rooted under its repository name, which makes it a real path relative to the environment root — the path those panels already hold — so opening a file in an editor and routing a per-file diff back to its owner both fall out of it.

Shared libraries checked out twice

Some workspaces keep a shared library both as a sibling repository and as a submodule inside each application that uses it. The application builds against its copy, so that is where a change must be made, but the copies are pinned independently and drift. On lease every copy is put on the canonical repository's base branch (fetched from the checkout on disk, no network), so a later patch applies by construction; Sync shared then carries the copy's changes into the canonical checkout, uncommitted; and push refuses while a copy still holds changes the canonical checkout has not taken.

Documentation

docs/pooled-workspaces.md covers configuration, the task lifecycle, per-environment ports, the shared-library flow and the limits.

Testing

npm run check is green. 54 new tests across five modules; the lease, readiness, release, fan-out and shared-library suites run against real git repositories (including real submodules with a deliberate pin gap) in temporary directories.

Note for the maintainer: four tests fail on main at 4bb51fa before this branch — one in src/lib/reduced-motion-styles.test.ts and three in src/components/DiffViewerDialog.client.test.tsx. I left them alone; they are unrelated to this change.

Known limits

  • Concurrency is the pool size; environments are not built on demand.
  • Commit navigation is off for pool tasks, since it is per-repository.
  • A submodule the environment keeps no canonical copy of still needs its own branch and PR by hand, and re-pinning an application after a shared branch merges is manual.

🤖 Generated with Claude Code

KoenLav and others added 12 commits September 17, 2026 12:05
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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_<REPO> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
A pool task's branches live in several member repositories, so merging and
pushing fan out over them. Both skip a repository with no commits, so the
branches that reach the remote are the ones the change actually touched, and
both run sequentially: a conflict in one repository means the change is not
integrated, and carrying on would leave half of it on base.

Push output is labelled per repository by the backend rather than interleaved,
because several `git push --progress` streams at once are unreadable and the
platform wants a pull request per repository afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Why a worktree is the wrong shape for a directory of git repositories, how a
lease is taken and given back, how a change is shown across repositories, how
ports are laid out per environment, and what the mode does not do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pool task's environment root holds no code of its own — its member
repositories do — so asking git about that one directory reported every pool
task as unchanged forever. The task badge now rolls the members up: any
repository with changes makes the task changed, and the branch is reported
only where every repository agrees on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things stopped the mode fitting a real multi-repository workspace.

A repository the manifest declares but nobody cloned was a blocker, so an
environment was refused over a stale line in a file. The Winston dev-env's
repos.tsv names three repositories that are not cloned and omits three that
are — an environment like that would never have been leased. It is now
reported and the environment is still usable.

The workspace's own repository was not a member either, so a change touching
the manifest, a shared script or the instructions at the root went untracked.
It participates as `.`, and because its files already sit at the environment
root, it is the one member whose paths are not re-rooted — which also makes it
the fallback when routing a path back to the repository that owns it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A shared library checked out both as a sibling repository and as a submodule
inside each application that uses it has to be edited in the submodule copy for
the running application to pick the change up, but committed in the sibling
checkout, which is the one with a branch. The copies are pinned independently —
the Winston applications pin four different commits of `shared`, none of them
the sibling checkout's tip — so carrying a diff between them is a patch across
a pin gap that can conflict, or apply cleanly against stale code.

Aligning first removes the gap: every copy is put on its canonical
repository's base branch when the environment is leased, fetched from the
checkout on disk rather than over the network, so a later patch applies by
construction and what is committed is what was actually run.

"Sync shared" then carries each copy's changes across — commits made inside it
as well as uncommitted work — and leaves the result uncommitted, because the
message belongs to the author. Pushing refuses while a copy still holds
changes the canonical checkout has not taken, so an application branch is never
sent without the shared change it was written against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant