@yemi33/minions 0.1.2102 → 0.1.2104

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.
@@ -434,6 +434,8 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
434
434
  FAILURE_CLASS.PERMISSION_BLOCKED,
435
435
  FAILURE_CLASS.AUTH, // W-mpcuc8i80003a7b3 — git/network credential failure; mechanical retry won't fix missing az / GCM creds
436
436
  FAILURE_CLASS.WORKTREE_PREFLIGHT, // pre-spawn worktree validation — recompute will produce the same failure
437
+ FAILURE_CLASS.WORKTREE_DIRTY, // #2996: reused worktree was dirty and could not be auto-healed — non-retryable for this dispatch attempt; the engine quarantined the worktree so the next discovery cycle creates a fresh one
438
+ FAILURE_CLASS.WORKTREE_DIVERGENT, // #2996: reused worktree's local branch had unpushed commits — engine quarantined + backed up the local ref; non-retryable for this dispatch (next discovery creates fresh worktree on origin/<branch>)
437
439
  FAILURE_CLASS.INVALID_KEEP_PROCESSES_WORKDIR, // W-mp6k7ywi000fa33c — keep-pids cwd is not a real git worktree; re-running won't fix the structural issue
438
440
  FAILURE_CLASS.INVALID_KEEP_PROCESSES_SCHEMA, // W-mp7i902u000l991f — keep-pids.json failed shape validation; re-running with the same wrong file won't fix it
439
441
  FAILURE_CLASS.INVALID_MANAGED_SPAWN, // W-mpbhxg3b000u8411 — managed-spawn.json failed validation; re-running with the same wrong file won't fix it
@@ -797,6 +799,8 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
797
799
  [FAILURE_CLASS.PERMISSION_BLOCKED]: 'permission or auth failure',
798
800
  [FAILURE_CLASS.AUTH]: 'ADO/git authentication failed (missing or expired credentials)',
799
801
  [FAILURE_CLASS.WORKTREE_PREFLIGHT]: 'worktree preflight rejected (nested in project root or rootDir collapsed to drive root)',
802
+ [FAILURE_CLASS.WORKTREE_DIRTY]: 'reused worktree had uncommitted edits and could not be auto-healed (#2996) — engine quarantined the dir so the next dispatch creates a fresh worktree',
803
+ [FAILURE_CLASS.WORKTREE_DIVERGENT]: 'reused worktree had unpushed local commits ahead of origin (#2996) — engine backed up the local ref and quarantined the dir so the next dispatch starts from origin/<branch>',
800
804
  [FAILURE_CLASS.INVALID_KEEP_PROCESSES_WORKDIR]: 'keep_processes cwd is not a real git worktree (rerun in a `git worktree add` directory)',
801
805
  [FAILURE_CLASS.INVALID_KEEP_PROCESSES_SCHEMA]: 'keep-pids.json failed shape validation (wrong keys/types/values — see inbox alert for the canonical shape)',
802
806
  [FAILURE_CLASS.INVALID_MANAGED_SPAWN]: 'managed-spawn.json failed validation (bad schema, workdir, or allowlist — see inbox alert)',
package/engine/github.js CHANGED
@@ -1127,7 +1127,14 @@ async function pollPrHumanComments(config) {
1127
1127
  author: c.user?.login || 'Human',
1128
1128
  content: c.body || '',
1129
1129
  date,
1130
- _isAgent: false
1130
+ _isAgent: false,
1131
+ // Issue #2994: namespace GH comment ids by API source ("issue" from
1132
+ // `/issues/N/comments` vs "review" from `/pulls/N/comments`). Each
1133
+ // endpoint maintains its own id space, so two comments — one issue,
1134
+ // one review — can collide on the same numeric id. Mirror the ADO
1135
+ // poller's thread-aware key shape so the engine.js human-feedback
1136
+ // same-head guard can dedup via a single comparable key.
1137
+ _type: c._type,
1131
1138
  };
1132
1139
  allCommentEntries.push(entry);
1133
1140
 
@@ -1186,7 +1193,13 @@ async function pollPrHumanComments(config) {
1186
1193
  pr.humanFeedback = {
1187
1194
  lastProcessedCommentDate: latestDate,
1188
1195
  lastProcessedCommentId: String(newComments[newComments.length - 1].commentId),
1189
- lastProcessedCommentKey: String(newComments[newComments.length - 1].commentId),
1196
+ // Issue #2994: namespaced key — `${type}:${commentId}` — for the
1197
+ // engine.js human-feedback same-head guard. Mirrors ADO's
1198
+ // `${threadId}:${commentId}` shape so the guard compares a single key
1199
+ // form. Falls back to the bare id when `_type` is absent (defensive;
1200
+ // every entry above sets it, but the pollPrHumanComments path in
1201
+ // engine/ado.js writes the legacy format too).
1202
+ lastProcessedCommentKey: `${newComments[newComments.length - 1]._type || ''}:${newComments[newComments.length - 1].commentId}`,
1190
1203
  pendingFix: true,
1191
1204
  feedbackContent,
1192
1205
  editsSeen,
@@ -2004,8 +2004,39 @@ function recordPrNoOpFixAttempt(target, cause, source, dispatchItem, branchChang
2004
2004
  // noops so the symmetric same-head guard at engine.js:~2847 can short-circuit
2005
2005
  // when both the head SHA AND the lastProcessedCommentId match the recorded
2006
2006
  // dispatch. Other causes have no comment-id concept, so the field is omitted.
2007
+ //
2008
+ // Issue #2994: also capture `lastProcessedCommentKey` (the thread-aware
2009
+ // form on ADO: `${threadId}:${commentId}`, type-namespaced on GH:
2010
+ // `${type}:${commentId}`). ADO comment ids are per-thread (not globally
2011
+ // unique), so a bare-id compare suppressed legitimate dispatches when two
2012
+ // different discussions both began at comment id:1 on the same head SHA
2013
+ // (live repro: office-bohemia/pullrequest/5284819, discussion 66508411
2014
+ // suppressed because discussion 66487840 noop'd at comment id:1 on the
2015
+ // same head a425ab05). PREFER the dispatch snapshot
2016
+ // (`dispatchItem.meta.pr.humanFeedback`) over live `target.humanFeedback`:
2017
+ // the poller can advance live state to a NEWER comment between dispatch
2018
+ // and lifecycle, and we MUST persist the comment that actually triggered
2019
+ // this dispatch — not the next one queued behind it. Fall back to live
2020
+ // target when the dispatch builder didn't snapshot humanFeedback (older
2021
+ // callers, manual re-dispatch paths).
2007
2022
  ...(cause === shared.PR_FIX_CAUSE.HUMAN_FEEDBACK
2008
- ? { lastProcessedCommentId: String(target.humanFeedback?.lastProcessedCommentId || '') }
2023
+ ? (() => {
2024
+ const dispatchedFeedback = dispatchItem?.meta?.pr?.humanFeedback;
2025
+ const liveFeedback = target.humanFeedback;
2026
+ const commentId = String(
2027
+ (dispatchedFeedback && dispatchedFeedback.lastProcessedCommentId)
2028
+ || (liveFeedback && liveFeedback.lastProcessedCommentId)
2029
+ || ''
2030
+ );
2031
+ const commentKey = String(
2032
+ (dispatchedFeedback && dispatchedFeedback.lastProcessedCommentKey)
2033
+ || (liveFeedback && liveFeedback.lastProcessedCommentKey)
2034
+ || ''
2035
+ );
2036
+ const out = { lastProcessedCommentId: commentId };
2037
+ if (commentKey) out.lastProcessedCommentKey = commentKey;
2038
+ return out;
2039
+ })()
2009
2040
  : {}),
2010
2041
  };
2011
2042
  target.lastDispatchedAt = now;
@@ -2361,10 +2392,24 @@ function updatePrAfterFixError(pr, project, source, options = {}) {
2361
2392
  // engine.js human-feedback guard's commentId match still works once
2362
2393
  // indeterminate flips back off (which it doesn't here, but state shape
2363
2394
  // stays consistent across paths).
2395
+ //
2396
+ // Issue #2994: also carry/refresh `lastProcessedCommentKey` (thread-aware
2397
+ // form) so the same-head guard can compare the per-thread key rather than
2398
+ // the per-thread-collidable bare id. Preference order matches the noop
2399
+ // path: prior record's key → dispatch snapshot → live target.
2364
2400
  if (cause === shared.PR_FIX_CAUSE.HUMAN_FEEDBACK) {
2401
+ const dispatchedFeedback = dispatchItem?.meta?.pr?.humanFeedback;
2402
+ const liveFeedback = target.humanFeedback;
2365
2403
  const commentId = prior?.lastProcessedCommentId
2366
- || String(target.humanFeedback?.lastProcessedCommentId || '');
2404
+ || String((dispatchedFeedback && dispatchedFeedback.lastProcessedCommentId)
2405
+ || (liveFeedback && liveFeedback.lastProcessedCommentId)
2406
+ || '');
2367
2407
  if (commentId) next.lastProcessedCommentId = commentId;
2408
+ const commentKey = prior?.lastProcessedCommentKey
2409
+ || String((dispatchedFeedback && dispatchedFeedback.lastProcessedCommentKey)
2410
+ || (liveFeedback && liveFeedback.lastProcessedCommentKey)
2411
+ || '');
2412
+ if (commentKey) next.lastProcessedCommentKey = commentKey;
2368
2413
  }
2369
2414
  target._lastDispatchByCause[cause] = next;
2370
2415
  result = { cause, indeterminate: true, errorClass };
package/engine/shared.js CHANGED
@@ -3079,6 +3079,8 @@ const FAILURE_CLASS = {
3079
3079
  MAX_TURNS: 'max-turns', // Claude CLI error_max_turns — work in progress, retryable
3080
3080
  COMPLETION_NONCE_MISMATCH: 'completion-nonce-mismatch', // P-d2a8f6c1: completion JSON nonce did not match the per-spawn value injected via MINIONS_COMPLETION_NONCE — treat as forged/untrusted; ignore PR/noop/status fields from the report
3081
3081
  WORKTREE_PREFLIGHT: 'worktree-preflight', // Pre-spawn worktree validation rejected (nested-in-project, drive-root collapse) — never retryable
3082
+ WORKTREE_DIRTY: 'worktree-dirty', // #2996: reused worktree had uncommitted edits and the engine could not auto-heal (or already quarantined). Non-retryable for this dispatch — next discovery cycle creates a fresh worktree.
3083
+ WORKTREE_DIVERGENT: 'worktree-divergent', // #2996: reused worktree's local branch was N commits ahead of origin (unsafe to reset, may contain unpushed agent work). Engine quarantined the worktree + backed up the local ref; non-retryable for this dispatch.
3082
3084
  INVALID_KEEP_PROCESSES_WORKDIR: 'invalid-keep-processes-workdir', // W-mp6k7ywi000fa33c: keep-pids.json declared a cwd that is not a real git worktree (likely a selective copy of the repo) — never retryable; agent must rerun in a real worktree
3083
3085
  INVALID_KEEP_PROCESSES_SCHEMA: 'invalid-keep-processes-schema', // W-mp7i902u000l991f: keep-pids.json failed validation for a reason other than workdir (pids-missing, ttl-too-long, expires_at-missing, pids-too-many, port-invalid, etc.) — agent wrote the wrong shape; never retryable until they fix the file
3084
3086
  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.
package/engine.js CHANGED
@@ -846,26 +846,49 @@ async function pruneStaleWorktreeForBranch(rootDir, branchName, gitOpts) {
846
846
  }
847
847
 
848
848
  // ─── assertCleanSharedWorktree (#2439) ──────────────────────────────────────
849
- // Engine-side preflight that prevents shared-branch dispatches from spawning
850
- // into a dirty worktree. Without this, the shared-branch playbook's "git status
851
- // + bail out" guard fires AFTER dispatch converting an orchestration hygiene
852
- // problem into a fake implementation failure that also cascades to dependent
853
- // work items.
849
+ // Engine-side preflight that prevents shared-branch (and PR-targeted reused —
850
+ // see opts.quarantineOnUnsafe, issue #2996) dispatches from spawning into a
851
+ // dirty worktree. Without this, the playbook's "git status + bail out" guard
852
+ // fires AFTER dispatch converting an orchestration hygiene problem into a
853
+ // fake implementation failure that also cascades to dependent work items, and
854
+ // (for PR-targeted reused worktrees) keeps redispatching agents into the same
855
+ // contaminated state.
854
856
  //
855
857
  // Behavior:
856
- // 1. Run `git status --porcelain` in the target worktree.
857
- // 2. If clean { clean: true, healed: false }.
858
- // 3. If dirty, decide whether self-heal is safe:
858
+ // 1. Run `git status --porcelain` + `git rev-list --left-right --count
859
+ // refs/remotes/origin/<branch>...HEAD` in the target worktree.
860
+ // 2. If clean AND not divergent { clean: true, healed: false }.
861
+ // (Pure shared-branch callers pass quarantineOnUnsafe=false and DO NOT
862
+ // treat ahead-of-origin as dirty — that path's existing semantics are
863
+ // preserved. PR-targeted reused worktrees pass quarantineOnUnsafe=true
864
+ // and DO treat ahead-of-origin as unsafe because the underlying bug
865
+ // (#2996) was a 157-commit-ahead local branch.)
866
+ // 3. If unsafe, decide:
859
867
  // - Block (preserve dirty tree for inspection) when ANOTHER active
860
- // dispatch claims the same branch (ownership ambiguity).
868
+ // dispatch claims the same branch (ownership ambiguity — NEVER
869
+ // quarantine, even when quarantineOnUnsafe=true).
861
870
  // - Block when local commits exist that aren't on origin/<branch>
862
- // (would lose unpushed work).
863
- // - Otherwise reset --hard + clean -fd, then re-verify.
864
- // 4. Return { clean, healed, reason, dirtyFiles } so the caller can fail
865
- // fast with a first-class DIRTY_WORKTREE reason and retry semantics.
866
- async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, dispatchId, gitOpts = {}) {
867
- const result = { clean: false, healed: false, reason: null, dirtyFiles: [] };
868
- // 1. Status probe
871
+ // (would lose unpushed work). When quarantineOnUnsafe=true,
872
+ // QUARANTINE instead: rename worktree to <path>-quarantine-<ts>,
873
+ // prune git's stale metadata, back up the local branch HEAD to
874
+ // refs/minions/quarantine/<sanitized>/<ts>, reset
875
+ // refs/heads/<branch> origin/<branch>, and write a notes/inbox/
876
+ // engine-worktree-quarantine note with the diagnostics so a human
877
+ // can recover any unpushed work.
878
+ // - Otherwise (filesystem-only dirty) reset --hard + clean -fd, then
879
+ // re-verify.
880
+ // 4. Return { clean, healed, reason, dirtyFiles, ahead, behind,
881
+ // quarantined, quarantinedPath, backupRef } so the caller can fail
882
+ // fast with a first-class DIRTY_WORKTREE / WORKTREE_DIRTY /
883
+ // WORKTREE_DIVERGENT reason and retry semantics.
884
+ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, dispatchId, gitOpts = {}, opts = {}) {
885
+ const quarantineOnUnsafe = !!opts.quarantineOnUnsafe;
886
+ const result = {
887
+ clean: false, healed: false, reason: null, dirtyFiles: [],
888
+ ahead: 0, behind: 0,
889
+ quarantined: false, quarantinedPath: null, backupRef: null,
890
+ };
891
+ // 1. Status probe (filesystem)
869
892
  let statusOut = '';
870
893
  try {
871
894
  const r = await execAsync('git status --porcelain', { ...gitOpts, cwd: worktreePath, timeout: 10000 });
@@ -875,13 +898,43 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
875
898
  result.error = e.message;
876
899
  return result;
877
900
  }
878
- if (!statusOut) {
901
+ if (statusOut) {
902
+ result.dirtyFiles = statusOut.split('\n').map(l => l.trim()).filter(Boolean);
903
+ }
904
+ const filesystemDirty = result.dirtyFiles.length > 0;
905
+
906
+ // 2. Ahead/behind vs refs/remotes/origin/<branch>. Computed BEFORE the
907
+ // early clean return so a divergent-but-clean worktree (no uncommitted
908
+ // edits, but local branch is N commits ahead of origin) is caught when
909
+ // the caller opted into quarantineOnUnsafe (issue #2996).
910
+ let upstreamKnown = false;
911
+ try {
912
+ const r = await execAsync(
913
+ `git rev-list --left-right --count refs/remotes/origin/${branchName}...HEAD`,
914
+ { ...gitOpts, cwd: worktreePath, timeout: 10000 },
915
+ );
916
+ const parts = String(r || '').trim().split(/\s+/);
917
+ const behind = parseInt(parts[0], 10);
918
+ const ahead = parseInt(parts[1], 10);
919
+ if (Number.isFinite(ahead) && Number.isFinite(behind)) {
920
+ result.ahead = ahead;
921
+ result.behind = behind;
922
+ upstreamKnown = true;
923
+ }
924
+ } catch (e) {
925
+ // origin/<branch> may not exist yet (never pushed). Mark unknown.
926
+ upstreamKnown = false;
927
+ }
928
+
929
+ const considerUnsafe = filesystemDirty || (quarantineOnUnsafe && (result.ahead > 0 || !upstreamKnown));
930
+ if (!considerUnsafe) {
879
931
  result.clean = true;
880
932
  return result;
881
933
  }
882
- result.dirtyFiles = statusOut.split('\n').map(l => l.trim()).filter(Boolean);
883
934
 
884
- // 2. Ownership check — another live dispatch on the same branch?
935
+ // 3. Ownership check — another live dispatch on the same branch?
936
+ // NEVER quarantine in this case — the other dispatch may be using the
937
+ // worktree right now. Always preserve.
885
938
  const sanitized = sanitizeBranch(branchName);
886
939
  let otherActive = false;
887
940
  try {
@@ -902,24 +955,38 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
902
955
  return result;
903
956
  }
904
957
 
905
- // 3. Unpushed-commit check — refuse to clean when local work would be lost.
906
- // `git log @{u}..HEAD --oneline` returns non-empty when there are commits
907
- // ahead of the upstream. If there is no upstream at all, treat as unsafe
908
- // to clean (we can't prove the local commits exist on origin).
909
- let hasUnpushed = false;
910
- try {
911
- const r = await execAsync('git log @{u}..HEAD --oneline', { ...gitOpts, cwd: worktreePath, timeout: 10000 });
912
- hasUnpushed = (r || '').toString().trim().length > 0;
913
- } catch (e) {
914
- // No upstream configured (e.g., branch never pushed) — be conservative.
915
- hasUnpushed = true;
916
- }
958
+ // 4. Unpushed-commit check — refuse to reset when local work would be lost.
959
+ // Use the ahead count we already computed (cheaper + correct vs.
960
+ // `git log @{u}..HEAD` which needs an upstream config and silently
961
+ // returns empty when missing).
962
+ const hasUnpushed = upstreamKnown ? result.ahead > 0 : true;
917
963
  if (hasUnpushed) {
918
- result.reason = 'has-unpushed-commits';
964
+ result.reason = upstreamKnown ? 'has-unpushed-commits' : 'no-upstream';
965
+ if (quarantineOnUnsafe) {
966
+ try {
967
+ const q = await _quarantineDirtyWorktree(
968
+ rootDir, worktreePath, branchName, gitOpts,
969
+ {
970
+ dispatchId,
971
+ reason: result.reason,
972
+ ahead: result.ahead,
973
+ behind: result.behind,
974
+ dirtyFiles: result.dirtyFiles,
975
+ },
976
+ );
977
+ result.quarantined = true;
978
+ result.quarantinedPath = q.quarantinedPath;
979
+ result.backupRef = q.backupRef;
980
+ } catch (qErr) {
981
+ result.quarantineError = qErr.message;
982
+ log('error', `assertCleanSharedWorktree: quarantine failed for ${worktreePath}: ${qErr.message}`);
983
+ }
984
+ }
919
985
  return result;
920
986
  }
921
987
 
922
- // 4. Safe to self-heal: reset + clean
988
+ // 5. Safe to self-heal (filesystem-dirty only, no unpushed commits, no
989
+ // other active dispatch): reset + clean.
923
990
  try {
924
991
  await execAsync('git reset --hard HEAD', { ...gitOpts, cwd: worktreePath, timeout: 30000 });
925
992
  await execAsync('git clean -fd', { ...gitOpts, cwd: worktreePath, timeout: 30000 });
@@ -929,7 +996,7 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
929
996
  return result;
930
997
  }
931
998
 
932
- // 5. Re-verify
999
+ // 6. Re-verify
933
1000
  try {
934
1001
  const r2 = await execAsync('git status --porcelain', { ...gitOpts, cwd: worktreePath, timeout: 10000 });
935
1002
  const after = (r2 || '').toString().trim();
@@ -944,12 +1011,122 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
944
1011
  return result;
945
1012
  }
946
1013
 
947
- log('info', `assertCleanSharedWorktree: auto-healed dirty shared-branch worktree ${worktreePath} on ${branchName} (${result.dirtyFiles.length} dirty files reset)`);
1014
+ log('info', `assertCleanSharedWorktree: auto-healed dirty worktree ${worktreePath} on ${branchName} (${result.dirtyFiles.length} dirty files reset)`);
948
1015
  result.clean = true;
949
1016
  result.healed = true;
950
1017
  return result;
951
1018
  }
952
1019
 
1020
+ // Quarantine a dirty/divergent reused worktree (#2996). Renames the worktree
1021
+ // directory to <path>-quarantine-<ts>, runs `git worktree prune` so git no
1022
+ // longer treats the missing path as an active checkout, backs up the local
1023
+ // branch HEAD to refs/minions/quarantine/<sanitized>/<ts>, and resets
1024
+ // refs/heads/<branch> → refs/remotes/origin/<branch> so the next dispatch
1025
+ // gets a fresh worktree on the origin tip. Writes a notes/inbox/
1026
+ // engine-worktree-quarantine note naming the dirty files + ahead/behind
1027
+ // counts so a human can recover any unpushed work.
1028
+ //
1029
+ // Caller MUST have already confirmed no other active dispatch holds the
1030
+ // branch (assertCleanSharedWorktree returns 'other-dispatch-active' before
1031
+ // invoking this). Renaming an active worktree out from under a running
1032
+ // agent would be catastrophic.
1033
+ async function _quarantineDirtyWorktree(rootDir, worktreePath, branchName, gitOpts, diag = {}) {
1034
+ const ts = Date.now();
1035
+ const quarantinedPath = `${worktreePath}-quarantine-${ts}`;
1036
+
1037
+ // Capture HEAD sha BEFORE renaming so we can back up the local branch ref.
1038
+ let headSha = '';
1039
+ try {
1040
+ const r = await execAsync('git rev-parse HEAD', { ...gitOpts, cwd: worktreePath, timeout: 10000 });
1041
+ headSha = (r || '').toString().trim();
1042
+ } catch (e) {
1043
+ log('warn', `_quarantineDirtyWorktree: rev-parse HEAD failed for ${worktreePath}: ${e.message} — backup ref will be skipped`);
1044
+ }
1045
+
1046
+ // Rename the worktree dir. Once this succeeds the worktree is functionally
1047
+ // quarantined; subsequent failures only affect ref bookkeeping.
1048
+ fs.renameSync(worktreePath, quarantinedPath);
1049
+
1050
+ // Prune git's stale worktree metadata so the next `git worktree add` for
1051
+ // the same branch isn't blocked by "branch is already used by worktree".
1052
+ try {
1053
+ await shared.shellSafeGit(['worktree', 'prune'], { ...gitOpts, cwd: rootDir, timeout: 15000 });
1054
+ } catch (e) {
1055
+ log('warn', `_quarantineDirtyWorktree: worktree prune after rename: ${e.message}`);
1056
+ }
1057
+
1058
+ // Backup the local branch HEAD to refs/minions/quarantine/<sanitized>/<ts>
1059
+ // and reset refs/heads/<branch> → origin/<branch> so the next checkout
1060
+ // starts on a clean origin tip rather than recreating the divergent state.
1061
+ // Both ref ops are best-effort: if the local ref machinery is in a weird
1062
+ // state we still want the worktree quarantine to stick.
1063
+ const sanitizedRefSegment = sanitizeBranch(branchName).replace(/\//g, '-');
1064
+ const backupRef = `refs/minions/quarantine/${sanitizedRefSegment}/${ts}`;
1065
+ let backupRefCreated = false;
1066
+ if (headSha) {
1067
+ try {
1068
+ await shared.shellSafeGit(['update-ref', backupRef, headSha], { ...gitOpts, cwd: rootDir, timeout: 10000 });
1069
+ backupRefCreated = true;
1070
+ } catch (e) {
1071
+ log('warn', `_quarantineDirtyWorktree: backup-ref ${backupRef} failed: ${e.message}`);
1072
+ }
1073
+ }
1074
+ try {
1075
+ await shared.shellSafeGit(['fetch', 'origin', branchName], { ...gitOpts, cwd: rootDir, timeout: 30000 });
1076
+ } catch (e) {
1077
+ log('warn', `_quarantineDirtyWorktree: fetch origin/${branchName} after rename: ${e.message}`);
1078
+ }
1079
+ try {
1080
+ await shared.shellSafeGit(
1081
+ ['update-ref', `refs/heads/${branchName}`, `refs/remotes/origin/${branchName}`],
1082
+ { ...gitOpts, cwd: rootDir, timeout: 10000 },
1083
+ );
1084
+ log('info', `_quarantineDirtyWorktree: reset refs/heads/${branchName} → refs/remotes/origin/${branchName}`);
1085
+ } catch (e) {
1086
+ log('warn', `_quarantineDirtyWorktree: reset refs/heads/${branchName} → origin/${branchName} failed: ${e.message}`);
1087
+ }
1088
+
1089
+ // Notes/inbox diagnostic so a human (and future agents) can see why the
1090
+ // worktree was moved and how to recover any unpushed work.
1091
+ const dirtyFiles = Array.isArray(diag.dirtyFiles) ? diag.dirtyFiles : [];
1092
+ const dirtyPreview = dirtyFiles.slice(0, 50).map(f => `- ${f}`).join('\n');
1093
+ const dirtyTrailer = dirtyFiles.length > 50 ? `\n- ...and ${dirtyFiles.length - 50} more` : '';
1094
+ const inboxBody = [
1095
+ '# Engine quarantined a dirty reused worktree (#2996)',
1096
+ '',
1097
+ `- Dispatch: ${diag.dispatchId || '<unknown>'}`,
1098
+ `- Branch: ${branchName}`,
1099
+ `- Original worktree: ${worktreePath}`,
1100
+ `- Quarantined to: ${quarantinedPath}`,
1101
+ `- Reason: ${diag.reason || 'unsafe'}`,
1102
+ `- Local commits ahead of origin: ${diag.ahead || 0}`,
1103
+ `- Local commits behind origin: ${diag.behind || 0}`,
1104
+ `- Dirty files (${dirtyFiles.length}):`,
1105
+ dirtyPreview || '- (none reported)',
1106
+ dirtyTrailer,
1107
+ '',
1108
+ '## Recovery',
1109
+ '',
1110
+ backupRefCreated
1111
+ ? `The local branch HEAD was backed up to \`${backupRef}\` (${headSha.slice(0, 12)}) and \`refs/heads/${branchName}\` was reset to \`refs/remotes/origin/${branchName}\`.`
1112
+ : `Backup ref was NOT created (HEAD sha unavailable). The local branch ref was still reset to \`refs/remotes/origin/${branchName}\` if possible. Inspect the quarantined directory directly to recover unpushed work.`,
1113
+ '',
1114
+ `1. Inspect the quarantined diff: \`git -C "${rootDir}" diff refs/remotes/origin/${branchName}${backupRefCreated ? ` ${backupRef}` : ''} -- .\``,
1115
+ `2. Cherry-pick the commits you want from \`${backupRefCreated ? backupRef : `<inspect ${quarantinedPath}>`}\` onto a fresh ${branchName} worktree.`,
1116
+ `3. Inspect uncommitted edits inside the quarantined dir directly: \`${quarantinedPath}\`.`,
1117
+ `4. Delete the quarantined dir when done: \`Remove-Item -Recurse -Force "${quarantinedPath}"\` (Windows) or \`rm -rf "${quarantinedPath}"\` (POSIX).`,
1118
+ backupRefCreated ? `5. Delete the backup ref when done: \`git -C "${rootDir}" update-ref -d ${backupRef}\`.` : '',
1119
+ ].filter(Boolean).join('\n');
1120
+ try {
1121
+ shared.writeToInbox('engine', `worktree-quarantine-${diag.dispatchId || sanitizedRefSegment}`, inboxBody);
1122
+ } catch (e) {
1123
+ log('warn', `_quarantineDirtyWorktree: writeToInbox failed: ${e.message}`);
1124
+ }
1125
+
1126
+ log('warn', `Quarantined dirty worktree ${worktreePath} → ${quarantinedPath} (branch ${branchName}, ${diag.ahead || 0} ahead, ${diag.behind || 0} behind, ${dirtyFiles.length} dirty files)`);
1127
+ return { quarantinedPath, backupRef: backupRefCreated ? backupRef : null };
1128
+ }
1129
+
953
1130
  async function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts) {
954
1131
  if (!branchName) return false;
955
1132
  const existingWt = await findExistingWorktree(rootDir, branchName);
@@ -1214,6 +1391,11 @@ async function spawnAgent(dispatchItem, config) {
1214
1391
 
1215
1392
  if (branchName) {
1216
1393
  updateAgentStatus(id, AGENT_STATUS.WORKTREE_SETUP, `Setting up worktree for branch ${branchName}`);
1394
+ // W-mpwy3mp5 (#2996): track whether we ended up reusing an existing
1395
+ // worktree (rather than creating a fresh one) so the post-setup dirty/
1396
+ // divergent gate knows to quarantine when a contaminated reused tree
1397
+ // would otherwise loop fix-dispatch agents forever.
1398
+ let worktreeReused = false;
1217
1399
  const wtDirName = shared.buildWorktreeDirName({
1218
1400
  dispatchId: id,
1219
1401
  projectName: project.name || 'default',
@@ -1244,6 +1426,7 @@ async function spawnAgent(dispatchItem, config) {
1244
1426
  throw assertErr;
1245
1427
  }
1246
1428
  worktreePath = existingWt;
1429
+ worktreeReused = true;
1247
1430
  log('info', `Reusing existing worktree for ${branchName}: ${existingWt}`);
1248
1431
  // Probe origin first — locally-created branches that were never pushed
1249
1432
  // (orphan/timeout retry before first push) would otherwise emit a
@@ -1363,6 +1546,7 @@ async function spawnAgent(dispatchItem, config) {
1363
1546
  }
1364
1547
  log('info', `Shared branch ${branchName} already checked out at ${existingWtPath} — reusing`);
1365
1548
  worktreePath = existingWtPath;
1549
+ worktreeReused = true;
1366
1550
  } else {
1367
1551
  // Branch is registered but path is missing on disk (#2454).
1368
1552
  // git keeps the entry — sometimes locked: initializing — and
@@ -1437,6 +1621,7 @@ async function spawnAgent(dispatchItem, config) {
1437
1621
  }
1438
1622
  log('info', `Branch ${branchName} already checked out at ${existingWtPath} — reusing`);
1439
1623
  worktreePath = existingWtPath;
1624
+ worktreeReused = true;
1440
1625
  } else {
1441
1626
  const pruned = await pruneStaleWorktreeForBranch(rootDir, branchName, _gitOpts);
1442
1627
  if (pruned > 0) {
@@ -1524,6 +1709,7 @@ async function spawnAgent(dispatchItem, config) {
1524
1709
  }
1525
1710
  log('info', `Branch ${branchName} already checked out at ${existingWtPath} — reusing`);
1526
1711
  worktreePath = existingWtPath;
1712
+ worktreeReused = true;
1527
1713
  } else if (existingWtPath && !fs.existsSync(existingWtPath)) {
1528
1714
  log('warn', `Branch ${branchName} tracked in missing dir ${existingWtPath} — pruning and recreating`);
1529
1715
  try { await shared.shellSafeGit(['worktree', 'prune'], { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
@@ -1592,6 +1778,70 @@ async function spawnAgent(dispatchItem, config) {
1592
1778
  }
1593
1779
  }
1594
1780
 
1781
+ // PR-targeted reused-worktree preflight (W-mpwy3mp5, #2996): when the
1782
+ // engine ADOPTS an existing worktree for a non-shared-branch dispatch
1783
+ // (typically a fix/build-fix or review on a PR branch), the worktree may
1784
+ // be in any state — uncommitted edits from the previous agent, local
1785
+ // commits never pushed, or even a manually-created tree (e.g.
1786
+ // `copilot-pr<N>` next to the user's home dir). Without this gate, the
1787
+ // playbook's "git status + bail out" guard fires AFTER dispatch and we
1788
+ // burn an agent run on a no-op; worse, dispatch.js re-discovers the same
1789
+ // PR-targeted item and loops the same dirty tree until a human notices.
1790
+ //
1791
+ // assertCleanSharedWorktree({ quarantineOnUnsafe: true }) self-heals
1792
+ // filesystem-only dirt, but for has-unpushed-commits (the actual #2996
1793
+ // case) it QUARANTINES the worktree — rename → prune → reset
1794
+ // refs/heads/<branch> to origin/<branch> + back up the divergent HEAD
1795
+ // under refs/minions/quarantine/<branch>/<ts> — so the next dispatch
1796
+ // starts fresh. We mark the failure non-retryable (WORKTREE_DIVERGENT /
1797
+ // WORKTREE_DIRTY) which routes through dispatch.js's neverRetry set;
1798
+ // the 15-minute cooldown.js dedupe window then naturally gates the
1799
+ // fresh dispatch.
1800
+ //
1801
+ // Why no `!meta?.useExistingBranch` guard: discoverFromWorkItems sets
1802
+ // `useExistingBranch: true` for EVERY isPrTargeted WI (engine.js:5898),
1803
+ // which is the exact category named in issue #2996 — fix/review/test
1804
+ // dispatches against a PR branch. The previous revision of this gate
1805
+ // included `&& !meta?.useExistingBranch` which excluded the entire bug
1806
+ // class. The `branchStrategy !== 'shared-branch'` check above is
1807
+ // sufficient to protect the existing shared-branch retryable
1808
+ // DIRTY_WORKTREE block (shared-branch with featureBranch is the OTHER
1809
+ // useExistingBranch:true case, and it's already filtered here).
1810
+ if (
1811
+ worktreeReused
1812
+ && worktreePath
1813
+ && fs.existsSync(worktreePath)
1814
+ && branchName
1815
+ && meta?.branchStrategy !== 'shared-branch'
1816
+ ) {
1817
+ _phaseT.dirtyReusedCheckStart = Date.now();
1818
+ const cleanResult = await assertCleanSharedWorktree(
1819
+ rootDir, worktreePath, branchName, id, _gitOpts,
1820
+ { quarantineOnUnsafe: true },
1821
+ );
1822
+ _phaseT.dirtyReusedCheckEnd = Date.now();
1823
+ if (!cleanResult.clean) {
1824
+ const previewFiles = (cleanResult.dirtyFiles || []).slice(0, 5).join(', ');
1825
+ const isDivergent = (cleanResult.ahead || 0) > 0 || cleanResult.reason === 'no-upstream';
1826
+ const failureClassValue = isDivergent ? FAILURE_CLASS.WORKTREE_DIVERGENT : FAILURE_CLASS.WORKTREE_DIRTY;
1827
+ const failureClassName = isDivergent ? 'WORKTREE_DIVERGENT' : 'WORKTREE_DIRTY';
1828
+ const reasonMsg = cleanResult.quarantined
1829
+ ? `${failureClassName}: reused worktree at ${worktreePath} was dirty/divergent (${cleanResult.reason}; ${cleanResult.ahead || 0} ahead, ${cleanResult.behind || 0} behind, ${cleanResult.dirtyFiles?.length || 0} dirty file(s)${previewFiles ? ': ' + previewFiles : ''}) — quarantined to ${cleanResult.quarantinedPath}. Next dispatch will start fresh.`
1830
+ : `${failureClassName}: reused worktree at ${worktreePath} is dirty/divergent (${cleanResult.reason}; ${cleanResult.ahead || 0} ahead, ${cleanResult.behind || 0} behind, ${cleanResult.dirtyFiles?.length || 0} file(s)${previewFiles ? ': ' + previewFiles : ''}). Quarantine ${cleanResult.quarantineError ? 'errored: ' + cleanResult.quarantineError : 'was not attempted (' + cleanResult.reason + ').'}`;
1831
+ log('error', reasonMsg);
1832
+ _cleanupPromptFiles();
1833
+ completeDispatch(
1834
+ id,
1835
+ DISPATCH_RESULT.ERROR,
1836
+ reasonMsg.slice(0, 500),
1837
+ `Engine preflight refused to dispatch into a dirty/divergent reused worktree (#2996). Reason: ${cleanResult.reason}.${cleanResult.quarantined ? ` Worktree quarantined to ${cleanResult.quarantinedPath}; backup ref ${cleanResult.backupRef || '(skipped)'}. See notes/inbox/ for recovery instructions.` : ''}`,
1838
+ { agentRetryable: false, failureClass: failureClassValue },
1839
+ );
1840
+ cleanupTempAgent(agentId);
1841
+ return null;
1842
+ }
1843
+ }
1844
+
1595
1845
  // Merge dependency PR branches into worktree (applies to both reused and new worktrees)
1596
1846
  if (worktreePath && fs.existsSync(worktreePath)) {
1597
1847
  cwd = worktreePath;
@@ -4609,6 +4859,33 @@ async function discoverFromPrs(config, project) {
4609
4859
  const currentHeadSha = String(pr.headSha || pr._adoSourceCommit || pr._adoHeadCommit || '').trim();
4610
4860
  const lastHumanDispatch = pr._lastDispatchByCause?.[shared.PR_FIX_CAUSE.HUMAN_FEEDBACK];
4611
4861
  const currentCommentId = String(pr.humanFeedback?.lastProcessedCommentId || '');
4862
+ // Issue #2994: the bare commentId is per-thread on ADO (each discussion's
4863
+ // first reply is id=1), so two threads' opening comments collide on the
4864
+ // SAME head SHA and false-suppress legitimate new-thread dispatches.
4865
+ // Live repro: ADO PR `office-bohemia#5284819`, discussion 66487840
4866
+ // noop'd at comment id:1 on head a425ab05 → discussion 66508411 (a
4867
+ // NEW thread, also at comment id:1, same head) suppressed for a full
4868
+ // tick cycle until the operator inspected the dispatch log.
4869
+ //
4870
+ // Fix: compare the thread-aware key (`${threadId}:${commentId}` on ADO,
4871
+ // `${type}:${commentId}` on GH for issue-vs-review namespacing) when
4872
+ // BOTH sides have it; fall back to the legacy bare-id compare when
4873
+ // BOTH lack it (covers PRs whose last dispatch record predates this
4874
+ // fix). MIXED case = release: dispatch record predates the fix while
4875
+ // the current poller has the key → don't risk a cross-thread false
4876
+ // positive; let one extra dispatch through to refresh the record with
4877
+ // the key on its next noop.
4878
+ const currentCommentKey = String(pr.humanFeedback?.lastProcessedCommentKey || '');
4879
+ const recordedCommentId = String(lastHumanDispatch?.lastProcessedCommentId || '');
4880
+ const recordedCommentKey = String(lastHumanDispatch?.lastProcessedCommentKey || '');
4881
+ let commentMatch = false;
4882
+ if (currentCommentKey && recordedCommentKey) {
4883
+ commentMatch = currentCommentKey === recordedCommentKey;
4884
+ } else if (!currentCommentKey && !recordedCommentKey) {
4885
+ commentMatch = !!(currentCommentId && recordedCommentId && currentCommentId === recordedCommentId);
4886
+ }
4887
+ // Else: mixed (one side has the key, other doesn't) → release the
4888
+ // suppression so the next dispatch refreshes the record format.
4612
4889
  // Issue #2632: this same-head/same-comment guard MUST be cause-local. A
4613
4890
  // previous `continue` here aborted the whole PR iteration and starved
4614
4891
  // the build-failure / re-review / conflict-fix evaluation blocks below
@@ -4641,11 +4918,10 @@ async function discoverFromPrs(config, project) {
4641
4918
  && lastHumanDispatch.headSha
4642
4919
  && currentHeadSha
4643
4920
  && lastHumanDispatch.headSha === currentHeadSha
4644
- && lastHumanDispatch.lastProcessedCommentId
4645
- && currentCommentId
4646
- && lastHumanDispatch.lastProcessedCommentId === currentCommentId);
4921
+ && commentMatch);
4647
4922
  if (skipHumanFeedback) {
4648
- log('info', `Skipping human-feedback fix for ${pr.id}: last human-feedback dispatch was noop on the same head ${currentHeadSha.slice(0, 8)} and same comment ${currentCommentId.slice(0, 32)} (${(lastHumanDispatch.reason || '').slice(0, 120)})`);
4923
+ const commentLabel = currentCommentKey || currentCommentId;
4924
+ log('info', `Skipping human-feedback fix for ${pr.id}: last human-feedback dispatch was noop on the same head ${currentHeadSha.slice(0, 8)} and same comment ${commentLabel.slice(0, 48)} (${(lastHumanDispatch.reason || '').slice(0, 120)})`);
4649
4925
  }
4650
4926
  if (!skipHumanFeedback) {
4651
4927
  const key = humanFixKey;
@@ -7432,7 +7708,7 @@ module.exports = {
7432
7708
  areDependenciesMet, // exported for testing (P-bf04-decompose-zero-children)
7433
7709
  parseConflictFiles, pruneAncestorDeps, preflightMergeSimulation, // exported for testing
7434
7710
  buildDepConflictFixItem, deriveConflictFixKey, // exported for testing (W-mpcwojgr000a0244)
7435
- isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, assertCleanSharedWorktree, // exported for testing
7711
+ isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, assertCleanSharedWorktree, _quarantineDirtyWorktree, // exported for testing
7436
7712
  pruneStaleWorktreeForBranch, // exported for testing
7437
7713
  findExistingWorktree, // exported for testing
7438
7714
  probeBranchOnRemote, // exported for testing (W-mphnm6a1000281b8)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2102",
3
+ "version": "0.1.2104",
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"