# Dispatch isolation gate (ADR-1239 / #2584)

The single source of truth for **how a dispatch site decides whether it may run an agent
isolated**. Every workflow that spawns an executor-like subagent reads this file and follows
it, rather than restating the rule inline (#2652).

`ISOLATION` — not `RUNTIME` — is the decision variable. **Never branch a dispatch site on a
runtime id.** Isolation is a negotiated capability declared per host; a runtime-name test
silently diverges the moment a new host declares support, which is exactly how #2652 happened:
`quick.md` and `diagnose-issues.md` kept a `RUNTIME != "claude"` gate after #2584 migrated the
phase scheduler, so Codex — which declares `orchestrator-worktree` — was refused isolation it
had in fact negotiated.

## Resolve ISOLATION

Run this in the dispatch site's config-gate step, right after `RUNTIME` / `USE_WORKTREES` are
read. It requires `gsd_run` to be defined (the standard shim preamble).

```bash
# Isolation is a NEGOTIATED CAPABILITY, not a runtime id (#2584). Fail-closed to none.
# #3045: this call PERSISTS the resolution to the run-scoped sentinel the isolation
# guard hooks read, as an unconditional side effect of resolving it.
# Keep the resolver's own failure DISTINGUISHABLE from a genuine `none`. Both
# fail closed — that policy is right — but only one of them may claim the host
# declared no primitive. A shim-resolution failure, a non-zero exit or empty
# stdout is NOT a capability verdict, and reporting it as one tells a Claude
# Code user their runtime "declares no executor-isolation primitive", which is
# false (#2652 review).
_ISOLATION_RAW=$(gsd_run query dispatch-isolation --raw 2>/dev/null)
_ISOLATION_RC=$?
if [ $_ISOLATION_RC -ne 0 ] || [ -z "$_ISOLATION_RAW" ]; then
  ISOLATION=none
  ISOLATION_RESOLVED=false      # fail closed, but we did NOT learn a verdict
else
  ISOLATION="$_ISOLATION_RAW"
  ISOLATION_RESOLVED=true
fi
case "$ISOLATION" in
  harness-worktree|orchestrator-worktree|none) ;;
  *) ISOLATION=none; ISOLATION_RESOLVED=false ;;   # out of vocabulary is not a verdict either
esac

# Project-level opt-out wins on every host; a host with no primitive fails closed.
[ "$USE_WORKTREES" = "false" ] && ISOLATION=none
if [ "$ISOLATION" = "none" ] && [ "$USE_WORKTREES" != "false" ]; then
  if [ "$ISOLATION_RESOLVED" = "true" ]; then
    echo "FATAL: runtime '$RUNTIME' declares no executor-isolation primitive (dispatch.isolation=none) — agents would run unisolated against the main checkout. Set workflow.use_worktrees=false." >&2
  else
    echo "FATAL: could not resolve this runtime's executor-isolation capability — 'gsd_run query dispatch-isolation' failed or returned nothing, so GSD cannot tell whether isolation is available. Refusing to dispatch rather than guess (a guard that cannot verify must not answer 'safe'). Re-run once the gsd-tools shim resolves, or set workflow.use_worktrees=false to run sequentially on purpose." >&2
  fi
  exit 1
fi

# Re-record: the opt-out above is decided in shell, where the resolver cannot see
# it, so the sentinel still asserts the naturally-resolved mode. See "Re-record
# after every degrade" below — this is the first of the mandatory calls.
gsd_run query dispatch-isolation --raw --force-isolation "$ISOLATION" >/dev/null 2>&1 || true
```

| `ISOLATION` | Meaning | What a dispatch site does |
|---|---|---|
| `harness-worktree` | The host's own harness creates and binds a worktree per agent. | Pass the host's declared `harnessFlag` on the dispatch. GSD runs no git. |
| `orchestrator-worktree` | No harness primitive, but a headless exec accepting a working directory. | GSD creates the worktree and process-spawns the agent into it. GSD performs every git operation. |
| `none` | No isolation primitive. | Run inline, sequentially. |

Fail-closed is the invariant: an undeclared, unknown, or unresolvable declaration degrades to
`none`, never to an unsafe parallel path.

## Resolve the harness flag (harness-worktree only)

The flag is descriptor data — never hardcode `isolation="worktree"`, which is Claude Code's
literal and wrong on any other harness-worktree host (Cursor declares the same capability).

```bash
HARNESS_FLAG=""
if [ "$ISOLATION" = "harness-worktree" ]; then
  HARNESS_FLAG=$(gsd_run query dispatch-isolation --json 2>/dev/null \
    | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);process.stdout.write(j&&j.harnessFlag?j.harnessFlag:"")}catch{process.stdout.write("")}})')
  [ -n "$HARNESS_FLAG" ] || { echo "FATAL: runtime declares dispatch.isolation=harness-worktree but no harnessIsolationFlag — refusing to dispatch an agent that would believe it is isolated." >&2; exit 1; }
fi
```

Substitute `$HARNESS_FLAG` for the `{harnessFlag}` placeholder in the dispatch call. On Claude
Code it resolves to literally `isolation="worktree"`.

## Single-agent dispatch sites

`quick.md` and `diagnose-issues.md` spawn through the host's own subagent tool, which can only
express the `harness-worktree` model. On an `orchestrator-worktree` host they must degrade to
sequential rather than pass a harness flag the host will ignore — dispatching unisolated while
reporting isolation is the one outcome worse than running sequentially:

```bash
if [ "$ISOLATION" = "orchestrator-worktree" ]; then
  echo "⚠ Runtime '$RUNTIME' declares dispatch.isolation=orchestrator-worktree, which requires GSD-driven process spawning. This dispatch site uses the host subagent tool, so it is running sequentially on the main working tree instead. Parallel wave execution (/gsd:execute-phase) is unaffected." >&2
  ISOLATION=none
  USE_WORKTREES=false
  gsd_run query dispatch-isolation --raw --force-isolation none >/dev/null 2>&1 || true
fi
```

## Re-record after every degrade

**Any block that changes `$ISOLATION` after the resolve above MUST re-record it before
dispatch.** This is not optional bookkeeping — it is what keeps the dispatch legal.

`query dispatch-isolation` writes the mode it resolved into a run-scoped sentinel
(`.gsd/dispatch-isolation-sentinel.json`) as an unconditional side effect, and the shipped
`PreToolUse` isolation guards read that sentinel at the instant of the dispatch call
(`hooks/gsd-agent-isolation-guard.js`, `hooks/gsd-cursor-subagent-start.js`, shared reader
`hooks/lib/isolation-sentinel.js`, #3045). Every degrade in this file is decided **in shell**,
where the resolver cannot see it. Degrade without re-recording and the sentinel still asserts
`harness-worktree` while the dispatch correctly omits the harness flag — the guard reads that
as a dropped isolation flag and **denies the dispatch with exit 2**. The task does not run
unisolated; it does not run at all.

```bash
gsd_run query dispatch-isolation --raw --force-isolation "$ISOLATION" >/dev/null 2>&1 || true
```

`--force-isolation` pushes the final, shell-computed value through the same single write path
(`none` also clears the stored `harnessFlag`, since none applies to sequential dispatch). It is
idempotent and last-write-wins, so a site that degrades more than once simply calls it again —
record immediately before dispatch so the sentinel is always fresh. Best-effort by design: a
write failure must never fail the dispatch, since the guards' sentinel-absent fallback is safe,
just less precise.

Wave sites re-record per plan rather than per phase — see
`gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md`.

Wave fan-out sites (`execute-phase`) implement both models — see
`gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md`.

## Base divergence

Any site that ends up with `ISOLATION != none` must also run the `worktree.base-check`
auto-degrade before dispatch (#683, #1369, #1941), or the agent's `worktree_branch_check` guard
halts on a stale fork base. `quick.md` and `execute-phase` do this today.
