@yemi33/minions 0.1.2355 → 0.1.2357
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/render-pipelines.js +12 -3
- package/dashboard/js/render-prs.js +55 -1
- package/dashboard/js/settings.js +30 -10
- package/dashboard.js +102 -2
- package/docs/README.md +1 -0
- package/docs/live-checkout-mode.md +6 -0
- package/docs/worktree-lifecycle.md +77 -1
- package/engine/ado.js +1 -1
- package/engine/cleanup.js +4 -4
- package/engine/db/migrations/016-pr-mirror-hash.js +31 -0
- package/engine/lifecycle.js +93 -5
- package/engine/live-checkout.js +141 -0
- package/engine/pipeline.js +87 -10
- package/engine/playbook.js +6 -5
- package/engine/pull-requests-store.js +70 -9
- package/engine/queries.js +44 -29
- package/engine/shared.js +208 -3
- package/engine.js +519 -87
- package/package.json +1 -1
package/engine/lifecycle.js
CHANGED
|
@@ -1122,6 +1122,13 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
|
|
|
1122
1122
|
const projScope = shared.getProjectPrScope(p);
|
|
1123
1123
|
if (projScope && projScope === urlScope) return p;
|
|
1124
1124
|
}
|
|
1125
|
+
// W-mrchrfe100067d3a — a PR scoped to one of a project's allowlisted
|
|
1126
|
+
// additionalRepoScopes (e.g. a backport PR opened against
|
|
1127
|
+
// yemi33/minions from the minions-opg checkout) belongs to that
|
|
1128
|
+
// project too, even though it isn't the project's primary scope.
|
|
1129
|
+
for (const p of projects) {
|
|
1130
|
+
if (shared.getProjectAdditionalPrScopes(p).includes(urlScope)) return p;
|
|
1131
|
+
}
|
|
1125
1132
|
}
|
|
1126
1133
|
const evidenceText = `${outputText}\n${evidenceUrl}`;
|
|
1127
1134
|
for (const p of projects) {
|
|
@@ -3047,6 +3054,71 @@ function clearBuildFixIneffective(target) {
|
|
|
3047
3054
|
return true;
|
|
3048
3055
|
}
|
|
3049
3056
|
|
|
3057
|
+
// #764 — PR-level circuit breaker for the WORKTREE_PREFLIGHT
|
|
3058
|
+
// branch-held-externally failure class (engine.js `_failBranchHeldByExternalWorktree`).
|
|
3059
|
+
// That failure already stamps the WI `agentRetryable: false`, which stops the
|
|
3060
|
+
// immediate quarantine-recovery-loop storm for that one dispatch instance, but
|
|
3061
|
+
// it carries no memory at the PR level — the human-feedback / review / build /
|
|
3062
|
+
// conflict fix dispatch pollers see the PR is still `pendingFix` (or has
|
|
3063
|
+
// coalesced feedback) and re-dispatch a brand-new work item next cooldown
|
|
3064
|
+
// window, forever, until a human manually releases the branch. Mirrors the
|
|
3065
|
+
// `_buildFixIneffective` shape (see `recordBuildFixIneffective` above) so the
|
|
3066
|
+
// dashboard/pause-inspection conventions stay consistent, but is keyed on the
|
|
3067
|
+
// holder path + branch rather than a head SHA — the signal that should clear
|
|
3068
|
+
// this pause is "the holder no longer holds the branch", not "a new commit
|
|
3069
|
+
// landed" (see `engine.js#isWorktreeHeldPauseSuppressed`, which performs that
|
|
3070
|
+
// live re-check and calls `clearWorktreeHeldPause` when the holder has moved
|
|
3071
|
+
// on).
|
|
3072
|
+
function recordWorktreeHeldPause(target, { holderPath, isProjectRoot, branch, dispatchItem } = {}) {
|
|
3073
|
+
if (!target) return null;
|
|
3074
|
+
const now = ts();
|
|
3075
|
+
const prior = target._worktreeHeldPaused && typeof target._worktreeHeldPaused === 'object'
|
|
3076
|
+
? target._worktreeHeldPaused : null;
|
|
3077
|
+
target._worktreeHeldPaused = {
|
|
3078
|
+
holderPath: holderPath || null,
|
|
3079
|
+
isProjectRoot: !!isProjectRoot,
|
|
3080
|
+
branch: branch || null,
|
|
3081
|
+
since: prior?.since || now,
|
|
3082
|
+
lastAt: now,
|
|
3083
|
+
dispatchId: dispatchItem?.id || null,
|
|
3084
|
+
reason: isProjectRoot
|
|
3085
|
+
? `Branch ${branch || '(unknown)'} is held by the project root (${holderPath}). PR-driven review/fix automation is paused until the root releases the branch.`
|
|
3086
|
+
: `Branch ${branch || '(unknown)'} is held by an external worktree at ${holderPath} the engine can't reach. PR-driven review/fix automation is paused until that worktree releases the branch.`,
|
|
3087
|
+
};
|
|
3088
|
+
if (!prior) {
|
|
3089
|
+
try {
|
|
3090
|
+
const prNumber = target.prNumber || shared.getPrNumber(target) || target.id || 'unknown';
|
|
3091
|
+
const noteBody = `# Worktree-held pause — PR automation stopped: ${target.id || prNumber}\n\n`
|
|
3092
|
+
+ `**PR:** ${target.url || target.id || '(unknown)'}\n`
|
|
3093
|
+
+ `**Branch:** ${branch || target.branch || '(unknown)'}\n`
|
|
3094
|
+
+ `**Holder:** ${holderPath || '(unknown)'}${isProjectRoot ? ' (project root)' : ' (external worktree)'}\n\n`
|
|
3095
|
+
+ `A WORKTREE_PREFLIGHT dispatch failed because this branch is checked out ${isProjectRoot ? 'at the project root' : 'in an external worktree'} the engine can't reach. `
|
|
3096
|
+
+ `Review/human-feedback/build-fix/merge-conflict automation for this PR is now paused so the poller stops re-dispatching against the same unresolved cause every cooldown window (issue #764).\n\n`
|
|
3097
|
+
+ `**Recovery:** ${isProjectRoot ? `\`git -C ${holderPath} checkout <mainBranch>\` to release the branch` : 'finish or remove the external worktree holding this branch'} — the engine auto-clears this pause the next time it detects the holder no longer holds the branch.\n`;
|
|
3098
|
+
shared.writeToInbox(
|
|
3099
|
+
'engine',
|
|
3100
|
+
`worktree-held-paused-${prNumber}`,
|
|
3101
|
+
noteBody,
|
|
3102
|
+
null,
|
|
3103
|
+
{ wi: dispatchItem?.meta?.item?.id || null, pr: target.id || null },
|
|
3104
|
+
);
|
|
3105
|
+
} catch (err) {
|
|
3106
|
+
log('warn', `worktree-held-paused inbox alert for ${target.id || '(unknown)'}: ${err.message}`);
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
return target._worktreeHeldPaused;
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
// Clears the `_worktreeHeldPaused` circuit breaker (issue #764) so PR-driven
|
|
3113
|
+
// review/fix dispatch can resume. Returns true when a record was actually
|
|
3114
|
+
// removed so callers (the dashboard resume endpoint, engine.js's live
|
|
3115
|
+
// re-check) can distinguish "cleared" from "nothing to clear".
|
|
3116
|
+
function clearWorktreeHeldPause(target) {
|
|
3117
|
+
if (!target?._worktreeHeldPaused) return false;
|
|
3118
|
+
delete target._worktreeHeldPaused;
|
|
3119
|
+
return true;
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3050
3122
|
function clearPrNoOpFixAttempt(target, cause) {
|
|
3051
3123
|
if (!target?._noOpFixes || !target._noOpFixes[cause]) return;
|
|
3052
3124
|
delete target._noOpFixes[cause];
|
|
@@ -6611,16 +6683,25 @@ function pruneScopeMismatchDuplicatePrs(config) {
|
|
|
6611
6683
|
const projects = shared.getProjects(config) || [];
|
|
6612
6684
|
if (projects.length === 0) return { pruned: 0, relocated: 0, scanned: 0 };
|
|
6613
6685
|
|
|
6614
|
-
// Map project name -> canonical scope so we can find which project IS
|
|
6615
|
-
// correctly-scoped owner for a given mismatch record.
|
|
6686
|
+
// Map project name -> canonical scope(s) so we can find which project IS
|
|
6687
|
+
// the correctly-scoped owner for a given mismatch record. Includes
|
|
6688
|
+
// additionalRepoScopes (W-mrchrfe100067d3a) so a stale `_invalidProjectScope`
|
|
6689
|
+
// stamp left over from before a project opted into a secondary scope (or
|
|
6690
|
+
// any record from another host that hasn't been re-normalized yet) maps
|
|
6691
|
+
// back to the project that now allowlists it, instead of relocating to a
|
|
6692
|
+
// nonexistent project or getting stuck unresolved.
|
|
6616
6693
|
const scopeByProject = new Map();
|
|
6617
6694
|
const projectByScope = new Map();
|
|
6618
6695
|
for (const p of projects) {
|
|
6619
6696
|
if (!p || !p.name) continue;
|
|
6620
6697
|
const sc = shared.getProjectPrScope(p);
|
|
6621
|
-
if (
|
|
6622
|
-
|
|
6623
|
-
|
|
6698
|
+
if (sc) {
|
|
6699
|
+
scopeByProject.set(p.name, sc);
|
|
6700
|
+
if (!projectByScope.has(sc)) projectByScope.set(sc, p);
|
|
6701
|
+
}
|
|
6702
|
+
for (const additionalScope of shared.getProjectAdditionalPrScopes(p)) {
|
|
6703
|
+
if (!projectByScope.has(additionalScope)) projectByScope.set(additionalScope, p);
|
|
6704
|
+
}
|
|
6624
6705
|
}
|
|
6625
6706
|
|
|
6626
6707
|
// Index all PRs by canonical id across all projects (post-_scope decoration).
|
|
@@ -6995,6 +7076,13 @@ module.exports = {
|
|
|
6995
7076
|
// Issue #671 — exported so dashboard.js can clear the build-fix-ineffective
|
|
6996
7077
|
// ("infra issue suspected") pause from the resume endpoint.
|
|
6997
7078
|
clearBuildFixIneffective,
|
|
7079
|
+
// Issue #764 — PR-level circuit breaker for WORKTREE_PREFLIGHT
|
|
7080
|
+
// branch-held-externally failures. Exported so engine.js can stamp the
|
|
7081
|
+
// pause at dispatch-failure time and auto-clear it via the live re-check,
|
|
7082
|
+
// and so dashboard.js can expose a manual clear endpoint + unit tests can
|
|
7083
|
+
// exercise both paths directly.
|
|
7084
|
+
recordWorktreeHeldPause,
|
|
7085
|
+
clearWorktreeHeldPause,
|
|
6998
7086
|
// W-mpx44p05000ze8d8 — exported so engine/ado.js + engine/github.js can
|
|
6999
7087
|
// drop stale `_noOpFixes` records during their PR status poll, and so
|
|
7000
7088
|
// unit tests can exercise the sweep helper directly.
|
package/engine/live-checkout.js
CHANGED
|
@@ -128,6 +128,21 @@
|
|
|
128
128
|
* (signature `(absPath:string) => boolean`, defaulting to fs.existsSync).
|
|
129
129
|
* Production callers omit both and the helper falls back to
|
|
130
130
|
* `require('./shared').shellSafeGit` and `fs.existsSync` respectively.
|
|
131
|
+
*
|
|
132
|
+
* `checkLiveCheckoutDirty` / `checkLiveCheckoutStaleBase` (W-mrcifup2000c218a):
|
|
133
|
+
* ALLOCATION-TIME pre-dispatch counterparts to the dirty / stale-base checks
|
|
134
|
+
* above. Both are cheap, read-only, non-mutating probes engine.js calls
|
|
135
|
+
* BEFORE a live-mode work item is dispatched (in the same allocation loop
|
|
136
|
+
* that already gates on `live_checkout_busy`), so a dirty tree or a
|
|
137
|
+
* contaminated local base leaves the item pending
|
|
138
|
+
* (`_pendingReason: 'live_checkout_dirty'` / `'live_checkout_stale_base'`)
|
|
139
|
+
* instead of burning a retry (dirty, pre-fix behavior) or failing
|
|
140
|
+
* non-retryably on the first hit (stale-base, pre-fix behavior). The
|
|
141
|
+
* in-spawn guards documented above (inside `prepareLiveCheckout`, surfaced by
|
|
142
|
+
* spawnAgent) remain in place as a defense-in-depth fallback for the race
|
|
143
|
+
* window between this probe and the actual git operations inside spawnAgent
|
|
144
|
+
* — they should now fire rarely, as the exception path rather than the
|
|
145
|
+
* common path.
|
|
131
146
|
*/
|
|
132
147
|
|
|
133
148
|
'use strict';
|
|
@@ -1205,6 +1220,130 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
1205
1220
|
return { ok: true, branch: branchName, created: true, originalRef, originalRefType };
|
|
1206
1221
|
}
|
|
1207
1222
|
|
|
1223
|
+
/**
|
|
1224
|
+
* checkLiveCheckoutDirty — W-mrcifup2000c218a
|
|
1225
|
+
*
|
|
1226
|
+
* Cheap, read-only, NON-MUTATING pre-dispatch probe for a dirty live-checkout
|
|
1227
|
+
* working tree. Mirrors prepareLiveCheckout's Step 1 dirty check (same
|
|
1228
|
+
* `git status --porcelain=v1 -b` command, same porcelain parsing, same
|
|
1229
|
+
* auto-cleanable-artifact allowance) but never deletes anything and never
|
|
1230
|
+
* throws on a git failure — callers use this at ALLOCATION time (before a WI
|
|
1231
|
+
* is dispatched) to decide whether to leave the item pending
|
|
1232
|
+
* (`_pendingReason: 'live_checkout_dirty'`) instead of spawning it only to
|
|
1233
|
+
* have prepareLiveCheckout refuse it after the fact.
|
|
1234
|
+
*
|
|
1235
|
+
* Intentionally permissive on ambiguity: if the `git status` probe itself
|
|
1236
|
+
* fails (transient error), or every dirty entry is an auto-cleanable build
|
|
1237
|
+
* artifact (which prepareLiveCheckout's spawn-time auto-clean step will
|
|
1238
|
+
* delete before it ever re-checks dirtiness), this reports `dirty:false` so
|
|
1239
|
+
* allocation does not block on a condition that resolves itself inside the
|
|
1240
|
+
* spawn path. The in-spawn guard (engine.js ~line 2617) remains the
|
|
1241
|
+
* authoritative check for the race window between this probe and the actual
|
|
1242
|
+
* git operations inside spawnAgent.
|
|
1243
|
+
*
|
|
1244
|
+
* @param {object} opts
|
|
1245
|
+
* @param {string} opts.localPath operator checkout root (git cwd)
|
|
1246
|
+
* @param {object} [opts.gitOpts] extra execFile opts merged into cwd
|
|
1247
|
+
* @param {boolean} [opts.skipDirtyCheck] #522: opt out entirely (GVFS repos)
|
|
1248
|
+
* @param {function} [opts._git] test seam; defaults to shellSafeGit
|
|
1249
|
+
* @returns {Promise<{dirty:boolean, dirtyFiles?:string[], branchInfo?:string, autoCleanable?:boolean, error?:string}>}
|
|
1250
|
+
*/
|
|
1251
|
+
async function checkLiveCheckoutDirty(opts = {}) {
|
|
1252
|
+
const { localPath, gitOpts, skipDirtyCheck, _git } = opts;
|
|
1253
|
+
if (skipDirtyCheck) return { dirty: false };
|
|
1254
|
+
if (!localPath || typeof localPath !== 'string') return { dirty: false };
|
|
1255
|
+
const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
|
|
1256
|
+
const baseOpts = { cwd: localPath, maxBuffer: LIVE_CHECKOUT_GIT_MAX_BUFFER, ...(gitOpts || {}) };
|
|
1257
|
+
let statusRaw;
|
|
1258
|
+
try {
|
|
1259
|
+
statusRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
|
|
1260
|
+
} catch (e) {
|
|
1261
|
+
// A probe failure is not proof of a dirty tree — don't block allocation
|
|
1262
|
+
// on it. spawnAgent's own dirty check (with its LIVE_CHECKOUT_FAILED
|
|
1263
|
+
// classification) is the authoritative fallback for a real git failure.
|
|
1264
|
+
return { dirty: false, error: e.message };
|
|
1265
|
+
}
|
|
1266
|
+
const lines = (typeof statusRaw === 'string' ? statusRaw : '')
|
|
1267
|
+
.split(/\r?\n/)
|
|
1268
|
+
.map((line) => line.replace(/\s+$/, ''))
|
|
1269
|
+
.filter((line) => line.length > 0);
|
|
1270
|
+
const branchInfo = lines.find((line) => line.startsWith('## ')) || '';
|
|
1271
|
+
const dirtyFiles = lines.filter((line) => !line.startsWith('## '));
|
|
1272
|
+
if (dirtyFiles.length === 0) return { dirty: false, branchInfo };
|
|
1273
|
+
const cleanablePaths = _collectAutoCleanablePaths(dirtyFiles);
|
|
1274
|
+
if (cleanablePaths) return { dirty: false, branchInfo, autoCleanable: true };
|
|
1275
|
+
return { dirty: true, dirtyFiles, branchInfo };
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* checkLiveCheckoutStaleBase — W-mrcifup2000c218a
|
|
1280
|
+
*
|
|
1281
|
+
* Cheap, read-only, NON-MUTATING, fetch-free pre-dispatch probe for the
|
|
1282
|
+
* STALE-BASE GUARD condition (W-mr98op8w000ma4ad). Mirrors prepareLiveCheckout's
|
|
1283
|
+
* Step 4d divergence check (same `git rev-list --count origin/<mainRef>..<mainRef>`
|
|
1284
|
+
* comparison) so allocation-time callers can detect a contaminated local base
|
|
1285
|
+
* BEFORE dispatching, instead of burning a non-retryable terminal failure after
|
|
1286
|
+
* the fact. Never fetches, never resets, never mutates anything.
|
|
1287
|
+
*
|
|
1288
|
+
* Scope mirrors prepareLiveCheckout exactly: only relevant when a NEW branch
|
|
1289
|
+
* would be forked (an existing local `branchName` checkout is unaffected by
|
|
1290
|
+
* this class of contamination), when HEAD sits on `mainRef` itself, and when
|
|
1291
|
+
* `origin/<mainRef>` is present locally to compare against. `allowNonMainBase`
|
|
1292
|
+
* (PR-targeted / shared-branch continuations) short-circuits to `stale:false`
|
|
1293
|
+
* — those dispatches intentionally build on a non-main branch and opt out of
|
|
1294
|
+
* this guard, same as prepareLiveCheckout.
|
|
1295
|
+
*
|
|
1296
|
+
* @param {object} opts
|
|
1297
|
+
* @param {string} opts.localPath operator checkout root (git cwd)
|
|
1298
|
+
* @param {string} opts.mainRef project base branch name
|
|
1299
|
+
* @param {object} [opts.gitOpts] extra execFile opts merged into cwd
|
|
1300
|
+
* @param {boolean} [opts.allowNonMainBase] opt out (PR-targeted/shared-branch)
|
|
1301
|
+
* @param {function} [opts._git] test seam; defaults to shellSafeGit
|
|
1302
|
+
* @returns {Promise<{stale:boolean, ahead?:number, mainRef?:string, localMainSha?:string, originMainSha?:string}>}
|
|
1303
|
+
*/
|
|
1304
|
+
async function checkLiveCheckoutStaleBase(opts = {}) {
|
|
1305
|
+
const { localPath, mainRef, gitOpts, allowNonMainBase, _git } = opts;
|
|
1306
|
+
if (allowNonMainBase) return { stale: false };
|
|
1307
|
+
if (!localPath || typeof localPath !== 'string') return { stale: false };
|
|
1308
|
+
if (!mainRef || typeof mainRef !== 'string') return { stale: false };
|
|
1309
|
+
const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
|
|
1310
|
+
const baseOpts = { cwd: localPath, maxBuffer: LIVE_CHECKOUT_GIT_MAX_BUFFER, ...(gitOpts || {}) };
|
|
1311
|
+
let curBranch = '';
|
|
1312
|
+
try {
|
|
1313
|
+
const raw = await git(['symbolic-ref', '-q', '--short', 'HEAD'], baseOpts);
|
|
1314
|
+
curBranch = (typeof raw === 'string' ? raw : '').trim();
|
|
1315
|
+
} catch {
|
|
1316
|
+
// Detached HEAD or probe error — out of scope for this guard (the
|
|
1317
|
+
// mid-operation/detached-HEAD preflight is a separate concern, still
|
|
1318
|
+
// handled in-spawn only).
|
|
1319
|
+
return { stale: false };
|
|
1320
|
+
}
|
|
1321
|
+
if (!curBranch || curBranch !== mainRef) return { stale: false };
|
|
1322
|
+
let originMainSha = '';
|
|
1323
|
+
try {
|
|
1324
|
+
const originRaw = await git(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${mainRef}`], baseOpts);
|
|
1325
|
+
originMainSha = (typeof originRaw === 'string' ? originRaw : '').trim();
|
|
1326
|
+
} catch {
|
|
1327
|
+
return { stale: false }; // no remote-tracking ref → ambiguous, skip (local-only/no-remote repo)
|
|
1328
|
+
}
|
|
1329
|
+
if (!originMainSha) return { stale: false };
|
|
1330
|
+
let aheadCount = -1;
|
|
1331
|
+
try {
|
|
1332
|
+
const cntRaw = await git(['rev-list', '--count', `refs/remotes/origin/${mainRef}..${mainRef}`], baseOpts);
|
|
1333
|
+
aheadCount = parseInt((typeof cntRaw === 'string' ? cntRaw : '').trim(), 10);
|
|
1334
|
+
if (Number.isNaN(aheadCount)) aheadCount = -1;
|
|
1335
|
+
} catch {
|
|
1336
|
+
aheadCount = -1; // rev-list failed → transient/ambiguous; don't block on it
|
|
1337
|
+
}
|
|
1338
|
+
if (aheadCount <= 0) return { stale: false };
|
|
1339
|
+
let localMainSha = '';
|
|
1340
|
+
try {
|
|
1341
|
+
const localRaw = await git(['rev-parse', '--verify', '--quiet', `refs/heads/${mainRef}`], baseOpts);
|
|
1342
|
+
localMainSha = (typeof localRaw === 'string' ? localRaw : '').trim();
|
|
1343
|
+
} catch { /* best-effort — informational only */ }
|
|
1344
|
+
return { stale: true, ahead: aheadCount, mainRef, localMainSha, originMainSha };
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1208
1347
|
/**
|
|
1209
1348
|
* restoreLiveCheckoutAtDispatchEnd — P-d9e6b2c4
|
|
1210
1349
|
*
|
|
@@ -1723,6 +1862,8 @@ async function maybeRestoreLiveCheckoutFromRecord(opts = {}) {
|
|
|
1723
1862
|
|
|
1724
1863
|
module.exports = {
|
|
1725
1864
|
prepareLiveCheckout,
|
|
1865
|
+
checkLiveCheckoutDirty,
|
|
1866
|
+
checkLiveCheckoutStaleBase,
|
|
1726
1867
|
restoreLiveCheckoutAtDispatchEnd,
|
|
1727
1868
|
resolveLiveCheckoutAutoStash,
|
|
1728
1869
|
performLiveCheckoutAutoStash,
|
package/engine/pipeline.js
CHANGED
|
@@ -8,7 +8,7 @@ const fs = require('fs');
|
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const shared = require('./shared');
|
|
10
10
|
const queries = require('./queries');
|
|
11
|
-
const { safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
|
|
11
|
+
const { safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR, isPrdArchived } = shared;
|
|
12
12
|
const routing = require('./routing');
|
|
13
13
|
const http = require('http');
|
|
14
14
|
const { shouldRunNow } = require('./scheduler');
|
|
@@ -501,11 +501,16 @@ function _findMeetingsInRun(run) {
|
|
|
501
501
|
return meetings;
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
-
// Check if a plan already exists for a given meeting (created manually via
|
|
505
|
-
|
|
504
|
+
// Check if a plan already exists for a given meeting (created manually via
|
|
505
|
+
// dashboard, or previously by this same pipeline stage). `pipelineSlugPrefix`
|
|
506
|
+
// (optional) is the slug a pipeline plan stage writes its files under
|
|
507
|
+
// (`slugify(stage.title)`) — matching on it catches pre-existing pipeline
|
|
508
|
+
// plans that predate the `**Source Meeting:**` marker below.
|
|
509
|
+
function _findExistingPlanForMeeting(meetingIds, plansDir, pipelineSlugPrefix) {
|
|
506
510
|
const files = safeReadDir(plansDir).filter(f => f.endsWith('.md'));
|
|
507
|
-
// Build slug prefixes for
|
|
511
|
+
// Build slug prefixes for dashboard, and (optionally) pipeline naming conventions
|
|
508
512
|
const slugPrefixes = [];
|
|
513
|
+
if (pipelineSlugPrefix) slugPrefixes.push(pipelineSlugPrefix);
|
|
509
514
|
for (const mid of meetingIds) {
|
|
510
515
|
const mtg = safeJson(path.join(MEETINGS_DIR, mid + '.json'));
|
|
511
516
|
if (mtg?.title) {
|
|
@@ -537,8 +542,26 @@ function _canonicalPlanName(planFile) {
|
|
|
537
542
|
return String(planFile || '').replace(/^(.*-\d{4}-\d{2}-\d{2})-\d+(\.md)$/i, '$1$2');
|
|
538
543
|
}
|
|
539
544
|
|
|
545
|
+
// PRD top-level statuses that indicate a genuine, materialized plan-to-prd
|
|
546
|
+
// conversion (mirrors PLAN_STATUS — a PRD's status field is drawn from the
|
|
547
|
+
// same enum as a plan's). 'archived' is intentionally NOT in PLAN_STATUS (it's
|
|
548
|
+
// an in-place-archive-only status stamped by dashboard.js's _archivePrdPostProcess,
|
|
549
|
+
// not a plan lifecycle state) but is a real status a converted-then-archived PRD
|
|
550
|
+
// can carry — checked separately below via isPrdArchived so an already-archived
|
|
551
|
+
// conversion isn't mistaken for a stub and re-triggered.
|
|
552
|
+
const _VALID_EXISTING_PRD_STATUSES = new Set(Object.values(PLAN_STATUS));
|
|
553
|
+
|
|
540
554
|
// Check if a PRD already exists for a given plan file (plan already converted).
|
|
541
555
|
// Matches by canonical name so a collision-bumped plan still finds its PRD.
|
|
556
|
+
//
|
|
557
|
+
// A matching `source_plan` alone is not proof of a successful conversion — a
|
|
558
|
+
// crashed/interrupted plan-to-prd write, or a fragment-looking LLM response
|
|
559
|
+
// (the same garbage-content failure mode fixed for plan stages in PR #750),
|
|
560
|
+
// can leave a stub PRD JSON with `source_plan` set but no recognized `status`
|
|
561
|
+
// and no `missing_features`. Treating that stub as "already converted" would
|
|
562
|
+
// permanently skip re-running plan-to-prd for a plan that never actually got
|
|
563
|
+
// a usable PRD. Require both a recognized top-level status AND an array
|
|
564
|
+
// `missing_features` field before counting the PRD as an existing conversion.
|
|
542
565
|
function _findExistingPrdForPlan(planFile, prdDir) {
|
|
543
566
|
if (!fs.existsSync(prdDir)) return null;
|
|
544
567
|
const prdFiles = safeReadDir(prdDir).filter(f => f.endsWith('.json'));
|
|
@@ -547,11 +570,28 @@ function _findExistingPrdForPlan(planFile, prdDir) {
|
|
|
547
570
|
// safeJsonNoRestore: PRDs are terminal artifacts — never restore archived
|
|
548
571
|
// PRDs from a stale .backup sidecar (W-mouptdh1000h9f39).
|
|
549
572
|
const prd = safeJsonNoRestore(path.join(prdDir, pf));
|
|
550
|
-
if (prd?.source_plan
|
|
573
|
+
if (!prd?.source_plan || _canonicalPlanName(path.basename(String(prd.source_plan))) !== planKey) continue;
|
|
574
|
+
if (!(_VALID_EXISTING_PRD_STATUSES.has(prd.status) || isPrdArchived(prd)) || !Array.isArray(prd.missing_features)) {
|
|
575
|
+
log('warn', `Pipeline plan-to-prd: found PRD "${pf}" for plan "${planFile}" but it lacks a valid status/missing_features (garbage/incomplete conversion) — treating plan-to-prd as not yet done`);
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
return pf;
|
|
551
579
|
}
|
|
552
580
|
return null;
|
|
553
581
|
}
|
|
554
582
|
|
|
583
|
+
// A well-formed plan (LLM path or fallback path) always starts with a markdown
|
|
584
|
+
// title and has substantive body content. Guards against `maxTurns: 1` LLM
|
|
585
|
+
// calls that spend their single turn on tool-oriented preamble/narration
|
|
586
|
+
// instead of the plan itself (W-mrbn0eex000265d7) — that kind of response is a
|
|
587
|
+
// short mid-task fragment, not a plan, and must not be written verbatim.
|
|
588
|
+
function _looksLikePlanContent(content) {
|
|
589
|
+
if (!content) return false;
|
|
590
|
+
const trimmed = content.trim();
|
|
591
|
+
if (trimmed.length < 200) return false;
|
|
592
|
+
return trimmed.startsWith('#');
|
|
593
|
+
}
|
|
594
|
+
|
|
555
595
|
async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
|
|
556
596
|
const plansDir = PLANS_DIR;
|
|
557
597
|
if (!fs.existsSync(plansDir)) fs.mkdirSync(plansDir, { recursive: true });
|
|
@@ -568,7 +608,7 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
|
|
|
568
608
|
// ── Reconciliation: check if a plan already exists for a meeting in this run ──
|
|
569
609
|
const meetingIds = _findMeetingsInRun(run);
|
|
570
610
|
if (meetingIds.length > 0) {
|
|
571
|
-
const existingPlanFile = _findExistingPlanForMeeting(meetingIds, plansDir);
|
|
611
|
+
const existingPlanFile = _findExistingPlanForMeeting(meetingIds, plansDir, slug);
|
|
572
612
|
if (existingPlanFile) {
|
|
573
613
|
log('info', `Pipeline ${run.pipelineId}: reconciling plan stage — adopting existing plan "${existingPlanFile}"`);
|
|
574
614
|
|
|
@@ -663,9 +703,11 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
|
|
|
663
703
|
});
|
|
664
704
|
if (result.missingRuntime) {
|
|
665
705
|
log('warn', `Pipeline plan LLM skipped: ${result.text || result.stderr || 'runtime unavailable'} — falling back to raw meeting context`);
|
|
666
|
-
} else if (result.text) {
|
|
706
|
+
} else if (result.text && _looksLikePlanContent(result.text)) {
|
|
667
707
|
content = result.text;
|
|
668
708
|
log('info', `Pipeline plan: LLM generated ${content.length} chars from meeting context`);
|
|
709
|
+
} else if (result.text) {
|
|
710
|
+
log('warn', `Pipeline plan LLM response looked like a fragment, not a plan (${result.text.length} chars, doesn't start with a title) — discarding and falling back to raw meeting context`);
|
|
669
711
|
}
|
|
670
712
|
} catch (e) { log('warn', `Pipeline plan LLM failed: ${e.message} — falling back to raw meeting context`); }
|
|
671
713
|
|
|
@@ -678,6 +720,14 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
|
|
|
678
720
|
if (stage.description) content += stage.description + '\n';
|
|
679
721
|
}
|
|
680
722
|
|
|
723
|
+
// Stamp every source meeting so a future/duplicate invocation for this run
|
|
724
|
+
// can find this plan via `_findExistingPlanForMeeting`'s content scan — the
|
|
725
|
+
// reconciliation guard above only matches on this literal marker or a slug
|
|
726
|
+
// prefix, and generated content (LLM or fallback) never included it before.
|
|
727
|
+
if (meetingIds.length > 0) {
|
|
728
|
+
content += '\n\n---\n\n' + meetingIds.map(mid => `**Source Meeting:** ${mid}`).join('\n') + '\n';
|
|
729
|
+
}
|
|
730
|
+
|
|
681
731
|
const filename = `${slug}-${dateStamp()}.md`;
|
|
682
732
|
const filePath = shared.uniquePath(path.join(plansDir, filename));
|
|
683
733
|
safeWrite(filePath, content);
|
|
@@ -998,7 +1048,7 @@ function isStageComplete(stage, stageState, run, config) {
|
|
|
998
1048
|
if (prIds.length === 0) return true; // nothing to merge
|
|
999
1049
|
const projects = shared.getProjects(config);
|
|
1000
1050
|
for (const project of projects) {
|
|
1001
|
-
const prs =
|
|
1051
|
+
const prs = safeJsonArr(shared.projectPrPath(project));
|
|
1002
1052
|
for (const prId of prIds) {
|
|
1003
1053
|
const pr = shared.findPrRecord(prs, prId, project);
|
|
1004
1054
|
if (pr && pr.status !== PR_STATUS.MERGED && pr.status !== PR_STATUS.ABANDONED) return false;
|
|
@@ -1150,7 +1200,7 @@ async function discoverPipelineWork(config) {
|
|
|
1150
1200
|
if (meetingIds.length > 0) {
|
|
1151
1201
|
const plansDir = PLANS_DIR;
|
|
1152
1202
|
if (fs.existsSync(plansDir)) {
|
|
1153
|
-
const existingPlan = _findExistingPlanForMeeting(meetingIds, plansDir);
|
|
1203
|
+
const existingPlan = _findExistingPlanForMeeting(meetingIds, plansDir, slugify(nextPlanStage.title || 'pipeline-plan'));
|
|
1154
1204
|
if (existingPlan) {
|
|
1155
1205
|
updateRunStage(pipeline.id, activeRun.runId, stage.id, {
|
|
1156
1206
|
status: PIPELINE_STATUS.COMPLETED, completedAt: ts(),
|
|
@@ -1184,7 +1234,34 @@ async function discoverPipelineWork(config) {
|
|
|
1184
1234
|
stageState.status = PIPELINE_STATUS.FAILED;
|
|
1185
1235
|
anyFailed = true;
|
|
1186
1236
|
} else if (depsReady) {
|
|
1187
|
-
|
|
1237
|
+
// W-mrbn0eex000265d7: persist RUNNING BEFORE the (potentially slow,
|
|
1238
|
+
// up-to-120s LLM-backed) executeStage call resolves. Plan stages in
|
|
1239
|
+
// particular kick off an LLM call; the stage's persisted status
|
|
1240
|
+
// previously stayed PENDING for the whole call, so a concurrent or
|
|
1241
|
+
// retried invocation of discoverPipelineWork (overlapping tick, or
|
|
1242
|
+
// resumed run after an engine restart mid-await) would see PENDING
|
|
1243
|
+
// again and re-execute the stage, writing a second duplicate plan
|
|
1244
|
+
// file. Marking RUNNING first means a re-entrant call takes the
|
|
1245
|
+
// RUNNING branch above (isStageComplete) instead of re-executing.
|
|
1246
|
+
updateRunStage(pipeline.id, activeRun.runId, stage.id, { status: PIPELINE_STATUS.RUNNING, startedAt: ts() });
|
|
1247
|
+
stageState.status = PIPELINE_STATUS.RUNNING;
|
|
1248
|
+
// W-mrbn0eex000265d7 (review follow-up): the RUNNING status above is
|
|
1249
|
+
// already persisted, so if executeStage throws instead of resolving
|
|
1250
|
+
// (e.g. a lock-timeout in mutateWorkItems, an fs error), an uncaught
|
|
1251
|
+
// rejection would propagate out of discoverPipelineWork to the
|
|
1252
|
+
// engine.js tick-phase catch, which only logs a warning — leaving
|
|
1253
|
+
// this stage's persisted status stuck at RUNNING forever with no
|
|
1254
|
+
// follow-up transition (previously it stayed PENDING and simply
|
|
1255
|
+
// retried next tick). Catch here and route the failure through the
|
|
1256
|
+
// normal FAILED path so dependents fail fast instead of the run
|
|
1257
|
+
// stalling silently.
|
|
1258
|
+
let result;
|
|
1259
|
+
try {
|
|
1260
|
+
result = await executeStage(stage, activeRun, pipeline, config);
|
|
1261
|
+
} catch (e) {
|
|
1262
|
+
log('warn', `Pipeline ${pipeline.id}: stage ${stage.id} threw during execution: ${e.message}`);
|
|
1263
|
+
result = { status: PIPELINE_STATUS.FAILED, error: e.message };
|
|
1264
|
+
}
|
|
1188
1265
|
updateRunStage(pipeline.id, activeRun.runId, stage.id, { ...result, startedAt: ts() });
|
|
1189
1266
|
Object.assign(stageState, result, { startedAt: ts() });
|
|
1190
1267
|
if (result.status === PIPELINE_STATUS.RUNNING) {
|
package/engine/playbook.js
CHANGED
|
@@ -12,7 +12,7 @@ const queries = require('./queries');
|
|
|
12
12
|
const { wrapUntrusted, buildSource } = require('./untrusted-fence');
|
|
13
13
|
const { MINIONS_BRAND_URL } = require('./comment-format');
|
|
14
14
|
|
|
15
|
-
const {
|
|
15
|
+
const { safeJsonArr, safeRead, getProjects, log, ts, dateStamp, truncateTextBytes, ENGINE_DEFAULTS, WI_STATUS, WORK_TYPE, PR_STATUS, DISPATCH_RESULT, getProjectOrg } = shared;
|
|
16
16
|
const { getConfig, getDispatch, getNotes, getPrs, getKnowledgeBaseIndex, AGENTS_DIR } = queries;
|
|
17
17
|
|
|
18
18
|
const MINIONS_DIR = shared.MINIONS_DIR;
|
|
@@ -1060,11 +1060,12 @@ function renderPlaybook(type, vars) {
|
|
|
1060
1060
|
// ─── Playbook Section Validator ──────────────────────────────────────────────
|
|
1061
1061
|
|
|
1062
1062
|
// Required structural section patterns — warn (do not throw) when absent.
|
|
1063
|
-
//
|
|
1063
|
+
// Only '## Your Task' is checked: it's the one section every playbook template
|
|
1064
|
+
// actually implements. '## Tools' / '## Constraints' were removed (#775) —
|
|
1065
|
+
// no template in playbooks/ defines those headers, so the checks always
|
|
1066
|
+
// failed and produced 100%-failure-rate warn-level noise in engine/log.json.
|
|
1064
1067
|
const _REQUIRED_PROMPT_SECTIONS = [
|
|
1065
1068
|
{ pattern: /^## Your Task\b/m, label: '## Your Task' },
|
|
1066
|
-
{ pattern: /^## Tools?\b/m, label: '## Tools' },
|
|
1067
|
-
{ pattern: /^## Constraints\b/m, label: '## Constraints' },
|
|
1068
1069
|
];
|
|
1069
1070
|
|
|
1070
1071
|
/**
|
|
@@ -1266,7 +1267,7 @@ function buildAgentContext(agentId, config, project) {
|
|
|
1266
1267
|
}
|
|
1267
1268
|
// Also check central pull-requests.json
|
|
1268
1269
|
try {
|
|
1269
|
-
const centralPrs =
|
|
1270
|
+
const centralPrs = safeJsonArr(path.join(MINIONS_DIR, 'pull-requests.json'));
|
|
1270
1271
|
for (const pr of centralPrs.filter(pr => pr.status === PR_STATUS.ACTIVE)) {
|
|
1271
1272
|
if (!allPrs.some(p => p.id === pr.id)) allPrs.push({ ...pr, _project: 'central' });
|
|
1272
1273
|
}
|
|
@@ -64,7 +64,33 @@ function _readJsonArrayFallback(scope) {
|
|
|
64
64
|
|
|
65
65
|
// Content-hash fingerprint per scope: same-length swaps (timestamps,
|
|
66
66
|
// reviewStatus enums) collide on (mtime, size) but never on SHA-1.
|
|
67
|
-
|
|
67
|
+
//
|
|
68
|
+
// W-mrcg7j9k000ja233 (#759): this used to be an in-memory-only Map, which
|
|
69
|
+
// resets to empty on every process restart. Persist it in SQL instead
|
|
70
|
+
// (`pr_mirror_hashes`) so a restart can't blank out the "does JSON already
|
|
71
|
+
// agree with SQL" trust state — that reset, combined with a restart landing
|
|
72
|
+
// mid-flight of another process's mirror write, is exactly what let a stale
|
|
73
|
+
// JSON snapshot get treated as canonical and silently wipe SQL rows for a
|
|
74
|
+
// scope (see _resyncScopeIfJsonDiverged / _hydrateScopeFromJson below).
|
|
75
|
+
function _getLastMirrorHash(db, scope) {
|
|
76
|
+
try {
|
|
77
|
+
const row = db.prepare('SELECT hash FROM pr_mirror_hashes WHERE scope = ?').get(scope);
|
|
78
|
+
return row ? row.hash : null;
|
|
79
|
+
} catch { return null; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function _setLastMirrorHash(db, scope, hash) {
|
|
83
|
+
try {
|
|
84
|
+
db.prepare(`
|
|
85
|
+
INSERT INTO pr_mirror_hashes (scope, hash, updated_at) VALUES (?, ?, ?)
|
|
86
|
+
ON CONFLICT(scope) DO UPDATE SET hash = excluded.hash, updated_at = excluded.updated_at
|
|
87
|
+
`).run(scope, hash, Date.now());
|
|
88
|
+
} catch { /* best-effort — a missed persisted-hash write just means the next resync re-checks */ }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _forgetLastMirrorHash(db, scope) {
|
|
92
|
+
try { db.prepare('DELETE FROM pr_mirror_hashes WHERE scope = ?').run(scope); } catch { /* best-effort */ }
|
|
93
|
+
}
|
|
68
94
|
|
|
69
95
|
const crypto = require('crypto');
|
|
70
96
|
|
|
@@ -78,7 +104,24 @@ function _fileContentHash(filePath) {
|
|
|
78
104
|
|
|
79
105
|
function _hydrateScopeFromJson(db, scope) {
|
|
80
106
|
const jsonItems = _readJsonArrayFallback(scope);
|
|
107
|
+
const beforeCount = db.prepare('SELECT COUNT(*) AS n FROM pull_requests WHERE scope = ?').get(scope).n;
|
|
108
|
+
const beforeIds = db.prepare('SELECT id FROM pull_requests WHERE scope = ?').all(scope).map(r => r.id);
|
|
81
109
|
db.prepare('DELETE FROM pull_requests WHERE scope = ?').run(scope);
|
|
110
|
+
// #759 — this DELETE used to be completely silent. Log every hydrate at
|
|
111
|
+
// warn level with before/after counts + the ids that are about to be
|
|
112
|
+
// dropped, so a JSON<->SQL resync that removes rows the operator didn't
|
|
113
|
+
// expect leaves a trace in engine-stdio.log instead of vanishing without
|
|
114
|
+
// explanation.
|
|
115
|
+
try {
|
|
116
|
+
const afterIds = new Set(jsonItems.filter(pr => pr && pr.id).map(pr => String(pr.id)));
|
|
117
|
+
const droppedIds = beforeIds.filter(id => !afterIds.has(id));
|
|
118
|
+
// eslint-disable-next-line no-console
|
|
119
|
+
console.warn(
|
|
120
|
+
`[pull-requests-store] _hydrateScopeFromJson(scope=${scope}): rebuilding SQL from JSON mirror — `
|
|
121
|
+
+ `before=${beforeCount} row(s), after=${jsonItems.length} row(s)`
|
|
122
|
+
+ (droppedIds.length ? `, dropping id(s): ${droppedIds.join(', ')}` : ''),
|
|
123
|
+
);
|
|
124
|
+
} catch { /* logging must never block the resync */ }
|
|
82
125
|
if (jsonItems.length === 0) return;
|
|
83
126
|
const now = Date.now();
|
|
84
127
|
const ins = db.prepare(`
|
|
@@ -107,18 +150,18 @@ function _hydrateScopeFromJson(db, scope) {
|
|
|
107
150
|
function _resyncScopeIfJsonDiverged(db, scope) {
|
|
108
151
|
const jsonPath = _filePathForScope(scope);
|
|
109
152
|
const currentHash = _fileContentHash(jsonPath);
|
|
110
|
-
const lastHash =
|
|
153
|
+
const lastHash = _getLastMirrorHash(db, scope);
|
|
111
154
|
if (currentHash == null) return;
|
|
112
155
|
if (lastHash != null && currentHash === lastHash) return;
|
|
113
156
|
if (lastHash == null) {
|
|
114
157
|
const sqlHas = db.prepare('SELECT 1 FROM pull_requests WHERE scope = ? LIMIT 1').get(scope);
|
|
115
158
|
if (sqlHas) {
|
|
116
|
-
|
|
159
|
+
_setLastMirrorHash(db, scope, currentHash);
|
|
117
160
|
return;
|
|
118
161
|
}
|
|
119
162
|
}
|
|
120
163
|
_hydrateScopeFromJson(db, scope);
|
|
121
|
-
|
|
164
|
+
_setLastMirrorHash(db, scope, currentHash);
|
|
122
165
|
}
|
|
123
166
|
|
|
124
167
|
function readPullRequestsForScope(scope) {
|
|
@@ -253,7 +296,7 @@ function _applyPullRequestsDiff(db, diff) {
|
|
|
253
296
|
return true;
|
|
254
297
|
}
|
|
255
298
|
|
|
256
|
-
function applyPullRequestsMutation(scope, mutator) {
|
|
299
|
+
function applyPullRequestsMutation(scope, mutator, options = {}) {
|
|
257
300
|
const { getDb, withTransaction } = require('./db');
|
|
258
301
|
const db = getDb();
|
|
259
302
|
|
|
@@ -270,6 +313,23 @@ function applyPullRequestsMutation(scope, mutator) {
|
|
|
270
313
|
: (Array.isArray(next) ? next : before);
|
|
271
314
|
const diff = _computePullRequestsDiff(scope, beforeSnapshot, after);
|
|
272
315
|
const wrote = _applyPullRequestsDiff(db, diff);
|
|
316
|
+
// W-mrcg7j9k000ja233 (#759) — mirror the JSON sidecar INSIDE this same
|
|
317
|
+
// SQL write transaction, BEFORE it commits, instead of after (as the
|
|
318
|
+
// caller in shared.js used to do post-return). This mirrors the fix
|
|
319
|
+
// already applied to work-items-store.js (W-mr9vcxvy000cdb4e): SQLite
|
|
320
|
+
// only allows one writer transaction at a time, so mirroring here
|
|
321
|
+
// guarantees the on-disk JSON file reflects this commit by the time it
|
|
322
|
+
// becomes visible to any other reader/writer. Without this ordering, a
|
|
323
|
+
// sibling process (dashboard vs. engine — separate Node processes, each
|
|
324
|
+
// with its own resync state) could observe the freshly committed SQL
|
|
325
|
+
// rows before the mirror file caught up, decide its own cached hash no
|
|
326
|
+
// longer matched the (still-stale) JSON file, and call
|
|
327
|
+
// _hydrateScopeFromJson — which DELETEs + reinserts the whole scope
|
|
328
|
+
// from that stale file, silently dropping the row this transaction just
|
|
329
|
+
// committed.
|
|
330
|
+
if (wrote) {
|
|
331
|
+
try { _mirrorJsonFromSql(scope, options.mirrorPath); } catch { /* best-effort — mirror failure must not block the SQL write */ }
|
|
332
|
+
}
|
|
273
333
|
return { wrote, result: after };
|
|
274
334
|
});
|
|
275
335
|
}
|
|
@@ -293,7 +353,7 @@ function _mirrorJsonFromSql(scope, filePath) {
|
|
|
293
353
|
const target = filePath || _filePathForScope(scope);
|
|
294
354
|
shared.safeWrite(target, items);
|
|
295
355
|
const h = _fileContentHash(target);
|
|
296
|
-
if (h != null)
|
|
356
|
+
if (h != null) _setLastMirrorHash(db, scope, h);
|
|
297
357
|
} catch {
|
|
298
358
|
// Mirror failures are non-fatal — SQL has already committed.
|
|
299
359
|
}
|
|
@@ -312,7 +372,8 @@ function _mirrorJsonFromSql(scope, filePath) {
|
|
|
312
372
|
function dropScope(scope) {
|
|
313
373
|
try {
|
|
314
374
|
const { getDb } = require('./db');
|
|
315
|
-
const
|
|
375
|
+
const db = getDb();
|
|
376
|
+
const result = db.prepare('DELETE FROM pull_requests WHERE scope = ?').run(scope);
|
|
316
377
|
// Mirror the work-items-store.js fix (W-mr96m3hm000fd5d7): don't blindly
|
|
317
378
|
// forget the mirror-hash. _resyncScopeIfJsonDiverged treats "no recorded
|
|
318
379
|
// hash" + "zero SQL rows" as a first-install hydrate and rebuilds SQL from
|
|
@@ -323,8 +384,8 @@ function dropScope(scope) {
|
|
|
323
384
|
// actually gone, e.g. dataMode: 'purge'/'archive').
|
|
324
385
|
const jsonPath = _filePathForScope(scope);
|
|
325
386
|
const currentHash = _fileContentHash(jsonPath);
|
|
326
|
-
if (currentHash == null)
|
|
327
|
-
else
|
|
387
|
+
if (currentHash == null) _forgetLastMirrorHash(db, scope);
|
|
388
|
+
else _setLastMirrorHash(db, scope, currentHash);
|
|
328
389
|
if (result && result.changes > 0) {
|
|
329
390
|
try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
|
|
330
391
|
}
|