@yemi33/minions 0.1.2379 → 0.1.2381
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/bin/minions.js +19 -9
- package/dashboard/js/refresh.js +3 -4
- package/dashboard/js/render-other.js +43 -23
- package/dashboard/js/render-prd.js +1 -1
- package/dashboard/js/render-work-items.js +13 -6
- package/dashboard/js/settings.js +40 -17
- package/dashboard.js +435 -768
- package/docs/architecture-review-2026-07-09.md +2 -4
- package/docs/auto-discovery.md +36 -49
- package/docs/blog-first-successful-dispatch.md +1 -1
- package/docs/branch-derivation.md +2 -2
- package/docs/command-center.md +1 -1
- package/docs/completion-reports.md +14 -4
- package/docs/constellation-bridge.md +59 -10
- package/docs/constellation-style-telemetry.md +6 -6
- package/docs/cooldown-merge-semantics.md +4 -0
- package/docs/copilot-cli-schema.md +3 -3
- package/docs/cross-repo-plans.md +17 -17
- package/docs/deprecated.json +2 -2
- package/docs/design-state-storage.md +1 -1
- package/docs/documentation-audit-2026-07-09.md +2 -2
- package/docs/engine-restart.md +20 -8
- package/docs/harness-mode.md +1 -1
- package/docs/live-checkout-mode.md +45 -26
- package/docs/managed-spawn.md +4 -4
- package/docs/onboarding.md +1 -2
- package/docs/pr-comment-followup.md +3 -3
- package/docs/pr-review-fix-loop.md +1 -1
- package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
- package/docs/qa-runbook-lifecycle.md +4 -4
- package/docs/qa-runbooks.md +2 -2
- package/docs/rfc-completion-json.md +4 -1
- package/docs/runtime-adapters.md +1 -1
- package/docs/self-improvement.md +4 -5
- package/docs/shared-lifecycle-module-map.md +3 -1
- package/docs/slim-ux/architecture-suggestions.md +5 -6
- package/docs/slim-ux/concepts.md +23 -25
- package/docs/watches.md +7 -7
- package/docs/workspace-manifests.md +1 -1
- package/docs/worktree-lifecycle.md +1 -1
- package/engine/abandoned-pr-reconciliation.js +4 -5
- package/engine/ado-status.js +5 -5
- package/engine/ado.js +20 -25
- package/engine/agent-worker-pool.js +58 -1
- package/engine/bridge.js +260 -5
- package/engine/cleanup.js +48 -131
- package/engine/cli.js +125 -83
- package/engine/cooldown.js +9 -16
- package/engine/db/index.js +22 -9
- package/engine/db/migrations/009-qa.js +1 -1
- package/engine/db/migrations/020-qa-session-scopes.js +23 -0
- package/engine/db/migrations/021-archived-work-items.js +72 -0
- package/engine/db/migrations/022-global-cc-session.js +31 -0
- package/engine/db/migrations/023-engine-state.js +30 -0
- package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
- package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
- package/engine/dispatch-store.js +2 -7
- package/engine/dispatch.js +39 -44
- package/engine/github.js +20 -27
- package/engine/lifecycle.js +279 -354
- package/engine/live-checkout.js +193 -149
- package/engine/llm.js +1 -1
- package/engine/logs-store.js +2 -2
- package/engine/managed-spawn.js +2 -23
- package/engine/meeting.js +6 -6
- package/engine/metrics-store.js +2 -2
- package/engine/note-link-backfill.js +6 -11
- package/engine/pipeline.js +18 -36
- package/engine/playbook.js +13 -16
- package/engine/prd-store.js +73 -54
- package/engine/preflight.js +2 -5
- package/engine/projects.js +15 -62
- package/engine/pull-requests-store.js +0 -17
- package/engine/qa-runbooks.js +2 -2
- package/engine/qa-runs.js +1 -8
- package/engine/qa-sessions.js +41 -64
- package/engine/queries.js +120 -219
- package/engine/routing.js +4 -6
- package/engine/scheduler.js +0 -4
- package/engine/shared-branch-pr-reconcile.js +2 -3
- package/engine/shared.js +268 -699
- package/engine/small-state-store.js +89 -10
- package/engine/state-operations.js +16 -4
- package/engine/stdio-timestamps.js +1 -1
- package/engine/timeout.js +5 -12
- package/engine/watch-actions.js +20 -22
- package/engine/watches-store.js +1 -1
- package/engine/watches.js +6 -10
- package/engine/work-item-validation.js +52 -0
- package/engine/work-items-store.js +127 -29
- package/engine/worktree-gc.js +2 -2
- package/engine/worktree-pool.js +8 -18
- package/engine.js +197 -358
- package/minions.js +2 -2
- package/package.json +1 -1
- package/playbooks/plan-to-prd.md +2 -2
- package/playbooks/shared-rules.md +3 -3
- package/playbooks/templates/followup-dispatch.md +1 -1
- package/playbooks/verify.md +1 -1
- package/prompts/cc-system.md +9 -9
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
|
|
4
|
+
function _resolveMinionsDir() {
|
|
5
|
+
const envHome = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME;
|
|
6
|
+
if (envHome) return envHome;
|
|
7
|
+
try { return require('../../shared').MINIONS_DIR; } catch { return null; }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function _toMs(value) {
|
|
11
|
+
if (value == null) return null;
|
|
12
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
13
|
+
const parsed = Date.parse(value);
|
|
14
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function _readArray(filePath) {
|
|
18
|
+
try {
|
|
19
|
+
const value = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
20
|
+
return Array.isArray(value) ? value : [];
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = {
|
|
27
|
+
version: 21,
|
|
28
|
+
description: 'store archived work items in SQLite',
|
|
29
|
+
up(db) {
|
|
30
|
+
db.exec(`
|
|
31
|
+
ALTER TABLE work_items ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
|
|
32
|
+
CREATE INDEX idx_wi_scope_archived ON work_items(scope, archived);
|
|
33
|
+
`);
|
|
34
|
+
|
|
35
|
+
const minionsDir = _resolveMinionsDir();
|
|
36
|
+
if (!minionsDir) return;
|
|
37
|
+
const insert = db.prepare(`
|
|
38
|
+
INSERT OR IGNORE INTO work_items
|
|
39
|
+
(id, scope, status, type, agent, parent_id, created_at, completed_at, data, updated_at, archived)
|
|
40
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
|
41
|
+
`);
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const importFile = (scope, filePath) => {
|
|
44
|
+
for (const raw of _readArray(filePath)) {
|
|
45
|
+
if (!raw || typeof raw !== 'object' || !raw.id) continue;
|
|
46
|
+
const item = { ...raw, _archived: true };
|
|
47
|
+
item.archivedAt = item.archivedAt || new Date(now).toISOString();
|
|
48
|
+
insert.run(
|
|
49
|
+
String(item.id),
|
|
50
|
+
scope,
|
|
51
|
+
String(item.status || 'done'),
|
|
52
|
+
item.type || null,
|
|
53
|
+
item.dispatched_to || item.agent || null,
|
|
54
|
+
item.parent_id || null,
|
|
55
|
+
_toMs(item.created_at || item.created),
|
|
56
|
+
_toMs(item.completed_at || item.completedAt),
|
|
57
|
+
JSON.stringify(item),
|
|
58
|
+
now,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
importFile('central', path.join(minionsDir, 'work-items-archive.json'));
|
|
64
|
+
let projects = [];
|
|
65
|
+
try { projects = fs.readdirSync(path.join(minionsDir, 'projects'), { withFileTypes: true }); }
|
|
66
|
+
catch { /* optional */ }
|
|
67
|
+
for (const project of projects) {
|
|
68
|
+
if (!project.isDirectory() || project.name === '.archived') continue;
|
|
69
|
+
importFile(project.name, path.join(minionsDir, 'projects', project.name, 'work-items-archive.json'));
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const CC_GLOBAL_SESSION_ID = '__global__';
|
|
5
|
+
|
|
6
|
+
function _resolveMinionsDir() {
|
|
7
|
+
const envHome = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME;
|
|
8
|
+
if (envHome) return envHome;
|
|
9
|
+
try { return require('../../shared').MINIONS_DIR; } catch { return null; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
version: 22,
|
|
14
|
+
description: 'global Command Center session',
|
|
15
|
+
up(db) {
|
|
16
|
+
const minionsDir = _resolveMinionsDir();
|
|
17
|
+
if (!minionsDir) return;
|
|
18
|
+
const legacyPath = path.join(minionsDir, 'engine', 'cc-session.json');
|
|
19
|
+
let session;
|
|
20
|
+
try {
|
|
21
|
+
session = JSON.parse(fs.readFileSync(legacyPath, 'utf8'));
|
|
22
|
+
} catch {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (!session || typeof session !== 'object' || Array.isArray(session) || !session.sessionId) return;
|
|
26
|
+
db.prepare(`
|
|
27
|
+
INSERT OR IGNORE INTO cc_sessions (id, data, updated_at)
|
|
28
|
+
VALUES (?, ?, ?)
|
|
29
|
+
`).run(CC_GLOBAL_SESSION_ID, JSON.stringify(session), Date.now());
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const ENGINE_STATE_MARKER = 'engine_state';
|
|
5
|
+
|
|
6
|
+
function _resolveMinionsDir() {
|
|
7
|
+
const envHome = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME;
|
|
8
|
+
if (envHome) return envHome;
|
|
9
|
+
try { return require('../../shared').MINIONS_DIR; } catch { return null; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
version: 23,
|
|
14
|
+
description: 'engine state marker',
|
|
15
|
+
up(db) {
|
|
16
|
+
const minionsDir = _resolveMinionsDir();
|
|
17
|
+
if (!minionsDir) return;
|
|
18
|
+
let state;
|
|
19
|
+
try {
|
|
20
|
+
state = JSON.parse(fs.readFileSync(path.join(minionsDir, 'engine', 'state.json'), 'utf8'));
|
|
21
|
+
} catch {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) return;
|
|
25
|
+
db.prepare(`
|
|
26
|
+
INSERT OR IGNORE INTO state_markers (key, value, updated_at)
|
|
27
|
+
VALUES (?, ?, ?)
|
|
28
|
+
`).run(ENGINE_STATE_MARKER, JSON.stringify(state), Date.now());
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
function _parseObject(raw) {
|
|
2
|
+
try {
|
|
3
|
+
const value = JSON.parse(raw);
|
|
4
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
5
|
+
} catch {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function _isArchivedEcho(live, archived) {
|
|
11
|
+
if (!live || !archived) return true;
|
|
12
|
+
const liveSource = live.source_plan || live.sourcePlan;
|
|
13
|
+
const archivedSource = archived.source_plan || archived.sourcePlan;
|
|
14
|
+
if (liveSource && archivedSource && liveSource !== archivedSource) return false;
|
|
15
|
+
const archivedIds = new Set(
|
|
16
|
+
(archived.missing_features || []).map(feature => feature && feature.id).filter(Boolean),
|
|
17
|
+
);
|
|
18
|
+
return !(live.missing_features || []).some(
|
|
19
|
+
feature => feature && feature.id && !archivedIds.has(feature.id),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function _deletePrdRow(db, prdId) {
|
|
24
|
+
db.prepare(`
|
|
25
|
+
UPDATE work_items SET prd_item_id=NULL
|
|
26
|
+
WHERE prd_item_id IN (SELECT id FROM prd_items WHERE prd_id=?)
|
|
27
|
+
`).run(prdId);
|
|
28
|
+
db.prepare('DELETE FROM prd_items WHERE prd_id=?').run(prdId);
|
|
29
|
+
db.prepare('DELETE FROM prd_verify_prs WHERE prd_id=?').run(prdId);
|
|
30
|
+
db.prepare('DELETE FROM prds WHERE id=?').run(prdId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = {
|
|
34
|
+
version: 24,
|
|
35
|
+
description: 'remove duplicate live PRD echoes of archived imports',
|
|
36
|
+
up(db) {
|
|
37
|
+
const rows = db.prepare(`
|
|
38
|
+
SELECT id, filename, archived, data
|
|
39
|
+
FROM prds
|
|
40
|
+
WHERE filename IN (SELECT filename FROM prds GROUP BY filename HAVING COUNT(DISTINCT archived) > 1)
|
|
41
|
+
ORDER BY filename, archived, id
|
|
42
|
+
`).all();
|
|
43
|
+
const archivedByFilename = new Map();
|
|
44
|
+
for (const row of rows) {
|
|
45
|
+
if (row.archived) archivedByFilename.set(row.filename, _parseObject(row.data));
|
|
46
|
+
}
|
|
47
|
+
for (const row of rows) {
|
|
48
|
+
if (row.archived) continue;
|
|
49
|
+
const archived = archivedByFilename.get(row.filename);
|
|
50
|
+
if (_isArchivedEcho(_parseObject(row.data), archived)) _deletePrdRow(db, row.id);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const { isMalformedPhantomWorkItem } = require('../../work-item-validation');
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
version: 25,
|
|
5
|
+
description: 'archive failed contentless work-item phantoms',
|
|
6
|
+
up(db) {
|
|
7
|
+
const rows = db.prepare(`
|
|
8
|
+
SELECT id, scope, data
|
|
9
|
+
FROM work_items
|
|
10
|
+
WHERE archived=0 AND status='failed'
|
|
11
|
+
`).all();
|
|
12
|
+
const update = db.prepare(`
|
|
13
|
+
UPDATE work_items
|
|
14
|
+
SET archived=1, data=?, updated_at=?
|
|
15
|
+
WHERE scope=? AND id=? AND archived=0
|
|
16
|
+
`);
|
|
17
|
+
const reconciledAt = new Date().toISOString();
|
|
18
|
+
for (const row of rows) {
|
|
19
|
+
let item;
|
|
20
|
+
try { item = JSON.parse(row.data); } catch { continue; }
|
|
21
|
+
if (!isMalformedPhantomWorkItem(item)) continue;
|
|
22
|
+
const preservedFields = Object.keys(item).sort();
|
|
23
|
+
item._archived = true;
|
|
24
|
+
item.archivedAt = item.archivedAt || reconciledAt;
|
|
25
|
+
item._corruption = {
|
|
26
|
+
kind: 'malformed-phantom',
|
|
27
|
+
reconciledAt,
|
|
28
|
+
preservedFields,
|
|
29
|
+
};
|
|
30
|
+
update.run(JSON.stringify(item), Date.now(), row.scope, row.id);
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
};
|
package/engine/dispatch-store.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
// engine/dispatch-store.js — SQL-backed
|
|
2
|
-
// section-shaped dispatch object. Same external contract as the legacy
|
|
3
|
-
// dispatch.json reader:
|
|
1
|
+
// engine/dispatch-store.js — SQL-backed section-shaped dispatch state:
|
|
4
2
|
//
|
|
5
3
|
// readDispatchSectioned() -> { pending, active, completed, review }
|
|
6
4
|
// applyDispatchMutation(fn) -> runs fn against the section object,
|
|
@@ -38,8 +36,7 @@ function readDispatchSectioned() {
|
|
|
38
36
|
const { getDb } = require('./db');
|
|
39
37
|
const db = getDb();
|
|
40
38
|
|
|
41
|
-
//
|
|
42
|
-
// semantics — returns the complete dispatch state regardless of how old
|
|
39
|
+
// Return the complete dispatch state regardless of how old
|
|
43
40
|
// completed entries are. Cross-reference consumers like state-integrity
|
|
44
41
|
// need the full completed list to match against work-items, and the
|
|
45
42
|
// dashboard's slim slice handles its own payload sizing downstream.
|
|
@@ -200,8 +197,6 @@ function applyDispatchMutation(mutator) {
|
|
|
200
197
|
}
|
|
201
198
|
const diff = _computeDispatchDiff(beforeSnapshot, after);
|
|
202
199
|
const wrote = _applyDispatchDiff(db, diff);
|
|
203
|
-
// First-touch guard: record that SQL was written this process so a later
|
|
204
|
-
// empty read won't resurrect a stale dispatch.json (see readDispatchSectioned).
|
|
205
200
|
return { wrote, result: after };
|
|
206
201
|
});
|
|
207
202
|
}
|
package/engine/dispatch.js
CHANGED
|
@@ -24,8 +24,8 @@ const queries = require('./queries');
|
|
|
24
24
|
const { setCooldown, setCooldownFailure } = require('./cooldown');
|
|
25
25
|
const dispatchEvents = require('./dispatch-events');
|
|
26
26
|
|
|
27
|
-
const {
|
|
28
|
-
mutatePullRequests, getProjects,
|
|
27
|
+
const { readWorkItems, mutateWorkItems,
|
|
28
|
+
mutatePullRequests, getProjects, log, ts, dateStamp,
|
|
29
29
|
sidecarDispatchPrompt, deleteDispatchPromptSidecar, DONE_STATUSES,
|
|
30
30
|
WI_STATUS, WORK_TYPE, DISPATCH_RESULT, ENGINE_DEFAULTS, AGENT_STATUS, FAILURE_CLASS, PR_STATUS } = shared;
|
|
31
31
|
const { getConfig, INBOX_DIR } = queries;
|
|
@@ -53,6 +53,8 @@ const NON_MUTATING_DISPATCH_TYPES = new Set([
|
|
|
53
53
|
// PR source branch always differs and causes phantom auto-cancellation (#369).
|
|
54
54
|
const PR_BRANCH_TARGETED_TYPES = new Set([
|
|
55
55
|
WORK_TYPE.FIX,
|
|
56
|
+
WORK_TYPE.BUILD_FIX_COMPLEX,
|
|
57
|
+
WORK_TYPE.TEST,
|
|
56
58
|
WORK_TYPE.VERIFY,
|
|
57
59
|
WORK_TYPE.DECOMPOSE,
|
|
58
60
|
]);
|
|
@@ -81,7 +83,7 @@ function _snapshotPrompts(dispatch) {
|
|
|
81
83
|
|
|
82
84
|
/**
|
|
83
85
|
* Sidecar oversized prompts only for items the mutator added or modified.
|
|
84
|
-
* Keeps dispatch
|
|
86
|
+
* Keeps dispatch state compact (#1167) without paying O(n) on every
|
|
85
87
|
* unrelated mutation (e.g. status flips, completion-marking).
|
|
86
88
|
*/
|
|
87
89
|
function _sidecarChangedPrompts(dispatch, prevSnap) {
|
|
@@ -148,7 +150,7 @@ function getPrDispatchDedupeKey(entry) {
|
|
|
148
150
|
if (!entry?.type) return null;
|
|
149
151
|
const targetKey = getPrDispatchTargetKey(entry);
|
|
150
152
|
if (!targetKey) return null;
|
|
151
|
-
const type = entry.type
|
|
153
|
+
const type = shared.isFixLikeWorkType(entry.type) ? WORK_TYPE.FIX : entry.type;
|
|
152
154
|
return `${targetKey}:${type}`;
|
|
153
155
|
}
|
|
154
156
|
|
|
@@ -197,7 +199,7 @@ function findActivePrOrBranchLock(dispatch, item) {
|
|
|
197
199
|
// PR-target dedup is FIX-only: a FIX shouldn't stack on top of another FIX
|
|
198
200
|
// for the same PR, but a REVIEW + FIX pair targeting the same PR is the
|
|
199
201
|
// normal review-then-fix flow and must not be dedup'd here.
|
|
200
|
-
if (item?.type
|
|
202
|
+
if (shared.isFixLikeWorkType(item?.type)) {
|
|
201
203
|
const prTargetKey = getPrDispatchTargetKey(item);
|
|
202
204
|
if (prTargetKey) {
|
|
203
205
|
const existing = active.find(d => getPrDispatchTargetKey(d) === prTargetKey);
|
|
@@ -306,9 +308,9 @@ function _persistInvalidWorkItem(item, evaluation) {
|
|
|
306
308
|
const meta = item?.meta;
|
|
307
309
|
const itemId = meta?.item?.id;
|
|
308
310
|
if (!itemId) return { stuck: false };
|
|
309
|
-
let
|
|
310
|
-
try {
|
|
311
|
-
if (!
|
|
311
|
+
let wiScope;
|
|
312
|
+
try { wiScope = lifecycle().resolveWorkItemScope(meta); } catch { wiScope = null; }
|
|
313
|
+
if (!wiScope) return { stuck: false };
|
|
312
314
|
// W-mrcrh5hj0017d22f — repeat-rejection cap. Compare against the
|
|
313
315
|
// description snapshot from the LAST rejection so an operator edit to the
|
|
314
316
|
// WI resets the counter (it deserves a fresh evaluation), while an
|
|
@@ -319,7 +321,7 @@ function _persistInvalidWorkItem(item, evaluation) {
|
|
|
319
321
|
const currentDescription = String((meta.item && meta.item.description) || '');
|
|
320
322
|
let stuck = false;
|
|
321
323
|
try {
|
|
322
|
-
mutateWorkItems(
|
|
324
|
+
mutateWorkItems(wiScope, (items) => {
|
|
323
325
|
if (!Array.isArray(items)) return items;
|
|
324
326
|
const idx = items.findIndex(w => w && w.id === itemId);
|
|
325
327
|
if (idx === -1) return items;
|
|
@@ -393,8 +395,7 @@ function _isPrdSourcedAndVetted(wi) {
|
|
|
393
395
|
const sourcePlan = wi && wi.sourcePlan;
|
|
394
396
|
if (!sourcePlan || typeof sourcePlan !== 'string') return false;
|
|
395
397
|
try {
|
|
396
|
-
const
|
|
397
|
-
const plan = shared.safeJsonNoRestore(prdPath);
|
|
398
|
+
const plan = require('./prd-store').readPrd(sourcePlan);
|
|
398
399
|
if (!plan || !plan.missing_features) return false;
|
|
399
400
|
const status = plan.status || shared.PLAN_STATUS.ACTIVE;
|
|
400
401
|
return status === shared.PLAN_STATUS.APPROVED || status === shared.PLAN_STATUS.ACTIVE;
|
|
@@ -454,7 +455,7 @@ async function addToDispatchWithValidation(item, opts = {}) {
|
|
|
454
455
|
: _isPrdSourcedAndVetted;
|
|
455
456
|
if (isPrdVetted(wi)) {
|
|
456
457
|
// Stamp the skip marker on the dispatch item so the entry that lands
|
|
457
|
-
// in dispatch
|
|
458
|
+
// in dispatch state carries observability — operators can spot which
|
|
458
459
|
// items the validator skipped vs which it ran.
|
|
459
460
|
item._preDispatchEvalSkipped = 'prd-sourced';
|
|
460
461
|
return addToDispatch(item);
|
|
@@ -569,17 +570,17 @@ function getStalePrDispatchReason(entry, config) {
|
|
|
569
570
|
function cancelSourceWorkItemForPrunedDispatch(entry, reason) {
|
|
570
571
|
const itemId = entry?.meta?.item?.id;
|
|
571
572
|
if (!itemId) return false;
|
|
572
|
-
let
|
|
573
|
-
try {
|
|
573
|
+
let wiScope;
|
|
574
|
+
try { wiScope = lifecycle().resolveWorkItemScope(entry.meta); }
|
|
574
575
|
catch (e) {
|
|
575
576
|
log('warn', `Failed to resolve source work item for pruned dispatch ${entry.id}: ${e.message}`);
|
|
576
577
|
return false;
|
|
577
578
|
}
|
|
578
|
-
if (!
|
|
579
|
+
if (!wiScope) return false;
|
|
579
580
|
|
|
580
581
|
let cancelled = false;
|
|
581
582
|
try {
|
|
582
|
-
mutateWorkItems(
|
|
583
|
+
mutateWorkItems(wiScope, (items) => {
|
|
583
584
|
if (!Array.isArray(items)) return items;
|
|
584
585
|
const wi = items.find(w => w && w.id === itemId);
|
|
585
586
|
if (!wi) return items;
|
|
@@ -608,9 +609,9 @@ function _resolveWorkItemForPrunedEntry(entry) {
|
|
|
608
609
|
const itemId = entry?.meta?.item?.id;
|
|
609
610
|
if (!itemId) return null;
|
|
610
611
|
try {
|
|
611
|
-
const
|
|
612
|
-
if (!
|
|
613
|
-
const items =
|
|
612
|
+
const wiScope = lifecycle().resolveWorkItemScope(entry.meta);
|
|
613
|
+
if (!wiScope) return null;
|
|
614
|
+
const items = readWorkItems(wiScope);
|
|
614
615
|
return items.find(w => w && w.id === itemId) || null;
|
|
615
616
|
} catch {
|
|
616
617
|
return null;
|
|
@@ -704,7 +705,7 @@ async function pruneStalePrDispatchesAsync(config = queries.getConfig()) {
|
|
|
704
705
|
for (const entry of pending) {
|
|
705
706
|
const reason = reasonsById.get(entry.id);
|
|
706
707
|
if (!reason) continue;
|
|
707
|
-
if (entry.type
|
|
708
|
+
if (!shared.isFixLikeWorkType(entry.type) || !_MERGED_OR_ABANDONED_REASON_RE.test(reason)) continue;
|
|
708
709
|
try {
|
|
709
710
|
const wi = _resolveWorkItemForPrunedEntry(entry);
|
|
710
711
|
const scopePaths = shared.extractScopeFilePathsFromWorkItem(wi);
|
|
@@ -851,9 +852,9 @@ const FORCE_DEMOTE_FAILURE_CLASSES = new Set([
|
|
|
851
852
|
function readLiveWorkItem(meta) {
|
|
852
853
|
const itemId = meta?.item?.id;
|
|
853
854
|
if (!itemId) return null;
|
|
854
|
-
const
|
|
855
|
-
if (!
|
|
856
|
-
const items =
|
|
855
|
+
const wiScope = lifecycle().resolveWorkItemScope(meta);
|
|
856
|
+
if (!wiScope) return null;
|
|
857
|
+
const items = readWorkItems(wiScope);
|
|
857
858
|
return items.find(i => i.id === itemId) || null;
|
|
858
859
|
}
|
|
859
860
|
|
|
@@ -894,11 +895,11 @@ function markCompletedSourceWorkItem(item) {
|
|
|
894
895
|
const meta = item?.meta;
|
|
895
896
|
const itemId = meta?.item?.id;
|
|
896
897
|
if (!itemId) return false;
|
|
897
|
-
const
|
|
898
|
-
if (!
|
|
898
|
+
const wiScope = lifecycle().resolveWorkItemScope(meta);
|
|
899
|
+
if (!wiScope) return false;
|
|
899
900
|
let found = false;
|
|
900
901
|
let terminal = false;
|
|
901
|
-
mutateWorkItems(
|
|
902
|
+
mutateWorkItems(wiScope, items => {
|
|
902
903
|
if (!Array.isArray(items)) return items;
|
|
903
904
|
const wi = items.find(i => i.id === itemId);
|
|
904
905
|
if (!wi) return items;
|
|
@@ -985,7 +986,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
985
986
|
if (item) {
|
|
986
987
|
log('info', `Completed dispatch: ${id} (${result}${reason ? ': ' + reason : ''})`);
|
|
987
988
|
// W-mpob4nyk0006580e — broadcast terminal state immediately after the
|
|
988
|
-
// `result` field is committed to dispatch.
|
|
989
|
+
// `result` field is committed to dispatch state. This is the single
|
|
989
990
|
// source of truth for the live-view banner; subscribers (dashboard SSE
|
|
990
991
|
// handler at handleAgentLiveStream) push it to clients so the banner
|
|
991
992
|
// mirrors `dispatch.result` exactly — no log-parsing heuristics that can
|
|
@@ -1046,11 +1047,11 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
1046
1047
|
const isForceDemote = failureClass && FORCE_DEMOTE_FAILURE_CLASSES.has(failureClass) && !isPrShipped;
|
|
1047
1048
|
if (isForceDemote) {
|
|
1048
1049
|
try {
|
|
1049
|
-
const
|
|
1050
|
-
if (
|
|
1050
|
+
const wiScope = lifecycle().resolveWorkItemScope(item.meta);
|
|
1051
|
+
if (wiScope) {
|
|
1051
1052
|
const classSuffix = ` [${failureClass.toUpperCase().replace(/-/g, '_')}]`;
|
|
1052
1053
|
const demoteReason = `Non-retryable failure: ${reason || failureClass}${classSuffix}`;
|
|
1053
|
-
mutateWorkItems(
|
|
1054
|
+
mutateWorkItems(wiScope, items => {
|
|
1054
1055
|
const wi = items.find(i => i.id === item.meta.item.id);
|
|
1055
1056
|
if (wi) {
|
|
1056
1057
|
wi.status = WI_STATUS.FAILED;
|
|
@@ -1093,7 +1094,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
1093
1094
|
} else {
|
|
1094
1095
|
let retries = (item.meta.item._retryCount || 0);
|
|
1095
1096
|
try {
|
|
1096
|
-
const wi =
|
|
1097
|
+
const wi = liveWi || readLiveWorkItem(item.meta);
|
|
1097
1098
|
if (wi) retries = wi._retryCount || 0;
|
|
1098
1099
|
} catch (e) { log('warn', 'read retry count: ' + e.message); }
|
|
1099
1100
|
const maxRetries = ENGINE_DEFAULTS.maxRetries;
|
|
@@ -1112,9 +1113,9 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
1112
1113
|
}
|
|
1113
1114
|
// Increment retry counter on the source work item
|
|
1114
1115
|
try {
|
|
1115
|
-
const
|
|
1116
|
-
if (
|
|
1117
|
-
mutateWorkItems(
|
|
1116
|
+
const wiScope = lifecycle().resolveWorkItemScope(item.meta);
|
|
1117
|
+
if (wiScope) {
|
|
1118
|
+
mutateWorkItems(wiScope, items => {
|
|
1118
1119
|
const wi = items.find(i => i.id === item.meta.item.id);
|
|
1119
1120
|
if (wi && wi.status !== WI_STATUS.PAUSED && wi.status !== WI_STATUS.DONE && !wi.completedAt) {
|
|
1120
1121
|
wi._retryCount = retries + 1;
|
|
@@ -1229,10 +1230,9 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
1229
1230
|
const isTransientPreflight = TRANSIENT_PREFLIGHT_CLASSES.has(failureClass);
|
|
1230
1231
|
if (prId && project) {
|
|
1231
1232
|
try {
|
|
1232
|
-
const prsPath = projectPrPath(project);
|
|
1233
1233
|
let restored = false;
|
|
1234
1234
|
let gaveUp = false;
|
|
1235
|
-
mutatePullRequests(
|
|
1235
|
+
mutatePullRequests(project, prs => {
|
|
1236
1236
|
const target = shared.findPrRecord(prs, { id: prId }, project);
|
|
1237
1237
|
if (!target?.humanFeedback) return;
|
|
1238
1238
|
if (isTransientPreflight) {
|
|
@@ -1383,12 +1383,7 @@ function cleanDispatchEntries(matchFn) {
|
|
|
1383
1383
|
const filesToDelete = [];
|
|
1384
1384
|
const dispatchDirsToRemove = [];
|
|
1385
1385
|
try {
|
|
1386
|
-
//
|
|
1387
|
-
// dispatch.json mirror stay coherent. Writing dispatch.json directly with
|
|
1388
|
-
// mutateJsonFileLocked here would leave stale rows in the `dispatches`
|
|
1389
|
-
// table; the very next mutateDispatch call (e.g. removeProject's
|
|
1390
|
-
// step 7.5 orphan-tagging pass) would then mirror SQL back to JSON and
|
|
1391
|
-
// resurrect the entries we just drained.
|
|
1386
|
+
// Keep the drain and orphan-tagging passes atomic in the dispatch store.
|
|
1392
1387
|
mutateDispatch((dispatch) => {
|
|
1393
1388
|
for (const queue of ['pending', 'active', 'completed']) {
|
|
1394
1389
|
dispatch[queue] = Array.isArray(dispatch[queue]) ? dispatch[queue] : [];
|
|
@@ -1449,10 +1444,10 @@ function cleanDispatchEntries(matchFn) {
|
|
|
1449
1444
|
* Cancel pending/queued work items matching a predicate. Done items pass through.
|
|
1450
1445
|
* Sets status=CANCELLED + _cancelledBy=reason. Returns count cancelled.
|
|
1451
1446
|
*/
|
|
1452
|
-
function cancelPendingWorkItems(
|
|
1447
|
+
function cancelPendingWorkItems(scope, matchFn, reason) {
|
|
1453
1448
|
let cancelled = 0;
|
|
1454
1449
|
try {
|
|
1455
|
-
mutateWorkItems(
|
|
1450
|
+
mutateWorkItems(scope, items => {
|
|
1456
1451
|
for (const w of items) {
|
|
1457
1452
|
if (!matchFn(w)) continue;
|
|
1458
1453
|
if (w.status !== WI_STATUS.PENDING && w.status !== WI_STATUS.QUEUED) continue;
|
package/engine/github.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const shared = require('./shared');
|
|
8
|
-
const { exec, execAsync, getProjects,
|
|
8
|
+
const { exec, execAsync, getProjects, readWorkItems, readPullRequests, safeJson, safeJsonArr, safeWrite, mutateMetrics, mutatePullRequests, MINIONS_DIR, getPrLinks, backfillPrPrdItems, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, ENGINE_DEFAULTS, createThrottleTracker, getProjectOrg } = shared;
|
|
9
9
|
const { getPrs } = require('./queries');
|
|
10
10
|
const { isPreviewStatusBody, hasMinionsMarker, hasVerdictPrefix } = require('./comment-classifier');
|
|
11
11
|
const { wrapUntrusted, buildSource } = require('./untrusted-fence');
|
|
@@ -291,8 +291,7 @@ async function _resolveViewerLogin(slug) {
|
|
|
291
291
|
// engine metrics aggregation (`getMetrics()` in `engine/queries.js`).
|
|
292
292
|
function _incrementViewerLoginResolutionFailures() {
|
|
293
293
|
try {
|
|
294
|
-
|
|
295
|
-
mutateJsonFileLocked(metricsPath, (metrics) => {
|
|
294
|
+
mutateMetrics((metrics) => {
|
|
296
295
|
metrics = metrics || {};
|
|
297
296
|
if (!metrics._engine) metrics._engine = {};
|
|
298
297
|
metrics._engine.viewerLoginResolutionFailures =
|
|
@@ -590,7 +589,7 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
590
589
|
}
|
|
591
590
|
|
|
592
591
|
if (projectUpdated > 0) {
|
|
593
|
-
|
|
592
|
+
mutatePullRequests(project, (currentPrs) => {
|
|
594
593
|
// Merge back only fields changed by callbacks; preserve concurrent disk updates.
|
|
595
594
|
for (const { before, after } of updatedRecords) {
|
|
596
595
|
const idx = currentPrs.findIndex(p => p.id === after.id);
|
|
@@ -601,7 +600,7 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
601
600
|
}
|
|
602
601
|
// W-mp5trwh60008386d: never downgrade `status: merged` — terminal state. A stale
|
|
603
602
|
// 404 reaching `prAbandonConfirmCount` could otherwise overwrite a concurrent
|
|
604
|
-
// success/merge poll. Keeps the central
|
|
603
|
+
// success/merge poll. Keeps the central PR status invariants
|
|
605
604
|
// intact even under multi-writer races.
|
|
606
605
|
if (currentPrs[idx].status === PR_STATUS.MERGED && after.status !== PR_STATUS.MERGED) {
|
|
607
606
|
after.status = PR_STATUS.MERGED;
|
|
@@ -629,9 +628,8 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
629
628
|
}
|
|
630
629
|
}
|
|
631
630
|
|
|
632
|
-
// Also poll manually-linked PRs from central
|
|
633
|
-
const
|
|
634
|
-
const centralPrs = safeJsonArr(centralPath);
|
|
631
|
+
// Also poll manually-linked PRs from central state (extract slug from URL).
|
|
632
|
+
const centralPrs = readPullRequests('central');
|
|
635
633
|
// Build a slug→project map for configured GitHub projects so we can detect
|
|
636
634
|
// PRs that are actually owned by the project loop (present in per-project file).
|
|
637
635
|
const configuredGhProjectsBySlug = new Map();
|
|
@@ -648,7 +646,7 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
648
646
|
if (ghMatch) {
|
|
649
647
|
const owningProject = configuredGhProjectsBySlug.get(ghMatch[1].toLowerCase());
|
|
650
648
|
if (owningProject) {
|
|
651
|
-
const projectPrs =
|
|
649
|
+
const projectPrs = readPullRequests(owningProject);
|
|
652
650
|
if (projectPrs.some(p => p.id === pr.id)) return false; // present → project loop handles it
|
|
653
651
|
}
|
|
654
652
|
}
|
|
@@ -726,7 +724,7 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
726
724
|
}
|
|
727
725
|
}
|
|
728
726
|
if (centralUpdated > 0) {
|
|
729
|
-
|
|
727
|
+
mutatePullRequests('central', (currentPrs) => {
|
|
730
728
|
// Only merge back central PRs that the callback actually modified
|
|
731
729
|
for (const { before, after } of updatedCentralRecords) {
|
|
732
730
|
const idx = currentPrs.findIndex(p => p.id === after.id);
|
|
@@ -1004,7 +1002,7 @@ async function pollPrStatus(config) {
|
|
|
1004
1002
|
}
|
|
1005
1003
|
// F6 (P-f6commentedit): drop the comment-edit dedup map on merge/abandon
|
|
1006
1004
|
// — no further re-dispatch path can fire from this PR, so keeping it
|
|
1007
|
-
// around would only bloat
|
|
1005
|
+
// around would only bloat the PR store.
|
|
1008
1006
|
if (pr.humanFeedback && pr.humanFeedback.editsSeen) {
|
|
1009
1007
|
delete pr.humanFeedback.editsSeen;
|
|
1010
1008
|
}
|
|
@@ -1204,7 +1202,7 @@ async function pollPrStatus(config) {
|
|
|
1204
1202
|
// review status flipped, etc.) is dropped via clearPrNoOpFixAttempt. The
|
|
1205
1203
|
// dispatch gate already ignores these stale records (isPrNoOpFixCausePaused
|
|
1206
1204
|
// re-validates the fingerprint), but without this sweep they linger in
|
|
1207
|
-
//
|
|
1205
|
+
// PR store forever — bloating the registry and confusing the
|
|
1208
1206
|
// dashboard `pr-paused-chip`. Runs AFTER all live-field updates so the
|
|
1209
1207
|
// fingerprint comparison uses post-poll PR state.
|
|
1210
1208
|
try {
|
|
@@ -1454,8 +1452,7 @@ async function reconcilePrs(config) {
|
|
|
1454
1452
|
const existingPrs = getPrs(project);
|
|
1455
1453
|
if (existingPrs.length === 0) {
|
|
1456
1454
|
try {
|
|
1457
|
-
const
|
|
1458
|
-
const wis = safeJsonArr(wiPath);
|
|
1455
|
+
const wis = readWorkItems(project);
|
|
1459
1456
|
if (wis.length === 0) continue;
|
|
1460
1457
|
} catch { continue; }
|
|
1461
1458
|
}
|
|
@@ -1478,18 +1475,15 @@ async function reconcilePrs(config) {
|
|
|
1478
1475
|
|
|
1479
1476
|
if (ghPrs.length === 0) continue;
|
|
1480
1477
|
|
|
1481
|
-
const
|
|
1482
|
-
const currentPrs = safeJsonArr(prPath);
|
|
1478
|
+
const currentPrs = readPullRequests(project);
|
|
1483
1479
|
shared.normalizePrRecords(currentPrs, project);
|
|
1484
1480
|
const existingIds = new Set(currentPrs.map(p => p.id));
|
|
1485
1481
|
let metadataUpdated = 0;
|
|
1486
1482
|
let projectAdded = 0;
|
|
1487
1483
|
|
|
1488
1484
|
// Load work items to match branches
|
|
1489
|
-
const
|
|
1490
|
-
const
|
|
1491
|
-
const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
1492
|
-
const centralItems = safeJsonArr(centralWiPath);
|
|
1485
|
+
const workItems = readWorkItems(project);
|
|
1486
|
+
const centralItems = readWorkItems('central');
|
|
1493
1487
|
const allItems = [...workItems, ...centralItems];
|
|
1494
1488
|
|
|
1495
1489
|
for (const { pr: ghPr, slug } of ghPrs) {
|
|
@@ -1528,7 +1522,7 @@ async function reconcilePrs(config) {
|
|
|
1528
1522
|
metadataUpdated++;
|
|
1529
1523
|
}
|
|
1530
1524
|
if (confirmedItemId) {
|
|
1531
|
-
shared.upsertPullRequestRecord(
|
|
1525
|
+
shared.upsertPullRequestRecord(project, existing || {
|
|
1532
1526
|
id: prId,
|
|
1533
1527
|
prNumber: ghPr.number,
|
|
1534
1528
|
title: (ghPr.title || `PR #${ghPr.number}`).slice(0, 120),
|
|
@@ -1587,7 +1581,7 @@ async function reconcilePrs(config) {
|
|
|
1587
1581
|
sourcePlan: linkedItem?.sourcePlan || '',
|
|
1588
1582
|
itemType: linkedItem?.itemType || '',
|
|
1589
1583
|
};
|
|
1590
|
-
const upserted = shared.upsertPullRequestRecord(
|
|
1584
|
+
const upserted = shared.upsertPullRequestRecord(project, entry, { project, itemId: confirmedItemId });
|
|
1591
1585
|
currentPrs.push(upserted.record || entry);
|
|
1592
1586
|
existingIds.add(prId);
|
|
1593
1587
|
projectAdded++;
|
|
@@ -1607,14 +1601,14 @@ async function reconcilePrs(config) {
|
|
|
1607
1601
|
const backfilled = backfillPrPrdItems(currentPrs, getPrLinks());
|
|
1608
1602
|
|
|
1609
1603
|
if (projectAdded > 0 || backfilled > 0 || metadataUpdated > 0) {
|
|
1610
|
-
|
|
1604
|
+
mutatePullRequests(project, (lockedPrs) => {
|
|
1611
1605
|
for (const pr of currentPrs) {
|
|
1612
1606
|
const idx = lockedPrs.findIndex(p => p.id === pr.id);
|
|
1613
1607
|
if (idx >= 0) lockedPrs[idx] = pr;
|
|
1614
1608
|
else lockedPrs.push(pr);
|
|
1615
1609
|
}
|
|
1616
1610
|
return lockedPrs;
|
|
1617
|
-
}
|
|
1611
|
+
});
|
|
1618
1612
|
totalAdded += projectAdded;
|
|
1619
1613
|
}
|
|
1620
1614
|
}
|
|
@@ -1872,8 +1866,7 @@ async function reconcileAbandonedPrs(config) {
|
|
|
1872
1866
|
const slugProbeCache = new Map(); // slug → 'ok' | 'fail'
|
|
1873
1867
|
|
|
1874
1868
|
for (const project of projects) {
|
|
1875
|
-
const
|
|
1876
|
-
const prs = safeJsonArr(prPath);
|
|
1869
|
+
const prs = readPullRequests(project);
|
|
1877
1870
|
// Filter: only abandoned PRs that don't already have the confirmed-404
|
|
1878
1871
|
// marker from a previous reconciliation pass. Marker means "we already
|
|
1879
1872
|
// verified this PR is genuinely deleted on the server" — no point
|
|
@@ -1958,7 +1951,7 @@ async function reconcileAbandonedPrs(config) {
|
|
|
1958
1951
|
|
|
1959
1952
|
if (updates.length > 0) {
|
|
1960
1953
|
const reconciledAt = ts();
|
|
1961
|
-
mutatePullRequests(
|
|
1954
|
+
mutatePullRequests(project, (currentPrs) => {
|
|
1962
1955
|
for (const upd of updates) {
|
|
1963
1956
|
const pr = currentPrs.find(p =>
|
|
1964
1957
|
shared.getCanonicalPrId(project, p, p.url || '') === upd.prId
|