@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.
@@ -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
- db.prepare('DELETE FROM prd_items WHERE prd_id=?').run(prdId);
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
- const insItem = db.prepare(`
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 features = Array.isArray(prd.missing_features) ? prd.missing_features : [];
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
- insItem.run(prdId, ft.id || null, ft.name || null, ft.project || prd.project || null,
103
- ft.status || null, ft.type || null, ft.workItemId || ft.work_item_id || null,
104
- JSON.stringify(ft), now);
105
- } catch { /* dup (prd_id, feature_id) → skip */ }
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 files
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
- // Scan active PRDs and archived PRDs (completed PRDs still need to show progress)
2149
- const planDirs = [
2150
- { dir: PRD_DIR, archived: false },
2151
- { dir: path.join(PRD_DIR, 'archive'), archived: true },
2152
- ];
2153
-
2154
- // Phase 10 step 3 (read-flip) — both the disk scan and the SQL-mirror source
2155
- // funnel each PRD through this one processor, so the consumed shape
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
- let useSqlReads = false;
2203
- try { useSqlReads = require('./features').isFeatureOn('prdReadsFromSql', config); } catch { /* fall back to disk */ }
2204
-
2205
- if (useSqlReads) {
2206
- // SQL source: reconcile the mirror from disk (catches PRDs written outside
2207
- // the dual-write chokepoint — plan-to-prd agent direct writes, archive
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 prdStore = require('./prd-store');
2212
- prdStore.reconcilePrdsFromDisk();
2213
- for (const { filename, archived, plan } of prdStore.listPrdRows()) {
2214
- try {
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
- } catch {
2226
- // Hard SQL failure fall back to the disk scan so the dashboard never
2227
- // goes blank on a transient DB problem.
2228
- useSqlReads = false;
2229
- }
2220
+ prdItemIdByKey = map;
2221
+ } catch { prdItemIdByKey = null; }
2230
2222
  }
2231
2223
 
2232
- if (!useSqlReads) {
2233
- for (const { dir, archived } of planDirs) {
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 planFiles = fs.readdirSync(dir).filter(f => f.endsWith('.json')).sort();
2236
- for (const pf of planFiles) {
2237
- try {
2238
- const filePath = path.join(dir, pf);
2239
- const stat = fs.statSync(filePath);
2240
- if (!latestStat || stat.mtimeMs > latestStat.mtimeMs) latestStat = stat;
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
- const wi = wiById[item.id];
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) — archived PRDs under prd/archive/ are
1009
- // permanently removed terminal artifacts. They must NOT auto-restore from a
1010
- // stale `.backup` sidecar (W-mouptdh1000h9f39: archived PRD came back and
1011
- // re-dispatched work). Intentionally NARROW to prd/archive/ only:
1012
- // - prd/archive/*.json no backup write + no safeJson restore
1013
- // - prd/*.json (root-level canonical) → normal backup/restore lifecycle
1014
- // preserved so concurrent-sweep loss is recoverable (prd-rename-race.test.js).
1015
- const _NO_BACKUP_JSON_RE = /(?:^|[\\/])prd[\\/]archive[\\/][^\\/]+\.json$/i;
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
- // Resolve the effective mode: canonical field first, then legacy worktreeMode fallback.
2679
- let effectiveMode;
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 (effectiveMode === CHECKOUT_MODES.LIVE) {
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 !== '') return false;
3643
- if (!(e.ccModel !== undefined && e.ccModel !== null && e.ccModel !== '')) return false;
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,