@yemi33/minions 0.1.2292 → 0.1.2294
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/settings.js +0 -45
- package/dashboard.js +3 -32
- package/docs/deprecated.json +6 -0
- package/docs/live-checkout-mode.md +0 -54
- package/docs/named-agents.md +1 -1
- package/docs/workspace-manifests.md +3 -4
- package/engine/cli.js +13 -0
- package/engine/features.js +14 -11
- package/engine/github.js +3 -0
- package/engine/lifecycle.js +86 -2
- package/engine/playbook.js +9 -4
- package/engine/prd-store.js +113 -7
- package/engine/queries.js +93 -72
- package/engine/shared.js +152 -30
- package/engine/work-items-store.js +21 -0
- package/engine.js +116 -151
- package/package.json +1 -1
- package/playbooks/fix.md +9 -0
package/engine/prd-store.js
CHANGED
|
@@ -74,7 +74,10 @@ function _upsertPrd(db, filename, archived, prd, now) {
|
|
|
74
74
|
sourcePlan, prd.sourcePlanModifiedAt || prd.source_plan_modified_at || null,
|
|
75
75
|
JSON.stringify(prd), now, prdId,
|
|
76
76
|
);
|
|
77
|
-
|
|
77
|
+
// NOTE: prd_items are NOT bulk-deleted here — they are upserted by their
|
|
78
|
+
// stable (prd_id, feature_id) key below so prd_items.id survives a re-mirror
|
|
79
|
+
// (work_items.prd_item_id is an FK to it). prd_verify_prs carries no FK, so
|
|
80
|
+
// a cheap delete+reinsert is fine.
|
|
78
81
|
db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(prdId);
|
|
79
82
|
} else {
|
|
80
83
|
const info = db.prepare(`
|
|
@@ -91,18 +94,73 @@ function _upsertPrd(db, filename, archived, prd, now) {
|
|
|
91
94
|
prdId = Number(info.lastInsertRowid);
|
|
92
95
|
}
|
|
93
96
|
|
|
94
|
-
|
|
97
|
+
// Upsert prd_items by their STABLE natural key (prd_id, feature_id) instead of
|
|
98
|
+
// delete-all-then-reinsert, so the surrogate prd_items.id is durable across
|
|
99
|
+
// re-mirrors. work_items.prd_item_id is an FK to this id; churning it (via
|
|
100
|
+
// AUTOINCREMENT on every mirror) dangled every stamped row — the Phase-10
|
|
101
|
+
// step-4 join-flip blocker. feature_id-less legacy items can't be keyed (NULL
|
|
102
|
+
// != NULL under UNIQUE), so they are swept + reinserted each time; they are
|
|
103
|
+
// never FK targets, so their churn is harmless.
|
|
104
|
+
const features = Array.isArray(prd.missing_features) ? prd.missing_features : [];
|
|
105
|
+
const keepIds = [];
|
|
106
|
+
for (const ft of features) { if (ft && typeof ft === 'object' && ft.id) keepIds.push(String(ft.id)); }
|
|
107
|
+
// Drop id-bearing items no longer on the PRD, plus all NULL-feature_id rows.
|
|
108
|
+
if (keepIds.length) {
|
|
109
|
+
db.prepare(
|
|
110
|
+
`DELETE FROM prd_items WHERE prd_id=? AND feature_id IS NOT NULL AND feature_id NOT IN (${keepIds.map(() => '?').join(',')})`
|
|
111
|
+
).run(prdId, ...keepIds);
|
|
112
|
+
} else {
|
|
113
|
+
db.prepare('DELETE FROM prd_items WHERE prd_id=? AND feature_id IS NOT NULL').run(prdId);
|
|
114
|
+
}
|
|
115
|
+
db.prepare('DELETE FROM prd_items WHERE prd_id=? AND feature_id IS NULL').run(prdId);
|
|
116
|
+
|
|
117
|
+
const upsertItem = db.prepare(`
|
|
95
118
|
INSERT INTO prd_items (prd_id, feature_id, name, project, status, type, work_item_id, data, updated_at)
|
|
96
119
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
120
|
+
ON CONFLICT(prd_id, feature_id) DO UPDATE SET
|
|
121
|
+
name=excluded.name, project=excluded.project, status=excluded.status,
|
|
122
|
+
type=excluded.type, work_item_id=excluded.work_item_id,
|
|
123
|
+
data=excluded.data, updated_at=excluded.updated_at
|
|
97
124
|
`);
|
|
98
|
-
const
|
|
125
|
+
const insItemNoId = db.prepare(`
|
|
126
|
+
INSERT INTO prd_items (prd_id, feature_id, name, project, status, type, work_item_id, data, updated_at)
|
|
127
|
+
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?)
|
|
128
|
+
`);
|
|
129
|
+
const selItemId = db.prepare('SELECT id FROM prd_items WHERE prd_id=? AND feature_id=?');
|
|
130
|
+
// Stamp the materialized work item(s) → prd_items.id. The two backref paths
|
|
131
|
+
// mirror the migration-015 backfill: an explicit feature.workItemId (set by
|
|
132
|
+
// lifecycle.stampPrdItemWorkItemId), and the sourcePlan(=PRD filename) +
|
|
133
|
+
// feature-id equality the legacy JSON model joined on. Self-heals on every
|
|
134
|
+
// mirror, so a WI materialized after this PRD was first seen still gets its FK.
|
|
135
|
+
const stampWiById = db.prepare(
|
|
136
|
+
'UPDATE work_items SET prd_item_id=? WHERE id=? AND (prd_item_id IS NULL OR prd_item_id<>?)'
|
|
137
|
+
);
|
|
138
|
+
const stampWiBySourcePlan = db.prepare(
|
|
139
|
+
`UPDATE work_items SET prd_item_id=? WHERE json_extract(data,'$.sourcePlan')=? AND json_extract(data,'$.id')=? AND (prd_item_id IS NULL OR prd_item_id<>?)`
|
|
140
|
+
);
|
|
99
141
|
for (const ft of features) {
|
|
100
142
|
if (!ft || typeof ft !== 'object') continue;
|
|
143
|
+
const proj = ft.project || prd.project || null;
|
|
144
|
+
const explicitWi = ft.workItemId || ft.work_item_id || null;
|
|
145
|
+
if (!ft.id) {
|
|
146
|
+
try {
|
|
147
|
+
insItemNoId.run(prdId, ft.name || null, proj, ft.status || null, ft.type || null,
|
|
148
|
+
explicitWi, JSON.stringify(ft), now);
|
|
149
|
+
} catch { /* best-effort */ }
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const featureId = String(ft.id);
|
|
101
153
|
try {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
154
|
+
upsertItem.run(prdId, featureId, ft.name || null, proj, ft.status || null, ft.type || null,
|
|
155
|
+
explicitWi, JSON.stringify(ft), now);
|
|
156
|
+
} catch { continue; }
|
|
157
|
+
let prdItemId;
|
|
158
|
+
try { prdItemId = selItemId.get(prdId, featureId)?.id; } catch { /* ignore */ }
|
|
159
|
+
if (!prdItemId) continue;
|
|
160
|
+
if (explicitWi) {
|
|
161
|
+
try { stampWiById.run(prdItemId, String(explicitWi), prdItemId); } catch { /* best-effort */ }
|
|
162
|
+
}
|
|
163
|
+
try { stampWiBySourcePlan.run(prdItemId, filename, featureId, prdItemId); } catch { /* best-effort */ }
|
|
106
164
|
}
|
|
107
165
|
|
|
108
166
|
const insVp = db.prepare(`
|
|
@@ -235,6 +293,53 @@ function reconcilePrdsFromDisk() {
|
|
|
235
293
|
return { ok: true, mirrored, removed };
|
|
236
294
|
}
|
|
237
295
|
|
|
296
|
+
// Idempotent boot sweep: populate work_items.prd_item_id for rows that predate
|
|
297
|
+
// the dual-write stamp (everything materialized before #546), or that a JSON
|
|
298
|
+
// re-hydrate transiently zeroed. The per-mirror stamp in _upsertPrd only fires
|
|
299
|
+
// for PRDs that are re-mirrored, and reconcile is mtime-gated — so a PRD that
|
|
300
|
+
// never changes again never re-stamps its historical WIs. This catch-up is
|
|
301
|
+
// set-based (two SQL UPDATEs, no per-row loop), only touches NULL FKs, and
|
|
302
|
+
// prefers the live (archived=0) prd_item when a basename exists in both buckets.
|
|
303
|
+
// Safe to run on every boot — a no-op once every matchable WI is linked.
|
|
304
|
+
// Returns { ok, byWorkItemId, bySourcePlan } counts.
|
|
305
|
+
function backfillWorkItemPrdItemIds() {
|
|
306
|
+
let db;
|
|
307
|
+
try { db = require('./db').getDb(); } catch { return { ok: false }; }
|
|
308
|
+
try {
|
|
309
|
+
return require('./db').withTransaction(db, () => {
|
|
310
|
+
// Path 1 — explicit prd_items.work_item_id backref (most precise).
|
|
311
|
+
const r1 = db.prepare(`
|
|
312
|
+
UPDATE work_items SET prd_item_id = (
|
|
313
|
+
SELECT pi.id FROM prd_items pi JOIN prds p ON p.id = pi.prd_id
|
|
314
|
+
WHERE pi.work_item_id = work_items.id
|
|
315
|
+
ORDER BY p.archived ASC, pi.id ASC LIMIT 1
|
|
316
|
+
)
|
|
317
|
+
WHERE prd_item_id IS NULL
|
|
318
|
+
AND EXISTS (SELECT 1 FROM prd_items pi WHERE pi.work_item_id = work_items.id)
|
|
319
|
+
`).run();
|
|
320
|
+
// Path 2 — sourcePlan(=PRD filename) + feature-id equality on the WI blob,
|
|
321
|
+
// for WIs the PRD side never stamped with an explicit workItemId.
|
|
322
|
+
const r2 = db.prepare(`
|
|
323
|
+
UPDATE work_items SET prd_item_id = (
|
|
324
|
+
SELECT pi.id FROM prd_items pi JOIN prds p ON p.id = pi.prd_id
|
|
325
|
+
WHERE p.filename = json_extract(work_items.data, '$.sourcePlan')
|
|
326
|
+
AND pi.feature_id = json_extract(work_items.data, '$.id')
|
|
327
|
+
ORDER BY p.archived ASC, pi.id ASC LIMIT 1
|
|
328
|
+
)
|
|
329
|
+
WHERE prd_item_id IS NULL
|
|
330
|
+
AND EXISTS (
|
|
331
|
+
SELECT 1 FROM prd_items pi JOIN prds p ON p.id = pi.prd_id
|
|
332
|
+
WHERE p.filename = json_extract(work_items.data, '$.sourcePlan')
|
|
333
|
+
AND pi.feature_id = json_extract(work_items.data, '$.id')
|
|
334
|
+
)
|
|
335
|
+
`).run();
|
|
336
|
+
return { ok: true, byWorkItemId: Number(r1.changes) || 0, bySourcePlan: Number(r2.changes) || 0 };
|
|
337
|
+
});
|
|
338
|
+
} catch (e) {
|
|
339
|
+
return { ok: false, reason: e && e.message };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
238
343
|
// List every PRD row as { filename, archived, plan } where plan is the parsed
|
|
239
344
|
// data blob. Sorted by (archived, filename) for deterministic consumer order
|
|
240
345
|
// (matches the disk-scan order once the caller also sorts). Read-flip source.
|
|
@@ -258,6 +363,7 @@ module.exports = {
|
|
|
258
363
|
removePrdFromSql,
|
|
259
364
|
readPrdFromSql,
|
|
260
365
|
reconcilePrdsFromDisk,
|
|
366
|
+
backfillWorkItemPrdItemIds,
|
|
261
367
|
listPrdRows,
|
|
262
368
|
_reconcileMtimes, // exported for tests (reset between cases)
|
|
263
369
|
_upsertPrd, // exported for tests
|
package/engine/queries.js
CHANGED
|
@@ -2077,8 +2077,7 @@ function getWorkItems(config, opts) {
|
|
|
2077
2077
|
|
|
2078
2078
|
// ── PRD Progress ────────────────────────────────────────────────────────────
|
|
2079
2079
|
|
|
2080
|
-
// Module-level caches for getPrdInfo() — avoids re-reading unchanged PRD
|
|
2081
|
-
const _prdFileCache = new Map(); // filePath → { mtimeMs, plan }
|
|
2080
|
+
// Module-level caches for getPrdInfo() — avoids re-reading unchanged PRD data
|
|
2082
2081
|
let _prdDirMtimes = { prd: 0, archive: 0 }; // directory mtimes to detect new/deleted files
|
|
2083
2082
|
let _prdResultCache = null; // cached final result
|
|
2084
2083
|
let _prdResultInputHash = ''; // hash of all input mtimes to detect any change
|
|
@@ -2141,21 +2140,22 @@ function getPrdInfo(config) {
|
|
|
2141
2140
|
const verifyPrsByPlan = {};
|
|
2142
2141
|
let latestStat = null;
|
|
2143
2142
|
|
|
2144
|
-
// Check if directory listings need refresh
|
|
2145
|
-
const dirsChanged = prdDirMtime !== _prdDirMtimes.prd || archiveDirMtime !== _prdDirMtimes.archive;
|
|
2143
|
+
// Check if directory listings need refresh (used by mtime hash)
|
|
2146
2144
|
_prdDirMtimes = { prd: prdDirMtime, archive: archiveDirMtime };
|
|
2147
2145
|
|
|
2148
|
-
//
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2146
|
+
// Phase 10 step 4.3 — FK-based WI↔PRD-item join scaffolding. `prdItemIdByKey`
|
|
2147
|
+
// maps `${physicalArchived} ${filename} ${feature_id}` → prd_items.id so each
|
|
2148
|
+
// PRD item carries its stable surrogate id. Declared here so processPrd's
|
|
2149
|
+
// closure can stamp `_prdItemId` at push time. Null when the FK join is off /
|
|
2150
|
+
// SQL unavailable → the per-item join falls back to the legacy feature-id
|
|
2151
|
+
// string match.
|
|
2152
|
+
let useFkJoin = false;
|
|
2153
|
+
try { useFkJoin = require('./features').isFeatureOn('prdJoinFromFk', config); } catch { /* fall back to string join */ }
|
|
2154
|
+
let prdItemIdByKey = null;
|
|
2155
|
+
|
|
2156
|
+
// Phase 10 step 3 (read-flip complete) — both disk-write and SQL-mirror sources
|
|
2157
|
+
// funnel each PRD through this one processor so the consumed shape
|
|
2156
2158
|
// (existingPrds / verifyPrsByPlan / allPrdItems) is identical by construction.
|
|
2157
|
-
// The only difference is where `plan` + `mtimeMs` come from. Equivalence of the
|
|
2158
|
-
// two paths is proven by test/unit/db-phase10-read-flip.test.js.
|
|
2159
2159
|
const processPrd = (pf, archived, plan, mtimeMs) => {
|
|
2160
2160
|
if (!plan || !plan.missing_features) return;
|
|
2161
2161
|
// Phase 10 step 4.2: archived-ness is the FLAG, not the directory. `archived`
|
|
@@ -2187,6 +2187,11 @@ function getPrdInfo(config) {
|
|
|
2187
2187
|
}));
|
|
2188
2188
|
}
|
|
2189
2189
|
for (const f of plan.missing_features) {
|
|
2190
|
+
// Stamp the stable surrogate prd_items.id, keyed on the PHYSICAL archived
|
|
2191
|
+
// bit (the `archived` param == SQL location), not the logical `isArch`.
|
|
2192
|
+
const prdItemId = (prdItemIdByKey && f && f.id != null)
|
|
2193
|
+
? (prdItemIdByKey.get(`${archived ? 1 : 0} ${pf} ${f.id}`) ?? null)
|
|
2194
|
+
: null;
|
|
2190
2195
|
allPrdItems.push({
|
|
2191
2196
|
...f, _source: pf, _planStatus: plan.status || 'active',
|
|
2192
2197
|
_planSummary: plan.plan_summary || pf, _planProject: plan.project || '',
|
|
@@ -2195,72 +2200,45 @@ function getPrdInfo(config) {
|
|
|
2195
2200
|
_planStale: planStale || plan.planStale || false, _lastSyncedFromPlan: plan.lastSyncedFromPlan || null,
|
|
2196
2201
|
_prdUpdatedAt: new Date(mtimeMs).toISOString(),
|
|
2197
2202
|
_prdCompletedAt: plan.completedAt || '',
|
|
2203
|
+
_prdItemId: prdItemId,
|
|
2198
2204
|
});
|
|
2199
2205
|
}
|
|
2200
2206
|
};
|
|
2201
2207
|
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
// moves, deletes), then read each PRD's content from SQL. Files still live on
|
|
2209
|
-
// disk in this phase, so stat them for the mtime fields + `age`.
|
|
2208
|
+
// Build the feature → prd_items.id map before the read loop so processPrd can
|
|
2209
|
+
// stamp _prdItemId. SQL is the unconditional source (Phase 10 step 3 complete),
|
|
2210
|
+
// so the FK join just needs useFkJoin. Best-effort: any failure leaves
|
|
2211
|
+
// prdItemIdByKey null and the join silently falls back to the legacy
|
|
2212
|
+
// feature-id string match.
|
|
2213
|
+
if (useFkJoin) {
|
|
2210
2214
|
try {
|
|
2211
|
-
const
|
|
2212
|
-
|
|
2213
|
-
for (const
|
|
2214
|
-
|
|
2215
|
-
const dir = archived ? path.join(PRD_DIR, 'archive') : PRD_DIR;
|
|
2216
|
-
let mtimeMs = 0;
|
|
2217
|
-
try {
|
|
2218
|
-
const stat = fs.statSync(path.join(dir, filename));
|
|
2219
|
-
mtimeMs = stat.mtimeMs;
|
|
2220
|
-
if (!latestStat || stat.mtimeMs > latestStat.mtimeMs) latestStat = stat;
|
|
2221
|
-
} catch { /* file may have just moved/deleted between reconcile and read */ }
|
|
2222
|
-
processPrd(filename, archived, plan, mtimeMs);
|
|
2223
|
-
} catch { /* optional */ }
|
|
2215
|
+
const db = require('./db').getDb();
|
|
2216
|
+
const map = new Map();
|
|
2217
|
+
for (const r of db.prepare('SELECT p.filename AS filename, p.archived AS archived, pi.feature_id AS feature_id, pi.id AS id FROM prd_items pi JOIN prds p ON p.id = pi.prd_id WHERE pi.feature_id IS NOT NULL').all()) {
|
|
2218
|
+
map.set(`${r.archived ? 1 : 0} ${r.filename} ${r.feature_id}`, r.id);
|
|
2224
2219
|
}
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
// goes blank on a transient DB problem.
|
|
2228
|
-
useSqlReads = false;
|
|
2229
|
-
}
|
|
2220
|
+
prdItemIdByKey = map;
|
|
2221
|
+
} catch { prdItemIdByKey = null; }
|
|
2230
2222
|
}
|
|
2231
2223
|
|
|
2232
|
-
|
|
2233
|
-
|
|
2224
|
+
// Phase 10 step 3 (read-flip complete) — SQL mirror is the unconditional
|
|
2225
|
+
// source. reconcilePrdsFromDisk() catches PRDs written outside the dual-write
|
|
2226
|
+
// chokepoint (plan-to-prd agent direct writes, archive moves, deletes). Files
|
|
2227
|
+
// still live on disk in this phase, so stat them for mtime fields + `age`.
|
|
2228
|
+
const prdStore = require('./prd-store');
|
|
2229
|
+
prdStore.reconcilePrdsFromDisk();
|
|
2230
|
+
for (const { filename, archived, plan } of prdStore.listPrdRows()) {
|
|
2231
|
+
try {
|
|
2232
|
+
const dir = archived ? path.join(PRD_DIR, 'archive') : PRD_DIR;
|
|
2233
|
+
let mtimeMs = 0;
|
|
2234
2234
|
try {
|
|
2235
|
-
const
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
// Per-file mtime cache: only re-read files that changed
|
|
2243
|
-
const cached = _prdFileCache.get(filePath);
|
|
2244
|
-
let plan;
|
|
2245
|
-
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
|
2246
|
-
plan = cached.plan;
|
|
2247
|
-
} else {
|
|
2248
|
-
plan = readJsonNoRestore(filePath);
|
|
2249
|
-
_prdFileCache.set(filePath, { mtimeMs: stat.mtimeMs, plan });
|
|
2250
|
-
}
|
|
2251
|
-
processPrd(pf, archived, plan, stat.mtimeMs);
|
|
2252
|
-
} catch { /* optional */ }
|
|
2253
|
-
}
|
|
2254
|
-
// Clean stale entries from file cache when dirs changed
|
|
2255
|
-
if (dirsChanged) {
|
|
2256
|
-
for (const cachedPath of _prdFileCache.keys()) {
|
|
2257
|
-
if (cachedPath.startsWith(dir) && !fs.existsSync(cachedPath)) _prdFileCache.delete(cachedPath);
|
|
2258
|
-
}
|
|
2259
|
-
}
|
|
2260
|
-
} catch { /* optional */ }
|
|
2261
|
-
}
|
|
2235
|
+
const stat = fs.statSync(path.join(dir, filename));
|
|
2236
|
+
mtimeMs = stat.mtimeMs;
|
|
2237
|
+
if (!latestStat || stat.mtimeMs > latestStat.mtimeMs) latestStat = stat;
|
|
2238
|
+
} catch { /* file may have just moved/deleted between reconcile and read */ }
|
|
2239
|
+
processPrd(filename, archived, plan, mtimeMs);
|
|
2240
|
+
} catch { /* optional */ }
|
|
2262
2241
|
}
|
|
2263
|
-
|
|
2264
2242
|
if (allPrdItems.length === 0) return { progress: null, status: null };
|
|
2265
2243
|
|
|
2266
2244
|
const items = allPrdItems;
|
|
@@ -2293,6 +2271,29 @@ function getPrdInfo(config) {
|
|
|
2293
2271
|
}
|
|
2294
2272
|
} catch { /* optional */ }
|
|
2295
2273
|
|
|
2274
|
+
// Phase 10 step 4.3 — index the (JSON-sourced) work items by their stable SQL
|
|
2275
|
+
// FK so the per-item join can resolve PRD item → WI by prd_items.id rather
|
|
2276
|
+
// than the feature-id string. The FK itself lives only in SQL (it's not in the
|
|
2277
|
+
// JSON mirror), so read work_items.prd_item_id once and join it to the WI
|
|
2278
|
+
// objects we already loaded, keyed by wi.id (first-wins, matching allWiById).
|
|
2279
|
+
// Best-effort: null map → the join falls back to the legacy string match.
|
|
2280
|
+
let wiByPrdItemId = null;
|
|
2281
|
+
if (useFkJoin && prdItemIdByKey) {
|
|
2282
|
+
try {
|
|
2283
|
+
const db = require('./db').getDb();
|
|
2284
|
+
const fkByWiId = new Map();
|
|
2285
|
+
for (const r of db.prepare('SELECT id, prd_item_id FROM work_items WHERE prd_item_id IS NOT NULL').all()) {
|
|
2286
|
+
if (!fkByWiId.has(r.id)) fkByWiId.set(r.id, r.prd_item_id);
|
|
2287
|
+
}
|
|
2288
|
+
const map = {};
|
|
2289
|
+
for (const wi of Object.values(allWiById)) {
|
|
2290
|
+
const fk = fkByWiId.get(wi.id);
|
|
2291
|
+
if (fk != null && map[fk] === undefined) map[fk] = wi;
|
|
2292
|
+
}
|
|
2293
|
+
wiByPrdItemId = map;
|
|
2294
|
+
} catch { wiByPrdItemId = null; }
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2296
2297
|
// PR-to-PRD linking — derived from PR.prdItems (single source of truth).
|
|
2297
2298
|
// getPullRequests includes records from unconfigured project subdirs so PRD
|
|
2298
2299
|
// links can resolve to last-known status even after a project is removed.
|
|
@@ -2383,12 +2384,33 @@ function getPrdInfo(config) {
|
|
|
2383
2384
|
}
|
|
2384
2385
|
}
|
|
2385
2386
|
|
|
2387
|
+
// Fallback (P-b6c8d0e2): PR records stamped with prdItemId/sourcePlan at sync
|
|
2388
|
+
// time. When the standard prLinks chain doesn't cover a PRD item (e.g. the WI
|
|
2389
|
+
// id differs from the PRD feature id, or prLinks is stale), use the PR record's
|
|
2390
|
+
// own prdItemId field to ensure the PRD view shows the PR.
|
|
2391
|
+
for (const pr of allPrs) {
|
|
2392
|
+
const prdItemIdFromPr = typeof pr.prdItemId === 'string' ? pr.prdItemId : '';
|
|
2393
|
+
if (!prdItemIdFromPr || !prdItemIdSet.has(prdItemIdFromPr)) continue;
|
|
2394
|
+
if (prdToPr[prdItemIdFromPr] && prdToPr[prdItemIdFromPr].length > 0) continue; // already covered
|
|
2395
|
+
if (!prdToPr[prdItemIdFromPr]) prdToPr[prdItemIdFromPr] = [];
|
|
2396
|
+
const url = buildPrUrlFromId(pr.id, pr, projects);
|
|
2397
|
+
prdToPr[prdItemIdFromPr].push({ id: pr.id, url, title: pr.title || '', status: pr.status || PR_STATUS.ACTIVE, _project: pr._project || '' });
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
|
|
2386
2401
|
// PRD JSON status is the source of truth — kept in sync with work item by syncPrdItemStatus.
|
|
2387
2402
|
// Map from PRD JSON values to display values (pending → missing for undispatched items)
|
|
2388
2403
|
// Augment each item with execution metadata from the work item.
|
|
2389
2404
|
const statusDisplay = { pending: 'missing', dispatched: 'in-progress' };
|
|
2390
2405
|
for (const item of items) {
|
|
2391
|
-
|
|
2406
|
+
// Phase 10 step 4.3 — resolve the work item via the stable SQL FK
|
|
2407
|
+
// (item._prdItemId → prd_items.id → work_items.prd_item_id) first; fall back
|
|
2408
|
+
// to the legacy feature-id string match (wiById[item.id]) for unstamped rows,
|
|
2409
|
+
// a NULL FK, the flag OFF, or SQL unavailable. The fallback reproduces the
|
|
2410
|
+
// pre-flip behavior exactly, so a live+archived feature-id collision (footgun
|
|
2411
|
+
// #7) is corrected when the FK is present and degrades to legacy otherwise.
|
|
2412
|
+
const wi = (wiByPrdItemId && item._prdItemId != null && wiByPrdItemId[item._prdItemId])
|
|
2413
|
+
|| wiById[item.id];
|
|
2392
2414
|
// PRD 'updated'/'missing' = intentional rework signal — takes priority over a done work item (#930).
|
|
2393
2415
|
// Otherwise work item status is source of truth when available (PRD JSON may lag behind).
|
|
2394
2416
|
// If PRD says dispatched/failed but no work item exists, treat as pending (orphaned — #779)
|
|
@@ -2462,7 +2484,6 @@ function getPrdInfo(config) {
|
|
|
2462
2484
|
|
|
2463
2485
|
/** Reset PRD info cache — exported for testing */
|
|
2464
2486
|
function resetPrdInfoCache() {
|
|
2465
|
-
_prdFileCache.clear();
|
|
2466
2487
|
_prdDirMtimes = { prd: 0, archive: 0 };
|
|
2467
2488
|
_prdResultCache = null;
|
|
2468
2489
|
_prdResultInputHash = '';
|
package/engine/shared.js
CHANGED
|
@@ -1005,14 +1005,18 @@ function _routeJsonReadToSql(p) {
|
|
|
1005
1005
|
* stale `.backup` is actively harmful. See its JSDoc for selection guidance.
|
|
1006
1006
|
*/
|
|
1007
1007
|
|
|
1008
|
-
// PL-prd-no-backup (W-mqub65ez0004b3bd) —
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
|
|
1008
|
+
// PL-prd-no-backup (W-mqub65ez0004b3bd) — PRD `.json` files must NOT auto-restore
|
|
1009
|
+
// from a stale `.backup` sidecar: a removed/archived PRD coming back and
|
|
1010
|
+
// re-dispatching work is the original resurrection footgun (W-mouptdh1000h9f39).
|
|
1011
|
+
// Phase 10 step 4.3 — BROADENED from prd/archive/ to ALL prd/*.json (root +
|
|
1012
|
+
// archive). The root-level backup lifecycle previously survived ONLY to recover
|
|
1013
|
+
// the enforceDeclaredPlanProject rename-race (a concurrent sweep deleting a
|
|
1014
|
+
// just-renamed PRD); that rename is now retired and SQL is the canonical store,
|
|
1015
|
+
// so the sidecar is pure resurrection fuel with no remaining benefit:
|
|
1016
|
+
// - prd/*.json AND prd/archive/*.json → no backup write + no safeJson restore.
|
|
1017
|
+
// Matches a single filename segment under prd/ (optionally one archive/ level),
|
|
1018
|
+
// so nested non-PRD paths and plans/ (which are .md) are unaffected.
|
|
1019
|
+
const _NO_BACKUP_JSON_RE = /(?:^|[\\/])prd[\\/](?:archive[\\/])?[^\\/]+\.json$/i;
|
|
1016
1020
|
function _isNoBackupJsonPath(p) {
|
|
1017
1021
|
return typeof p === 'string' && _NO_BACKUP_JSON_RE.test(p);
|
|
1018
1022
|
}
|
|
@@ -1778,6 +1782,23 @@ function _tryRouteMutateToSql(filePath, mutateFn, onWrote) {
|
|
|
1778
1782
|
if (smallRoute) {
|
|
1779
1783
|
if (!fpNorm.endsWith('/engine/' + baseName)) return null;
|
|
1780
1784
|
const store = require('./small-state-store');
|
|
1785
|
+
// Pre-warm the SQLite DB BEFORE acquiring the file lock. On the very first
|
|
1786
|
+
// call from a process (or after a test-isolation MINIONS_TEST_DIR swap),
|
|
1787
|
+
// getDb() opens the DB file and runs migrations — which can take tens of
|
|
1788
|
+
// milliseconds on a loaded box. When 5+ concurrent processes all hit this
|
|
1789
|
+
// path simultaneously, whichever one wins the file lock holds it for the
|
|
1790
|
+
// entire DB-init duration. The other 4 processes each have only a 5-second
|
|
1791
|
+
// timeout window to acquire the lock; if DB init exceeds that, they throw
|
|
1792
|
+
// "Lock timeout" and exit 1 (observed in CI after opg#552 backport which
|
|
1793
|
+
// added a new test that increases system load during parallel runs).
|
|
1794
|
+
// Pre-warming here means getDb() returns the cached _db instantly when
|
|
1795
|
+
// called again inside the lock, so the lock is held only for the fast
|
|
1796
|
+
// SQL transaction + mirror write — not for DB initialization.
|
|
1797
|
+
try {
|
|
1798
|
+
require('./db').getDb();
|
|
1799
|
+
} catch (e) {
|
|
1800
|
+
throw new Error(`small-state-store: SQLite unavailable — ${e.message}`);
|
|
1801
|
+
}
|
|
1781
1802
|
// Hold the JSON file lock across the SQL transaction AND the mirror
|
|
1782
1803
|
// write. The SQL transaction itself is cross-process serialized via
|
|
1783
1804
|
// BEGIN IMMEDIATE inside the SQL store, but the JSON mirror is a
|
|
@@ -2675,21 +2696,12 @@ const CHECKOUT_MODES = Object.freeze({ WORKTREE: 'worktree', LIVE: 'live' });
|
|
|
2675
2696
|
function resolveCheckoutMode(project, workItemType) {
|
|
2676
2697
|
if (!project || typeof project !== 'object') return CHECKOUT_MODES.WORKTREE;
|
|
2677
2698
|
const canonical = project.checkoutMode;
|
|
2678
|
-
//
|
|
2679
|
-
|
|
2680
|
-
if (canonical === CHECKOUT_MODES.LIVE || canonical === CHECKOUT_MODES.WORKTREE) {
|
|
2681
|
-
effectiveMode = canonical;
|
|
2682
|
-
} else if (project.worktreeMode === 'live') {
|
|
2683
|
-
effectiveMode = CHECKOUT_MODES.LIVE;
|
|
2684
|
-
} else {
|
|
2685
|
-
effectiveMode = CHECKOUT_MODES.WORKTREE;
|
|
2686
|
-
}
|
|
2687
|
-
// liveValidation without an effective live mode is a misconfiguration — warn and ignore.
|
|
2688
|
-
if (project.liveValidation && effectiveMode !== CHECKOUT_MODES.LIVE) {
|
|
2699
|
+
// liveValidation without checkoutMode: 'live' is a misconfiguration — warn and ignore.
|
|
2700
|
+
if (project.liveValidation && canonical !== CHECKOUT_MODES.LIVE) {
|
|
2689
2701
|
log('warn', 'resolveCheckoutMode: liveValidation is set but checkoutMode is not "live" — liveValidation ignored',
|
|
2690
2702
|
{ projectName: project.name });
|
|
2691
2703
|
}
|
|
2692
|
-
if (
|
|
2704
|
+
if (canonical === CHECKOUT_MODES.LIVE) {
|
|
2693
2705
|
// Apply liveValidation routing when the block is present and workItemType is provided.
|
|
2694
2706
|
if (project.liveValidation && workItemType !== undefined) {
|
|
2695
2707
|
return workItemType === project.liveValidation.type
|
|
@@ -2698,6 +2710,11 @@ function resolveCheckoutMode(project, workItemType) {
|
|
|
2698
2710
|
}
|
|
2699
2711
|
return CHECKOUT_MODES.LIVE;
|
|
2700
2712
|
}
|
|
2713
|
+
if (canonical === CHECKOUT_MODES.WORKTREE) return CHECKOUT_MODES.WORKTREE;
|
|
2714
|
+
// Legacy field fallback (only consulted when checkoutMode is absent/unknown).
|
|
2715
|
+
const legacy = project.worktreeMode;
|
|
2716
|
+
if (legacy === 'live') return CHECKOUT_MODES.LIVE;
|
|
2717
|
+
// legacy 'isolated' (and anything else) → the default worktree behavior.
|
|
2701
2718
|
return CHECKOUT_MODES.WORKTREE;
|
|
2702
2719
|
}
|
|
2703
2720
|
|
|
@@ -3639,8 +3656,14 @@ let _legacyCcModelMigrationLogged = false;
|
|
|
3639
3656
|
function applyLegacyCcModelMigration(config, { logger = log } = {}) {
|
|
3640
3657
|
if (!config || !config.engine || typeof config.engine !== 'object') return false;
|
|
3641
3658
|
const e = config.engine;
|
|
3642
|
-
if (e.defaultModel !== undefined && e.defaultModel !== null && e.defaultModel !== '')
|
|
3643
|
-
|
|
3659
|
+
if (e.defaultModel !== undefined && e.defaultModel !== null && e.defaultModel !== '') {
|
|
3660
|
+
logger('info', 'legacy-cc-model-migration: no-op (0 records migrated), safe to schedule removal.');
|
|
3661
|
+
return false;
|
|
3662
|
+
}
|
|
3663
|
+
if (!(e.ccModel !== undefined && e.ccModel !== null && e.ccModel !== '')) {
|
|
3664
|
+
logger('info', 'legacy-cc-model-migration: no-op (0 records migrated), safe to schedule removal.');
|
|
3665
|
+
return false;
|
|
3666
|
+
}
|
|
3644
3667
|
e.defaultModel = e.ccModel;
|
|
3645
3668
|
if (!_legacyCcModelMigrationLogged) {
|
|
3646
3669
|
_legacyCcModelMigrationLogged = true;
|
|
@@ -4856,12 +4879,6 @@ function agentCanUseRepo(agent, repoTarget) {
|
|
|
4856
4879
|
return false;
|
|
4857
4880
|
}
|
|
4858
4881
|
|
|
4859
|
-
/** Return the agent's effective memory scope. Unknown values fall back to 'shared'. */
|
|
4860
|
-
function agentMemoryScope(agent) {
|
|
4861
|
-
const manifest = resolveAgentManifest(agent);
|
|
4862
|
-
return MEMORY_SCOPES.includes(manifest.memory_scope) ? manifest.memory_scope : 'shared';
|
|
4863
|
-
}
|
|
4864
|
-
|
|
4865
4882
|
/**
|
|
4866
4883
|
* Merge a manifest's `allowed_tools` list into the runtime adapter's existing
|
|
4867
4884
|
* `allowedTools` CSV baseline. Returns the merged CSV string the runtime
|
|
@@ -7064,6 +7081,91 @@ function addPrLink(prId, itemId, { project = null, url = '', prNumber = null } =
|
|
|
7064
7081
|
});
|
|
7065
7082
|
}
|
|
7066
7083
|
|
|
7084
|
+
// Fields copied from "loser" duplicates into the winner when collapsing.
|
|
7085
|
+
// Additive (array-merge) fields are listed separately.
|
|
7086
|
+
const _DEDUP_MERGE_SCALAR_KEYS = [
|
|
7087
|
+
'url', 'title', 'agent', 'branch', 'description', 'sourcePlan', 'itemType',
|
|
7088
|
+
'mergedAt', 'closedAt', 'created', 'reviewStatus',
|
|
7089
|
+
'minionsReview', '_automationFixCauses',
|
|
7090
|
+
];
|
|
7091
|
+
|
|
7092
|
+
// Pick the "most complete" record from a list of duplicates.
|
|
7093
|
+
// Preference order:
|
|
7094
|
+
// 1. merged status (most terminal / most informative)
|
|
7095
|
+
// 2. record with a URL (enables live polling)
|
|
7096
|
+
// 3. record with more own keys (richer data)
|
|
7097
|
+
function _pickBestPrRecord(candidates) {
|
|
7098
|
+
if (candidates.length === 1) return candidates[0];
|
|
7099
|
+
const merged = candidates.filter(c => c && c.status === PR_STATUS.MERGED);
|
|
7100
|
+
const pool = merged.length > 0 ? merged : candidates.filter(Boolean);
|
|
7101
|
+
const withUrl = pool.filter(c => c.url);
|
|
7102
|
+
const final = withUrl.length > 0 ? withUrl : pool;
|
|
7103
|
+
return final.reduce((a, b) => Object.keys(b || {}).length > Object.keys(a || {}).length ? b : a);
|
|
7104
|
+
}
|
|
7105
|
+
|
|
7106
|
+
// Merge non-conflicting fields from a loser duplicate into the winner.
|
|
7107
|
+
// Scalar fields are filled when winner is empty; prdItems are union-merged.
|
|
7108
|
+
function _mergeDuplicatePrInto(winner, loser) {
|
|
7109
|
+
if (!winner || !loser) return;
|
|
7110
|
+
for (const key of _DEDUP_MERGE_SCALAR_KEYS) {
|
|
7111
|
+
if ((winner[key] == null || winner[key] === '') && loser[key] != null && loser[key] !== '') {
|
|
7112
|
+
winner[key] = loser[key];
|
|
7113
|
+
}
|
|
7114
|
+
}
|
|
7115
|
+
winner.prdItems = normalizePrLinkItems([...(winner.prdItems || []), ...(loser.prdItems || [])]);
|
|
7116
|
+
}
|
|
7117
|
+
|
|
7118
|
+
/**
|
|
7119
|
+
* P-e9f0a2b4 — One-time repair helper: collapse duplicate PR records that share
|
|
7120
|
+
* the same `prNumber` within a single `pull-requests.json` file.
|
|
7121
|
+
*
|
|
7122
|
+
* Call this at engine start for every project's PR file (and the central file).
|
|
7123
|
+
* Idempotent — no-op when no duplicates exist.
|
|
7124
|
+
*
|
|
7125
|
+
* Selection rule (matching the task spec):
|
|
7126
|
+
* 1. Prefer `merged` status over any other status.
|
|
7127
|
+
* 2. Among tied: prefer the record with a URL.
|
|
7128
|
+
* 3. Among tied: prefer the record with more fields (richer data).
|
|
7129
|
+
* Fields unique to the losers are merged into the winner:
|
|
7130
|
+
* • scalar fields: filled only when the winner's field is empty
|
|
7131
|
+
* • prdItems: union-merged
|
|
7132
|
+
*
|
|
7133
|
+
* @param {string} prPath Path to the pull-requests.json file.
|
|
7134
|
+
* @param {{ project?: object|null }} opts Optional project config for normalization.
|
|
7135
|
+
* @returns {{ collapsed: number }} Number of duplicate records removed.
|
|
7136
|
+
*/
|
|
7137
|
+
function collapseDuplicatePrRecords(prPath, { project = null } = {}) {
|
|
7138
|
+
let collapsed = 0;
|
|
7139
|
+
mutatePullRequests(prPath, (prs) => {
|
|
7140
|
+
normalizePrRecords(prs, project);
|
|
7141
|
+
// Group by prNumber.
|
|
7142
|
+
const byNumber = new Map();
|
|
7143
|
+
for (const pr of prs) {
|
|
7144
|
+
if (!pr) continue;
|
|
7145
|
+
const num = getPrNumber(pr);
|
|
7146
|
+
if (num == null) continue;
|
|
7147
|
+
if (!byNumber.has(num)) byNumber.set(num, []);
|
|
7148
|
+
byNumber.get(num).push(pr);
|
|
7149
|
+
}
|
|
7150
|
+
// Identify loser records to remove (by object reference, not id, because
|
|
7151
|
+
// normalization may have changed multiple records to the same canonical id).
|
|
7152
|
+
const toRemove = new Set();
|
|
7153
|
+
for (const [, group] of byNumber) {
|
|
7154
|
+
if (group.length <= 1) continue;
|
|
7155
|
+
const best = _pickBestPrRecord(group);
|
|
7156
|
+
for (const dup of group) {
|
|
7157
|
+
if (!dup || dup === best) continue;
|
|
7158
|
+
_mergeDuplicatePrInto(best, dup);
|
|
7159
|
+
toRemove.add(dup);
|
|
7160
|
+
collapsed++;
|
|
7161
|
+
}
|
|
7162
|
+
}
|
|
7163
|
+
if (toRemove.size === 0) return prs;
|
|
7164
|
+
return prs.filter(pr => !toRemove.has(pr));
|
|
7165
|
+
});
|
|
7166
|
+
return { collapsed };
|
|
7167
|
+
}
|
|
7168
|
+
|
|
7067
7169
|
/**
|
|
7068
7170
|
* Canonical PR-producing work contract helper.
|
|
7069
7171
|
*
|
|
@@ -7117,6 +7219,26 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
|
|
|
7117
7219
|
skipped = true;
|
|
7118
7220
|
return prs;
|
|
7119
7221
|
}
|
|
7222
|
+
// P-e9f0a2b4 — When findPrRecord returns null but multiple records share the
|
|
7223
|
+
// same prNumber (e.g. cross-scope contamination or a project-config change
|
|
7224
|
+
// that shifted canonical IDs), collapse the duplicates before inserting.
|
|
7225
|
+
// Without this check, every subsequent upsert would append yet another record
|
|
7226
|
+
// because numberMatches.length > 1 forces findPrRecord to return null.
|
|
7227
|
+
if (!target && prNumber != null) {
|
|
7228
|
+
const numberMatches = prs.filter(pr => pr && !pr.userDeleted && getPrNumber(pr) === prNumber);
|
|
7229
|
+
if (numberMatches.length > 1) {
|
|
7230
|
+
const best = _pickBestPrRecord(numberMatches);
|
|
7231
|
+
const toRemoveIds = new Set(numberMatches.filter(p => p !== best).map(p => p.id));
|
|
7232
|
+
for (const dup of numberMatches) {
|
|
7233
|
+
if (dup === best) continue;
|
|
7234
|
+
_mergeDuplicatePrInto(best, dup);
|
|
7235
|
+
}
|
|
7236
|
+
for (let i = prs.length - 1; i >= 0; i--) {
|
|
7237
|
+
if (prs[i] && toRemoveIds.has(prs[i].id)) prs.splice(i, 1);
|
|
7238
|
+
}
|
|
7239
|
+
target = best; // update the surviving record instead of inserting new
|
|
7240
|
+
}
|
|
7241
|
+
}
|
|
7120
7242
|
if (!target && typeof beforeInsert === 'function' && beforeInsert(prs, normalizedEntry) === false) {
|
|
7121
7243
|
skipped = true;
|
|
7122
7244
|
return prs;
|
|
@@ -7129,7 +7251,7 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
|
|
|
7129
7251
|
target.id = canonicalId;
|
|
7130
7252
|
if (prNumber != null) target.prNumber = prNumber;
|
|
7131
7253
|
const targetWasAutoManaged = isAutoManagedPrRecord(target);
|
|
7132
|
-
for (const key of ['url', 'title', 'description', 'agent', 'branch', 'reviewStatus', 'status', 'created', 'sourcePlan', 'itemType']) {
|
|
7254
|
+
for (const key of ['url', 'title', 'description', 'agent', 'branch', 'reviewStatus', 'status', 'created', 'sourcePlan', 'itemType', 'prdItemId']) {
|
|
7133
7255
|
if (normalizedEntry[key] != null && normalizedEntry[key] !== '' && (target[key] == null || target[key] === '')) {
|
|
7134
7256
|
target[key] = normalizedEntry[key];
|
|
7135
7257
|
}
|
|
@@ -8965,7 +9087,6 @@ module.exports = {
|
|
|
8965
9087
|
validateWorkspaceManifest,
|
|
8966
9088
|
resolveAgentManifest,
|
|
8967
9089
|
agentCanUseRepo,
|
|
8968
|
-
agentMemoryScope,
|
|
8969
9090
|
mergeManifestAllowedTools,
|
|
8970
9091
|
formatManifestRejection,
|
|
8971
9092
|
getProjects,
|
|
@@ -9017,6 +9138,7 @@ module.exports = {
|
|
|
9017
9138
|
mergePrLinkItems, // exported for testing
|
|
9018
9139
|
isContextOnlyPrRecord,
|
|
9019
9140
|
upsertPullRequestRecord,
|
|
9141
|
+
collapseDuplicatePrRecords, // P-e9f0a2b4 — one-time repair helper
|
|
9020
9142
|
isAutoManagedPrRecord, // W-mq5s5ttx000j7ab8-a — exported for engine + watch-plugin gate consolidation
|
|
9021
9143
|
autoEnrollPrFromWorkItem,
|
|
9022
9144
|
autoEnrollPrFromFixWorkItem,
|