@yemi33/minions 0.1.2187 → 0.1.2189

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 CHANGED
@@ -8130,25 +8130,69 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8130
8130
  let _docAbort = null;
8131
8131
  let _docStreamEnded = false;
8132
8132
  let _docHeartbeatTimer = null;
8133
+ // W-mpetru71000re5de — per-stream backpressure clock + approximate queued
8134
+ // bytes, mirroring writeCcEvent so a runtime error frame can't be swallowed
8135
+ // forever behind a stuck consumer (the "spins endlessly" failure mode).
8136
+ // _docBpStartedAt timestamps the first write that returned false (reset on
8137
+ // 'drain'); _docQueuedBytes accumulates bytes pushed past res.writable's
8138
+ // highWaterMark. The heartbeat tick force-closes the stream once the clock
8139
+ // exceeds SSE_STUCK_KILL_MS, which fires req.on('close') → abort + cleanup
8140
+ // so the client's reader gets `done` and renders a real error instead of
8141
+ // spinning. Doc-chat has no reconnect-replay, so we only shed intermediate
8142
+ // frames (chunk/tool/progress/heartbeat) — terminal `done` and `error`
8143
+ // frames are NEVER shed so the final answer / typed error always lands.
8144
+ let _docBpStartedAt = null;
8145
+ let _docQueuedBytes = 0;
8146
+ try {
8147
+ res.on('drain', () => { _docBpStartedAt = null; _docQueuedBytes = 0; });
8148
+ } catch { /* listener registration is best-effort */ }
8133
8149
  const writeDocEvent = (payload) => {
8134
- // TODO(W-mpetru71000re5de): doc-chat SSE has the same unbounded-queue
8135
- // failure mode as CC's writeCcEvent res.write() returning false from
8136
- // backpressure silently queues bytes in Node's WritableState.buffered[].
8137
- // Out of scope for this fix (task is scoped to CC only). When this is
8138
- // addressed, mirror the SSE_MAX_QUEUE_BYTES shed + SSE_STUCK_KILL_MS
8139
- // heartbeat force-close pattern from the writeCcEvent closure
8140
- // (dashboard.js, search for SSE_MAX_QUEUE_BYTES).
8150
+ const type = payload && payload.type;
8151
+ const isTerminal = type === 'done' || type === 'error';
8152
+ const _logFail = (reason) => {
8153
+ try {
8154
+ shared.log('warn', `[doc-sse-fail] ${JSON.stringify({ doc: docKey || 'unknown', type, reason, destroyed: !!res.destroyed, writableEnded: !!res.writableEnded, streamEnded: _docStreamEnded })}`);
8155
+ } catch { /* telemetry is best-effort */ }
8156
+ };
8157
+ if (res.destroyed || res.writableEnded) {
8158
+ _logFail(res.destroyed ? 'res-destroyed' : 'res-writable-ended');
8159
+ return false;
8160
+ }
8161
+ let wire;
8141
8162
  try {
8142
- const type = payload && payload.type;
8143
- // W-mpmwxni2000c25c7-d mirror the writeCcEvent change so doc-chat
8144
- // also emits `event: error` for terminal errors. Same back-compat:
8145
- // the JSON still carries `type: 'error'` for data-line parsers.
8163
+ // W-mpmwxni2000c25c7-d terminal error frames go out as `event: error`
8164
+ // so SSE consumers using addEventListener('error', …) can target them.
8165
+ // The JSON payload still carries `type: 'error'` for the data-line
8166
+ // parser in modal-qa.js.
8146
8167
  const eventLine = (type === 'error') ? 'event: error\n' : '';
8147
- res.write(eventLine + 'data: ' + JSON.stringify(payload) + '\n\n');
8148
- return true;
8168
+ wire = eventLine + 'data: ' + JSON.stringify(payload) + '\n\n';
8149
8169
  } catch {
8170
+ _logFail('json-serialize-failed');
8150
8171
  return false;
8151
8172
  }
8173
+ // Shed intermediate frames once the queue exceeds the cap, but ALWAYS
8174
+ // attempt terminal frames — the `done` frame carries the full final
8175
+ // answer and the `error` frame is the whole point of this fix.
8176
+ if (!isTerminal && _docQueuedBytes > SSE_MAX_QUEUE_BYTES) {
8177
+ try {
8178
+ shared.log('warn', `[doc-sse-shed] doc=${docKey || 'unknown'} type=${type} queuedBytes=${_docQueuedBytes} wireBytes=${wire.length}`);
8179
+ } catch { /* telemetry is best-effort */ }
8180
+ return true;
8181
+ }
8182
+ let writeOk;
8183
+ try { writeOk = res.write(wire); }
8184
+ catch { _logFail('res-write-threw'); return false; }
8185
+ if (writeOk === false) {
8186
+ // Backpressure — the write is still queued. Start (or extend) the
8187
+ // backpressure clock so the heartbeat tick can force-close a stuck
8188
+ // stream, and accumulate approximate queued bytes for the shed gate.
8189
+ if (_docBpStartedAt == null) _docBpStartedAt = Date.now();
8190
+ _docQueuedBytes += wire.length;
8191
+ try {
8192
+ shared.log('warn', `[doc-sse-backpressure] doc=${docKey || 'unknown'} type=${type} bytes=${wire.length} queuedBytes=${_docQueuedBytes} bpMs=${Date.now() - _docBpStartedAt}`);
8193
+ } catch { /* telemetry is best-effort */ }
8194
+ }
8195
+ return true;
8152
8196
  };
8153
8197
  const stopDocHeartbeat = () => {
8154
8198
  if (_docHeartbeatTimer) {
@@ -8196,6 +8240,20 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8196
8240
  stopDocHeartbeat();
8197
8241
  return;
8198
8242
  }
8243
+ // W-mpetru71000re5de — force-close streams stuck on backpressure.
8244
+ // res.destroy() fires req.on('close'), which runs the teardown path
8245
+ // below (stop heartbeat → drop the in-flight guard → abort the LLM),
8246
+ // so a runtime that finished (or errored) but whose frame can't reach
8247
+ // a wedged consumer no longer leaves the client spinning forever.
8248
+ if (_docBpStartedAt && Date.now() - _docBpStartedAt > SSE_STUCK_KILL_MS) {
8249
+ const stuckMs = Date.now() - _docBpStartedAt;
8250
+ try {
8251
+ shared.log('warn', `[doc-sse-stuck-close] doc=${docKey || 'unknown'} stuckMs=${stuckMs} queuedBytes=${_docQueuedBytes}`);
8252
+ } catch { /* telemetry is best-effort */ }
8253
+ stopDocHeartbeat();
8254
+ try { res.destroy(); } catch { /* swallow — req.on('close') will still fire */ }
8255
+ return;
8256
+ }
8199
8257
  if (!writeDocEvent({ type: 'heartbeat' })) stopDocHeartbeat();
8200
8258
  }, CC_STREAM_HEARTBEAT_MS);
8201
8259
 
@@ -8256,12 +8314,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8256
8314
  llm.trackEngineError('doc-chat', errCode);
8257
8315
  const isHardFailure = !partial && !(finalize && finalize.edited);
8258
8316
  if (isHardFailure) {
8259
- const errPayload = {
8317
+ // Route through writeDocEvent so the terminal error frame inherits
8318
+ // the destroyed/ended guards and is never shed under backpressure.
8319
+ writeDocEvent({
8320
+ type: 'error',
8260
8321
  message: ccError.typedMessage || ccError.errorMessage || 'Document chat failed',
8261
8322
  code: errCode,
8262
8323
  retriable: ccError.retriable !== false,
8263
- };
8264
- try { res.write(`event: error\ndata: ${JSON.stringify(errPayload)}\n\n`); } catch {}
8324
+ });
8265
8325
  }
8266
8326
  }
8267
8327
  const { answer: finalAnswer, ...donePayload } = payload;
@@ -13500,13 +13560,28 @@ if (require.main === module) {
13500
13560
  // (engine/shared.js#openUrlInBrowser) now owns the env-var check and
13501
13561
  // emits a debug-level SUPPRESSED log entry so we can prove the kill-
13502
13562
  // switch is firing.
13503
- const result = shared.openUrlInBrowser(`http://localhost:${PORT}`, {
13504
- reason: 'dashboard-self-open',
13505
- callerHint: 'dashboard.js:13124',
13506
- });
13507
- if (!result.ok && !result.suppressed) {
13508
- console.log(` Could not auto-open browser: ${result.error}`);
13509
- console.log(` Please open http://localhost:${PORT} manually.`);
13563
+ //
13564
+ // W-mqef-dashboard-tty — only self-open for an interactive human run
13565
+ // (`node dashboard.js` in a terminal). The CLI spawns the dashboard
13566
+ // DETACHED + non-TTY with MINIONS_NO_AUTO_OPEN=1 and orchestrates the open
13567
+ // itself, so it never relied on this branch. But the dashboard integration
13568
+ // tests — and any agent running `npm test` spawn dashboard.js with piped
13569
+ // stdio and NO env guard, so this fired for real on every test run, popping
13570
+ // a browser tab to a random localhost port that the test then tore down
13571
+ // (blank window on the operator's desktop). stdout.isTTY is false for every
13572
+ // programmatic spawn (tests, agents, CI, the detached CLI dashboard) and
13573
+ // true only for a real terminal session, so it's the correct discriminator.
13574
+ if (process.stdout.isTTY) {
13575
+ const result = shared.openUrlInBrowser(`http://localhost:${PORT}`, {
13576
+ reason: 'dashboard-self-open',
13577
+ callerHint: 'dashboard.js:13124',
13578
+ });
13579
+ if (!result.ok && !result.suppressed) {
13580
+ console.log(` Could not auto-open browser: ${result.error}`);
13581
+ console.log(` Please open http://localhost:${PORT} manually.`);
13582
+ }
13583
+ } else {
13584
+ console.log(` Open http://localhost:${PORT} in your browser.`);
13510
13585
  }
13511
13586
 
13512
13587
  // Warm the CC runtime binary cache off the request path so the first CC /
@@ -142,6 +142,24 @@ EPERM/EBUSY stragglers that the dispatch-end GC couldn't reap and sweeps
142
142
  the `git worktree list` registry for OUT-of-root entries the in-root
143
143
  scanner is blind to.
144
144
 
145
+ ### Ownership marker — out-of-root GC only touches engine worktrees (W-mqecdoot)
146
+
147
+ A project repo's `git worktree list` includes EVERY worktree registered
148
+ against it — including ones a developer created by hand
149
+ (`git worktree add /tmp/my-work <branch>`) for their own work. The
150
+ out-of-root sweep used to evict any such entry that wasn't pinned to a
151
+ live dispatch / pool / managed-spawn, **deleting human worktrees
152
+ mid-edit**. The fix: the engine stamps a `.minions-worktree` ownership
153
+ marker (`shared.WORKTREE_OWNER_MARKER`) into every worktree it creates
154
+ (`shared.writeWorktreeOwnerMarker`, called right after `git worktree add`
155
+ in `engine.js#runWorktreeAdd`). `pruneOrphanWorktreesFromGitRegistry`
156
+ now requires `shared.hasWorktreeOwnerMarker(path)` before evicting an
157
+ out-of-root worktree — **no marker → foreign → kept and never
158
+ escalated** (fail-open: leak a dir rather than nuke unpushed work). The
159
+ trailing `git worktree prune --expire=now` (drops registry entries whose
160
+ dirs are already gone) still runs regardless of ownership. The marker is
161
+ gitignored so it never shows up in any worktree's `git status`.
162
+
145
163
  ## Holder identification + opt-in auto-reap (W-mq6f2fe0000557fa)
146
164
 
147
165
  Orphan-sweep escalations also run `shared.findProcessesWithCwdInside(wt)`
package/engine/shared.js CHANGED
@@ -7790,6 +7790,49 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
7790
7790
  }
7791
7791
  }
7792
7792
 
7793
+ // W-mqecdoot — engine worktree ownership marker. The out-of-root worktree GC
7794
+ // (worktree-gc.pruneOrphanWorktreesFromGitRegistry) reaps worktrees registered
7795
+ // in a project's git that live OUTSIDE the configured worktreeRoot. Without a
7796
+ // positive ownership signal it cannot tell an engine-created orphan from a
7797
+ // worktree a human developer created by hand (`git worktree add /tmp/foo …`)
7798
+ // for their own work — and was deleting the latter mid-edit. We stamp this
7799
+ // marker into every worktree the engine creates so the GC can require proof of
7800
+ // ownership before removal; a worktree with NO marker is treated as foreign
7801
+ // and left untouched (fail-open: leak a dir rather than nuke someone's work).
7802
+ const WORKTREE_OWNER_MARKER = '.minions-worktree';
7803
+
7804
+ // Best-effort stamp dropped right after a successful `git worktree add`. Never
7805
+ // throws — a missing marker only costs us the ability to auto-GC that dir.
7806
+ function writeWorktreeOwnerMarker(worktreePath, info = {}) {
7807
+ if (!worktreePath) return false;
7808
+ try {
7809
+ const target = path.join(path.resolve(worktreePath), WORKTREE_OWNER_MARKER);
7810
+ const payload = {
7811
+ engine: true,
7812
+ createdAt: new Date().toISOString(),
7813
+ pid: process.pid,
7814
+ ...info,
7815
+ };
7816
+ fs.writeFileSync(target, JSON.stringify(payload, null, 2) + '\n');
7817
+ return true;
7818
+ } catch (e) {
7819
+ log('debug', `writeWorktreeOwnerMarker: could not stamp ${worktreePath}: ${e.message}`);
7820
+ return false;
7821
+ }
7822
+ }
7823
+
7824
+ // True only when the worktree carries the engine ownership marker. Fail-closed
7825
+ // (returns false) on any read error so an unreadable dir is treated as foreign
7826
+ // and kept, never blindly removed.
7827
+ function hasWorktreeOwnerMarker(worktreePath) {
7828
+ if (!worktreePath) return false;
7829
+ try {
7830
+ return fs.existsSync(path.join(path.resolve(worktreePath), WORKTREE_OWNER_MARKER));
7831
+ } catch {
7832
+ return false;
7833
+ }
7834
+ }
7835
+
7793
7836
  function slugify(text, maxLen = 50) {
7794
7837
  return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, maxLen);
7795
7838
  }
@@ -8195,6 +8238,9 @@ module.exports = {
8195
8238
  listProcessReachable,
8196
8239
  removeWorktree,
8197
8240
  isWorktreePathLive,
8241
+ WORKTREE_OWNER_MARKER,
8242
+ writeWorktreeOwnerMarker,
8243
+ hasWorktreeOwnerMarker,
8198
8244
  _normalizeWorktreePath, // exported for testing
8199
8245
  _writeWorktreeSkipLiveInboxNote, // exported for testing
8200
8246
  _retryFsOp, // exported for testing (W-mq5o6bvy000x7191)
@@ -771,6 +771,14 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
771
771
  const _parseWorktreePorcelain = typeof opts.parseWorktreePorcelain === 'function'
772
772
  ? opts.parseWorktreePorcelain
773
773
  : shared.parseWorktreePorcelain;
774
+ // W-mqecdoot — positive ownership gate. An out-of-root worktree is only
775
+ // engine-managed if it carries the ownership marker we stamp at creation
776
+ // (shared.writeWorktreeOwnerMarker). Worktrees a human created by hand
777
+ // (`git worktree add /tmp/foo …`) have no marker and must be left alone —
778
+ // this sweep was deleting them mid-edit.
779
+ const _hasOwnerMarker = typeof opts.hasOwnerMarker === 'function'
780
+ ? opts.hasOwnerMarker
781
+ : shared.hasWorktreeOwnerMarker;
774
782
 
775
783
  // Branches of active/pending dispatches (with and without `refs/heads/`
776
784
  // normalization). Matched case-insensitively to mirror git's behavior on
@@ -875,6 +883,21 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
875
883
  if (anchored) { projStats.kept++; result.kept++; continue; }
876
884
  }
877
885
 
886
+ // W-mqecdoot — ownership gate. Only ever evict a worktree the engine
887
+ // positively created (carries the ownership marker stamped at
888
+ // `git worktree add` time). No marker → it's a human's hand-made worktree
889
+ // (or a pre-marker legacy dir); KEEP it and do NOT escalate. The
890
+ // `git worktree prune --expire=now` below still drops registry entries
891
+ // whose dirs are already gone, which is safe regardless of ownership.
892
+ let owned = false;
893
+ try { owned = !!_hasOwnerMarker(wtAbs); }
894
+ catch (_e) { owned = false; }
895
+ if (!owned) {
896
+ projStats.kept++; result.kept++;
897
+ log('debug', `worktree-gc: keeping out-of-root ${wtAbs} — no engine ownership marker (foreign/hand-made worktree)`);
898
+ continue;
899
+ }
900
+
878
901
  // Slow-cadence gate for already-escalated stuck paths
879
902
  const slowRetryMs = (opts.config?.engine?.worktreeStuckSlowRetryMs)
880
903
  ?? shared.ENGINE_DEFAULTS.worktreeStuckSlowRetryMs
package/engine.js CHANGED
@@ -916,6 +916,10 @@ async function runWorktreeAdd(rootDir, worktreePath, addArgs, gitOpts, worktreeC
916
916
  log('warn', `Retrying git worktree add (attempt ${attempt + 1}/${retries + 1}) for ${path.basename(worktreePath)}`);
917
917
  }
918
918
  await shared.shellSafeGit(['worktree', 'add', worktreePath, ...addArgs], { ...gitOpts, cwd: rootDir });
919
+ // W-mqecdoot — stamp the engine ownership marker so the out-of-root
920
+ // worktree GC can distinguish a worktree we created from one a human
921
+ // developer made by hand. Best-effort; never blocks the spawn.
922
+ shared.writeWorktreeOwnerMarker(worktreePath, { rootDir });
919
923
  return;
920
924
  } catch (err) {
921
925
  lastErr = err;
@@ -3302,6 +3306,14 @@ async function spawnAgent(dispatchItem, config) {
3302
3306
  // them unconditionally regardless of repo host.
3303
3307
  childEnv.GIT_TERMINAL_PROMPT = '0';
3304
3308
  childEnv.GCM_INTERACTIVE = 'never';
3309
+ // W-mqef-dashboard-tty — agents run headless and must never pop a browser on
3310
+ // the operator's desktop. Stamp the suppression env so anything an agent runs
3311
+ // that funnels through shared.openUrlInBrowser — notably the dashboard a
3312
+ // `npm test` run boots — inherits the guard. The engine's own process env did
3313
+ // NOT carry this flag, so agent-spawned dashboards were self-opening to a
3314
+ // random localhost port (blank window). Belt-and-suspenders with dashboard.js's
3315
+ // stdout.isTTY gate.
3316
+ childEnv.MINIONS_NO_AUTO_OPEN = '1';
3305
3317
 
3306
3318
  if (getRepoHost(project) === 'ado') {
3307
3319
  // Inject cached ADO token so ADO agents skip re-authentication (#998).
@@ -3752,6 +3764,9 @@ async function spawnAgent(dispatchItem, config) {
3752
3764
  // credential dialogs on `git push` against stale PATs.
3753
3765
  childEnv.GIT_TERMINAL_PROMPT = '0';
3754
3766
  childEnv.GCM_INTERACTIVE = 'never';
3767
+ // W-mqef-dashboard-tty — same browser-popup suppression on steering resume
3768
+ // (see the initial spawn site). Agents must never auto-open a browser.
3769
+ childEnv.MINIONS_NO_AUTO_OPEN = '1';
3755
3770
  if (getRepoHost(project) === 'ado') {
3756
3771
  // Inject cached ADO token for steering session too (#998)
3757
3772
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2187",
3
+ "version": "0.1.2189",
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"