@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 +88 -27
- package/engine/watches.js +72 -1
- package/package.json +1 -1
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
|
-
|
|
7196
|
+
let archiveWarnings = [];
|
|
7121
7197
|
if (isPrd) {
|
|
7122
|
-
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7128
|
-
|
|
7129
|
-
|
|
7130
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|