@yemi33/minions 0.1.2380 → 0.1.2382
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.
- package/bin/minions.js +41 -2
- package/dashboard/js/refresh.js +9 -3
- package/dashboard/js/render-other.js +81 -23
- package/dashboard/js/render-work-items.js +61 -20
- package/dashboard/js/settings.js +47 -25
- package/dashboard/pages/engine.html +6 -0
- package/dashboard.js +126 -62
- package/docs/README.md +1 -0
- package/docs/claude-md-propagation.md +118 -0
- package/docs/engine-restart.md +10 -0
- package/docs/live-checkout-mode.md +45 -26
- package/docs/worktree-lifecycle.md +9 -0
- package/engine/claude-md-context.js +195 -0
- package/engine/consolidation.js +47 -3
- package/engine/db/migrations/026-pre-dispatch-rejections.js +55 -0
- package/engine/dispatch.js +78 -24
- package/engine/lifecycle.js +129 -84
- package/engine/live-checkout.js +193 -149
- package/engine/pipeline.js +147 -20
- package/engine/playbook.js +47 -15
- package/engine/prd-store.js +14 -6
- package/engine/quarantine-refs.js +33 -0
- package/engine/queries.js +8 -6
- package/engine/restart-health.js +69 -4
- package/engine/routing.js +1 -1
- package/engine/runtimes/claude.js +6 -0
- package/engine/runtimes/codex.js +18 -0
- package/engine/runtimes/copilot.js +7 -0
- package/engine/shared.js +199 -106
- package/engine/supervisor.js +72 -16
- package/engine/timeout.js +87 -16
- package/engine/untrusted-fence.js +2 -1
- package/engine.js +301 -31
- package/package.json +1 -1
- package/prompts/cc-system.md +7 -7
|
@@ -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
|
|
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
|
|
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.
|
|
31
|
+
### 2. Dirty-tree policy
|
|
32
32
|
|
|
33
|
-
**W-mrcifup2000c218a — detected at ALLOCATION time first.**
|
|
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
|
|
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
|
-
|
|
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.
|
|
46
|
+
#### 2b. Auto-stash on dirty (W-mqtvnnj1000357fa)
|
|
47
47
|
|
|
48
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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.
|
|
69
|
-
4.
|
|
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** —
|
|
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`
|
|
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
|
|
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
|
|
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 —
|
|
262
|
-
//
|
|
263
|
-
// (
|
|
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
|
|
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-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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` |
|
|
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). |
|
|
@@ -77,6 +77,15 @@ distinguishes the **original worktree-preflight issue** from a
|
|
|
77
77
|
|
|
78
78
|
## Quarantine path (dirty / divergent)
|
|
79
79
|
|
|
80
|
+
Before quarantine, a reused worktree that is filesystem-clean and strictly
|
|
81
|
+
ahead of `origin/<branch>` is pushed fast-forward to origin. Dispatch close
|
|
82
|
+
and stale-orphan cleanup run the same preservation check before pool return or
|
|
83
|
+
worktree removal. Dirty trees and branches confirmed behind origin retain the
|
|
84
|
+
quarantine fallback; transient probe, fetch, auth, and push failures leave clean
|
|
85
|
+
ahead commits in place for retry. Recoverable
|
|
86
|
+
`refs/minions/quarantine/*` and `refs/minions/quarantine-wip/*` refs are listed
|
|
87
|
+
on Dashboard → Engine under **Quarantined Work**.
|
|
88
|
+
|
|
80
89
|
When `discoverFromWorkItems` finds a worktree in a `WORKTREE_DIRTY`,
|
|
81
90
|
`WORKTREE_DIVERGENT`, or post-stuck state, `_quarantineDirtyWorktree`
|
|
82
91
|
moves it to `<root>/.quarantined/<basename>-<utc>` and lets the next tick
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/claude-md-context.js — CLAUDE.md propagation for non-Claude runtimes.
|
|
3
|
+
*
|
|
4
|
+
* W-mrdon0pe000l045a. CLAUDE.md auto-discovery is a Claude-Code-CLI-only
|
|
5
|
+
* convention (the claude binary reads CLAUDE.md itself at startup). Copilot and
|
|
6
|
+
* Codex have NO equivalent auto-load, so repo-authored CLAUDE.md files — which
|
|
7
|
+
* frequently carry load-bearing "must be kept in sync" rules and name blessed
|
|
8
|
+
* tooling — are structurally invisible to a Copilot/Codex-based fleet.
|
|
9
|
+
*
|
|
10
|
+
* This module discovers the *nearest applicable* CLAUDE.md files for a dispatch
|
|
11
|
+
* (bounded walk-UP from path hints toward the project root — NOT a full monorepo
|
|
12
|
+
* walk) and renders them into a prompt block. engine/playbook.js injects that
|
|
13
|
+
* block as a "Project instructions (CLAUDE.md)" context layer, gated on the
|
|
14
|
+
* runtime capability `claudeMdNativeDiscovery` (skip when true → Claude) and the
|
|
15
|
+
* `propagateClaudeMdForNonClaudeRuntimes` config flag.
|
|
16
|
+
*
|
|
17
|
+
* The whole point is to be CHEAP: it only stats CLAUDE.md at each ancestor
|
|
18
|
+
* directory of a small set of hint dirs (capped depth + capped file count), so
|
|
19
|
+
* it never walks a huge monorepo like office/src.
|
|
20
|
+
*
|
|
21
|
+
* This module is pure file discovery + string building — no engine state, no
|
|
22
|
+
* locking, no writes.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const { truncateTextBytes, ENGINE_DEFAULTS } = require('./shared');
|
|
28
|
+
|
|
29
|
+
const CLAUDE_MD_FILENAME = 'CLAUDE.md';
|
|
30
|
+
// Cap the number of CLAUDE.md files collected across all hints — defense against
|
|
31
|
+
// a dispatch with many path hints spread across a deep tree.
|
|
32
|
+
const MAX_FILES = 8;
|
|
33
|
+
// Cap the walk-up depth from any single hint dir — defense for pathological deep
|
|
34
|
+
// Windows paths so a single hint can't spin the loop unbounded.
|
|
35
|
+
const MAX_WALK_DEPTH = 24;
|
|
36
|
+
// Byte cap for the whole injected block. Reuses the same budget the sibling
|
|
37
|
+
// notes / agent-memory context layers use (maxNotesPromptBytes), so CLAUDE.md
|
|
38
|
+
// propagation can never balloon a prompt on a large monorepo.
|
|
39
|
+
const DEFAULT_MAX_BYTES = ENGINE_DEFAULTS.maxNotesPromptBytes;
|
|
40
|
+
|
|
41
|
+
function _pathEq(a, b) {
|
|
42
|
+
if (process.platform === 'win32') return a.toLowerCase() === b.toLowerCase();
|
|
43
|
+
return a === b;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Is `target` inside (or equal to) `root`?
|
|
47
|
+
function _isWithin(root, target) {
|
|
48
|
+
const rel = path.relative(root, target);
|
|
49
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Normalize a hint (a file OR directory, relative-to-root OR absolute) into an
|
|
54
|
+
* absolute directory contained within `root`. Returns null when the hint
|
|
55
|
+
* escapes the project root (path traversal guard). Non-existent hints are still
|
|
56
|
+
* resolved structurally (extension ⇒ file ⇒ parent dir; otherwise dir).
|
|
57
|
+
*/
|
|
58
|
+
function _hintToDir(root, hint) {
|
|
59
|
+
if (!hint || typeof hint !== 'string' || !hint.trim()) return null;
|
|
60
|
+
const cleaned = hint.trim();
|
|
61
|
+
let abs = path.isAbsolute(cleaned) ? cleaned : path.join(root, cleaned);
|
|
62
|
+
abs = path.resolve(abs);
|
|
63
|
+
if (!_isWithin(root, abs)) return null;
|
|
64
|
+
try {
|
|
65
|
+
const st = fs.statSync(abs);
|
|
66
|
+
if (st.isDirectory()) return abs;
|
|
67
|
+
return path.dirname(abs);
|
|
68
|
+
} catch {
|
|
69
|
+
// Doesn't exist on disk — infer file vs dir from a trailing extension.
|
|
70
|
+
return path.extname(abs) ? path.dirname(abs) : abs;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Discover CLAUDE.md files by walking UP from each hint directory to the project
|
|
76
|
+
* root, plus the project root itself as a guaranteed fallback. Returns entries
|
|
77
|
+
* ordered nearest-first (deepest directory first, project root last) so the
|
|
78
|
+
* caller renders "nearest wins" — the most specific instructions lead.
|
|
79
|
+
*
|
|
80
|
+
* @param {{ projectRoot: string, pathHints?: string[] }} opts
|
|
81
|
+
* @returns {{ absPath: string, relPath: string, depth: number }[]}
|
|
82
|
+
*/
|
|
83
|
+
function discoverClaudeMdFiles({ projectRoot, pathHints = [] } = {}) {
|
|
84
|
+
if (!projectRoot || typeof projectRoot !== 'string') return [];
|
|
85
|
+
const root = path.resolve(projectRoot);
|
|
86
|
+
try { if (!fs.statSync(root).isDirectory()) return []; } catch { return []; }
|
|
87
|
+
|
|
88
|
+
// Starting dirs: each valid hint dir, then the root itself (fallback so the
|
|
89
|
+
// project-root CLAUDE.md is always considered even with zero hints).
|
|
90
|
+
const startDirs = [];
|
|
91
|
+
for (const h of Array.isArray(pathHints) ? pathHints : []) {
|
|
92
|
+
const d = _hintToDir(root, h);
|
|
93
|
+
if (d) startDirs.push(d);
|
|
94
|
+
}
|
|
95
|
+
startDirs.push(root);
|
|
96
|
+
|
|
97
|
+
const found = new Map(); // absPath -> { depth, rel }
|
|
98
|
+
for (const start of startDirs) {
|
|
99
|
+
let dir = start;
|
|
100
|
+
let steps = 0;
|
|
101
|
+
while (steps <= MAX_WALK_DEPTH) {
|
|
102
|
+
const candidate = path.join(dir, CLAUDE_MD_FILENAME);
|
|
103
|
+
if (!found.has(candidate)) {
|
|
104
|
+
try {
|
|
105
|
+
if (fs.statSync(candidate).isFile()) {
|
|
106
|
+
const rel = path.relative(root, dir);
|
|
107
|
+
const depth = (rel === '' || rel === '.') ? 0 : rel.split(/[\\/]/).length;
|
|
108
|
+
found.set(candidate, { depth });
|
|
109
|
+
}
|
|
110
|
+
} catch { /* no CLAUDE.md at this level */ }
|
|
111
|
+
}
|
|
112
|
+
if (_pathEq(dir, root)) break; // stop at project root (never above)
|
|
113
|
+
const parent = path.dirname(dir);
|
|
114
|
+
if (_pathEq(parent, dir)) break; // filesystem-root guard
|
|
115
|
+
if (!_isWithin(root, parent)) break;
|
|
116
|
+
dir = parent;
|
|
117
|
+
steps++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const entries = Array.from(found.entries()).map(([absPath, meta]) => ({
|
|
122
|
+
absPath,
|
|
123
|
+
relPath: (path.relative(root, absPath) || CLAUDE_MD_FILENAME).replace(/\\/g, '/'),
|
|
124
|
+
depth: meta.depth,
|
|
125
|
+
}));
|
|
126
|
+
// nearest-first: deepest dir first; stable tiebreak on relPath for determinism.
|
|
127
|
+
entries.sort((a, b) => (b.depth - a.depth) || a.relPath.localeCompare(b.relPath));
|
|
128
|
+
return entries.slice(0, MAX_FILES);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the CLAUDE.md propagation block (inner content, no outer header/fence —
|
|
133
|
+
* engine/playbook.js owns those). Reads each discovered file, labels it by its
|
|
134
|
+
* repo-relative path, and truncates to stay within `maxBytes` total. Returns
|
|
135
|
+
* `{ block, files }` where `block` is '' when nothing was found or all files
|
|
136
|
+
* were empty.
|
|
137
|
+
*
|
|
138
|
+
* @param {{ projectRoot: string, pathHints?: string[], maxBytes?: number }} opts
|
|
139
|
+
*/
|
|
140
|
+
function buildClaudeMdPropagationBlock({ projectRoot, pathHints = [], maxBytes = DEFAULT_MAX_BYTES } = {}) {
|
|
141
|
+
const files = discoverClaudeMdFiles({ projectRoot, pathHints });
|
|
142
|
+
if (files.length === 0) return { block: '', files: [] };
|
|
143
|
+
|
|
144
|
+
const cap = Math.max(256, Number(maxBytes) || DEFAULT_MAX_BYTES);
|
|
145
|
+
const parts = [];
|
|
146
|
+
const included = [];
|
|
147
|
+
let used = 0;
|
|
148
|
+
|
|
149
|
+
for (const f of files) {
|
|
150
|
+
let content;
|
|
151
|
+
try { content = fs.readFileSync(f.absPath, 'utf8'); } catch { continue; }
|
|
152
|
+
if (!content || !content.trim()) continue;
|
|
153
|
+
|
|
154
|
+
const heading = `\n\n### ${f.relPath}\n\n`;
|
|
155
|
+
const headingBytes = Buffer.byteLength(heading, 'utf8');
|
|
156
|
+
const remaining = cap - used - headingBytes;
|
|
157
|
+
if (remaining <= 0) break; // no room even for the next heading
|
|
158
|
+
|
|
159
|
+
const body = truncateTextBytes(content, remaining, '\n\n_...CLAUDE.md truncated (read the full file if needed)_');
|
|
160
|
+
parts.push(heading + body);
|
|
161
|
+
used += headingBytes + Buffer.byteLength(body, 'utf8');
|
|
162
|
+
included.push(f.relPath);
|
|
163
|
+
if (used >= cap) break;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (parts.length === 0) return { block: '', files: [] };
|
|
167
|
+
return { block: parts.join('').replace(/^\n+/, ''), files: included };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Does the named runtime auto-discover CLAUDE.md natively? Reads the adapter's
|
|
172
|
+
* `capabilities.claudeMdNativeDiscovery`. Unknown runtimes return false ("does
|
|
173
|
+
* NOT auto-discover") so propagation is the safe additive default — an
|
|
174
|
+
* unrecognized runtime is far more likely to lack native CLAUDE.md loading than
|
|
175
|
+
* to have it.
|
|
176
|
+
*/
|
|
177
|
+
function runtimeAutoDiscoversClaudeMd(cliName) {
|
|
178
|
+
try {
|
|
179
|
+
const { resolveRuntime } = require('./runtimes');
|
|
180
|
+
const rt = resolveRuntime(cliName);
|
|
181
|
+
return !!(rt && rt.capabilities && rt.capabilities.claudeMdNativeDiscovery);
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
CLAUDE_MD_FILENAME,
|
|
189
|
+
MAX_FILES,
|
|
190
|
+
MAX_WALK_DEPTH,
|
|
191
|
+
DEFAULT_MAX_BYTES,
|
|
192
|
+
discoverClaudeMdFiles,
|
|
193
|
+
buildClaudeMdPropagationBlock,
|
|
194
|
+
runtimeAutoDiscoversClaudeMd,
|
|
195
|
+
};
|
package/engine/consolidation.js
CHANGED
|
@@ -85,6 +85,34 @@ function _readNotesForAppendOrNull() {
|
|
|
85
85
|
return fs.existsSync(NOTES_PATH) ? null : '';
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// Resiliently write notes.md from inside a consolidation lock callback.
|
|
89
|
+
// CRASH GUARD (#858): safeWrite -> renameSync throws EPERM on Windows when
|
|
90
|
+
// notes.md is held open by another process (e.g. a leaked dashboard
|
|
91
|
+
// /state/notes.md read stream). That throw used to propagate out of the
|
|
92
|
+
// async consolidateWithLLM(...) chain / the regex-fallback timer callback with
|
|
93
|
+
// NO surrounding try/catch, becoming an UNHANDLED PROMISE REJECTION (or an
|
|
94
|
+
// uncaught timer exception) that killed the whole engine daemon. Supervisor
|
|
95
|
+
// respawned it, but pooled Copilot ACP agents cannot reattach across restart,
|
|
96
|
+
// so every in-flight agent was orphaned.
|
|
97
|
+
//
|
|
98
|
+
// This wraps safeWrite in shared._retryFsOp (extra exponential-backoff retries
|
|
99
|
+
// on EPERM/EBUSY/EACCES on top of safeWrite's own short retry loop) AND a
|
|
100
|
+
// try/catch, so a transient lock retries then DEFERS: returns false so the
|
|
101
|
+
// caller leaves the inbox notes in place for the next tick instead of crashing.
|
|
102
|
+
// Returns true on success, false if the write could not be completed.
|
|
103
|
+
function _writeNotesOrDefer(content, label) {
|
|
104
|
+
try {
|
|
105
|
+
// Keep the in-lock retry budget small (safeWrite already retries EPERM ~5x
|
|
106
|
+
// internally): a few extra exponential-backoff attempts cover a slightly
|
|
107
|
+
// longer transient hold without stalling the consolidation lock.
|
|
108
|
+
shared._retryFsOp(() => safeWrite(NOTES_PATH, content), `write notes.md (${label})`, { attempts: 3, baseMs: 100 });
|
|
109
|
+
return true;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
log('warn', `${label}: notes.md write failed after retries (${err?.code || ''} ${err?.message || err}) \u2014 deferring, inbox notes left for next cycle`);
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
88
116
|
// Per-agent memory files live under knowledge/agents/<agent>.md and are
|
|
89
117
|
// injected into individual agent prompts (in addition to the broadcast
|
|
90
118
|
// notes.md). See knowledge/agents/README.md for the convention.
|
|
@@ -971,7 +999,17 @@ function consolidateWithLLM(items, existingNotes, files, config) {
|
|
|
971
999
|
fallbackDone = true;
|
|
972
1000
|
if (message) log('warn', message);
|
|
973
1001
|
if (err?.message) log('debug', `LLM error: ${err.message}`);
|
|
974
|
-
|
|
1002
|
+
// CRASH GUARD (#858): _fallback runs from a promise .catch AND from a
|
|
1003
|
+
// setTimeout callback. An uncaught throw from consolidateWithRegex in the
|
|
1004
|
+
// timer path is an uncaught exception that kills the daemon; in the .catch
|
|
1005
|
+
// path it is an unhandled rejection. The notes.md write already defers on
|
|
1006
|
+
// lock contention, but keep a top-level guard so no other regex-fallback
|
|
1007
|
+
// failure can ever crash the engine — leave inbox notes for the next tick.
|
|
1008
|
+
try {
|
|
1009
|
+
consolidateWithRegex(items, files, config);
|
|
1010
|
+
} catch (e) {
|
|
1011
|
+
log('warn', `Regex consolidation fallback failed (${e?.code || ''} ${e?.message || e}) \u2014 inbox notes left for next cycle`);
|
|
1012
|
+
}
|
|
975
1013
|
}
|
|
976
1014
|
|
|
977
1015
|
const llmCall = callLLM(prompt, sysPrompt, {
|
|
@@ -1033,7 +1071,9 @@ function consolidateWithLLM(items, existingNotes, files, config) {
|
|
|
1033
1071
|
// DATA-LOSS GUARD: cap size but archive the overflow instead of
|
|
1034
1072
|
// silently discarding it (see _capNotesPreservingOverflow).
|
|
1035
1073
|
const newContent = _capNotesPreservingOverflow(current + entry);
|
|
1036
|
-
|
|
1074
|
+
// CRASH GUARD (#858): defer (return false) on a transient write lock
|
|
1075
|
+
// instead of throwing out of this async chain and crashing the daemon.
|
|
1076
|
+
if (!_writeNotesOrDefer(newContent, 'LLM consolidation')) return false;
|
|
1037
1077
|
return true;
|
|
1038
1078
|
});
|
|
1039
1079
|
if (!wrote) {
|
|
@@ -1169,7 +1209,10 @@ function consolidateWithRegex(items, files, config) {
|
|
|
1169
1209
|
}
|
|
1170
1210
|
// DATA-LOSS GUARD: cap size but archive the overflow (see LLM path).
|
|
1171
1211
|
const newContent = _capNotesPreservingOverflow(current + entry);
|
|
1172
|
-
|
|
1212
|
+
// CRASH GUARD (#858): defer (return false) on a transient write lock
|
|
1213
|
+
// instead of throwing out of this synchronous fallback (invoked from a
|
|
1214
|
+
// timer callback and a promise .catch) and crashing the daemon.
|
|
1215
|
+
if (!_writeNotesOrDefer(newContent, 'Regex consolidation')) return false;
|
|
1173
1216
|
return true;
|
|
1174
1217
|
});
|
|
1175
1218
|
if (!wrote) {
|
|
@@ -1530,4 +1573,5 @@ module.exports = {
|
|
|
1530
1573
|
buildCondensedKbBody,
|
|
1531
1574
|
_alertContentHash,
|
|
1532
1575
|
_alertHashExists,
|
|
1576
|
+
_writeNotesOrDefer,
|
|
1533
1577
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
function _parseObject(raw) {
|
|
2
|
+
try {
|
|
3
|
+
const value = JSON.parse(raw);
|
|
4
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
5
|
+
} catch {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
version: 26,
|
|
12
|
+
description: 'normalize exhausted pre-dispatch rejections out of execution failures',
|
|
13
|
+
up(db) {
|
|
14
|
+
const rows = db.prepare(`
|
|
15
|
+
SELECT rowid, data
|
|
16
|
+
FROM work_items
|
|
17
|
+
WHERE status = 'failed' AND archived = 0
|
|
18
|
+
`).all();
|
|
19
|
+
const update = db.prepare(`
|
|
20
|
+
UPDATE work_items
|
|
21
|
+
SET status = 'pending', data = ?, updated_at = ?
|
|
22
|
+
WHERE rowid = ? AND archived = 0
|
|
23
|
+
`);
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
for (const row of rows) {
|
|
26
|
+
const item = _parseObject(row.data);
|
|
27
|
+
if (!item || item._failureClass !== 'pre-dispatch-eval-stuck') continue;
|
|
28
|
+
const evaluation = item._preDispatchEval && typeof item._preDispatchEval === 'object'
|
|
29
|
+
? item._preDispatchEval
|
|
30
|
+
: {};
|
|
31
|
+
const evaluatedAt = evaluation.evaluatedAt || item.failedAt || new Date(now).toISOString();
|
|
32
|
+
const history = Array.isArray(evaluation.history) ? evaluation.history : [];
|
|
33
|
+
if (history.length === 0 && evaluation.reason) {
|
|
34
|
+
history.push({
|
|
35
|
+
reason: evaluation.reason,
|
|
36
|
+
evaluatedAt,
|
|
37
|
+
description: evaluation.description || '',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
item.status = 'pending';
|
|
41
|
+
item._pendingReason = 'pre_dispatch_eval_rejected';
|
|
42
|
+
item._preDispatchEval = {
|
|
43
|
+
...evaluation,
|
|
44
|
+
valid: false,
|
|
45
|
+
exhausted: true,
|
|
46
|
+
exhaustedAt: evaluation.exhaustedAt || evaluatedAt,
|
|
47
|
+
history,
|
|
48
|
+
};
|
|
49
|
+
delete item._failureClass;
|
|
50
|
+
delete item.failReason;
|
|
51
|
+
delete item.failedAt;
|
|
52
|
+
update.run(JSON.stringify(item), now, row.rowid);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|