@yemi33/minions 0.1.2379 → 0.1.2380
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 +2 -3
- package/dashboard/js/render-prd.js +1 -1
- package/dashboard/js/render-work-items.js +13 -6
- package/dashboard.js +393 -721
- 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/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 +34 -41
- package/engine/github.js +20 -27
- package/engine/lifecycle.js +221 -283
- 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 +1 -1
- 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 +3 -5
- package/engine/scheduler.js +0 -4
- package/engine/shared-branch-pr-reconcile.js +2 -3
- package/engine/shared.js +132 -637
- 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 +147 -343
- 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 +2 -2
|
@@ -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;
|
|
@@ -81,7 +81,7 @@ function _snapshotPrompts(dispatch) {
|
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
83
|
* Sidecar oversized prompts only for items the mutator added or modified.
|
|
84
|
-
* Keeps dispatch
|
|
84
|
+
* Keeps dispatch state compact (#1167) without paying O(n) on every
|
|
85
85
|
* unrelated mutation (e.g. status flips, completion-marking).
|
|
86
86
|
*/
|
|
87
87
|
function _sidecarChangedPrompts(dispatch, prevSnap) {
|
|
@@ -306,9 +306,9 @@ function _persistInvalidWorkItem(item, evaluation) {
|
|
|
306
306
|
const meta = item?.meta;
|
|
307
307
|
const itemId = meta?.item?.id;
|
|
308
308
|
if (!itemId) return { stuck: false };
|
|
309
|
-
let
|
|
310
|
-
try {
|
|
311
|
-
if (!
|
|
309
|
+
let wiScope;
|
|
310
|
+
try { wiScope = lifecycle().resolveWorkItemScope(meta); } catch { wiScope = null; }
|
|
311
|
+
if (!wiScope) return { stuck: false };
|
|
312
312
|
// W-mrcrh5hj0017d22f — repeat-rejection cap. Compare against the
|
|
313
313
|
// description snapshot from the LAST rejection so an operator edit to the
|
|
314
314
|
// WI resets the counter (it deserves a fresh evaluation), while an
|
|
@@ -319,7 +319,7 @@ function _persistInvalidWorkItem(item, evaluation) {
|
|
|
319
319
|
const currentDescription = String((meta.item && meta.item.description) || '');
|
|
320
320
|
let stuck = false;
|
|
321
321
|
try {
|
|
322
|
-
mutateWorkItems(
|
|
322
|
+
mutateWorkItems(wiScope, (items) => {
|
|
323
323
|
if (!Array.isArray(items)) return items;
|
|
324
324
|
const idx = items.findIndex(w => w && w.id === itemId);
|
|
325
325
|
if (idx === -1) return items;
|
|
@@ -393,8 +393,7 @@ function _isPrdSourcedAndVetted(wi) {
|
|
|
393
393
|
const sourcePlan = wi && wi.sourcePlan;
|
|
394
394
|
if (!sourcePlan || typeof sourcePlan !== 'string') return false;
|
|
395
395
|
try {
|
|
396
|
-
const
|
|
397
|
-
const plan = shared.safeJsonNoRestore(prdPath);
|
|
396
|
+
const plan = require('./prd-store').readPrd(sourcePlan);
|
|
398
397
|
if (!plan || !plan.missing_features) return false;
|
|
399
398
|
const status = plan.status || shared.PLAN_STATUS.ACTIVE;
|
|
400
399
|
return status === shared.PLAN_STATUS.APPROVED || status === shared.PLAN_STATUS.ACTIVE;
|
|
@@ -454,7 +453,7 @@ async function addToDispatchWithValidation(item, opts = {}) {
|
|
|
454
453
|
: _isPrdSourcedAndVetted;
|
|
455
454
|
if (isPrdVetted(wi)) {
|
|
456
455
|
// Stamp the skip marker on the dispatch item so the entry that lands
|
|
457
|
-
// in dispatch
|
|
456
|
+
// in dispatch state carries observability — operators can spot which
|
|
458
457
|
// items the validator skipped vs which it ran.
|
|
459
458
|
item._preDispatchEvalSkipped = 'prd-sourced';
|
|
460
459
|
return addToDispatch(item);
|
|
@@ -569,17 +568,17 @@ function getStalePrDispatchReason(entry, config) {
|
|
|
569
568
|
function cancelSourceWorkItemForPrunedDispatch(entry, reason) {
|
|
570
569
|
const itemId = entry?.meta?.item?.id;
|
|
571
570
|
if (!itemId) return false;
|
|
572
|
-
let
|
|
573
|
-
try {
|
|
571
|
+
let wiScope;
|
|
572
|
+
try { wiScope = lifecycle().resolveWorkItemScope(entry.meta); }
|
|
574
573
|
catch (e) {
|
|
575
574
|
log('warn', `Failed to resolve source work item for pruned dispatch ${entry.id}: ${e.message}`);
|
|
576
575
|
return false;
|
|
577
576
|
}
|
|
578
|
-
if (!
|
|
577
|
+
if (!wiScope) return false;
|
|
579
578
|
|
|
580
579
|
let cancelled = false;
|
|
581
580
|
try {
|
|
582
|
-
mutateWorkItems(
|
|
581
|
+
mutateWorkItems(wiScope, (items) => {
|
|
583
582
|
if (!Array.isArray(items)) return items;
|
|
584
583
|
const wi = items.find(w => w && w.id === itemId);
|
|
585
584
|
if (!wi) return items;
|
|
@@ -608,9 +607,9 @@ function _resolveWorkItemForPrunedEntry(entry) {
|
|
|
608
607
|
const itemId = entry?.meta?.item?.id;
|
|
609
608
|
if (!itemId) return null;
|
|
610
609
|
try {
|
|
611
|
-
const
|
|
612
|
-
if (!
|
|
613
|
-
const items =
|
|
610
|
+
const wiScope = lifecycle().resolveWorkItemScope(entry.meta);
|
|
611
|
+
if (!wiScope) return null;
|
|
612
|
+
const items = readWorkItems(wiScope);
|
|
614
613
|
return items.find(w => w && w.id === itemId) || null;
|
|
615
614
|
} catch {
|
|
616
615
|
return null;
|
|
@@ -851,9 +850,9 @@ const FORCE_DEMOTE_FAILURE_CLASSES = new Set([
|
|
|
851
850
|
function readLiveWorkItem(meta) {
|
|
852
851
|
const itemId = meta?.item?.id;
|
|
853
852
|
if (!itemId) return null;
|
|
854
|
-
const
|
|
855
|
-
if (!
|
|
856
|
-
const items =
|
|
853
|
+
const wiScope = lifecycle().resolveWorkItemScope(meta);
|
|
854
|
+
if (!wiScope) return null;
|
|
855
|
+
const items = readWorkItems(wiScope);
|
|
857
856
|
return items.find(i => i.id === itemId) || null;
|
|
858
857
|
}
|
|
859
858
|
|
|
@@ -894,11 +893,11 @@ function markCompletedSourceWorkItem(item) {
|
|
|
894
893
|
const meta = item?.meta;
|
|
895
894
|
const itemId = meta?.item?.id;
|
|
896
895
|
if (!itemId) return false;
|
|
897
|
-
const
|
|
898
|
-
if (!
|
|
896
|
+
const wiScope = lifecycle().resolveWorkItemScope(meta);
|
|
897
|
+
if (!wiScope) return false;
|
|
899
898
|
let found = false;
|
|
900
899
|
let terminal = false;
|
|
901
|
-
mutateWorkItems(
|
|
900
|
+
mutateWorkItems(wiScope, items => {
|
|
902
901
|
if (!Array.isArray(items)) return items;
|
|
903
902
|
const wi = items.find(i => i.id === itemId);
|
|
904
903
|
if (!wi) return items;
|
|
@@ -985,7 +984,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
985
984
|
if (item) {
|
|
986
985
|
log('info', `Completed dispatch: ${id} (${result}${reason ? ': ' + reason : ''})`);
|
|
987
986
|
// W-mpob4nyk0006580e — broadcast terminal state immediately after the
|
|
988
|
-
// `result` field is committed to dispatch.
|
|
987
|
+
// `result` field is committed to dispatch state. This is the single
|
|
989
988
|
// source of truth for the live-view banner; subscribers (dashboard SSE
|
|
990
989
|
// handler at handleAgentLiveStream) push it to clients so the banner
|
|
991
990
|
// mirrors `dispatch.result` exactly — no log-parsing heuristics that can
|
|
@@ -1046,11 +1045,11 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
1046
1045
|
const isForceDemote = failureClass && FORCE_DEMOTE_FAILURE_CLASSES.has(failureClass) && !isPrShipped;
|
|
1047
1046
|
if (isForceDemote) {
|
|
1048
1047
|
try {
|
|
1049
|
-
const
|
|
1050
|
-
if (
|
|
1048
|
+
const wiScope = lifecycle().resolveWorkItemScope(item.meta);
|
|
1049
|
+
if (wiScope) {
|
|
1051
1050
|
const classSuffix = ` [${failureClass.toUpperCase().replace(/-/g, '_')}]`;
|
|
1052
1051
|
const demoteReason = `Non-retryable failure: ${reason || failureClass}${classSuffix}`;
|
|
1053
|
-
mutateWorkItems(
|
|
1052
|
+
mutateWorkItems(wiScope, items => {
|
|
1054
1053
|
const wi = items.find(i => i.id === item.meta.item.id);
|
|
1055
1054
|
if (wi) {
|
|
1056
1055
|
wi.status = WI_STATUS.FAILED;
|
|
@@ -1093,7 +1092,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
1093
1092
|
} else {
|
|
1094
1093
|
let retries = (item.meta.item._retryCount || 0);
|
|
1095
1094
|
try {
|
|
1096
|
-
const wi =
|
|
1095
|
+
const wi = liveWi || readLiveWorkItem(item.meta);
|
|
1097
1096
|
if (wi) retries = wi._retryCount || 0;
|
|
1098
1097
|
} catch (e) { log('warn', 'read retry count: ' + e.message); }
|
|
1099
1098
|
const maxRetries = ENGINE_DEFAULTS.maxRetries;
|
|
@@ -1112,9 +1111,9 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
1112
1111
|
}
|
|
1113
1112
|
// Increment retry counter on the source work item
|
|
1114
1113
|
try {
|
|
1115
|
-
const
|
|
1116
|
-
if (
|
|
1117
|
-
mutateWorkItems(
|
|
1114
|
+
const wiScope = lifecycle().resolveWorkItemScope(item.meta);
|
|
1115
|
+
if (wiScope) {
|
|
1116
|
+
mutateWorkItems(wiScope, items => {
|
|
1118
1117
|
const wi = items.find(i => i.id === item.meta.item.id);
|
|
1119
1118
|
if (wi && wi.status !== WI_STATUS.PAUSED && wi.status !== WI_STATUS.DONE && !wi.completedAt) {
|
|
1120
1119
|
wi._retryCount = retries + 1;
|
|
@@ -1229,10 +1228,9 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
1229
1228
|
const isTransientPreflight = TRANSIENT_PREFLIGHT_CLASSES.has(failureClass);
|
|
1230
1229
|
if (prId && project) {
|
|
1231
1230
|
try {
|
|
1232
|
-
const prsPath = projectPrPath(project);
|
|
1233
1231
|
let restored = false;
|
|
1234
1232
|
let gaveUp = false;
|
|
1235
|
-
mutatePullRequests(
|
|
1233
|
+
mutatePullRequests(project, prs => {
|
|
1236
1234
|
const target = shared.findPrRecord(prs, { id: prId }, project);
|
|
1237
1235
|
if (!target?.humanFeedback) return;
|
|
1238
1236
|
if (isTransientPreflight) {
|
|
@@ -1383,12 +1381,7 @@ function cleanDispatchEntries(matchFn) {
|
|
|
1383
1381
|
const filesToDelete = [];
|
|
1384
1382
|
const dispatchDirsToRemove = [];
|
|
1385
1383
|
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.
|
|
1384
|
+
// Keep the drain and orphan-tagging passes atomic in the dispatch store.
|
|
1392
1385
|
mutateDispatch((dispatch) => {
|
|
1393
1386
|
for (const queue of ['pending', 'active', 'completed']) {
|
|
1394
1387
|
dispatch[queue] = Array.isArray(dispatch[queue]) ? dispatch[queue] : [];
|
|
@@ -1449,10 +1442,10 @@ function cleanDispatchEntries(matchFn) {
|
|
|
1449
1442
|
* Cancel pending/queued work items matching a predicate. Done items pass through.
|
|
1450
1443
|
* Sets status=CANCELLED + _cancelledBy=reason. Returns count cancelled.
|
|
1451
1444
|
*/
|
|
1452
|
-
function cancelPendingWorkItems(
|
|
1445
|
+
function cancelPendingWorkItems(scope, matchFn, reason) {
|
|
1453
1446
|
let cancelled = 0;
|
|
1454
1447
|
try {
|
|
1455
|
-
mutateWorkItems(
|
|
1448
|
+
mutateWorkItems(scope, items => {
|
|
1456
1449
|
for (const w of items) {
|
|
1457
1450
|
if (!matchFn(w)) continue;
|
|
1458
1451
|
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
|