dispatch: two isolation modes - main vs worktree+branch - invert the surface-to-isolation mapping #145

Closed
opened 2026-05-28 10:09:26 +00:00 by coilysiren · 1 comment
Owner

dispatch: two isolation modes (main / worktree+branch), invert the surface->isolation mapping

Builds on

coilysiren/coily#270 (surface split), coilysiren/coily#144 (consult as 4th positional surface). This issue is orthogonal to #144 - it changes isolation, not the surface set - but the two interact (see "Interaction with #144").

Today

Only the interactive surface uses a git worktree (cli-guard dispatch/interactive.go, one worktree per issue at <WorktreeRoot>/<repo>/issue-<N> on branch dispatch/issue-<N>, reaped on merge). The detached surfaces headless and cascade share the canonical checkout (runDetached -> repoPath, dispatch/dispatch.go).

Problem

That mapping is backwards on the axis that actually matters, which is concurrency, not churn velocity. Two claude -p processes sharing one working tree and index corrupt each other: index locks, half-applied edits, branch thrash. The surfaces most likely to run multiple concurrent workers in the same repo are exactly the detached ones:

  • cascade fans out into multiple concurrent sub-workers, and a migration lands several in one repo at once. Worst case for a shared checkout, and today it runs them all in the bare tree. Latent correctness bug, not just churn.
  • headless can be fanned out in parallel ("open one for me" x5); today they race in repoPath.
  • interactive / consult are paced by operator attention - effectively serial, and a double-dispatch is human-noticeable.

Model

The isolation design space is a 2x2 (branch axis x worktree axis), but only two cells are coherent:

  • main - work directly on the default branch in the canonical checkout. No worktree, no branch ceremony.
  • worktree + branch - isolated working dir on its own non-default branch. (git makes you name a branch when creating a worktree, so the two are one atomic thing.)

The other two cells are dead: non-default-branch-without-worktree pays branch ceremony for no isolation benefit, and worktree-on-the-default-branch is something git forbids (a branch cannot be checked out in two worktrees at once).

Change

Invert the surface -> isolation mapping along the fast/slow (concurrency) line:

  • interactive, consult (supervised, serial, trusted) -> main. Drop the worktree and the auto-merge dance these do today. Supervised work is what-you-see-is-what-ships.
  • headless, cascade (detached, parallel, lights-out) -> worktree + branch. Each worker gets its own isolated dir, so concurrent workers in one repo stop racing.

Landing policy is a separate knob

Auto-merge-when-green vs hold-as-a-PR is NOT a third isolation mode. Once a run is on its own branch, what happens to that branch (fast-forward to main, or sit as a PR for review) is a per-surface policy decision that rides on top of "worktree + branch." Keep it independent: e.g. cascade leaves can auto-land their slice while headless can hold a PR, without that distinction leaking back into the isolation question. Settle the per-surface landing policy as part of this work but do not model it as a worktree mode.

Open questions

  • Abandon-safety for main-mode surfaces: an abandoned half-done supervised session leaves main dirty. Acceptable (operator is watching), or does main-mode still want an in-place branch? Lean: acceptable, operator is right there.
  • Per-surface landing policy table: which detached surfaces auto-merge vs open a PR.
  • Concurrency floor for main-mode: detect and refuse a second interactive/consult into a repo that already has a live dispatch session, since main-mode has no isolation to fall back on.

Implementation surface

  • cli-guard dispatch/: move worktree resolution off interactive and onto the detached path; runDetached (dispatch.go) gains worktree placement, interactive.go loses it (or routes through main). Reaping (reap.go) follows the detached surfaces. Tests across interactive_test.go, cascade_test.go.
  • coily: bump cli-guard dep.
  • coily-dispatch SKILL.md: rewrite the worktree description - worktrees now belong to headless/cascade, not interactive.

Acceptance

  • coily dispatch headless / cascade run in a per-issue worktree+branch; two concurrent headless dispatches into one repo do not share a working tree.
  • coily dispatch interactive / consult run directly in the canonical checkout on the default branch, no worktree created.
  • Landing policy per surface is documented and implemented.
# dispatch: two isolation modes (main / worktree+branch), invert the surface->isolation mapping ## Builds on coilysiren/coily#270 (surface split), coilysiren/coily#144 (consult as 4th positional surface). This issue is orthogonal to #144 - it changes *isolation*, not the surface set - but the two interact (see "Interaction with #144"). ## Today Only the `interactive` surface uses a git worktree (`cli-guard dispatch/interactive.go`, one worktree per issue at `<WorktreeRoot>/<repo>/issue-<N>` on branch `dispatch/issue-<N>`, reaped on merge). The detached surfaces `headless` and `cascade` share the canonical checkout (`runDetached` -> `repoPath`, `dispatch/dispatch.go`). ## Problem That mapping is backwards on the axis that actually matters, which is **concurrency, not churn velocity**. Two `claude -p` processes sharing one working tree and index corrupt each other: index locks, half-applied edits, branch thrash. The surfaces most likely to run multiple concurrent workers in the *same* repo are exactly the detached ones: * `cascade` fans out into multiple concurrent sub-workers, and a migration lands several in one repo at once. Worst case for a shared checkout, and today it runs them all in the bare tree. Latent correctness bug, not just churn. * `headless` can be fanned out in parallel ("open one for me" x5); today they race in `repoPath`. * `interactive` / `consult` are paced by operator attention - effectively serial, and a double-dispatch is human-noticeable. ## Model The isolation design space is a 2x2 (branch axis x worktree axis), but only two cells are coherent: * **main** - work directly on the default branch in the canonical checkout. No worktree, no branch ceremony. * **worktree + branch** - isolated working dir on its own non-default branch. (git makes you name a branch when creating a worktree, so the two are one atomic thing.) The other two cells are dead: non-default-branch-without-worktree pays branch ceremony for no isolation benefit, and worktree-on-the-default-branch is something git forbids (a branch cannot be checked out in two worktrees at once). ## Change Invert the surface -> isolation mapping along the fast/slow (concurrency) line: * `interactive`, `consult` (supervised, serial, trusted) -> **main**. Drop the worktree and the auto-merge dance these do today. Supervised work is what-you-see-is-what-ships. * `headless`, `cascade` (detached, parallel, lights-out) -> **worktree + branch**. Each worker gets its own isolated dir, so concurrent workers in one repo stop racing. ## Landing policy is a separate knob Auto-merge-when-green vs hold-as-a-PR is NOT a third isolation mode. Once a run is on its own branch, what happens to that branch (fast-forward to main, or sit as a PR for review) is a per-surface policy decision that rides on top of "worktree + branch." Keep it independent: e.g. cascade leaves can auto-land their slice while headless can hold a PR, without that distinction leaking back into the isolation question. Settle the per-surface landing policy as part of this work but do not model it as a worktree mode. ## Open questions * Abandon-safety for `main`-mode surfaces: an abandoned half-done supervised session leaves `main` dirty. Acceptable (operator is watching), or does main-mode still want an in-place branch? Lean: acceptable, operator is right there. * Per-surface landing policy table: which detached surfaces auto-merge vs open a PR. * Concurrency floor for main-mode: detect and refuse a second `interactive`/`consult` into a repo that already has a live dispatch session, since main-mode has no isolation to fall back on. ## Implementation surface * **cli-guard** `dispatch/`: move worktree resolution off `interactive` and onto the detached path; `runDetached` (`dispatch.go`) gains worktree placement, `interactive.go` loses it (or routes through `main`). Reaping (`reap.go`) follows the detached surfaces. Tests across `interactive_test.go`, `cascade_test.go`. * **coily**: bump cli-guard dep. * **coily-dispatch SKILL.md**: rewrite the worktree description - worktrees now belong to headless/cascade, not interactive. ## Acceptance * `coily dispatch headless` / `cascade` run in a per-issue worktree+branch; two concurrent headless dispatches into one repo do not share a working tree. * `coily dispatch interactive` / `consult` run directly in the canonical checkout on the default branch, no worktree created. * Landing policy per surface is documented and implemented.
coilysiren changed title from dispatch: two isolation modes main / worktree+branch , invert surface- isolation mapping to dispatch: two isolation modes - main vs worktree+branch - invert the surface-to-isolation mapping 2026-05-28 10:09:40 +00:00
Author
Owner

Landed on GitHub main as f6e9ba5 (consumer bump) + cli-guard 02f4153 / cli-guard#35 (the dispatch logic).

The surface-to-isolation mapping is now inverted:

  • headless, cascade (detached, parallel) run in a per-issue git worktree+branch and land by merging into main when green - concurrent workers in one repo no longer share a working tree/index.
  • interactive, consult (supervised, serial) run directly on the default branch in the canonical checkout - no worktree, no auto-merge dance.

Landing policy: detached workers merge their branch into main when green; supervised work commits to main directly. --no-worktree is dropped (main mode is the only interactive placement).

All acceptance criteria met; cli-guard + coily tests, vet, lint, and pre-commit hooks green.

Closing manually rather than via the commit trailer: the coily GitHub and Forgejo remotes have forked (Forgejo has ~8 commits of work GitHub lacks - fj alias, forgejo actions-log reader, formula v2.45/2.46 bumps, lockdown syncs - and GitHub has the consult surface + this work), so the closes #145 trailer pushed to GitHub never reached this Forgejo issue. Mirror divergence filed as a follow-up; it needs human reconciliation and was out of scope to fix from a dispatch worker without force-pushing.

Landed on GitHub `main` as `f6e9ba5` (consumer bump) + cli-guard `02f4153` / cli-guard#35 (the dispatch logic). The surface-to-isolation mapping is now inverted: - `headless`, `cascade` (detached, parallel) run in a per-issue git worktree+branch and land by merging into `main` when green - concurrent workers in one repo no longer share a working tree/index. - `interactive`, `consult` (supervised, serial) run directly on the default branch in the canonical checkout - no worktree, no auto-merge dance. Landing policy: detached workers merge their branch into main when green; supervised work commits to main directly. `--no-worktree` is dropped (main mode is the only interactive placement). All acceptance criteria met; cli-guard + coily tests, vet, lint, and pre-commit hooks green. Closing manually rather than via the commit trailer: the coily GitHub and Forgejo remotes have forked (Forgejo has ~8 commits of work GitHub lacks - fj alias, forgejo actions-log reader, formula v2.45/2.46 bumps, lockdown syncs - and GitHub has the consult surface + this work), so the `closes #145` trailer pushed to GitHub never reached this Forgejo issue. Mirror divergence filed as a follow-up; it needs human reconciliation and was out of scope to fix from a dispatch worker without force-pushing.
Sign in to join this conversation.
No labels
P0
P1
P2
P3
P4
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
coilyco-bridge/coily#145
No description provided.