@yemi33/minions 0.1.2238 → 0.1.2240
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/js/command-center.js +25 -2
- package/dashboard/js/refresh.js +18 -3
- package/dashboard/js/render-other.js +19 -5
- package/dashboard/js/render-plans.js +8 -6
- package/dashboard/js/render-work-items.js +5 -3
- package/dashboard/js/utils.js +23 -13
- package/dashboard.js +2 -2
- package/docs/harness-transparency.md +6 -5
- package/docs/live-checkout-mode.md +37 -7
- package/engine/cli.js +26 -0
- package/engine/comment-classifier.js +1 -1
- package/engine/consolidation.js +119 -24
- package/engine/dispatch-store.js +27 -4
- package/engine/dispatch.js +1 -0
- package/engine/github.js +4 -1
- package/engine/live-checkout.js +292 -10
- package/engine/queries.js +115 -14
- package/engine/shared.js +49 -17
- package/engine/timeout.js +10 -1
- package/engine/watchdog.js +75 -0
- package/engine.js +202 -13
- package/package.json +1 -1
package/engine/queries.js
CHANGED
|
@@ -526,6 +526,61 @@ function _wiIdFromNoteFrontmatter(fm) {
|
|
|
526
526
|
return fm.work_item || fm.wi || fm.sourceItem || null;
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
+
// Archive notes-by-WI cache. The archive dir accumulates thousands of
|
|
530
|
+
// immutable note files (2000+ and growing); reading + frontmatter-parsing all
|
|
531
|
+
// of them was the single dominant SYNCHRONOUS cost inside getWorkItems()
|
|
532
|
+
// (measured: multiple seconds with a cold OS file cache, a few hundred ms warm)
|
|
533
|
+
// — and getWorkItems runs on a 1 s TTL, so the dashboard re-read the whole
|
|
534
|
+
// archive every few seconds. That blocked the dashboard event loop and starved
|
|
535
|
+
// the Command Center SSE heartbeat into stalls + reconnects. Archive files are
|
|
536
|
+
// written once (consolidation moves inbox → archive and never edits in place),
|
|
537
|
+
// so an mtime-gated cache on the archive DIRECTORY is safe: any add/remove
|
|
538
|
+
// bumps the dir mtime and busts the cache; the TTL is a belt-and-suspenders
|
|
539
|
+
// backstop. Inbox is NOT cached here — it is tiny and churns constantly, so
|
|
540
|
+
// _buildNotesByWiMap scans it fresh every call (see below).
|
|
541
|
+
let _archiveNotesByWiCache = null;
|
|
542
|
+
let _archiveNotesByWiCacheAt = 0;
|
|
543
|
+
let _archiveNotesByWiCacheMtime = null;
|
|
544
|
+
// The dir-mtime gate is the PRIMARY freshness signal (NTFS/POSIX bump the
|
|
545
|
+
// archive dir mtime on every add/remove/rename, so a newly-archived note busts
|
|
546
|
+
// the cache on the next call). The TTL is only a belt-and-suspenders backstop
|
|
547
|
+
// for the rare FS that misses an mtime update — kept long so we don't pay the
|
|
548
|
+
// full multi-thousand-file re-read on a fixed cadence when nothing changed.
|
|
549
|
+
const ARCHIVE_NOTES_BY_WI_TTL = 300000; // 5 min
|
|
550
|
+
|
|
551
|
+
function _buildArchiveNotesByWiMap() {
|
|
552
|
+
let dirMtime = null;
|
|
553
|
+
try { dirMtime = fs.statSync(ARCHIVE_DIR).mtimeMs; } catch { dirMtime = null; }
|
|
554
|
+
const now = Date.now();
|
|
555
|
+
if (_archiveNotesByWiCache
|
|
556
|
+
&& (now - _archiveNotesByWiCacheAt) < ARCHIVE_NOTES_BY_WI_TTL
|
|
557
|
+
&& _archiveNotesByWiCacheMtime === dirMtime) {
|
|
558
|
+
return _archiveNotesByWiCache;
|
|
559
|
+
}
|
|
560
|
+
const out = Object.create(null);
|
|
561
|
+
for (const f of safeReadDir(ARCHIVE_DIR)) {
|
|
562
|
+
if (!f.endsWith('.md')) continue;
|
|
563
|
+
const fm = _parseNoteFrontmatter(safeRead(path.join(ARCHIVE_DIR, f)));
|
|
564
|
+
const wiId = _wiIdFromNoteFrontmatter(fm);
|
|
565
|
+
if (!wiId) continue;
|
|
566
|
+
if (!out[wiId]) out[wiId] = [];
|
|
567
|
+
out[wiId].push('archive:' + f);
|
|
568
|
+
}
|
|
569
|
+
_archiveNotesByWiCache = out;
|
|
570
|
+
_archiveNotesByWiCacheAt = now;
|
|
571
|
+
_archiveNotesByWiCacheMtime = dirMtime;
|
|
572
|
+
return out;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Test/maintenance hook — drop the archive notes cache (mirrors the other
|
|
576
|
+
// invalidate* helpers). Not normally needed: the cache self-heals via the
|
|
577
|
+
// dir-mtime gate + TTL.
|
|
578
|
+
function invalidateArchiveNotesByWiCache() {
|
|
579
|
+
_archiveNotesByWiCache = null;
|
|
580
|
+
_archiveNotesByWiCacheAt = 0;
|
|
581
|
+
_archiveNotesByWiCacheMtime = null;
|
|
582
|
+
}
|
|
583
|
+
|
|
529
584
|
// Build a wiId → [filename, ...] map by scanning the inbox + archive dirs.
|
|
530
585
|
// Archive entries are prefixed with `archive:` so the dashboard renderer can
|
|
531
586
|
// tell them apart (matches the convention already used by _artifacts.notes).
|
|
@@ -537,17 +592,18 @@ function _buildNotesByWiMap() {
|
|
|
537
592
|
if (!out[wiId]) out[wiId] = [];
|
|
538
593
|
if (!out[wiId].includes(token)) out[wiId].push(token);
|
|
539
594
|
};
|
|
595
|
+
// Inbox: tiny and churns constantly — scan fresh every call.
|
|
540
596
|
for (const f of safeReadDir(INBOX_DIR)) {
|
|
541
597
|
if (!f.endsWith('.md')) continue;
|
|
542
598
|
const fm = _parseNoteFrontmatter(safeRead(path.join(INBOX_DIR, f)));
|
|
543
599
|
const wiId = _wiIdFromNoteFrontmatter(fm);
|
|
544
600
|
if (wiId) addNote(wiId, f);
|
|
545
601
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
602
|
+
// Archive: large + immutable — served from the mtime-gated cache so we don't
|
|
603
|
+
// re-read thousands of files on every getWorkItems() rebuild.
|
|
604
|
+
const archiveMap = _buildArchiveNotesByWiMap();
|
|
605
|
+
for (const wiId in archiveMap) {
|
|
606
|
+
for (const token of archiveMap[wiId]) addNote(wiId, token);
|
|
551
607
|
}
|
|
552
608
|
return out;
|
|
553
609
|
}
|
|
@@ -635,7 +691,11 @@ function getAgentStatus(agentId) {
|
|
|
635
691
|
const staleOrphanTimeout = config.engine?.heartbeatTimeout || ENGINE_DEFAULTS.heartbeatTimeout;
|
|
636
692
|
const staleThresholdMs = staleOrphanTimeout * 2;
|
|
637
693
|
const now = Date.now();
|
|
638
|
-
|
|
694
|
+
// Lean read — this fallback only inspects core fields (dispatched_to,
|
|
695
|
+
// status, dispatched_at, …), never _pr/_artifacts/_notes. Skipping
|
|
696
|
+
// enrichment keeps the per-idle-agent getAgents() roster build off the
|
|
697
|
+
// heavy detail-modal path. (Fix #3 — dashboard event-loop perf.)
|
|
698
|
+
const allItems = getWorkItems(config, { enrich: false });
|
|
639
699
|
const latestInFlight = allItems
|
|
640
700
|
.filter(w => {
|
|
641
701
|
if ((w.dispatched_to || '').toLowerCase() !== String(agentId).toLowerCase()) return false;
|
|
@@ -1711,10 +1771,24 @@ function getKnowledgeBaseIndex() {
|
|
|
1711
1771
|
let _workItemsCache = null;
|
|
1712
1772
|
let _workItemsCacheAt = 0;
|
|
1713
1773
|
let _workItemsCacheMtimes = null;
|
|
1774
|
+
// Lean cache for getWorkItems(config, { enrich: false }) — core fields only,
|
|
1775
|
+
// skipping the PR cross-reference + per-item _artifacts/_notes detail-modal
|
|
1776
|
+
// enrichment (the synchronous KB-scan + agent-dir reads that the detail modal
|
|
1777
|
+
// needs but hot callers don't). getAgentStatus()'s per-idle-agent fallback and
|
|
1778
|
+
// count-only callers use this so the agent roster / CC preamble never pay the
|
|
1779
|
+
// enrichment cost. Held separately so an enrich:false caller can't poison the
|
|
1780
|
+
// enriched cache (and vice-versa) — readAllWorkItems() returns fresh row
|
|
1781
|
+
// objects each call, so the two caches never share mutable references.
|
|
1782
|
+
let _workItemsLeanCache = null;
|
|
1783
|
+
let _workItemsLeanCacheAt = 0;
|
|
1784
|
+
let _workItemsLeanCacheMtimes = null;
|
|
1714
1785
|
function invalidateWorkItemsCache() {
|
|
1715
1786
|
_workItemsCache = null;
|
|
1716
1787
|
_workItemsCacheAt = 0;
|
|
1717
1788
|
_workItemsCacheMtimes = null;
|
|
1789
|
+
_workItemsLeanCache = null;
|
|
1790
|
+
_workItemsLeanCacheAt = 0;
|
|
1791
|
+
_workItemsLeanCacheMtimes = null;
|
|
1718
1792
|
}
|
|
1719
1793
|
|
|
1720
1794
|
function _workItemsFilePaths(config) {
|
|
@@ -1744,14 +1818,29 @@ function _workItemsMtimesDiffer(prev, curr) {
|
|
|
1744
1818
|
return false;
|
|
1745
1819
|
}
|
|
1746
1820
|
|
|
1747
|
-
|
|
1821
|
+
// getWorkItems(config, { enrich })
|
|
1822
|
+
// enrich (default true): also cross-reference PRs and populate the
|
|
1823
|
+
// detail-modal _artifacts/_notes fields. The /api/work-items list payload
|
|
1824
|
+
// needs these (PR badges + note chips).
|
|
1825
|
+
// enrich: false — core fields only (status, dispatched_to, depends_on, …)
|
|
1826
|
+
// plus the cheap dispatch cross-reference. Skips the synchronous PR
|
|
1827
|
+
// cross-ref and per-item KB/agent-dir enrichment. Use from hot callers that
|
|
1828
|
+
// never read _artifacts/_notes (getAgentStatus fallback, count-only callers)
|
|
1829
|
+
// so the agent roster / CC preamble don't pay the enrichment cost on every
|
|
1830
|
+
// cold rebuild.
|
|
1831
|
+
function getWorkItems(config, opts) {
|
|
1832
|
+
const enrich = !(opts && opts.enrich === false);
|
|
1748
1833
|
const now = Date.now();
|
|
1749
1834
|
config = config || getConfig();
|
|
1750
|
-
if (
|
|
1835
|
+
if (enrich) {
|
|
1836
|
+
if (_workItemsCache && (now - _workItemsCacheAt) < 1000) {
|
|
1837
|
+
const currMtimes = _snapshotWorkItemsMtimes(config);
|
|
1838
|
+
if (!_workItemsMtimesDiffer(_workItemsCacheMtimes, currMtimes)) return _workItemsCache;
|
|
1839
|
+
}
|
|
1840
|
+
} else if (_workItemsLeanCache && (now - _workItemsLeanCacheAt) < 1000) {
|
|
1751
1841
|
const currMtimes = _snapshotWorkItemsMtimes(config);
|
|
1752
|
-
if (!_workItemsMtimesDiffer(
|
|
1842
|
+
if (!_workItemsMtimesDiffer(_workItemsLeanCacheMtimes, currMtimes)) return _workItemsLeanCache;
|
|
1753
1843
|
}
|
|
1754
|
-
const projects = getProjects(config);
|
|
1755
1844
|
let allItems = [];
|
|
1756
1845
|
|
|
1757
1846
|
// SQL is the canonical (and only) work-items store after Phase 9.
|
|
@@ -1795,6 +1884,10 @@ function getWorkItems(config) {
|
|
|
1795
1884
|
}
|
|
1796
1885
|
}
|
|
1797
1886
|
|
|
1887
|
+
// Detail-modal enrichment (PR cross-ref + _artifacts/_notes). Skipped for
|
|
1888
|
+
// enrich:false callers — see the getWorkItems header.
|
|
1889
|
+
if (enrich) {
|
|
1890
|
+
const projects = getProjects(config);
|
|
1798
1891
|
// Cross-reference with PRs
|
|
1799
1892
|
const allPrs = getPullRequests(config);
|
|
1800
1893
|
for (const item of allItems) {
|
|
@@ -1890,6 +1983,7 @@ function getWorkItems(config) {
|
|
|
1890
1983
|
const mentions = _notesByWi[item.id];
|
|
1891
1984
|
if (mentions && mentions.length > 0) item._notes = mentions;
|
|
1892
1985
|
}
|
|
1986
|
+
} // end if (enrich)
|
|
1893
1987
|
|
|
1894
1988
|
const statusOrder = {
|
|
1895
1989
|
pending: 0,
|
|
@@ -1906,9 +2000,16 @@ function getWorkItems(config) {
|
|
|
1906
2000
|
return (b.created || '').localeCompare(a.created || '');
|
|
1907
2001
|
});
|
|
1908
2002
|
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
2003
|
+
const cacheMtimes = _snapshotWorkItemsMtimes(config);
|
|
2004
|
+
if (enrich) {
|
|
2005
|
+
_workItemsCache = allItems;
|
|
2006
|
+
_workItemsCacheAt = now;
|
|
2007
|
+
_workItemsCacheMtimes = cacheMtimes;
|
|
2008
|
+
} else {
|
|
2009
|
+
_workItemsLeanCache = allItems;
|
|
2010
|
+
_workItemsLeanCacheAt = now;
|
|
2011
|
+
_workItemsLeanCacheMtimes = cacheMtimes;
|
|
2012
|
+
}
|
|
1912
2013
|
return allItems;
|
|
1913
2014
|
}
|
|
1914
2015
|
|
|
@@ -3103,7 +3204,7 @@ module.exports = {
|
|
|
3103
3204
|
getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getKnowledgeBaseIndex,
|
|
3104
3205
|
|
|
3105
3206
|
// Work items & PRD
|
|
3106
|
-
getWorkItems, invalidateWorkItemsCache, getPrdInfo,
|
|
3207
|
+
getWorkItems, invalidateWorkItemsCache, invalidateArchiveNotesByWiCache, getPrdInfo,
|
|
3107
3208
|
|
|
3108
3209
|
// W-mq5uzmc6001d708f — test hooks for the defensive PR enrichment cache.
|
|
3109
3210
|
_resetPrEnrichmentCacheForTest,
|
package/engine/shared.js
CHANGED
|
@@ -2958,19 +2958,20 @@ const ENGINE_DEFAULTS = {
|
|
|
2958
2958
|
copilotSuppressAgentsMd: true, // Copilot --no-custom-instructions: stop AGENTS.md auto-load from fighting Minions playbook prompts
|
|
2959
2959
|
copilotStreamMode: 'on', // Copilot --stream <on|off>: 'on' streams assistant.message_delta events live; 'off' batches them
|
|
2960
2960
|
copilotReasoningSummaries: false, // Copilot --enable-reasoning-summaries (Anthropic-family models only)
|
|
2961
|
-
// P-mcp-storm — Copilot
|
|
2962
|
-
//
|
|
2963
|
-
//
|
|
2964
|
-
//
|
|
2965
|
-
//
|
|
2966
|
-
//
|
|
2967
|
-
//
|
|
2968
|
-
//
|
|
2969
|
-
//
|
|
2970
|
-
//
|
|
2971
|
-
//
|
|
2972
|
-
//
|
|
2973
|
-
//
|
|
2961
|
+
// P-mcp-storm — Copilot SPAWNS every server in its `mcp-config.json` on every
|
|
2962
|
+
// process start and authenticates it. `--disable-mcp-server <name>` only hides
|
|
2963
|
+
// a server's TOOLS from the model; it does NOT stop the server process (proven
|
|
2964
|
+
// empirically — agents spawned azmcp/workiq even with the flag set), so any
|
|
2965
|
+
// auth-requiring server pops a Microsoft sign-in window on EVERY agent spawn.
|
|
2966
|
+
// The ONLY real lever is the CONFIG: a server absent from the config can't be
|
|
2967
|
+
// spawned. The engine therefore points agents at a private COPILOT_HOME
|
|
2968
|
+
// (`shared.resolveAgentCopilotHome`) whose mcp-config is a copy of the operator's
|
|
2969
|
+
// `~/.copilot/mcp-config.json` MINUS the names in this list (see engine.js
|
|
2970
|
+
// `_applyAgentCopilotHome`). Default [] = inherit ALL of the operator's MCP
|
|
2971
|
+
// servers for agents (non-breaking). Add a name to disable it for agents only;
|
|
2972
|
+
// the operator's interactive `~/.copilot` is never touched. Per-agent override
|
|
2973
|
+
// `agent.copilotAgentDisabledMcpServers`. Resolved through
|
|
2974
|
+
// `shared.resolveCopilotAgentDisabledMcpServers(agent, engine)`.
|
|
2974
2975
|
copilotAgentDisabledMcpServers: [],
|
|
2975
2976
|
ccUseWorkerPool: false, // Sub-task C of W-mp2w003600196c51 (CC perf): when true AND CC runtime is copilot, _invokeCcStream routes through engine/cc-worker-pool.js (persistent `copilot --acp` per CC tab) instead of spawning a fresh CLI per turn. Off by default — opt-in feature flag. **Structurally copilot-only**: the pool spawns `copilot --acp` (Agent Client Protocol); Claude Code does not implement ACP, so resolveCcUseWorkerPool returns false on non-copilot CC runtimes even with explicit-true (W-mphlriic00095f69 — prevents silent runtime switch). Engine/agent dispatch path stays per-process regardless.
|
|
2976
2977
|
maxBudgetUsd: undefined, // fleet USD ceiling for --max-budget-usd (per-agent override: agents.<id>.maxBudgetUsd). Honors 0 via ?? so a literal cap of $0 works
|
|
@@ -3375,13 +3376,15 @@ function resolveAgentBareMode(agent, engine) {
|
|
|
3375
3376
|
}
|
|
3376
3377
|
|
|
3377
3378
|
// P-mcp-storm — Resolve the list of MCP server names to disable for spawned
|
|
3378
|
-
// Copilot agents
|
|
3379
|
+
// Copilot agents. The engine FILTERS these out of the agent's seeded
|
|
3380
|
+
// COPILOT_HOME mcp-config (engine.js `_applyAgentCopilotHome`) — a server absent
|
|
3381
|
+
// from the config can't be spawned, which is the only effective disable
|
|
3382
|
+
// (`--disable-mcp-server` does not stop the process). Chain: per-agent
|
|
3379
3383
|
// `agent.copilotAgentDisabledMcpServers` → `engine.copilotAgentDisabledMcpServers`
|
|
3380
3384
|
// → `[]` (inherit all). Tolerant of either an array OR a comma/whitespace-
|
|
3381
3385
|
// separated string (Settings UI / `MINIONS_*` env deliver strings). Always
|
|
3382
3386
|
// returns a deduped array of trimmed, non-empty strings. See ENGINE_DEFAULTS
|
|
3383
|
-
// for
|
|
3384
|
-
// `hermeticHarness` does NOT suppress it).
|
|
3387
|
+
// for the full rationale.
|
|
3385
3388
|
function _normalizeMcpServerList(v) {
|
|
3386
3389
|
if (v == null) return null;
|
|
3387
3390
|
const raw = Array.isArray(v) ? v : String(v).split(/[\s,]+/);
|
|
@@ -4297,7 +4300,9 @@ const FAILURE_CLASS = {
|
|
|
4297
4300
|
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.
|
|
4298
4301
|
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.
|
|
4299
4302
|
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.
|
|
4300
|
-
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.
|
|
4303
|
+
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. RESERVED for confirmed dirty status results only — a thrown helper/git exception is LIVE_CHECKOUT_FAILED, not this (#305).
|
|
4304
|
+
LIVE_CHECKOUT_FAILED: 'live-checkout-failed', // #305 (live-checkout dispatch mode): prepareLiveCheckout THREW before agent spawn (helper guard, ref validation, or a transient `git status`/`rev-parse`/`checkout` failure) — distinct from the confirmed-dirty result (LIVE_CHECKOUT_DIRTY) which the helper returns, not throws. A thrown error is NOT proof the tree is dirty, so it must not be over-classified as dirty. Retryable with bounded backoff (NOT in dispatch.js neverRetry): racy branch-lock handoff, just-finished sibling dispatch, or transient git errors frequently clear on the next attempt; the engine auto-retries up to ENGINE_DEFAULTS.maxRetries before giving up. Genuinely terminal underlying reasons (auth, validation) still short-circuit via the reason-string check in isRetryableFailureReason.
|
|
4305
|
+
LIVE_CHECKOUT_MID_OPERATION: 'live-checkout-mid-operation', // P-a7f3c1d9 (live-checkout dispatch mode): spawnAgent could not switch/create the target branch in project.localPath because the operator tree is mid-operation — an in-progress merge/rebase/cherry-pick/bisect or a detached HEAD. Distinct from LIVE_CHECKOUT_DIRTY (uncommitted changes): here the tree may be clean but the branch op cannot proceed. Engine refuses to spawn (it never runs `git reset`/`git clean`/`git rebase --abort` against the operator tree). Non-retryable — operator must finish or abort the in-progress operation, or checkout a branch, before re-dispatch.
|
|
4301
4306
|
INVALID_WORKDIR: 'invalid-workdir', // P-714ef144: dispatch carried a meta.workdir override that failed validation — non-string, absolute path, drive-letter prefix, null byte, ".." segment, or post-resolve containment escape against project.localPath / worktree root. Engine refuses to spawn (the subpath would either be unreachable on disk or point outside the operator's allowed surface). Non-retryable — operator must fix the WI's meta.workdir before re-dispatch. Inbox alert lists the offending value + the resolved-vs-base mismatch.
|
|
4302
4307
|
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).
|
|
4303
4308
|
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.
|
|
@@ -5635,6 +5640,32 @@ function resolveProjectRootDir(localPath, minionsDir) {
|
|
|
5635
5640
|
return fallback;
|
|
5636
5641
|
}
|
|
5637
5642
|
|
|
5643
|
+
/**
|
|
5644
|
+
* Resolve a stable, repo-EXTERNAL COPILOT_HOME for spawned copilot agents.
|
|
5645
|
+
*
|
|
5646
|
+
* Copilot loads `$COPILOT_HOME/mcp-config.json` and SPAWNS every server in it on
|
|
5647
|
+
* every process start. `--disable-mcp-server <name>` only hides a server's tools
|
|
5648
|
+
* from the model — it does NOT stop the server PROCESS from launching, so any
|
|
5649
|
+
* server that does interactive auth (azure-kusto/azmcp, DevBox, workiq, loop, …)
|
|
5650
|
+
* still pops a Microsoft sign-in window on EVERY agent spawn. With minions
|
|
5651
|
+
* spawning agents continuously that is an unbounded popup storm.
|
|
5652
|
+
*
|
|
5653
|
+
* The only effective lever is to give agents a COPILOT_HOME whose mcp-config has
|
|
5654
|
+
* NO user servers (the engine seeds `{"mcpServers":{}}` there). Copilot auth is
|
|
5655
|
+
* unaffected — it uses GH_TOKEN / the OS credential store, not files under the
|
|
5656
|
+
* home — and the operator's real `~/.copilot` (interactive copilot) is untouched.
|
|
5657
|
+
*
|
|
5658
|
+
* Placed on the MINIONS_DIR volume (copilot's session-store can grow to GBs, so
|
|
5659
|
+
* keep it off a possibly-full system drive) but OUTSIDE the repo, at the drive
|
|
5660
|
+
* root, so concurrent git branch switches in the working tree can never delete or
|
|
5661
|
+
* dirty it. Stable path so copilot session resume (`--resume`) stays consistent
|
|
5662
|
+
* across spawns. Falls back to the user home when no minionsDir is given.
|
|
5663
|
+
*/
|
|
5664
|
+
function resolveAgentCopilotHome(minionsDir) {
|
|
5665
|
+
const base = minionsDir ? path.parse(path.resolve(String(minionsDir))).root : os.homedir();
|
|
5666
|
+
return path.join(base, '.minions-agent-copilot-home');
|
|
5667
|
+
}
|
|
5668
|
+
|
|
5638
5669
|
// ── Spawn cwd vs worktree placement (W-mp73x32w000l143d) ──────────────────────
|
|
5639
5670
|
// Work types that don't need a git worktree — they read repo state but don't
|
|
5640
5671
|
// produce code changes. Centralized here so engine.js spawnAgent and any
|
|
@@ -8929,6 +8960,7 @@ module.exports = {
|
|
|
8929
8960
|
parseWorktreePorcelain,
|
|
8930
8961
|
assertWorktreeOutsideProject,
|
|
8931
8962
|
resolveProjectRootDir,
|
|
8963
|
+
resolveAgentCopilotHome,
|
|
8932
8964
|
resolveSpawnPaths,
|
|
8933
8965
|
validateWorkItemWorkdir,
|
|
8934
8966
|
applyWorkdir,
|
package/engine/timeout.js
CHANGED
|
@@ -748,7 +748,16 @@ function checkTimeouts(config) {
|
|
|
748
748
|
// mid-multi-turn session. Confirmed 2026-05-11: agents on both Copilot and
|
|
749
749
|
// Claude died at turn boundaries with claude/copilot.exe still actively
|
|
750
750
|
// making API calls, with the sentinel + claude events interleaved in the log.
|
|
751
|
-
|
|
751
|
+
//
|
|
752
|
+
// PID requirement: the alive guard is only reliable when a verifiable OS PID
|
|
753
|
+
// is present. A PID-less tracked object (e.g. stale in-memory handle after
|
|
754
|
+
// engine restart, or a Copilot spawn that never resolved its PID) has no
|
|
755
|
+
// real process behind it — isTrackedProcessAlive falls back to `!!proc &&
|
|
756
|
+
// !proc.killed` which is unverifiable. Trusting it loops forever (#319).
|
|
757
|
+
// When the PID is null, the terminal sentinel is the strongest available
|
|
758
|
+
// signal; complete from output unconditionally.
|
|
759
|
+
const pidVerifiable = trackedProcessPid(procInfo) !== null;
|
|
760
|
+
if (processAlive && pidVerifiable) {
|
|
752
761
|
log('warn', `${item.id}: [process-exit] code=${processExitCode} sentinel found in live-output.log but tracked process (pid=${trackedProcessPid(procInfo) || '?'}) is still alive — treating sentinel as stale/premature, skipping completion`);
|
|
753
762
|
} else {
|
|
754
763
|
completeFromOutput(item, liveLogPath, processExitCode, liveLogTail, hasProcess);
|
package/engine/watchdog.js
CHANGED
|
@@ -46,6 +46,54 @@ const WATCHDOG_LOG_NAME = 'watchdog-stdio.log';
|
|
|
46
46
|
// over months. Truncates to half-size on overflow (oldest lines dropped).
|
|
47
47
|
const WATCHDOG_LOG_MAX_BYTES = 2 * 1024 * 1024;
|
|
48
48
|
|
|
49
|
+
// Post-spawn grace beacon (P-b2d6c14a). Each watchdog tick is a fresh OS-
|
|
50
|
+
// scheduled process with NO in-memory memory of the previous tick, and the
|
|
51
|
+
// engine writes its PID to control.json LATE in boot. So on a slow Windows
|
|
52
|
+
// cold-start the next scheduled tick can read a not-yet-written PID, conclude
|
|
53
|
+
// "dead", and fire a SECOND `minions start` before the first finished binding
|
|
54
|
+
// — re-introducing the documented duplicate-engine/dashboard storm class. The
|
|
55
|
+
// in-process supervisor avoids this with an in-memory POST_SPAWN_GRACE_MS
|
|
56
|
+
// (engine/supervisor.js:76); the watchdog can't keep state across ticks, so it
|
|
57
|
+
// persists a tiny timestamp beacon to disk and stands down inside the window.
|
|
58
|
+
const WATCHDOG_SPAWN_BEACON_NAME = 'watchdog-spawn.json';
|
|
59
|
+
// Floor for the grace window. The effective window is max(2 × interval, floor)
|
|
60
|
+
// so a fast cadence (e.g. --interval=1) still leaves a slow cold-start time to
|
|
61
|
+
// finish, while a long cadence scales the window up with the interval.
|
|
62
|
+
const POST_SPAWN_GRACE_FLOOR_MS = 90 * 1000;
|
|
63
|
+
|
|
64
|
+
function watchdogSpawnBeaconPath(minionsHome) {
|
|
65
|
+
return path.join(minionsHome, 'engine', WATCHDOG_SPAWN_BEACON_NAME);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Effective grace window: max(2 × interval, 90s). intervalMin defaults to the
|
|
69
|
+
// scheduler's default cadence when the caller doesn't thread one through.
|
|
70
|
+
function resolvePostSpawnGraceMs(intervalMin) {
|
|
71
|
+
const iv = Number.isFinite(intervalMin) && intervalMin > 0 ? intervalMin : DEFAULT_INTERVAL_MIN;
|
|
72
|
+
return Math.max(2 * iv * 60 * 1000, POST_SPAWN_GRACE_FLOOR_MS);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Read the last-spawn timestamp (ms epoch) from the beacon. Returns null when
|
|
76
|
+
// the beacon is missing, unreadable, or malformed — any of which means "no
|
|
77
|
+
// recent spawn on record", so recovery proceeds (fail-open).
|
|
78
|
+
function readSpawnBeaconTs(minionsHome) {
|
|
79
|
+
try {
|
|
80
|
+
const obj = JSON.parse(fs.readFileSync(watchdogSpawnBeaconPath(minionsHome), 'utf8'));
|
|
81
|
+
const ts = obj && Number(obj.ts);
|
|
82
|
+
return Number.isFinite(ts) ? ts : null;
|
|
83
|
+
} catch { return null; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Stamp the beacon just before we spawn. Failure is swallowed (fail-open): a
|
|
87
|
+
// beacon we couldn't write must never block the recovery spawn — at worst we
|
|
88
|
+
// lose the double-fire guard for one window, which is the pre-existing risk.
|
|
89
|
+
function writeSpawnBeacon(minionsHome, nowMs, action) {
|
|
90
|
+
try {
|
|
91
|
+
const dir = path.join(minionsHome, 'engine');
|
|
92
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
93
|
+
fs.writeFileSync(watchdogSpawnBeaconPath(minionsHome), JSON.stringify({ ts: nowMs, action }));
|
|
94
|
+
} catch { /* fail-open — recovery still proceeds */ }
|
|
95
|
+
}
|
|
96
|
+
|
|
49
97
|
function logLine(minionsHome, msg) {
|
|
50
98
|
try {
|
|
51
99
|
const dir = path.join(minionsHome, 'engine');
|
|
@@ -155,6 +203,24 @@ async function tick(opts) {
|
|
|
155
203
|
|
|
156
204
|
const partial = engineAlive || dashUp;
|
|
157
205
|
const action = partial ? 'restart' : 'start';
|
|
206
|
+
|
|
207
|
+
// Post-spawn grace: if a previous tick already issued a spawn within the
|
|
208
|
+
// grace window, stand down instead of double-firing. Each tick is a fresh
|
|
209
|
+
// stateless process, so the only memory is the on-disk beacon. This closes
|
|
210
|
+
// the slow-cold-start race where control.json's late PID write lets the next
|
|
211
|
+
// scheduled tick spawn a second daemon stack.
|
|
212
|
+
const graceMs = resolvePostSpawnGraceMs(opts.intervalMin);
|
|
213
|
+
const nowMs = now().getTime();
|
|
214
|
+
const lastSpawnTs = readSpawnBeaconTs(minionsHome);
|
|
215
|
+
if (lastSpawnTs != null && nowMs - lastSpawnTs < graceMs) {
|
|
216
|
+
logLine(
|
|
217
|
+
minionsHome,
|
|
218
|
+
`unhealthy but recent spawn ${nowMs - lastSpawnTs}ms ago (< grace ${graceMs}ms) ` +
|
|
219
|
+
`→ standing down (post-spawn grace, avoiding double-fire)`
|
|
220
|
+
);
|
|
221
|
+
return { healthy: false, action: 'skip', reason: 'post-spawn-grace' };
|
|
222
|
+
}
|
|
223
|
+
|
|
158
224
|
logLine(
|
|
159
225
|
minionsHome,
|
|
160
226
|
`unhealthy engine=${enginePid || '-'}(${engineAlive ? 'alive' : 'dead'}) ` +
|
|
@@ -162,6 +228,11 @@ async function tick(opts) {
|
|
|
162
228
|
);
|
|
163
229
|
|
|
164
230
|
try {
|
|
231
|
+
// Stamp the spawn beacon BEFORE spawning so the next tick (which may fire
|
|
232
|
+
// before the new stack has bound its port / written control.json) sees the
|
|
233
|
+
// in-flight spawn and stands down. Write failure is swallowed — recovery
|
|
234
|
+
// proceeds regardless (fail-open).
|
|
235
|
+
writeSpawnBeacon(minionsHome, nowMs, action);
|
|
165
236
|
// Detach the child so its lifetime is independent of this tick process.
|
|
166
237
|
// The OS scheduler closes our stdio shortly after exit; we don't want
|
|
167
238
|
// that closure to cascade into the new daemon stack.
|
|
@@ -477,6 +548,10 @@ module.exports = {
|
|
|
477
548
|
MAC_PLIST_LABEL,
|
|
478
549
|
LINUX_UNIT_NAME,
|
|
479
550
|
WATCHDOG_LOG_NAME,
|
|
551
|
+
WATCHDOG_SPAWN_BEACON_NAME,
|
|
552
|
+
POST_SPAWN_GRACE_FLOOR_MS,
|
|
553
|
+
watchdogSpawnBeaconPath,
|
|
554
|
+
resolvePostSpawnGraceMs,
|
|
480
555
|
buildMacPlist,
|
|
481
556
|
buildLinuxService,
|
|
482
557
|
buildLinuxTimer,
|