@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/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
- for (const f of safeReadDir(ARCHIVE_DIR)) {
547
- if (!f.endsWith('.md')) continue;
548
- const fm = _parseNoteFrontmatter(safeRead(path.join(ARCHIVE_DIR, f)));
549
- const wiId = _wiIdFromNoteFrontmatter(fm);
550
- if (wiId) addNote(wiId, 'archive:' + f);
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
- const allItems = getWorkItems(config);
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
- function getWorkItems(config) {
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 (_workItemsCache && (now - _workItemsCacheAt) < 1000) {
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(_workItemsCacheMtimes, currMtimes)) return _workItemsCache;
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
- _workItemsCache = allItems;
1910
- _workItemsCacheAt = now;
1911
- _workItemsCacheMtimes = _snapshotWorkItemsMtimes(config);
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 loads `~/.copilot/mcp-config.json` (the operator's
2962
- // user-scope MCP servers) UNCONDITIONALLY on every process start: it is NOT
2963
- // gated by `--add-dir`, cwd, or `hermeticHarness` (proven empirically a
2964
- // copilot spawned from a neutral dir with no `--add-dir ~/.copilot` still
2965
- // connects every server). So every spawned Copilot agent boots the operator's
2966
- // full interactive MCP stack; on Windows each `type:local`/`stdio` server
2967
- // (e.g. playwright, maestro) launches its own visible console window and
2968
- // playwright opens browser tabs. The only lever Copilot exposes is
2969
- // `--disable-mcp-server <name>` (repeatable). This list names the servers the
2970
- // engine disables for autonomous agent dispatches. Default [] = inherit ALL
2971
- // (no behavior change). Per-agent override `agent.copilotAgentDisabledMcpServers`.
2972
- // Resolved through `shared.resolveCopilotAgentDisabledMcpServers(agent, engine)`.
2973
- // NOTE: `hermeticHarness` does NOT suppress these (it only strips `--add-dir`).
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 (emitted as `--disable-mcp-server <name>`). Chain: per-agent
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 why this exists (Copilot loads ~/.copilot MCP config unconditionally;
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
- if (processAlive) {
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);
@@ -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,