@yemi33/minions 0.1.2284 → 0.1.2286
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 +95 -60
- package/engine/lifecycle.js +7 -0
- package/engine/shared.js +6 -1
- package/engine.js +41 -5
- package/package.json +1 -1
package/dashboard.js
CHANGED
|
@@ -8022,77 +8022,103 @@ const server = http.createServer(async (req, res) => {
|
|
|
8022
8022
|
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
8023
8023
|
}
|
|
8024
8024
|
|
|
8025
|
+
// Shared by pause + reject (and any future PRD-stop handler): stop a PRD's
|
|
8026
|
+
// materialized work. Kills any active dispatch for the PRD's items, transitions
|
|
8027
|
+
// every non-completed WI sourced from the PRD to `targetStatus`, AND cancels the
|
|
8028
|
+
// still-running plan-to-prd regeneration WI for the PRD's source plan so it can't
|
|
8029
|
+
// silently rebuild the PRD we just paused/rejected. Returns the count of WIs
|
|
8030
|
+
// transitioned. Lockless reads → cleanDispatchEntries (atomic kill + remove) →
|
|
8031
|
+
// mutateWorkItems, mirroring the original pause flow. (RC3 — reject had no
|
|
8032
|
+
// cleanup; pause never stopped regeneration.)
|
|
8033
|
+
function stopPlanMaterializedWork(prdFile, prdSourcePlan, opts) {
|
|
8034
|
+
const targetStatus = opts.targetStatus;
|
|
8035
|
+
const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
|
|
8036
|
+
for (const proj of PROJECTS) wiPaths.push(shared.projectWorkItemsPath(proj));
|
|
8037
|
+
|
|
8038
|
+
// Step 1: find dispatched item ids (read-only, no lock).
|
|
8039
|
+
const dispatchedItemIds = new Set();
|
|
8040
|
+
for (const wiPath of wiPaths) {
|
|
8041
|
+
try {
|
|
8042
|
+
for (const w of safeJsonArr(wiPath)) {
|
|
8043
|
+
if (w.sourcePlan !== prdFile) continue;
|
|
8044
|
+
if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
|
|
8045
|
+
if (w.status === WI_STATUS.DISPATCHED && w.id) dispatchedItemIds.add(w.id);
|
|
8046
|
+
}
|
|
8047
|
+
} catch { /* file may not exist */ }
|
|
8048
|
+
}
|
|
8049
|
+
|
|
8050
|
+
// Step 2: kill active dispatches via the canonical primitive (resolves PIDs from
|
|
8051
|
+
// the pid sidecar, kills outside the dispatch lock, removes via mutateDispatch).
|
|
8052
|
+
if (dispatchedItemIds.size > 0) {
|
|
8053
|
+
cleanDispatchEntries((d) => {
|
|
8054
|
+
const itemId = d.meta?.item?.id;
|
|
8055
|
+
if (itemId && dispatchedItemIds.has(itemId)) return true;
|
|
8056
|
+
if (d.meta?.dispatchKey && [...dispatchedItemIds].some(id => d.meta.dispatchKey.includes(id))) return true;
|
|
8057
|
+
return false;
|
|
8058
|
+
});
|
|
8059
|
+
}
|
|
8060
|
+
|
|
8061
|
+
// Step 3: transition WIs per path (each lock held briefly, no nesting).
|
|
8062
|
+
let affected = 0;
|
|
8063
|
+
for (const wiPath of wiPaths) {
|
|
8064
|
+
try {
|
|
8065
|
+
mutateWorkItems(wiPath, items => {
|
|
8066
|
+
for (const w of items) {
|
|
8067
|
+
if (w.sourcePlan !== prdFile) continue;
|
|
8068
|
+
if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
|
|
8069
|
+
if (w.status !== targetStatus) affected++;
|
|
8070
|
+
w.status = targetStatus;
|
|
8071
|
+
if (opts.stampField) w[opts.stampField] = opts.stampValue;
|
|
8072
|
+
delete w._resumedAt;
|
|
8073
|
+
delete w.dispatched_at;
|
|
8074
|
+
delete w.dispatched_to;
|
|
8075
|
+
delete w.failReason;
|
|
8076
|
+
delete w.failedAt;
|
|
8077
|
+
}
|
|
8078
|
+
});
|
|
8079
|
+
} catch (e) { console.error('stopPlanMaterializedWork work items:', e.message); }
|
|
8080
|
+
}
|
|
8081
|
+
|
|
8082
|
+
// Step 4: cancel the still-running plan-to-prd regeneration WI for this source
|
|
8083
|
+
// plan — otherwise a pending/dispatched plan-to-prd run rebuilds the PRD we just
|
|
8084
|
+
// stopped. (delete handles the DONE plan-to-prd WI separately to revert to draft.)
|
|
8085
|
+
if (prdSourcePlan) {
|
|
8086
|
+
try {
|
|
8087
|
+
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
8088
|
+
mutateWorkItems(centralPath, items => {
|
|
8089
|
+
for (const w of items) {
|
|
8090
|
+
if (w.type === WORK_TYPE.PLAN_TO_PRD && w.planFile === prdSourcePlan &&
|
|
8091
|
+
!DONE_STATUSES.has(w.status) && w.status !== WI_STATUS.CANCELLED) {
|
|
8092
|
+
w.status = WI_STATUS.CANCELLED;
|
|
8093
|
+
w._cancelledBy = opts.stampValue || 'prd-stopped';
|
|
8094
|
+
}
|
|
8095
|
+
}
|
|
8096
|
+
});
|
|
8097
|
+
} catch (e) { console.error('stopPlanMaterializedWork plan-to-prd:', e.message); }
|
|
8098
|
+
}
|
|
8099
|
+
return affected;
|
|
8100
|
+
}
|
|
8101
|
+
|
|
8025
8102
|
async function handlePlansPause(req, res) {
|
|
8026
8103
|
try {
|
|
8027
8104
|
const body = await readBody(req);
|
|
8028
8105
|
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
8029
8106
|
if (!body.file.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename (got `' + body.file + '`). Pass prd/<plan>.json, not the source plans/<plan>.md.' });
|
|
8030
8107
|
const planPath = resolvePlanPath(body.file);
|
|
8031
|
-
|
|
8108
|
+
let prdSourcePlan = null;
|
|
8109
|
+
const updated = mutateJsonFileLocked(planPath, (plan) => {
|
|
8032
8110
|
if (!plan || Array.isArray(plan) || typeof plan !== 'object') plan = {};
|
|
8033
8111
|
plan.status = 'paused';
|
|
8034
8112
|
plan.pausedAt = new Date().toISOString();
|
|
8035
8113
|
return plan;
|
|
8036
8114
|
}, { defaultValue: {} });
|
|
8115
|
+
prdSourcePlan = updated?.source_plan || null;
|
|
8037
8116
|
|
|
8038
|
-
// Propagate pause to materialized work items across all projects
|
|
8039
|
-
//
|
|
8040
|
-
|
|
8041
|
-
|
|
8042
|
-
|
|
8043
|
-
wiPaths.push(shared.projectWorkItemsPath(proj));
|
|
8044
|
-
}
|
|
8045
|
-
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
8046
|
-
|
|
8047
|
-
// Step 1: Read work items (read-only, no lock) to find plan items that are dispatched.
|
|
8048
|
-
const dispatchedItemIds = new Set();
|
|
8049
|
-
for (const wiPath of wiPaths) {
|
|
8050
|
-
try {
|
|
8051
|
-
const items = safeJsonArr(wiPath);
|
|
8052
|
-
for (const w of items) {
|
|
8053
|
-
if (w.sourcePlan !== body.file) continue;
|
|
8054
|
-
if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
|
|
8055
|
-
if (w.status === WI_STATUS.DISPATCHED && w.id) dispatchedItemIds.add(w.id);
|
|
8056
|
-
}
|
|
8057
|
-
} catch { /* file may not exist */ }
|
|
8058
|
-
}
|
|
8059
|
-
|
|
8060
|
-
// Step 2: Route PID resolution + kill + dispatch removal through the canonical primitive.
|
|
8061
|
-
// cleanDispatchEntries resolves PIDs from engine/tmp/dispatch-<id>-*/pid-<id>.pid
|
|
8062
|
-
// (see shared.findDispatchPidFile), kills outside the dispatch lock, and removes the
|
|
8063
|
-
// entries via mutateDispatch (SQL + JSON mirror in one atomic write).
|
|
8064
|
-
// The defunct per-agent status sidecar reads/writes that used to live here are gone
|
|
8065
|
-
// (engine/queries.js documents that file no longer exists).
|
|
8066
|
-
if (dispatchedItemIds.size > 0) {
|
|
8067
|
-
const matchFn = (d) => {
|
|
8068
|
-
const itemId = d.meta?.item?.id;
|
|
8069
|
-
if (itemId && dispatchedItemIds.has(itemId)) return true;
|
|
8070
|
-
if (d.meta?.dispatchKey && [...dispatchedItemIds].some(id => d.meta.dispatchKey.includes(id))) return true;
|
|
8071
|
-
return false;
|
|
8072
|
-
};
|
|
8073
|
-
cleanDispatchEntries(matchFn);
|
|
8074
|
-
}
|
|
8075
|
-
|
|
8076
|
-
// Step 3: Mutate work-items.json per path — pause items (each lock held briefly, no nesting).
|
|
8077
|
-
let reset = 0;
|
|
8078
|
-
for (const wiPath of wiPaths) {
|
|
8079
|
-
try {
|
|
8080
|
-
mutateWorkItems(wiPath, items => {
|
|
8081
|
-
for (const w of items) {
|
|
8082
|
-
if (w.sourcePlan !== body.file) continue;
|
|
8083
|
-
if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
|
|
8084
|
-
if (w.status !== WI_STATUS.PAUSED) reset++;
|
|
8085
|
-
w.status = WI_STATUS.PAUSED;
|
|
8086
|
-
w._pausedBy = 'prd-pause';
|
|
8087
|
-
delete w._resumedAt;
|
|
8088
|
-
delete w.dispatched_at;
|
|
8089
|
-
delete w.dispatched_to;
|
|
8090
|
-
delete w.failReason;
|
|
8091
|
-
delete w.failedAt;
|
|
8092
|
-
}
|
|
8093
|
-
});
|
|
8094
|
-
} catch (e) { console.error('reset work items:', e.message); }
|
|
8095
|
-
}
|
|
8117
|
+
// Propagate pause to materialized work items across all projects + cancel the
|
|
8118
|
+
// plan-to-prd regeneration WI so a paused PRD can't be silently rebuilt.
|
|
8119
|
+
const reset = stopPlanMaterializedWork(body.file, prdSourcePlan, {
|
|
8120
|
+
targetStatus: WI_STATUS.PAUSED, stampField: '_pausedBy', stampValue: 'prd-pause',
|
|
8121
|
+
});
|
|
8096
8122
|
|
|
8097
8123
|
invalidateStatusCache();
|
|
8098
8124
|
invalidatePlansCache();
|
|
@@ -8149,8 +8175,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
8149
8175
|
return data;
|
|
8150
8176
|
}, { defaultValue: {} });
|
|
8151
8177
|
|
|
8178
|
+
// RC3: reject used to flip only the PRD status — its materialized work items
|
|
8179
|
+
// kept dispatching and any active agent kept running, and a pending plan-to-prd
|
|
8180
|
+
// run could rebuild the PRD. Reject is terminal, so cancel the materialized WIs,
|
|
8181
|
+
// kill active dispatches, and cancel the plan-to-prd regeneration WI.
|
|
8182
|
+
const cancelled = stopPlanMaterializedWork(body.file, plan?.source_plan || null, {
|
|
8183
|
+
targetStatus: WI_STATUS.CANCELLED, stampField: '_cancelledBy', stampValue: 'prd-rejected',
|
|
8184
|
+
});
|
|
8185
|
+
|
|
8186
|
+
invalidateStatusCache();
|
|
8152
8187
|
invalidatePlansCache();
|
|
8153
|
-
return jsonReply(res, 200, { ok: true, status: 'rejected' });
|
|
8188
|
+
return jsonReply(res, 200, { ok: true, status: 'rejected', cancelledWorkItems: cancelled });
|
|
8154
8189
|
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
8155
8190
|
}
|
|
8156
8191
|
|
package/engine/lifecycle.js
CHANGED
|
@@ -767,10 +767,17 @@ function syncPrdItemStatus(itemId, status, sourcePlan) {
|
|
|
767
767
|
// safeJsonNoRestore so an archived PRD's .backup sidecar can't resurrect
|
|
768
768
|
// the active PRD just because a done WI still references it.
|
|
769
769
|
const plan = safeJsonNoRestore(fpath);
|
|
770
|
+
// Frozen PRDs (paused/rejected/awaiting-approval/completed) must not have
|
|
771
|
+
// their item statuses mutated by a late-arriving WI status update — that
|
|
772
|
+
// would silently flip a paused/rejected PRD's items and feed the
|
|
773
|
+
// materializer. Mirrors the guard reconcilePrdStatuses already has.
|
|
774
|
+
if (_PRD_FROZEN_STATUSES.has(plan?.status)) continue;
|
|
770
775
|
const feature = plan?.missing_features?.find(f => f.id === itemId);
|
|
771
776
|
if (!feature || feature.status === status) continue;
|
|
772
777
|
let updated = false;
|
|
773
778
|
mutateJsonFileLocked(fpath, (fresh) => {
|
|
779
|
+
// Re-check under the lock — status may have frozen between peek and lock.
|
|
780
|
+
if (_PRD_FROZEN_STATUSES.has(fresh?.status)) return fresh;
|
|
774
781
|
const f = fresh?.missing_features?.find(x => x.id === itemId);
|
|
775
782
|
if (f && f.status !== status) {
|
|
776
783
|
f.status = status;
|
package/engine/shared.js
CHANGED
|
@@ -1328,7 +1328,12 @@ function forEachPidFile(callback) {
|
|
|
1328
1328
|
function neutralizeJsonBackupSidecar(filePath, inertData = { status: 'archived' }) {
|
|
1329
1329
|
const backupPath = filePath + '.backup';
|
|
1330
1330
|
try {
|
|
1331
|
-
|
|
1331
|
+
// Retry the unlink — on Windows a transient EPERM/EBUSY (AV scanner, lagging
|
|
1332
|
+
// OS handle, indexer) otherwise leaves the .backup sidecar in place, and the
|
|
1333
|
+
// next by-name safeJson() resurrects the just-deleted PRD from it. _retryFsOp
|
|
1334
|
+
// rethrows non-retryable codes (e.g. ENOENT) immediately, so the absent path
|
|
1335
|
+
// below still fires for an already-missing sidecar.
|
|
1336
|
+
_retryFsOp(() => fs.unlinkSync(backupPath), `neutralize backup ${path.basename(backupPath)}`);
|
|
1332
1337
|
return { ok: true, action: 'removed', backupPath };
|
|
1333
1338
|
} catch (unlinkErr) {
|
|
1334
1339
|
if (unlinkErr.code === 'ENOENT') return { ok: true, action: 'absent', backupPath };
|
package/engine.js
CHANGED
|
@@ -9304,6 +9304,26 @@ let tickCount = 0;
|
|
|
9304
9304
|
// In-memory cache of plan filenames confirmed completed — avoids redundant
|
|
9305
9305
|
// checkPlanCompletion calls. Cleared automatically on engine restart.
|
|
9306
9306
|
const completedPlanCache = new Set();
|
|
9307
|
+
// Filenames where a live PRD shares a basename with an archived PRD but diverges
|
|
9308
|
+
// (different source_plan / introduces new work). We refuse to auto-purge those
|
|
9309
|
+
// and warn once instead of every tick. (RC4 — footgun #7 collision protection.)
|
|
9310
|
+
const _ghostPurgeCollisionWarned = new Set();
|
|
9311
|
+
|
|
9312
|
+
// True when a live PRD that shares a basename with an archived one is merely a
|
|
9313
|
+
// stale echo of the archive (a .backup ghost-restore), safe to purge. False when
|
|
9314
|
+
// it's a genuinely distinct re-opened PRD that must NOT be silently deleted.
|
|
9315
|
+
// Conservative: if either file is unreadable we fall back to the legacy
|
|
9316
|
+
// "purge the ghost" behavior so we don't regress resurrection cleanup.
|
|
9317
|
+
function _isGhostPrdRestore(live, archived) {
|
|
9318
|
+
if (!live || !archived) return true;
|
|
9319
|
+
const ls = live.source_plan || live.sourcePlan;
|
|
9320
|
+
const as = archived.source_plan || archived.sourcePlan;
|
|
9321
|
+
if (ls && as && ls !== as) return false; // different plan identity → real PRD
|
|
9322
|
+
const archivedIds = new Set((archived.missing_features || []).map(f => f && f.id).filter(Boolean));
|
|
9323
|
+
const hasNewWork = (live.missing_features || []).some(f => f && f.id && !archivedIds.has(f.id));
|
|
9324
|
+
if (hasNewWork) return false; // introduces work the archive never had → real PRD
|
|
9325
|
+
return true; // same identity, no new work → stale echo → ghost
|
|
9326
|
+
}
|
|
9307
9327
|
let lastWatchCheckAt = 0;
|
|
9308
9328
|
let lastPrStatusPollAt = 0;
|
|
9309
9329
|
let lastPrCommentsPollAt = 0;
|
|
@@ -9722,11 +9742,26 @@ async function tickInner() {
|
|
|
9722
9742
|
for (const file of prdFiles) {
|
|
9723
9743
|
if (completedPlanCache.has(file)) continue;
|
|
9724
9744
|
if (fs.existsSync(path.join(PRD_DIR, 'archive', file))) {
|
|
9725
|
-
//
|
|
9726
|
-
|
|
9727
|
-
|
|
9728
|
-
|
|
9729
|
-
|
|
9745
|
+
// A live PRD basename also exists in the archive. Historically this was
|
|
9746
|
+
// unconditionally treated as an orphaned .backup ghost-restore and purged
|
|
9747
|
+
// — but a re-opened plan can legitimately create a NEW live PRD sharing
|
|
9748
|
+
// an archived basename (footgun #7). Identity-check before deleting so we
|
|
9749
|
+
// never silently destroy a real divergent PRD.
|
|
9750
|
+
const liveP = safeJsonNoRestore(path.join(PRD_DIR, file));
|
|
9751
|
+
const archP = safeJsonNoRestore(path.join(PRD_DIR, 'archive', file));
|
|
9752
|
+
if (_isGhostPrdRestore(liveP, archP)) {
|
|
9753
|
+
// Orphaned backup restore — plan is already archived. Purge the ghost copy.
|
|
9754
|
+
try { fs.unlinkSync(path.join(PRD_DIR, file)); } catch { }
|
|
9755
|
+
shared.neutralizeJsonBackupSidecar(path.join(PRD_DIR, file));
|
|
9756
|
+
completedPlanCache.add(file);
|
|
9757
|
+
continue;
|
|
9758
|
+
}
|
|
9759
|
+
// Divergent live PRD — do NOT delete. Warn once and fall through to the
|
|
9760
|
+
// normal completion handling below so it's treated as a real PRD.
|
|
9761
|
+
if (!_ghostPurgeCollisionWarned.has(file)) {
|
|
9762
|
+
log('warn', `PRD ${file} shares a basename with an archived PRD but diverges (different source_plan or new work) — NOT purging; resolve the collision manually`);
|
|
9763
|
+
_ghostPurgeCollisionWarned.add(file);
|
|
9764
|
+
}
|
|
9730
9765
|
}
|
|
9731
9766
|
const plan = safeJson(path.join(PRD_DIR, file));
|
|
9732
9767
|
if (plan && plan.missing_features) {
|
|
@@ -10446,6 +10481,7 @@ module.exports = {
|
|
|
10446
10481
|
materializeSpecsAsWorkItems, // exported for testing (P-f7-git-log)
|
|
10447
10482
|
reservePrdFilename, // exported for testing (P-9b7e5d3c)
|
|
10448
10483
|
sweepStaleArchivedPrdBackups, // exported for testing
|
|
10484
|
+
_isGhostPrdRestore, // exported for testing (RC4 — ghost-purge identity check)
|
|
10449
10485
|
|
|
10450
10486
|
// Shared helpers (used by lifecycle.js and tests)
|
|
10451
10487
|
reconcileItemsWithPrs, detectDependencyCycles,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2286",
|
|
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"
|