@yemi33/minions 0.1.2378 → 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/README.md +1 -0
- 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-inbox-entries-schema.md +31 -39
- 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/acp-transport.js +49 -8
- package/engine/ado-status.js +5 -5
- package/engine/ado.js +20 -25
- package/engine/agent-worker-pool.js +124 -15
- 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/pooled-agent-process.js +46 -26
- 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 +167 -349
- 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
package/engine.js
CHANGED
|
@@ -57,7 +57,7 @@ const IDENTITY_DIR = path.join(MINIONS_DIR, 'identity');
|
|
|
57
57
|
|
|
58
58
|
// Re-export from queries for internal use (avoid changing every call site)
|
|
59
59
|
const { CONFIG_PATH, NOTES_PATH, AGENTS_DIR, ENGINE_DIR, CONTROL_PATH,
|
|
60
|
-
|
|
60
|
+
INBOX_DIR, KNOWLEDGE_DIR, PLANS_DIR, PRD_DIR } = queries;
|
|
61
61
|
|
|
62
62
|
// ─── Multi-Project Support ──────────────────────────────────────────────────
|
|
63
63
|
// Config can have either:
|
|
@@ -102,7 +102,7 @@ function validateConfig(config) {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
const { getProjects, projectRoot, projectStateDir,
|
|
105
|
+
const { getProjects, projectRoot, projectStateDir, getAdoOrgBase, sanitizeBranch, parseSkillFrontmatter, safeReadDir,
|
|
106
106
|
logTs, dateStamp, log } = shared;
|
|
107
107
|
|
|
108
108
|
// ─── Utilities ──────────────────────────────────────────────────────────────
|
|
@@ -110,12 +110,13 @@ const { getProjects, projectRoot, projectStateDir, projectWorkItemsPath, project
|
|
|
110
110
|
const safeJson = shared.safeJson;
|
|
111
111
|
const safeJsonArr = shared.safeJsonArr;
|
|
112
112
|
const safeJsonObj = shared.safeJsonObj;
|
|
113
|
-
const safeJsonNoRestore = shared.safeJsonNoRestore;
|
|
114
113
|
const safeRead = shared.safeRead;
|
|
115
114
|
const safeWrite = shared.safeWrite;
|
|
116
115
|
const safeUnlink = shared.safeUnlink;
|
|
117
116
|
const mutateJsonFileLocked = shared.mutateJsonFileLocked;
|
|
118
117
|
const mutateControl = shared.mutateControl;
|
|
118
|
+
const readWorkItems = shared.readWorkItems;
|
|
119
|
+
const readPullRequests = shared.readPullRequests;
|
|
119
120
|
const mutateWorkItems = shared.mutateWorkItems;
|
|
120
121
|
const mutatePullRequests = shared.mutatePullRequests;
|
|
121
122
|
const withFileLock = shared.withFileLock;
|
|
@@ -175,7 +176,7 @@ const ghToken = require('./engine/gh-token');
|
|
|
175
176
|
const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, reconcilePrdStatuses, handlePostMerge, checkPlanCompletion,
|
|
176
177
|
syncPrsFromOutput, updatePrAfterReview, updatePrAfterFix, checkForLearnings, extractSkillsFromOutput,
|
|
177
178
|
updateAgentHistory, updateMetrics, createReviewFeedbackForAuthor, parseAgentOutput, syncPrdFromPrs, persistVerifyPrsToPrd,
|
|
178
|
-
isItemCompleted, classifyFailure: classifyFailureFallback, diagnoseEmptyOutput, processPendingRebases,
|
|
179
|
+
isItemCompleted, classifyFailure: classifyFailureFallback, diagnoseEmptyOutput, processPendingRebases, resolveWorkItemScope,
|
|
179
180
|
mergeArtifactNotes, promoteCompletionArtifacts, pruneScopeMismatchDuplicatePrs, collapseAllDuplicatePrRecords,
|
|
180
181
|
recordWorktreeHeldPause, clearWorktreeHeldPause } = require('./engine/lifecycle');
|
|
181
182
|
|
|
@@ -701,8 +702,7 @@ function resolveDependencyBranches(depIds, sourcePlan, project, config) {
|
|
|
701
702
|
|
|
702
703
|
// Find PR branches for each dependency work item
|
|
703
704
|
for (const p of projects) {
|
|
704
|
-
const
|
|
705
|
-
const prs = safeJsonArr(prPath);
|
|
705
|
+
const prs = readPullRequests(p);
|
|
706
706
|
for (const pr of prs) {
|
|
707
707
|
if (!pr.branch || pr.status !== PR_STATUS.ACTIVE) continue;
|
|
708
708
|
const linked = (pr.prdItems || []).some(id =>
|
|
@@ -2159,7 +2159,7 @@ function _tryAutoResolveLiveCheckoutWorktreeConflict({ conflictPath, gitRoot, en
|
|
|
2159
2159
|
async function spawnAgent(dispatchItem, config) {
|
|
2160
2160
|
const { id, agent: agentId, type, meta } = dispatchItem;
|
|
2161
2161
|
// Resolve prompt — prefers sidecar file when dispatchItem._promptFile is set
|
|
2162
|
-
// (large prompts are written to engine/contexts/<id>.md to keep dispatch
|
|
2162
|
+
// (large prompts are written to engine/contexts/<id>.md to keep dispatch rows
|
|
2163
2163
|
// small — see shared.sidecarDispatchPrompt / #1167).
|
|
2164
2164
|
let taskPrompt = shared.resolveDispatchPrompt(dispatchItem);
|
|
2165
2165
|
const claudeConfig = config.claude || {};
|
|
@@ -2415,12 +2415,8 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2415
2415
|
// dir via shared.dispatchPidCandidates / findDispatchPidFile. Best-effort —
|
|
2416
2416
|
// backward-compat fallbacks still scan dispatch-<safeId>-* dirs by id.
|
|
2417
2417
|
//
|
|
2418
|
-
// W-mq9b7lor (H1):
|
|
2419
|
-
//
|
|
2420
|
-
// mutateDispatch call because engine/dispatch.js#mutateDispatch regenerates
|
|
2421
|
-
// dispatch.json from SQL after every successful mutation (see
|
|
2422
|
-
// engine/dispatch.js:98-101). Without the SQL write, the tmpDir pointer is
|
|
2423
|
-
// lost and orphan-reap/kill/cleanup paths can't find the prompt directory.
|
|
2418
|
+
// W-mq9b7lor (H1): persist through mutateDispatch so the tmpDir pointer
|
|
2419
|
+
// survives and orphan-reap/kill/cleanup paths can find the prompt directory.
|
|
2424
2420
|
try {
|
|
2425
2421
|
dispatchItem.tmpDir = dispatchTmpDir;
|
|
2426
2422
|
mutateDispatch((dispatch) => {
|
|
@@ -2519,7 +2515,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2519
2515
|
if ((meta?.source === 'pr' || meta?.source === 'pr-human-feedback') && meta?.pr) {
|
|
2520
2516
|
try {
|
|
2521
2517
|
const pauseProject = meta.project || project;
|
|
2522
|
-
mutatePullRequests(
|
|
2518
|
+
mutatePullRequests(pauseProject, (prs) => {
|
|
2523
2519
|
if (!Array.isArray(prs)) return prs;
|
|
2524
2520
|
const target = shared.findPrRecord(prs, meta.pr, pauseProject);
|
|
2525
2521
|
if (!target) return prs;
|
|
@@ -2581,7 +2577,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2581
2577
|
// validation WI auto-created by the post-completion hook that carries a PR
|
|
2582
2578
|
// reference but no explicit branch), resolve the target branch from the work
|
|
2583
2579
|
// item's PR reference so prepareLiveCheckout can check out the correct PR
|
|
2584
|
-
// source branch. resolveWorkItemPrRecord loads
|
|
2580
|
+
// source branch. resolveWorkItemPrRecord loads tracked PR state and finds the
|
|
2585
2581
|
// PR record; resolvePrBranch extracts the source branch from it. Both helpers
|
|
2586
2582
|
// are hoisted function declarations so the order-of-definition is safe.
|
|
2587
2583
|
if (liveMode && !branchName && !READ_ONLY_ROOT_TASK_TYPES.has(type)) {
|
|
@@ -2682,9 +2678,9 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2682
2678
|
wiId: _wiIdForAlert,
|
|
2683
2679
|
log: (msg, lvl) => log(lvl || 'info', msg),
|
|
2684
2680
|
clearDirtyStamp: () => {
|
|
2685
|
-
const
|
|
2686
|
-
if (
|
|
2687
|
-
|
|
2681
|
+
const _wiScope = resolveWorkItemScope(dispatchItem.meta);
|
|
2682
|
+
if (_wiScope && dispatchItem.meta?.item?.id) {
|
|
2683
|
+
mutateWorkItems(_wiScope, (data) => {
|
|
2688
2684
|
if (!Array.isArray(data)) return data;
|
|
2689
2685
|
const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
|
|
2690
2686
|
if (wi && wi._pendingReason === 'live_checkout_dirty') delete wi._pendingReason;
|
|
@@ -2821,9 +2817,9 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2821
2817
|
try { writeInboxAlert(`live-checkout-dirty-${_wiIdForAlert}`, _alertBody); }
|
|
2822
2818
|
catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
|
|
2823
2819
|
try {
|
|
2824
|
-
const
|
|
2825
|
-
if (
|
|
2826
|
-
|
|
2820
|
+
const _wiScope = resolveWorkItemScope(dispatchItem.meta);
|
|
2821
|
+
if (_wiScope && dispatchItem.meta?.item?.id) {
|
|
2822
|
+
mutateWorkItems(_wiScope, (data) => {
|
|
2827
2823
|
if (!Array.isArray(data)) return data;
|
|
2828
2824
|
const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
|
|
2829
2825
|
if (wi) {
|
|
@@ -2892,9 +2888,9 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2892
2888
|
catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
|
|
2893
2889
|
const _pendingReason = _isMidOp ? 'live_checkout_mid_operation' : 'live_checkout_detached_head';
|
|
2894
2890
|
try {
|
|
2895
|
-
const
|
|
2896
|
-
if (
|
|
2897
|
-
|
|
2891
|
+
const _wiScope = resolveWorkItemScope(dispatchItem.meta);
|
|
2892
|
+
if (_wiScope && dispatchItem.meta?.item?.id) {
|
|
2893
|
+
mutateWorkItems(_wiScope, (data) => {
|
|
2898
2894
|
if (!Array.isArray(data)) return data;
|
|
2899
2895
|
const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
|
|
2900
2896
|
if (wi) wi._pendingReason = _pendingReason;
|
|
@@ -2966,9 +2962,9 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2966
2962
|
try { writeInboxAlert(`live-checkout-blocked-${_wiIdForAlert}`, _alertBody); }
|
|
2967
2963
|
catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
|
|
2968
2964
|
try {
|
|
2969
|
-
const
|
|
2970
|
-
if (
|
|
2971
|
-
|
|
2965
|
+
const _wiScope = resolveWorkItemScope(dispatchItem.meta);
|
|
2966
|
+
if (_wiScope && dispatchItem.meta?.item?.id) {
|
|
2967
|
+
mutateWorkItems(_wiScope, (data) => {
|
|
2972
2968
|
if (!Array.isArray(data)) return data;
|
|
2973
2969
|
const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
|
|
2974
2970
|
if (wi) wi._pendingReason = 'live_checkout_wrong_base';
|
|
@@ -3025,9 +3021,9 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3025
3021
|
try { writeInboxAlert(`live-checkout-blocked-${_wiIdForAlert}`, _alertBody); }
|
|
3026
3022
|
catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
|
|
3027
3023
|
try {
|
|
3028
|
-
const
|
|
3029
|
-
if (
|
|
3030
|
-
|
|
3024
|
+
const _wiScope = resolveWorkItemScope(dispatchItem.meta);
|
|
3025
|
+
if (_wiScope && dispatchItem.meta?.item?.id) {
|
|
3026
|
+
mutateWorkItems(_wiScope, (data) => {
|
|
3031
3027
|
if (!Array.isArray(data)) return data;
|
|
3032
3028
|
const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
|
|
3033
3029
|
if (wi) wi._pendingReason = 'live_checkout_blob_fetch';
|
|
@@ -3129,9 +3125,9 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3129
3125
|
try { writeInboxAlert(`live-checkout-worktree-conflict-${_wiIdForAlert}`, _alertBody); }
|
|
3130
3126
|
catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
|
|
3131
3127
|
try {
|
|
3132
|
-
const
|
|
3133
|
-
if (
|
|
3134
|
-
|
|
3128
|
+
const _wiScope = resolveWorkItemScope(dispatchItem.meta);
|
|
3129
|
+
if (_wiScope && dispatchItem.meta?.item?.id) {
|
|
3130
|
+
mutateWorkItems(_wiScope, (data) => {
|
|
3135
3131
|
if (!Array.isArray(data)) return data;
|
|
3136
3132
|
const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
|
|
3137
3133
|
if (wi) wi._pendingReason = 'live_checkout_worktree_conflict';
|
|
@@ -3215,9 +3211,9 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3215
3211
|
try { writeInboxAlert(`live-checkout-stale-base-${_wiIdForAlert}`, _alertBody); }
|
|
3216
3212
|
catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
|
|
3217
3213
|
try {
|
|
3218
|
-
const
|
|
3219
|
-
if (
|
|
3220
|
-
|
|
3214
|
+
const _wiScope = resolveWorkItemScope(dispatchItem.meta);
|
|
3215
|
+
if (_wiScope && dispatchItem.meta?.item?.id) {
|
|
3216
|
+
mutateWorkItems(_wiScope, (data) => {
|
|
3221
3217
|
if (!Array.isArray(data)) return data;
|
|
3222
3218
|
const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
|
|
3223
3219
|
if (wi) wi._pendingReason = 'live_checkout_stale_base';
|
|
@@ -3244,9 +3240,9 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3244
3240
|
// second-strike on its next dirty encounter. Only write when the stamp is set.
|
|
3245
3241
|
if (dispatchItem.meta?.item?._liveCheckoutDirtyAttempts) {
|
|
3246
3242
|
try {
|
|
3247
|
-
const
|
|
3248
|
-
if (
|
|
3249
|
-
|
|
3243
|
+
const _wiScope = resolveWorkItemScope(dispatchItem.meta);
|
|
3244
|
+
if (_wiScope && dispatchItem.meta?.item?.id) {
|
|
3245
|
+
mutateWorkItems(_wiScope, (data) => {
|
|
3250
3246
|
if (!Array.isArray(data)) return data;
|
|
3251
3247
|
const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
|
|
3252
3248
|
if (wi) delete wi._liveCheckoutDirtyAttempts;
|
|
@@ -3905,7 +3901,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3905
3901
|
// Fetch all dependency branches in parallel (git fetches are independent)
|
|
3906
3902
|
const fetchable = depBranches.filter(d => !_failedRefCache.has(d.branch));
|
|
3907
3903
|
const unfetchable = depBranches.filter(d => _failedRefCache.has(d.branch));
|
|
3908
|
-
const allPrsForDeps = unfetchable.length > 0 ? shared.getProjects(config).reduce((acc, p) => acc.concat(
|
|
3904
|
+
const allPrsForDeps = unfetchable.length > 0 ? shared.getProjects(config).reduce((acc, p) => acc.concat(readPullRequests(p)), []) : [];
|
|
3909
3905
|
for (const { branch: depBranch, prId } of unfetchable) {
|
|
3910
3906
|
const pr = allPrsForDeps.find(p => p.id === prId);
|
|
3911
3907
|
if (pr && (pr.status === PR_STATUS.MERGED || pr.status === PR_STATUS.CLOSED)) {
|
|
@@ -3938,7 +3934,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3938
3934
|
})
|
|
3939
3935
|
);
|
|
3940
3936
|
const hasFetchFailures = fetchResults.some(r => r.status === 'rejected');
|
|
3941
|
-
const allPrsForFetch = hasFetchFailures ? shared.getProjects(config).reduce((acc, p) => acc.concat(
|
|
3937
|
+
const allPrsForFetch = hasFetchFailures ? shared.getProjects(config).reduce((acc, p) => acc.concat(readPullRequests(p)), []) : [];
|
|
3942
3938
|
// Track branches recovered by local-only push so they can be merged
|
|
3943
3939
|
const recoveredBranches = new Set();
|
|
3944
3940
|
for (let i = 0; i < fetchResults.length; i++) {
|
|
@@ -4291,9 +4287,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4291
4287
|
// so commits land on the dep's existing PR branch (W-mpcwojgr000a0244).
|
|
4292
4288
|
if (depConflictBranch && meta?.item?.id && project) {
|
|
4293
4289
|
try {
|
|
4294
|
-
const
|
|
4295
|
-
? projectWorkItemsPath(project)
|
|
4296
|
-
: path.join(MINIONS_DIR, 'work-items.json');
|
|
4290
|
+
const wiScope = project.name ? project : 'central';
|
|
4297
4291
|
const newItem = buildDepConflictFixItem({
|
|
4298
4292
|
depConflictBranch,
|
|
4299
4293
|
depConflictFiles,
|
|
@@ -4303,7 +4297,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4303
4297
|
blockedItem: meta.item,
|
|
4304
4298
|
projectName: project.name || null,
|
|
4305
4299
|
});
|
|
4306
|
-
mutateWorkItems(
|
|
4300
|
+
mutateWorkItems(wiScope, items => {
|
|
4307
4301
|
// Don't create duplicate conflict-fix items
|
|
4308
4302
|
const existing = items.find(i => i.id === newItem.id && i.status !== WI_STATUS.DONE && i.status !== WI_STATUS.FAILED && i.status !== WI_STATUS.CANCELLED);
|
|
4309
4303
|
if (existing) return;
|
|
@@ -4538,7 +4532,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4538
4532
|
log('info', `Spawning agent: ${agentId} (${id}) in ${cwd}`);
|
|
4539
4533
|
log('info', `Task type: ${type} | Branch: ${branchName || 'none'}`);
|
|
4540
4534
|
|
|
4541
|
-
// Agent status is derived from dispatch
|
|
4535
|
+
// Agent status is derived from dispatch state.
|
|
4542
4536
|
|
|
4543
4537
|
// Spawn the claude process
|
|
4544
4538
|
const childEnv = shared.cleanChildEnv();
|
|
@@ -4693,13 +4687,24 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4693
4687
|
});
|
|
4694
4688
|
} else {
|
|
4695
4689
|
// P-1d8f0b93 — pooled copilot dispatch. No separate spawn-agent.js
|
|
4696
|
-
// child process exists, so
|
|
4697
|
-
//
|
|
4698
|
-
//
|
|
4699
|
-
// cold-spawning. Everything downstream (stdout/stderr handlers,
|
|
4690
|
+
// child process exists, so hand the task and trusted system context to
|
|
4691
|
+
// the PooledAgentProcess separately instead of cold-spawning. Everything
|
|
4692
|
+
// downstream (stdout/stderr handlers,
|
|
4700
4693
|
// onAgentClose, live-output.log, PID-file writing below) treats `proc`
|
|
4701
4694
|
// identically to a real child_process either way.
|
|
4702
|
-
const
|
|
4695
|
+
const pooledSessionKeys = [
|
|
4696
|
+
'MINIONS_COMPLETION_REPORT',
|
|
4697
|
+
'MINIONS_COMPLETION_NONCE',
|
|
4698
|
+
'MINIONS_LIVE_OUTPUT_PATH',
|
|
4699
|
+
'MINIONS_STEERING_ACK_DIR',
|
|
4700
|
+
'MINIONS_AGENT_CWD',
|
|
4701
|
+
];
|
|
4702
|
+
const pooledProcessEnv = { ...childEnv };
|
|
4703
|
+
const pooledSessionContext = {};
|
|
4704
|
+
for (const key of pooledSessionKeys) {
|
|
4705
|
+
if (pooledProcessEnv[key] !== undefined) pooledSessionContext[key] = pooledProcessEnv[key];
|
|
4706
|
+
delete pooledProcessEnv[key];
|
|
4707
|
+
}
|
|
4703
4708
|
proc = new PooledAgentProcess({
|
|
4704
4709
|
pool: agentWorkerPool,
|
|
4705
4710
|
dispatchId: id,
|
|
@@ -4710,7 +4715,10 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4710
4715
|
// flat top-level config array, not per-agent-resolved.
|
|
4711
4716
|
mcpServers: (engineConfig && engineConfig.mcpServers) || [],
|
|
4712
4717
|
hermeticDirs: addDirs,
|
|
4713
|
-
|
|
4718
|
+
processEnv: pooledProcessEnv,
|
|
4719
|
+
sessionContext: pooledSessionContext,
|
|
4720
|
+
systemPromptText: systemPrompt,
|
|
4721
|
+
promptText: fullTaskPrompt,
|
|
4714
4722
|
});
|
|
4715
4723
|
// The child_process PID file is normally written asynchronously by
|
|
4716
4724
|
// spawn-agent.js itself (engine/spawn-agent.js:606) once it starts.
|
|
@@ -5398,7 +5406,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5398
5406
|
// agent described in its sidecar. This gate (a) rejects malformed
|
|
5399
5407
|
// sidecars as a hard non-retryable failure with a dedicated failure
|
|
5400
5408
|
// class + inbox alert, and (b) on success spawns each spec detached and
|
|
5401
|
-
// batch-records them in
|
|
5409
|
+
// batch-records them in SQL managed-process state. Healthcheck loops
|
|
5402
5410
|
// + dispatch ERROR-on-healthcheck-failure land in the follow-up item;
|
|
5403
5411
|
// for now a spec that spawns successfully is recorded with
|
|
5404
5412
|
// healthy:false, alive:true and the engine sweep / item-3 healthcheck
|
|
@@ -5897,9 +5905,9 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5897
5905
|
// engine/managed-logs/.
|
|
5898
5906
|
if (managedSpawnHealthcheckFailure && managedSpawnHealthcheckFailure.annotation && dispatchItem.meta?.item?.id) {
|
|
5899
5907
|
try {
|
|
5900
|
-
const
|
|
5901
|
-
if (
|
|
5902
|
-
mutateWorkItems(
|
|
5908
|
+
const wiScope = resolveWorkItemScope(dispatchItem.meta);
|
|
5909
|
+
if (wiScope) {
|
|
5910
|
+
mutateWorkItems(wiScope, items => {
|
|
5903
5911
|
const wi = items.find(i => i.id === dispatchItem.meta.item.id);
|
|
5904
5912
|
if (!wi) return items;
|
|
5905
5913
|
wi._managedSpawnPartial = managedSpawnHealthcheckFailure.annotation;
|
|
@@ -5916,9 +5924,9 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5916
5924
|
// rather than a queue gate.
|
|
5917
5925
|
if (keepProcessesAcceptanceFailure && dispatchItem.meta?.item?.id) {
|
|
5918
5926
|
try {
|
|
5919
|
-
const
|
|
5920
|
-
if (
|
|
5921
|
-
|
|
5927
|
+
const wiScope = resolveWorkItemScope(dispatchItem.meta);
|
|
5928
|
+
if (wiScope) {
|
|
5929
|
+
mutateWorkItems(wiScope, data => {
|
|
5922
5930
|
if (!Array.isArray(data)) return data;
|
|
5923
5931
|
const wi = data.find(i => i.id === dispatchItem.meta.item.id);
|
|
5924
5932
|
if (!wi) return data;
|
|
@@ -5935,7 +5943,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5935
5943
|
// Cleanup temp files (whole per-dispatch dir, including PID/sysprompt
|
|
5936
5944
|
// tmp/prompt — P-f6-tmp-toctou). removeDispatchTmpDir validates the path
|
|
5937
5945
|
// resolves under engine/tmp/dispatch-* before rmSync, so a corrupted
|
|
5938
|
-
// dispatch
|
|
5946
|
+
// dispatch field cannot redirect this to an arbitrary path.
|
|
5939
5947
|
try { shared.removeDispatchTmpDir(dispatchTmpDir); } catch { /* cleanup */ }
|
|
5940
5948
|
|
|
5941
5949
|
log('info', `Agent ${agentId} completed. Output saved to ${archivePath}`);
|
|
@@ -6037,7 +6045,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
6037
6045
|
item.started_at = startedAt;
|
|
6038
6046
|
// Persist the resolved runtime so post-completion hooks (lifecycle.js) can
|
|
6039
6047
|
// route output parsing through the right adapter. Also surfaces the choice
|
|
6040
|
-
// in dispatch
|
|
6048
|
+
// in dispatch state for debugging multi-runtime fleets.
|
|
6041
6049
|
item.runtimeName = runtimeName;
|
|
6042
6050
|
// P-3f5b7d26 — persist pooled on the DISK record (activeProcesses/_pooled
|
|
6043
6051
|
// is wiped on restart); cli.js's reattach path needs this since a pooled
|
|
@@ -6064,14 +6072,9 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
6064
6072
|
// Atomically stamp dispatched_to/dispatched_at on the originating work item (#402).
|
|
6065
6073
|
if (meta?.item?.id) {
|
|
6066
6074
|
try {
|
|
6067
|
-
|
|
6068
|
-
if (
|
|
6069
|
-
|
|
6070
|
-
} else if (meta.source === 'work-item' && meta.project?.name) {
|
|
6071
|
-
wiPath = projectWorkItemsPath({ name: meta.project.name, localPath: meta.project.localPath });
|
|
6072
|
-
}
|
|
6073
|
-
if (wiPath) {
|
|
6074
|
-
mutateJsonFileLocked(wiPath, (items) => {
|
|
6075
|
+
const wiScope = resolveWorkItemScope(meta);
|
|
6076
|
+
if (wiScope) {
|
|
6077
|
+
mutateWorkItems(wiScope, (items) => {
|
|
6075
6078
|
if (!Array.isArray(items)) return items;
|
|
6076
6079
|
const wi = items.find(i => i.id === meta.item.id);
|
|
6077
6080
|
if (wi) {
|
|
@@ -6080,7 +6083,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
6080
6083
|
if (wi.status !== WI_STATUS.DISPATCHED) wi.status = WI_STATUS.DISPATCHED;
|
|
6081
6084
|
}
|
|
6082
6085
|
return items;
|
|
6083
|
-
}
|
|
6086
|
+
});
|
|
6084
6087
|
}
|
|
6085
6088
|
} catch (e) { log('warn', `stamp dispatched_to on work item ${meta.item.id}: ${e.message}`); }
|
|
6086
6089
|
}
|
|
@@ -6105,12 +6108,9 @@ function areDependenciesMet(item, config) {
|
|
|
6105
6108
|
for (const depId of deps) {
|
|
6106
6109
|
const depItem = allWorkItems.find(w => w.id === depId);
|
|
6107
6110
|
if (!depItem) {
|
|
6108
|
-
// Fallback: check PRD
|
|
6109
|
-
// safeJsonNoRestore — if the PRD has been archived, treat the dep as
|
|
6110
|
-
// unmet rather than resurrecting the active PRD from .backup
|
|
6111
|
-
// (W-mouptdh1000h9f39).
|
|
6111
|
+
// Fallback: check the PRD store — plan-to-prd agents may pre-set items to done.
|
|
6112
6112
|
try {
|
|
6113
|
-
const plan =
|
|
6113
|
+
const plan = prdStore.readPrd(sourcePlan);
|
|
6114
6114
|
const prdItem = (plan?.missing_features || []).find(f => f.id === depId);
|
|
6115
6115
|
if (prdItem && PRD_MET_STATUSES.has(prdItem.status)) continue; // PRD says done — treat as met
|
|
6116
6116
|
} catch (e) { log('warn', 'check PRD dep status: ' + e.message); }
|
|
@@ -6182,8 +6182,8 @@ function detectDependencyCycles(items) {
|
|
|
6182
6182
|
}
|
|
6183
6183
|
|
|
6184
6184
|
// Reconciles work items against known PRs.
|
|
6185
|
-
// Primary linkage comes from
|
|
6186
|
-
// uses
|
|
6185
|
+
// Primary linkage comes from tracked PR prdItems; fallback linkage
|
|
6186
|
+
// uses SQL PR links so matching does not depend on branch/title parsing.
|
|
6187
6187
|
// onlyIds: if provided, only items whose ID is in this Set are eligible.
|
|
6188
6188
|
function reconcileItemsWithPrs(items, allPrs, { onlyIds } = {}) {
|
|
6189
6189
|
const prLinks = shared.getPrLinks();
|
|
@@ -6275,7 +6275,7 @@ function updateSnapshot(config) {
|
|
|
6275
6275
|
|
|
6276
6276
|
// ─── Cooldowns (extracted to engine/cooldown.js) ─────────────────────────────
|
|
6277
6277
|
|
|
6278
|
-
const {
|
|
6278
|
+
const { dispatchCooldowns, loadCooldowns, saveCooldowns,
|
|
6279
6279
|
isOnCooldown, setCooldown, setCooldownWithContext, drainCoalescedContexts,
|
|
6280
6280
|
setCooldownFailure, clearCooldown, getPrReviewCooldownKey, clearLegacyPrReviewCooldown,
|
|
6281
6281
|
isAlreadyDispatched, isBranchActive } = require('./engine/cooldown');
|
|
@@ -6283,12 +6283,11 @@ const { COOLDOWN_PATH, dispatchCooldowns, loadCooldowns, saveCooldowns,
|
|
|
6283
6283
|
// Auto-clean pending/failed work items for a PRD so they re-materialize with updated plan data
|
|
6284
6284
|
function autoCleanPrdWorkItems(prdFile, config) {
|
|
6285
6285
|
const allProjects = getProjects(config);
|
|
6286
|
-
const
|
|
6287
|
-
for (const proj of allProjects) wiPaths.push(projectWorkItemsPath(proj));
|
|
6286
|
+
const workItemScopes = ['central', ...allProjects];
|
|
6288
6287
|
const deletedIds = [];
|
|
6289
|
-
for (const
|
|
6288
|
+
for (const scope of workItemScopes) {
|
|
6290
6289
|
try {
|
|
6291
|
-
mutateWorkItems(
|
|
6290
|
+
mutateWorkItems(scope, items => {
|
|
6292
6291
|
const filtered = items.filter(w => {
|
|
6293
6292
|
if (w.sourcePlan === prdFile && (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.FAILED)) {
|
|
6294
6293
|
deletedIds.push(w.id); return false;
|
|
@@ -6331,51 +6330,6 @@ function safePrdProjectSlug(projectName) {
|
|
|
6331
6330
|
return slug || 'project';
|
|
6332
6331
|
}
|
|
6333
6332
|
|
|
6334
|
-
/**
|
|
6335
|
-
* Atomically reserve a unique PRD filename in `prdDir` (P-9b7e5d3c).
|
|
6336
|
-
*
|
|
6337
|
-
* Replaces the racy "fs.existsSync(...) then write" pattern: two parallel
|
|
6338
|
-
* materializations on the same plan slug previously both observed a 'free'
|
|
6339
|
-
* filename and silently overwrote each other. This helper uses
|
|
6340
|
-
* `fs.openSync(path, 'wx')` so the OS rejects duplicate creates atomically.
|
|
6341
|
-
*
|
|
6342
|
-
* Attempt schedule (100 attempts total):
|
|
6343
|
-
* attempt = 0 → baseFileName (e.g. "slug.json")
|
|
6344
|
-
* attempt = 1 → "${stem}-1${ext}" (e.g. "slug-1.json")
|
|
6345
|
-
* attempt = 2 → "${stem}-2${ext}"
|
|
6346
|
-
* ...
|
|
6347
|
-
* attempt = 99 → "${stem}-99${ext}"
|
|
6348
|
-
*
|
|
6349
|
-
* Behavior:
|
|
6350
|
-
* - On EEXIST: increment attempt and retry.
|
|
6351
|
-
* - Any other openSync error propagates immediately.
|
|
6352
|
-
* - After 100 EEXIST failures: throw
|
|
6353
|
-
* 'Could not reserve unique PRD filename after 100 attempts'.
|
|
6354
|
-
* - On success: write `content` to the reserved file, close fd, return basename.
|
|
6355
|
-
*/
|
|
6356
|
-
function reservePrdFilename(prdDir, baseFileName, content = '') {
|
|
6357
|
-
const ext = path.extname(baseFileName);
|
|
6358
|
-
const stem = ext ? baseFileName.slice(0, -ext.length) : baseFileName;
|
|
6359
|
-
for (let attempt = 0; attempt < 100; attempt++) {
|
|
6360
|
-
const candidateName = attempt === 0 ? baseFileName : `${stem}-${attempt}${ext}`;
|
|
6361
|
-
const candidatePath = path.join(prdDir, candidateName);
|
|
6362
|
-
let fd;
|
|
6363
|
-
try {
|
|
6364
|
-
fd = fs.openSync(candidatePath, 'wx');
|
|
6365
|
-
} catch (err) {
|
|
6366
|
-
if (err && err.code === 'EEXIST') continue;
|
|
6367
|
-
throw err;
|
|
6368
|
-
}
|
|
6369
|
-
try {
|
|
6370
|
-
if (content) fs.writeSync(fd, content);
|
|
6371
|
-
} finally {
|
|
6372
|
-
fs.closeSync(fd);
|
|
6373
|
-
}
|
|
6374
|
-
return candidateName;
|
|
6375
|
-
}
|
|
6376
|
-
throw new Error('Could not reserve unique PRD filename after 100 attempts');
|
|
6377
|
-
}
|
|
6378
|
-
|
|
6379
6333
|
// Fleet branches the engine/CC create. A worktree-mode operator checkout should
|
|
6380
6334
|
// NEVER sit on one of these (or any branch); when it does, a flow ran raw
|
|
6381
6335
|
// `git checkout`/`merge` in the live checkout instead of a worktree.
|
|
@@ -6444,16 +6398,15 @@ function restoreHijackedOperatorCheckouts(config) {
|
|
|
6444
6398
|
}
|
|
6445
6399
|
|
|
6446
6400
|
function materializePlansAsWorkItems(config) {
|
|
6447
|
-
if (!fs.existsSync(PRD_DIR)) { try { fs.mkdirSync(PRD_DIR, { recursive: true }); } catch (e) { log('warn', 'create PRD directory: ' + e.message); } }
|
|
6448
6401
|
const writePrdLocked = (fileName, data) => {
|
|
6449
|
-
return
|
|
6402
|
+
return prdStore.writePrd(fileName, data);
|
|
6450
6403
|
};
|
|
6451
|
-
const mutatePrdLocked = (fileName, fallback, mutator
|
|
6452
|
-
return
|
|
6404
|
+
const mutatePrdLocked = (fileName, fallback, mutator) => {
|
|
6405
|
+
return prdStore.mutatePrd(fileName, (current) => {
|
|
6453
6406
|
if (!current?.missing_features && fallback?.missing_features) current = fallback;
|
|
6454
6407
|
if (!current || Array.isArray(current) || typeof current !== 'object') current = {};
|
|
6455
6408
|
return mutator(current) || current;
|
|
6456
|
-
}, { defaultValue: fallback || {}
|
|
6409
|
+
}, { defaultValue: fallback || {} });
|
|
6457
6410
|
};
|
|
6458
6411
|
const enforceDeclaredPlanProject = (fileName, currentPlan) => {
|
|
6459
6412
|
if (!currentPlan?.source_plan) return { fileName, plan: currentPlan };
|
|
@@ -6512,7 +6465,7 @@ function materializePlansAsWorkItems(config) {
|
|
|
6512
6465
|
const jsonName = mf.replace(/\.md$/, '.json');
|
|
6513
6466
|
// Atomic open ('wx' + retry) so two parallel migrations on the
|
|
6514
6467
|
// same slug don't both overwrite a single PRD (P-9b7e5d3c).
|
|
6515
|
-
const reserved =
|
|
6468
|
+
const reserved = prdStore.createPrdUnique(jsonName, parsed);
|
|
6516
6469
|
try { fs.unlinkSync(path.join(checkDir, mf)); } catch { /* cleanup */ }
|
|
6517
6470
|
log('info', `Plan enforcement: moved ${mf} → prd/${reserved} (PRDs must be .json in prd/)`);
|
|
6518
6471
|
}
|
|
@@ -6529,7 +6482,7 @@ function materializePlansAsWorkItems(config) {
|
|
|
6529
6482
|
if (parsed?.missing_features) {
|
|
6530
6483
|
// Atomic open ('wx' + retry) so two parallel migrations on the
|
|
6531
6484
|
// same slug don't both overwrite a single PRD (P-9b7e5d3c).
|
|
6532
|
-
const reserved =
|
|
6485
|
+
const reserved = prdStore.createPrdUnique(jf, parsed);
|
|
6533
6486
|
try { fs.unlinkSync(path.join(PLANS_DIR, jf)); } catch { /* cleanup */ }
|
|
6534
6487
|
log('info', `Auto-migrated PRD ${jf} from plans/ to prd/${reserved}`);
|
|
6535
6488
|
}
|
|
@@ -6549,10 +6502,7 @@ function materializePlansAsWorkItems(config) {
|
|
|
6549
6502
|
const SEQUENTIAL_ID_RE = /^[A-Za-z]+-?\d+$/;
|
|
6550
6503
|
|
|
6551
6504
|
for (let file of planFiles) {
|
|
6552
|
-
|
|
6553
|
-
// read, do not auto-resurrect it from a stale .backup sidecar
|
|
6554
|
-
// (W-mouptdh1000h9f39).
|
|
6555
|
-
let plan = safeJsonNoRestore(path.join(PRD_DIR, file));
|
|
6505
|
+
let plan = prdStore.readPrd(file);
|
|
6556
6506
|
if (!plan?.missing_features) continue;
|
|
6557
6507
|
// Resurrection-re-execution guard ("old PRDs came back"): never (re)materialize
|
|
6558
6508
|
// a defunct PRD — archived, or completed with its source plan gone from plans/.
|
|
@@ -6699,7 +6649,7 @@ function materializePlansAsWorkItems(config) {
|
|
|
6699
6649
|
const defaultProjectName = plan.project || file.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
|
|
6700
6650
|
const allProjects = getProjects(config);
|
|
6701
6651
|
const defaultProject = shared.resolveProjectSource(defaultProjectName, allProjects, { allowCentral: false }).project;
|
|
6702
|
-
// No project found — use central
|
|
6652
|
+
// No project found — use central state (engine works without projects).
|
|
6703
6653
|
const useCentral = !defaultProject;
|
|
6704
6654
|
|
|
6705
6655
|
const statusFilter = PRD_MATERIALIZABLE;
|
|
@@ -6723,7 +6673,7 @@ function materializePlansAsWorkItems(config) {
|
|
|
6723
6673
|
);
|
|
6724
6674
|
|
|
6725
6675
|
// Group items by target project (per-item project field overrides plan-level project)
|
|
6726
|
-
// When no projects are configured, all items
|
|
6676
|
+
// When no projects are configured, all items use the central scope.
|
|
6727
6677
|
const itemsByProject = new Map(); // projectName -> { project, items: [] }
|
|
6728
6678
|
for (const item of items) {
|
|
6729
6679
|
if (item.project) {
|
|
@@ -6773,19 +6723,19 @@ function materializePlansAsWorkItems(config) {
|
|
|
6773
6723
|
// A pause/reject/awaiting-approval signal may arrive between the outer-loop initial
|
|
6774
6724
|
// read and this point (e.g. operator pauses via dashboard while the tick is running).
|
|
6775
6725
|
// Re-reading here prevents phantom WI creation on a paused, rejected, or unapproved plan.
|
|
6776
|
-
const freshPrd = prdStore.readPrd(
|
|
6726
|
+
const freshPrd = prdStore.readPrd(file);
|
|
6777
6727
|
const freshStatus = (freshPrd || {}).status || ((freshPrd || {}).requires_approval ? 'awaiting-approval' : null);
|
|
6778
6728
|
if (freshStatus === PLAN_STATUS.PAUSED || freshStatus === PLAN_STATUS.REJECTED || freshStatus === 'awaiting-approval') {
|
|
6779
6729
|
log('warn', `PRD ${file}: status changed to '${freshStatus}' mid-tick — skipping WI batch for ${projName} (P-f1a3b5c7)`);
|
|
6780
6730
|
break;
|
|
6781
6731
|
}
|
|
6782
6732
|
|
|
6783
|
-
const
|
|
6733
|
+
const wiScope = project || 'central';
|
|
6784
6734
|
let created = 0;
|
|
6785
6735
|
const newlyCreatedIds = new Set(); // tracks IDs created in this pass for reconciliation scoping
|
|
6786
6736
|
const deferredReopens = []; // cross-project re-opens executed after this lock releases
|
|
6787
6737
|
|
|
6788
|
-
mutateWorkItems(
|
|
6738
|
+
mutateWorkItems(wiScope, existingItems => {
|
|
6789
6739
|
for (const item of projItems) {
|
|
6790
6740
|
// Re-open: 'updated' or 'missing' re-opens a done work item (#906)
|
|
6791
6741
|
// Use composite (sourcePlan, id) key so a WI from another PRD with the
|
|
@@ -6807,7 +6757,7 @@ function materializePlansAsWorkItems(config) {
|
|
|
6807
6757
|
if (!alreadyExists) {
|
|
6808
6758
|
for (const p of allProjects) {
|
|
6809
6759
|
if (String(p.name || '').toLowerCase() === String(projName || '').toLowerCase()) continue;
|
|
6810
|
-
const otherItems =
|
|
6760
|
+
const otherItems = readWorkItems(p);
|
|
6811
6761
|
const otherWi = otherItems.find(w => w.id === item.id && w.sourcePlan === file);
|
|
6812
6762
|
if (otherWi) {
|
|
6813
6763
|
if (DONE_STATUSES.has(otherWi.status) && shouldReopen) {
|
|
@@ -6853,7 +6803,7 @@ function materializePlansAsWorkItems(config) {
|
|
|
6853
6803
|
|
|
6854
6804
|
if (created > 0) {
|
|
6855
6805
|
// Reconciliation: exact prdItems match only, scoped to newly created items
|
|
6856
|
-
const allPrsForReconcile = allProjects.flatMap(p =>
|
|
6806
|
+
const allPrsForReconcile = allProjects.flatMap(p => readPullRequests(p));
|
|
6857
6807
|
const reconciled = reconcileItemsWithPrs(existingItems, allPrsForReconcile, { onlyIds: newlyCreatedIds });
|
|
6858
6808
|
if (reconciled > 0) log('info', `Plan reconciliation: marked ${reconciled} item(s) as done → ${projName}`);
|
|
6859
6809
|
|
|
@@ -6887,8 +6837,7 @@ function materializePlansAsWorkItems(config) {
|
|
|
6887
6837
|
for (const { itemId, projectName: rProjName, item: rItem } of deferredReopens) {
|
|
6888
6838
|
const rProject = shared.resolveProjectSource(rProjName, allProjects, { allowCentral: false }).project;
|
|
6889
6839
|
if (!rProject) continue;
|
|
6890
|
-
|
|
6891
|
-
mutateWorkItems(rPath, items => {
|
|
6840
|
+
mutateWorkItems(rProject, items => {
|
|
6892
6841
|
const target = items.find(w => w.id === itemId);
|
|
6893
6842
|
if (target && DONE_STATUSES.has(target.status)) {
|
|
6894
6843
|
shared.reopenWorkItem(target);
|
|
@@ -6968,8 +6917,7 @@ function materializePlansAsWorkItems(config) {
|
|
|
6968
6917
|
function clearPendingHumanFeedbackFlag(projectMeta, prId) {
|
|
6969
6918
|
if (!prId) return;
|
|
6970
6919
|
try {
|
|
6971
|
-
|
|
6972
|
-
mutatePullRequests(prsPath, prs => {
|
|
6920
|
+
mutatePullRequests(projectMeta, prs => {
|
|
6973
6921
|
const target = shared.findPrRecord(prs, prId, projectMeta);
|
|
6974
6922
|
if (!target?.humanFeedback?.pendingFix) return;
|
|
6975
6923
|
target.humanFeedback.pendingFix = false;
|
|
@@ -7044,7 +6992,7 @@ async function isWorktreeHeldPauseSuppressed(pr, project) {
|
|
|
7044
6992
|
}
|
|
7045
6993
|
if (!stillHeld) {
|
|
7046
6994
|
try {
|
|
7047
|
-
mutatePullRequests(
|
|
6995
|
+
mutatePullRequests(project, (prs) => {
|
|
7048
6996
|
if (!Array.isArray(prs)) return prs;
|
|
7049
6997
|
const target = shared.findPrRecord(prs, pr, project);
|
|
7050
6998
|
if (!target) return prs;
|
|
@@ -7096,7 +7044,7 @@ function resolvePrBranch(pr) {
|
|
|
7096
7044
|
function updatePrBranchResolutionState(project, pr, { branch = '', reason = '' } = {}) {
|
|
7097
7045
|
let changed = false;
|
|
7098
7046
|
try {
|
|
7099
|
-
mutatePullRequests(
|
|
7047
|
+
mutatePullRequests(project, prs => {
|
|
7100
7048
|
const target = shared.findPrRecord(prs, pr, project);
|
|
7101
7049
|
if (!target) return;
|
|
7102
7050
|
if (branch) {
|
|
@@ -7349,19 +7297,24 @@ function _resetAutoFixPausedLogState() {
|
|
|
7349
7297
|
// persist — the warning is for the operator at engine startup/run time).
|
|
7350
7298
|
const _warnedSilentDiscovery = new Set();
|
|
7351
7299
|
|
|
7352
|
-
function _warnSilentDiscoveryOnce(kind, project,
|
|
7300
|
+
function _warnSilentDiscoveryOnce(kind, project, config) {
|
|
7353
7301
|
const key = `${kind}:${project?.name || project?.localPath || 'default'}`;
|
|
7354
7302
|
if (_warnedSilentDiscovery.has(key)) return;
|
|
7355
7303
|
let count = 0;
|
|
7356
|
-
try {
|
|
7357
|
-
|
|
7304
|
+
try {
|
|
7305
|
+
const records = kind === 'pullRequests'
|
|
7306
|
+
? shared.readPullRequests(project)
|
|
7307
|
+
: shared.readWorkItems(project);
|
|
7308
|
+
if (Array.isArray(records)) count = records.length;
|
|
7309
|
+
} catch {}
|
|
7310
|
+
if (count <= 0) return;
|
|
7358
7311
|
_warnedSilentDiscovery.add(key);
|
|
7359
7312
|
const projName = project?.name || '(unnamed)';
|
|
7360
7313
|
const hasBlock = !!(project?.workSources?.[kind] || config?.workSources?.[kind]);
|
|
7361
7314
|
const hint = hasBlock
|
|
7362
7315
|
? `Toggle in Dashboard → Settings → Project, or set \`workSources.${kind}.enabled: true\` in config.json.`
|
|
7363
7316
|
: `Run \`minions doctor\` to inspect, \`minions init\` if you cloned the repo without setup, or re-link the project: \`minions add ${project?.localPath || projName}\`.`;
|
|
7364
|
-
log('warn', `Silent-discovery footgun: project "${projName}" has ${count} record(s)
|
|
7317
|
+
log('warn', `Silent-discovery footgun: project "${projName}" has ${count} ${kind} record(s) but workSources.${kind}.enabled is not true — engine will not pick them up. ${hint}`);
|
|
7365
7318
|
}
|
|
7366
7319
|
|
|
7367
7320
|
/**
|
|
@@ -7382,12 +7335,12 @@ function _logPrDispatchSkipOnce(pr, reason) {
|
|
|
7382
7335
|
}
|
|
7383
7336
|
|
|
7384
7337
|
/**
|
|
7385
|
-
* Scan
|
|
7338
|
+
* Scan tracked PR state for records needing review or fixes.
|
|
7386
7339
|
*/
|
|
7387
7340
|
async function discoverFromPrs(config, project) {
|
|
7388
7341
|
const src = project?.workSources?.pullRequests || config.workSources?.pullRequests;
|
|
7389
7342
|
if (!src?.enabled) {
|
|
7390
|
-
_warnSilentDiscoveryOnce('pullRequests', project,
|
|
7343
|
+
_warnSilentDiscoveryOnce('pullRequests', project, config);
|
|
7391
7344
|
return [];
|
|
7392
7345
|
}
|
|
7393
7346
|
|
|
@@ -7535,7 +7488,7 @@ async function discoverFromPrs(config, project) {
|
|
|
7535
7488
|
if (pr.reviewStatus !== REVIEW_STATUS.APPROVED) pr.reviewStatus = liveStatus;
|
|
7536
7489
|
// Persist so next tick doesn't re-check
|
|
7537
7490
|
try {
|
|
7538
|
-
|
|
7491
|
+
mutatePullRequests(project, data => {
|
|
7539
7492
|
if (!Array.isArray(data)) return data;
|
|
7540
7493
|
const target = shared.findPrRecord(data, pr, project);
|
|
7541
7494
|
if (target && target.reviewStatus !== REVIEW_STATUS.APPROVED) target.reviewStatus = liveStatus;
|
|
@@ -7751,7 +7704,7 @@ async function discoverFromPrs(config, project) {
|
|
|
7751
7704
|
log('info', `Pre-dispatch vote check: ${pr.id} is ${liveStatus} (cached was waiting) — skipping re-review`);
|
|
7752
7705
|
if (pr.reviewStatus !== REVIEW_STATUS.APPROVED) pr.reviewStatus = liveStatus;
|
|
7753
7706
|
try {
|
|
7754
|
-
|
|
7707
|
+
mutatePullRequests(project, data => {
|
|
7755
7708
|
if (!Array.isArray(data)) return data;
|
|
7756
7709
|
const target = shared.findPrRecord(data, pr, project);
|
|
7757
7710
|
if (target && target.reviewStatus !== REVIEW_STATUS.APPROVED) target.reviewStatus = liveStatus;
|
|
@@ -7926,7 +7879,7 @@ async function discoverFromPrs(config, project) {
|
|
|
7926
7879
|
const live = await checkBcFn(pr, project);
|
|
7927
7880
|
if (live?.buildStatusStale) {
|
|
7928
7881
|
try {
|
|
7929
|
-
mutatePullRequests(
|
|
7882
|
+
mutatePullRequests(project, prs => {
|
|
7930
7883
|
const target = shared.findPrRecord(prs, pr, project);
|
|
7931
7884
|
if (!target) return;
|
|
7932
7885
|
target._buildStatusStale = true;
|
|
@@ -7938,7 +7891,7 @@ async function discoverFromPrs(config, project) {
|
|
|
7938
7891
|
log('info', `Pre-dispatch build check: ${pr.id} build is ${live.buildStatus} (cached was failing) — skipping build-fix`);
|
|
7939
7892
|
// Persist the fresh status so subsequent ticks don't re-check on every pass
|
|
7940
7893
|
try {
|
|
7941
|
-
mutatePullRequests(
|
|
7894
|
+
mutatePullRequests(project, prs => {
|
|
7942
7895
|
const target = shared.findPrRecord(prs, pr, project);
|
|
7943
7896
|
if (!target) return;
|
|
7944
7897
|
target.buildStatus = live.buildStatus;
|
|
@@ -8002,8 +7955,7 @@ async function discoverFromPrs(config, project) {
|
|
|
8002
7955
|
if (pr.agent && !pr._buildFailNotified) {
|
|
8003
7956
|
// Mark noted to prevent repeated processing; the fix dispatch and PR state carry the failure details.
|
|
8004
7957
|
try {
|
|
8005
|
-
|
|
8006
|
-
mutatePullRequests(prPath, prs => {
|
|
7958
|
+
mutatePullRequests(project, prs => {
|
|
8007
7959
|
const target = shared.findPrRecord(prs, pr, project);
|
|
8008
7960
|
if (target) {
|
|
8009
7961
|
target._buildFailNotified = true;
|
|
@@ -8068,7 +8020,7 @@ async function discoverFromPrs(config, project) {
|
|
|
8068
8020
|
if (live && live.mergeConflict === false) {
|
|
8069
8021
|
log('info', `Pre-dispatch conflict check: ${pr.id} reports clean merge (cached was conflict) — skipping conflict-fix`);
|
|
8070
8022
|
try {
|
|
8071
|
-
mutatePullRequests(
|
|
8023
|
+
mutatePullRequests(project, prs => {
|
|
8072
8024
|
const target = shared.findPrRecord(prs, pr, project);
|
|
8073
8025
|
if (!target) return;
|
|
8074
8026
|
delete target._mergeConflict;
|
|
@@ -8097,7 +8049,7 @@ async function discoverFromPrs(config, project) {
|
|
|
8097
8049
|
newWork.push(item);
|
|
8098
8050
|
// Record dispatch timestamp so re-dispatch is suppressed during ADO lag window
|
|
8099
8051
|
try {
|
|
8100
|
-
mutatePullRequests(
|
|
8052
|
+
mutatePullRequests(project, prs => {
|
|
8101
8053
|
const target = shared.findPrRecord(prs, pr, project);
|
|
8102
8054
|
if (target) target._conflictFixedAt = new Date().toISOString();
|
|
8103
8055
|
});
|
|
@@ -8197,7 +8149,7 @@ function _buildRunnerBriefVars(item, project) {
|
|
|
8197
8149
|
}
|
|
8198
8150
|
|
|
8199
8151
|
/**
|
|
8200
|
-
* Scan work-
|
|
8152
|
+
* Scan a project work-item scope for manually queued tasks.
|
|
8201
8153
|
*/
|
|
8202
8154
|
function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, project, root, branchName, options = {}) {
|
|
8203
8155
|
const worktreePath = options.worktreePath || path.resolve(root, config.engine?.worktreeRoot || '../worktrees', `${branchName}`);
|
|
@@ -8411,7 +8363,7 @@ function resolveWorkItemPrRecord(item, project) {
|
|
|
8411
8363
|
if (!project) return null;
|
|
8412
8364
|
const prRef = getWorkItemPrRef(item);
|
|
8413
8365
|
if (!prRef) return null;
|
|
8414
|
-
const prs =
|
|
8366
|
+
const prs = readPullRequests(project);
|
|
8415
8367
|
shared.normalizePrRecords(prs, project);
|
|
8416
8368
|
return shared.findPrRecord(prs, prRef, project);
|
|
8417
8369
|
}
|
|
@@ -8478,7 +8430,7 @@ function refreshDeferredReviewPrompt(item, config) {
|
|
|
8478
8430
|
// head SHA before rebuilding the prompt.
|
|
8479
8431
|
let livePr = pr;
|
|
8480
8432
|
try {
|
|
8481
|
-
const prs =
|
|
8433
|
+
const prs = readPullRequests(project);
|
|
8482
8434
|
const found = shared.findPrRecord(prs, pr, project);
|
|
8483
8435
|
if (found) livePr = found;
|
|
8484
8436
|
} catch (e) { log('warn', `refreshDeferredReviewPrompt: live PR lookup for ${pr.id} failed: ${e.message}`); }
|
|
@@ -8510,12 +8462,12 @@ function refreshDeferredReviewPrompt(item, config) {
|
|
|
8510
8462
|
function discoverFromWorkItems(config, project) {
|
|
8511
8463
|
const src = project?.workSources?.workItems || config.workSources?.workItems;
|
|
8512
8464
|
if (!src?.enabled) {
|
|
8513
|
-
_warnSilentDiscoveryOnce('workItems', project,
|
|
8465
|
+
_warnSilentDiscoveryOnce('workItems', project, config);
|
|
8514
8466
|
return [];
|
|
8515
8467
|
}
|
|
8516
8468
|
|
|
8517
8469
|
const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(MINIONS_DIR, '..');
|
|
8518
|
-
const items =
|
|
8470
|
+
const items = readWorkItems(project);
|
|
8519
8471
|
// W-mq9b7lor (H3): snapshot each item's serializable state BEFORE the
|
|
8520
8472
|
// discover loop runs so we can compute per-item field deltas at the end
|
|
8521
8473
|
// and apply them inside the mutateWorkItems lock — the prior
|
|
@@ -8625,7 +8577,7 @@ function discoverFromWorkItems(config, project) {
|
|
|
8625
8577
|
}
|
|
8626
8578
|
|
|
8627
8579
|
const key = `work-${project?.name || 'default'}-${item.id}`;
|
|
8628
|
-
// Self-heal: collect keys for batched dispatch
|
|
8580
|
+
// Self-heal: collect keys for batched dispatch cleanup.
|
|
8629
8581
|
selfHealKeys.add(key);
|
|
8630
8582
|
dispatchCooldowns.delete(key);
|
|
8631
8583
|
// Cooldown bypass for resumed items
|
|
@@ -8931,7 +8883,7 @@ function discoverFromWorkItems(config, project) {
|
|
|
8931
8883
|
}
|
|
8932
8884
|
}
|
|
8933
8885
|
if (patches.size > 0) {
|
|
8934
|
-
mutateWorkItems(
|
|
8886
|
+
mutateWorkItems(project, (current) => {
|
|
8935
8887
|
for (const it of current) {
|
|
8936
8888
|
const patch = patches.get(it.id);
|
|
8937
8889
|
if (!patch) continue;
|
|
@@ -9197,10 +9149,9 @@ function materializeSpecsAsWorkItems(config, project) {
|
|
|
9197
9149
|
|
|
9198
9150
|
if (recentSpecs.length === 0) return;
|
|
9199
9151
|
|
|
9200
|
-
const wiPath = projectWorkItemsPath(project);
|
|
9201
9152
|
let created = 0;
|
|
9202
9153
|
|
|
9203
|
-
mutateWorkItems(
|
|
9154
|
+
mutateWorkItems(project, existingItems => {
|
|
9204
9155
|
for (const pr of mergedPrs) {
|
|
9205
9156
|
const prBranch = (pr.branch || '').toLowerCase();
|
|
9206
9157
|
const matchedSpecs = recentSpecs.filter(doc => {
|
|
@@ -9287,12 +9238,11 @@ function extractSpecInfo(filePath, projectRoot_) {
|
|
|
9287
9238
|
}
|
|
9288
9239
|
|
|
9289
9240
|
/**
|
|
9290
|
-
* Scan central
|
|
9241
|
+
* Scan central SQL state for project-agnostic tasks.
|
|
9291
9242
|
* Uses the shared work-item.md playbook with multi-project context injected.
|
|
9292
9243
|
*/
|
|
9293
9244
|
function discoverCentralWorkItems(config) {
|
|
9294
|
-
const
|
|
9295
|
-
const items = safeJsonArr(centralPath);
|
|
9245
|
+
const items = readWorkItems('central');
|
|
9296
9246
|
const projects = getProjects(config);
|
|
9297
9247
|
const dispatchProjects = getCentralDispatchProjects(projects);
|
|
9298
9248
|
const newWork = [];
|
|
@@ -9592,7 +9542,6 @@ function discoverCentralWorkItems(config) {
|
|
|
9592
9542
|
// Inject plan-to-prd variables — read the plan file content for the playbook
|
|
9593
9543
|
if (workType === WORK_TYPE.PLAN_TO_PRD && item.planFile) {
|
|
9594
9544
|
if (!fs.existsSync(PLANS_DIR)) fs.mkdirSync(PLANS_DIR, { recursive: true });
|
|
9595
|
-
if (!fs.existsSync(PRD_DIR)) fs.mkdirSync(PRD_DIR, { recursive: true });
|
|
9596
9545
|
if (planFileContent !== null) {
|
|
9597
9546
|
vars.plan_content = planFileContent;
|
|
9598
9547
|
} else {
|
|
@@ -9634,7 +9583,7 @@ function discoverCentralWorkItems(config) {
|
|
|
9634
9583
|
const prdRows = prdStore.listPrdRows();
|
|
9635
9584
|
const prdFiles = prdRows.filter(row => !row.archived).map(row => row.filename);
|
|
9636
9585
|
for (const pf of prdFiles) {
|
|
9637
|
-
const prd = prdStore.readPrd(
|
|
9586
|
+
const prd = prdStore.readPrd(pf);
|
|
9638
9587
|
if (planKey && prd && !shared.isPrdArchived(prd) && shared.prdMatchesSourcePlan(prd.source_plan, planKey)) {
|
|
9639
9588
|
prdFilename = pf;
|
|
9640
9589
|
vars.existing_prd_json = JSON.stringify(prd, null, 2);
|
|
@@ -9733,53 +9682,20 @@ function discoverCentralWorkItems(config) {
|
|
|
9733
9682
|
}
|
|
9734
9683
|
|
|
9735
9684
|
if (mutations.size > 0) {
|
|
9736
|
-
//
|
|
9737
|
-
|
|
9685
|
+
// Apply field patches to a fresh transactional snapshot.
|
|
9686
|
+
mutateWorkItems('central', (freshItems) => {
|
|
9738
9687
|
if (!Array.isArray(freshItems)) freshItems = [];
|
|
9739
9688
|
for (const fi of freshItems) {
|
|
9740
9689
|
const m = mutations.get(fi.id);
|
|
9741
9690
|
if (m) Object.assign(fi, m);
|
|
9742
9691
|
}
|
|
9743
9692
|
return freshItems;
|
|
9744
|
-
}
|
|
9693
|
+
});
|
|
9745
9694
|
}
|
|
9746
9695
|
return newWork;
|
|
9747
9696
|
}
|
|
9748
9697
|
|
|
9749
9698
|
|
|
9750
|
-
/**
|
|
9751
|
-
* Sweep stale `.backup` sidecars in `prd/` whose archived counterpart already
|
|
9752
|
-
* lives in `prd/archive/<name>.json` and the active `prd/<name>.json` is
|
|
9753
|
-
* absent. Pre-neutralize-era archives left these sidecars behind; without this
|
|
9754
|
-
* sweep, any caller that touched `prd/<name>.json` with the restore-enabled
|
|
9755
|
-
* `safeJson` would resurrect the archived PRD as active (W-mouptdh1000h9f39).
|
|
9756
|
-
*
|
|
9757
|
-
* Idempotent and best-effort: readdir / existsSync / unlink failures are
|
|
9758
|
-
* swallowed so a single weird filesystem state never blocks the discovery
|
|
9759
|
-
* tick. Returns the number of sidecars purged (for tests / logging).
|
|
9760
|
-
*/
|
|
9761
|
-
function sweepStaleArchivedPrdBackups(prdDir, prdArchiveDir) {
|
|
9762
|
-
let purged = 0;
|
|
9763
|
-
if (!fs.existsSync(prdArchiveDir)) return purged;
|
|
9764
|
-
let archivedNames;
|
|
9765
|
-
try { archivedNames = fs.readdirSync(prdArchiveDir).filter(f => f.endsWith('.json')); }
|
|
9766
|
-
catch { return purged; }
|
|
9767
|
-
for (const f of archivedNames) {
|
|
9768
|
-
const activePath = path.join(prdDir, f);
|
|
9769
|
-
const backupPath = activePath + '.backup';
|
|
9770
|
-
// Active PRD wins — the dedicated ghost-PRD purge in discoverWork handles
|
|
9771
|
-
// the case where both active.json and active.json.backup are present.
|
|
9772
|
-
if (fs.existsSync(activePath)) continue;
|
|
9773
|
-
if (!fs.existsSync(backupPath)) continue;
|
|
9774
|
-
try {
|
|
9775
|
-
fs.unlinkSync(backupPath);
|
|
9776
|
-
purged++;
|
|
9777
|
-
log('info', `Purged stale .backup sidecar for archived PRD: ${f}`);
|
|
9778
|
-
} catch { /* best-effort */ }
|
|
9779
|
-
}
|
|
9780
|
-
return purged;
|
|
9781
|
-
}
|
|
9782
|
-
|
|
9783
9699
|
/**
|
|
9784
9700
|
* Run all work discovery sources and queue new items
|
|
9785
9701
|
* Priority: fix (0) > ask (1) > review (1) > implement (2) > work-items (3) > central (4)
|
|
@@ -9789,7 +9705,7 @@ async function discoverWork(config) {
|
|
|
9789
9705
|
const projects = getProjects(config);
|
|
9790
9706
|
let persistedFixes = 0, persistedReviews = 0, persistedOther = 0;
|
|
9791
9707
|
|
|
9792
|
-
// Side-effect passes: materialize plans and design docs into work-
|
|
9708
|
+
// Side-effect passes: materialize plans and design docs into SQL work-item state.
|
|
9793
9709
|
// These write to project work queues — picked up by discoverFromWorkItems below.
|
|
9794
9710
|
// Gated by config.engine?.planMaterializationEnabled !== false (P-d6f0a2b5) —
|
|
9795
9711
|
// operators can suppress the PRD reconcile + plan materialization pair
|
|
@@ -9813,10 +9729,10 @@ async function discoverWork(config) {
|
|
|
9813
9729
|
// projects)` loop below leaves this whole async function permanently
|
|
9814
9730
|
// unresolved, so anything only collected into a local array and persisted
|
|
9815
9731
|
// at the very end (the pre-fix allFixes/allReviews/allWorkItems) never
|
|
9816
|
-
// reaches dispatch
|
|
9732
|
+
// reaches dispatch state — silently discarding valid work already
|
|
9817
9733
|
// discovered for unrelated, healthy projects processed earlier in the same
|
|
9818
9734
|
// loop. Persisting per batch means a healthy project's discoveries land in
|
|
9819
|
-
// dispatch
|
|
9735
|
+
// dispatch state immediately, before the loop reaches a slow project.
|
|
9820
9736
|
async function persistBatch(items) {
|
|
9821
9737
|
if (!items || items.length === 0) return;
|
|
9822
9738
|
// Gate reviews and fixes: only when at max concurrency — idle agents should pick up reviews
|
|
@@ -9869,7 +9785,7 @@ async function discoverWork(config) {
|
|
|
9869
9785
|
} catch (e) {
|
|
9870
9786
|
// Fail-open contract preserved: any validator throw here means the
|
|
9871
9787
|
// item is dropped from this discovery batch rather than wedging the
|
|
9872
|
-
// whole chunk. The item stays
|
|
9788
|
+
// whole chunk. The item stays pending in SQL and the
|
|
9873
9789
|
// next tick re-discovers it.
|
|
9874
9790
|
log('warn', `pre-dispatch-eval: addToDispatchWithValidation threw for ${item?.meta?.item?.id || item?.id || 'unknown'}: ${e?.message || e}`);
|
|
9875
9791
|
}
|
|
@@ -9945,7 +9861,6 @@ async function discoverWork(config) {
|
|
|
9945
9861
|
const scheduledWork = discoverScheduledWork(config);
|
|
9946
9862
|
if (scheduledWork.length > 0) {
|
|
9947
9863
|
const { createMeeting, getMeetings } = require('./engine/meeting');
|
|
9948
|
-
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
9949
9864
|
// Separate meetings (no work-items write) from task items
|
|
9950
9865
|
const taskItems = [];
|
|
9951
9866
|
for (const item of scheduledWork) {
|
|
@@ -9959,8 +9874,7 @@ async function discoverWork(config) {
|
|
|
9959
9874
|
}
|
|
9960
9875
|
}
|
|
9961
9876
|
if (taskItems.length > 0) {
|
|
9962
|
-
|
|
9963
|
-
mutateJsonFileLocked(centralPath, (items) => {
|
|
9877
|
+
mutateWorkItems('central', (items) => {
|
|
9964
9878
|
if (!Array.isArray(items)) items = [];
|
|
9965
9879
|
let added = 0;
|
|
9966
9880
|
// Snapshot active dedup keys BEFORE the loop so multiple items in the
|
|
@@ -9996,7 +9910,7 @@ async function discoverWork(config) {
|
|
|
9996
9910
|
log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
|
|
9997
9911
|
}
|
|
9998
9912
|
return items;
|
|
9999
|
-
}
|
|
9913
|
+
});
|
|
10000
9914
|
}
|
|
10001
9915
|
}
|
|
10002
9916
|
} catch (e) { log('warn', 'discover scheduled work: ' + e.message); }
|
|
@@ -10022,51 +9936,19 @@ async function discoverWork(config) {
|
|
|
10022
9936
|
if (tickCount % (ENGINE_DEFAULTS.planCompletionScanEvery || 60) === 0) {
|
|
10023
9937
|
try {
|
|
10024
9938
|
const lifecycle = require('./engine/lifecycle');
|
|
10025
|
-
const
|
|
10026
|
-
|
|
10027
|
-
|
|
10028
|
-
|
|
10029
|
-
|
|
10030
|
-
|
|
10031
|
-
|
|
10032
|
-
|
|
10033
|
-
|
|
10034
|
-
|
|
10035
|
-
// Pass 2 — completion sweep. (The ghost-PRD purge that used to live here
|
|
10036
|
-
// was a REDUNDANT + UNSAFE duplicate of the plan-completion scan's
|
|
10037
|
-
// version: it unlinked prd/<f> whenever prd/archive/<f> existed, with NO
|
|
10038
|
-
// `_isGhostPrdRestore` identity check — the exact footgun #7 (#496)
|
|
10039
|
-
// hazard of deleting a legitimately re-opened PRD that shares an archived
|
|
10040
|
-
// basename. The plan-completion scan does the same purge safely (identity-
|
|
10041
|
-
// checked + neutralize); here the isDefunctPrd guard below already skips
|
|
10042
|
-
// archived/defunct PRDs, so this loop never re-runs completion on a ghost.)
|
|
10043
|
-
for (const f of prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename)) {
|
|
10044
|
-
if (completedPlanCache.has(f)) continue;
|
|
10045
|
-
// safeJsonNoRestore — defense in depth: if the file vanished between
|
|
10046
|
-
// readdir and read (e.g. concurrent archive), do not resurrect it
|
|
10047
|
-
// from a stale .backup sidecar (W-mouptdh1000h9f39).
|
|
10048
|
-
const plan = safeJsonNoRestore(path.join(prdDir, f));
|
|
10049
|
-
if (!plan?.missing_features) continue;
|
|
10050
|
-
// Resurrection guard (mirror of completion-scan-B / #523): never re-run
|
|
10051
|
-
// completion (which re-creates the verify gate) for a defunct PRD —
|
|
10052
|
-
// archived, or completed with its plan gone from plans/.
|
|
10053
|
-
if (shared.isDefunctPrd(plan, PLANS_DIR)) { completedPlanCache.add(f); continue; }
|
|
10054
|
-
// A completed PRD may still be missing its aggregate verify WI — e.g. it
|
|
10055
|
-
// landed on disk pre-completed via the plan-to-prd / pipeline path, so no
|
|
10056
|
-
// agent-completion event ever drove checkPlanCompletion (W-mqk5ld3p). The
|
|
10057
|
-
// function is idempotent (re-checks for an existing verify WI under lock),
|
|
10058
|
-
// so run it for completed plans too and cache only once it reports nothing
|
|
10059
|
-
// left to do (truthy return).
|
|
10060
|
-
if (plan.status === PLAN_STATUS.COMPLETED) {
|
|
10061
|
-
const done = lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
|
|
10062
|
-
if (done) completedPlanCache.add(f);
|
|
10063
|
-
continue;
|
|
10064
|
-
}
|
|
10065
|
-
if (plan.status !== PLAN_STATUS.APPROVED && plan.status !== PLAN_STATUS.ACTIVE) continue;
|
|
10066
|
-
// Simulate the meta object checkPlanCompletion expects
|
|
10067
|
-
const completed = lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
|
|
10068
|
-
if (completed) completedPlanCache.add(f);
|
|
9939
|
+
for (const f of prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename)) {
|
|
9940
|
+
if (completedPlanCache.has(f)) continue;
|
|
9941
|
+
const plan = prdStore.readPrd(f);
|
|
9942
|
+
if (!plan?.missing_features) continue;
|
|
9943
|
+
if (shared.isDefunctPrd(plan, PLANS_DIR)) { completedPlanCache.add(f); continue; }
|
|
9944
|
+
if (plan.status === PLAN_STATUS.COMPLETED) {
|
|
9945
|
+
const done = lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
|
|
9946
|
+
if (done) completedPlanCache.add(f);
|
|
9947
|
+
continue;
|
|
10069
9948
|
}
|
|
9949
|
+
if (plan.status !== PLAN_STATUS.APPROVED && plan.status !== PLAN_STATUS.ACTIVE) continue;
|
|
9950
|
+
const completed = lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
|
|
9951
|
+
if (completed) completedPlanCache.add(f);
|
|
10070
9952
|
}
|
|
10071
9953
|
} catch (e) { log('warn', 'plan completion sweep: ' + e.message); }
|
|
10072
9954
|
}
|
|
@@ -10190,26 +10072,6 @@ const completedPlanCache = new Set();
|
|
|
10190
10072
|
// Dedupe the "Skipping defunct PRD" materializer log so a lingering defunct PRD
|
|
10191
10073
|
// doesn't spam one line per discovery tick; cleared when the file stops being defunct.
|
|
10192
10074
|
const _defunctPrdSkipLogged = new Set();
|
|
10193
|
-
// Filenames where a live PRD shares a basename with an archived PRD but diverges
|
|
10194
|
-
// (different source_plan / introduces new work). We refuse to auto-purge those
|
|
10195
|
-
// and warn once instead of every tick. (RC4 — footgun #7 collision protection.)
|
|
10196
|
-
const _ghostPurgeCollisionWarned = new Set();
|
|
10197
|
-
|
|
10198
|
-
// True when a live PRD that shares a basename with an archived one is merely a
|
|
10199
|
-
// stale echo of the archive (a .backup ghost-restore), safe to purge. False when
|
|
10200
|
-
// it's a genuinely distinct re-opened PRD that must NOT be silently deleted.
|
|
10201
|
-
// Conservative: if either file is unreadable we fall back to the legacy
|
|
10202
|
-
// "purge the ghost" behavior so we don't regress resurrection cleanup.
|
|
10203
|
-
function _isGhostPrdRestore(live, archived) {
|
|
10204
|
-
if (!live || !archived) return true;
|
|
10205
|
-
const ls = live.source_plan || live.sourcePlan;
|
|
10206
|
-
const as = archived.source_plan || archived.sourcePlan;
|
|
10207
|
-
if (ls && as && ls !== as) return false; // different plan identity → real PRD
|
|
10208
|
-
const archivedIds = new Set((archived.missing_features || []).map(f => f && f.id).filter(Boolean));
|
|
10209
|
-
const hasNewWork = (live.missing_features || []).some(f => f && f.id && !archivedIds.has(f.id));
|
|
10210
|
-
if (hasNewWork) return false; // introduces work the archive never had → real PRD
|
|
10211
|
-
return true; // same identity, no new work → stale echo → ghost
|
|
10212
|
-
}
|
|
10213
10075
|
let lastWatchCheckAt = 0;
|
|
10214
10076
|
let lastPrStatusPollAt = 0;
|
|
10215
10077
|
let lastPrCommentsPollAt = 0;
|
|
@@ -10437,7 +10299,7 @@ async function tickInner() {
|
|
|
10437
10299
|
}
|
|
10438
10300
|
|
|
10439
10301
|
// 2.53. managed-spawn TTL/dead-PID sweep + log rotation (P-8a4d6f29). Walks
|
|
10440
|
-
//
|
|
10302
|
+
// SQL managed-process state, kills TTL-expired specs, drops dead-PID
|
|
10441
10303
|
// rows, rotates managed-logs/<name>.log past ENGINE_DEFAULTS.managedSpawn
|
|
10442
10304
|
// .logRotateBytes. Mirrors the keep-processes sweep cadence (sweepEvery=180)
|
|
10443
10305
|
// so the engine never iterates per-spec on every tick. Healthcheck loops
|
|
@@ -10484,16 +10346,12 @@ async function tickInner() {
|
|
|
10484
10346
|
safe('checkWatches', () => {
|
|
10485
10347
|
const { checkWatches } = require('./engine/watches');
|
|
10486
10348
|
const projects = getProjects(config);
|
|
10487
|
-
const
|
|
10488
|
-
|
|
10489
|
-
|
|
10490
|
-
|
|
10491
|
-
const workItems = projects.flatMap(p => {
|
|
10492
|
-
const wiPath = path.join(MINIONS_DIR, 'projects', p.name, 'work-items.json');
|
|
10493
|
-
return safeJsonArr(wiPath);
|
|
10494
|
-
});
|
|
10349
|
+
const pullRequestStore = require('./engine/pull-requests-store');
|
|
10350
|
+
const workItemStore = require('./engine/work-items-store');
|
|
10351
|
+
const pullRequests = projects.flatMap(p => pullRequestStore.readPullRequestsForScope(p.name));
|
|
10352
|
+
const workItems = projects.flatMap(p => workItemStore.readWorkItemsForScope(p.name));
|
|
10495
10353
|
// Also include central work items
|
|
10496
|
-
const centralWi =
|
|
10354
|
+
const centralWi = workItemStore.readWorkItemsForScope('central');
|
|
10497
10355
|
|
|
10498
10356
|
// Gather state for the new generalized target types. Each block is
|
|
10499
10357
|
// best-effort — if a module/file is missing the watch evaluator will
|
|
@@ -10501,23 +10359,11 @@ async function tickInner() {
|
|
|
10501
10359
|
let meetings = [];
|
|
10502
10360
|
try { meetings = require('./engine/meeting').getMeetings(); } catch { /* optional */ }
|
|
10503
10361
|
|
|
10504
|
-
|
|
10505
|
-
|
|
10506
|
-
// carries its filename in _source for target lookup.
|
|
10507
|
-
const plans = [];
|
|
10508
|
-
try {
|
|
10509
|
-
const prdDir = path.join(MINIONS_DIR, 'prd');
|
|
10510
|
-
for (const dir of [prdDir, path.join(prdDir, 'archive')]) {
|
|
10511
|
-
if (!fs.existsSync(dir)) continue;
|
|
10512
|
-
for (const f of fs.readdirSync(dir).filter(x => x.endsWith('.json'))) {
|
|
10513
|
-
const plan = safeJson(path.join(dir, f));
|
|
10514
|
-
if (plan) plans.push({ ...plan, _source: f, _sourcePlan: plan.source_plan || '' });
|
|
10515
|
-
}
|
|
10516
|
-
}
|
|
10517
|
-
} catch { /* optional */ }
|
|
10362
|
+
const plans = require('./engine/prd-store').listPrdRows()
|
|
10363
|
+
.map(({ filename, plan }) => ({ ...plan, _source: filename, _sourcePlan: plan.source_plan || '' }));
|
|
10518
10364
|
|
|
10519
10365
|
let scheduleRuns = {};
|
|
10520
|
-
try { scheduleRuns =
|
|
10366
|
+
try { scheduleRuns = require('./engine/small-state-store').readScheduleRuns(); } catch { /* optional */ }
|
|
10521
10367
|
|
|
10522
10368
|
let pipelineRuns = {};
|
|
10523
10369
|
try { pipelineRuns = require('./engine/pipeline').getPipelineRuns(); } catch { /* optional */ }
|
|
@@ -10617,29 +10463,7 @@ async function tickInner() {
|
|
|
10617
10463
|
const prdFiles = prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename);
|
|
10618
10464
|
for (const file of prdFiles) {
|
|
10619
10465
|
if (completedPlanCache.has(file)) continue;
|
|
10620
|
-
|
|
10621
|
-
// A live PRD basename also exists in the archive. Historically this was
|
|
10622
|
-
// unconditionally treated as an orphaned .backup ghost-restore and purged
|
|
10623
|
-
// — but a re-opened plan can legitimately create a NEW live PRD sharing
|
|
10624
|
-
// an archived basename (footgun #7). Identity-check before deleting so we
|
|
10625
|
-
// never silently destroy a real divergent PRD.
|
|
10626
|
-
const liveP = safeJsonNoRestore(path.join(PRD_DIR, file));
|
|
10627
|
-
const archP = safeJsonNoRestore(path.join(PRD_DIR, 'archive', file));
|
|
10628
|
-
if (_isGhostPrdRestore(liveP, archP)) {
|
|
10629
|
-
// Orphaned backup restore — plan is already archived. Purge the ghost copy.
|
|
10630
|
-
try { fs.unlinkSync(path.join(PRD_DIR, file)); } catch { }
|
|
10631
|
-
shared.neutralizeJsonBackupSidecar(path.join(PRD_DIR, file));
|
|
10632
|
-
completedPlanCache.add(file);
|
|
10633
|
-
continue;
|
|
10634
|
-
}
|
|
10635
|
-
// Divergent live PRD — do NOT delete. Warn once and fall through to the
|
|
10636
|
-
// normal completion handling below so it's treated as a real PRD.
|
|
10637
|
-
if (!_ghostPurgeCollisionWarned.has(file)) {
|
|
10638
|
-
log('warn', `PRD ${file} shares a basename with an archived PRD but diverges (different source_plan or new work) — NOT purging; resolve the collision manually`);
|
|
10639
|
-
_ghostPurgeCollisionWarned.add(file);
|
|
10640
|
-
}
|
|
10641
|
-
}
|
|
10642
|
-
const plan = safeJson(path.join(PRD_DIR, file));
|
|
10466
|
+
const plan = prdStore.readPrd(file);
|
|
10643
10467
|
// Resurrection-re-execution guard: a defunct PRD (archived, or completed
|
|
10644
10468
|
// with its source plan gone from plans/) must NOT have its verify gate
|
|
10645
10469
|
// re-created. This scan deliberately re-runs completion for completed
|
|
@@ -10753,13 +10577,12 @@ async function tickInner() {
|
|
|
10753
10577
|
// Check for failed items blocking pending items
|
|
10754
10578
|
for (const project of projects) {
|
|
10755
10579
|
try {
|
|
10756
|
-
const wiPath = projectWorkItemsPath(project);
|
|
10757
10580
|
// Collect keys to clear AFTER work-items lock is released (avoid nested locks)
|
|
10758
10581
|
const dispatchKeysToClear = [];
|
|
10759
10582
|
const cooldownKeysToClear = [];
|
|
10760
10583
|
|
|
10761
10584
|
if (_isTickStale(myGeneration)) return;
|
|
10762
|
-
mutateWorkItems(
|
|
10585
|
+
mutateWorkItems(project, items => {
|
|
10763
10586
|
let changed = false;
|
|
10764
10587
|
const failedIds = new Set(items.filter(w => w.status === WI_STATUS.FAILED).map(w => w.id));
|
|
10765
10588
|
const pendingWithBlockedDeps = items.filter(w =>
|
|
@@ -11022,7 +10845,7 @@ async function tickInner() {
|
|
|
11022
10845
|
assignPendingDispatchAgent(item, altAgent, config);
|
|
11023
10846
|
pendingAgents.add(altAgent);
|
|
11024
10847
|
log('info', `Reassigning ${item.id} from unspawned temp ${prevAgent} to ${altAgent} — temp agent never spawned`);
|
|
11025
|
-
// Persist reassignment
|
|
10848
|
+
// Persist reassignment so it survives restarts/ticks.
|
|
11026
10849
|
persistPendingDispatchAgent(item);
|
|
11027
10850
|
}
|
|
11028
10851
|
}
|
|
@@ -11258,12 +11081,10 @@ async function tickInner() {
|
|
|
11258
11081
|
// Defensive: ensure the work item is re-queued if completeDispatch didn't fire
|
|
11259
11082
|
if (item.meta?.item?.id) {
|
|
11260
11083
|
try {
|
|
11261
|
-
const
|
|
11262
|
-
|
|
11263
|
-
: item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
|
|
11264
|
-
if (wiPath) {
|
|
11084
|
+
const wiScope = resolveWorkItemScope(item.meta);
|
|
11085
|
+
if (wiScope) {
|
|
11265
11086
|
if (_isTickStale(myGeneration)) return;
|
|
11266
|
-
mutateWorkItems(
|
|
11087
|
+
mutateWorkItems(wiScope, items => {
|
|
11267
11088
|
const wi = items.find(i => i.id === item.meta.item.id);
|
|
11268
11089
|
if (wi && wi.status === WI_STATUS.DISPATCHED) {
|
|
11269
11090
|
// completeDispatch didn't update the work item — re-queue manually
|
|
@@ -11386,8 +11207,8 @@ async function tickInner() {
|
|
|
11386
11207
|
// clobbered. Previously the mutator callback assigned dp.pending from the
|
|
11387
11208
|
// pre-lock postDispatch snapshot, overwriting any freshly loaded queue
|
|
11388
11209
|
// entries. Sentinel-undefined for _pendingReason / _agentBusySince
|
|
11389
|
-
// mirrors the in-loop `delete` semantics —
|
|
11390
|
-
// undefined-valued keys
|
|
11210
|
+
// mirrors the in-loop `delete` semantics — the SQL store serializes
|
|
11211
|
+
// records without undefined-valued keys.
|
|
11391
11212
|
const patches = new Map();
|
|
11392
11213
|
for (const item of (postDispatch.pending || [])) {
|
|
11393
11214
|
patches.set(item.id, {
|
|
@@ -11503,7 +11324,7 @@ function emitMemoryBaseline(tickN) {
|
|
|
11503
11324
|
module.exports = {
|
|
11504
11325
|
// Paths
|
|
11505
11326
|
MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLAYBOOKS_DIR, PLANS_DIR, PRD_DIR,
|
|
11506
|
-
CONTROL_PATH,
|
|
11327
|
+
CONTROL_PATH, INBOX_DIR, KNOWLEDGE_DIR, ARCHIVE_DIR,
|
|
11507
11328
|
IDENTITY_DIR, CONFIG_PATH, ROUTING_PATH, NOTES_PATH, SKILLS_DIR,
|
|
11508
11329
|
|
|
11509
11330
|
// Utilities
|
|
@@ -11528,10 +11349,7 @@ module.exports = {
|
|
|
11528
11349
|
discoverWork, discoverFromPrs, discoverFromWorkItems, discoverCentralWorkItems,
|
|
11529
11350
|
materializePlansAsWorkItems,
|
|
11530
11351
|
materializeSpecsAsWorkItems, // exported for testing (P-f7-git-log)
|
|
11531
|
-
reservePrdFilename, // exported for testing (P-9b7e5d3c)
|
|
11532
11352
|
restoreHijackedOperatorCheckouts, // exported for testing — operator-checkout self-heal
|
|
11533
|
-
sweepStaleArchivedPrdBackups, // exported for testing
|
|
11534
|
-
_isGhostPrdRestore, // exported for testing (RC4 — ghost-purge identity check)
|
|
11535
11353
|
|
|
11536
11354
|
// Shared helpers (used by lifecycle.js and tests)
|
|
11537
11355
|
reconcileItemsWithPrs, detectDependencyCycles,
|