@yemi33/minions 0.1.2187 → 0.1.2188
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 +76 -16
- package/docs/worktree-lifecycle.md +18 -0
- package/engine/shared.js +46 -0
- package/engine/worktree-gc.js +23 -0
- package/engine.js +4 -0
- package/package.json +1 -1
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
|
-
|
|
8135
|
-
|
|
8136
|
-
|
|
8137
|
-
|
|
8138
|
-
|
|
8139
|
-
|
|
8140
|
-
|
|
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
|
-
|
|
8143
|
-
//
|
|
8144
|
-
//
|
|
8145
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -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)
|
package/engine/worktree-gc.js
CHANGED
|
@@ -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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2188",
|
|
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"
|