@yemi33/minions 0.1.2380 → 0.1.2381

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,34 +20,34 @@ For these repos, the operator usually already has one canonical checkout that bu
20
20
 
21
21
  ## Contract (six guarantees)
22
22
 
23
- Live mode is opinionated about what the engine will and will not do to the operator's tree. The contract has six guarantees; the engine enforces all six and fails dispatches that would violate them.
23
+ Live mode is opinionated about what the engine will and will not do to the operator's tree. The contract has six guarantees; the engine enforces them by recovering when explicitly configured, otherwise leaving blocked work pending or failing closed.
24
24
 
25
25
  ### 1. Per-project mutating-concurrency cap of 1
26
26
 
27
- Only one mutating dispatch (fix / implement / review / test / verify / decompose / docs) runs per live-mode project at a time. Read-only types (meeting / ask / explore / plan / plan-to-prd) are excluded from the cap because they never write to disk.
27
+ Only one worktree-requiring dispatch (fix / build-fix-complex / implement / review / test / verify / decompose / setup / docs) runs per live-mode project at a time. Read-only types (meeting / ask / explore / plan / plan-to-prd) are excluded from the cap because they never write to disk.
28
28
 
29
29
  Implementation: `engine.js` builds a `liveProjectsInUse` set from the active dispatch list at every allocation pass and per-tick re-annotation; pending items whose project is already in the set are skipped with a `skipReason` and re-tried on the next tick. (`engine.js:7198`, `engine.js:7460`.)
30
30
 
31
- ### 2. Refuse-on-dirty (engine never resets the operator tree)
31
+ ### 2. Dirty-tree policy
32
32
 
33
- **W-mrcifup2000c218a — detected at ALLOCATION time first.** Before a live-mode work item is even dispatched, `engine.js`'s dispatch-existing-pending loop calls `engine/live-checkout.js#checkLiveCheckoutDirty` (a cheap, read-only, non-mutating `git status --porcelain=v1 -b` probe same command, same auto-cleanable-artifact allowance as the in-spawn check below) in the same pass that already gates on `live_checkout_busy`. A dirty hit stamps `_pendingReason: 'live_checkout_dirty'` on the pending item and `continue`s — **no dispatch attempt, no retry consumed, no failure recorded** — exactly like the busy gate. The item is re-checked every tick and dispatches normally the moment the tree is clean. A `live-checkout-dirty-<wi-id>` inbox alert is written (deduped per day, same as the in-spawn alert below) so a persistent dirty condition is still surfaced to a human even though the WI itself stays pending.
33
+ **W-mrcifup2000c218a — detected at ALLOCATION time first.** The allocator runs a cheap read-only dirty probe. When both auto-stash and auto-reset are disabled, a dirty hit stamps `_pendingReason: 'live_checkout_dirty'`, writes a deduped inbox alert, and leaves the WI pending without consuming a retry. When either recovery policy is enabled, dirt alone does not block allocation so `spawnAgent` can perform the configured recovery; the independent stale-base precheck still blocks committed base drift.
34
34
 
35
35
  The in-spawn check below remains as a **defense-in-depth fallback** for the race window between the allocation-time probe and the actual git operations inside `spawnAgent` (e.g. the tree goes dirty in the few seconds between the pre-check and the spawn) — it is now the *exception* path, not the common path.
36
36
 
37
- Before spawning, `engine/live-checkout.js#prepareLiveCheckout` runs `git status --porcelain` from `project.localPath`. Any output (staged, unstaged, untracked) **returns** an explicit `{ ok:false, reason:'dirty', dirtyFiles:[…] }` result (it does NOT throw), which refuses the dispatch immediately:
37
+ Before spawning, `engine/live-checkout.js#prepareLiveCheckout` first refuses an in-progress repository operation or detached HEAD, captures the original ref, then runs `git status --porcelain` from `project.localPath`. Any dirty output (staged, unstaged, untracked) **returns** an explicit `{ ok:false, reason:'dirty', dirtyFiles:[…] }` result unless explicit reset recovery is requested. `spawnAgent` uses that result to run auto-stash first and optional auto-reset second. Only when configured recovery is unavailable or fails does the confirmed-dirty result become a dispatch refusal:
38
38
 
39
39
  - Non-retryable `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` (added to `engine/dispatch.js`'s `neverRetry` set so the dispatcher never re-spawns mechanically). **Reserved for this confirmed-dirty result only** — a thrown helper/git error is `LIVE_CHECKOUT_FAILED`, see below (#305). **Auto-cleanup exception (W-mqzmkoqt000hbca2, #582):** when `liveCheckoutAutoReset` or `liveCheckoutAutoStash` is enabled (per-project or fleet-wide), `spawnAgent` treats the dirty refusal as retryable instead — the engine re-cleans the tree on every attempt, so the two-strike cap below is skipped and the dispatch keeps retrying up to the normal `maxRetries` cap. The `dispatch.js` `neverRetry` entry is kept only as a defensive fallback for callers that omit the explicit `agentRetryable` override.
40
40
  - Inbox alert written via `dispatch.writeInboxAlert('live-checkout-dirty-<wi-id>', body)`. The body lists the dirty files verbatim from `git status --porcelain`.
41
41
  - Work item stamped with `_pendingReason: 'live_checkout_dirty'` so the dashboard surfaces the block.
42
42
  - Completion summary: `live-checkout refused: N dirty file(s) in <localPath>`.
43
43
 
44
- The engine never calls `git reset --hard`, `git clean -fd`, or any other state-mutating command against the operator's checkout — not at spawn, not at cleanup, not on timeout, not on engine restart. The dispatch-scoped cleanup paths (`worktreePool.returnToPool`, `worktree-gc.gcDispatchWorktreeIfOrphan`, `_quarantineDirtyWorktree`) are naturally no-ops because `worktreePath` stays `null` end-to-end (`engine.js:1219`). The **periodic** worktree GC, however, is NOT `worktreePath`-gated — it derives its targets from `git worktree list --porcelain`, which for a live project returns the operator's *own* primary checkout. So `engine/cleanup.js#runPeriodicWorktreeSweep` now **filters out live-checkout projects entirely** (`shared.isLiveCheckoutProject`) before handing the list to the three pruners (PL-live-checkout-reliability-hardening), keeping the operator's real checkout out of the GC decision surface rather than relying only on the pruners' path-equality + ownership-marker gates. (The one **opt-in** exception is auto-stash — see [2b](#2b-opt-in-auto-stash-on-dirty-w-mqtvnnj1000357fa) — which the operator explicitly enables and which the engine never reverses on the operator's behalf.)
44
+ Outside the explicit auto-stash and auto-reset policies, the engine never resets, cleans, or stashes the operator checkout. Dispatch-scoped cleanup paths are no-ops because `worktreePath` stays null, and periodic worktree GC filters live-checkout projects out entirely.
45
45
 
46
- #### 2b. Opt-in auto-stash on dirty (W-mqtvnnj1000357fa)
46
+ #### 2b. Auto-stash on dirty (W-mqtvnnj1000357fa)
47
47
 
48
- By default the dirty refusal above stands. When the operator opts in, the engine instead **stashes** the dirty changes so the dispatch can proceed without manual intervention:
48
+ Auto-stash is **ON by default**. The engine stashes dirty changes so dispatch can proceed without manual intervention; operators can opt out per project or fleet-wide:
49
49
 
50
- - **Enable** via per-project `project.liveCheckoutAutoStash: true` (takes priority) or the fleet-wide fallback `engine.liveCheckoutAutoStash: true` (default `false`). Resolution is `engine/live-checkout.js#resolveLiveCheckoutAutoStash` — an explicit per-project boolean wins; otherwise the engine value applies; otherwise `false`. Both are surfaced as Settings toggles (the per-project control is a tri-state *Use fleet default / On / Off* select; the fleet-wide one lives under **Settings → Worktrees**).
50
+ - Configure via per-project `project.liveCheckoutAutoStash` (takes priority) or fleet-wide `engine.liveCheckoutAutoStash` (default `true`). Both are surfaced in Settings.
51
51
  - When enabled and the tree is dirty, `spawnAgent` delegates the whole flow to `engine/live-checkout.js#applyLiveCheckoutAutoStash`, which calls `performLiveCheckoutAutoStash` to run `git stash push --include-untracked -m "minions-auto-stash-<dispatchId>-<timestamp>"` in `project.localPath` (`--include-untracked` so the `??` files that `git status --porcelain` counts as dirty are parked too). The stash git command runs **outside any file lock**.
52
52
  - On stash **success** the helper re-runs `prepareLiveCheckout` (the tree is now clean), clears any stale `_pendingReason: 'live_checkout_dirty'` stamp (via the injected `clearDirtyStamp` callback), writes a `live-checkout-autostash-<wi-id>` inbox note with the stash name + manual-pop guidance, and returns `{ outcome:'stashed', liveResult }` so dispatch proceeds. If the re-preflight throws, it returns `{ outcome:'threw', error }` and `spawnAgent` fails the dispatch as `LIVE_CHECKOUT_FAILED`.
53
53
  - On stash **failure** the error is surfaced (logged, never swallowed), the helper returns `{ outcome:'unchanged', liveResult }`, and the dispatch falls through to the normal retry-once-then-fail dirty path above.
@@ -61,25 +61,26 @@ A thrown error from `prepareLiveCheckout` — a required-arg/ref-validation guar
61
61
 
62
62
  #### 2b. Opt-in auto-reset on dirty (`liveCheckoutAutoReset`, W-mqvejug6000eeb20)
63
63
 
64
- By default the dirty tree refusal (Guarantee 2) is terminal the engine never touches the operator's uncommitted work. **Opt in** to automatic recovery with `liveCheckoutAutoReset`. When enabled, a dirty live tree is **force-reset to the remote** instead of refusing the dispatch:
64
+ Auto-stash is the default non-destructive recovery. `liveCheckoutAutoReset` is an additional **opt-in, destructive fallback** used only when auto-stash is disabled, skipped, or fails and the tree remains dirty. It removes uncommitted WIP without changing the current branch or any commit:
65
65
 
66
66
  1. `prepareLiveCheckout` detects the dirty tree (same porcelain preflight as Guarantee 2).
67
67
  2. It resolves the effective flag via `shared.resolveLiveCheckoutAutoReset(project, engine)` — **per-project `project.liveCheckoutAutoReset` (boolean) wins**, else the fleet-wide `engine.liveCheckoutAutoReset`, else `false`. (engine.js does not thread project/engine config into the helper, so the production path self-resolves by loading `config.json` and matching the project by `localPath`; it **fails closed** to `false` on any config read error so a glitch can never silently trigger a destructive reset.)
68
- 3. If ON: `git fetch origin` + `git reset --hard origin/<branch>`, then **re-run the porcelain preflight once**. If the tree is now clean, dispatch proceeds normally. If the reset failed or the tree is *still* dirty, it falls back to the safe `{ ok:false, reason:'dirty' }` refusal — the engine never dispatches onto an unexpected tree.
69
- 4. On a successful reset it writes a single `live-checkout-autoreset-<wiId>` inbox note listing **exactly which paths were discarded**, so the operator can recover them from `git reflog` / `git fsck --lost-found`.
68
+ 3. Before mutation, `prepareLiveCheckout` refuses an in-progress merge/rebase/cherry-pick/revert/bisect or detached HEAD.
69
+ 4. If ON: `git reset --hard HEAD` discards tracked index/worktree changes and `git clean -fd` removes ordinary untracked files. The helper then **always re-runs the porcelain preflight once**, even when either command reports an error, because a failed command may have partially changed the tree. If the tree is verified clean, dispatch proceeds normally. Otherwise it returns the safe `{ ok:false, reason:'dirty' }` refusal with the actual remaining paths when available.
70
+ 5. Every destructive attempt writes a uniquely keyed `live-checkout-autoreset-<attempt>-<dispatch-or-WI-id>` inbox note recording each command outcome plus the before/after dirty paths, including partial failures.
70
71
 
71
- This is **DESTRUCTIVE** — it permanently discards the operator's uncommitted changes in the live checkout. As of W-mqzbbhn2 it was **ON by default**; as of **W-mrawgw4q000a6bff it is OFF by default again** (`ENGINE_DEFAULTS.liveCheckoutAutoReset: false`) — the destructive-by-default behavior caused unwanted data loss when an operator had legitimate local drift they expected the engine to leave alone. Set `liveCheckoutAutoReset: true` (per-project or fleet-wide) to opt back in to reset-to-remote recovery.
72
+ This is **DESTRUCTIVE** — tracked WIP and untracked files can be permanently unrecoverable. It does **not** fetch, switch branches, reset to a remote ref, or erase local commits; the stale-base guard still refuses a local base with unpushed committed drift. As of W-mqzbbhn2 it was **ON by default**; as of **W-mrawgw4q000a6bff it is OFF by default again** (`ENGINE_DEFAULTS.liveCheckoutAutoReset: false`). Set `liveCheckoutAutoReset: true` (per-project or fleet-wide) to opt back in.
72
73
 
73
74
  - **Fleet-wide:** Dashboard → Settings → `Live-checkout auto-reset (fleet-wide)` (`engine.liveCheckoutAutoReset`).
74
75
  - **Per-project override:** set `liveCheckoutAutoReset: true|false` on the project object in `config.json` (see the config snippet under *Enabling live mode*). A per-project boolean overrides the fleet-wide default for that project. *(Per-project Settings-UI persistence is deferred — the per-project override is config.json-only for now.)*
75
76
 
76
- **Precedence when both `liveCheckoutAutoStash` and `liveCheckoutAutoReset` are enabled (W-mrawgw4q000a6bff).** `engine.js spawnAgent` now tries the **non-destructive** auto-stash first: it resolves `liveCheckoutAutoStash` up front and, if enabled, calls the initial `prepareLiveCheckout` with `autoReset` forced `false` so the destructive reset never runs before stash gets a chance. `applyLiveCheckoutAutoStash` then attempts the stash (see [2b auto-stash](#2b-opt-in-auto-stash-on-dirty-w-mqtvnnj1000357fa) above) and its own internal re-preflight also forces `autoReset: false`. Only if the stash itself fails or was skipped (`outcome:'unchanged'`) **and** the tree is still confirmed dirty **and** `resolveLiveCheckoutAutoReset` resolves `true` does `spawnAgent` invoke `prepareLiveCheckout` a second time with `autoReset: true` as an explicit, last-resort destructive fallback. **Behavior change:** previously (reset-before-stash, reset ON by default) the common both-enabled case force-reset the branch to origin on every dirty dispatch; now the branch is only synced to origin when stash fails — the common case just parks the dirty changes non-destructively and leaves the branch where it was. There is deliberately **no automatic `git merge --ff-only origin/<branch>`** after a successful stash — auto-stash's contract is "park changes so dispatch proceeds," not "sync to origin"; that stronger guarantee remains `liveCheckoutAutoReset`'s job, available as an explicit opt-in fallback.
77
+ **Precedence when both `liveCheckoutAutoStash` and `liveCheckoutAutoReset` are enabled (W-mrawgw4q000a6bff).** `engine.js spawnAgent` tries the **non-destructive** auto-stash first: it resolves `liveCheckoutAutoStash` up front and, if enabled, calls the initial `prepareLiveCheckout` with `autoReset` forced `false`. `applyLiveCheckoutAutoStash` attempts the stash and its own internal re-preflight also forces `autoReset: false`. Only if the stash fails or is skipped (`outcome:'unchanged'`), the tree remains dirty, and `resolveLiveCheckoutAutoReset` resolves `true` does `spawnAgent` invoke `prepareLiveCheckout` again with `autoReset: true`. Neither recovery policy syncs the branch to origin.
77
78
 
78
79
  #### 2c. Stale `.git/index.lock` self-heal (W-mr3tayu4)
79
80
 
80
81
  Before any preflight git command runs, `prepareLiveCheckout` calls the shared, age-gated `shared.removeStaleIndexLock(localPath)` to clear a leftover `.git/index.lock` from a crashed/killed git process. Live mode never resets/cleans the operator tree, so nothing else would clear it, and — since `LIVE_CHECKOUT_FAILED` is retryable — every automatic retry used to hit the same stale lock and fail identically forever until an operator deleted it by hand. The remover only touches locks older than 5 minutes, so a lock held by a currently-running git process is never removed out from under it. `engine.js`'s own worktree-mode lock cleanup now delegates to the same shared helper.
81
82
 
82
- ### 3. No auto-pull, no `--force`, no fast-forward
83
+ ### 3. No implicit remote sync or force checkout
83
84
 
84
85
  After the clean-tree check, `prepareLiveCheckout`:
85
86
 
@@ -247,7 +248,7 @@ This mirrors, in CC-instruction form, what `prepareLiveCheckout` enforces engine
247
248
 
248
249
  1. Open the Minions dashboard → **Settings** → **Projects** → expand the target project.
249
250
  2. Set **Checkout mode** → **Live checkout**. A yellow warning chip appears immediately:
250
- > ⚠ Live mode: dispatches run directly in this repo's checkout. Only one mutating dispatch runs at a time. Dirty working trees block dispatch — commit or stash before running.
251
+ > ⚠ Live mode: dispatches run directly in this repo's checkout. Only one mutating dispatch runs at a time. Dirty-tree behavior follows the auto-stash/reset settings.
251
252
  3. Click **Save**. The dashboard POSTs the change through `mergeSettingsConfigUpdate`; `shared.validateCheckoutMode` rejects anything other than `'worktree'` or `'live'` with HTTP 400 (the legacy `'isolated'` value is silently coerced to `'worktree'`).
252
253
 
253
254
  ### Enabling live mode (config.json)
@@ -258,10 +259,9 @@ This mirrors, in CC-instruction form, what `prepareLiveCheckout` enforces engine
258
259
  "name": "android-aosp",
259
260
  "localPath": "/home/yemi/aosp",
260
261
  "checkoutMode": "live",
261
- // "liveCheckoutAutoReset": true, // OPT-IN, DESTRUCTIVE — on a dirty tree,
262
- // git fetch origin + git reset --hard origin/<branch> instead of refusing
263
- // (discards uncommitted operator changes; logs a live-checkout-autoreset
264
- // inbox note). Overrides the fleet-wide engine.liveCheckoutAutoReset.
262
+ // "liveCheckoutAutoReset": true, // OPT-IN, DESTRUCTIVE fallback if
263
+ // auto-stash is disabled/fails, reset tracked WIP to HEAD and clean
264
+ // untracked files (preserves branch + commits; logs an inbox note).
265
265
  // …
266
266
  }]
267
267
  }
@@ -269,11 +269,30 @@ This mirrors, in CC-instruction form, what `prepareLiveCheckout` enforces engine
269
269
 
270
270
  Absent / `null` / `''` reads as `'worktree'` (the default) — explicit is preferred. A legacy `"worktreeMode": "live"` is still honored (and `"worktreeMode": "isolated"` reads as `'worktree'`), but new configs should use `checkoutMode`.
271
271
 
272
+ ### Enabling hybrid validation
273
+
274
+ Hybrid mode routes only selected canonical work-item types on the live checkout; every unselected type uses an isolated worktree. The normal configuration selects `test` and optionally creates one PR-targeted validation WI automatically:
275
+
276
+ ```jsonc
277
+ {
278
+ "projects": [{
279
+ "name": "android-aosp",
280
+ "checkoutMode": "live",
281
+ "liveValidation": {
282
+ "type": "test",
283
+ "autoDispatch": true
284
+ }
285
+ }]
286
+ }
287
+ ```
288
+
289
+ `type` may be a string or array, but every entry must be a live-capable canonical `WORK_TYPE`. `build-and-test` is a playbook alias and is normalized to `test` only for backward compatibility. `autoDispatch` must be boolean and requires `test` in the configured types; the generated WI has type `test`, carries structured `targetPr` / `pr_id` / URL-reference fields, targets the source PR branch, and renders the `build-and-test` playbook. Successful direct PR-backed `fix` and `build-fix-complex` dispatches, including human-feedback fixes, use the dispatch ID as their dedup key and do not add a nonexistent WI dependency.
290
+
272
291
  ### Enabling auto-stash on dirty (config.json)
273
292
 
274
293
  ```jsonc
275
294
  {
276
- // Fleet-wide fallback (default false) — applies to every live project that
295
+ // Fleet-wide fallback (default true) — applies to every live project that
277
296
  // doesn't set its own override.
278
297
  "engine": { "liveCheckoutAutoStash": true },
279
298
  "projects": [{
@@ -287,11 +306,11 @@ Absent / `null` / `''` reads as `'worktree'` (the default) — explicit is prefe
287
306
  }
288
307
  ```
289
308
 
290
- With auto-stash on, a dirty tree is `git stash push --include-untracked`'d before dispatch instead of refusing (see [2b](#2b-opt-in-auto-stash-on-dirty-w-mqtvnnj1000357fa)). The engine never pops the stash — recover with `git stash pop` manually.
309
+ With auto-stash on, a dirty tree is `git stash push --include-untracked`'d before dispatch instead of refusing (see [2b](#2b-auto-stash-on-dirty-w-mqtvnnj1000357fa)). The engine never pops the stash — recover with `git stash pop` manually.
291
310
 
292
311
  ### Recovering from `live_checkout_dirty` refusal
293
312
 
294
- When dispatch is blocked by a dirty tree, the dashboard shows the work item as pending with `_pendingReason: 'live_checkout_dirty'` and an inbox alert lists the dirty files. (To make the engine *auto-recover* from this instead of refusing — at the cost of discarding the dirty changes — enable [opt-in auto-reset](#2b-opt-in-auto-reset-on-dirty-livecheckoutautoreset-w-mqvejug6000eeb20).) From the project checkout:
313
+ When dispatch is blocked by a dirty tree, the dashboard shows the work item as pending with `_pendingReason: 'live_checkout_dirty'` and an inbox alert lists the dirty files. Enable auto-stash for non-destructive automatic recovery; enable [auto-reset](#2b-opt-in-auto-reset-on-dirty-livecheckoutautoreset-w-mqvejug6000eeb20) only as a destructive fallback. To recover manually from the project checkout:
295
314
 
296
315
  ```bash
297
316
  # Option A — preserve work for later
@@ -305,7 +324,7 @@ git checkout -- .
305
324
  git clean -fd
306
325
  ```
307
326
 
308
- Then re-dispatch. The next tick will re-pick the pending work item; nothing else needs clearing on the engine side (the inbox alert is informational only).
327
+ The next tick automatically re-picks the pending work item; nothing needs clearing on the engine side.
309
328
 
310
329
  ### Branch-leak mitigation
311
330
 
@@ -344,9 +363,9 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
344
363
  | `engine/shared.js` — `CHECKOUT_MODES`, `validateCheckoutMode`, `resolveCheckoutMode`, `isLiveCheckoutProject` | Enum + validator + back-compat resolver (P-a3f9b201; consolidated W-mqiaw974). |
345
364
  | `engine/shared.js` — `resolveLiveCheckoutAutoReset` + `ENGINE_DEFAULTS.liveCheckoutAutoReset` | Pure precedence resolver (per-project boolean > fleet-wide engine default > false) + the fleet-wide default (OFF as of W-mrawgw4q000a6bff — was ON as of W-mqzbbhn2). Gates the dirty-tree auto-reset in `prepareLiveCheckout` (W-mqvejug6000eeb20); in `engine.js spawnAgent` only fires as a fallback after auto-stash fails/is disabled (W-mrawgw4q000a6bff). |
346
365
  | `engine/shared.js` — `resolveSpawnPaths` | Returns `{ cwd: localPath, worktreeRootDir: null, liveMode: true }` for live projects (P-a3f9b202). |
347
- | `engine/live-checkout.js` — `prepareLiveCheckout` | Pure helper: dirty check, mid-operation / detached-HEAD preflight (incl. `BISECT_LOG`; throw-on-git-dir-failure; exit-1-only detached), original-ref capture, **already-on-branch fast path**, `refs/heads/<branch>` existence check, branch resolution from HEAD (no fetch — issue #226), **no-half-switch + `blob-fetch`/`worktree-conflict` classification** for partial-clone hydration failures and cross-worktree branch conflicts, **opt-in dirty auto-reset** (`git fetch origin` + `reset --hard origin/<branch>` + re-check + `live-checkout-autoreset-<wiId>` note when `liveCheckoutAutoReset` is on), **50 MB git maxBuffer** (P-a3f9b203; preflight + capture P-b2e8d4a6; hardening PL-live-checkout-reliability-hardening; auto-reset + maxBuffer W-mqvejug6000eeb20). |
366
+ | `engine/live-checkout.js` — `prepareLiveCheckout` | Pure helper by default: pre-mutation mid-operation / detached-HEAD preflight, original-ref capture, dirty check, **already-on-branch fast path**, `refs/heads/<branch>` existence check, branch resolution from HEAD (no fetch — issue #226), **no-half-switch + `blob-fetch`/`worktree-conflict` classification**, and opt-in destructive WIP cleanup (`reset --hard HEAD` + `clean -fd` + re-check + audit note) that preserves the branch and committed history. Git probes use a **50 MB maxBuffer**. |
348
367
  | `engine/live-checkout.js` — `restoreLiveCheckoutAtDispatchEnd` | Dispatch-end auto-restore (plain `git checkout <originalRef>`, never `--force`/reset/clean/stash, best-effort) + **self-healing dirty recovery** (auto-commit agent WIP onto the agent branch) + `live-checkout-failed-<dispatchId>` terminal-failure alert + `live-checkout-branch-<dispatchId>` fallback notify (now also on unexpected restore errors) (P-d9e6b2c4; self-heal PL-live-checkout-reliability-hardening). |
349
- | `engine/live-checkout.js` — `resolveLiveCheckoutAutoStash`, `performLiveCheckoutAutoStash`, `applyLiveCheckoutAutoStash` | Opt-in auto-stash: resolver (per-project boolean wins, else `engine.liveCheckoutAutoStash`, else false) + `git stash push --include-untracked` runner returning `{ ok, stashMessage, error? }` (never throws on git failure) + the `applyLiveCheckoutAutoStash` orchestrator (stashre-preflight → clear retry stamp → operator inbox note) returning a `{ outcome:'unchanged'\|'stashed'\|'threw' }` discriminant. Engine never auto-pops (W-mqtvnnj1000357fa). |
368
+ | `engine/live-checkout.js` — `resolveLiveCheckoutAutoStash`, `performLiveCheckoutAutoStash`, `applyLiveCheckoutAutoStash` | Auto-stash resolver (per-project boolean, then fleet setting, then default true) + `git stash push --include-untracked` runner + stash/re-preflight orchestration. Engine never auto-pops (W-mqtvnnj1000357fa). |
350
369
  | `engine/live-checkout.js` — `maybeRestoreLiveCheckoutFromRecord` | Shared wrapper that fires the dispatch-end restore from a persisted dispatch record; used by `cli.js` + both `timeout.js` reaping paths so a restart-spanning live dispatch is never stranded (PL-live-checkout-reliability-hardening). |
351
370
  | `engine.js` — `spawnAgent` live-mode block | Calls `prepareLiveCheckout`, delegates opt-in auto-stash to `applyLiveCheckoutAutoStash` on a dirty tree (W-mqtvnnj1000357fa), handles dirty / throw branches, gates `git worktree add` on `!liveMode` (P-a3f9b204). |
352
371
  | `engine.js` — `spawnAgent` mid-op / detached-HEAD refusal block | Emits `LIVE_CHECKOUT_MID_OPERATION`, writes `live-checkout-blocked-<wi-id>` alert, stamps `_pendingReason: 'live_checkout_mid_operation'` / `'live_checkout_detached_head'` (P-c5a1f3b8). |
@@ -53,6 +53,8 @@ const NON_MUTATING_DISPATCH_TYPES = new Set([
53
53
  // PR source branch always differs and causes phantom auto-cancellation (#369).
54
54
  const PR_BRANCH_TARGETED_TYPES = new Set([
55
55
  WORK_TYPE.FIX,
56
+ WORK_TYPE.BUILD_FIX_COMPLEX,
57
+ WORK_TYPE.TEST,
56
58
  WORK_TYPE.VERIFY,
57
59
  WORK_TYPE.DECOMPOSE,
58
60
  ]);
@@ -148,7 +150,7 @@ function getPrDispatchDedupeKey(entry) {
148
150
  if (!entry?.type) return null;
149
151
  const targetKey = getPrDispatchTargetKey(entry);
150
152
  if (!targetKey) return null;
151
- const type = entry.type === WORK_TYPE.FIX ? WORK_TYPE.FIX : entry.type;
153
+ const type = shared.isFixLikeWorkType(entry.type) ? WORK_TYPE.FIX : entry.type;
152
154
  return `${targetKey}:${type}`;
153
155
  }
154
156
 
@@ -197,7 +199,7 @@ function findActivePrOrBranchLock(dispatch, item) {
197
199
  // PR-target dedup is FIX-only: a FIX shouldn't stack on top of another FIX
198
200
  // for the same PR, but a REVIEW + FIX pair targeting the same PR is the
199
201
  // normal review-then-fix flow and must not be dedup'd here.
200
- if (item?.type === WORK_TYPE.FIX) {
202
+ if (shared.isFixLikeWorkType(item?.type)) {
201
203
  const prTargetKey = getPrDispatchTargetKey(item);
202
204
  if (prTargetKey) {
203
205
  const existing = active.find(d => getPrDispatchTargetKey(d) === prTargetKey);
@@ -703,7 +705,7 @@ async function pruneStalePrDispatchesAsync(config = queries.getConfig()) {
703
705
  for (const entry of pending) {
704
706
  const reason = reasonsById.get(entry.id);
705
707
  if (!reason) continue;
706
- if (entry.type !== WORK_TYPE.FIX || !_MERGED_OR_ABANDONED_REASON_RE.test(reason)) continue;
708
+ if (!shared.isFixLikeWorkType(entry.type) || !_MERGED_OR_ABANDONED_REASON_RE.test(reason)) continue;
707
709
  try {
708
710
  const wi = _resolveWorkItemForPrunedEntry(entry);
709
711
  const scopePaths = shared.extractScopeFilePathsFromWorkItem(wi);
@@ -1559,7 +1559,7 @@ function isPrAttachmentRequired(type, item, meta = {}) {
1559
1559
  // PR — the agent updates meta.pr in place. The meta.pr short-circuit beats
1560
1560
  // the explicit-flag fallthrough so a legacy requiresPr:true fix doesn't
1561
1561
  // trigger the contract when there's already a PR attached.
1562
- if ((type === WORK_TYPE.FIX || type === WORK_TYPE.TEST) && meta?.pr) return false;
1562
+ if ((shared.isFixLikeWorkType(type) || type === WORK_TYPE.TEST) && meta?.pr) return false;
1563
1563
 
1564
1564
  // Standalone test work is usually pure build/run/verify. It should only be
1565
1565
  // PR-required when the caller explicitly marks it as file-changing work.
@@ -1568,7 +1568,7 @@ function isPrAttachmentRequired(type, item, meta = {}) {
1568
1568
  return explicit
1569
1569
  || type === WORK_TYPE.IMPLEMENT
1570
1570
  || type === WORK_TYPE.IMPLEMENT_LARGE
1571
- || type === WORK_TYPE.FIX;
1571
+ || shared.isFixLikeWorkType(type);
1572
1572
  }
1573
1573
 
1574
1574
  function readOptionalJsonStrict(filePath, label, validate) {
@@ -5494,7 +5494,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5494
5494
  // Auto-recover: if a failed implement/fix/test agent created PRs, it likely succeeded before the failure surfaced.
5495
5495
  // P-d2a8f6c1: skip auto-recovery for nonce-mismatched dispatches — a forged
5496
5496
  // report shouldn't be able to ride PRs into a "done" status.
5497
- const prCreatingType = type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX || type === WORK_TYPE.TEST;
5497
+ const prCreatingType = type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || shared.isFixLikeWorkType(type) || type === WORK_TYPE.TEST;
5498
5498
  const autoRecovered = !nonceMismatch && !agentReportedFailure && !isSuccess && prsCreatedCount > 0 && prCreatingType && !!meta?.item?.id;
5499
5499
  if (autoRecovered) {
5500
5500
  log('info', `Auto-recovery: agent failed but created ${prsCreatedCount} PR(s) — upgrading ${meta.item.id} to done`);
@@ -5792,14 +5792,16 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5792
5792
  try { stampPrdItemWorkItemId(meta.item.id, meta.item.sourcePlan); } catch (err) { log('warn', `stampPrdItemWorkItemId: ${err.message}`); }
5793
5793
  }
5794
5794
  promoteCompletionArtifacts(meta, agentId, dispatchItem.id, structuredCompletion, { resultSummary });
5795
- // M003 — auto-dispatch a live-validation WI when a coding WI completes.
5796
- try { autoDispatchLiveValidationWi(meta, config); } catch (err) { log('warn', `autoDispatchLiveValidationWi: ${err.message}`); }
5797
5795
  // P-mqyp0009y025z6a7 — goal invalidation: cancel any pending/queued WIs
5798
5796
  // listed in the completion report's optional `invalidates[]` field.
5799
5797
  if (structuredCompletion && Array.isArray(structuredCompletion.invalidates) && structuredCompletion.invalidates.length > 0) {
5800
5798
  try { applyGoalInvalidation(meta.item.id, structuredCompletion.invalidates, config); } catch (err) { log('warn', `applyGoalInvalidation: ${err.message}`); }
5801
5799
  }
5802
5800
  }
5801
+ if (effectiveSuccess && !skipDoneStatus) {
5802
+ // Coding WIs and direct PR-sourced fix dispatches share this follow-up path.
5803
+ try { autoDispatchLiveValidationWi(meta, config, dispatchItem); } catch (err) { log('warn', `autoDispatchLiveValidationWi: ${err.message}`); }
5804
+ }
5803
5805
  // Failure retry is handled by completeDispatch in dispatch.js — not duplicated here.
5804
5806
  // Only clear _decomposing flag on failure so decompose items don't get permanently stuck.
5805
5807
  if (!effectiveSuccess && meta?.item?.id && type === WORK_TYPE.DECOMPOSE) {
@@ -5994,7 +5996,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5994
5996
 
5995
5997
  let prFixBranchChange = null;
5996
5998
  let prFixBuildStillFailing = null;
5997
- if (type === WORK_TYPE.FIX && effectiveSuccess && meta?.pr?.id) {
5999
+ if (shared.isFixLikeWorkType(type) && effectiveSuccess && meta?.pr?.id) {
5998
6000
  try {
5999
6001
  prFixBranchChange = await detectPrFixBranchChange(meta, config);
6000
6002
  } catch (err) {
@@ -6136,7 +6138,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6136
6138
  } else if (type === WORK_TYPE.REVIEW) {
6137
6139
  log('warn', `Skipping PR review metadata update for ${meta?.pr?.id || meta?.pr?.url || '(unknown PR)'} because review dispatch ${dispatchItem.id} did not complete cleanly`);
6138
6140
  }
6139
- if (type === WORK_TYPE.FIX && effectiveSuccess) {
6141
+ if (shared.isFixLikeWorkType(type) && effectiveSuccess) {
6140
6142
  updatePrAfterFix(meta?.pr, meta?.project, meta?.source, {
6141
6143
  branchChanged: fixCompletionChangedBranch(structuredCompletion),
6142
6144
  automationCauseKey: meta?.automationCauseKey,
@@ -6173,7 +6175,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6173
6175
  }
6174
6176
  } catch (err) { log('warn', `PRD sync after fix: ${err.message}`); }
6175
6177
  }
6176
- } else if (type === WORK_TYPE.FIX && meta?.pr?.id) {
6178
+ } else if (shared.isFixLikeWorkType(type) && meta?.pr?.id) {
6177
6179
  // W-mph6br6a0006a2b9 (F9): the fix dispatch did not complete cleanly
6178
6180
  // (agent crash, non-zero exit, hard contract failure, or nonce mismatch),
6179
6181
  // so updatePrAfterFix above was skipped. Without a write here the PR's
@@ -6800,37 +6802,9 @@ function collapseAllDuplicatePrRecords(config) {
6800
6802
  // a coding WI (not one of the validation types itself). Skips if the coding WI
6801
6803
  // has no PR.
6802
6804
  //
6803
- // W-mrdq22j700056c6c — gated to real code-authoring completions only. Only
6804
- // item.type in {implement, implement:large, fix} ever spawns a follow-up
6805
- // validation WI. Every other work-item type (review, test, verify, setup,
6806
- // explore, ask, docs, decompose, ...) is excluded even when it resolves a PR
6807
- // ref and autoDispatch is on, because those completions don't author a code
6808
- // diff worth re-validating — e.g. a `review` dispatch that merely comments on
6809
- // someone else's PR previously spawned a spurious "Validate: Review opg PR
6810
- // #792 ..." follow-up. See the ELIGIBLE_TYPES check below.
6811
- //
6812
- // Files exactly ONE validation WI per coding-WI completion (W-mrhybval0001).
6813
- // `liveValidation.type` still governs CHECKOUT ROUTING — the set of work-item
6814
- // types that run in-place on the live checkout (see resolveCheckoutMode's
6815
- // membership check) — but auto-validation no longer fans that array out into
6816
- // one validation WI per type. That earlier behavior (W-mr2m1ute000a9c01)
6817
- // conflated "which types run live" with "how many validation tasks to file";
6818
- // a multi-select routing config would silently spawn N validation WIs after
6819
- // every coding completion. Validation is a single downstream step, so we
6820
- // collapse to one WI.
6821
- //
6822
- // liveValidation.type may be a single string (legacy / back-compat) or an
6823
- // array of strings (multi-select hybrid types). We choose ONE validation type
6824
- // for the single WI. It MUST be a member of lvTypes, otherwise resolveCheckoutMode
6825
- // would route the validation WI to a worktree instead of the live checkout
6826
- // (defeating hybrid validation). Preference order: the canonical `test` type
6827
- // when configured, else the first configured live type. The completing item's
6828
- // own type is excluded so a validation-type WI can't recursively validate
6829
- // itself (anti-loop).
6830
- //
6831
- // Only these item.type values ever trigger this hook (see ELIGIBLE_TYPES
6832
- // below) — real code-authoring completions, not review/test/verify/setup/
6833
- // explore/ask/docs/decompose completions.
6805
+ // Only real code-authoring completions trigger the hook. The follow-up is
6806
+ // always a canonical `test` WI with the PR-focused `build-and-test` playbook;
6807
+ // liveValidation.type controls routing, not the semantic type of the task.
6834
6808
  //
6835
6809
  // Deduplicates per codingWiId: any non-terminal WI with
6836
6810
  // meta.liveValidationFor === codingWiId blocks a second dispatch, so only one
@@ -6852,22 +6826,30 @@ function collapseAllDuplicatePrRecords(config) {
6852
6826
  // verify, setup, explore, ask, docs, decompose, ...) is excluded even when it
6853
6827
  // resolves a PR ref, because those completions don't author a code diff
6854
6828
  // worth re-validating.
6855
- const ELIGIBLE_LIVE_VALIDATION_TYPES = new Set([WORK_TYPE.IMPLEMENT, WORK_TYPE.IMPLEMENT_LARGE, WORK_TYPE.FIX]);
6829
+ const ELIGIBLE_LIVE_VALIDATION_TYPES = new Set([
6830
+ WORK_TYPE.IMPLEMENT,
6831
+ WORK_TYPE.IMPLEMENT_LARGE,
6832
+ WORK_TYPE.FIX,
6833
+ WORK_TYPE.BUILD_FIX_COMPLEX,
6834
+ ]);
6856
6835
 
6857
- function autoDispatchLiveValidationWi(meta, config) {
6858
- const item = meta?.item;
6859
- if (!item?.id || !item?.type) return;
6836
+ function autoDispatchLiveValidationWi(meta, config, dispatchItem = null) {
6837
+ const item = meta?.item || null;
6838
+ const sourceType = item?.type || dispatchItem?.type || null;
6839
+ const sourceKey = item?.id || dispatchItem?.id || null;
6840
+ if (!sourceKey || !sourceType) return;
6841
+ if (!item && !meta?.pr) return;
6860
6842
 
6861
6843
  // Never re-trigger off the completion of a WI that was itself auto-created
6862
6844
  // as a live-validation follow-up — that's what causes the infinite
6863
6845
  // "Validate: Validate: ..." cascade (W-mr9u39az000db5c2 / issue #708).
6864
- if (item.meta && item.meta.liveValidationFor) return;
6846
+ if (item?.meta && item.meta.liveValidationFor) return;
6865
6847
 
6866
- // W-mrdq22j700056c6c — only implement/implement:large/fix completions ever
6848
+ // Only implement/implement:large/fix/build-fix-complex completions ever
6867
6849
  // author a real code diff worth validating. Review, test, verify, setup,
6868
6850
  // explore, ask, docs, decompose, etc. completions must never spawn a
6869
6851
  // "Validate: ..." follow-up, even when they resolve a PR reference.
6870
- if (!ELIGIBLE_LIVE_VALIDATION_TYPES.has(item.type)) return;
6852
+ if (!ELIGIBLE_LIVE_VALIDATION_TYPES.has(sourceType)) return;
6871
6853
 
6872
6854
  // Issue #716 — QA Session infrastructure WIs (SETUP/DRAFT/EXECUTE, tagged
6873
6855
  // via meta.sessionId and always dispatched with skipPr:true) never own a
@@ -6877,7 +6859,7 @@ function autoDispatchLiveValidationWi(meta, config) {
6877
6859
  // for all three phases) spawns spurious "Validate: QA Session ..." WIs the
6878
6860
  // moment any PR reference resolves against it (see the skipPr guard below
6879
6861
  // resolveWorkItemPrRecord in engine.js for the matching pre-dispatch fix).
6880
- if (item.skipPr || (item.meta && item.meta.sessionId)) return;
6862
+ if (item?.skipPr || (item?.meta && item.meta.sessionId)) return;
6881
6863
 
6882
6864
  const projectName = meta?.project?.name;
6883
6865
  if (!projectName) return;
@@ -6887,23 +6869,8 @@ function autoDispatchLiveValidationWi(meta, config) {
6887
6869
  const project = projects.find(p => p && p.name === projectName);
6888
6870
  if (!project) return;
6889
6871
 
6890
- const lv = project.liveValidation;
6891
- if (!lv || lv.autoDispatch !== true || !lv.type) return;
6892
-
6893
- const lvTypes = Array.isArray(lv.type) ? lv.type : [lv.type];
6894
-
6895
- // Candidate validation types = configured live types minus the completing
6896
- // item's own type (avoid an infinite validate-the-validation loop).
6897
- const candidates = lvTypes.filter(t => t && t !== item.type);
6898
- if (candidates.length === 0) return;
6899
-
6900
- // Collapse to a SINGLE validation type: prefer the canonical `test` type when
6901
- // it is a configured live type, else the first configured live type. Either
6902
- // way the chosen type is a member of lvTypes, so the validation WI routes to
6903
- // the live checkout (not a worktree).
6904
- const validationType = candidates.includes(WORK_TYPE.TEST)
6905
- ? WORK_TYPE.TEST
6906
- : candidates[0];
6872
+ const validationType = shared.resolveLiveValidationAutoDispatchType(project);
6873
+ if (!validationType) return;
6907
6874
 
6908
6875
  // Resolve PR reference: prefer the canonical stamped _pr field (set by
6909
6876
  // stampWiPrRef which runs earlier in runPostCompletionHooks), then fall
@@ -6915,11 +6882,22 @@ function autoDispatchLiveValidationWi(meta, config) {
6915
6882
  // (W-mq18ec6h000p7b87) and would otherwise let a non-fix WI (e.g.
6916
6883
  // 'implement'/'docs') that merely *mentions* an unrelated PR number in
6917
6884
  // prose trigger a live-validation dispatch against the wrong PR.
6918
- const prRef = item._pr || item._prUrl || shared.extractStructuredWorkItemPrRef(item) || null;
6885
+ const prRef = item
6886
+ ? (item._pr || item._prUrl || shared.extractStructuredWorkItemPrRef(item) || null)
6887
+ : (meta?.pr || null);
6919
6888
  if (!prRef) return;
6920
6889
 
6921
- const codingWiId = item.id;
6922
- const wiScope = resolveWorkItemScope(meta);
6890
+ const prUrl = item?._prUrl || (typeof prRef === 'object'
6891
+ ? (prRef.url || prRef.webUrl || prRef.remoteUrl || null)
6892
+ : (/^https?:\/\//i.test(String(prRef)) ? String(prRef) : null));
6893
+ const prId = typeof prRef === 'object'
6894
+ ? (prRef.id || prRef.pr_id || prRef.prId || prRef.pullRequestId || shared.getPrNumber(prRef))
6895
+ : String(prRef);
6896
+ const prTarget = prUrl || (prId != null ? String(prId) : null);
6897
+ if (!prTarget) return;
6898
+
6899
+ const codingWiId = sourceKey;
6900
+ const wiScope = item ? resolveWorkItemScope(meta) : projectName;
6923
6901
  if (!wiScope) return;
6924
6902
 
6925
6903
  try {
@@ -6942,7 +6920,7 @@ function autoDispatchLiveValidationWi(meta, config) {
6942
6920
  return items;
6943
6921
  }
6944
6922
 
6945
- const proposedTitle = 'Validate: ' + (item.title || codingWiId);
6923
+ const proposedTitle = 'Validate: ' + (item?.title || meta?.pr?.title || codingWiId);
6946
6924
  // Defense-in-depth (belt-and-suspenders alongside the meta.liveValidationFor
6947
6925
  // guard above): never create a doubly-nested "Validate: Validate: ..."
6948
6926
  // title even if some future caller manages to invoke this function
@@ -6957,11 +6935,20 @@ function autoDispatchLiveValidationWi(meta, config) {
6957
6935
  title: proposedTitle,
6958
6936
  type: validationType,
6959
6937
  status: WI_STATUS.PENDING,
6960
- depends_on: [codingWiId],
6961
- references: [prRef],
6962
- meta: { liveValidationFor: codingWiId, liveValidationType: validationType },
6938
+ depends_on: item?.id ? [codingWiId] : [],
6939
+ targetPr: prTarget,
6940
+ pr_id: prId != null ? String(prId) : prTarget,
6941
+ references: prUrl
6942
+ ? [{ url: prUrl, label: 'Source PR' }]
6943
+ : [prTarget],
6944
+ meta: {
6945
+ liveValidationFor: codingWiId,
6946
+ liveValidationType: validationType,
6947
+ playbook: 'build-and-test',
6948
+ ...(dispatchItem?.id ? { sourceDispatchId: dispatchItem.id } : {}),
6949
+ },
6963
6950
  project: projectName,
6964
- priority: item.priority || 'medium',
6951
+ priority: item?.priority || 'medium',
6965
6952
  created: ts(),
6966
6953
  createdBy: 'lifecycle:live-validation-auto-dispatch',
6967
6954
  };