@yemi33/minions 0.1.2160 → 0.1.2162
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/dashboard/docs/typography.md +7 -1
- package/dashboard/js/refresh.js +1 -1
- package/dashboard/js/render-work-items.js +2 -8
- package/dashboard/js/settings.js +39 -0
- package/dashboard/slim/styles.css +11 -4
- package/dashboard/styles.css +14 -5
- package/dashboard.js +35 -0
- package/docs/live-checkout-mode.md +143 -0
- package/engine/cleanup.js +6 -0
- package/engine/dispatch.js +1 -0
- package/engine/live-checkout.js +133 -0
- package/engine/preflight.js +3 -3
- package/engine/project-discovery.js +10 -2
- package/engine/shared.js +65 -6
- package/engine/timeout.js +5 -0
- package/engine/worktree-pool.js +12 -4
- package/engine.js +192 -4
- package/package.json +1 -1
|
@@ -109,6 +109,12 @@ These declarations are intentional and exempt from the tripwire:
|
|
|
109
109
|
- SVG `font-size='90'` inside the `data:image/svg+xml` favicon link in
|
|
110
110
|
`dashboard/layout.html` and `dashboard/slim/layout.html` — runtime
|
|
111
111
|
data URL, CSS variables don't resolve inside it.
|
|
112
|
-
- The `--text-*` token declarations themselves in `:root` blocks.
|
|
112
|
+
- The `--text-*` token declarations themselves in `:root` blocks. Each
|
|
113
|
+
primitive is `calc(<base>px * var(--minions-text-scale))` — the
|
|
114
|
+
`--minions-text-scale` knob (W-mq71mqtx00060fa9) nudges the dashboard's
|
|
115
|
+
real text size from one place (main `1.05`, slim pinned to `1` so the
|
|
116
|
+
slim UI keeps its current sizing). The base px stays the source of truth
|
|
117
|
+
and must match between `dashboard/styles.css` and
|
|
118
|
+
`dashboard/slim/styles.css`.
|
|
113
119
|
|
|
114
120
|
Everything else MUST go through a token.
|
package/dashboard/js/refresh.js
CHANGED
|
@@ -35,12 +35,13 @@ const _WI_ENRICHMENT_FIELDS = [
|
|
|
35
35
|
//
|
|
36
36
|
// Triggers:
|
|
37
37
|
// * _preDispatchEval.valid === false (pre-dispatch acceptance gate rejected)
|
|
38
|
-
// * _pendingReason in { pr_not_found, no_agent, budget_exceeded
|
|
38
|
+
// * _pendingReason in { pr_not_found, no_agent, budget_exceeded }
|
|
39
39
|
//
|
|
40
40
|
// Does NOT trigger for transient reasons that auto-clear:
|
|
41
41
|
// * cooldown / retry_cooldown — wait it out
|
|
42
42
|
// * already_dispatched — engine reconciles on next tick
|
|
43
43
|
// * branch_locked — wait for the holding dispatch
|
|
44
|
+
// * dependency_unmet — auto-clears when upstream dep finishes (engine discovery loop + DEPENDENCY_MET watch)
|
|
44
45
|
//
|
|
45
46
|
// Returns { kind, short, full } when the item needs attention, else null.
|
|
46
47
|
// Pure helper (no DOM / escapeHtml deps) so it can be unit-tested in Node.
|
|
@@ -74,13 +75,6 @@ function needsAttentionInfo(item) {
|
|
|
74
75
|
var budgetMsg = 'Assigned agent ' + agent + ' is over its maxBudgetUsd cap.';
|
|
75
76
|
return { kind: 'budget_exceeded', short: budgetMsg, full: budgetMsg };
|
|
76
77
|
}
|
|
77
|
-
if (reason === 'dependency_unmet') {
|
|
78
|
-
var deps = Array.isArray(item.depends_on) && item.depends_on.length
|
|
79
|
-
? item.depends_on.join(', ')
|
|
80
|
-
: '<unknown>';
|
|
81
|
-
var depMsg = 'Waiting on dependencies: ' + deps + '. (One or more are not yet done.)';
|
|
82
|
-
return { kind: 'dependency_unmet', short: depMsg, full: depMsg };
|
|
83
|
-
}
|
|
84
78
|
return null;
|
|
85
79
|
}
|
|
86
80
|
|
package/dashboard/js/settings.js
CHANGED
|
@@ -229,6 +229,23 @@ async function openSettings() {
|
|
|
229
229
|
'<div style="font-size:var(--text-xs);color:var(--muted);margin-top:1px">Parsed from <code>git symbolic-ref refs/remotes/origin/HEAD</code>' + (localBranch ? '. Local HEAD: <code>' + escHtml(localBranch) + '</code>' : '') + '.</div>' +
|
|
230
230
|
'</div>' +
|
|
231
231
|
'</div>';
|
|
232
|
+
// P-a3f9b207 — per-project worktreeMode dropdown + warning chip. Default
|
|
233
|
+
// 'isolated' (engine creates a dedicated worktree per dispatch); 'live'
|
|
234
|
+
// runs the agent directly in p.localPath for repos where worktrees are
|
|
235
|
+
// unworkable. Chip is hidden by default and toggled reactively below.
|
|
236
|
+
var currentWtMode = (p.worktreeMode === 'live') ? 'live' : 'isolated';
|
|
237
|
+
var wtModeSearch = 'worktree mode isolated live checkout dispatch';
|
|
238
|
+
var worktreeModeBlock =
|
|
239
|
+
'<div data-search="' + escHtml(wtModeSearch) + '" style="margin-bottom:6px">' +
|
|
240
|
+
'<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Worktree mode</label>' +
|
|
241
|
+
'<select id="set-worktreeMode-' + escHtml(p.name) + '" data-worktree-mode-select="' + escHtml(p.name) + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' +
|
|
242
|
+
'<option value="isolated"' + (currentWtMode === 'isolated' ? ' selected' : '') + '>Isolated (default)</option>' +
|
|
243
|
+
'<option value="live"' + (currentWtMode === 'live' ? ' selected' : '') + '>Live checkout</option>' +
|
|
244
|
+
'</select>' +
|
|
245
|
+
'<div data-worktree-mode-chip="' + escHtml(p.name) + '" style="' + (currentWtMode === 'live' ? '' : 'display:none;') + 'margin-top:6px;padding:6px 8px;background:rgba(234,179,8,0.12);border:1px solid var(--yellow);border-radius:4px;color:var(--yellow);font-size:var(--text-xs);line-height:1.4">' +
|
|
246
|
+
'⚠ 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.' +
|
|
247
|
+
'</div>' +
|
|
248
|
+
'</div>';
|
|
232
249
|
return '<div data-settings-project="' + escHtml(p.name) + '" data-search="project ' + escHtml(p.name.toLowerCase()) + '" style="border:1px solid var(--border);border-radius:6px;padding:10px 12px;margin-bottom:12px">' +
|
|
233
250
|
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
|
|
234
251
|
'<div style="font-size:var(--text-md);font-weight:600">' + escHtml(p.name) + '</div>' +
|
|
@@ -236,6 +253,7 @@ async function openSettings() {
|
|
|
236
253
|
'</div>' +
|
|
237
254
|
pathRow +
|
|
238
255
|
branchGrid +
|
|
256
|
+
worktreeModeBlock +
|
|
239
257
|
driftNote +
|
|
240
258
|
'<div style="display:flex;flex-direction:column;gap:6px;margin-top:8px">' +
|
|
241
259
|
settingsToggle('Discover from PRs', 'set-ws-prs-' + p.name, p.workSources.pullRequests.enabled, 'Discovery gate: scan repo for open PRs and surface them as review tasks. Independent of ADO/GitHub polling — does not affect already-tracked PRs.') +
|
|
@@ -563,6 +581,20 @@ async function openSettings() {
|
|
|
563
581
|
}
|
|
564
582
|
});
|
|
565
583
|
});
|
|
584
|
+
|
|
585
|
+
// P-a3f9b207 — toggle the live-mode warning chip reactively when the
|
|
586
|
+
// operator flips the per-project worktreeMode dropdown (before save). The
|
|
587
|
+
// chip is rendered once with display:none for isolated projects and
|
|
588
|
+
// visible for already-live projects; this handler only flips the
|
|
589
|
+
// display style — no markup is regenerated.
|
|
590
|
+
document.querySelectorAll('[data-worktree-mode-select]').forEach(function(sel) {
|
|
591
|
+
sel.addEventListener('change', function() {
|
|
592
|
+
const projName = sel.getAttribute('data-worktree-mode-select');
|
|
593
|
+
const chip = document.querySelector('[data-worktree-mode-chip="' + (window.CSS && CSS.escape ? CSS.escape(projName) : projName) + '"]');
|
|
594
|
+
if (!chip) return;
|
|
595
|
+
chip.style.display = (sel.value === 'live') ? '' : 'none';
|
|
596
|
+
});
|
|
597
|
+
});
|
|
566
598
|
}
|
|
567
599
|
|
|
568
600
|
async function initRuntimeFleetUI(engineCfg, agentsCfg) {
|
|
@@ -939,9 +971,16 @@ async function saveSettings() {
|
|
|
939
971
|
// Projects. Empty string = clear the override; the field stays optional.
|
|
940
972
|
const mainBranchInput = document.getElementById('set-mainBranch-' + p.name);
|
|
941
973
|
const mainBranchValue = mainBranchInput ? mainBranchInput.value.trim() : (p.mainBranch || '');
|
|
974
|
+
// P-a3f9b207 — per-project worktreeMode. Normalize to 'isolated' for
|
|
975
|
+
// any value other than 'live' so a stale DOM never POSTs garbage; the
|
|
976
|
+
// server-side validator (shared.validateWorktreeMode) is the
|
|
977
|
+
// authoritative gate for unknown values.
|
|
978
|
+
const wtModeInput = document.getElementById('set-worktreeMode-' + p.name);
|
|
979
|
+
const wtModeValue = (wtModeInput && wtModeInput.value === 'live') ? 'live' : 'isolated';
|
|
942
980
|
return {
|
|
943
981
|
name: p.name,
|
|
944
982
|
mainBranch: mainBranchValue || null,
|
|
983
|
+
worktreeMode: wtModeValue,
|
|
945
984
|
workSources: {
|
|
946
985
|
pullRequests: { enabled: document.getElementById('set-ws-prs-' + p.name)?.checked ?? true },
|
|
947
986
|
workItems: { enabled: document.getElementById('set-ws-wi-' + p.name)?.checked ?? true }
|
|
@@ -28,10 +28,17 @@
|
|
|
28
28
|
(template-baked into slim/layout.html), so we duplicate the
|
|
29
29
|
token VALUES here verbatim — see dashboard/docs/typography.md.
|
|
30
30
|
A tripwire test asserts the slim and main tokens stay in
|
|
31
|
-
sync (test/unit/dashboard-font-size-tokens.test.js).
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
sync (test/unit/dashboard-font-size-tokens.test.js).
|
|
32
|
+
|
|
33
|
+
W-mq71mqtx00060fa9 — the primitive DECLARATIONS stay byte-identical
|
|
34
|
+
to main (`calc(Npx * var(--minions-text-scale))`), but slim pins
|
|
35
|
+
--minions-text-scale to 1 while main bumps it to 1.05. That keeps
|
|
36
|
+
the slim UI at its current text size — the global bump is excluded
|
|
37
|
+
here by design. Do NOT raise this value to match main. */
|
|
38
|
+
--minions-text-scale: 1;
|
|
39
|
+
--text-xs: calc(10px * var(--minions-text-scale)); --text-sm: calc(11px * var(--minions-text-scale)); --text-base: calc(12px * var(--minions-text-scale));
|
|
40
|
+
--text-md: calc(13px * var(--minions-text-scale)); --text-lg: calc(14px * var(--minions-text-scale)); --text-xl: calc(16px * var(--minions-text-scale)); --text-2xl: calc(18px * var(--minions-text-scale));
|
|
41
|
+
--text-stat: calc(22px * var(--minions-text-scale)); --text-stat-lg: calc(28px * var(--minions-text-scale)); --text-display: calc(32px * var(--minions-text-scale));
|
|
35
42
|
--text-role-display: var(--text-display);
|
|
36
43
|
--text-heading: var(--text-2xl);
|
|
37
44
|
--text-subheading: var(--text-xl);
|
package/dashboard/styles.css
CHANGED
|
@@ -15,13 +15,22 @@
|
|
|
15
15
|
--space-1: 2px; --space-2: 4px; --space-3: 6px; --space-4: 8px;
|
|
16
16
|
--space-5: 10px; --space-6: 12px; --space-7: 16px; --space-8: 20px; --space-9: 24px;
|
|
17
17
|
|
|
18
|
-
/* Typography — size primitives
|
|
18
|
+
/* Typography — size primitives. Source of truth for the
|
|
19
19
|
~700 callsites migrated in PR #66; do NOT introduce new raw `Npx`
|
|
20
20
|
font-sizes outside this block. Tripwire:
|
|
21
|
-
test/unit/dashboard-font-size-tokens.test.js
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
test/unit/dashboard-font-size-tokens.test.js
|
|
22
|
+
|
|
23
|
+
W-mq71mqtx00060fa9 — every primitive is scaled by --minions-text-scale
|
|
24
|
+
so the whole dashboard's real text size can be nudged from one knob
|
|
25
|
+
(currently +5%, a slight bump). This is an actual font-size change,
|
|
26
|
+
not a zoom. The base px values below stay the source of truth and
|
|
27
|
+
MUST match dashboard/slim/styles.css verbatim — slim multiplies by
|
|
28
|
+
its own (un-scaled) factor of 1, so the slim UI is excluded from the
|
|
29
|
+
bump by construction. */
|
|
30
|
+
--minions-text-scale: 1.05;
|
|
31
|
+
--text-xs: calc(10px * var(--minions-text-scale)); --text-sm: calc(11px * var(--minions-text-scale)); --text-base: calc(12px * var(--minions-text-scale));
|
|
32
|
+
--text-md: calc(13px * var(--minions-text-scale)); --text-lg: calc(14px * var(--minions-text-scale)); --text-xl: calc(16px * var(--minions-text-scale)); --text-2xl: calc(18px * var(--minions-text-scale));
|
|
33
|
+
--text-stat: calc(22px * var(--minions-text-scale)); --text-stat-lg: calc(28px * var(--minions-text-scale)); --text-display: calc(32px * var(--minions-text-scale));
|
|
25
34
|
|
|
26
35
|
/* Typography — role aliases (W-mq1c8og40003a7cf). Prefer these for
|
|
27
36
|
NEW code: the role names survive a size-scale redesign while the
|
package/dashboard.js
CHANGED
|
@@ -215,6 +215,17 @@ function mergeSettingsConfigUpdate(current, candidate, body, patch = {}) {
|
|
|
215
215
|
} else {
|
|
216
216
|
delete currentProject.mainBranch;
|
|
217
217
|
}
|
|
218
|
+
// P-a3f9b208 — mirror worktreeMode the same way: empty / unset on
|
|
219
|
+
// candidate clears the field so the engine falls back to "isolated".
|
|
220
|
+
// Without this branch the validated POST-body update (mutated on
|
|
221
|
+
// `candidate` in handleSettingsUpdate) is silently dropped on the way
|
|
222
|
+
// through mergeSettingsConfigUpdate → mutateDashboardConfig and never
|
|
223
|
+
// reaches disk — the endpoint would return 200 but persist nothing.
|
|
224
|
+
if (Object.prototype.hasOwnProperty.call(candidateProject, 'worktreeMode')) {
|
|
225
|
+
currentProject.worktreeMode = candidateProject.worktreeMode;
|
|
226
|
+
} else {
|
|
227
|
+
delete currentProject.worktreeMode;
|
|
228
|
+
}
|
|
218
229
|
}
|
|
219
230
|
}
|
|
220
231
|
shared.pruneDefaultClaudeConfig(current);
|
|
@@ -7908,6 +7919,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7908
7919
|
repoName: detected.repoName || name,
|
|
7909
7920
|
mainBranch: detected.mainBranch || 'main',
|
|
7910
7921
|
prUrlBase: detected.prUrlBase,
|
|
7922
|
+
// P-a3f9b201: thread worktreeMode from POST body. buildProjectEntry
|
|
7923
|
+
// validates via shared.validateWorktreeMode — unknown values bubble
|
|
7924
|
+
// up as HTTP 400 below.
|
|
7925
|
+
worktreeMode: body.worktreeMode,
|
|
7911
7926
|
});
|
|
7912
7927
|
|
|
7913
7928
|
// Create centralized project state files.
|
|
@@ -9380,6 +9395,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9380
9395
|
name: p.name,
|
|
9381
9396
|
localPath: p.localPath || null,
|
|
9382
9397
|
mainBranch: p.mainBranch || null,
|
|
9398
|
+
// P-a3f9b207 — surface worktreeMode so the Settings UI can pre-fill
|
|
9399
|
+
// the per-project dropdown. null === absent (engine defaults to
|
|
9400
|
+
// 'isolated' downstream); 'live' opts into the live-checkout path.
|
|
9401
|
+
worktreeMode: p.worktreeMode || null,
|
|
9383
9402
|
workSources: {
|
|
9384
9403
|
pullRequests: { enabled: p.workSources?.pullRequests?.enabled !== false, cooldownMinutes: p.workSources?.pullRequests?.cooldownMinutes ?? 30 },
|
|
9385
9404
|
workItems: { enabled: p.workSources?.workItems?.enabled !== false, cooldownMinutes: p.workSources?.workItems?.cooldownMinutes ?? 0 }
|
|
@@ -9747,6 +9766,22 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9747
9766
|
if (raw) proj.mainBranch = raw;
|
|
9748
9767
|
else delete proj.mainBranch;
|
|
9749
9768
|
}
|
|
9769
|
+
// P-a3f9b201 — per-project worktreeMode enum ('isolated' default,
|
|
9770
|
+
// 'live' for repos where worktrees are unworkable). Empty string /
|
|
9771
|
+
// null clears the override (engine falls back to 'isolated'); any
|
|
9772
|
+
// other value flows through shared.validateWorktreeMode which throws
|
|
9773
|
+
// HTTP 400 on unknown values. Errors propagate to the outer catch
|
|
9774
|
+
// and are returned via e.statusCode/e.message.
|
|
9775
|
+
if (Object.prototype.hasOwnProperty.call(update, 'worktreeMode')) {
|
|
9776
|
+
const raw = update.worktreeMode;
|
|
9777
|
+
if (raw === '' || raw === null) {
|
|
9778
|
+
delete proj.worktreeMode;
|
|
9779
|
+
} else {
|
|
9780
|
+
const validated = shared.validateWorktreeMode(raw);
|
|
9781
|
+
if (validated === undefined) delete proj.worktreeMode;
|
|
9782
|
+
else proj.worktreeMode = validated;
|
|
9783
|
+
}
|
|
9784
|
+
}
|
|
9750
9785
|
}
|
|
9751
9786
|
}
|
|
9752
9787
|
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Live-checkout dispatch mode
|
|
2
|
+
|
|
3
|
+
> Per-project opt-in (`project.worktreeMode: 'live'`) that runs Minions agents directly inside the operator's project checkout instead of an engine-managed git worktree.
|
|
4
|
+
>
|
|
5
|
+
> Plan: `plan-w-mq5rmtt9000a42f9-2026-06-08`. PRD: `prd/minions-opg-2026-06-10.json` (items `P-a3f9b201` … `P-a3f9b209`). Default for every project remains `'isolated'`; the rest of this doc only applies when a project flips itself to `'live'`.
|
|
6
|
+
|
|
7
|
+
## Motivation
|
|
8
|
+
|
|
9
|
+
The default `isolated` mode spawns each dispatch inside its own git worktree under `../worktrees/`. That works for almost every repo, but it falls over when:
|
|
10
|
+
|
|
11
|
+
- **`repo`-managed multi-project trees** (Android AOSP / Office mobile / Chromium) where the manifest pins dozens of nested repos and `git worktree add` either errors out or strands sub-projects.
|
|
12
|
+
- **Submodule-heavy repos** where worktrees skip `.gitmodules` configuration or leave submodules pointing at the wrong SHA.
|
|
13
|
+
- **Native build state** that lives next to the source (Gradle caches, Xcode `DerivedData`, CMake `build/`, sccache, `.cargo/`, Bazel `bazel-*` symlinks). A fresh worktree throws all of it away and the next build re-downloads / re-compiles.
|
|
14
|
+
- **Deep Windows paths** that hit MAX_PATH (260 chars) once `../worktrees/dispatch-<long-id>-<slug>/<repo-name>/<deep/nested/path>` is appended.
|
|
15
|
+
- **Sparse clones with custom hooks** (`.git/hooks/*`, `git lfs install --local`, `git config --local`) that the operator has set up by hand and that a fresh worktree does not inherit.
|
|
16
|
+
|
|
17
|
+
For these repos, the operator usually already has one canonical checkout that builds correctly. Live-checkout mode lets the agent dispatch into that checkout instead of cloning a side-by-side copy.
|
|
18
|
+
|
|
19
|
+
## Contract (four guarantees)
|
|
20
|
+
|
|
21
|
+
Live mode is opinionated about what the engine will and will not do to the operator's tree. The contract has four guarantees; the engine enforces all four and fails dispatches that would violate them.
|
|
22
|
+
|
|
23
|
+
### 1. Per-project mutating-concurrency cap of 1
|
|
24
|
+
|
|
25
|
+
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.
|
|
26
|
+
|
|
27
|
+
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`.)
|
|
28
|
+
|
|
29
|
+
### 2. Refuse-on-dirty (engine never resets the operator tree)
|
|
30
|
+
|
|
31
|
+
Before spawning, `engine/live-checkout.js#prepareLiveCheckout` runs `git status --porcelain` from `project.localPath`. Any output (staged, unstaged, untracked) refuses the dispatch immediately:
|
|
32
|
+
|
|
33
|
+
- Non-retryable `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` (added to `engine/dispatch.js`'s `neverRetry` set so the dispatcher never re-spawns mechanically).
|
|
34
|
+
- Inbox alert written via `dispatch.writeInboxAlert('live-checkout-dirty-<wi-id>', body)`. The body lists the dirty files verbatim from `git status --porcelain`.
|
|
35
|
+
- Work item stamped with `_pendingReason: 'live_checkout_dirty'` so the dashboard surfaces the block.
|
|
36
|
+
- Completion summary: `live-checkout refused: N dirty file(s) in <localPath>`.
|
|
37
|
+
|
|
38
|
+
The engine never calls `git reset --hard`, `git clean -fd`, `git stash`, or any other state-mutating command against the operator's checkout — not at spawn, not at cleanup, not on timeout, not on engine restart. Cleanup paths (`worktreePool.returnToPool`, `worktree-gc.gcDispatchWorktreeIfOrphan`, `_quarantineDirtyWorktree`) are naturally no-ops because `worktreePath` stays `null` end-to-end (`engine.js:1219`).
|
|
39
|
+
|
|
40
|
+
### 3. No auto-pull, no `--force`, no fast-forward
|
|
41
|
+
|
|
42
|
+
After the clean-tree check, `prepareLiveCheckout`:
|
|
43
|
+
|
|
44
|
+
1. Best-effort `git fetch origin <mainRef>` — failure is logged at `warn` and execution continues so offline operators can resume work on an already-checked-out branch.
|
|
45
|
+
2. `git rev-parse --verify <branchName>`:
|
|
46
|
+
- Succeeds → branch exists locally → plain `git checkout <branchName>` (no `--force`, no `-B`, no pull, no reset). The operator owns the checkout state; if they have local commits or a stale tip relative to `origin/<branchName>`, those are preserved.
|
|
47
|
+
- Fails → `git checkout -b <branchName> origin/<mainRef>` (new branch, tracks `origin/<mainRef>` as its starting point).
|
|
48
|
+
|
|
49
|
+
PR-source live dispatches (`meta.branch` set) and shared-branch live dispatches (`meta.branchStrategy === 'shared-branch'`) flow through the same code path with no special case. If the operator already has a PR branch checked out at a different SHA than `origin/<branch>`, the engine will not fast-forward it — that is the intended contract (see Open Q5 in the source PRD). Resolve manually with `git pull --ff-only` or commit the local divergence first.
|
|
50
|
+
|
|
51
|
+
### 4. No worktree pool, no per-WI subdirectory isolation
|
|
52
|
+
|
|
53
|
+
Live mode shares one checkout per project. There is no pool to recycle, no quarantine directory, no per-WI subdirectory under the project root. The mutating-concurrency cap (Guarantee 1) is the only isolation mechanism: agents take turns in the same directory.
|
|
54
|
+
|
|
55
|
+
Pool short-circuits live in `engine.js:1368` and `engine/cleanup.js`; both gate on `worktreePath` truthiness and `!liveMode`, so the borrow / return / orphan-GC paths execute zero git commands when the project is live.
|
|
56
|
+
|
|
57
|
+
## Operator workflow
|
|
58
|
+
|
|
59
|
+
### Enabling live mode (dashboard)
|
|
60
|
+
|
|
61
|
+
1. Open the Minions dashboard → **Settings** → **Projects** → expand the target project.
|
|
62
|
+
2. Set **Worktree mode** → **Live checkout**. A yellow warning chip appears immediately:
|
|
63
|
+
> ⚠ 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.
|
|
64
|
+
3. Click **Save**. The dashboard POSTs the change through `mergeSettingsConfigUpdate`; `shared.validateWorktreeMode` rejects anything other than `'isolated'` or `'live'` with HTTP 400.
|
|
65
|
+
|
|
66
|
+
### Enabling live mode (config.json)
|
|
67
|
+
|
|
68
|
+
```jsonc
|
|
69
|
+
{
|
|
70
|
+
"projects": [{
|
|
71
|
+
"name": "android-aosp",
|
|
72
|
+
"localPath": "/home/yemi/aosp",
|
|
73
|
+
"worktreeMode": "live",
|
|
74
|
+
// …
|
|
75
|
+
}]
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Absent / `null` / `''` reads as `'isolated'` (the default) — explicit is preferred.
|
|
80
|
+
|
|
81
|
+
### Recovering from `live_checkout_dirty` refusal
|
|
82
|
+
|
|
83
|
+
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. From the project checkout:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# Option A — preserve work for later
|
|
87
|
+
git stash push --include-untracked --message "minions: pre-dispatch stash"
|
|
88
|
+
|
|
89
|
+
# Option B — commit the changes (preferred if they are intentional)
|
|
90
|
+
git add -A && git commit -m "wip: hand-off to minions"
|
|
91
|
+
|
|
92
|
+
# Option C — discard the changes
|
|
93
|
+
git checkout -- .
|
|
94
|
+
git clean -fd
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
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).
|
|
98
|
+
|
|
99
|
+
### Branch-leak mitigation
|
|
100
|
+
|
|
101
|
+
Live mode never deletes branches it creates. Over time the project checkout accumulates `work/W-…` and `user/<login>/<wi>-<slug>` branches. Periodically prune merged branches:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# Delete all local branches already merged into the current branch (typically main).
|
|
105
|
+
git checkout main && git pull --ff-only
|
|
106
|
+
git branch --merged | grep -E '^\s+(work/|user/)' | xargs -r git branch -d
|
|
107
|
+
|
|
108
|
+
# More aggressive (force-delete branches whose tip is gone from the remote):
|
|
109
|
+
git fetch --prune origin
|
|
110
|
+
git for-each-ref --format '%(refname:short) %(upstream:track)' refs/heads \
|
|
111
|
+
| awk '$2 == "[gone]" {print $1}' \
|
|
112
|
+
| xargs -r git branch -D
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The engine has no opinion about local branches; this hygiene is the operator's responsibility in live mode.
|
|
116
|
+
|
|
117
|
+
## Non-goals
|
|
118
|
+
|
|
119
|
+
Live-checkout mode is deliberately small. These are NOT supported and will not be added:
|
|
120
|
+
|
|
121
|
+
- **No `auto` mode.** The choice between `isolated` and `live` is per-project and operator-set. The engine will not auto-detect submodules / `repo` workspaces and silently switch modes.
|
|
122
|
+
- **No auto-stash on dirty refusal.** The engine refuses and exits; it never `git stash`es to "make room" for a dispatch. Stashes silently mutate the operator's tree and conflate engine state with operator state.
|
|
123
|
+
- **No concurrent dispatches per project.** The cap is 1; raising it would require per-WI subdirectories, which live mode explicitly does not provide.
|
|
124
|
+
- **No per-WI subdirectory isolation.** Live mode is one-checkout-per-project by design. If you need isolation, use `worktreeMode: 'isolated'` (the default).
|
|
125
|
+
- **No per-WI override.** `worktreeMode` is per-project only. There is no `meta.worktreeMode` on a work item that overrides the project setting.
|
|
126
|
+
- **No auto-pull / no fast-forward on existing branches.** See Guarantee 3. If a PR branch is checked out locally at a different SHA than `origin/<branch>`, the operator resolves it manually.
|
|
127
|
+
- **No special timeout / kill handling.** Live-mode dispatches are killed by PID exactly like isolated-mode dispatches (`engine/timeout.js` header comment). The engine sends SIGTERM/SIGKILL to the tracked process and never touches the working tree on kill.
|
|
128
|
+
|
|
129
|
+
## Related code
|
|
130
|
+
|
|
131
|
+
| File | Purpose |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `engine/shared.js` — `WORKTREE_MODES`, `validateWorktreeMode` | Enum + validator (P-a3f9b201). |
|
|
134
|
+
| `engine/shared.js` — `resolveSpawnPaths` | Returns `{ cwd: localPath, worktreeRootDir: null, liveMode: true }` for live projects (P-a3f9b202). |
|
|
135
|
+
| `engine/live-checkout.js` — `prepareLiveCheckout` | Pure helper: dirty check, best-effort fetch, branch resolution (P-a3f9b203). |
|
|
136
|
+
| `engine.js` — `spawnAgent` live-mode block | Calls `prepareLiveCheckout`, handles dirty / throw branches, gates `git worktree add` on `!liveMode` (P-a3f9b204). |
|
|
137
|
+
| `engine.js` — dispatcher `liveProjectsInUse` set | Per-project mutating-concurrency cap (P-a3f9b205). |
|
|
138
|
+
| `engine.js` — worktree-pool / orphan-GC short-circuits | `worktreePath===null` no-ops in live mode (P-a3f9b206). |
|
|
139
|
+
| `dashboard/js/settings.js` — worktreeMode dropdown + chip | Operator-facing UI (P-a3f9b207). |
|
|
140
|
+
| `test/unit/{resolve-spawn-paths-live-mode,prepare-live-checkout,spawn-agent-live-mode-wiring}.test.js` | Wiring and contract tests (P-a3f9b208). |
|
|
141
|
+
| `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` | Non-retryable refusal class. |
|
|
142
|
+
| `engine/dispatch.js` — `isRetryableFailureReason` neverRetry | Excludes `LIVE_CHECKOUT_DIRTY` from mechanical retry. |
|
|
143
|
+
| `engine/timeout.js` header comment | Confirms no special live-mode kill handling. |
|
package/engine/cleanup.js
CHANGED
|
@@ -567,6 +567,12 @@ async function runCleanup(config, verbose = false) {
|
|
|
567
567
|
// change to both locations, producing mirror-write leaks.
|
|
568
568
|
// We only WARN here — removing someone else's worktree without consent could
|
|
569
569
|
// destroy in-flight work. The operator runs `git worktree remove <path>`.
|
|
570
|
+
// P-a3f9b206: live-mode projects (worktreeMode === 'live') intentionally
|
|
571
|
+
// never create linked worktrees — `git worktree list` returns only the
|
|
572
|
+
// operator's main checkout, which `shared.isPathInside` correctly excludes
|
|
573
|
+
// (equal-path is not "inside"). An empty/main-only nested-worktree scan
|
|
574
|
+
// result is by-design for live mode; do not "fix" it by adding the main
|
|
575
|
+
// worktree to the warnings.
|
|
570
576
|
cleaned.nestedWorktrees = 0;
|
|
571
577
|
const _scannedRoots = new Set(); // dedup projects sharing localPath
|
|
572
578
|
for (const project of projects) {
|
package/engine/dispatch.js
CHANGED
|
@@ -430,6 +430,7 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
|
|
|
430
430
|
FAILURE_CLASS.INVALID_MANAGED_SPAWN, // W-mpbhxg3b000u8411 — managed-spawn.json failed validation; re-running with the same wrong file won't fix it
|
|
431
431
|
FAILURE_CLASS.MANAGED_SPAWN_HEALTHCHECK_FAILED, // W-mpbhxg3b000u8411 — healthcheck timed out; agent must fix the spec or the service it spawned
|
|
432
432
|
FAILURE_CLASS.INJECTION_FLAGGED, // F5 (W-mpeklod3000we69c) — agent spotted a prompt-injection attempt in spliced untrusted content; a human must review the source before re-dispatch
|
|
433
|
+
FAILURE_CLASS.LIVE_CHECKOUT_DIRTY, // P-a3f9b204 — live-checkout refused to spawn because operator localPath is dirty; mechanical retry won't fix it (operator must commit/stash/discard)
|
|
433
434
|
]);
|
|
434
435
|
if (neverRetry.has(failureClass)) return false;
|
|
435
436
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/live-checkout.js — P-a3f9b203
|
|
3
|
+
*
|
|
4
|
+
* Pure helper for the live-checkout dispatch mode (plan-w-mq5rmtt9000a42f9-2026-06-08).
|
|
5
|
+
*
|
|
6
|
+
* `prepareLiveCheckout({ localPath, branchName, mainRef, gitOpts, dispatchId, wiId, log, _git })`
|
|
7
|
+
*
|
|
8
|
+
* Lifecycle (called by spawnAgent in engine.js when resolveSpawnPaths returns liveMode:true):
|
|
9
|
+
* 1. `git status --porcelain` from localPath. Non-empty output → bail with
|
|
10
|
+
* { ok:false, reason:'dirty', dirtyFiles:[…] } so spawnAgent can fail the
|
|
11
|
+
* dispatch non-retryably and write an inbox alert. NO mutating git calls
|
|
12
|
+
* after this point if dirty.
|
|
13
|
+
* 2. `git fetch origin <mainRef>` — BEST-EFFORT. Failure is logged via the
|
|
14
|
+
* injected `log` (severity 'warn') and execution continues. Offline
|
|
15
|
+
* operators can still resume work on a previously-checked-out branch.
|
|
16
|
+
* 3. Branch resolution:
|
|
17
|
+
* a. `git rev-parse --verify <branchName>` succeeds → branch exists
|
|
18
|
+
* locally → `git checkout <branchName>` (NO --force, NO auto-pull,
|
|
19
|
+
* NO fast-forward, NO reset). The operator owns the checkout
|
|
20
|
+
* state per the live-checkout contract; the engine never
|
|
21
|
+
* overwrites local commits.
|
|
22
|
+
* b. rev-parse fails → branch is new → `git checkout -b <branchName>
|
|
23
|
+
* origin/<mainRef>` (tracks remote main as upstream-of-creation).
|
|
24
|
+
* 4. Returns { ok:true, branch, created:boolean }.
|
|
25
|
+
*
|
|
26
|
+
* PURE — does NOT call completeDispatch, does NOT write inbox alerts. That
|
|
27
|
+
* translation belongs in spawnAgent (engine.js). Keeping this helper free of
|
|
28
|
+
* side effects lets unit tests exercise every branch with a mocked git
|
|
29
|
+
* runner and zero filesystem touching.
|
|
30
|
+
*
|
|
31
|
+
* All git invocations route through `shared.shellSafeGit` (argv-form,
|
|
32
|
+
* shell:false, execFile). Branch and main-ref are validated via
|
|
33
|
+
* `shared.validateGitRef` before any argv is constructed, so a maliciously
|
|
34
|
+
* crafted branch name like "--evil-flag" is rejected before reaching git.
|
|
35
|
+
*
|
|
36
|
+
* Tests inject a mock runner via the private `_git` option (signature
|
|
37
|
+
* `(args:string[], opts:{cwd, gitExtraArgs?:string[]}) => Promise<string>`).
|
|
38
|
+
* Production callers omit `_git` and the helper falls back to
|
|
39
|
+
* `require('./shared').shellSafeGit`.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
'use strict';
|
|
43
|
+
|
|
44
|
+
const shared = require('./shared');
|
|
45
|
+
|
|
46
|
+
async function prepareLiveCheckout(opts = {}) {
|
|
47
|
+
const {
|
|
48
|
+
localPath,
|
|
49
|
+
branchName,
|
|
50
|
+
mainRef,
|
|
51
|
+
gitOpts,
|
|
52
|
+
dispatchId, // accepted for caller bookkeeping; not used by the helper
|
|
53
|
+
wiId, // accepted for caller bookkeeping; not used by the helper
|
|
54
|
+
log,
|
|
55
|
+
_git, // private injection for testing — defaults to shared.shellSafeGit
|
|
56
|
+
} = opts;
|
|
57
|
+
|
|
58
|
+
// ── Required-arg guards. Throw rather than return {ok:false} so a
|
|
59
|
+
// caller's missing wiring fails loudly at the call site.
|
|
60
|
+
if (!localPath || typeof localPath !== 'string') {
|
|
61
|
+
throw new Error('prepareLiveCheckout: localPath is required (got ' + JSON.stringify(localPath) + ')');
|
|
62
|
+
}
|
|
63
|
+
if (!branchName || typeof branchName !== 'string') {
|
|
64
|
+
throw new Error('prepareLiveCheckout: branchName is required (got ' + JSON.stringify(branchName) + ')');
|
|
65
|
+
}
|
|
66
|
+
if (!mainRef || typeof mainRef !== 'string') {
|
|
67
|
+
throw new Error('prepareLiveCheckout: mainRef is required (got ' + JSON.stringify(mainRef) + ')');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Validate refs before they hit git argv (defense in depth).
|
|
71
|
+
// shellSafeGit uses execFile (shell:false) so metacharacters are inert,
|
|
72
|
+
// but a leading-dash ref like "--evil" would still be parsed as a git
|
|
73
|
+
// flag. validateGitRef throws on those cases (and on other rev-spec
|
|
74
|
+
// quirks). Wrap so the caller-facing message mentions the field name.
|
|
75
|
+
try { shared.validateGitRef(branchName); }
|
|
76
|
+
catch (e) { throw new Error('prepareLiveCheckout: invalid branchName ref — ' + e.message); }
|
|
77
|
+
try { shared.validateGitRef(mainRef); }
|
|
78
|
+
catch (e) { throw new Error('prepareLiveCheckout: invalid mainRef — ' + e.message); }
|
|
79
|
+
|
|
80
|
+
const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
|
|
81
|
+
const baseOpts = { cwd: localPath, ...(gitOpts || {}) };
|
|
82
|
+
|
|
83
|
+
// Small log helper that's safe even when the caller didn't pass `log`.
|
|
84
|
+
const warn = (msg) => {
|
|
85
|
+
if (typeof log !== 'function') return;
|
|
86
|
+
try { log(msg, 'warn'); } catch { /* never let logging crash the helper */ }
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// ── Step 1: git status --porcelain. Bail early on dirty tree. ─────────
|
|
90
|
+
// Porcelain format begins each line with a two-char XY status code (e.g.
|
|
91
|
+
// " M file.js", "?? new.js", "MM stage+unstage.js"). The leading space on
|
|
92
|
+
// unstaged-only lines is SIGNIFICANT, so we split first and strip ONLY
|
|
93
|
+
// trailing whitespace/CR per line — outer-trimming the whole blob would
|
|
94
|
+
// eat the leading XY space on the first line.
|
|
95
|
+
const statusRaw = await git(['status', '--porcelain'], baseOpts);
|
|
96
|
+
const statusStr = typeof statusRaw === 'string' ? statusRaw : '';
|
|
97
|
+
const dirtyFiles = statusStr
|
|
98
|
+
.split(/\r?\n/)
|
|
99
|
+
.map((line) => line.replace(/\s+$/, ''))
|
|
100
|
+
.filter((line) => line.length > 0);
|
|
101
|
+
if (dirtyFiles.length > 0) {
|
|
102
|
+
return { ok: false, reason: 'dirty', dirtyFiles };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Step 2: best-effort fetch of mainRef. Log + continue on failure. ──
|
|
106
|
+
try {
|
|
107
|
+
await git(['fetch', 'origin', mainRef], baseOpts);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
const reason = (e && e.message) ? e.message : String(e);
|
|
110
|
+
warn('prepareLiveCheckout: git fetch origin ' + mainRef + ' failed (continuing): ' + reason);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Step 3: branch resolution + checkout. ─────────────────────────────
|
|
114
|
+
let branchExists = false;
|
|
115
|
+
try {
|
|
116
|
+
await git(['rev-parse', '--verify', branchName], baseOpts);
|
|
117
|
+
branchExists = true;
|
|
118
|
+
} catch {
|
|
119
|
+
branchExists = false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (branchExists) {
|
|
123
|
+
// Plain checkout — NO --force, NO -B, NO pull. Operator owns local commits.
|
|
124
|
+
await git(['checkout', branchName], baseOpts);
|
|
125
|
+
return { ok: true, branch: branchName, created: false };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// New branch — create off of remote main.
|
|
129
|
+
await git(['checkout', '-b', branchName, 'origin/' + mainRef], baseOpts);
|
|
130
|
+
return { ok: true, branch: branchName, created: true };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { prepareLiveCheckout };
|
package/engine/preflight.js
CHANGED
|
@@ -479,14 +479,14 @@ function _checkBypassFlagSupported(runtimeName, adapter, helpText) {
|
|
|
479
479
|
return {
|
|
480
480
|
name,
|
|
481
481
|
ok: 'warn',
|
|
482
|
-
message: `adapter did not declare permissionBypassFlags — cannot verify ${runtimeName} CLI accepts headless bypass
|
|
482
|
+
message: `adapter did not declare permissionBypassFlags — cannot verify ${runtimeName} CLI accepts headless bypass. See docs/runtime-adapters.md.`,
|
|
483
483
|
};
|
|
484
484
|
}
|
|
485
485
|
if (helpText == null || helpText === '') {
|
|
486
486
|
return {
|
|
487
487
|
name,
|
|
488
488
|
ok: 'warn',
|
|
489
|
-
message: `could not invoke ${runtimeName} --help to verify ${flags.join(' ')} support — Minions will still pass the flag(s) but you may see permission prompts if the CLI doesn't accept them
|
|
489
|
+
message: `could not invoke ${runtimeName} --help to verify ${flags.join(' ')} support — Minions will still pass the flag(s) but you may see permission prompts if the CLI doesn't accept them. See docs/runtime-adapters.md.`,
|
|
490
490
|
};
|
|
491
491
|
}
|
|
492
492
|
const missing = flags.filter(f => !helpText.includes(f));
|
|
@@ -494,7 +494,7 @@ function _checkBypassFlagSupported(runtimeName, adapter, helpText) {
|
|
|
494
494
|
return {
|
|
495
495
|
name,
|
|
496
496
|
ok: 'warn',
|
|
497
|
-
message: `${runtimeName} --help does not list expected flag(s): ${missing.join(', ')} — your CLI may be outdated. Agents will hang on permission prompts. Update with the CLI's package manager (npm i -g @anthropic-ai/claude-code for Claude; winget upgrade Microsoft.CopilotCLI for Copilot)
|
|
497
|
+
message: `${runtimeName} --help does not list expected flag(s): ${missing.join(', ')} — your CLI may be outdated. Agents will hang on permission prompts. Update with the CLI's package manager (npm i -g @anthropic-ai/claude-code for Claude; winget upgrade Microsoft.CopilotCLI for Copilot). See docs/runtime-adapters.md.`,
|
|
498
498
|
};
|
|
499
499
|
}
|
|
500
500
|
return {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { execFileSync: defaultExecFileSync } = require('child_process');
|
|
8
|
+
const shared = require('./shared');
|
|
8
9
|
|
|
9
10
|
function decodeUrlSegment(segment) {
|
|
10
11
|
try { return decodeURIComponent(String(segment || '')); } catch { return String(segment || ''); }
|
|
@@ -340,11 +341,16 @@ function buildPrUrlBase({ repoHost, org, project, repoName, prUrlBase }) {
|
|
|
340
341
|
return '';
|
|
341
342
|
}
|
|
342
343
|
|
|
343
|
-
function buildProjectEntry({ name, description, localPath, repoHost, repositoryId, org, project, repoName, mainBranch, prUrlBase }) {
|
|
344
|
+
function buildProjectEntry({ name, description, localPath, repoHost, repositoryId, org, project, repoName, mainBranch, prUrlBase, worktreeMode }) {
|
|
344
345
|
const safeName = (name || 'project').replace(/[^a-zA-Z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'project';
|
|
345
346
|
const host = repoHost || 'github';
|
|
346
347
|
const isAdo = host === 'ado';
|
|
347
|
-
|
|
348
|
+
// P-a3f9b201: validateWorktreeMode returns undefined on absent/empty (so the
|
|
349
|
+
// field is omitted and resolves to 'isolated' downstream) and throws an HTTP
|
|
350
|
+
// 400 Error on unknown values. Run validation BEFORE building the entry so a
|
|
351
|
+
// typo at the dashboard or CLI never lands in config.json.
|
|
352
|
+
const resolvedWorktreeMode = shared.validateWorktreeMode(worktreeMode);
|
|
353
|
+
const entry = {
|
|
348
354
|
name: safeName,
|
|
349
355
|
description: description || '',
|
|
350
356
|
localPath: (localPath || '').replace(/\\/g, '/'),
|
|
@@ -361,6 +367,8 @@ function buildProjectEntry({ name, description, localPath, repoHost, repositoryI
|
|
|
361
367
|
workItems: { enabled: true, cooldownMinutes: 0 },
|
|
362
368
|
},
|
|
363
369
|
};
|
|
370
|
+
if (resolvedWorktreeMode !== undefined) entry.worktreeMode = resolvedWorktreeMode;
|
|
371
|
+
return entry;
|
|
364
372
|
}
|
|
365
373
|
|
|
366
374
|
function buildScanResult(repoPath, detected = {}, linked = false) {
|
package/engine/shared.js
CHANGED
|
@@ -2281,6 +2281,29 @@ function classifyInboxItem(name, content) {
|
|
|
2281
2281
|
return 'project-notes';
|
|
2282
2282
|
}
|
|
2283
2283
|
|
|
2284
|
+
// ── Worktree Mode Enum (P-a3f9b201) ─────────────────────────────────────────
|
|
2285
|
+
// Per-project switch between the default `isolated` mode (engine creates a
|
|
2286
|
+
// dedicated worktree per dispatch under ../worktrees) and `live` mode (the
|
|
2287
|
+
// agent runs directly in the operator's working checkout — used for repos
|
|
2288
|
+
// where git worktrees are unworkable, e.g. submodules, hooks, large binary
|
|
2289
|
+
// caches). Default is `isolated`; absent/undefined `worktreeMode` on a
|
|
2290
|
+
// project entry MUST read as 'isolated' everywhere downstream. Unknown
|
|
2291
|
+
// values are rejected at the validators — never silently coerced — so a
|
|
2292
|
+
// typo in the dashboard or in config.json cannot wedge dispatch into an
|
|
2293
|
+
// unknown mode.
|
|
2294
|
+
const WORKTREE_MODES = Object.freeze({ ISOLATED: 'isolated', LIVE: 'live' });
|
|
2295
|
+
|
|
2296
|
+
function validateWorktreeMode(value) {
|
|
2297
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
2298
|
+
if (typeof value !== 'string') {
|
|
2299
|
+
throw _httpError(400, `Invalid worktreeMode: must be a string (got ${typeof value}). Accepted values: 'isolated', 'live'.`);
|
|
2300
|
+
}
|
|
2301
|
+
if (value !== WORKTREE_MODES.ISOLATED && value !== WORKTREE_MODES.LIVE) {
|
|
2302
|
+
throw _httpError(400, `Invalid worktreeMode: "${value}". Accepted values: 'isolated' (default), 'live'.`);
|
|
2303
|
+
}
|
|
2304
|
+
return value;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2284
2307
|
// ── Engine Defaults ─────────────────────────────────────────────────────────
|
|
2285
2308
|
// Single source of truth for engine configuration defaults.
|
|
2286
2309
|
// Used by: engine.js, minions.js (init). config.template.json only has the project schema.
|
|
@@ -3724,6 +3747,7 @@ const FAILURE_CLASS = {
|
|
|
3724
3747
|
INVALID_MANAGED_SPAWN: 'invalid-managed-spawn', // P-7a3b1c92: agents/<id>/managed-spawn.json failed validator (bad schema, broken workdir, executable/env not on allowlist, healthcheck shape wrong). Engine refuses to spawn any spec — agent must fix file; never retryable as-is.
|
|
3725
3748
|
MANAGED_SPAWN_HEALTHCHECK_FAILED: 'managed-spawn-healthcheck-failed', // P-7a3b1c92: at least one managed-spawn spec was spawned but failed its healthcheck within timeout_s. Engine killed the failing PIDs; siblings stay alive. Dispatch ERROR with the failing spec name + log tail surfaced in the inbox alert.
|
|
3726
3749
|
INJECTION_FLAGGED: 'injection-flagged', // F5 (W-mpeklod3000we69c): the agent set `securityFlags.injectionAttempt:true` in its completion report after spotting a prompt-injection attempt inside an <UNTRUSTED-INPUT> fence. Engine writes a security inbox note + stamps `_securityFlag` on the WI and treats the dispatch as non-retryable so a human can review the source before the agent re-runs.
|
|
3750
|
+
LIVE_CHECKOUT_DIRTY: 'live-checkout-dirty', // P-a3f9b204 (live-checkout dispatch mode): spawnAgent ran prepareLiveCheckout against project.localPath and `git status --porcelain` reported uncommitted changes. Engine refused to spawn (it never runs `git reset`/`git clean` against the operator tree). Inbox alert lists dirty files; WI is stamped `_pendingReason: 'live_checkout_dirty'`. Non-retryable — operator must commit/stash/discard before re-dispatch.
|
|
3727
3751
|
MODEL_UNAVAILABLE: 'model-unavailable', // W-mpg6isvy000xca4d: requested model returned overloaded_error / 503 / service_unavailable. Retriable — engine swaps in the runtime-appropriate fallback model on next spawn (Claude leans on --fallback-model already plumbed; Copilot overrides --model with engine.copilotFallbackModel).
|
|
3728
3752
|
WORKSPACE_MANIFEST_REPO: 'workspace-manifest-repo-forbidden', // W-mq07avbk000m5543: dispatch routed an agent to a project/repo not present in its workspace_manifest.allowed_repos. Structural — never retryable until the manifest is widened or a different agent is chosen.
|
|
3729
3753
|
WORKSPACE_MANIFEST_TOOL: 'workspace-manifest-tool-forbidden', // W-mq07avbk000m5543: out-of-scope tool call (manifest enforcement at the runtime gate). Non-retryable as-is.
|
|
@@ -4948,17 +4972,50 @@ const READ_ONLY_ROOT_TASK_TYPES = new Set(['meeting', 'ask', 'explore', 'plan-to
|
|
|
4948
4972
|
* field for read-only stages. Only code-mutating pipeline stages need a
|
|
4949
4973
|
* worktree, and they take the normal code-mutating path below.
|
|
4950
4974
|
*
|
|
4951
|
-
*
|
|
4975
|
+
* **Live-checkout mode (P-a3f9b202).** When `project.worktreeMode === 'live'`
|
|
4976
|
+
* the resolver short-circuits BEFORE both branches below and returns
|
|
4977
|
+
* `{ cwd: <abs localPath>, worktreeRootDir: null, liveMode: true }` for ALL
|
|
4978
|
+
* task types — read-only and code-mutating alike. The agent runs directly
|
|
4979
|
+
* in the operator's working checkout (no `git worktree add`), used for
|
|
4980
|
+
* repos where worktrees are unworkable. Throws
|
|
4981
|
+
* `LIVE_CHECKOUT_NO_LOCALPATH` if `localPath` is missing/falsy — live mode
|
|
4982
|
+
* has no fallback because there is no neutral location to dispatch into.
|
|
4983
|
+
*
|
|
4984
|
+
* @param {{ localPath?: string|null, worktreeMode?: string|null }|null|undefined} project
|
|
4952
4985
|
* @param {string} type — work type (e.g. 'fix', 'explore', 'meeting')
|
|
4953
|
-
* @param {string} minionsDir — MINIONS_DIR fallback anchor
|
|
4954
|
-
* @returns {{ cwd: string|null, worktreeRootDir: string|null }}
|
|
4955
|
-
* - For
|
|
4956
|
-
* - For
|
|
4986
|
+
* @param {string} minionsDir — MINIONS_DIR fallback anchor (ignored in live mode)
|
|
4987
|
+
* @returns {{ cwd: string|null, worktreeRootDir: string|null, liveMode?: boolean }}
|
|
4988
|
+
* - For live mode (any type): { cwd: <abs localPath>, worktreeRootDir: null, liveMode: true }
|
|
4989
|
+
* - For isolated read-only types: { cwd: <project dir or MINIONS_DIR>, worktreeRootDir: null }
|
|
4990
|
+
* - For isolated code-mutating types: { cwd: null, worktreeRootDir: <project root> }
|
|
4957
4991
|
* (caller defaults cwd to worktreeRootDir before worktree creation)
|
|
4958
|
-
*
|
|
4992
|
+
* The optional `liveMode` discriminator lets callers branch on one boolean
|
|
4993
|
+
* instead of re-reading `project.worktreeMode`.
|
|
4994
|
+
* @throws {Error} LIVE_CHECKOUT_NO_LOCALPATH (live mode, missing localPath),
|
|
4995
|
+
* WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT (isolated code-mutating),
|
|
4959
4996
|
* or WORKTREE_ROOTDIR_MISSING_BASE if neither anchor present.
|
|
4960
4997
|
*/
|
|
4961
4998
|
function resolveSpawnPaths(project, type, minionsDir) {
|
|
4999
|
+
// ── Live-checkout mode short-circuit (P-a3f9b202) ──────────────────────
|
|
5000
|
+
// Runs BEFORE the read-only / code-mutating split so live mode is the
|
|
5001
|
+
// single decision point regardless of task type — read-only tasks in
|
|
5002
|
+
// live mode still report liveMode:true so downstream callers don't have
|
|
5003
|
+
// to re-read project.worktreeMode to know they're running in-place.
|
|
5004
|
+
if (project?.worktreeMode === WORKTREE_MODES.LIVE) {
|
|
5005
|
+
if (!project.localPath) {
|
|
5006
|
+
const err = new Error(
|
|
5007
|
+
'live-checkout mode requires project.localPath (worktreeMode === "live" but localPath is missing/falsy).'
|
|
5008
|
+
);
|
|
5009
|
+
err.code = 'LIVE_CHECKOUT_NO_LOCALPATH';
|
|
5010
|
+
throw err;
|
|
5011
|
+
}
|
|
5012
|
+
return {
|
|
5013
|
+
cwd: path.resolve(String(project.localPath)),
|
|
5014
|
+
worktreeRootDir: null,
|
|
5015
|
+
liveMode: true,
|
|
5016
|
+
};
|
|
5017
|
+
}
|
|
5018
|
+
|
|
4962
5019
|
const isReadOnly = READ_ONLY_ROOT_TASK_TYPES.has(type);
|
|
4963
5020
|
if (isReadOnly) {
|
|
4964
5021
|
if (project?.localPath) return { cwd: path.resolve(String(project.localPath)), worktreeRootDir: null };
|
|
@@ -7558,6 +7615,8 @@ module.exports = {
|
|
|
7558
7615
|
HAS_DANGEROUS_KEY_MAX_NODES,
|
|
7559
7616
|
validateProjectName,
|
|
7560
7617
|
validateProjectPath,
|
|
7618
|
+
WORKTREE_MODES,
|
|
7619
|
+
validateWorktreeMode,
|
|
7561
7620
|
validatePid,
|
|
7562
7621
|
PR_FIX_CAUSE,
|
|
7563
7622
|
getPrFixAutomationCause,
|
package/engine/timeout.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* engine/timeout.js — Runtime timeout, stale-orphan cleanup, steering, and idle checks.
|
|
3
|
+
*
|
|
4
|
+
* Live-checkout dispatches (project.worktreeMode === 'live') are killed by PID
|
|
5
|
+
* exactly like isolated-mode dispatches — no special handling, no in-place
|
|
6
|
+
* `git reset`/`git clean`, no working-tree cleanup. The engine only ever sends
|
|
7
|
+
* SIGTERM/SIGKILL to the tracked process; the operator owns the checkout.
|
|
3
8
|
*/
|
|
4
9
|
|
|
5
10
|
const fs = require('fs');
|
package/engine/worktree-pool.js
CHANGED
|
@@ -79,11 +79,19 @@ function mutateWorktreePool(mutator) {
|
|
|
79
79
|
*/
|
|
80
80
|
function getProjectPoolSize(projectName, config) {
|
|
81
81
|
config = config || {};
|
|
82
|
+
let proj = null;
|
|
82
83
|
if (projectName && Array.isArray(config.projects)) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
proj = config.projects.find(p => p && p.name === projectName) || null;
|
|
85
|
+
}
|
|
86
|
+
// P-a3f9b206: short-circuit for live-mode projects. The pool is meaningless
|
|
87
|
+
// when dispatches run in-place in the operator's local checkout — there is
|
|
88
|
+
// no "warm" worktree to recycle, and a non-zero pool size could trick the
|
|
89
|
+
// borrow path in spawnAgent into running for a project that no longer
|
|
90
|
+
// wants pooled worktrees. Beats both per-project worktreePoolSize and the
|
|
91
|
+
// engine-wide fleet default.
|
|
92
|
+
if (proj && proj.worktreeMode === shared.WORKTREE_MODES.LIVE) return 0;
|
|
93
|
+
if (proj && Number.isFinite(Number(proj.worktreePoolSize))) {
|
|
94
|
+
return Math.max(0, Math.floor(Number(proj.worktreePoolSize)));
|
|
87
95
|
}
|
|
88
96
|
const fleet = config.engine?.worktreePoolSize ?? ENGINE_DEFAULTS.worktreePoolSize;
|
|
89
97
|
if (Number.isFinite(Number(fleet))) return Math.max(0, Math.floor(Number(fleet)));
|
package/engine.js
CHANGED
|
@@ -1634,9 +1634,9 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1634
1634
|
// stages now short-circuit alongside any other read-only WI (see the gate at
|
|
1635
1635
|
// `if (branchName && READ_ONLY_ROOT_TASK_TYPES.has(type))` below).
|
|
1636
1636
|
const _preBranchName = meta?.branch ? sanitizeBranch(meta.branch) : null;
|
|
1637
|
-
let cwd, worktreeRootDir;
|
|
1637
|
+
let cwd, worktreeRootDir, liveMode = false;
|
|
1638
1638
|
try {
|
|
1639
|
-
({ cwd, worktreeRootDir } = shared.resolveSpawnPaths(project, type, MINIONS_DIR));
|
|
1639
|
+
({ cwd, worktreeRootDir, liveMode = false } = shared.resolveSpawnPaths(project, type, MINIONS_DIR));
|
|
1640
1640
|
} catch (rootErr) {
|
|
1641
1641
|
if (rootErr?.code === 'WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT' || rootErr?.code === 'WORKTREE_ROOTDIR_MISSING_BASE') {
|
|
1642
1642
|
log('error', `spawnAgent: project rootDir resolution failed for ${id}: ${rootErr.message}`);
|
|
@@ -1836,7 +1836,100 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1836
1836
|
worktreePath = null;
|
|
1837
1837
|
}
|
|
1838
1838
|
|
|
1839
|
-
|
|
1839
|
+
// ── Live-checkout mode handling (P-a3f9b204) ─────────────────────────────
|
|
1840
|
+
// When project.worktreeMode === 'live', resolveSpawnPaths returned
|
|
1841
|
+
// cwd = project.localPath, worktreeRootDir = null, liveMode = true.
|
|
1842
|
+
// Instead of creating an engine-managed worktree we run prepareLiveCheckout
|
|
1843
|
+
// in-place: it validates a clean tree and then checks out (or creates) the
|
|
1844
|
+
// target branch. On dirty refusal we write an inbox alert, stamp
|
|
1845
|
+
// `_pendingReason: 'live_checkout_dirty'` on the WI, complete the dispatch
|
|
1846
|
+
// non-retryably with FAILURE_CLASS.LIVE_CHECKOUT_DIRTY, and return — the
|
|
1847
|
+
// engine never runs `git reset`/`git clean` against the operator tree.
|
|
1848
|
+
// On success we fall through; the worktree-creation block below is gated
|
|
1849
|
+
// on `!liveMode`, so it never runs, and downstream cleanup paths
|
|
1850
|
+
// (worktreePool.returnToPool, worktree-gc.gcDispatchWorktreeIfOrphan) are
|
|
1851
|
+
// natural no-ops because worktreePath stays null.
|
|
1852
|
+
// Read-only types short-circuited above (branchName was set to null) and
|
|
1853
|
+
// skip this block — the agent already runs read-only in cwd=localPath in
|
|
1854
|
+
// both isolated and live mode, so no in-place branch checkout is needed.
|
|
1855
|
+
if (liveMode && branchName && !READ_ONLY_ROOT_TASK_TYPES.has(type)) {
|
|
1856
|
+
const _liveMainRef = sanitizeBranch(shared.resolveMainBranch(cwd, project.mainBranch));
|
|
1857
|
+
const _wiIdForAlert = meta?.item?.id || id;
|
|
1858
|
+
const _liveCheckout = require('./engine/live-checkout');
|
|
1859
|
+
let _liveResult;
|
|
1860
|
+
try {
|
|
1861
|
+
_liveResult = await _liveCheckout.prepareLiveCheckout({
|
|
1862
|
+
localPath: cwd,
|
|
1863
|
+
branchName,
|
|
1864
|
+
mainRef: _liveMainRef,
|
|
1865
|
+
gitOpts: _gitOpts,
|
|
1866
|
+
dispatchId: id,
|
|
1867
|
+
wiId: _wiIdForAlert,
|
|
1868
|
+
log: (msg, lvl) => log(lvl || 'info', msg),
|
|
1869
|
+
});
|
|
1870
|
+
} catch (liveErr) {
|
|
1871
|
+
log('error', `spawnAgent: live-checkout helper failed for ${id}: ${liveErr.message}`);
|
|
1872
|
+
_cleanupPromptFiles();
|
|
1873
|
+
completeDispatch(
|
|
1874
|
+
id,
|
|
1875
|
+
DISPATCH_RESULT.ERROR,
|
|
1876
|
+
`live-checkout failed: ${liveErr.message}`.slice(0, 800),
|
|
1877
|
+
'Live-checkout helper threw before agent spawn; non-retryable until the underlying git state or invocation is fixed.',
|
|
1878
|
+
{ failureClass: FAILURE_CLASS.LIVE_CHECKOUT_DIRTY, agentRetryable: false },
|
|
1879
|
+
);
|
|
1880
|
+
cleanupTempAgent(agentId);
|
|
1881
|
+
return null;
|
|
1882
|
+
}
|
|
1883
|
+
if (_liveResult && _liveResult.ok === false && _liveResult.reason === 'dirty') {
|
|
1884
|
+
const _dirtyFiles = Array.isArray(_liveResult.dirtyFiles) ? _liveResult.dirtyFiles : [];
|
|
1885
|
+
const _alertBody = [
|
|
1886
|
+
'# Live-checkout refused: dirty worktree',
|
|
1887
|
+
'',
|
|
1888
|
+
`**Project:** ${project.name || '(unknown)'}`,
|
|
1889
|
+
`**Local path:** ${cwd}`,
|
|
1890
|
+
`**Branch:** ${branchName}`,
|
|
1891
|
+
`**Work item:** ${_wiIdForAlert}`,
|
|
1892
|
+
`**Dispatch:** ${id}`,
|
|
1893
|
+
'',
|
|
1894
|
+
`The engine refused to spawn the agent because \`${cwd}\` has uncommitted changes. Live-checkout mode runs in your project directly — the engine never \`git reset\` or \`git clean\` your tree.`,
|
|
1895
|
+
'',
|
|
1896
|
+
'## Dirty files (`git status --porcelain`)',
|
|
1897
|
+
'',
|
|
1898
|
+
'```',
|
|
1899
|
+
...(_dirtyFiles.length > 0 ? _dirtyFiles : ['(none reported)']),
|
|
1900
|
+
'```',
|
|
1901
|
+
'',
|
|
1902
|
+
'Recovery: commit, stash, or discard the changes in the project checkout, then re-dispatch the work item.',
|
|
1903
|
+
].join('\n');
|
|
1904
|
+
try { writeInboxAlert(`live-checkout-dirty-${_wiIdForAlert}`, _alertBody); }
|
|
1905
|
+
catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
|
|
1906
|
+
try {
|
|
1907
|
+
const _wiPath = resolveWorkItemPath(dispatchItem.meta);
|
|
1908
|
+
if (_wiPath && dispatchItem.meta?.item?.id) {
|
|
1909
|
+
mutateJsonFileLocked(_wiPath, (data) => {
|
|
1910
|
+
if (!Array.isArray(data)) return data;
|
|
1911
|
+
const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
|
|
1912
|
+
if (wi) wi._pendingReason = 'live_checkout_dirty';
|
|
1913
|
+
return data;
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
} catch (e) { log('warn', `live-checkout: failed to stamp _pendingReason: ${e.message}`); }
|
|
1917
|
+
log('error', `spawnAgent: live-checkout refused for ${id}: ${_dirtyFiles.length} dirty file(s) in ${cwd}`);
|
|
1918
|
+
_cleanupPromptFiles();
|
|
1919
|
+
completeDispatch(
|
|
1920
|
+
id,
|
|
1921
|
+
DISPATCH_RESULT.ERROR,
|
|
1922
|
+
`live-checkout refused: ${_dirtyFiles.length} dirty file(s) in ${cwd}`.slice(0, 800),
|
|
1923
|
+
'Live-checkout mode requires a clean tree; engine refuses to overwrite operator changes. Commit/stash/discard and re-dispatch.',
|
|
1924
|
+
{ failureClass: FAILURE_CLASS.LIVE_CHECKOUT_DIRTY, agentRetryable: false },
|
|
1925
|
+
);
|
|
1926
|
+
cleanupTempAgent(agentId);
|
|
1927
|
+
return null;
|
|
1928
|
+
}
|
|
1929
|
+
log('info', `live-checkout: ${_liveResult.created ? 'created' : 'switched to'} branch ${branchName} in ${cwd} (in-place; no worktree)`);
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
if (branchName && !liveMode) {
|
|
1840
1933
|
updateAgentStatus(id, AGENT_STATUS.WORKTREE_SETUP, `Setting up worktree for branch ${branchName}`);
|
|
1841
1934
|
// W-mpwy3mp5 (#2996): track whether we ended up reusing an existing
|
|
1842
1935
|
// worktree (rather than creating a fresh one) so the post-setup dirty/
|
|
@@ -1901,7 +1994,14 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1901
1994
|
const _isSharedForPool = meta?.branchStrategy === 'shared-branch' || meta?.useExistingBranch;
|
|
1902
1995
|
const _poolProject = project.name || 'default';
|
|
1903
1996
|
const _poolSize = worktreePool.getProjectPoolSize(_poolProject, config);
|
|
1904
|
-
|
|
1997
|
+
// P-a3f9b206: belt-and-braces `!liveMode` gate. The borrow block lives
|
|
1998
|
+
// inside the `if (branchName && !liveMode)` worktree-create gate from
|
|
1999
|
+
// P-a3f9b204, so liveMode dispatches already skip it; this duplicates
|
|
2000
|
+
// the check so a future refactor that hoists the borrow out of the
|
|
2001
|
+
// create block still excludes live-mode projects. Combined with the
|
|
2002
|
+
// worktree-pool `getProjectPoolSize` short-circuit, the pool is
|
|
2003
|
+
// unreachable from three layers for live-mode dispatches.
|
|
2004
|
+
if (_poolSize > 0 && !_isSharedForPool && branchName && !liveMode) {
|
|
1905
2005
|
let _branchOnRemote = true;
|
|
1906
2006
|
try {
|
|
1907
2007
|
await shared.shellSafeGit(
|
|
@@ -8222,6 +8322,26 @@ async function tickInner() {
|
|
|
8222
8322
|
for (const d of (dispatch.active || [])) {
|
|
8223
8323
|
if (d.meta?.branch) lockedBranches.add(sanitizeBranch(d.meta.branch));
|
|
8224
8324
|
}
|
|
8325
|
+
// P-a3f9b205: Per-project mutating-concurrency gate for live-mode projects.
|
|
8326
|
+
// When project.worktreeMode === 'live', the agent runs in-place inside the
|
|
8327
|
+
// operator's localPath instead of a dedicated worktree, so two concurrent
|
|
8328
|
+
// mutating dispatches to the same project would clobber each other's
|
|
8329
|
+
// index / working tree. Seed `liveProjectsInUse` from dispatch.active so
|
|
8330
|
+
// the gate survives across ticks (not just within one allocation pass).
|
|
8331
|
+
// Read-only types (meeting/ask/explore/plan/plan-to-prd) never write, so
|
|
8332
|
+
// they are excluded from the cap. Isolated-mode projects are also
|
|
8333
|
+
// excluded — they get a fresh worktree per dispatch and are naturally
|
|
8334
|
+
// safe.
|
|
8335
|
+
const liveProjectsInUse = new Set();
|
|
8336
|
+
for (const d of (dispatch.active || [])) {
|
|
8337
|
+
if (READ_ONLY_ROOT_TASK_TYPES.has(d.type)) continue;
|
|
8338
|
+
const projName = d.project || d.meta?.project?.name || null;
|
|
8339
|
+
if (!projName) continue;
|
|
8340
|
+
const projCfg = shared.findProjectByName(shared.getProjects(config), projName);
|
|
8341
|
+
if (projCfg && projCfg.worktreeMode === shared.WORKTREE_MODES.LIVE) {
|
|
8342
|
+
liveProjectsInUse.add(projName);
|
|
8343
|
+
}
|
|
8344
|
+
}
|
|
8225
8345
|
const seenPendingIds = new Set();
|
|
8226
8346
|
const toDispatch = [];
|
|
8227
8347
|
let generalSlots = slotsAvailable;
|
|
@@ -8367,11 +8487,38 @@ async function tickInner() {
|
|
|
8367
8487
|
// Branch mutex: skip items targeting a branch already locked by an active or newly-dispatched task
|
|
8368
8488
|
const itemBranch = item.meta?.branch ? sanitizeBranch(item.meta.branch) : null;
|
|
8369
8489
|
if (itemBranch && lockedBranches.has(itemBranch)) continue;
|
|
8490
|
+
// P-a3f9b205: Per-project mutating-concurrency gate. Two concurrent
|
|
8491
|
+
// mutating dispatches against the same live-mode project would step on
|
|
8492
|
+
// each other in the operator's localPath, so cap at 1 per project.
|
|
8493
|
+
// The branch-mutex check above is the more-specific reason when both
|
|
8494
|
+
// apply (same project + same branch), which is why this gate runs
|
|
8495
|
+
// AFTER it.
|
|
8496
|
+
const itemProjName = item.project || item.meta?.project?.name || null;
|
|
8497
|
+
if (
|
|
8498
|
+
itemProjName
|
|
8499
|
+
&& !READ_ONLY_ROOT_TASK_TYPES.has(item.type)
|
|
8500
|
+
&& liveProjectsInUse.has(itemProjName)
|
|
8501
|
+
) {
|
|
8502
|
+
item._pendingReason = 'live_checkout_busy';
|
|
8503
|
+
continue;
|
|
8504
|
+
}
|
|
8370
8505
|
if (generalSlots <= 0) continue;
|
|
8371
8506
|
seenPendingIds.add(item.id);
|
|
8372
8507
|
toDispatch.push(item);
|
|
8373
8508
|
busyAgents.add(item.agent);
|
|
8374
8509
|
if (itemBranch) lockedBranches.add(itemBranch);
|
|
8510
|
+
// Track this project as in-use so subsequent pending items in the same
|
|
8511
|
+
// tick hit the gate above. The seed loop already captured cross-tick
|
|
8512
|
+
// active dispatches; this line covers within-tick fan-out.
|
|
8513
|
+
if (
|
|
8514
|
+
itemProjName
|
|
8515
|
+
&& !READ_ONLY_ROOT_TASK_TYPES.has(item.type)
|
|
8516
|
+
) {
|
|
8517
|
+
const projCfg = shared.findProjectByName(shared.getProjects(config), itemProjName);
|
|
8518
|
+
if (projCfg && projCfg.worktreeMode === shared.WORKTREE_MODES.LIVE) {
|
|
8519
|
+
liveProjectsInUse.add(itemProjName);
|
|
8520
|
+
}
|
|
8521
|
+
}
|
|
8375
8522
|
generalSlots--;
|
|
8376
8523
|
}
|
|
8377
8524
|
|
|
@@ -8437,6 +8584,19 @@ async function tickInner() {
|
|
|
8437
8584
|
for (const d of (postDispatch.active || [])) {
|
|
8438
8585
|
if (d.meta?.branch) postLockedBranches.add(sanitizeBranch(d.meta.branch));
|
|
8439
8586
|
}
|
|
8587
|
+
// P-a3f9b205: Rebuild the per-project live-mode mutating set from the
|
|
8588
|
+
// post-dispatch active list so the annotation loop's reason is consistent
|
|
8589
|
+
// with whatever actually got spawned this tick.
|
|
8590
|
+
const postLiveProjectsInUse = new Set();
|
|
8591
|
+
for (const d of (postDispatch.active || [])) {
|
|
8592
|
+
if (READ_ONLY_ROOT_TASK_TYPES.has(d.type)) continue;
|
|
8593
|
+
const projName = d.project || d.meta?.project?.name || null;
|
|
8594
|
+
if (!projName) continue;
|
|
8595
|
+
const projCfg = shared.findProjectByName(shared.getProjects(config), projName);
|
|
8596
|
+
if (projCfg && projCfg.worktreeMode === shared.WORKTREE_MODES.LIVE) {
|
|
8597
|
+
postLiveProjectsInUse.add(projName);
|
|
8598
|
+
}
|
|
8599
|
+
}
|
|
8440
8600
|
let skipReasonChanged = false;
|
|
8441
8601
|
for (const item of (postDispatch.pending || [])) {
|
|
8442
8602
|
let reason = null;
|
|
@@ -8449,6 +8609,18 @@ async function tickInner() {
|
|
|
8449
8609
|
const pendingBranch = item.meta?.branch ? sanitizeBranch(item.meta.branch) : null;
|
|
8450
8610
|
if (pendingBranch && postLockedBranches.has(pendingBranch)) {
|
|
8451
8611
|
reason = 'branch_locked';
|
|
8612
|
+
} else {
|
|
8613
|
+
// P-a3f9b205: surface the per-project live-mode gate. Order matters:
|
|
8614
|
+
// max_concurrency / agent_busy / branch_locked are all more specific
|
|
8615
|
+
// and win when both apply.
|
|
8616
|
+
const itemProjName = item.project || item.meta?.project?.name || null;
|
|
8617
|
+
if (
|
|
8618
|
+
itemProjName
|
|
8619
|
+
&& !READ_ONLY_ROOT_TASK_TYPES.has(item.type)
|
|
8620
|
+
&& postLiveProjectsInUse.has(itemProjName)
|
|
8621
|
+
) {
|
|
8622
|
+
reason = 'live_checkout_busy';
|
|
8623
|
+
}
|
|
8452
8624
|
}
|
|
8453
8625
|
}
|
|
8454
8626
|
// Track when item first became blocked on a busy agent for reassignment threshold
|
|
@@ -8467,6 +8639,22 @@ async function tickInner() {
|
|
|
8467
8639
|
item.skipReason = reason;
|
|
8468
8640
|
skipReasonChanged = true;
|
|
8469
8641
|
}
|
|
8642
|
+
// P-a3f9b205: mirror _pendingReason for live_checkout_busy so the
|
|
8643
|
+
// dashboard's pending-reason chip surfaces the value (it reads
|
|
8644
|
+
// _pendingReason, not skipReason). Only the live-mode gate flows
|
|
8645
|
+
// through _pendingReason here — other reasons keep their existing
|
|
8646
|
+
// semantics (max_concurrency / agent_busy / branch_locked already
|
|
8647
|
+
// have their own _pendingReason owners earlier in the tick).
|
|
8648
|
+
if (reason === 'live_checkout_busy') {
|
|
8649
|
+
if (item._pendingReason !== 'live_checkout_busy') {
|
|
8650
|
+
item._pendingReason = 'live_checkout_busy';
|
|
8651
|
+
skipReasonChanged = true;
|
|
8652
|
+
}
|
|
8653
|
+
} else if (item._pendingReason === 'live_checkout_busy') {
|
|
8654
|
+
// Gate cleared — drop the stale pending reason so the chip clears.
|
|
8655
|
+
delete item._pendingReason;
|
|
8656
|
+
skipReasonChanged = true;
|
|
8657
|
+
}
|
|
8470
8658
|
}
|
|
8471
8659
|
if (skipReasonChanged) {
|
|
8472
8660
|
if (_isTickStale(myGeneration)) return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2162",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|