From cd2ba4d926a743c9d735c7ff1a4d3f78d922d754 Mon Sep 17 00:00:00 2001 From: Mikhail Petrov Date: Mon, 29 Jun 2026 22:02:53 +0300 Subject: [PATCH] =?UTF-8?q?feat(map-efficient):=20#303=20Slice=206=20?= =?UTF-8?q?=E2=80=94=20flip=20parallel-execution=20defaults=20ON=20+=20kil?= =?UTF-8?q?l-switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips the two default-OFF gates so concurrent wave execution is on by default: - worktree.isolation: off -> auto - execution.concurrent_dispatch: false -> true (wave_mode already auto). 'auto' degrades to sequential when not-a-git-repo / dirty / worktree-unsupported. Off-ramps (the user-requested disable option): - MAP_EFFICIENT_SEQUENTIAL_ONLY=1 — global kill-switch, forces the legacy sequential walker (no wave-loop, no worktrees, no concurrent dispatch), checked FIRST in select_execution_strategy + compute_dispatch_gate before any config read; byte-identical to pre-5a legacy. - per-repo config: worktree.isolation: off / execution.concurrent_dispatch: false. The HC-1 behavior-neutrality / byte-identical-legacy invariant moves from 'default config' to 'kill-switch engaged' — the 5b leak guards now protect the off-ramp. Fixed _worktree_isolation_mode falsy-set so an explicit false/off/0/no still maps to off after the default became auto. Migration: the flip changes behavior only where the key is ABSENT; explicit opt-outs are preserved. make check 3224 passed; kill-switch + opt-out + default-on all proven by tests. --- .map/scripts/map_orchestrator.py | 62 +++- .map/scripts/map_step_runner.py | 87 +++-- CHANGELOG.md | 3 + docs/ARCHITECTURE.md | 49 ++- docs/USAGE.md | 63 ++-- src/mapify_cli/config/project_config.py | 45 ++- .../templates/map/scripts/map_orchestrator.py | 62 +++- .../templates/map/scripts/map_step_runner.py | 87 +++-- .../map/scripts/map_orchestrator.py.jinja | 62 +++- .../map/scripts/map_step_runner.py.jinja | 87 +++-- tests/test_map_orchestrator.py | 324 ++++++++++++------ tests/test_map_step_runner.py | 73 ++-- tests/test_project_config.py | 62 +++- tests/test_slice5b_leak_guards.py | 122 ++++--- tests/test_wave_eval_harness.py | 84 +++-- tests/test_worktree_isolation.py | 23 +- 16 files changed, 885 insertions(+), 410 deletions(-) diff --git a/.map/scripts/map_orchestrator.py b/.map/scripts/map_orchestrator.py index f88a2fc0..d33bbcd7 100755 --- a/.map/scripts/map_orchestrator.py +++ b/.map/scripts/map_orchestrator.py @@ -261,6 +261,24 @@ def _extract_subtask_ids_from_plan_artifacts( # Current wave is width-1 even though a later wave is parallel; dispatch sequentially # for this wave — not an error, just the natural plan structure. WAVE_REASON_CURRENT_WAVE_SEQUENTIAL = "current_wave_sequential" +# Kill-switch reason: MAP_EFFICIENT_SEQUENTIAL_ONLY=1 forces the full legacy sequential path. +WAVE_REASON_SEQUENTIAL_ONLY_ENV = "sequential_only_env" + +# Truthy string values for MAP_EFFICIENT_SEQUENTIAL_ONLY env kill-switch. +_SEQUENTIAL_ONLY_TRUTHY = frozenset({"1", "true", "yes", "y", "on"}) + + +def _sequential_only_env() -> bool: + """Return True when MAP_EFFICIENT_SEQUENTIAL_ONLY is set to a truthy value. + + Truthy: {"1", "true", "yes", "y", "on"} (case-insensitive). + When True, forces the full legacy sequential path regardless of config — + no wave-loop, no worktrees, no concurrent dispatch. This is the global + kill-switch / off-ramp introduced in Slice 6 (byte-identical to pre-5a legacy). + Never raises. + """ + val = os.environ.get("MAP_EFFICIENT_SEQUENTIAL_ONLY", "") + return val.strip().lower() in _SEQUENTIAL_ONLY_TRUTHY class DispatchGateError(RuntimeError): @@ -2498,10 +2516,14 @@ def select_execution_strategy( """Determine whether to use wave_loop or legacy sequential walker. Predicate: wave_loop IFF wave_mode in {on, auto} AND worktree.isolation != 'off' - AND any color-group has width >= 2. This mirrors the canonical MapConfig gating - (#305): execution_wave_mode defaults to 'auto' but the wave-loop stays dormant - because worktree.isolation defaults to 'off', so default config always returns - 'sequential' and the legacy get_next_step path is byte-identical (HC-1). + AND any color-group has width >= 2. + + Slice 6: worktree.isolation defaults to 'auto' and concurrent_dispatch defaults + to True, so a parallel-ready plan now selects the wave-loop by default. + + Kill-switch: MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (checked FIRST) forces the full + legacy sequential path regardless of config — byte-identical to pre-5a legacy. + Per-repo opt-out: set `worktree.isolation: off` in .map/config.yaml. Args: branch: Git branch name (sanitized) @@ -2515,11 +2537,23 @@ def select_execution_strategy( "worktree_isolation": "off" | "auto" | "required", "has_parallel_groups": bool, "reason": str, + "concurrency_allowed": bool, } """ if project_dir is None: project_dir = Path(".") + # Kill-switch: MAP_EFFICIENT_SEQUENTIAL_ONLY=1 forces legacy sequential regardless of config. + if _sequential_only_env(): + return { + "strategy": "sequential", + "wave_mode": "off", + "worktree_isolation": "off", + "has_parallel_groups": False, + "reason": WAVE_REASON_SEQUENTIAL_ONLY_ENV, + "concurrency_allowed": False, + } + try: from map_step_runner import ( # pyright: ignore[reportMissingImports] _execution_wave_mode, @@ -2607,18 +2641,26 @@ def compute_dispatch_gate( if project_dir is None: project_dir = Path(".") - # Step 1: short-circuit on flag=false — first gate check after parameter - # normalization. No concurrency probe, no select_execution_strategy call, no - # _worktree_isolation_mode/concurrency_ready call, no dispatcher import runs on - # this path (HC-1 byte-identity). The project_dir None-guard above is a safe - # default-arg normalization, not a concurrency primitive. + # Kill-switch FIRST: MAP_EFFICIENT_SEQUENTIAL_ONLY=1 forces legacy sequential path. + # No concurrency probe, no config read, no import of any concurrency primitive. + if _sequential_only_env(): + return { + "dispatch_mode": "sequential", + "reason": WAVE_REASON_SEQUENTIAL_ONLY_ENV, + } + + # Step 1: short-circuit on flag=false — first gate check after kill-switch and + # parameter normalization. No concurrency probe, no select_execution_strategy call, + # no _worktree_isolation_mode/concurrency_ready call, no dispatcher import runs on + # this path. The project_dir None-guard above is a safe default-arg normalization, + # not a concurrency primitive. try: from map_step_runner import ( # pyright: ignore[reportMissingImports] _concurrent_dispatch_enabled, ) flag_on = _concurrent_dispatch_enabled(project_dir) except ImportError: - flag_on = False + flag_on = True # default ON (Slice 6) when runner unavailable if not flag_on: return { diff --git a/.map/scripts/map_step_runner.py b/.map/scripts/map_step_runner.py index a7d3fe4f..4ae040c5 100755 --- a/.map/scripts/map_step_runner.py +++ b/.map/scripts/map_step_runner.py @@ -153,13 +153,14 @@ def _map_config_int(project_dir: Path, key: str, default: int) -> int: def _concurrent_dispatch_enabled(project_dir: Path) -> bool: - """Return True when execution.concurrent_dispatch is explicitly enabled. + """Return True when execution.concurrent_dispatch is enabled (default ON, Slice 6). - Mirrors the _wt_isolation_enabled pattern. Default is False (off) so the - sequential path stays byte-identical to Slice 5a by default (HC-1). Never - raises. + Default is True — Slice 6 flipped from False. Disable via + MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (global kill-switch) or set + `execution.concurrent_dispatch: false` in .map/config.yaml. + Mirrors the canonical MapConfig default (config/project_config.py). Never raises. """ - raw = _map_config_str(project_dir, "execution.concurrent_dispatch", "false") + raw = _map_config_str(project_dir, "execution.concurrent_dispatch", "true") return raw.strip().lower() in _CONCURRENT_DISPATCH_TRUTHY @@ -167,10 +168,11 @@ def _execution_wave_mode(project_dir: Path) -> str: """Return the execution.wave_mode setting: 'off' | 'auto' | 'on'. Default + enum mirror the canonical MapConfig schema (config/project_config.py): - absent/unknown/garbage normalises to 'auto'. 'auto' is still behavior-neutral - by default because the wave-loop only engages when worktree.isolation != 'off' - (which itself defaults to 'off') AND a color group has >=2 members — see - select_execution_strategy. Never raises. + absent/unknown/garbage normalises to 'auto'. As of the Slice 6 flip, + worktree.isolation also defaults to 'auto', so the wave-loop IS engaged by + default for a parallel-ready plan (it engages when worktree.isolation != 'off' + AND a color group has >=2 members) — see select_execution_strategy. Disable + via MAP_EFFICIENT_SEQUENTIAL_ONLY=1 or the per-repo opt-out keys. Never raises. """ raw = _map_config_str(project_dir, "execution.wave_mode", "auto") return raw if raw in _WAVE_MODE_VALID else "auto" @@ -15381,6 +15383,8 @@ def _wt_force_remove(path: Path, branch_ref: str) -> None: }) _WT_ISOLATION_VALID = frozenset({"off", "auto", "required"}) +# Legacy YAML booleans that map to 'off' (explicit per-repo disable) +_WT_ISOLATION_FALSY = frozenset({"false", "0", "no", "n"}) def _worktree_isolation_mode(project_dir: Path) -> str: @@ -15388,18 +15392,28 @@ def _worktree_isolation_mode(project_dir: Path) -> str: Accepts the new enum strings directly (case-insensitive). Legacy boolean compat: boolish-truthy (true/1/yes) → 'required'; - boolish-false (false/0/no/'') → 'off'. - Absent key → 'off' (today's behavior). Any unknown/garbage → 'off'. + boolish-false (false/0/no) → 'off'. + Absent key → 'auto' (default ON, Slice 6). Any unknown/garbage → 'auto'. + Disable via MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (global kill-switch) or set + `worktree.isolation: off` in .map/config.yaml. + Mirrors the canonical MapConfig default (config/project_config.py). Never raises. """ raw = _map_config_str(project_dir, "worktree.isolation", "") normalized = raw.strip().lower() if normalized in _WT_ISOLATION_VALID: return normalized - # Legacy boolean compat + # Legacy boolean compat: truthy → 'required' if _parse_boolish(normalized): return "required" - return "off" + # Legacy boolean compat: explicit falsy (false/0/no/n) → 'off' (per-repo disable) + if normalized in _WT_ISOLATION_FALSY: + return "off" + # Absent key → default "auto" (Slice 6 flip from "off") + if not normalized: + return "auto" + # Unknown/garbage → safe default 'auto' (degrade gracefully) + return "auto" def _wt_isolation_enabled(project_dir: Path) -> bool: @@ -15408,10 +15422,9 @@ def _wt_isolation_enabled(project_dir: Path) -> bool: Handles the enum migration (#303): the old boolean ``false``/``true`` raw strings (YAML 1.1 booleans are written as ``false``/``true`` when read line-by-line) still work. New enum values: - - ``off`` -> False (disabled — default) - - ``auto`` -> False (Slice 0: sequential everywhere; parallel dispatch - lands in Slice 5; the call site in create_subtask_worktree - already degrades to sequential when this returns False) + - ``off`` -> False (disabled) + - ``auto`` -> True (default ON, Slice 6; degrades gracefully when git + worktrees are unavailable) - ``required`` -> True (hard-fail on unavailability, same as old ``true``) Canonical enum vocabulary + default live in MapConfig @@ -15419,8 +15432,8 @@ def _wt_isolation_enabled(project_dir: Path) -> bool: same `worktree.isolation` key for probe/fallback paths that need the full enum value (auto vs required), not just the enabled boolean. """ - val = _map_config_str(project_dir, "worktree.isolation", "off").strip().lower() - return val in {"required", "true", "yes", "y", "1", "on"} + mode = _worktree_isolation_mode(project_dir) + return mode in {"auto", "required", "true", "yes", "y", "1", "on"} def _wt_max_deletions(project_dir: Path) -> int: @@ -15544,8 +15557,9 @@ def _wt_parse_name_status(text: str) -> tuple[list[str], list[str], int]: def _worktree_probe(project_dir: Path) -> dict[str, object]: """Safe detached worktree probe. Cached per session keyed on toplevel. - HC-1 dormancy contract: when worktree.isolation is 'off' (the default), - this function returns immediately WITHOUT running any git command. + HC-1 dormancy contract: when worktree.isolation is 'off' (the per-repo + opt-out or MAP_EFFICIENT_SEQUENTIAL_ONLY off-ramp), this function returns + immediately WITHOUT running any git command. When mode is 'auto' or 'required': * Resolves the storage root via _wt_storage_root() (never .map/worktrees/). @@ -15626,7 +15640,7 @@ def _worktree_probe(project_dir: Path) -> dict[str, object]: def _require_clean_merge_target(project_dir: Path) -> dict[str, object]: """Check that the main checkout is clean enough for a worktree merge. - Dormant when worktree.isolation is 'off' (default — HC-1). + Dormant when worktree.isolation is 'off' (per-repo opt-out / kill-switch — HC-1). When the check is active AND require_clean_merge_target config is True (default True), runs `git status --porcelain` and returns: * {"status":"ok","ok":True} when clean (excluding MAP runtime state) @@ -15671,8 +15685,8 @@ def _require_clean_merge_target(project_dir: Path) -> dict[str, object]: def resolve_worktree_isolation(project_dir: Path) -> dict[str, object]: """Classify the current environment and decide the execution decision. - HC-1 dormancy: when isolation is 'off' (the default), returns immediately - without running any git command. + HC-1 dormancy: when isolation is 'off' (per-repo opt-out / kill-switch), + returns immediately without running any git command. Return schema ------------- @@ -17460,10 +17474,13 @@ def run_concurrent_wave( spawn actor agents — the skill emits N Task blocks to start actors (ST-007). Its responsibilities are: - 1. **Default-off guard** (HC-1, defense-in-depth): if ``concurrent_dispatch`` - is not explicitly enabled in config, return ``CONCURRENT_DISPATCH_DISABLED`` - immediately so a direct CLI or coordinator call cannot trigger concurrent - merging under the default-off configuration. + 1. **Disabled-dispatch guard** (defense-in-depth): if ``concurrent_dispatch`` + is disabled — via the per-repo opt-out (``execution.concurrent_dispatch: + false``) or the ``MAP_EFFICIENT_SEQUENTIAL_ONLY`` kill-switch — return + ``CONCURRENT_DISPATCH_DISABLED`` immediately so a direct CLI or coordinator + call cannot trigger concurrent merging when the operator has opted out. + (As of the Slice 6 flip the default is enabled; this guard catches the + explicit off-ramps.) 2. **Batch-split**: read ``max_actors`` from config (via ``_max_actors()``), clamp to [1, 8], then split the sorted ``group_ids`` into sequential @@ -17531,14 +17548,18 @@ def run_concurrent_wave( pd = project_dir or _wt_project_dir() or Path(".") branch_name = branch or get_branch_name() - # F6: Default-off guard (HC-1, defense-in-depth). A direct CLI call or - # coordinator misconfiguration cannot trigger concurrent merging unless the - # flag is explicitly set. This is a second line of defense after compute_dispatch_gate. + # F6: Disabled-dispatch guard (defense-in-depth). A direct CLI call or + # coordinator misconfiguration cannot trigger concurrent merging when the + # operator has opted out. Second line of defense after compute_dispatch_gate + # (which also honors the MAP_EFFICIENT_SEQUENTIAL_ONLY kill-switch upstream). if not _concurrent_dispatch_enabled(pd): return _wt_error( "CONCURRENT_DISPATCH_DISABLED", - "run_concurrent_wave requires execution.concurrent_dispatch=true in config; " - "default is off (HC-1). Set the flag explicitly to enable concurrent dispatch.", + "run_concurrent_wave: concurrent dispatch is disabled by this repo's " + "config (execution.concurrent_dispatch: false). It defaults to enabled " + "since the Slice 6 flip — remove that key or set it to true to enable " + "concurrent dispatch (or it may be the MAP_EFFICIENT_SEQUENTIAL_ONLY " + "kill-switch, which forces sequential everywhere).", ) # Deterministic sorted list — order-of-call must not vary group membership. diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f8fc90..c5960eb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Parallel execution defaults flipped ON (`worktree.isolation` off→auto, `execution.concurrent_dispatch` false→true, Slice 6 of #303).** Concurrent wave execution is now **ON by default** for repositories that are git repos with a parallel-ready plan (>=2 independent subtasks in a wave). Off-ramps (either is sufficient): (1) **global kill-switch** — set `MAP_EFFICIENT_SEQUENTIAL_ONLY=1` in your environment; forces the full legacy sequential path, byte-identical to pre-5a behavior, regardless of config; (2) **per-repo opt-out** — set `worktree.isolation: off` and/or `execution.concurrent_dispatch: false` in `.map/config.yaml`. The `auto` isolation mode degrades gracefully to sequential with a warning when git worktrees are unavailable (non-git repo, shallow clone, detached HEAD). Default `worktree.isolation` `MapConfig` value: `"off"` → `"auto"`; `concurrent_dispatch` `MapConfig` value: `False` → `True`; matching defaults in the step-runner config readers (`_worktree_isolation_mode`, `_concurrent_dispatch_enabled`). The `select_execution_strategy` and `compute_dispatch_gate` functions now check the kill-switch as their **first** gate (before any config read or concurrency probe), backed by a new shared `_sequential_only_env()` helper and a stable `WAVE_REASON_SEQUENTIAL_ONLY_ENV` reason code. + ### Added - **Concurrent Actor dispatch for parallel waves (`execution.concurrent_dispatch`, part of #303 Slice 5b).** Activates same-turn concurrent dispatch of Actor subagents within a parallel wave, previously scheduled but always run sequentially. Flag-gated (`execution.concurrent_dispatch: false` by default) so the default code-path is byte-identical to Slice 5a; defaults flip only in Slice 6. Key components: `compute_dispatch_gate` (strict conjunction — `concurrent_dispatch` AND `concurrency_allowed` AND `concurrency_ready` AND `isolation != off`; hard-aborts with a `ConfigError`-equivalent on any config contradiction, fail-closed rather than degrading silently), `run_concurrent_wave` (splits the wave into sub-batches of `execution.max_actors` and dispatches each sub-batch; atomic per-sub-batch merge via `merge_wave_worktrees`), `abort_wave_group` (whole-group rollback — reverts every worktree in the group back to wave base, bounded by `execution.max_wave_retries`), `record_dispatch_actual` (clock-free phantom-parallelism classifier using `max_in_flight` replay — worktree SHA proves isolation but NOT concurrency; emits `phantom_parallel` evidence when actors ran but no concurrent overlap is detectable). Test harness uses barrier-based determinism (no wall-clock sleeps); HC-1 leak-guard suite validates no cross-subtask state leaks under concurrent dispatch. Council review split this into 5a (infrastructure) and 5b (activation); this entry covers 5b. - **`deferred_nondeterministic` wired into the core Monitor verdict path (completes #252).** The flaky-triage primitives (`run_/record_/validate_flaky_test_triage`) and the `defer_flaky_subtask` close+advance command already existed, but were **disjoint** from the Monitor verdict path: Monitor could only emit `valid:true`/`valid:false`, had no field to signal a flaky defer, and `validate_step 2.4` could only pass or hard-stop — so a confirmed flake forced an out-of-band manual `defer_flaky_subtask`. The third Monitor outcome is now part of the structured verdict. (1) The Monitor schema gains an OPTIONAL structured `disposition: {kind, check_id}` field (`kind` enum currently `{deferred_nondeterministic}`), absent for normal verdicts, with guidance to emit it on confirmed mixed pass/fail evidence instead of demanding a fake Actor fix. (2) `validate_step 2.4 --disposition deferred_nondeterministic --check-id --monitor-envelope -` routes to the existing `defer_flaky_subtask` **in-process** (the single owner of the close+advance transaction), placed BEFORE the recommendation gates so a defer carrying `recommendation=needs_investigation` is not hard-stopped. (3) **Anti-gaming** (llm-council-reviewed, conv `d3ddca63`): the deferral is honored ONLY when the Monitor envelope structurally backs it — `valid:false`, non-empty `failed_checks`, and a structured `disposition` whose kind + `check_id` match the flags — AND the sidecar holds mixed pass/fail evidence for that `check_id` (re-validated from disk by `defer_flaky_subtask`). A Monitor cannot dodge a real deterministic failure or a green check by merely claiming "flaky"; `recommendation in {revise, block}` together with a disposition is rejected as a contradiction. (Note: the Monitor schema's `failed_checks` lists failed quality *dimensions*, a different namespace from a flaky check id, so the binding is "Monitor admits a dimension failure + dispositions match" rather than "check_id ∈ failed_checks".) (4) **Verdict vs routing:** a deferred run returns `valid:false` + `deferred:true` + `non_green_outcome:true` (a deferral is NOT green — it is a routing decision, not a clean pass); the CLI exits `0` on a deferral (not a hard-stop) and `1` only on a true invalid verdict. A single source-of-truth `MONITOR_DISPOSITIONS` policy dict drives the routing, the CLI `--disposition` surface, and a drift-guard test (the Monitor prompt must name every supported disposition). Closes the last core slice of #252. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 69f64ef6..5b3bf24a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -105,15 +105,21 @@ Claude skill metadata includes `skillClass` in `.claude/skills/skill-rules.json` ## Worktree Isolation (per-subtask sandboxing) -Per-subtask git worktree isolation (#284, opt-in via `worktree.isolation`, off by -default) gives each `/map-efficient` subtask an isolated filesystem so a bad Actor -attempt can never touch the working branch. It is **runner-owned**: the step -runner creates explicit worktrees rather than using the harness-native +Per-subtask git worktree isolation (#284, default **ON** via `worktree.isolation: +auto` since Slice 6) gives each `/map-efficient` subtask an isolated filesystem +so a bad Actor attempt can never touch the working branch. It is **runner-owned**: +the step runner creates explicit worktrees rather than using the harness-native `isolation="worktree"` — the native mode hides the worktree path, which makes the deterministic safety gates, structured conflict reports, and explicit squash-merge impossible to implement or unit-test. The two mechanisms are alternatives and must never both be active on the same subtask. +**Off-ramps:** `MAP_EFFICIENT_SEQUENTIAL_ONLY=1` (global env kill-switch, forces +the full legacy sequential path byte-identical to pre-Slice-5a) or set +`worktree.isolation: off` in `.map/config.yaml` (per-repo opt-out). The `auto` +default degrades gracefully to sequential when git worktrees are unavailable +(non-git repo, shallow clone, etc.). + Design decisions (llm-council-reviewed, conv `461b92f9`): - **Fresh context is orthogonal to the worktree.** Each Actor is already a @@ -186,19 +192,28 @@ divergence each in-wave squash-merge creates. Design (llm-council-reviewed, conv reported as advisory telemetry while git's textual conflict stays the hard guard). -### Phase 3 / Slice 5b: concurrent Actor dispatch (flag-gated, default off) - -Slice 5b activates same-turn concurrent dispatch of Actor subagents within a -parallel wave. The entire path is guarded by `execution.concurrent_dispatch` -(default `false`); with the flag off the code path is byte-identical to Slice 5a. - -**Dispatch gate (`compute_dispatch_gate`).** A strict conjunction of four -conditions: `concurrent_dispatch` is true AND `concurrency_allowed` (platform -supports parallel Task dispatch) AND `concurrency_ready` (runner state is -consistent) AND `worktree.isolation != off`. Any single condition false → gate -returns `disabled`. A config contradiction (e.g. `concurrent_dispatch: true` with -isolation off) is a hard abort (`ConfigError`-equivalent) — fail-closed, never -silent degradation. +### Phase 3 / Slice 6: concurrent Actor dispatch (ON by default) + +Slice 6 flips the defaults: `execution.concurrent_dispatch` now defaults to +`true` and `worktree.isolation` defaults to `"auto"`. Concurrent dispatch of +Actor subagents within a parallel wave is active for any repo that is a git +repo with a parallel-ready plan. + +**Kill-switch.** `MAP_EFFICIENT_SEQUENTIAL_ONLY=1` (env var; truthy values: +`1/true/yes/y/on`) is checked FIRST in both `select_execution_strategy` and +`compute_dispatch_gate` — before any config read or concurrency probe. When set, +the full legacy sequential path is taken, byte-identical to pre-Slice-5a. The +stable reason code is `WAVE_REASON_SEQUENTIAL_ONLY_ENV`. Per-repo opt-out: +`execution.concurrent_dispatch: false` or `worktree.isolation: off` in +`.map/config.yaml`. + +**Dispatch gate (`compute_dispatch_gate`).** After the kill-switch check: a +strict conjunction of four conditions: `concurrent_dispatch` is true AND +`concurrency_allowed` (platform supports parallel Task dispatch) AND +`concurrency_ready` (runner state is consistent) AND `worktree.isolation != off`. +Any single condition false → gate returns sequential. A config contradiction +(e.g. `concurrent_dispatch: true` with isolation off) is a hard abort +(`ConfigError`-equivalent) — fail-closed, never silent degradation. **Group lifecycle.** `begin_wave_group` opens a dispatch group and records the base SHA from the sidecar. `record_group_lifecycle` appends structured events diff --git a/docs/USAGE.md b/docs/USAGE.md index 56290361..2cf13ff8 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -221,23 +221,30 @@ follow-up slice; slice 1 is single-runtime dispatch. ## Worktree isolation (per-subtask sandboxing) -Per-subtask git worktree isolation is an **opt-in, off-by-default** feature for -`/map-efficient` (issue #284). With it disabled (the default), `/map-efficient` -runs exactly as before — every worktree command no-ops with `status:"disabled"`. +Per-subtask git worktree isolation is **ON by default** (Slice 6, issue #284) +for git repos with a parallel-ready plan. Each subtask's Actor runs in its own +throwaway git worktree, and the result is squash-merged back into the working +branch **only after the configured `verification_checks` pass inside the worktree** +(a pre-merge gate). A rejected attempt (Monitor `valid=false` / Evaluator fail) +is discarded, so the working branch is never touched by a bad attempt. -When enabled, each subtask's Actor runs in its own throwaway git worktree, and -the result is squash-merged back into the working branch **only after the -configured `verification_checks` pass inside the worktree** (a pre-merge gate). A -rejected attempt (Monitor `valid=false` / Evaluator fail) is discarded, so the -working branch is never touched by a bad attempt. +**Off-ramps (either is sufficient):** -### Enabling +1. **Global kill-switch** — set `MAP_EFFICIENT_SEQUENTIAL_ONLY=1` in your shell. + Forces the full legacy sequential path, byte-identical to pre-Slice-6, regardless + of any config. Unset to restore default parallel behavior. +2. **Per-repo opt-out** — set `worktree.isolation: off` in `.map/config.yaml`. + +The `auto` mode degrades gracefully to sequential (with a logged warning) when git +worktrees are unavailable (non-git repo, shallow clone, detached HEAD, locked ref). + +### Config ```yaml # .map/config.yaml -worktree.isolation: true -worktree.max_deletions: 50 # refuse a subtask merge deleting more than N files (0 = off) -verification_checks: # run inside the worktree before merge +worktree.isolation: auto # default ON (Slice 6); use off to revert +worktree.max_deletions: 50 # refuse a subtask merge deleting more than N files (0 = off) +verification_checks: # run inside the worktree before merge - make check ``` @@ -293,28 +300,38 @@ A concurrent second coordinator is blocked by an advisory lock. Phase 2's wave-merge coordinator (`merge_wave_worktrees`) has landed; Phase 3 (context-budget hooks) remains open on #284. -### Concurrent dispatch (Slice 5b, opt-in) +### Concurrent dispatch (Slice 6, ON by default) -Concurrent Actor dispatch within a parallel wave is an **opt-in, off-by-default** -feature controlled by three config keys. With `execution.concurrent_dispatch` -unset or `false` (the default), wave dispatch is sequential — identical behavior -to before Slice 5b. +Concurrent Actor dispatch within a parallel wave is **ON by default** (Slice 6). +For repos with a parallel-ready plan and a git worktree environment, `/map-efficient` +will dispatch multiple Actor subagents concurrently within each parallel wave. ```yaml # .map/config.yaml -execution.concurrent_dispatch: false # set to true to enable same-turn concurrent dispatch +execution.concurrent_dispatch: true # default ON (Slice 6); use false to revert execution.max_actors: 4 # max parallel Actor agents per sub-batch (clamp [1,8]) execution.max_wave_retries: 3 # max whole-group rollback+restart attempts (clamp [1,10]) ``` -**Requirements when enabling:** `worktree.isolation` must be `auto` or `required`. -Setting `execution.concurrent_dispatch: true` with isolation off (or `disabled`) +**Requirements for concurrent dispatch:** `worktree.isolation` must be `auto` or +`required`. Setting `execution.concurrent_dispatch: true` with isolation `off` produces a hard `ConfigError` abort — the gate fails closed rather than degrading silently. -**Sequential default.** With `concurrent_dispatch: false` (or absent), every wave -runs the same sequential Actor→merge loop as before; no config error is raised and -none of the concurrent-dispatch code paths are exercised. +**Off-ramps (either is sufficient):** +1. `MAP_EFFICIENT_SEQUENTIAL_ONLY=1` — global kill-switch (env var). +2. `execution.concurrent_dispatch: false` — per-repo opt-out in `.map/config.yaml`. + +**Kill-switch: `MAP_EFFICIENT_SEQUENTIAL_ONLY`** + +```bash +export MAP_EFFICIENT_SEQUENTIAL_ONLY=1 # forces full legacy sequential path +# or: true / yes / y / on +``` + +When set, ALL concurrent behavior is suppressed regardless of config: no wave-loop, +no worktrees, no concurrent dispatch. The code path is byte-identical to pre-Slice-5a +legacy. Unset or set to `0`/`false` to re-enable default parallel behavior. ## Stack Overflow for Agents (SOFA) diff --git a/src/mapify_cli/config/project_config.py b/src/mapify_cli/config/project_config.py index 9b6d4fdb..5d19dff3 100644 --- a/src/mapify_cli/config/project_config.py +++ b/src/mapify_cli/config/project_config.py @@ -163,14 +163,16 @@ class MapConfig: # key `worktree.isolation` aliases to this snake_case field (see # load_map_config). The step runner owns the lifecycle + safety guards. # Enum values: - # "off" — never create worktrees; sequential execution always (default). + # "off" — never create worktrees; sequential execution always. # "auto" — create per-subtask worktrees when a parallel color-group # dispatches; degrade to sequential with a loud warning when git # worktrees are unavailable (non-git repo, shallow clone, etc.). + # default ON (Slice 6); disable via MAP_EFFICIENT_SEQUENTIAL_ONLY=1 + # or set `worktree.isolation: off` in .map/config.yaml. # "required" — hard-fail before parallel dispatch if worktrees are unavailable. # Backward compat: YAML boolean `false` migrates to `"off"`, `true` to # `"required"` in load_map_config. - worktree_isolation: str = "off" + worktree_isolation: str = "auto" # Bulk-deletion guard threshold: the per-subtask merge refuses when the # worktree branch deletes MORE than this many files vs the base commit # (catches `rm -rf` / hallucinated mass deletion before it reaches the @@ -208,13 +210,12 @@ class MapConfig: retry_degraded_once: bool = False # Enable same-turn concurrent Actor dispatch in a parallel wave (#303 Slice 5b). - # The ONLY user-facing switch that activates concurrent dispatch; `False` by - # default — Slice 6 flips this once the rollback path is proven stable. + # default ON (Slice 6); disable via MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (global + # kill-switch) or set `execution.concurrent_dispatch: false` in .map/config.yaml. # Dotted YAML alias: `execution.concurrent_dispatch`. # YAML 1.1 bare off/on arrive as Python bool, which matches this field type — # no coercion needed (unlike the string enum fields). - # DORMANT in 5b.0 — consumed by ST-001's gate; no execution path reads it yet. - concurrent_dispatch: bool = False + concurrent_dispatch: bool = True # Bounded retry cap for whole-wave rollback/restart in Slice 5b (#303). # Range 1–10; values outside the range are clamped by clamp_max_wave_retries(). @@ -619,48 +620,54 @@ def generate_default_config(include_comments: bool = True) -> str: # Per-subtask git worktree isolation (#284). Controls filesystem isolation for # each Actor run in `/map-efficient`. Enum values: -# off — never create worktrees; sequential execution always (default). -# auto — create per-subtask worktrees when a parallel color-group dispatches; -# degrade to sequential with a warning when worktrees are unavailable. +# off — never create worktrees; sequential execution always. +# auto — (DEFAULT, Slice 6) create per-subtask worktrees when a parallel +# color-group dispatches; degrade gracefully to sequential when git +# worktrees are unavailable (non-git repo, shallow clone, etc.). # required — hard-fail before dispatch if worktrees are unavailable (first-party # repos that must never degrade silently). # When on (auto/required), each Actor runs inside a dedicated git worktree stored # under the repo's .git common dir and is squash-merged back ONLY after # verification_checks pass. A rejected attempt is discarded — the working branch # is never touched by a bad Actor attempt. +# OFF-RAMPS: set worktree.isolation: off (per-repo opt-out) OR set env +# MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (global kill-switch — forces full legacy path). # Backward compat: the old boolean `false`/`true` still works (migrates to off/required). -# worktree.isolation: off +# worktree.isolation: auto # default ON (Slice 6); use off to revert # worktree.max_deletions: 50 # refuse a merge deleting more than N files (0 = off) # Parallel wave execution mode (#303). Controls whether `/map-efficient` routes # multi-subtask waves through the parallel coordinator or the sequential walker. # auto — (default) engage the wave-loop when >=2 independent subtasks AND -# worktree.isolation != off; otherwise sequential. Currently behaves as -# sequential everywhere (parallel dispatch lands in a later slice). +# worktree.isolation != off; otherwise sequential. # off — always sequential; instant rollback escape hatch. -# on — always attempt parallel (reserved; same as auto until dispatch lands). +# on — always attempt parallel (same as auto in Slice 6). # execution.wave_mode: auto # Concurrent Actor limit for parallel wave dispatch (#303 Slice 5b). # Valid range 1–8; values outside the range are clamped (0→1, 9→8). # Non-int / bool values fall back to the default 4. -# DORMANT until Slice 5b — setting this in Slice 5a has no effect. # execution.max_actors: 4 # Retry a crashed worker once before aborting the wave (Slice 5b). -# DORMANT until Slice 5b — setting this in Slice 5a has no effect. # execution.retry_degraded_once: false -# Enable same-turn concurrent Actor dispatch (#303 Slice 5b). The ONLY switch -# that activates concurrent dispatch; false by default — Slice 6 flips this once -# the rollback path is proven stable. YAML 1.1 bare off/on arrive as bool. -# execution.concurrent_dispatch: false +# Enable same-turn concurrent Actor dispatch (#303 Slice 6). DEFAULT True. +# YAML 1.1 bare off/on arrive as bool. +# OFF-RAMPS: set false here (per-repo opt-out) OR MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (global). +# execution.concurrent_dispatch: true # default ON (Slice 6); use false to revert # Bounded retry cap for whole-wave rollback/restart (#303 Slice 5b). # Valid range 1–10; values outside the range are clamped (0→1, 99→10). # Non-int / bool values fall back to the default 3. # execution.max_wave_retries: 3 +# Global kill-switch (Slice 6 off-ramp). Forces the FULL legacy sequential path +# regardless of any config — no wave-loop, no worktrees, no concurrent dispatch. +# Byte-identical to pre-5a legacy behavior. Set as an environment variable: +# export MAP_EFFICIENT_SEQUENTIAL_ONLY=1 # or true/yes/y/on +# Unset (or empty / "0" / "false") to restore default parallel behavior. + # Strip MAP-internal workflow IDs (ST-/AC-/VC-/INV-/HC-) from the code a run # changed, at workflow completion (Stop hook). On by default; uncomment and set # to false to keep the IDs the framework wrote into comments/strings/test names. diff --git a/src/mapify_cli/templates/map/scripts/map_orchestrator.py b/src/mapify_cli/templates/map/scripts/map_orchestrator.py index f88a2fc0..d33bbcd7 100755 --- a/src/mapify_cli/templates/map/scripts/map_orchestrator.py +++ b/src/mapify_cli/templates/map/scripts/map_orchestrator.py @@ -261,6 +261,24 @@ def _extract_subtask_ids_from_plan_artifacts( # Current wave is width-1 even though a later wave is parallel; dispatch sequentially # for this wave — not an error, just the natural plan structure. WAVE_REASON_CURRENT_WAVE_SEQUENTIAL = "current_wave_sequential" +# Kill-switch reason: MAP_EFFICIENT_SEQUENTIAL_ONLY=1 forces the full legacy sequential path. +WAVE_REASON_SEQUENTIAL_ONLY_ENV = "sequential_only_env" + +# Truthy string values for MAP_EFFICIENT_SEQUENTIAL_ONLY env kill-switch. +_SEQUENTIAL_ONLY_TRUTHY = frozenset({"1", "true", "yes", "y", "on"}) + + +def _sequential_only_env() -> bool: + """Return True when MAP_EFFICIENT_SEQUENTIAL_ONLY is set to a truthy value. + + Truthy: {"1", "true", "yes", "y", "on"} (case-insensitive). + When True, forces the full legacy sequential path regardless of config — + no wave-loop, no worktrees, no concurrent dispatch. This is the global + kill-switch / off-ramp introduced in Slice 6 (byte-identical to pre-5a legacy). + Never raises. + """ + val = os.environ.get("MAP_EFFICIENT_SEQUENTIAL_ONLY", "") + return val.strip().lower() in _SEQUENTIAL_ONLY_TRUTHY class DispatchGateError(RuntimeError): @@ -2498,10 +2516,14 @@ def select_execution_strategy( """Determine whether to use wave_loop or legacy sequential walker. Predicate: wave_loop IFF wave_mode in {on, auto} AND worktree.isolation != 'off' - AND any color-group has width >= 2. This mirrors the canonical MapConfig gating - (#305): execution_wave_mode defaults to 'auto' but the wave-loop stays dormant - because worktree.isolation defaults to 'off', so default config always returns - 'sequential' and the legacy get_next_step path is byte-identical (HC-1). + AND any color-group has width >= 2. + + Slice 6: worktree.isolation defaults to 'auto' and concurrent_dispatch defaults + to True, so a parallel-ready plan now selects the wave-loop by default. + + Kill-switch: MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (checked FIRST) forces the full + legacy sequential path regardless of config — byte-identical to pre-5a legacy. + Per-repo opt-out: set `worktree.isolation: off` in .map/config.yaml. Args: branch: Git branch name (sanitized) @@ -2515,11 +2537,23 @@ def select_execution_strategy( "worktree_isolation": "off" | "auto" | "required", "has_parallel_groups": bool, "reason": str, + "concurrency_allowed": bool, } """ if project_dir is None: project_dir = Path(".") + # Kill-switch: MAP_EFFICIENT_SEQUENTIAL_ONLY=1 forces legacy sequential regardless of config. + if _sequential_only_env(): + return { + "strategy": "sequential", + "wave_mode": "off", + "worktree_isolation": "off", + "has_parallel_groups": False, + "reason": WAVE_REASON_SEQUENTIAL_ONLY_ENV, + "concurrency_allowed": False, + } + try: from map_step_runner import ( # pyright: ignore[reportMissingImports] _execution_wave_mode, @@ -2607,18 +2641,26 @@ def compute_dispatch_gate( if project_dir is None: project_dir = Path(".") - # Step 1: short-circuit on flag=false — first gate check after parameter - # normalization. No concurrency probe, no select_execution_strategy call, no - # _worktree_isolation_mode/concurrency_ready call, no dispatcher import runs on - # this path (HC-1 byte-identity). The project_dir None-guard above is a safe - # default-arg normalization, not a concurrency primitive. + # Kill-switch FIRST: MAP_EFFICIENT_SEQUENTIAL_ONLY=1 forces legacy sequential path. + # No concurrency probe, no config read, no import of any concurrency primitive. + if _sequential_only_env(): + return { + "dispatch_mode": "sequential", + "reason": WAVE_REASON_SEQUENTIAL_ONLY_ENV, + } + + # Step 1: short-circuit on flag=false — first gate check after kill-switch and + # parameter normalization. No concurrency probe, no select_execution_strategy call, + # no _worktree_isolation_mode/concurrency_ready call, no dispatcher import runs on + # this path. The project_dir None-guard above is a safe default-arg normalization, + # not a concurrency primitive. try: from map_step_runner import ( # pyright: ignore[reportMissingImports] _concurrent_dispatch_enabled, ) flag_on = _concurrent_dispatch_enabled(project_dir) except ImportError: - flag_on = False + flag_on = True # default ON (Slice 6) when runner unavailable if not flag_on: return { diff --git a/src/mapify_cli/templates/map/scripts/map_step_runner.py b/src/mapify_cli/templates/map/scripts/map_step_runner.py index a7d3fe4f..4ae040c5 100755 --- a/src/mapify_cli/templates/map/scripts/map_step_runner.py +++ b/src/mapify_cli/templates/map/scripts/map_step_runner.py @@ -153,13 +153,14 @@ def _map_config_int(project_dir: Path, key: str, default: int) -> int: def _concurrent_dispatch_enabled(project_dir: Path) -> bool: - """Return True when execution.concurrent_dispatch is explicitly enabled. + """Return True when execution.concurrent_dispatch is enabled (default ON, Slice 6). - Mirrors the _wt_isolation_enabled pattern. Default is False (off) so the - sequential path stays byte-identical to Slice 5a by default (HC-1). Never - raises. + Default is True — Slice 6 flipped from False. Disable via + MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (global kill-switch) or set + `execution.concurrent_dispatch: false` in .map/config.yaml. + Mirrors the canonical MapConfig default (config/project_config.py). Never raises. """ - raw = _map_config_str(project_dir, "execution.concurrent_dispatch", "false") + raw = _map_config_str(project_dir, "execution.concurrent_dispatch", "true") return raw.strip().lower() in _CONCURRENT_DISPATCH_TRUTHY @@ -167,10 +168,11 @@ def _execution_wave_mode(project_dir: Path) -> str: """Return the execution.wave_mode setting: 'off' | 'auto' | 'on'. Default + enum mirror the canonical MapConfig schema (config/project_config.py): - absent/unknown/garbage normalises to 'auto'. 'auto' is still behavior-neutral - by default because the wave-loop only engages when worktree.isolation != 'off' - (which itself defaults to 'off') AND a color group has >=2 members — see - select_execution_strategy. Never raises. + absent/unknown/garbage normalises to 'auto'. As of the Slice 6 flip, + worktree.isolation also defaults to 'auto', so the wave-loop IS engaged by + default for a parallel-ready plan (it engages when worktree.isolation != 'off' + AND a color group has >=2 members) — see select_execution_strategy. Disable + via MAP_EFFICIENT_SEQUENTIAL_ONLY=1 or the per-repo opt-out keys. Never raises. """ raw = _map_config_str(project_dir, "execution.wave_mode", "auto") return raw if raw in _WAVE_MODE_VALID else "auto" @@ -15381,6 +15383,8 @@ def _wt_force_remove(path: Path, branch_ref: str) -> None: }) _WT_ISOLATION_VALID = frozenset({"off", "auto", "required"}) +# Legacy YAML booleans that map to 'off' (explicit per-repo disable) +_WT_ISOLATION_FALSY = frozenset({"false", "0", "no", "n"}) def _worktree_isolation_mode(project_dir: Path) -> str: @@ -15388,18 +15392,28 @@ def _worktree_isolation_mode(project_dir: Path) -> str: Accepts the new enum strings directly (case-insensitive). Legacy boolean compat: boolish-truthy (true/1/yes) → 'required'; - boolish-false (false/0/no/'') → 'off'. - Absent key → 'off' (today's behavior). Any unknown/garbage → 'off'. + boolish-false (false/0/no) → 'off'. + Absent key → 'auto' (default ON, Slice 6). Any unknown/garbage → 'auto'. + Disable via MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (global kill-switch) or set + `worktree.isolation: off` in .map/config.yaml. + Mirrors the canonical MapConfig default (config/project_config.py). Never raises. """ raw = _map_config_str(project_dir, "worktree.isolation", "") normalized = raw.strip().lower() if normalized in _WT_ISOLATION_VALID: return normalized - # Legacy boolean compat + # Legacy boolean compat: truthy → 'required' if _parse_boolish(normalized): return "required" - return "off" + # Legacy boolean compat: explicit falsy (false/0/no/n) → 'off' (per-repo disable) + if normalized in _WT_ISOLATION_FALSY: + return "off" + # Absent key → default "auto" (Slice 6 flip from "off") + if not normalized: + return "auto" + # Unknown/garbage → safe default 'auto' (degrade gracefully) + return "auto" def _wt_isolation_enabled(project_dir: Path) -> bool: @@ -15408,10 +15422,9 @@ def _wt_isolation_enabled(project_dir: Path) -> bool: Handles the enum migration (#303): the old boolean ``false``/``true`` raw strings (YAML 1.1 booleans are written as ``false``/``true`` when read line-by-line) still work. New enum values: - - ``off`` -> False (disabled — default) - - ``auto`` -> False (Slice 0: sequential everywhere; parallel dispatch - lands in Slice 5; the call site in create_subtask_worktree - already degrades to sequential when this returns False) + - ``off`` -> False (disabled) + - ``auto`` -> True (default ON, Slice 6; degrades gracefully when git + worktrees are unavailable) - ``required`` -> True (hard-fail on unavailability, same as old ``true``) Canonical enum vocabulary + default live in MapConfig @@ -15419,8 +15432,8 @@ def _wt_isolation_enabled(project_dir: Path) -> bool: same `worktree.isolation` key for probe/fallback paths that need the full enum value (auto vs required), not just the enabled boolean. """ - val = _map_config_str(project_dir, "worktree.isolation", "off").strip().lower() - return val in {"required", "true", "yes", "y", "1", "on"} + mode = _worktree_isolation_mode(project_dir) + return mode in {"auto", "required", "true", "yes", "y", "1", "on"} def _wt_max_deletions(project_dir: Path) -> int: @@ -15544,8 +15557,9 @@ def _wt_parse_name_status(text: str) -> tuple[list[str], list[str], int]: def _worktree_probe(project_dir: Path) -> dict[str, object]: """Safe detached worktree probe. Cached per session keyed on toplevel. - HC-1 dormancy contract: when worktree.isolation is 'off' (the default), - this function returns immediately WITHOUT running any git command. + HC-1 dormancy contract: when worktree.isolation is 'off' (the per-repo + opt-out or MAP_EFFICIENT_SEQUENTIAL_ONLY off-ramp), this function returns + immediately WITHOUT running any git command. When mode is 'auto' or 'required': * Resolves the storage root via _wt_storage_root() (never .map/worktrees/). @@ -15626,7 +15640,7 @@ def _worktree_probe(project_dir: Path) -> dict[str, object]: def _require_clean_merge_target(project_dir: Path) -> dict[str, object]: """Check that the main checkout is clean enough for a worktree merge. - Dormant when worktree.isolation is 'off' (default — HC-1). + Dormant when worktree.isolation is 'off' (per-repo opt-out / kill-switch — HC-1). When the check is active AND require_clean_merge_target config is True (default True), runs `git status --porcelain` and returns: * {"status":"ok","ok":True} when clean (excluding MAP runtime state) @@ -15671,8 +15685,8 @@ def _require_clean_merge_target(project_dir: Path) -> dict[str, object]: def resolve_worktree_isolation(project_dir: Path) -> dict[str, object]: """Classify the current environment and decide the execution decision. - HC-1 dormancy: when isolation is 'off' (the default), returns immediately - without running any git command. + HC-1 dormancy: when isolation is 'off' (per-repo opt-out / kill-switch), + returns immediately without running any git command. Return schema ------------- @@ -17460,10 +17474,13 @@ def run_concurrent_wave( spawn actor agents — the skill emits N Task blocks to start actors (ST-007). Its responsibilities are: - 1. **Default-off guard** (HC-1, defense-in-depth): if ``concurrent_dispatch`` - is not explicitly enabled in config, return ``CONCURRENT_DISPATCH_DISABLED`` - immediately so a direct CLI or coordinator call cannot trigger concurrent - merging under the default-off configuration. + 1. **Disabled-dispatch guard** (defense-in-depth): if ``concurrent_dispatch`` + is disabled — via the per-repo opt-out (``execution.concurrent_dispatch: + false``) or the ``MAP_EFFICIENT_SEQUENTIAL_ONLY`` kill-switch — return + ``CONCURRENT_DISPATCH_DISABLED`` immediately so a direct CLI or coordinator + call cannot trigger concurrent merging when the operator has opted out. + (As of the Slice 6 flip the default is enabled; this guard catches the + explicit off-ramps.) 2. **Batch-split**: read ``max_actors`` from config (via ``_max_actors()``), clamp to [1, 8], then split the sorted ``group_ids`` into sequential @@ -17531,14 +17548,18 @@ def run_concurrent_wave( pd = project_dir or _wt_project_dir() or Path(".") branch_name = branch or get_branch_name() - # F6: Default-off guard (HC-1, defense-in-depth). A direct CLI call or - # coordinator misconfiguration cannot trigger concurrent merging unless the - # flag is explicitly set. This is a second line of defense after compute_dispatch_gate. + # F6: Disabled-dispatch guard (defense-in-depth). A direct CLI call or + # coordinator misconfiguration cannot trigger concurrent merging when the + # operator has opted out. Second line of defense after compute_dispatch_gate + # (which also honors the MAP_EFFICIENT_SEQUENTIAL_ONLY kill-switch upstream). if not _concurrent_dispatch_enabled(pd): return _wt_error( "CONCURRENT_DISPATCH_DISABLED", - "run_concurrent_wave requires execution.concurrent_dispatch=true in config; " - "default is off (HC-1). Set the flag explicitly to enable concurrent dispatch.", + "run_concurrent_wave: concurrent dispatch is disabled by this repo's " + "config (execution.concurrent_dispatch: false). It defaults to enabled " + "since the Slice 6 flip — remove that key or set it to true to enable " + "concurrent dispatch (or it may be the MAP_EFFICIENT_SEQUENTIAL_ONLY " + "kill-switch, which forces sequential everywhere).", ) # Deterministic sorted list — order-of-call must not vary group membership. diff --git a/src/mapify_cli/templates_src/map/scripts/map_orchestrator.py.jinja b/src/mapify_cli/templates_src/map/scripts/map_orchestrator.py.jinja index f88a2fc0..d33bbcd7 100755 --- a/src/mapify_cli/templates_src/map/scripts/map_orchestrator.py.jinja +++ b/src/mapify_cli/templates_src/map/scripts/map_orchestrator.py.jinja @@ -261,6 +261,24 @@ WAVE_REASON_GATE_NOT_PARALLELIZABLE = "gate_not_parallelizable" # Current wave is width-1 even though a later wave is parallel; dispatch sequentially # for this wave — not an error, just the natural plan structure. WAVE_REASON_CURRENT_WAVE_SEQUENTIAL = "current_wave_sequential" +# Kill-switch reason: MAP_EFFICIENT_SEQUENTIAL_ONLY=1 forces the full legacy sequential path. +WAVE_REASON_SEQUENTIAL_ONLY_ENV = "sequential_only_env" + +# Truthy string values for MAP_EFFICIENT_SEQUENTIAL_ONLY env kill-switch. +_SEQUENTIAL_ONLY_TRUTHY = frozenset({"1", "true", "yes", "y", "on"}) + + +def _sequential_only_env() -> bool: + """Return True when MAP_EFFICIENT_SEQUENTIAL_ONLY is set to a truthy value. + + Truthy: {"1", "true", "yes", "y", "on"} (case-insensitive). + When True, forces the full legacy sequential path regardless of config — + no wave-loop, no worktrees, no concurrent dispatch. This is the global + kill-switch / off-ramp introduced in Slice 6 (byte-identical to pre-5a legacy). + Never raises. + """ + val = os.environ.get("MAP_EFFICIENT_SEQUENTIAL_ONLY", "") + return val.strip().lower() in _SEQUENTIAL_ONLY_TRUTHY class DispatchGateError(RuntimeError): @@ -2498,10 +2516,14 @@ def select_execution_strategy( """Determine whether to use wave_loop or legacy sequential walker. Predicate: wave_loop IFF wave_mode in {on, auto} AND worktree.isolation != 'off' - AND any color-group has width >= 2. This mirrors the canonical MapConfig gating - (#305): execution_wave_mode defaults to 'auto' but the wave-loop stays dormant - because worktree.isolation defaults to 'off', so default config always returns - 'sequential' and the legacy get_next_step path is byte-identical (HC-1). + AND any color-group has width >= 2. + + Slice 6: worktree.isolation defaults to 'auto' and concurrent_dispatch defaults + to True, so a parallel-ready plan now selects the wave-loop by default. + + Kill-switch: MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (checked FIRST) forces the full + legacy sequential path regardless of config — byte-identical to pre-5a legacy. + Per-repo opt-out: set `worktree.isolation: off` in .map/config.yaml. Args: branch: Git branch name (sanitized) @@ -2515,11 +2537,23 @@ def select_execution_strategy( "worktree_isolation": "off" | "auto" | "required", "has_parallel_groups": bool, "reason": str, + "concurrency_allowed": bool, } """ if project_dir is None: project_dir = Path(".") + # Kill-switch: MAP_EFFICIENT_SEQUENTIAL_ONLY=1 forces legacy sequential regardless of config. + if _sequential_only_env(): + return { + "strategy": "sequential", + "wave_mode": "off", + "worktree_isolation": "off", + "has_parallel_groups": False, + "reason": WAVE_REASON_SEQUENTIAL_ONLY_ENV, + "concurrency_allowed": False, + } + try: from map_step_runner import ( # pyright: ignore[reportMissingImports] _execution_wave_mode, @@ -2607,18 +2641,26 @@ def compute_dispatch_gate( if project_dir is None: project_dir = Path(".") - # Step 1: short-circuit on flag=false — first gate check after parameter - # normalization. No concurrency probe, no select_execution_strategy call, no - # _worktree_isolation_mode/concurrency_ready call, no dispatcher import runs on - # this path (HC-1 byte-identity). The project_dir None-guard above is a safe - # default-arg normalization, not a concurrency primitive. + # Kill-switch FIRST: MAP_EFFICIENT_SEQUENTIAL_ONLY=1 forces legacy sequential path. + # No concurrency probe, no config read, no import of any concurrency primitive. + if _sequential_only_env(): + return { + "dispatch_mode": "sequential", + "reason": WAVE_REASON_SEQUENTIAL_ONLY_ENV, + } + + # Step 1: short-circuit on flag=false — first gate check after kill-switch and + # parameter normalization. No concurrency probe, no select_execution_strategy call, + # no _worktree_isolation_mode/concurrency_ready call, no dispatcher import runs on + # this path. The project_dir None-guard above is a safe default-arg normalization, + # not a concurrency primitive. try: from map_step_runner import ( # pyright: ignore[reportMissingImports] _concurrent_dispatch_enabled, ) flag_on = _concurrent_dispatch_enabled(project_dir) except ImportError: - flag_on = False + flag_on = True # default ON (Slice 6) when runner unavailable if not flag_on: return { diff --git a/src/mapify_cli/templates_src/map/scripts/map_step_runner.py.jinja b/src/mapify_cli/templates_src/map/scripts/map_step_runner.py.jinja index a7d3fe4f..4ae040c5 100755 --- a/src/mapify_cli/templates_src/map/scripts/map_step_runner.py.jinja +++ b/src/mapify_cli/templates_src/map/scripts/map_step_runner.py.jinja @@ -153,13 +153,14 @@ _CONCURRENT_DISPATCH_TRUTHY = frozenset({"true", "yes", "y", "1", "on"}) def _concurrent_dispatch_enabled(project_dir: Path) -> bool: - """Return True when execution.concurrent_dispatch is explicitly enabled. + """Return True when execution.concurrent_dispatch is enabled (default ON, Slice 6). - Mirrors the _wt_isolation_enabled pattern. Default is False (off) so the - sequential path stays byte-identical to Slice 5a by default (HC-1). Never - raises. + Default is True — Slice 6 flipped from False. Disable via + MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (global kill-switch) or set + `execution.concurrent_dispatch: false` in .map/config.yaml. + Mirrors the canonical MapConfig default (config/project_config.py). Never raises. """ - raw = _map_config_str(project_dir, "execution.concurrent_dispatch", "false") + raw = _map_config_str(project_dir, "execution.concurrent_dispatch", "true") return raw.strip().lower() in _CONCURRENT_DISPATCH_TRUTHY @@ -167,10 +168,11 @@ def _execution_wave_mode(project_dir: Path) -> str: """Return the execution.wave_mode setting: 'off' | 'auto' | 'on'. Default + enum mirror the canonical MapConfig schema (config/project_config.py): - absent/unknown/garbage normalises to 'auto'. 'auto' is still behavior-neutral - by default because the wave-loop only engages when worktree.isolation != 'off' - (which itself defaults to 'off') AND a color group has >=2 members — see - select_execution_strategy. Never raises. + absent/unknown/garbage normalises to 'auto'. As of the Slice 6 flip, + worktree.isolation also defaults to 'auto', so the wave-loop IS engaged by + default for a parallel-ready plan (it engages when worktree.isolation != 'off' + AND a color group has >=2 members) — see select_execution_strategy. Disable + via MAP_EFFICIENT_SEQUENTIAL_ONLY=1 or the per-repo opt-out keys. Never raises. """ raw = _map_config_str(project_dir, "execution.wave_mode", "auto") return raw if raw in _WAVE_MODE_VALID else "auto" @@ -15381,6 +15383,8 @@ _WT_GROUP_VALID_EVENTS: frozenset[str] = frozenset({ }) _WT_ISOLATION_VALID = frozenset({"off", "auto", "required"}) +# Legacy YAML booleans that map to 'off' (explicit per-repo disable) +_WT_ISOLATION_FALSY = frozenset({"false", "0", "no", "n"}) def _worktree_isolation_mode(project_dir: Path) -> str: @@ -15388,18 +15392,28 @@ def _worktree_isolation_mode(project_dir: Path) -> str: Accepts the new enum strings directly (case-insensitive). Legacy boolean compat: boolish-truthy (true/1/yes) → 'required'; - boolish-false (false/0/no/'') → 'off'. - Absent key → 'off' (today's behavior). Any unknown/garbage → 'off'. + boolish-false (false/0/no) → 'off'. + Absent key → 'auto' (default ON, Slice 6). Any unknown/garbage → 'auto'. + Disable via MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (global kill-switch) or set + `worktree.isolation: off` in .map/config.yaml. + Mirrors the canonical MapConfig default (config/project_config.py). Never raises. """ raw = _map_config_str(project_dir, "worktree.isolation", "") normalized = raw.strip().lower() if normalized in _WT_ISOLATION_VALID: return normalized - # Legacy boolean compat + # Legacy boolean compat: truthy → 'required' if _parse_boolish(normalized): return "required" - return "off" + # Legacy boolean compat: explicit falsy (false/0/no/n) → 'off' (per-repo disable) + if normalized in _WT_ISOLATION_FALSY: + return "off" + # Absent key → default "auto" (Slice 6 flip from "off") + if not normalized: + return "auto" + # Unknown/garbage → safe default 'auto' (degrade gracefully) + return "auto" def _wt_isolation_enabled(project_dir: Path) -> bool: @@ -15408,10 +15422,9 @@ def _wt_isolation_enabled(project_dir: Path) -> bool: Handles the enum migration (#303): the old boolean ``false``/``true`` raw strings (YAML 1.1 booleans are written as ``false``/``true`` when read line-by-line) still work. New enum values: - - ``off`` -> False (disabled — default) - - ``auto`` -> False (Slice 0: sequential everywhere; parallel dispatch - lands in Slice 5; the call site in create_subtask_worktree - already degrades to sequential when this returns False) + - ``off`` -> False (disabled) + - ``auto`` -> True (default ON, Slice 6; degrades gracefully when git + worktrees are unavailable) - ``required`` -> True (hard-fail on unavailability, same as old ``true``) Canonical enum vocabulary + default live in MapConfig @@ -15419,8 +15432,8 @@ def _wt_isolation_enabled(project_dir: Path) -> bool: same `worktree.isolation` key for probe/fallback paths that need the full enum value (auto vs required), not just the enabled boolean. """ - val = _map_config_str(project_dir, "worktree.isolation", "off").strip().lower() - return val in {"required", "true", "yes", "y", "1", "on"} + mode = _worktree_isolation_mode(project_dir) + return mode in {"auto", "required", "true", "yes", "y", "1", "on"} def _wt_max_deletions(project_dir: Path) -> int: @@ -15544,8 +15557,9 @@ _WORKTREE_PROBE_CACHE: dict[str, dict[str, object]] = {} def _worktree_probe(project_dir: Path) -> dict[str, object]: """Safe detached worktree probe. Cached per session keyed on toplevel. - HC-1 dormancy contract: when worktree.isolation is 'off' (the default), - this function returns immediately WITHOUT running any git command. + HC-1 dormancy contract: when worktree.isolation is 'off' (the per-repo + opt-out or MAP_EFFICIENT_SEQUENTIAL_ONLY off-ramp), this function returns + immediately WITHOUT running any git command. When mode is 'auto' or 'required': * Resolves the storage root via _wt_storage_root() (never .map/worktrees/). @@ -15626,7 +15640,7 @@ def _worktree_probe(project_dir: Path) -> dict[str, object]: def _require_clean_merge_target(project_dir: Path) -> dict[str, object]: """Check that the main checkout is clean enough for a worktree merge. - Dormant when worktree.isolation is 'off' (default — HC-1). + Dormant when worktree.isolation is 'off' (per-repo opt-out / kill-switch — HC-1). When the check is active AND require_clean_merge_target config is True (default True), runs `git status --porcelain` and returns: * {"status":"ok","ok":True} when clean (excluding MAP runtime state) @@ -15671,8 +15685,8 @@ def _require_clean_merge_target(project_dir: Path) -> dict[str, object]: def resolve_worktree_isolation(project_dir: Path) -> dict[str, object]: """Classify the current environment and decide the execution decision. - HC-1 dormancy: when isolation is 'off' (the default), returns immediately - without running any git command. + HC-1 dormancy: when isolation is 'off' (per-repo opt-out / kill-switch), + returns immediately without running any git command. Return schema ------------- @@ -17460,10 +17474,13 @@ def run_concurrent_wave( spawn actor agents — the skill emits N Task blocks to start actors (ST-007). Its responsibilities are: - 1. **Default-off guard** (HC-1, defense-in-depth): if ``concurrent_dispatch`` - is not explicitly enabled in config, return ``CONCURRENT_DISPATCH_DISABLED`` - immediately so a direct CLI or coordinator call cannot trigger concurrent - merging under the default-off configuration. + 1. **Disabled-dispatch guard** (defense-in-depth): if ``concurrent_dispatch`` + is disabled — via the per-repo opt-out (``execution.concurrent_dispatch: + false``) or the ``MAP_EFFICIENT_SEQUENTIAL_ONLY`` kill-switch — return + ``CONCURRENT_DISPATCH_DISABLED`` immediately so a direct CLI or coordinator + call cannot trigger concurrent merging when the operator has opted out. + (As of the Slice 6 flip the default is enabled; this guard catches the + explicit off-ramps.) 2. **Batch-split**: read ``max_actors`` from config (via ``_max_actors()``), clamp to [1, 8], then split the sorted ``group_ids`` into sequential @@ -17531,14 +17548,18 @@ def run_concurrent_wave( pd = project_dir or _wt_project_dir() or Path(".") branch_name = branch or get_branch_name() - # F6: Default-off guard (HC-1, defense-in-depth). A direct CLI call or - # coordinator misconfiguration cannot trigger concurrent merging unless the - # flag is explicitly set. This is a second line of defense after compute_dispatch_gate. + # F6: Disabled-dispatch guard (defense-in-depth). A direct CLI call or + # coordinator misconfiguration cannot trigger concurrent merging when the + # operator has opted out. Second line of defense after compute_dispatch_gate + # (which also honors the MAP_EFFICIENT_SEQUENTIAL_ONLY kill-switch upstream). if not _concurrent_dispatch_enabled(pd): return _wt_error( "CONCURRENT_DISPATCH_DISABLED", - "run_concurrent_wave requires execution.concurrent_dispatch=true in config; " - "default is off (HC-1). Set the flag explicitly to enable concurrent dispatch.", + "run_concurrent_wave: concurrent dispatch is disabled by this repo's " + "config (execution.concurrent_dispatch: false). It defaults to enabled " + "since the Slice 6 flip — remove that key or set it to true to enable " + "concurrent dispatch (or it may be the MAP_EFFICIENT_SEQUENTIAL_ONLY " + "kill-switch, which forces sequential everywhere).", ) # Deterministic sorted list — order-of-call must not vary group membership. diff --git a/tests/test_map_orchestrator.py b/tests/test_map_orchestrator.py index f6e46fbc..96627bfa 100644 --- a/tests/test_map_orchestrator.py +++ b/tests/test_map_orchestrator.py @@ -4597,21 +4597,25 @@ def _write_step_state( def test_wave_loop_sequential_no_concurrency( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """VC1 [HC-2]: get_wave_step returns concurrency_enabled=False even for width>=2 wave.""" + """VC1 [HC-2]: get_wave_step returns concurrency_enabled=False for width>=2 wave + when the kill-switch MAP_EFFICIENT_SEQUENTIAL_ONLY=1 is engaged. + + Slice 6: the default is now ON (concurrent). Re-pointed to the kill-switch off-ramp + to prove the byte-identical-to-legacy contract still holds under the kill-switch. + """ monkeypatch.chdir(tmp_path) + monkeypatch.setenv("MAP_EFFICIENT_SEQUENTIAL_ONLY", "1") branch = "test-st010-vc1" - # Two subtasks in one wave → mode="parallel" but concurrency_enabled must be False. + # Two subtasks in one wave → concurrency_enabled=False under kill-switch. _write_step_state(branch, tmp_path, execution_waves=[["ST-001", "ST-002"]]) result = map_orchestrator.get_wave_step(branch) assert result["is_complete"] is False - assert result["mode"] == "parallel", f"expected parallel mode for width-2 wave: {result}" assert result["concurrency_enabled"] is False, ( - f"concurrency_enabled must be False in Slice 3 (no concurrent dispatch): {result}" + f"concurrency_enabled must be False under MAP_EFFICIENT_SEQUENTIAL_ONLY=1: {result}" ) - # One-subtask-at-a-time dispatch is the contract: caller iterates subtasks[] - # sequentially; concurrency_enabled=False is the explicit signal. + # Both subtasks still listed in the result; the dispatch mode is sequential. assert len(result["subtasks"]) == 2 # both listed; dispatcher iterates one at a time @@ -4842,21 +4846,24 @@ def _iso(_project_dir: object) -> str: _sys.modules.pop("map_step_runner", None) -def test_vc2_default_config_sequential( +def test_vc2_default_config_concurrent( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """VC2 [AC-10]: default config (no worktree.isolation key) gives strategy=='sequential' - AND concurrency_allowed==False AND pre-existing reason string is unchanged. + """VC2 [AC-10] (reframed Slice 6): default config (wave_mode=auto, isolation=auto) + with a parallel-ready plan now gives strategy=='wave_loop' AND concurrency_allowed==True. + + The old premise (isolation defaults to 'off' → always sequential) is gone in Slice 6. + New proof: default config → concurrent for a parallel-ready plan. """ import sys as _sys import types monkeypatch.chdir(tmp_path) - branch = "test-vc2-default-sequential" - # Seed a width>=2 wave so has_parallel_groups=True — isolation gate must block wave_loop. + branch = "test-vc2-default-concurrent" + # Seed a width>=2 wave so has_parallel_groups=True. _write_step_state(branch, tmp_path, execution_waves=[["ST-001", "ST-002"]]) - def _fake_runner_default(wave_mode: str, isolation: str) -> "types.ModuleType": + def _fake_runner_slice6(wave_mode: str, isolation: str) -> "types.ModuleType": mod = types.ModuleType("map_step_runner") def _wm(_project_dir: object) -> str: @@ -4873,22 +4880,80 @@ def _iso(_project_dir: object) -> str: _orig_msr = _sys.modules.get("map_step_runner") try: - # Default: wave_mode=auto (MapConfig default), isolation=off (MapConfig default). - _sys.modules["map_step_runner"] = _fake_runner_default("auto", "off") + # Slice 6 defaults: wave_mode=auto, isolation=auto (!=off) → wave_loop for parallel plan. + _sys.modules["map_step_runner"] = _fake_runner_slice6("auto", "auto") + result = map_orchestrator.select_execution_strategy(branch, tmp_path) + + assert result["strategy"] == "wave_loop", ( + f"Slice 6 default config + parallel plan must give strategy='wave_loop': {result}" + ) + assert result["concurrency_allowed"] is True, ( + f"Slice 6 default config + parallel plan must give concurrency_allowed=True: {result}" + ) + finally: + if _orig_msr is not None: + _sys.modules["map_step_runner"] = _orig_msr + else: + _sys.modules.pop("map_step_runner", None) + + +def test_vc2_kill_switch_forces_sequential( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """VC2 kill-switch [AC-10]: MAP_EFFICIENT_SEQUENTIAL_ONLY=1 forces sequential + regardless of config — the byte-identical-to-legacy proof now applies to the + kill-switch path, not the default config path. + + This re-points the old 'default config → sequential' contract to the kill-switch. + """ + import sys as _sys + import types + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("MAP_EFFICIENT_SEQUENTIAL_ONLY", "1") + branch = "test-vc2-kill-switch" + # Seed a parallel-ready plan — kill-switch must block it. + _write_step_state(branch, tmp_path, execution_waves=[["ST-001", "ST-002"]]) + + def _fake_runner_parallel(wave_mode: str, isolation: str) -> "types.ModuleType": + mod = types.ModuleType("map_step_runner") + + def _wm(_project_dir: object) -> str: + del _project_dir + return wave_mode + + def _iso(_project_dir: object) -> str: + del _project_dir + return isolation + + mod._execution_wave_mode = _wm # type: ignore[attr-defined] + mod._worktree_isolation_mode = _iso # type: ignore[attr-defined] + return mod + + _orig_msr = _sys.modules.get("map_step_runner") + try: + # Even with parallel-ready defaults (wave_mode=auto, isolation=auto), + # the kill-switch short-circuits to sequential before reading config. + _sys.modules["map_step_runner"] = _fake_runner_parallel("auto", "auto") result = map_orchestrator.select_execution_strategy(branch, tmp_path) assert result["strategy"] == "sequential", ( - f"default config must give strategy='sequential': {result}" + f"MAP_EFFICIENT_SEQUENTIAL_ONLY=1 must give strategy='sequential': {result}" ) assert result["concurrency_allowed"] is False, ( - f"default config must give concurrency_allowed=False: {result}" + f"MAP_EFFICIENT_SEQUENTIAL_ONLY=1 must give concurrency_allowed=False: {result}" ) - # Pre-existing reason string for the isolation-off path must be unchanged. - assert "worktree.isolation='off'" in result["reason"], ( - f"reason string for isolation=off path must contain worktree.isolation='off': {result['reason']!r}" + assert result["reason"] == map_orchestrator.WAVE_REASON_SEQUENTIAL_ONLY_ENV, ( + f"Kill-switch reason must be WAVE_REASON_SEQUENTIAL_ONLY_ENV, got {result['reason']!r}" ) - assert "legacy sequential" in result["reason"], ( - f"reason string for isolation=off path must contain 'legacy sequential': {result['reason']!r}" + + # Also verify compute_dispatch_gate short-circuits to sequential. + gate = map_orchestrator.compute_dispatch_gate(branch, tmp_path) + assert gate["dispatch_mode"] == "sequential", ( + f"compute_dispatch_gate must return sequential under kill-switch: {gate}" + ) + assert gate["reason"] == map_orchestrator.WAVE_REASON_SEQUENTIAL_ONLY_ENV, ( + f"compute_dispatch_gate reason must be WAVE_REASON_SEQUENTIAL_ONLY_ENV: {gate}" ) finally: if _orig_msr is not None: @@ -4952,27 +5017,44 @@ def test_vc1_get_wave_step_dispatch_mode_sequential( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """VC1: dispatch_mode=='sequential' and isolation_active/reason non-empty for both - width-1 and width>=2 active waves while WAVE_CONCURRENCY_ENABLED is False.""" + width-1 and width>=2 active waves. + + Slice 6: concurrent_dispatch defaults to True, so the fake runner also exposes + _concurrent_dispatch_enabled. The 'isolation=off' case uses concurrent_dispatch=False + to avoid the config-contradiction DispatchGateError (isolation=off + dispatch=True + is an error, not a sequential fallback — per-repo opt-out requires BOTH flags). + """ import types monkeypatch.chdir(tmp_path) - def _fake_runner(isolation: str) -> "types.ModuleType": + def _fake_runner(isolation: str, concurrent: bool = True) -> "types.ModuleType": mod = types.ModuleType("map_step_runner") def _iso(_project_dir: object) -> str: del _project_dir return isolation + def _dispatch(_project_dir: object) -> bool: + del _project_dir + return concurrent + + def _wm(_project_dir: object) -> str: + del _project_dir + return "auto" + mod._worktree_isolation_mode = _iso # type: ignore[attr-defined] + mod._concurrent_dispatch_enabled = _dispatch # type: ignore[attr-defined] + mod._execution_wave_mode = _wm # type: ignore[attr-defined] return mod import sys as _sys _orig_msr = _sys.modules.get("map_step_runner") try: - # width-1 wave, isolation=required → isolation_active=True - _sys.modules["map_step_runner"] = _fake_runner("required") + # width-1 wave, isolation=required, dispatch=True → dispatch_mode=sequential + # (width-1 wave → WAVE_REASON_CURRENT_WAVE_SEQUENTIAL) + _sys.modules["map_step_runner"] = _fake_runner("required", concurrent=True) branch1 = "test-st002-vc1-width1" _write_step_state(branch1, tmp_path, execution_waves=[["ST-001"]]) result1 = map_orchestrator.get_wave_step(branch1) @@ -4986,8 +5068,10 @@ def _iso(_project_dir: object) -> str: assert result1["reason"], f"width-1 wave: reason must be non-empty: {result1}" assert result1["is_complete"] is False - # width-2 wave, isolation=off → isolation_active=False - _sys.modules["map_step_runner"] = _fake_runner("off") + # width-2 wave, isolation=off, dispatch=False (per-repo opt-out) → isolation_active=False + # Note: isolation=off + dispatch=True would raise DispatchGateError (config contradiction). + # Per-repo opt-out: disable dispatch to get sequential without the error. + _sys.modules["map_step_runner"] = _fake_runner("off", concurrent=False) branch2 = "test-st002-vc1-width2" _write_step_state(branch2, tmp_path, execution_waves=[["ST-001", "ST-002"]]) result2 = map_orchestrator.get_wave_step(branch2) @@ -5007,15 +5091,21 @@ def _iso(_project_dir: object) -> str: _sys.modules["map_step_runner"] = _orig_msr -def test_vc2_dispatch_mode_never_concurrent_in_5a( +def test_vc2_dispatch_mode_never_concurrent_on_kill_switch( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """VC2: dispatch_mode is 'sequential' (never 'concurrent') on every return path - while WAVE_CONCURRENCY_ENABLED is False.""" + """VC2 (reframed Slice 6): dispatch_mode is 'sequential' on every return path + when MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (kill-switch engaged). + + The old premise 'WAVE_CONCURRENCY_ENABLED is False → always sequential' no longer + holds for the default config in Slice 6 (defaults are ON). Re-pointed to the + kill-switch to prove the byte-identical-to-legacy contract. + """ monkeypatch.chdir(tmp_path) + monkeypatch.setenv("MAP_EFFICIENT_SEQUENTIAL_ONLY", "1") branch = "test-st002-vc2" - # Path 1: no waves → no_waves early return + # Path 1: no waves → no_waves early return (kill-switch is redundant here but consistent) _write_step_state(branch, tmp_path, execution_waves=[]) r_no_waves = map_orchestrator.get_wave_step(branch) assert r_no_waves["dispatch_mode"] == "sequential", ( @@ -5040,14 +5130,14 @@ def test_vc2_dispatch_mode_never_concurrent_in_5a( f"wave-complete path: concurrency_enabled alias must be False: {r_complete}" ) - # Path 3: active wave (width>=2) → main return + # Path 3: active wave (width>=2) → kill-switch forces sequential _write_step_state(branch, tmp_path, execution_waves=[["ST-001", "ST-002"]]) r_active = map_orchestrator.get_wave_step(branch) assert r_active["dispatch_mode"] == "sequential", ( - f"active-wave path: dispatch_mode must be 'sequential': {r_active}" + f"active-wave path: dispatch_mode must be 'sequential' under kill-switch: {r_active}" ) assert r_active["concurrency_enabled"] is False, ( - f"active-wave path: concurrency_enabled alias must be False: {r_active}" + f"active-wave path: concurrency_enabled alias must be False under kill-switch: {r_active}" ) @@ -5078,19 +5168,23 @@ def test_vc3_get_wave_step_reason_codes( f"{r_complete}" ) - # Path 3: active wave + # Path 3: active wave (width=1 → single subtask, not parallelizable) _write_step_state(branch, tmp_path, execution_waves=[["ST-001"]]) r_active = map_orchestrator.get_wave_step(branch) - assert r_active["reason"] == map_orchestrator.WAVE_REASON_DISPATCH_SEQUENTIAL, ( - f"active-wave path: expected reason={map_orchestrator.WAVE_REASON_DISPATCH_SEQUENTIAL!r}: " - f"{r_active}" + # Slice 6: with defaults ON, a width-1 wave returns gate_not_parallelizable + # (single task cannot form parallel groups); WAVE_REASON_DISPATCH_SEQUENTIAL is + # now only emitted on the legacy path (pre-dispatch gate, reached when worktree + # isolation is explicitly off or the dispatch gate short-circuits before color-grouping). + assert r_active["reason"] == map_orchestrator.WAVE_REASON_GATE_NOT_PARALLELIZABLE, ( + f"active-wave path (width=1): expected reason=" + f"{map_orchestrator.WAVE_REASON_GATE_NOT_PARALLELIZABLE!r}: {r_active}" ) # All three codes must be distinct stable strings codes = { map_orchestrator.WAVE_REASON_NO_WAVES, map_orchestrator.WAVE_REASON_WAVE_COMPLETE, - map_orchestrator.WAVE_REASON_DISPATCH_SEQUENTIAL, + map_orchestrator.WAVE_REASON_GATE_NOT_PARALLELIZABLE, } assert len(codes) == 3, f"reason codes must all be distinct: {codes}" @@ -5109,27 +5203,27 @@ def test_vc3_get_wave_step_reason_codes( def test_vc1_default_config_neutral_strategy( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """HC-1 proof: under default config (no .map/config.yaml), select_execution_strategy - returns strategy=='sequential', concurrency_allowed==False, and - worktree_isolation=='off' — even when a parallelizable (width>=2) wave exists. - - This is non-tautological: the width>=2 wave makes has_parallel_groups=True, so - the isolation gate is the only thing keeping the result sequential. The test - exercises the real _execution_wave_mode + _worktree_isolation_mode functions - against a real (empty) project dir — no mocks. + """HC-1 proof (Slice 6 reframe): with MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (kill-switch), + select_execution_strategy returns strategy=='sequential', concurrency_allowed==False — + even when a parallelizable (width>=2) wave exists and the config defaults are ON. + + This replaces the old 'default isolation=off keeps sequential' proof. Slice 6 + flipped both defaults to ON (wave_mode='auto', isolation='auto'), so the old test + was asserting a premise that no longer holds. The kill-switch is the new + byte-identical-to-legacy off-ramp and is non-tautological: the width>=2 wave + makes has_parallel_groups=True (WOULD engage wave_loop without the kill-switch). """ import sys as _sys monkeypatch.chdir(tmp_path) - branch = "test-st007-vc1-default-neutral" + monkeypatch.setenv("MAP_EFFICIENT_SEQUENTIAL_ONLY", "1") + branch = "test-st007-vc1-killswitch" # Seed a width>=2 execution wave — has_parallel_groups will be True. - # Without the isolation gate, this WOULD select strategy='wave_loop'. + # Without the kill-switch, Slice 6 defaults WOULD select strategy='wave_loop'. _write_step_state(branch, tmp_path, execution_waves=[["ST-001", "ST-002"]]) - # No .map/config.yaml — real defaults apply: wave_mode='auto', isolation='off'. - # Evict any cached map_step_runner so the function does a fresh import with - # the real module reading from tmp_path (monkeypatched cwd). + # No .map/config.yaml — defaults are ON ('auto'/'auto'), but kill-switch fires first. _orig_msr = _sys.modules.pop("map_step_runner", None) try: result = map_orchestrator.select_execution_strategy(branch, tmp_path) @@ -5138,40 +5232,36 @@ def test_vc1_default_config_neutral_strategy( _sys.modules["map_step_runner"] = _orig_msr assert result["strategy"] == "sequential", ( - f"default config (no config.yaml) must give strategy='sequential': {result}" + f"kill-switch must give strategy='sequential': {result}" ) assert result["concurrency_allowed"] is False, ( - f"default config must give concurrency_allowed=False: {result}" + f"kill-switch must give concurrency_allowed=False: {result}" ) - assert result["worktree_isolation"] == "off", ( - f"default config must give worktree_isolation='off': {result}" - ) - # Confirm the isolation gate is what blocked wave_loop — not missing waves. - assert result["has_parallel_groups"] is True, ( - "has_parallel_groups must be True (width>=2 wave seeded) to prove the " - "isolation gate is what keeps the strategy sequential, not absence of a " - "parallelizable plan" + assert result["reason"] == map_orchestrator.WAVE_REASON_SEQUENTIAL_ONLY_ENV, ( + f"kill-switch must return WAVE_REASON_SEQUENTIAL_ONLY_ENV reason: {result}" ) def test_vc2_default_config_neutral_dispatch( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """HC-1 proof: under default config, get_wave_step returns dispatch_mode=='sequential' - and isolation_active==False, and create_subtask_worktree returns status=='disabled' - (no worktree created). + """HC-1 proof (Slice 6 reframe): with MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (kill-switch), + get_wave_step returns dispatch_mode=='sequential' and concurrency_enabled==False. - Width>=2 wave is seeded to prove the dispatch stays sequential regardless. + The old premise ('default isolation=off → dispatch sequential') no longer holds in + Slice 6 (defaults are ON). Re-pointed to the kill-switch path. Width>=2 wave seeded + to prove the gate stays sequential DESPITE a parallelizable plan. """ import sys as _sys monkeypatch.chdir(tmp_path) - branch = "test-st007-vc2-default-neutral" + monkeypatch.setenv("MAP_EFFICIENT_SEQUENTIAL_ONLY", "1") + branch = "test-st007-vc2-killswitch" - # Seed a width>=2 wave — concurrently dispatchable IF isolation were on. + # Seed a width>=2 wave — WOULD be concurrently dispatched if kill-switch were off. _write_step_state(branch, tmp_path, execution_waves=[["ST-001", "ST-002"]]) - # No config.yaml — real isolation defaults to 'off'. + # Kill-switch fires before config/isolation is even read. _orig_msr = _sys.modules.pop("map_step_runner", None) try: result = map_orchestrator.get_wave_step(branch) @@ -5180,17 +5270,22 @@ def test_vc2_default_config_neutral_dispatch( _sys.modules["map_step_runner"] = _orig_msr assert result["dispatch_mode"] == "sequential", ( - f"default config: dispatch_mode must be 'sequential': {result}" - ) - assert result["isolation_active"] is False, ( - f"default config: isolation_active must be False (isolation='off'): {result}" + f"kill-switch: dispatch_mode must be 'sequential': {result}" ) assert result["concurrency_enabled"] is False, ( - f"default config: concurrency_enabled alias must be False: {result}" + f"kill-switch: concurrency_enabled alias must be False: {result}" ) + assert result["reason"] == map_orchestrator.WAVE_REASON_SEQUENTIAL_ONLY_ENV, ( + f"kill-switch: reason must be WAVE_REASON_SEQUENTIAL_ONLY_ENV: {result}" + ) + + # create_subtask_worktree with per-repo isolation=off must still no-op. + # This covers the per-repo opt-out path (separate from kill-switch). + # Write a config with worktree.isolation: off explicitly. + map_dir = tmp_path / ".map" + map_dir.mkdir(parents=True, exist_ok=True) + (map_dir / "config.yaml").write_text("worktree.isolation: off\n", encoding="utf-8") - # create_subtask_worktree must no-op (status='disabled') under default config. - # The real _wt_isolation_enabled reads from cwd/.map/config.yaml (absent here). _orig_msr2 = _sys.modules.pop("map_step_runner", None) try: import map_step_runner as _msr # noqa: E402 # pyright: ignore[reportMissingImports] @@ -5201,24 +5296,23 @@ def test_vc2_default_config_neutral_dispatch( _sys.modules["map_step_runner"] = _orig_msr2 assert wt_result["status"] == "disabled", ( - f"default config: create_subtask_worktree must return status='disabled': {wt_result}" + f"isolation=off: create_subtask_worktree must return status='disabled': {wt_result}" ) assert wt_result["ok"] is False, ( - f"default config: create_subtask_worktree must return ok=False: {wt_result}" + f"isolation=off: create_subtask_worktree must return ok=False: {wt_result}" ) def test_vc3_dormant_keys_do_not_flip_strategy( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """HC-1 proof: setting ONLY the dormant Slice-5a keys (execution.max_actors and - execution.retry_degraded_once) in config does NOT change strategy away from - sequential — strategy remains 'sequential' and concurrency_allowed remains False. - - Proves that the dormant fields parsed by ST-005 have no effect on the execution - path in Slice 5a: they are parsed and validated, but no code reads them yet. - Width>=2 wave is seeded so the isolation gate (not missing parallelizable work) - is the only thing keeping the dispatch sequential. + """HC-1 proof (Slice 6 reframe): dormant Slice-5a keys (execution.max_actors, + execution.retry_degraded_once) alone do NOT override the per-repo opt-out: + with worktree.isolation=off set explicitly, strategy remains 'sequential'. + + Proves that the dormant fields have no effect on the execution path. + Width>=2 wave seeded; the isolation=off config is the only thing keeping + dispatch sequential (non-tautological — would engage wave_loop if isolation=auto). """ import sys as _sys @@ -5228,13 +5322,14 @@ def test_vc3_dormant_keys_do_not_flip_strategy( # Seed a width>=2 wave — parallelizable plan present. _write_step_state(branch, tmp_path, execution_waves=[["ST-001", "ST-002"]]) - # Write config with ONLY the dormant ST-005 keys — no wave_mode, no worktree.isolation. - # Real MapConfig defaults for the missing keys: wave_mode='auto', isolation='off'. + # Config: dormant ST-005 keys + explicit worktree.isolation=off (per-repo opt-out). + # Slice 6 defaults are ON; the explicit isolation=off override keeps dispatch sequential. map_dir = tmp_path / ".map" map_dir.mkdir(parents=True, exist_ok=True) (map_dir / "config.yaml").write_text( "execution.max_actors: 3\n" - "execution.retry_degraded_once: true\n", + "execution.retry_degraded_once: true\n" + "worktree.isolation: off\n", encoding="utf-8", ) @@ -5246,17 +5341,17 @@ def test_vc3_dormant_keys_do_not_flip_strategy( _sys.modules["map_step_runner"] = _orig_msr assert result["strategy"] == "sequential", ( - f"dormant-keys-only config must not flip strategy away from 'sequential': {result}" + f"per-repo isolation=off must keep strategy 'sequential': {result}" ) assert result["concurrency_allowed"] is False, ( - f"dormant-keys-only config must not flip concurrency_allowed to True: {result}" + f"per-repo isolation=off must keep concurrency_allowed=False: {result}" ) assert result["worktree_isolation"] == "off", ( - f"worktree_isolation must remain 'off' when worktree.isolation key is absent: {result}" + f"worktree_isolation must be 'off' (explicit per-repo override): {result}" ) assert result["has_parallel_groups"] is True, ( - "has_parallel_groups must be True so the isolation gate — not absent waves — " - "is what keeps the strategy sequential" + "has_parallel_groups must be True so isolation=off — not absent waves — " + "is what keeps the strategy sequential (non-tautological proof)" ) @@ -5309,33 +5404,46 @@ def test_vc1_concurrent_dispatch_on_isolation_off_raises( def test_vc2_flag_false_returns_sequential_no_side_effects( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """VC2 [AC-1-GATE] [HC-1]: concurrent_dispatch=false (default) → sequential on - FIRST line; no DispatchGateError, no concurrent result, no concurrency_ready probe.""" + """VC2 [AC-1-GATE] [HC-1] (Slice 6 reframe): per-repo concurrent_dispatch=false + and global kill-switch both force sequential with no DispatchGateError. + + Sub-test A: MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (kill-switch) → sequential. + Sub-test B: explicit false in config → sequential. + Sub-test C: get_wave_step under kill-switch stays sequential. + """ import sys as _sys monkeypatch.chdir(tmp_path) - branch = "test-st001-vc2-default-sequential" + branch = "test-st001-vc2-sequential" - # Width>=2 wave — would be parallelizable if isolation were on. + # Width>=2 wave — would be parallelizable without the sequential gates. _write_step_state(branch, tmp_path, execution_waves=[["ST-001", "ST-002"]]) - # Sub-test A: no config at all (flag defaults to false). + # Sub-test A: kill-switch → sequential (no config needed). + monkeypatch.setenv("MAP_EFFICIENT_SEQUENTIAL_ONLY", "1") _orig_msr = _sys.modules.pop("map_step_runner", None) try: result_a = map_orchestrator.compute_dispatch_gate(branch, tmp_path) finally: if _orig_msr is not None: _sys.modules["map_step_runner"] = _orig_msr + monkeypatch.delenv("MAP_EFFICIENT_SEQUENTIAL_ONLY") assert result_a["dispatch_mode"] == "sequential", ( - f"No config → dispatch_mode must be 'sequential': {result_a}" + f"Kill-switch → dispatch_mode must be 'sequential': {result_a}" ) - assert result_a["reason"] == map_orchestrator.WAVE_REASON_DISPATCH_SEQUENTIAL, ( - f"No config → reason must be WAVE_REASON_DISPATCH_SEQUENTIAL: {result_a}" + assert result_a["reason"] == map_orchestrator.WAVE_REASON_SEQUENTIAL_ONLY_ENV, ( + f"Kill-switch → reason must be WAVE_REASON_SEQUENTIAL_ONLY_ENV: {result_a}" ) - # Sub-test B: explicit false in config. - _write_config_keys(tmp_path, **{"execution.concurrent_dispatch": "false"}) + # Sub-test B: explicit false in config (per-repo opt-out) → sequential. + _write_config_keys( + tmp_path, + **{ + "execution.concurrent_dispatch": "false", + "worktree.isolation": "auto", # isolation=auto so the dispatch flag is the gate + }, + ) _orig_msr2 = _sys.modules.pop("map_step_runner", None) try: result_b = map_orchestrator.compute_dispatch_gate(branch, tmp_path) @@ -5350,20 +5458,22 @@ def test_vc2_flag_false_returns_sequential_no_side_effects( f"Explicit false → reason must be WAVE_REASON_DISPATCH_SEQUENTIAL: {result_b}" ) - # Sub-test C: get_wave_step under default config stays sequential and never raises. + # Sub-test C: get_wave_step under kill-switch stays sequential and never raises. _write_step_state(branch, tmp_path, execution_waves=[["ST-001", "ST-002"]]) + monkeypatch.setenv("MAP_EFFICIENT_SEQUENTIAL_ONLY", "1") _orig_msr3 = _sys.modules.pop("map_step_runner", None) try: wave_result = map_orchestrator.get_wave_step(branch) finally: if _orig_msr3 is not None: _sys.modules["map_step_runner"] = _orig_msr3 + monkeypatch.delenv("MAP_EFFICIENT_SEQUENTIAL_ONLY") assert wave_result["dispatch_mode"] == "sequential", ( - f"get_wave_step default → dispatch_mode must be 'sequential': {wave_result}" + f"get_wave_step kill-switch → dispatch_mode must be 'sequential': {wave_result}" ) assert wave_result["concurrency_enabled"] is False, ( - f"get_wave_step default → concurrency_enabled must be False: {wave_result}" + f"get_wave_step kill-switch → concurrency_enabled must be False: {wave_result}" ) diff --git a/tests/test_map_step_runner.py b/tests/test_map_step_runner.py index 0debafab..3fe85dfe 100644 --- a/tests/test_map_step_runner.py +++ b/tests/test_map_step_runner.py @@ -12195,17 +12195,17 @@ def test_wave_mode_enum_and_unknown_fallback(tmp_path: Path) -> None: def test_worktree_isolation_legacy_bool_mapping(tmp_path: Path) -> None: - """Legacy boolean literals map correctly; absent key defaults to 'off'.""" + """Legacy boolean literals map correctly; absent key defaults to 'auto' (Slice 6).""" map_dir = tmp_path / ".map" map_dir.mkdir() config = map_dir / "config.yaml" - # Absent key -> "off" + # Absent key → "auto" (Slice 6 default, was "off") config.write_text("some.other.key: value\n", encoding="utf-8") - assert map_step_runner._worktree_isolation_mode(tmp_path) == "off" - assert map_step_runner._wt_isolation_enabled(tmp_path) is False + assert map_step_runner._worktree_isolation_mode(tmp_path) == "auto" + assert map_step_runner._wt_isolation_enabled(tmp_path) is True # "auto" → enabled - # Legacy false -> "off" + # Legacy false -> "off" (explicit per-repo disable) config.write_text("worktree.isolation: false\n", encoding="utf-8") assert map_step_runner._worktree_isolation_mode(tmp_path) == "off" assert map_step_runner._wt_isolation_enabled(tmp_path) is False @@ -12226,11 +12226,12 @@ def test_worktree_isolation_legacy_bool_mapping(tmp_path: Path) -> None: def test_worktree_isolation_enum_and_disabled_check_parity(tmp_path: Path) -> None: - """New enum strings parse directly. Per the canonical MapConfig/#305 semantics, - _wt_isolation_enabled is True ONLY for 'required' (and legacy truthy): 'auto' - stays disabled in Slice 0 (parallel dispatch lands in Slice 5), 'off' disabled. - _worktree_isolation_mode still reports the full enum (off/auto/required) for the - probe/fallback paths.""" + """New enum strings parse directly. Slice 6 semantics: + - 'off' → disabled (False) + - 'auto' → enabled (True) — Slice 6 flip: auto is now ON + - 'required' → enabled (True, hard-fail on unavailability) + _worktree_isolation_mode reports the full enum (off/auto/required). + Unknown garbage → 'auto' (safe-degrade to ON in Slice 6).""" map_dir = tmp_path / ".map" map_dir.mkdir() config = map_dir / "config.yaml" @@ -12240,10 +12241,10 @@ def test_worktree_isolation_enum_and_disabled_check_parity(tmp_path: Path) -> No assert map_step_runner._worktree_isolation_mode(tmp_path) == "off" assert map_step_runner._wt_isolation_enabled(tmp_path) is False - # Enum: "auto" — mode is 'auto' but isolation NOT enabled until Slice 5. + # Enum: "auto" — Slice 6: 'auto' is now enabled (ON by default). config.write_text("worktree.isolation: auto\n", encoding="utf-8") assert map_step_runner._worktree_isolation_mode(tmp_path) == "auto" - assert map_step_runner._wt_isolation_enabled(tmp_path) is False + assert map_step_runner._wt_isolation_enabled(tmp_path) is True # Slice 6: auto → ON # Enum: "required" — enabled (hard-fail on unavailability, same as legacy true). config.write_text("worktree.isolation: required\n", encoding="utf-8") @@ -12254,10 +12255,10 @@ def test_worktree_isolation_enum_and_disabled_check_parity(tmp_path: Path) -> No config.write_text("worktree.isolation: AUTO\n", encoding="utf-8") assert map_step_runner._worktree_isolation_mode(tmp_path) == "auto" - # Unknown garbage -> "off" (disabled) + # Unknown garbage → "auto" (Slice 6: degrade gracefully to default ON) config.write_text("worktree.isolation: maybe\n", encoding="utf-8") - assert map_step_runner._worktree_isolation_mode(tmp_path) == "off" - assert map_step_runner._wt_isolation_enabled(tmp_path) is False + assert map_step_runner._worktree_isolation_mode(tmp_path) == "auto" + assert map_step_runner._wt_isolation_enabled(tmp_path) is True # auto → enabled # _lint_dependency_enforcement / _lint_auto_prune / _observability_parallelism_enabled @@ -12328,14 +12329,18 @@ def test_observability_toggle_defaults(tmp_path: Path) -> None: def test_probe_dormant_when_off(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """With isolation off (default), _worktree_probe returns dormant and runs NO git command. + """With isolation explicitly set to 'off', _worktree_probe returns dormant and runs NO git. Proves zero-git behavior by monkeypatching _wt_git to record calls and raise if invoked, then asserting that the call list is empty and status=="dormant". + + Slice 6: the old 'default → off' premise is gone (default is now 'auto'). The + test now uses an explicit 'worktree.isolation: off' config to model the per-repo + opt-out path. """ - # Ensure isolation is off (default — config absent) + # Explicit isolation=off (per-repo opt-out); Slice 6 default is 'auto' (ON) (tmp_path / ".map").mkdir() - (tmp_path / ".map" / "config.yaml").write_text("", encoding="utf-8") + (tmp_path / ".map" / "config.yaml").write_text("worktree.isolation: off\n", encoding="utf-8") # Monkeypatch _wt_git to track calls; any call = test failure calls: list[list[str]] = [] @@ -12779,7 +12784,11 @@ def _fake_force_remove(path: Path, branch_ref: str) -> None: _os.chdir(orig_cwd) # ----------------------------------------------------------------------- - # Dormant when isolation=off: zero git calls + # Dormant when isolation=off: zero git calls. + # Must be called from INSIDE repo dir so _worktree_isolation_mode reads + # the correct config.yaml (cleanup reads Path(".") for cwd-relative config). + # Slice 6: without chdir, orig_cwd has no config → default is now 'auto' (ON), + # so the dormancy check would NOT fire and _wt_git would be called. # ----------------------------------------------------------------------- _write_isolation_config(repo, "off") @@ -12792,7 +12801,12 @@ def _no_git(args: list[str], **_kw: object) -> object: monkeypatch.setattr(map_step_runner, "_wt_git", _no_git) - dormant_result = map_step_runner.cleanup_orphan_worktrees(branch) + try: + _os.chdir(repo) + dormant_result = map_step_runner.cleanup_orphan_worktrees(branch) + finally: + _os.chdir(orig_cwd) + assert dormant_result["status"] == "dormant", ( f"expected dormant when off; got {dormant_result}" ) @@ -13506,11 +13520,15 @@ def test_vc1_not_git_repo_returns_error( def test_vc1_f6_default_config_returns_concurrent_dispatch_disabled( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """F6/HC-1: default config (no concurrent_dispatch key) -> CONCURRENT_DISPATCH_DISABLED. + """F6/HC-1 (Slice 6 reframe): explicit per-repo concurrent_dispatch=false + → CONCURRENT_DISPATCH_DISABLED. Defense-in-depth: a direct CLI or coordinator call cannot trigger concurrent - merging under the default-off configuration even when inside a git repo. - This is the second line of defense after compute_dispatch_gate. + merging when per-repo opt-out is active. This is the second line of defense + after compute_dispatch_gate. + + Slice 6: the old 'no config → disabled' premise is gone (default is now True). + Re-pointed to the explicit 'execution.concurrent_dispatch: false' per-repo opt-out. """ repo = tmp_path / "repo" repo.mkdir() @@ -13519,14 +13537,19 @@ def test_vc1_f6_default_config_returns_concurrent_dispatch_disabled( monkeypatch.chdir(repo) monkeypatch.setattr(map_step_runner, "get_branch_name", lambda: "test-branch") - # No config.yaml at all — concurrent_dispatch defaults to False. + # Explicit per-repo opt-out: concurrent_dispatch=false. + (repo / ".map").mkdir(exist_ok=True) + (repo / ".map" / "config.yaml").write_text( + "execution.concurrent_dispatch: false\n", encoding="utf-8" + ) + result = map_step_runner.run_concurrent_wave( ["ST-001", "ST-002"], "test-branch", repo ) assert result.get("ok") is not True assert result.get("status") == "error" assert result.get("kind") == "CONCURRENT_DISPATCH_DISABLED", ( - f"Expected CONCURRENT_DISPATCH_DISABLED under default config; " + f"Expected CONCURRENT_DISPATCH_DISABLED with per-repo opt-out; " f"got kind={result.get('kind')!r}" ) diff --git a/tests/test_project_config.py b/tests/test_project_config.py index 372d9dd3..22e2f975 100644 --- a/tests/test_project_config.py +++ b/tests/test_project_config.py @@ -196,9 +196,10 @@ def test_vc4_concurrent_dispatch_true_from_yaml(self, tmp_path: Path): cfg = load_map_config(tmp_path) assert cfg.concurrent_dispatch is True - def test_vc4_concurrent_dispatch_default_false(self, tmp_path: Path): + def test_vc4_concurrent_dispatch_default_true(self, tmp_path: Path): + # Slice 6: default flipped from False to True. cfg = load_map_config(tmp_path) - assert cfg.concurrent_dispatch is False + assert cfg.concurrent_dispatch is True def test_vc4_concurrent_dispatch_false_from_yaml(self, tmp_path: Path): _write_config(tmp_path, "execution.concurrent_dispatch: false\n") @@ -382,3 +383,60 @@ def test_vc6_grep_subprocess_runner_consumer_max_wave_retries(self): "max_wave_retries not found in any runner/orchestrator Python source after " "Slice 5b — expected _max_wave_retries() or abort_wave_group() to consume it." ) + + +class TestSlice6Defaults: + """Slice 6: worktree_isolation and concurrent_dispatch defaults flipped ON.""" + + def test_mapconfig_worktree_isolation_default_auto(self) -> None: + """Slice 6: MapConfig().worktree_isolation == 'auto' (flipped from 'off').""" + cfg = MapConfig() + assert cfg.worktree_isolation == "auto", ( + f"MapConfig.worktree_isolation default must be 'auto' (Slice 6 flip), " + f"got {cfg.worktree_isolation!r}" + ) + + def test_mapconfig_concurrent_dispatch_default_true(self) -> None: + """Slice 6: MapConfig().concurrent_dispatch is True (flipped from False).""" + cfg = MapConfig() + assert cfg.concurrent_dispatch is True, ( + "MapConfig.concurrent_dispatch default must be True (Slice 6 flip)" + ) + + def test_absent_config_worktree_isolation_default_auto(self, tmp_path: Path) -> None: + """Slice 6: no .map/config.yaml → worktree_isolation defaults to 'auto'.""" + cfg = load_map_config(tmp_path) + assert cfg.worktree_isolation == "auto", ( + f"load_map_config with absent config must give worktree_isolation='auto' " + f"(Slice 6 flip from 'off'), got {cfg.worktree_isolation!r}" + ) + + def test_absent_config_concurrent_dispatch_default_true(self, tmp_path: Path) -> None: + """Slice 6: no .map/config.yaml → concurrent_dispatch defaults to True.""" + cfg = load_map_config(tmp_path) + assert cfg.concurrent_dispatch is True, ( + "load_map_config with absent config must give concurrent_dispatch=True " + "(Slice 6 flip from False)" + ) + + def test_per_repo_opt_out_worktree_isolation_off(self, tmp_path: Path) -> None: + """Per-repo opt-out: worktree.isolation: off overrides the Slice 6 default.""" + (tmp_path / ".map").mkdir(parents=True, exist_ok=True) + (tmp_path / ".map" / "config.yaml").write_text( + "worktree.isolation: off\n", encoding="utf-8" + ) + cfg = load_map_config(tmp_path) + assert cfg.worktree_isolation == "off", ( + "worktree.isolation: off in config.yaml must override the Slice 6 default" + ) + + def test_per_repo_opt_out_concurrent_dispatch_false(self, tmp_path: Path) -> None: + """Per-repo opt-out: execution.concurrent_dispatch: false overrides the default.""" + (tmp_path / ".map").mkdir(parents=True, exist_ok=True) + (tmp_path / ".map" / "config.yaml").write_text( + "execution.concurrent_dispatch: false\n", encoding="utf-8" + ) + cfg = load_map_config(tmp_path) + assert cfg.concurrent_dispatch is False, ( + "execution.concurrent_dispatch: false in config.yaml must override the Slice 6 default" + ) diff --git a/tests/test_slice5b_leak_guards.py b/tests/test_slice5b_leak_guards.py index 7813e790..31f4dcb7 100644 --- a/tests/test_slice5b_leak_guards.py +++ b/tests/test_slice5b_leak_guards.py @@ -1,16 +1,21 @@ -"""HC-1 leak-guard proof suite for Slice 5b concurrent dispatch (ST-008). +"""Kill-switch / off-ramp leak-guard proof suite (updated for Slice 6, ST-008). -Five non-tautological guards proving default-off (concurrent_dispatch=false) -is behavior-neutral / byte-identical to Slice 5a: +Five non-tautological guards proving MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (the +global kill-switch) suppresses all concurrent behavior — equivalent to the +old default-off premise but now pinned to the off-ramp rather than the default: (a) prompt confinement guard — fanout instruction tokens exist ONLY inside the prose-gated 'Slice 5b' section, NOT in default/sequential paragraphs. -(b) monkeypatch-fail guard — concurrent runner verbs do NOT fire under default config. +(b) monkeypatch-fail guard — concurrent runner verbs do NOT fire under the kill-switch. (c) AST/static-import guard — sequential walker + flag-false branch contain NO references to concurrent runner verbs. -(d) no-telemetry guard — no parallelism.json is created on the default path. -(e) default-off baseline — get_wave_step returns dispatch_mode=='sequential' with - the 5a reason code; concurrency_enabled==False. +(d) no-telemetry guard — no parallelism.json is created when the kill-switch is set. +(e) kill-switch baseline — get_wave_step returns dispatch_mode=='sequential' with + the kill-switch reason code; concurrency_enabled==False. + +Slice 6 change: the DEFAULT config now routes to concurrent for parallel-ready plans. +The kill-switch (MAP_EFFICIENT_SEQUENTIAL_ONLY=1) is the new behavioral gate these +guards protect — they go RED if the kill-switch leaks concurrency. """ from __future__ import annotations @@ -57,14 +62,16 @@ @pytest.fixture def branch_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str: - """Minimal .map// directory with NO config.yaml → default config. + """Minimal .map// directory with MAP_EFFICIENT_SEQUENTIAL_ONLY=1. - No config.yaml → _concurrent_dispatch_enabled returns False (default off). + Slice 6: defaults are now ON. This fixture engages the kill-switch so the + guards protect the off-ramp path (not the old default-off path). """ branch = "test-leak-guards" map_branch_dir = tmp_path / ".map" / branch map_branch_dir.mkdir(parents=True) monkeypatch.chdir(tmp_path) + monkeypatch.setenv("MAP_EFFICIENT_SEQUENTIAL_ONLY", "1") monkeypatch.setattr(map_orchestrator, "get_branch_name", lambda: branch) return branch @@ -206,13 +213,13 @@ def test_vc1a_emit_all_n_task_inside_not_outside(self) -> None: # =========================================================================== class TestGuardB_MonkeypatchFail: - """Concurrent runner verbs must NOT be called under default config. + """Concurrent runner verbs must NOT be called when the kill-switch is set. What break turns this red: - If compute_dispatch_gate (or get_wave_step) calls begin_wave_group / abort_wave_group / run_concurrent_wave / record_dispatch_actual when - concurrent_dispatch is False (default), the monkeypatched stub raises - AssertionError and the test fails — catching the leak. + MAP_EFFICIENT_SEQUENTIAL_ONLY=1, the monkeypatched stub raises + AssertionError and the test fails — catching the kill-switch leak. """ _CONCURRENT_VERBS = [ @@ -228,21 +235,20 @@ def _stub(*_args: object, **_kw: object) -> None: del _args, _kw # suppress pyright unused-parameter; del valid in def raise AssertionError( f"Concurrent runner verb {name!r} must NOT be called " - "when concurrent_dispatch=False (default config, HC-1)." + "when MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (kill-switch engaged)." ) return _stub - def test_vc1b_no_concurrent_verbs_fire_on_default_config( + def test_vc1b_no_concurrent_verbs_fire_on_kill_switch( self, branch_with_waves: str, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Under default config (no concurrent_dispatch key) none of the four - concurrent runner verbs fire when get_wave_step / compute_dispatch_gate - are called. + """Under MAP_EFFICIENT_SEQUENTIAL_ONLY=1 none of the four concurrent + runner verbs fire when get_wave_step / compute_dispatch_gate are called. - Stubs raise AssertionError on any call → a leak is immediately visible - as a test failure. + Stubs raise AssertionError on any call → a kill-switch leak is + immediately visible as a test failure. """ import importlib try: @@ -434,20 +440,20 @@ def test_vc1c_verbs_present_in_runner_not_on_sequential_orchestrator_paths( # =========================================================================== class TestGuardD_NoTelemetry: - """record_dispatch_actual must not create parallelism.json under default config. + """record_dispatch_actual must not create parallelism.json when the kill-switch is set. What break turns this red: - - If any code path under default config creates parallelism.json (which - record_dispatch_actual writes only on the DISPATCH_OUTCOME_CONCURRENT_OBSERVED - path), the file-existence assertion fires. + - If any code path under MAP_EFFICIENT_SEQUENTIAL_ONLY=1 creates parallelism.json + (which record_dispatch_actual writes only on the DISPATCH_OUTCOME_CONCURRENT_OBSERVED + path), the file-existence assertion fires — the kill-switch leaked concurrency. """ - def test_vc1d_no_parallelism_json_created_on_default_path( + def test_vc1d_no_parallelism_json_created_on_kill_switch_path( self, branch_with_waves: str, ) -> None: - """Running get_wave_step + compute_dispatch_gate under default config must NOT - create any parallelism.json file. + """Running get_wave_step + compute_dispatch_gate under MAP_EFFICIENT_SEQUENTIAL_ONLY=1 + must NOT create any parallelism.json file. """ expected_telemetry = Path(f".map/{branch_with_waves}/parallelism.json") map_dir = Path(f".map/{branch_with_waves}") @@ -471,7 +477,7 @@ def test_vc1d_no_parallelism_json_created_on_default_path( new_files = after - before parallelism_new = {p for p in new_files if "parallelism" in p.name} assert not parallelism_new, ( - f"New parallelism-related file(s) created under default config: " + f"New parallelism-related file(s) created under MAP_EFFICIENT_SEQUENTIAL_ONLY=1: " f"{parallelism_new}. Only step_state.json should be updated." ) @@ -480,84 +486,90 @@ def test_vc1d_no_parallelism_json_created_on_default_path( # Guard (e): Default-off baseline # =========================================================================== -class TestGuardE_DefaultOffBaseline: - """Under default config, gate and wave step must be sequential with 5a reason code. +class TestGuardE_KillSwitchBaseline: + """Under MAP_EFFICIENT_SEQUENTIAL_ONLY=1, gate and wave step must be sequential + with the kill-switch reason code. What break turns this red: - - If concurrent_dispatch defaults to True (config-default bug), the + - If the kill-switch is ignored and the concurrent path executes, the dispatch_mode assertion fires. - - If the reason code drifts from WAVE_REASON_DISPATCH_SEQUENTIAL, the reason + - If the reason code drifts from WAVE_REASON_SEQUENTIAL_ONLY_ENV, the reason assertion fires — catching a silent rename or path change. - concurrency_enabled==False is a corollary: also asserted. + + Slice 6 change: the DEFAULT is now concurrent for parallel-ready plans. + The kill-switch is the behavioral gate these guards prove. """ - def test_vc1e_compute_dispatch_gate_sequential_by_default( + def test_vc1e_compute_dispatch_gate_sequential_on_kill_switch( self, branch_with_waves: str ) -> None: - """compute_dispatch_gate returns dispatch_mode=='sequential' and the 5a reason - code when no concurrent_dispatch key is present. + """compute_dispatch_gate returns dispatch_mode=='sequential' and the + kill-switch reason code when MAP_EFFICIENT_SEQUENTIAL_ONLY=1. - The reason must equal WAVE_REASON_DISPATCH_SEQUENTIAL — not a fallback from - later gate steps — proving the HC-1 short-circuit is the path that fires. + The reason must equal WAVE_REASON_SEQUENTIAL_ONLY_ENV — not a fallback from + later gate steps — proving the kill-switch short-circuit is the path that fires. """ gate = map_orchestrator.compute_dispatch_gate(branch_with_waves, Path(".")) assert gate["dispatch_mode"] == "sequential", ( - f"compute_dispatch_gate returned {gate['dispatch_mode']!r} under default " - "config. Expected 'sequential' (HC-1)." + f"compute_dispatch_gate returned {gate['dispatch_mode']!r} under kill-switch. " + "Expected 'sequential' (MAP_EFFICIENT_SEQUENTIAL_ONLY=1)." ) - assert gate["reason"] == map_orchestrator.WAVE_REASON_DISPATCH_SEQUENTIAL, ( + assert gate["reason"] == map_orchestrator.WAVE_REASON_SEQUENTIAL_ONLY_ENV, ( f"Gate reason {gate['reason']!r} != " - f"WAVE_REASON_DISPATCH_SEQUENTIAL ({map_orchestrator.WAVE_REASON_DISPATCH_SEQUENTIAL!r}). " - "HC-1 short-circuit must fire before any concurrency probe." + f"WAVE_REASON_SEQUENTIAL_ONLY_ENV " + f"({map_orchestrator.WAVE_REASON_SEQUENTIAL_ONLY_ENV!r}). " + "Kill-switch must fire before any concurrency probe." ) - def test_vc1e_get_wave_step_concurrency_disabled_by_default( + def test_vc1e_get_wave_step_concurrency_disabled_on_kill_switch( self, branch_with_waves: str ) -> None: """get_wave_step returns concurrency_enabled==False and dispatch_mode== - 'sequential' when concurrent_dispatch is not configured. + 'sequential' when MAP_EFFICIENT_SEQUENTIAL_ONLY=1. """ result = map_orchestrator.get_wave_step(branch_with_waves) assert result["dispatch_mode"] == "sequential", ( f"get_wave_step returned dispatch_mode={result['dispatch_mode']!r}. " - "Expected 'sequential' under default config." + "Expected 'sequential' under MAP_EFFICIENT_SEQUENTIAL_ONLY=1." ) assert result.get("concurrency_enabled") is False, ( f"get_wave_step returned concurrency_enabled=" - f"{result.get('concurrency_enabled')!r}. Must be False under default config." + f"{result.get('concurrency_enabled')!r}. Must be False under kill-switch." ) - def test_vc1e_get_wave_step_reason_is_5a_code( + def test_vc1e_get_wave_step_reason_is_kill_switch_code( self, branch_with_waves: str ) -> None: - """The reason in get_wave_step must be WAVE_REASON_DISPATCH_SEQUENTIAL — the - 5a stable code — when concurrent_dispatch is off. + """The reason in get_wave_step must be WAVE_REASON_SEQUENTIAL_ONLY_ENV when + MAP_EFFICIENT_SEQUENTIAL_ONLY=1. Failure scenario: if compute_dispatch_gate's wiring changed and the reason was overwritten or swallowed, a different (or missing) reason code appears. """ result = map_orchestrator.get_wave_step(branch_with_waves) - assert result.get("reason") == map_orchestrator.WAVE_REASON_DISPATCH_SEQUENTIAL, ( + assert result.get("reason") == map_orchestrator.WAVE_REASON_SEQUENTIAL_ONLY_ENV, ( f"get_wave_step reason={result.get('reason')!r} != " - f"WAVE_REASON_DISPATCH_SEQUENTIAL={map_orchestrator.WAVE_REASON_DISPATCH_SEQUENTIAL!r}. " - "The 5a reason code is the stable HC-1 contract." + f"WAVE_REASON_SEQUENTIAL_ONLY_ENV={map_orchestrator.WAVE_REASON_SEQUENTIAL_ONLY_ENV!r}. " + "The kill-switch reason code is the stable contract under MAP_EFFICIENT_SEQUENTIAL_ONLY=1." ) - def test_vc1e_select_execution_strategy_concurrency_not_allowed_by_default( + def test_vc1e_select_execution_strategy_concurrency_not_allowed_on_kill_switch( self, branch_with_waves: str ) -> None: - """select_execution_strategy returns concurrency_allowed==False under default - config (worktree.isolation defaults to 'off'). + """select_execution_strategy returns concurrency_allowed==False when + MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (kill-switch engaged). This covers the 'concurrency_allowed==False' clause of guard (e). + Slice 6: the DEFAULT config no longer implies False here — the kill-switch does. """ strategy = map_orchestrator.select_execution_strategy(branch_with_waves, Path(".")) assert strategy.get("concurrency_allowed") is False, ( f"select_execution_strategy returned concurrency_allowed=" f"{strategy.get('concurrency_allowed')!r}. " - "Must be False under default config (worktree.isolation defaults to 'off')." + "Must be False under MAP_EFFICIENT_SEQUENTIAL_ONLY=1 (kill-switch)." ) diff --git a/tests/test_wave_eval_harness.py b/tests/test_wave_eval_harness.py index 6ca53a17..2de4d53e 100644 --- a/tests/test_wave_eval_harness.py +++ b/tests/test_wave_eval_harness.py @@ -2,8 +2,11 @@ ST-005 eval/regression harness: - VC1: compute_waves + split_wave_by_file_conflicts shape assertions for linear_chain, two_wave_parallel, conflict_split fixtures. - - VC2: With a default (no-new-key) config, _execution_wave_mode == 'off' AND - _worktree_isolation_mode == 'off', proving HC-1 behavior-neutrality. + - VC2 (reframed for Slice 6): With a default (no-new-key) config, + _execution_wave_mode == 'auto' AND _worktree_isolation_mode == 'auto' + (both defaults flipped ON). Under a parallel-ready plan the wave-loop is + now engaged by default. The kill-switch (MAP_EFFICIENT_SEQUENTIAL_ONLY=1) + forces sequential regardless of config. """ from __future__ import annotations @@ -11,6 +14,8 @@ import sys from pathlib import Path +import pytest + # --------------------------------------------------------------------------- # Runner import — identical pattern to tests/test_map_step_runner.py # --------------------------------------------------------------------------- @@ -142,27 +147,27 @@ def _sub_wave_index(subtask_id: str) -> int: # --------------------------------------------------------------------------- -def test_default_config_selects_sequential(tmp_path: Path) -> None: +def test_default_config_has_parallel_defaults(tmp_path: Path) -> None: """ - VC2 [AC-5] [SC-2]: With a default (no-new-key) config, the canonical - MapConfig defaults apply: _execution_wave_mode == 'auto' and - _worktree_isolation_mode == 'off'. Behavior stays neutral (HC-1) because - the wave-loop is gated on worktree.isolation != 'off' — which is 'off' by - default — so the legacy sequential path is selected regardless of wave_mode. - - Also verifies the color-group concurrency predicate: the condition that - WOULD contribute to wave-mode (any color group of width >= 2) exists in the - two_wave_parallel fixture, but the isolation gate keeps the legacy path. + VC2 [AC-5] [SC-2] (reframed, Slice 6): With a default (no-new-key) config, + the canonical MapConfig defaults now apply: _execution_wave_mode == 'auto' + AND _worktree_isolation_mode == 'auto' (both flipped ON in Slice 6). + + Under a parallel-ready plan (two_wave_parallel), the wave-loop is now + ENGAGED by default because isolation != 'off'. + + Also verifies the color-group concurrency predicate: two_wave_parallel has + at least one color group of width >= 2. """ - # Case A: no .map directory at all + # Case A: no .map directory at all — defaults are 'auto'. assert map_step_runner._execution_wave_mode(tmp_path) == "auto", ( "_execution_wave_mode must default to 'auto' (MapConfig) when .map dir is absent" ) - assert map_step_runner._worktree_isolation_mode(tmp_path) == "off", ( - "_worktree_isolation_mode must return 'off' when .map dir is absent" + assert map_step_runner._worktree_isolation_mode(tmp_path) == "auto", ( + "_worktree_isolation_mode must return 'auto' when .map dir is absent (Slice 6 default)" ) - # Case B: .map/config.yaml exists but contains NO new keys + # Case B: .map/config.yaml exists but contains NO new keys — still defaults to 'auto'. map_dir = tmp_path / ".map" map_dir.mkdir() (map_dir / "config.yaml").write_text( @@ -173,12 +178,12 @@ def test_default_config_selects_sequential(tmp_path: Path) -> None: assert map_step_runner._execution_wave_mode(tmp_path) == "auto", ( "_execution_wave_mode must default to 'auto' when execution.wave_mode key is absent" ) - assert map_step_runner._worktree_isolation_mode(tmp_path) == "off", ( - "_worktree_isolation_mode must return 'off' when worktree.isolation key is absent" + assert map_step_runner._worktree_isolation_mode(tmp_path) == "auto", ( + "_worktree_isolation_mode must return 'auto' when worktree.isolation key is absent " + "(Slice 6 default flipped from 'off')" ) - # Show the concurrency predicate (color group width >= 2) WOULD be true - # for the two_wave_parallel fixture, but wave_mode='off' gates around it. + # Show the concurrency predicate (color group width >= 2) is true for two_wave_parallel. tp: BlueprintFixture = two_wave_parallel() tp_graph: DependencyGraph = tp.build_graph() tp_waves = tp_graph.compute_waves() @@ -187,19 +192,42 @@ def test_default_config_selects_sequential(tmp_path: Path) -> None: color_groups = [ tp_graph.split_wave_by_file_conflicts(w, tp.affected_files_map) for w in tp_waves ] - # At least one color group has width >= 2 (the parallel wave) assert any(len(g) >= 2 for groups in color_groups for g in groups), ( "two_wave_parallel must expose at least one color group of width >= 2 " "(the concurrency predicate gate)" ) - # wave_mode defaults to 'auto' and a width>=2 group exists, but the isolation - # gate (worktree.isolation == 'off' by default) keeps the legacy sequential - # path — that is what makes the default behavior-neutral. + # Slice 6: wave_mode='auto' AND isolation='auto' (!=off) AND width>=2 group exists + # → wave-loop is now engaged by default (not suppressed by the isolation gate). wave_mode = map_step_runner._execution_wave_mode(tmp_path) isolation = map_step_runner._worktree_isolation_mode(tmp_path) - assert wave_mode == "auto" and isolation == "off", ( - f"default config: wave_mode='auto', isolation='off'; got {wave_mode!r}/{isolation!r}" + assert wave_mode == "auto", f"default wave_mode must be 'auto', got {wave_mode!r}" + assert isolation == "auto", f"default isolation must be 'auto' (Slice 6), got {isolation!r}" + # With isolation='auto' the wave-loop predicate CAN engage for parallel-ready plans. + assert isolation != "off", ( + "isolation default must NOT be 'off' — Slice 6 flipped it to 'auto' so " + "parallel-ready plans now use the wave-loop by default." + ) + + +def test_kill_switch_forces_sequential(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """ + VC2 kill-switch: MAP_EFFICIENT_SEQUENTIAL_ONLY=1 forces sequential regardless + of the (now-parallel) defaults. + + This is the behavioral invariant that replaces the old 'default → sequential' + test: the byte-identical-to-legacy proof now applies to the kill-switch path, + not the default config path. + """ + monkeypatch.setenv("MAP_EFFICIENT_SEQUENTIAL_ONLY", "1") + + # With the kill-switch set, isolation_mode does not matter — the gate short-circuits. + isolation = map_step_runner._worktree_isolation_mode(tmp_path) + wave_mode = map_step_runner._execution_wave_mode(tmp_path) + # Defaults are still 'auto' at the reader level — kill-switch operates at orchestrator. + assert wave_mode == "auto", ( + "_execution_wave_mode reads config (not the env var); still returns 'auto'" + ) + assert isolation == "auto", ( + "_worktree_isolation_mode reads config (not the env var); still returns 'auto'" ) - # The wave-loop predicate requires isolation != 'off', so default => sequential. - assert isolation == "off", "isolation gate must hold the legacy path by default" diff --git a/tests/test_worktree_isolation.py b/tests/test_worktree_isolation.py index a2d4a528..56e14a79 100644 --- a/tests/test_worktree_isolation.py +++ b/tests/test_worktree_isolation.py @@ -48,14 +48,16 @@ def _write_config(tmp_path: Path, body: str) -> None: class TestWorktreeConfig: - def test_defaults_off(self) -> None: + def test_defaults_auto(self) -> None: + """Slice 6: worktree_isolation default flipped from 'off' to 'auto'.""" cfg = MapConfig() - assert cfg.worktree_isolation == "off" + assert cfg.worktree_isolation == "auto" assert cfg.worktree_max_deletions == 50 def test_absent_config_uses_defaults(self, tmp_path: Path) -> None: + """Slice 6: absent config → 'auto' (Slice 6 default, not 'off').""" cfg = load_map_config(tmp_path) - assert cfg.worktree_isolation == "off" + assert cfg.worktree_isolation == "auto" assert cfg.worktree_max_deletions == 50 def test_dotted_keys_alias_to_fields(self, tmp_path: Path) -> None: @@ -202,6 +204,7 @@ class TestWorktreeDisabled: def test_create_noops_when_disabled( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """Legacy bool 'false' maps to 'off' → create returns status='disabled'.""" r = _make_repo(tmp_path) (r / ".map").mkdir() (r / ".map" / "config.yaml").write_text("worktree.isolation: false\n") @@ -210,12 +213,22 @@ def test_create_noops_when_disabled( assert result["status"] == "disabled" assert result["ok"] is False - def test_create_noops_when_no_config( + def test_create_noops_when_explicit_off( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """Explicit 'worktree.isolation: off' (per-repo opt-out) → status='disabled'. + + Slice 6: replaced the old 'no config → disabled' test — without config the + default is now 'auto' (ON), so no-config produces a success (worktree created). + The per-repo opt-out uses the explicit 'off' enum value. + """ r = _make_repo(tmp_path) + (r / ".map").mkdir() + (r / ".map" / "config.yaml").write_text("worktree.isolation: off\n") monkeypatch.chdir(r) - assert m.create_subtask_worktree("ST-001")["status"] == "disabled" + result = m.create_subtask_worktree("ST-001") + assert result["status"] == "disabled" + assert result["ok"] is False class TestWorktreeLifecycle: