@yemi33/minions 0.1.1949 → 0.1.1951
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/js/command-center.js +9 -0
- package/dashboard/js/modal-qa.js +10 -0
- package/dashboard/js/refresh.js +4 -0
- package/dashboard/js/render-dispatch.js +25 -0
- package/dashboard/js/render-other.js +109 -2
- package/dashboard/js/settings.js +1 -1
- package/dashboard/layout.html +2 -2
- package/dashboard/pages/engine.html +6 -0
- package/dashboard/slim.html +1987 -0
- package/dashboard/styles.css +8 -0
- package/dashboard.js +450 -40
- package/docs/completion-reports.md +25 -0
- package/docs/design-state-storage.md +1 -1
- package/docs/slim-ux/architecture-suggestions.md +467 -0
- package/docs/slim-ux/concepts.md +824 -0
- package/engine/ado-mcp-wrapper.js +33 -7
- package/engine/ado.js +123 -15
- package/engine/cc-worker-pool.js +41 -0
- package/engine/cleanup.js +71 -34
- package/engine/cli.js +37 -0
- package/engine/dispatch.js +32 -9
- package/engine/features.js +6 -0
- package/engine/gh-token.js +137 -0
- package/engine/github.js +166 -29
- package/engine/issues.js +29 -0
- package/engine/keep-process-sweep.js +397 -0
- package/engine/lifecycle.js +150 -33
- package/engine/playbook.js +17 -0
- package/engine/queries.js +71 -0
- package/engine/recovery.js +6 -0
- package/engine/shared.js +481 -30
- package/engine/spawn-agent.js +44 -2
- package/engine/timeout.js +34 -11
- package/engine/worktree-pool.js +410 -0
- package/engine.js +643 -119
- package/package.json +6 -3
- package/playbooks/review.md +2 -0
- package/playbooks/shared-rules.md +3 -1
- package/prompts/cc-system.md +24 -0
- package/engine/copilot-models.json +0 -5
package/engine/lifecycle.js
CHANGED
|
@@ -921,10 +921,17 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
|
|
|
921
921
|
kind: 'positive-signal',
|
|
922
922
|
body: `Closing duplicate — ${duplicateOnBranch.id} already tracks this branch.`,
|
|
923
923
|
});
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
|
|
927
|
-
|
|
924
|
+
// P-a7c4d2e8 (F2): argv-form gh + slug validation. ghSlug is regex-
|
|
925
|
+
// extracted from agent stdout (untrusted) — must validate. Passing
|
|
926
|
+
// the comment as a single argv element makes embedded `"`, backticks,
|
|
927
|
+
// `$()`, and newlines inert.
|
|
928
|
+
try {
|
|
929
|
+
const validatedSlug = shared.validateGhSlug(ghSlug);
|
|
930
|
+
shared.shellSafeGh(['pr', 'close', String(prId), '--repo', validatedSlug, '--comment', dupComment], { timeout: 15000 })
|
|
931
|
+
.catch(() => {});
|
|
932
|
+
} catch (validationErr) {
|
|
933
|
+
log('warn', `Skipping duplicate-PR close: invalid gh slug "${ghSlug.slice(0, 64)}": ${validationErr.message}`);
|
|
934
|
+
}
|
|
928
935
|
}
|
|
929
936
|
} catch { /* best-effort */ }
|
|
930
937
|
continue;
|
|
@@ -1604,21 +1611,64 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
|
|
|
1604
1611
|
// If platform hasn't propagated the vote yet (returns 'pending'), keep current status unchanged.
|
|
1605
1612
|
// The poller will pick up the real status on the next cycle (~3 min).
|
|
1606
1613
|
let postReviewStatus = null; // null = don't change
|
|
1614
|
+
let liveStatus = null;
|
|
1615
|
+
const projectObjForChecks = reviewProject || shared.getProjects(config)[0];
|
|
1616
|
+
const hostForChecks = projectObjForChecks?.repoHost || 'ado';
|
|
1617
|
+
const checkFn = hostForChecks === 'github'
|
|
1618
|
+
? require('./github').checkLiveReviewStatus
|
|
1619
|
+
: require('./ado').checkLiveReviewStatus;
|
|
1607
1620
|
try {
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
const host = projectObj.repoHost || 'ado';
|
|
1611
|
-
const checkFn = host === 'github'
|
|
1612
|
-
? require('./github').checkLiveReviewStatus
|
|
1613
|
-
: require('./ado').checkLiveReviewStatus;
|
|
1614
|
-
const liveStatus = await checkFn(reviewPr, projectObj);
|
|
1615
|
-
if (liveStatus && liveStatus !== 'pending') postReviewStatus = liveStatus;
|
|
1621
|
+
if (projectObjForChecks) {
|
|
1622
|
+
liveStatus = await checkFn(reviewPr, projectObjForChecks);
|
|
1616
1623
|
}
|
|
1617
1624
|
} catch (e) { log('warn', `Post-review status check for ${reviewPr.id}: ${e.message}`); }
|
|
1618
1625
|
|
|
1626
|
+
// Capture the agent's verdict early so we can decide whether to reconcile
|
|
1627
|
+
// the platform vote BEFORE we lock in postReviewStatus.
|
|
1628
|
+
const verdictRaw = reviewVerdictFromCompletion(structuredCompletion) || parseReviewVerdict(resultSummary);
|
|
1629
|
+
const reviewerLower = String(agentId || '').toLowerCase();
|
|
1630
|
+
const authorLower = String(reviewPr.agent || '').toLowerCase();
|
|
1631
|
+
const isSelfReview = !!(reviewerLower && authorLower && reviewerLower === authorLower);
|
|
1632
|
+
|
|
1633
|
+
// W-mp7b1g8q000fea45 — Reconcile stale own negative vote/review on verdict flip.
|
|
1634
|
+
// When the agent flips request_changes → approved, the prior platform vote
|
|
1635
|
+
// (ADO -5/-10 or GH CHANGES_REQUESTED review) is NOT auto-cleared; the live
|
|
1636
|
+
// check above would still report 'changes-requested' / 'waiting' and override
|
|
1637
|
+
// the agent's approved verdict, leaving humans with a misleading red badge.
|
|
1638
|
+
// Actively clear the agent's prior negative on flip, then re-check live so we
|
|
1639
|
+
// never claim approved if some OTHER reviewer (human, different minion
|
|
1640
|
+
// account) still has a negative vote/review on the PR.
|
|
1641
|
+
const prevReviewStatus = reviewPr?.reviewStatus || '';
|
|
1642
|
+
const wasNegative = prevReviewStatus === 'changes-requested' || prevReviewStatus === 'waiting'
|
|
1643
|
+
|| liveStatus === 'changes-requested' || liveStatus === 'waiting';
|
|
1644
|
+
if (verdictRaw === 'approved' && !isSelfReview && wasNegative && projectObjForChecks) {
|
|
1645
|
+
try {
|
|
1646
|
+
const reconcileFn = hostForChecks === 'github'
|
|
1647
|
+
? require('./github').dismissPriorViewerChangesRequestedReviews
|
|
1648
|
+
: require('./ado').resetReviewerNegativeVote;
|
|
1649
|
+
if (typeof reconcileFn === 'function') {
|
|
1650
|
+
const result = await reconcileFn(reviewPr, projectObjForChecks);
|
|
1651
|
+
const cleared = !!(result && (result.changed || result.dismissed > 0));
|
|
1652
|
+
if (cleared) {
|
|
1653
|
+
// Re-check live so a remaining negative from another reviewer still
|
|
1654
|
+
// wins. If recheck fails, keep the prior liveStatus (conservative).
|
|
1655
|
+
try {
|
|
1656
|
+
const recheck = await checkFn(reviewPr, projectObjForChecks);
|
|
1657
|
+
if (recheck != null) liveStatus = recheck;
|
|
1658
|
+
} catch (e) { log('warn', `Post-reset live re-check for ${reviewPr.id}: ${e.message}`); }
|
|
1659
|
+
log('info', `PR ${reviewPr.id}: cleared stale ${hostForChecks} negative vote/review by ${reviewerName} on flip → live now ${liveStatus}`);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
} catch (e) {
|
|
1663
|
+
log('warn', `Vote reconciliation for ${reviewPr.id}: ${e.message}`);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
if (liveStatus && liveStatus !== 'pending') postReviewStatus = liveStatus;
|
|
1668
|
+
|
|
1619
1669
|
// Fallback: if live check returned pending (e.g., GitHub self-approval blocked), use the agent's completion report.
|
|
1620
1670
|
if (!postReviewStatus) {
|
|
1621
|
-
const verdict =
|
|
1671
|
+
const verdict = verdictRaw;
|
|
1622
1672
|
if (verdict) {
|
|
1623
1673
|
// P-e1c8a9d2 — Refuse self-review APPROVE: an agent approving their own PR
|
|
1624
1674
|
// is meaningless self-promotion (PR #1867/#1868/#2253 all shipped this way).
|
|
@@ -1628,9 +1678,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
|
|
|
1628
1678
|
// the verdict and skips the no-verdict retry path, so _retryCount stays
|
|
1629
1679
|
// unchanged. REQUEST_CHANGES from a self-author is still accepted — a
|
|
1630
1680
|
// self-author flagging issues on their own PR is harmless and useful.
|
|
1631
|
-
|
|
1632
|
-
const authorLower = String(reviewPr.agent || '').toLowerCase();
|
|
1633
|
-
if (verdict === 'approved' && reviewerLower && authorLower && reviewerLower === authorLower) {
|
|
1681
|
+
if (verdict === 'approved' && isSelfReview) {
|
|
1634
1682
|
log('warn', `review verdict rejected: self-review (reviewer=${reviewerName}, author=${reviewPr.agent})`);
|
|
1635
1683
|
} else {
|
|
1636
1684
|
postReviewStatus = verdict;
|
|
@@ -2038,6 +2086,16 @@ async function rebaseBranchOntoMain(pr, project, config) {
|
|
|
2038
2086
|
const root = path.resolve(project.localPath);
|
|
2039
2087
|
const mainBranch = shared.resolveMainBranch(root, project.mainBranch);
|
|
2040
2088
|
const branch = pr.branch;
|
|
2089
|
+
// P-a7c4d2e8 (F3): defense-in-depth — validate refs before any git command
|
|
2090
|
+
// even though sanitizeBranch ran upstream. Older state may carry poisoned refs.
|
|
2091
|
+
let validatedBranch, validatedMain;
|
|
2092
|
+
try {
|
|
2093
|
+
validatedBranch = shared.validateGitRef(branch);
|
|
2094
|
+
validatedMain = shared.validateGitRef(mainBranch);
|
|
2095
|
+
} catch (refErr) {
|
|
2096
|
+
log('warn', `Post-merge rebase: refusing invalid ref (${refErr.message})`);
|
|
2097
|
+
return { success: false, error: refErr.message };
|
|
2098
|
+
}
|
|
2041
2099
|
const wtRoot = path.resolve(root, config.engine?.worktreeRoot || ENGINE_DEFAULTS.worktreeRoot);
|
|
2042
2100
|
const tmpWt = path.join(wtRoot, `rebase-${shared.sanitizeBranch(branch)}-${Date.now()}`).replace(/\\/g, '/');
|
|
2043
2101
|
const _gitOpts = { cwd: root, timeout: 30000, windowsHide: true };
|
|
@@ -2054,34 +2112,34 @@ async function rebaseBranchOntoMain(pr, project, config) {
|
|
|
2054
2112
|
}
|
|
2055
2113
|
|
|
2056
2114
|
try {
|
|
2057
|
-
await
|
|
2115
|
+
await shared.shellSafeGit(['fetch', 'origin', validatedMain, validatedBranch], _gitOpts);
|
|
2058
2116
|
try {
|
|
2059
|
-
await
|
|
2117
|
+
await shared.shellSafeGit(['worktree', 'add', tmpWt, validatedBranch], { ..._gitOpts, timeout: 60000 });
|
|
2060
2118
|
} catch (wtErr) {
|
|
2061
2119
|
// Branch may already be checked out in a stale worktree — prune and retry once
|
|
2062
2120
|
if (String(wtErr.message || wtErr).includes('already checked out')) {
|
|
2063
|
-
await
|
|
2064
|
-
await
|
|
2121
|
+
await shared.shellSafeGit(['worktree', 'prune'], _gitOpts);
|
|
2122
|
+
await shared.shellSafeGit(['worktree', 'add', tmpWt, validatedBranch], { ..._gitOpts, timeout: 60000 });
|
|
2065
2123
|
} else { throw wtErr; }
|
|
2066
2124
|
}
|
|
2067
2125
|
} catch (err) {
|
|
2068
2126
|
log('warn', `Post-merge rebase: setup failed for ${branch}: ${err.message}`);
|
|
2069
|
-
try { await
|
|
2127
|
+
try { await shared.shellSafeGit(['worktree', 'remove', tmpWt, '--force'], _gitOpts); } catch {}
|
|
2070
2128
|
return { success: false, error: err.message };
|
|
2071
2129
|
}
|
|
2072
2130
|
|
|
2073
2131
|
try {
|
|
2074
|
-
await
|
|
2075
|
-
await
|
|
2132
|
+
await shared.shellSafeGit(['rebase', `origin/${validatedMain}`], { cwd: tmpWt, timeout: 120000, windowsHide: true });
|
|
2133
|
+
await shared.shellSafeGit(['push', '--force-with-lease', 'origin', validatedBranch], { cwd: tmpWt, timeout: 30000, windowsHide: true });
|
|
2076
2134
|
log('info', `Post-merge rebase: rebased ${branch} onto ${mainBranch} and force-pushed`);
|
|
2077
2135
|
return { success: true };
|
|
2078
2136
|
} catch (err) {
|
|
2079
|
-
try { await
|
|
2137
|
+
try { await shared.shellSafeGit(['rebase', '--abort'], { cwd: tmpWt, timeout: 10000, windowsHide: true }); } catch {}
|
|
2080
2138
|
log('warn', `Post-merge rebase failed for ${branch}: ${err.message}`);
|
|
2081
2139
|
return { success: false, error: err.message };
|
|
2082
2140
|
} finally {
|
|
2083
2141
|
try { shared.removeWorktree(tmpWt, root, wtRoot); } catch {}
|
|
2084
|
-
try { await
|
|
2142
|
+
try { await shared.shellSafeGit(['worktree', 'prune'], _gitOpts); } catch {}
|
|
2085
2143
|
}
|
|
2086
2144
|
}
|
|
2087
2145
|
|
|
@@ -3194,9 +3252,60 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
3194
3252
|
let { resultSummary, taskUsage, sessionId, model } = parseAgentOutput(stdout, runtimeName);
|
|
3195
3253
|
|
|
3196
3254
|
// Prefer the sidecar completion report; keep fenced output as a compatibility fallback.
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3255
|
+
let reportCompletion = parseCompletionReportFile(dispatchItem, { warnIfMissing: true });
|
|
3256
|
+
|
|
3257
|
+
// P-d2a8f6c1 (agent trust boundary F8): validate the per-spawn nonce. The
|
|
3258
|
+
// engine injects MINIONS_COMPLETION_NONCE per spawn and stamps it on the
|
|
3259
|
+
// active-process record; here we compare it against the JSON report's
|
|
3260
|
+
// `nonce` field. A mismatch means the report was forged (e.g. a prompt-
|
|
3261
|
+
// injected agent wrote into a sibling agent's completion path) — discard
|
|
3262
|
+
// every signal it carries (PR attachment, noop, status, retryable, …) and
|
|
3263
|
+
// mark the dispatch failed with the dedicated failure_class. A missing
|
|
3264
|
+
// nonce degrades to a warning by default for one release; flip
|
|
3265
|
+
// ENGINE_DEFAULTS.completionNonceRequired (or engine.completionNonceRequired
|
|
3266
|
+
// in config.json) to true to hard-fail missing nonces too. Mismatched
|
|
3267
|
+
// nonces always hard-fail regardless of the flag. The fenced/summary
|
|
3268
|
+
// fallbacks share the same trust boundary — once the JSON sidecar is
|
|
3269
|
+
// proven untrusted, we don't fall back to stdout-derived completions
|
|
3270
|
+
// either, since they cannot prove provenance.
|
|
3271
|
+
let nonceMismatch = null;
|
|
3272
|
+
const expectedNonce = (opts && typeof opts.expectedNonce === 'string' && opts.expectedNonce) ? opts.expectedNonce : null;
|
|
3273
|
+
const completionNonceRequired = !!(opts && opts.completionNonceRequired);
|
|
3274
|
+
if (expectedNonce && reportCompletion) {
|
|
3275
|
+
const reportNonce = typeof reportCompletion.nonce === 'string' ? reportCompletion.nonce : null;
|
|
3276
|
+
const wiId = dispatchItem.meta?.item?.id || 'N/A';
|
|
3277
|
+
if (!reportNonce) {
|
|
3278
|
+
if (completionNonceRequired) {
|
|
3279
|
+
const reason = `Completion report missing required nonce for dispatch ${dispatchItem.id}`;
|
|
3280
|
+
log('error', `[security] completion-nonce-missing dispatch=${dispatchItem.id} agent=${agentId || 'unknown'} wi=${wiId} required=true`);
|
|
3281
|
+
nonceMismatch = {
|
|
3282
|
+
severity: 'hard',
|
|
3283
|
+
failureClass: shared.FAILURE_CLASS.COMPLETION_NONCE_MISMATCH,
|
|
3284
|
+
reason,
|
|
3285
|
+
kind: 'missing',
|
|
3286
|
+
};
|
|
3287
|
+
} else {
|
|
3288
|
+
log('warn', `[security] completion-nonce-missing dispatch=${dispatchItem.id} agent=${agentId || 'unknown'} wi=${wiId} required=false (degraded — report honored)`);
|
|
3289
|
+
}
|
|
3290
|
+
} else if (reportNonce !== expectedNonce) {
|
|
3291
|
+
const reason = `Completion report nonce mismatch for dispatch ${dispatchItem.id} — treating completion as untrusted`;
|
|
3292
|
+
const expectedShort = expectedNonce.slice(0, 8);
|
|
3293
|
+
const gotShort = String(reportNonce).slice(0, 8);
|
|
3294
|
+
log('error', `[security] completion-nonce-mismatch dispatch=${dispatchItem.id} agent=${agentId || 'unknown'} wi=${wiId} expected=${expectedShort} got=${gotShort}`);
|
|
3295
|
+
nonceMismatch = {
|
|
3296
|
+
severity: 'hard',
|
|
3297
|
+
failureClass: shared.FAILURE_CLASS.COMPLETION_NONCE_MISMATCH,
|
|
3298
|
+
reason,
|
|
3299
|
+
kind: 'mismatch',
|
|
3300
|
+
};
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
if (nonceMismatch) {
|
|
3304
|
+
reportCompletion = null;
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
const fencedCompletion = (nonceMismatch || reportCompletion) ? null : parseStructuredCompletion(stdout, runtimeName);
|
|
3308
|
+
const summaryCompletion = (nonceMismatch || reportCompletion || fencedCompletion) ? null : parseCompletionFieldSummary(resultSummary);
|
|
3200
3309
|
const fallbackCompletion = fencedCompletion || summaryCompletion;
|
|
3201
3310
|
const fallbackSource = fencedCompletion && hasCompletionFence(stdout, runtimeName) ? 'fenced-completion' : 'summary-completion';
|
|
3202
3311
|
// P-c8f5e1b3 — telemetry: completion-fallback. Emit a grep-able log + bump a
|
|
@@ -3259,19 +3368,27 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
3259
3368
|
|
|
3260
3369
|
const completionStatus = normalizeCompletionStatus(structuredCompletion?.status);
|
|
3261
3370
|
const agentNeedsRerun = parseCompletionBoolean(structuredCompletion?.needs_rerun ?? structuredCompletion?.needsRerun) === true;
|
|
3262
|
-
|
|
3371
|
+
// P-d2a8f6c1: a nonce mismatch always counts as an agent-reported failure
|
|
3372
|
+
// (regardless of what the discarded report claimed). This forces both the
|
|
3373
|
+
// effectiveSuccess gate below and the dispatch result in engine.js to ERROR.
|
|
3374
|
+
const agentReportedFailure = !!nonceMismatch
|
|
3375
|
+
|| completionStatus.startsWith('fail')
|
|
3263
3376
|
|| completionStatus === 'error'
|
|
3264
3377
|
|| hasActionableFailureClass(structuredCompletion?.failure_class)
|
|
3265
3378
|
|| agentNeedsRerun;
|
|
3266
|
-
|
|
3379
|
+
// Untrusted completions cannot be honored as "retryable" by the agent — its
|
|
3380
|
+
// retryable claim was discarded with the rest of the report. Force false.
|
|
3381
|
+
const agentRetryable = nonceMismatch ? false : parseCompletionBoolean(structuredCompletion?.retryable);
|
|
3267
3382
|
|
|
3268
3383
|
// Auto-recover: if a failed implement/fix/test agent created PRs, it likely succeeded before the failure surfaced.
|
|
3384
|
+
// P-d2a8f6c1: skip auto-recovery for nonce-mismatched dispatches — a forged
|
|
3385
|
+
// report shouldn't be able to ride PRs into a "done" status.
|
|
3269
3386
|
const prCreatingType = type === WORK_TYPE.IMPLEMENT || type === WORK_TYPE.IMPLEMENT_LARGE || type === WORK_TYPE.FIX || type === WORK_TYPE.TEST;
|
|
3270
|
-
const autoRecovered = !agentReportedFailure && !isSuccess && prsCreatedCount > 0 && prCreatingType && !!meta?.item?.id;
|
|
3387
|
+
const autoRecovered = !nonceMismatch && !agentReportedFailure && !isSuccess && prsCreatedCount > 0 && prCreatingType && !!meta?.item?.id;
|
|
3271
3388
|
if (autoRecovered) {
|
|
3272
3389
|
log('info', `Auto-recovery: agent failed but created ${prsCreatedCount} PR(s) — upgrading ${meta.item.id} to done`);
|
|
3273
3390
|
}
|
|
3274
|
-
const effectiveSuccess = (isSuccess && !agentReportedFailure) || autoRecovered;
|
|
3391
|
+
const effectiveSuccess = !nonceMismatch && ((isSuccess && !agentReportedFailure) || autoRecovered);
|
|
3275
3392
|
|
|
3276
3393
|
let nonCleanReportWritten = false;
|
|
3277
3394
|
if (completionStatus.startsWith('partial') || autoRecovered || (agentReportedFailure && isSuccess)) {
|
|
@@ -3696,7 +3813,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
3696
3813
|
const metricsResult = isAutoRetry ? 'retry' : finalResult;
|
|
3697
3814
|
updateMetrics(agentId, dispatchItem, metricsResult, taskUsage, prsCreatedCount, model);
|
|
3698
3815
|
|
|
3699
|
-
return { resultSummary, taskUsage, autoRecovered, structuredCompletion, completionContractFailure, agentReportedFailure, agentRetryable };
|
|
3816
|
+
return { resultSummary, taskUsage, autoRecovered, structuredCompletion, completionContractFailure, agentReportedFailure, agentRetryable, nonceMismatch };
|
|
3700
3817
|
}
|
|
3701
3818
|
|
|
3702
3819
|
// ─── PR → PRD Status Sync ─────────────────────────────────────────────────────
|
package/engine/playbook.js
CHANGED
|
@@ -451,6 +451,23 @@ function renderPlaybook(type, vars) {
|
|
|
451
451
|
}
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
+
// W-mp68q6ke0010de68 — opt-in keep_processes hint. Injected only when the
|
|
455
|
+
// dispatcher set vars.keep_processes (truthy) from the work item's
|
|
456
|
+
// `meta.keep_processes`. Built via the keep-process-sweep module so the
|
|
457
|
+
// copy stays in sync with the engine-side caps it actually enforces.
|
|
458
|
+
if (vars.keep_processes) {
|
|
459
|
+
try {
|
|
460
|
+
const keepProcessSweep = require('./keep-process-sweep');
|
|
461
|
+
const hint = keepProcessSweep.buildKeepProcessesHint({
|
|
462
|
+
agentId: vars.agent_id,
|
|
463
|
+
workItemId: vars.item_id || vars.task_id,
|
|
464
|
+
ttlMinutes: vars.keep_processes_ttl_minutes,
|
|
465
|
+
minionsDir: MINIONS_DIR,
|
|
466
|
+
});
|
|
467
|
+
if (hint) inertAppendices.push(hint);
|
|
468
|
+
} catch (e) { log('warn', `keep_processes hint render failed: ${e.message}`); }
|
|
469
|
+
}
|
|
470
|
+
|
|
454
471
|
// Inject KB guardrail
|
|
455
472
|
content += `\n\n---\n\n## Knowledge Base Rules\n\n`;
|
|
456
473
|
content += `**Never delete, move, or overwrite files in \`knowledge/\`.** The sweep (consolidation engine) is the only process that writes to \`knowledge/\`. If you think a KB file is wrong, note it in your learnings file — do not touch \`knowledge/\` directly.\n`;
|
package/engine/queries.js
CHANGED
|
@@ -1526,6 +1526,75 @@ function resetPrdInfoCache() {
|
|
|
1526
1526
|
_prdResultInputHash = '';
|
|
1527
1527
|
}
|
|
1528
1528
|
|
|
1529
|
+
// ── Project git status (current branch + dirty/detached) ──────────────────
|
|
1530
|
+
// Cached per resolved localPath with a 30s TTL so /api/status doesn't shell
|
|
1531
|
+
// out to git on every poll. Mirrors the cache shape used by getDiskVersion in
|
|
1532
|
+
// dashboard.js (TTL + cached map). All git invocations pipe stderr to suppress
|
|
1533
|
+
// the `fatal: not a git repository` noise on non-git project paths — same
|
|
1534
|
+
// requirement enforced for install/boot paths in
|
|
1535
|
+
// test/unit/runtime-fleet-helpers.test.js:465.
|
|
1536
|
+
const _projectGitStatusCache = new Map();
|
|
1537
|
+
const PROJECT_GIT_STATUS_TTL = 30000; // 30s
|
|
1538
|
+
|
|
1539
|
+
function _gitExec(localPath, args) {
|
|
1540
|
+
const { execFileSync } = require('child_process');
|
|
1541
|
+
return execFileSync('git', ['-C', localPath, ...args], {
|
|
1542
|
+
encoding: 'utf8',
|
|
1543
|
+
timeout: 10000,
|
|
1544
|
+
windowsHide: true,
|
|
1545
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function getProjectGitStatus(localPath) {
|
|
1550
|
+
const key = String(localPath || '').replace(/\\/g, '/');
|
|
1551
|
+
if (!key) return { gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'missing' };
|
|
1552
|
+
const now = Date.now();
|
|
1553
|
+
const cached = _projectGitStatusCache.get(key);
|
|
1554
|
+
if (cached && (now - cached.ts) < PROJECT_GIT_STATUS_TTL) return cached.value;
|
|
1555
|
+
|
|
1556
|
+
let value = { gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'missing' };
|
|
1557
|
+
try {
|
|
1558
|
+
if (!fs.existsSync(localPath)) {
|
|
1559
|
+
// missing — cache the negative result
|
|
1560
|
+
} else {
|
|
1561
|
+
let isRepo = false;
|
|
1562
|
+
try {
|
|
1563
|
+
const out = _gitExec(localPath, ['rev-parse', '--is-inside-work-tree']).trim();
|
|
1564
|
+
isRepo = out === 'true';
|
|
1565
|
+
} catch { isRepo = false; }
|
|
1566
|
+
if (!isRepo) {
|
|
1567
|
+
value = { gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'non-git' };
|
|
1568
|
+
} else {
|
|
1569
|
+
let branch = null;
|
|
1570
|
+
let detached = false;
|
|
1571
|
+
try {
|
|
1572
|
+
branch = _gitExec(localPath, ['rev-parse', '--abbrev-ref', 'HEAD']).trim() || null;
|
|
1573
|
+
} catch { branch = null; }
|
|
1574
|
+
if (branch === 'HEAD') {
|
|
1575
|
+
detached = true;
|
|
1576
|
+
try { branch = _gitExec(localPath, ['rev-parse', '--short', 'HEAD']).trim() || null; }
|
|
1577
|
+
catch { branch = null; }
|
|
1578
|
+
}
|
|
1579
|
+
let dirty = false;
|
|
1580
|
+
try {
|
|
1581
|
+
const status = _gitExec(localPath, ['status', '--porcelain', '--untracked-files=no']);
|
|
1582
|
+
dirty = status.length > 0;
|
|
1583
|
+
} catch { dirty = false; }
|
|
1584
|
+
value = { gitBranch: branch, gitDetached: detached, gitDirty: dirty, gitState: 'ok' };
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
} catch {
|
|
1588
|
+
// Defensive — never let a git probe failure break /api/status
|
|
1589
|
+
value = { gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'missing' };
|
|
1590
|
+
}
|
|
1591
|
+
_projectGitStatusCache.set(key, { ts: now, value });
|
|
1592
|
+
return value;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/** Reset project git-status cache — exported for testing */
|
|
1596
|
+
function resetProjectGitStatusCache() { _projectGitStatusCache.clear(); }
|
|
1597
|
+
|
|
1529
1598
|
// ── Exports ─────────────────────────────────────────────────────────────────
|
|
1530
1599
|
|
|
1531
1600
|
module.exports = {
|
|
@@ -1538,7 +1607,9 @@ module.exports = {
|
|
|
1538
1607
|
readHeadTail, // exported for testing
|
|
1539
1608
|
detectInFlightTool, // exported for testing
|
|
1540
1609
|
resetPrdInfoCache,
|
|
1610
|
+
resetProjectGitStatusCache,
|
|
1541
1611
|
invalidateKnowledgeBaseCache,
|
|
1612
|
+
getProjectGitStatus,
|
|
1542
1613
|
|
|
1543
1614
|
// Core state
|
|
1544
1615
|
getConfig, getControl, getDispatch, getDispatchQueue, getDispatchCompletionReport, invalidateDispatchCache,
|
package/engine/recovery.js
CHANGED
|
@@ -76,6 +76,12 @@ const RECOVERY_RECIPES = new Map([
|
|
|
76
76
|
freshSession: true,
|
|
77
77
|
description: 'Context exhausted — retry with fresh session, flag if repeated',
|
|
78
78
|
}],
|
|
79
|
+
[FAILURE_CLASS.WORKTREE_PREFLIGHT, {
|
|
80
|
+
maxAttempts: 0,
|
|
81
|
+
escalation: ESCALATION_POLICY.NO_RETRY,
|
|
82
|
+
freshSession: false,
|
|
83
|
+
description: 'Worktree preflight rejected — same inputs will recompute to the same rejection (drive-root rootDir, nested-in-project worktree). Fix the dispatch (attach a project, move MINIONS_DIR, or override engine.worktreeRoot) before retrying.',
|
|
84
|
+
}],
|
|
79
85
|
[FAILURE_CLASS.UNKNOWN, {
|
|
80
86
|
maxAttempts: null, // null = fall back to ENGINE_DEFAULTS.maxRetries
|
|
81
87
|
escalation: ESCALATION_POLICY.AUTO,
|