@yemi33/minions 0.1.2173 → 0.1.2175

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
@@ -1105,6 +1105,82 @@ function resolvePlanPath(file) {
1105
1105
  return active;
1106
1106
  }
1107
1107
 
1108
+ // W-mqa13ulk0002def5 — PRD post-archive: flip status+archivedAt, neutralize
1109
+ // the .backup sidecar, and move the source plan markdown to plans/archive/.
1110
+ // Each concern runs in its own try/catch so a failure in one (e.g. lock
1111
+ // contention from the engine's plan-completion scan throwing in
1112
+ // mutateJsonFileLocked) does not silently swallow the others — the bug that
1113
+ // orphaned plans/killswitches-and-granular-controls.md on 2026-06-11 after its
1114
+ // PRD archived OK. Failures are pushed into archiveWarnings (returned to the
1115
+ // dashboard as `warnings: [...]`) so the user sees them instead of a silent
1116
+ // `archivedSource: null`. _mutate / _safeJsonObj injection points are test
1117
+ // seams; production callers omit them.
1118
+ function _archivePrdPostProcess({ planFile, archivePath, planPath, plansDir, _mutate, _safeJsonObj } = {}) {
1119
+ const mutate = typeof _mutate === 'function' ? _mutate : mutateJsonFileLocked;
1120
+ const readObj = typeof _safeJsonObj === 'function' ? _safeJsonObj : safeJsonObj;
1121
+ const archiveWarnings = [];
1122
+ let plan = {};
1123
+ let archivedSource = null;
1124
+
1125
+ // (a) Flip status + archivedAt.
1126
+ try {
1127
+ plan = mutate(archivePath, (data) => {
1128
+ if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
1129
+ data.status = 'archived';
1130
+ data.archivedAt = new Date().toISOString();
1131
+ return data;
1132
+ }, { defaultValue: {} }) || {};
1133
+ } catch (e) {
1134
+ const warning = `Archive status flip failed for ${planFile}: ${e.message}`;
1135
+ archiveWarnings.push(warning);
1136
+ console.warn(warning);
1137
+ }
1138
+
1139
+ // Re-read the PRD from disk if mutate threw or returned an empty object —
1140
+ // the file was already renamed into the archive dir, so the source_plan
1141
+ // field is still on disk and the markdown move below needs it.
1142
+ if (!plan.source_plan) {
1143
+ try {
1144
+ const disk = readObj(archivePath);
1145
+ if (disk && typeof disk === 'object' && disk.source_plan) plan = disk;
1146
+ } catch { /* readObj fallback is best-effort */ }
1147
+ }
1148
+
1149
+ // (b) Neutralize the .backup sidecar so safeJson auto-restore does not
1150
+ // resurrect the pre-completion snapshot on engine restart (regression of #f28162b0).
1151
+ try {
1152
+ const backupCleanup = shared.neutralizeJsonBackupSidecar(planPath);
1153
+ if (!backupCleanup.ok) {
1154
+ const warning = `Archive backup cleanup failed for ${planFile}: unlink failed (${backupCleanup.unlinkError}); fallback neutralize failed (${backupCleanup.writeError})`;
1155
+ archiveWarnings.push(warning);
1156
+ console.warn(warning);
1157
+ }
1158
+ } catch (e) {
1159
+ const warning = `Archive backup cleanup failed for ${planFile}: ${e.message}`;
1160
+ archiveWarnings.push(warning);
1161
+ console.warn(warning);
1162
+ }
1163
+
1164
+ // (c) Move the source plan markdown into plans/archive/.
1165
+ if (plan.source_plan) {
1166
+ try {
1167
+ const mdPath = path.join(plansDir, plan.source_plan);
1168
+ if (fs.existsSync(mdPath)) {
1169
+ const planArchive = path.join(plansDir, 'archive');
1170
+ if (!fs.existsSync(planArchive)) fs.mkdirSync(planArchive, { recursive: true });
1171
+ fs.renameSync(mdPath, path.join(planArchive, plan.source_plan));
1172
+ archivedSource = plan.source_plan;
1173
+ }
1174
+ } catch (e) {
1175
+ const warning = `Archive could not move source plan ${plan.source_plan}: ${e.message}`;
1176
+ archiveWarnings.push(warning);
1177
+ console.warn(warning);
1178
+ }
1179
+ }
1180
+
1181
+ return { archivedSource, archiveWarnings, plan };
1182
+ }
1183
+
1108
1184
  // Test-mode banner: surfaced in <title> + <h1> + body class when the dashboard
1109
1185
  // is started under MINIONS_TEST_DIR or on a non-default REQUESTED port. Makes
1110
1186
  // it visually obvious that the user is looking at a sandboxed instance, not
@@ -7117,34 +7193,17 @@ const server = http.createServer(async (req, res) => {
7117
7193
 
7118
7194
  let archivedSource = null;
7119
7195
  let plan = {};
7120
- const archiveWarnings = [];
7196
+ let archiveWarnings = [];
7121
7197
  if (isPrd) {
7122
- try {
7123
- plan = mutateJsonFileLocked(archivePath, (data) => {
7124
- if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
7125
- data.status = 'archived';
7126
- data.archivedAt = new Date().toISOString();
7127
- return data;
7128
- }, { defaultValue: {} }) || {};
7129
- // Without removing the .backup sidecar, safeJson would auto-restore the
7130
- // pre-completion snapshot on engine restart, re-triggering plan completion
7131
- // and spawning duplicate verify tasks (regression of #f28162b0).
7132
- const backupCleanup = shared.neutralizeJsonBackupSidecar(planPath);
7133
- if (!backupCleanup.ok) {
7134
- const warning = `Archive backup cleanup failed for ${body.file}: unlink failed (${backupCleanup.unlinkError}); fallback neutralize failed (${backupCleanup.writeError})`;
7135
- archiveWarnings.push(warning);
7136
- console.warn(warning);
7137
- }
7138
- if (plan.source_plan) {
7139
- const mdPath = path.join(PLANS_DIR, plan.source_plan);
7140
- if (fs.existsSync(mdPath)) {
7141
- const planArchive = path.join(PLANS_DIR, 'archive');
7142
- if (!fs.existsSync(planArchive)) fs.mkdirSync(planArchive, { recursive: true });
7143
- fs.renameSync(mdPath, path.join(planArchive, plan.source_plan));
7144
- archivedSource = plan.source_plan;
7145
- }
7146
- }
7147
- } catch { /* optional */ }
7198
+ const result = _archivePrdPostProcess({
7199
+ planFile: body.file,
7200
+ archivePath,
7201
+ planPath,
7202
+ plansDir: PLANS_DIR,
7203
+ });
7204
+ archivedSource = result.archivedSource;
7205
+ archiveWarnings = result.archiveWarnings;
7206
+ plan = result.plan;
7148
7207
  }
7149
7208
 
7150
7209
  // Cancel pending work items linked to this plan so the engine stops
@@ -12351,6 +12410,8 @@ module.exports = {
12351
12410
  _linkPullRequestForTracking: linkPullRequestForTracking,
12352
12411
  _updatePullRequestObserveFlag: updatePullRequestObserveFlag,
12353
12412
  _resolveSkillReadPath,
12413
+ // exported for testing — see test/unit/plans-archive-warnings.test.js
12414
+ _archivePrdPostProcess,
12354
12415
  // Per-CC-turn correlation surface
12355
12416
  _ccTurnCreations,
12356
12417
  _recordCcTurnCreation,
package/engine/watches.js CHANGED
@@ -150,7 +150,14 @@ function registerTargetType(type, spec) {
150
150
  throw new Error(`registerTargetType(${type}): absoluteConditions entry '${c}' is not in conditions[]`);
151
151
  }
152
152
  }
153
- TARGET_TYPES[type] = { ...spec, absoluteConditions: absoluteSet };
153
+ // W-mqa63opd000ha836 optional hook: returns true when the target has
154
+ // reached a state from which `condition` can never fire again (e.g. a
155
+ // PR's status is `merged` and we're watching `build-fail`). Default
156
+ // returns false (back-compat — current target types unchanged).
157
+ const terminalFn = typeof spec.isTerminalForCondition === 'function'
158
+ ? spec.isTerminalForCondition
159
+ : () => false;
160
+ TARGET_TYPES[type] = { ...spec, absoluteConditions: absoluteSet, isTerminalForCondition: terminalFn };
154
161
  }
155
162
 
156
163
  /** Returns the registered spec for a target type, or null. */
@@ -517,6 +524,40 @@ function checkWatches(config, state) {
517
524
  });
518
525
  }
519
526
 
527
+ // W-mqa63opd000ha836 — auto-expire watches whose target has reached a
528
+ // terminal state from which the watched condition can never fire again.
529
+ // Independent of the absolute-condition fire-once path above: covers the
530
+ // long-tail case where a `build-fail` (or similar) watch was armed on an
531
+ // active PR that subsequently merged without ever tripping its condition.
532
+ // Without this, the watch sits `active` forever, polling every interval.
533
+ //
534
+ // Guardrail: do NOT auto-expire until the watch has had a chance to fire
535
+ // on its first real check — `triggerCount > 0` covers post-fire watches;
536
+ // `prevState.status === entity.status` covers second-and-later checks
537
+ // (initial _captureState on tick-1 makes these equal, so a watch armed
538
+ // on an already-terminal target with `condition: merged` still fires
539
+ // once via the absolute-condition path before this branch expires it).
540
+ if (watch.status === WATCH_STATUS.ACTIVE) {
541
+ const _ttForTerm = TARGET_TYPES[watch.targetType];
542
+ if (_ttForTerm && typeof _ttForTerm.isTerminalForCondition === 'function') {
543
+ try {
544
+ const _entityForTerm = _ttForTerm.fetchEntity(watch.target, state || {});
545
+ if (_entityForTerm) {
546
+ const _hadShot = (watch.triggerCount || 0) > 0
547
+ || (previousState && previousState.status !== undefined
548
+ && previousState.status === _entityForTerm.status);
549
+ if (_hadShot && _ttForTerm.isTerminalForCondition(watch.condition, _entityForTerm, previousState)) {
550
+ watch.status = WATCH_STATUS.EXPIRED;
551
+ const _termStatus = _entityForTerm.status;
552
+ log('info', `Watch auto-expired (terminal target state): ${watch.id} — ${watch.targetType} ${watch.target} is ${_termStatus}, condition ${watch.condition} cannot fire again`);
553
+ }
554
+ }
555
+ } catch (termErr) {
556
+ log('warn', `Watch terminal-state check error (${watch.id}): ${termErr.message}`);
557
+ }
558
+ }
559
+ }
560
+
520
561
  // Capture state for change detection on next check
521
562
  watch._lastState = _captureState(watch, state);
522
563
 
@@ -704,6 +745,36 @@ registerTargetType(WATCH_TARGET_TYPE.PR, {
704
745
  WATCH_CONDITION.MERGED, WATCH_CONDITION.BUILD_FAIL, WATCH_CONDITION.BUILD_PASS,
705
746
  WATCH_CONDITION.READY_FOR_MERGE,
706
747
  ],
748
+ // W-mqa63opd000ha836 — zombie-watch janitor: once a PR reaches a terminal
749
+ // status (merged / closed / abandoned), most PR conditions can never fire
750
+ // again so the watch should auto-expire instead of polling forever.
751
+ //
752
+ // Excluded from terminal-expire:
753
+ // - merged — already handled by the absoluteConditions fire-once
754
+ // path; including it here is redundant and risks
755
+ // expiring before the watch's one shot to fire.
756
+ // - status-change — could theoretically still fire on a status flip
757
+ // (e.g. closed→reopened on GitHub), even though rare.
758
+ // - any — same rationale as status-change.
759
+ //
760
+ // Build-fail / build-pass / vote-change / new-comments / head-commit-change /
761
+ // mergeable-flipped / behind-master / ready-for-merge / draft-flipped all
762
+ // mutate fields that are frozen the moment the PR is merged/closed/abandoned,
763
+ // so a fresh fire is impossible.
764
+ isTerminalForCondition: (condition, pr) => {
765
+ if (!pr || !pr.status) return false;
766
+ const status = pr.status;
767
+ const isTerminal = status === shared.PR_STATUS.MERGED
768
+ || status === shared.PR_STATUS.CLOSED
769
+ || status === shared.PR_STATUS.ABANDONED;
770
+ if (!isTerminal) return false;
771
+ if (condition === WATCH_CONDITION.MERGED
772
+ || condition === WATCH_CONDITION.STATUS_CHANGE
773
+ || condition === WATCH_CONDITION.ANY) {
774
+ return false;
775
+ }
776
+ return true;
777
+ },
707
778
  fetchEntity: (target, state) => findPrByTarget(state.pullRequests, target),
708
779
  captureState: (pr) => ({
709
780
  status: pr.status, buildStatus: pr.buildStatus, reviewStatus: pr.reviewStatus,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2173",
3
+ "version": "0.1.2175",
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"