@yemi33/minions 0.1.2381 → 0.1.2382
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 +41 -2
- package/dashboard/js/refresh.js +8 -2
- package/dashboard/js/render-other.js +38 -0
- package/dashboard/js/render-work-items.js +61 -20
- package/dashboard/js/settings.js +7 -8
- package/dashboard/pages/engine.html +6 -0
- package/dashboard.js +84 -15
- package/docs/README.md +1 -0
- package/docs/claude-md-propagation.md +118 -0
- package/docs/engine-restart.md +10 -0
- package/docs/worktree-lifecycle.md +9 -0
- package/engine/claude-md-context.js +195 -0
- package/engine/consolidation.js +47 -3
- package/engine/db/migrations/026-pre-dispatch-rejections.js +55 -0
- package/engine/dispatch.js +73 -21
- package/engine/lifecycle.js +70 -12
- package/engine/pipeline.js +147 -20
- package/engine/playbook.js +35 -0
- package/engine/prd-store.js +14 -6
- package/engine/quarantine-refs.js +33 -0
- package/engine/queries.js +8 -6
- package/engine/restart-health.js +69 -4
- package/engine/runtimes/claude.js +6 -0
- package/engine/runtimes/codex.js +18 -0
- package/engine/runtimes/copilot.js +7 -0
- package/engine/shared.js +63 -44
- package/engine/supervisor.js +72 -16
- package/engine/timeout.js +87 -16
- package/engine/untrusted-fence.js +2 -1
- package/engine.js +251 -16
- package/package.json +1 -1
package/docs/engine-restart.md
CHANGED
|
@@ -70,6 +70,16 @@ If an agent is killed as an orphan and the work item retries, cooldowns use expo
|
|
|
70
70
|
|
|
71
71
|
The engine.js *process itself* (not an agent) can crash and get auto-respawned by three independent mechanisms: `engine/supervisor.js#checkEngine` (dead-PID), `engine/supervisor.js#checkEngineHung` (stale-heartbeat), and `engine/watchdog.js` (OS-scheduled external recovery); `dashboard.js`'s in-process 30s watchdog also auto-restarts on a dead PID (the user-triggered Restart Engine button is excluded). Each successful respawn calls `shared.recordEngineRespawn(source)`, which appends `{ts, source}` to `control.json`'s `engineRespawns[]` (rolling window, default `CRASH_LOOP_WINDOW_MS` = 10 min). Once respawns within the window reach `CRASH_LOOP_THRESHOLD` (default 3), `control.crashLoopAlert` is set (dashboard-visible via `/api/status`) and a deduped (one-per-day) `notes/inbox/` alert is written; the alert clears automatically once the window rolls past the incident. Override the window/threshold via `MINIONS_CRASH_LOOP_WINDOW_MS` / `MINIONS_CRASH_LOOP_THRESHOLD` env vars.
|
|
72
72
|
|
|
73
|
+
### 7. Exclusive Restart Topology
|
|
74
|
+
|
|
75
|
+
Managed dashboard spawns must bind the requested port; automatic fallback remains available only for standalone `node dashboard.js` use. Before a supervisor respawn, every matching stale daemon is killed and confirmed dead. If any PID survives, the supervisor skips the replacement and retries on its next tick instead of creating a second engine or a dashboard on another port.
|
|
76
|
+
|
|
77
|
+
`minions restart` verifies ownership of the exact engine, dashboard, and supervisor PIDs it spawned and rejects duplicate daemon processes. A failed verification tears down the partial stack and leaves `stop-intent.json` set so watchdogs cannot race another replacement into the failed topology.
|
|
78
|
+
|
|
79
|
+
PID-bearing file locks are reaped immediately when their recorded owner is dead. The five-minute stale threshold remains for legacy locks without owner metadata and the longer safety window remains for live holders.
|
|
80
|
+
|
|
81
|
+
Dashboard hot polling avoids recurring event-loop stalls: freshness-directory walks are coalesced briefly, plan scans are cached for 30 seconds and processed in cooperative batches, and quarantine-ref Git reads run asynchronously in parallel.
|
|
82
|
+
|
|
73
83
|
## Safe Restart Pattern
|
|
74
84
|
|
|
75
85
|
```bash
|
|
@@ -77,6 +77,15 @@ distinguishes the **original worktree-preflight issue** from a
|
|
|
77
77
|
|
|
78
78
|
## Quarantine path (dirty / divergent)
|
|
79
79
|
|
|
80
|
+
Before quarantine, a reused worktree that is filesystem-clean and strictly
|
|
81
|
+
ahead of `origin/<branch>` is pushed fast-forward to origin. Dispatch close
|
|
82
|
+
and stale-orphan cleanup run the same preservation check before pool return or
|
|
83
|
+
worktree removal. Dirty trees and branches confirmed behind origin retain the
|
|
84
|
+
quarantine fallback; transient probe, fetch, auth, and push failures leave clean
|
|
85
|
+
ahead commits in place for retry. Recoverable
|
|
86
|
+
`refs/minions/quarantine/*` and `refs/minions/quarantine-wip/*` refs are listed
|
|
87
|
+
on Dashboard → Engine under **Quarantined Work**.
|
|
88
|
+
|
|
80
89
|
When `discoverFromWorkItems` finds a worktree in a `WORKTREE_DIRTY`,
|
|
81
90
|
`WORKTREE_DIVERGENT`, or post-stuck state, `_quarantineDirtyWorktree`
|
|
82
91
|
moves it to `<root>/.quarantined/<basename>-<utc>` and lets the next tick
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/claude-md-context.js — CLAUDE.md propagation for non-Claude runtimes.
|
|
3
|
+
*
|
|
4
|
+
* W-mrdon0pe000l045a. CLAUDE.md auto-discovery is a Claude-Code-CLI-only
|
|
5
|
+
* convention (the claude binary reads CLAUDE.md itself at startup). Copilot and
|
|
6
|
+
* Codex have NO equivalent auto-load, so repo-authored CLAUDE.md files — which
|
|
7
|
+
* frequently carry load-bearing "must be kept in sync" rules and name blessed
|
|
8
|
+
* tooling — are structurally invisible to a Copilot/Codex-based fleet.
|
|
9
|
+
*
|
|
10
|
+
* This module discovers the *nearest applicable* CLAUDE.md files for a dispatch
|
|
11
|
+
* (bounded walk-UP from path hints toward the project root — NOT a full monorepo
|
|
12
|
+
* walk) and renders them into a prompt block. engine/playbook.js injects that
|
|
13
|
+
* block as a "Project instructions (CLAUDE.md)" context layer, gated on the
|
|
14
|
+
* runtime capability `claudeMdNativeDiscovery` (skip when true → Claude) and the
|
|
15
|
+
* `propagateClaudeMdForNonClaudeRuntimes` config flag.
|
|
16
|
+
*
|
|
17
|
+
* The whole point is to be CHEAP: it only stats CLAUDE.md at each ancestor
|
|
18
|
+
* directory of a small set of hint dirs (capped depth + capped file count), so
|
|
19
|
+
* it never walks a huge monorepo like office/src.
|
|
20
|
+
*
|
|
21
|
+
* This module is pure file discovery + string building — no engine state, no
|
|
22
|
+
* locking, no writes.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const { truncateTextBytes, ENGINE_DEFAULTS } = require('./shared');
|
|
28
|
+
|
|
29
|
+
const CLAUDE_MD_FILENAME = 'CLAUDE.md';
|
|
30
|
+
// Cap the number of CLAUDE.md files collected across all hints — defense against
|
|
31
|
+
// a dispatch with many path hints spread across a deep tree.
|
|
32
|
+
const MAX_FILES = 8;
|
|
33
|
+
// Cap the walk-up depth from any single hint dir — defense for pathological deep
|
|
34
|
+
// Windows paths so a single hint can't spin the loop unbounded.
|
|
35
|
+
const MAX_WALK_DEPTH = 24;
|
|
36
|
+
// Byte cap for the whole injected block. Reuses the same budget the sibling
|
|
37
|
+
// notes / agent-memory context layers use (maxNotesPromptBytes), so CLAUDE.md
|
|
38
|
+
// propagation can never balloon a prompt on a large monorepo.
|
|
39
|
+
const DEFAULT_MAX_BYTES = ENGINE_DEFAULTS.maxNotesPromptBytes;
|
|
40
|
+
|
|
41
|
+
function _pathEq(a, b) {
|
|
42
|
+
if (process.platform === 'win32') return a.toLowerCase() === b.toLowerCase();
|
|
43
|
+
return a === b;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Is `target` inside (or equal to) `root`?
|
|
47
|
+
function _isWithin(root, target) {
|
|
48
|
+
const rel = path.relative(root, target);
|
|
49
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Normalize a hint (a file OR directory, relative-to-root OR absolute) into an
|
|
54
|
+
* absolute directory contained within `root`. Returns null when the hint
|
|
55
|
+
* escapes the project root (path traversal guard). Non-existent hints are still
|
|
56
|
+
* resolved structurally (extension ⇒ file ⇒ parent dir; otherwise dir).
|
|
57
|
+
*/
|
|
58
|
+
function _hintToDir(root, hint) {
|
|
59
|
+
if (!hint || typeof hint !== 'string' || !hint.trim()) return null;
|
|
60
|
+
const cleaned = hint.trim();
|
|
61
|
+
let abs = path.isAbsolute(cleaned) ? cleaned : path.join(root, cleaned);
|
|
62
|
+
abs = path.resolve(abs);
|
|
63
|
+
if (!_isWithin(root, abs)) return null;
|
|
64
|
+
try {
|
|
65
|
+
const st = fs.statSync(abs);
|
|
66
|
+
if (st.isDirectory()) return abs;
|
|
67
|
+
return path.dirname(abs);
|
|
68
|
+
} catch {
|
|
69
|
+
// Doesn't exist on disk — infer file vs dir from a trailing extension.
|
|
70
|
+
return path.extname(abs) ? path.dirname(abs) : abs;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Discover CLAUDE.md files by walking UP from each hint directory to the project
|
|
76
|
+
* root, plus the project root itself as a guaranteed fallback. Returns entries
|
|
77
|
+
* ordered nearest-first (deepest directory first, project root last) so the
|
|
78
|
+
* caller renders "nearest wins" — the most specific instructions lead.
|
|
79
|
+
*
|
|
80
|
+
* @param {{ projectRoot: string, pathHints?: string[] }} opts
|
|
81
|
+
* @returns {{ absPath: string, relPath: string, depth: number }[]}
|
|
82
|
+
*/
|
|
83
|
+
function discoverClaudeMdFiles({ projectRoot, pathHints = [] } = {}) {
|
|
84
|
+
if (!projectRoot || typeof projectRoot !== 'string') return [];
|
|
85
|
+
const root = path.resolve(projectRoot);
|
|
86
|
+
try { if (!fs.statSync(root).isDirectory()) return []; } catch { return []; }
|
|
87
|
+
|
|
88
|
+
// Starting dirs: each valid hint dir, then the root itself (fallback so the
|
|
89
|
+
// project-root CLAUDE.md is always considered even with zero hints).
|
|
90
|
+
const startDirs = [];
|
|
91
|
+
for (const h of Array.isArray(pathHints) ? pathHints : []) {
|
|
92
|
+
const d = _hintToDir(root, h);
|
|
93
|
+
if (d) startDirs.push(d);
|
|
94
|
+
}
|
|
95
|
+
startDirs.push(root);
|
|
96
|
+
|
|
97
|
+
const found = new Map(); // absPath -> { depth, rel }
|
|
98
|
+
for (const start of startDirs) {
|
|
99
|
+
let dir = start;
|
|
100
|
+
let steps = 0;
|
|
101
|
+
while (steps <= MAX_WALK_DEPTH) {
|
|
102
|
+
const candidate = path.join(dir, CLAUDE_MD_FILENAME);
|
|
103
|
+
if (!found.has(candidate)) {
|
|
104
|
+
try {
|
|
105
|
+
if (fs.statSync(candidate).isFile()) {
|
|
106
|
+
const rel = path.relative(root, dir);
|
|
107
|
+
const depth = (rel === '' || rel === '.') ? 0 : rel.split(/[\\/]/).length;
|
|
108
|
+
found.set(candidate, { depth });
|
|
109
|
+
}
|
|
110
|
+
} catch { /* no CLAUDE.md at this level */ }
|
|
111
|
+
}
|
|
112
|
+
if (_pathEq(dir, root)) break; // stop at project root (never above)
|
|
113
|
+
const parent = path.dirname(dir);
|
|
114
|
+
if (_pathEq(parent, dir)) break; // filesystem-root guard
|
|
115
|
+
if (!_isWithin(root, parent)) break;
|
|
116
|
+
dir = parent;
|
|
117
|
+
steps++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const entries = Array.from(found.entries()).map(([absPath, meta]) => ({
|
|
122
|
+
absPath,
|
|
123
|
+
relPath: (path.relative(root, absPath) || CLAUDE_MD_FILENAME).replace(/\\/g, '/'),
|
|
124
|
+
depth: meta.depth,
|
|
125
|
+
}));
|
|
126
|
+
// nearest-first: deepest dir first; stable tiebreak on relPath for determinism.
|
|
127
|
+
entries.sort((a, b) => (b.depth - a.depth) || a.relPath.localeCompare(b.relPath));
|
|
128
|
+
return entries.slice(0, MAX_FILES);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the CLAUDE.md propagation block (inner content, no outer header/fence —
|
|
133
|
+
* engine/playbook.js owns those). Reads each discovered file, labels it by its
|
|
134
|
+
* repo-relative path, and truncates to stay within `maxBytes` total. Returns
|
|
135
|
+
* `{ block, files }` where `block` is '' when nothing was found or all files
|
|
136
|
+
* were empty.
|
|
137
|
+
*
|
|
138
|
+
* @param {{ projectRoot: string, pathHints?: string[], maxBytes?: number }} opts
|
|
139
|
+
*/
|
|
140
|
+
function buildClaudeMdPropagationBlock({ projectRoot, pathHints = [], maxBytes = DEFAULT_MAX_BYTES } = {}) {
|
|
141
|
+
const files = discoverClaudeMdFiles({ projectRoot, pathHints });
|
|
142
|
+
if (files.length === 0) return { block: '', files: [] };
|
|
143
|
+
|
|
144
|
+
const cap = Math.max(256, Number(maxBytes) || DEFAULT_MAX_BYTES);
|
|
145
|
+
const parts = [];
|
|
146
|
+
const included = [];
|
|
147
|
+
let used = 0;
|
|
148
|
+
|
|
149
|
+
for (const f of files) {
|
|
150
|
+
let content;
|
|
151
|
+
try { content = fs.readFileSync(f.absPath, 'utf8'); } catch { continue; }
|
|
152
|
+
if (!content || !content.trim()) continue;
|
|
153
|
+
|
|
154
|
+
const heading = `\n\n### ${f.relPath}\n\n`;
|
|
155
|
+
const headingBytes = Buffer.byteLength(heading, 'utf8');
|
|
156
|
+
const remaining = cap - used - headingBytes;
|
|
157
|
+
if (remaining <= 0) break; // no room even for the next heading
|
|
158
|
+
|
|
159
|
+
const body = truncateTextBytes(content, remaining, '\n\n_...CLAUDE.md truncated (read the full file if needed)_');
|
|
160
|
+
parts.push(heading + body);
|
|
161
|
+
used += headingBytes + Buffer.byteLength(body, 'utf8');
|
|
162
|
+
included.push(f.relPath);
|
|
163
|
+
if (used >= cap) break;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (parts.length === 0) return { block: '', files: [] };
|
|
167
|
+
return { block: parts.join('').replace(/^\n+/, ''), files: included };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Does the named runtime auto-discover CLAUDE.md natively? Reads the adapter's
|
|
172
|
+
* `capabilities.claudeMdNativeDiscovery`. Unknown runtimes return false ("does
|
|
173
|
+
* NOT auto-discover") so propagation is the safe additive default — an
|
|
174
|
+
* unrecognized runtime is far more likely to lack native CLAUDE.md loading than
|
|
175
|
+
* to have it.
|
|
176
|
+
*/
|
|
177
|
+
function runtimeAutoDiscoversClaudeMd(cliName) {
|
|
178
|
+
try {
|
|
179
|
+
const { resolveRuntime } = require('./runtimes');
|
|
180
|
+
const rt = resolveRuntime(cliName);
|
|
181
|
+
return !!(rt && rt.capabilities && rt.capabilities.claudeMdNativeDiscovery);
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
CLAUDE_MD_FILENAME,
|
|
189
|
+
MAX_FILES,
|
|
190
|
+
MAX_WALK_DEPTH,
|
|
191
|
+
DEFAULT_MAX_BYTES,
|
|
192
|
+
discoverClaudeMdFiles,
|
|
193
|
+
buildClaudeMdPropagationBlock,
|
|
194
|
+
runtimeAutoDiscoversClaudeMd,
|
|
195
|
+
};
|
package/engine/consolidation.js
CHANGED
|
@@ -85,6 +85,34 @@ function _readNotesForAppendOrNull() {
|
|
|
85
85
|
return fs.existsSync(NOTES_PATH) ? null : '';
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// Resiliently write notes.md from inside a consolidation lock callback.
|
|
89
|
+
// CRASH GUARD (#858): safeWrite -> renameSync throws EPERM on Windows when
|
|
90
|
+
// notes.md is held open by another process (e.g. a leaked dashboard
|
|
91
|
+
// /state/notes.md read stream). That throw used to propagate out of the
|
|
92
|
+
// async consolidateWithLLM(...) chain / the regex-fallback timer callback with
|
|
93
|
+
// NO surrounding try/catch, becoming an UNHANDLED PROMISE REJECTION (or an
|
|
94
|
+
// uncaught timer exception) that killed the whole engine daemon. Supervisor
|
|
95
|
+
// respawned it, but pooled Copilot ACP agents cannot reattach across restart,
|
|
96
|
+
// so every in-flight agent was orphaned.
|
|
97
|
+
//
|
|
98
|
+
// This wraps safeWrite in shared._retryFsOp (extra exponential-backoff retries
|
|
99
|
+
// on EPERM/EBUSY/EACCES on top of safeWrite's own short retry loop) AND a
|
|
100
|
+
// try/catch, so a transient lock retries then DEFERS: returns false so the
|
|
101
|
+
// caller leaves the inbox notes in place for the next tick instead of crashing.
|
|
102
|
+
// Returns true on success, false if the write could not be completed.
|
|
103
|
+
function _writeNotesOrDefer(content, label) {
|
|
104
|
+
try {
|
|
105
|
+
// Keep the in-lock retry budget small (safeWrite already retries EPERM ~5x
|
|
106
|
+
// internally): a few extra exponential-backoff attempts cover a slightly
|
|
107
|
+
// longer transient hold without stalling the consolidation lock.
|
|
108
|
+
shared._retryFsOp(() => safeWrite(NOTES_PATH, content), `write notes.md (${label})`, { attempts: 3, baseMs: 100 });
|
|
109
|
+
return true;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
log('warn', `${label}: notes.md write failed after retries (${err?.code || ''} ${err?.message || err}) \u2014 deferring, inbox notes left for next cycle`);
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
88
116
|
// Per-agent memory files live under knowledge/agents/<agent>.md and are
|
|
89
117
|
// injected into individual agent prompts (in addition to the broadcast
|
|
90
118
|
// notes.md). See knowledge/agents/README.md for the convention.
|
|
@@ -971,7 +999,17 @@ function consolidateWithLLM(items, existingNotes, files, config) {
|
|
|
971
999
|
fallbackDone = true;
|
|
972
1000
|
if (message) log('warn', message);
|
|
973
1001
|
if (err?.message) log('debug', `LLM error: ${err.message}`);
|
|
974
|
-
|
|
1002
|
+
// CRASH GUARD (#858): _fallback runs from a promise .catch AND from a
|
|
1003
|
+
// setTimeout callback. An uncaught throw from consolidateWithRegex in the
|
|
1004
|
+
// timer path is an uncaught exception that kills the daemon; in the .catch
|
|
1005
|
+
// path it is an unhandled rejection. The notes.md write already defers on
|
|
1006
|
+
// lock contention, but keep a top-level guard so no other regex-fallback
|
|
1007
|
+
// failure can ever crash the engine — leave inbox notes for the next tick.
|
|
1008
|
+
try {
|
|
1009
|
+
consolidateWithRegex(items, files, config);
|
|
1010
|
+
} catch (e) {
|
|
1011
|
+
log('warn', `Regex consolidation fallback failed (${e?.code || ''} ${e?.message || e}) \u2014 inbox notes left for next cycle`);
|
|
1012
|
+
}
|
|
975
1013
|
}
|
|
976
1014
|
|
|
977
1015
|
const llmCall = callLLM(prompt, sysPrompt, {
|
|
@@ -1033,7 +1071,9 @@ function consolidateWithLLM(items, existingNotes, files, config) {
|
|
|
1033
1071
|
// DATA-LOSS GUARD: cap size but archive the overflow instead of
|
|
1034
1072
|
// silently discarding it (see _capNotesPreservingOverflow).
|
|
1035
1073
|
const newContent = _capNotesPreservingOverflow(current + entry);
|
|
1036
|
-
|
|
1074
|
+
// CRASH GUARD (#858): defer (return false) on a transient write lock
|
|
1075
|
+
// instead of throwing out of this async chain and crashing the daemon.
|
|
1076
|
+
if (!_writeNotesOrDefer(newContent, 'LLM consolidation')) return false;
|
|
1037
1077
|
return true;
|
|
1038
1078
|
});
|
|
1039
1079
|
if (!wrote) {
|
|
@@ -1169,7 +1209,10 @@ function consolidateWithRegex(items, files, config) {
|
|
|
1169
1209
|
}
|
|
1170
1210
|
// DATA-LOSS GUARD: cap size but archive the overflow (see LLM path).
|
|
1171
1211
|
const newContent = _capNotesPreservingOverflow(current + entry);
|
|
1172
|
-
|
|
1212
|
+
// CRASH GUARD (#858): defer (return false) on a transient write lock
|
|
1213
|
+
// instead of throwing out of this synchronous fallback (invoked from a
|
|
1214
|
+
// timer callback and a promise .catch) and crashing the daemon.
|
|
1215
|
+
if (!_writeNotesOrDefer(newContent, 'Regex consolidation')) return false;
|
|
1173
1216
|
return true;
|
|
1174
1217
|
});
|
|
1175
1218
|
if (!wrote) {
|
|
@@ -1530,4 +1573,5 @@ module.exports = {
|
|
|
1530
1573
|
buildCondensedKbBody,
|
|
1531
1574
|
_alertContentHash,
|
|
1532
1575
|
_alertHashExists,
|
|
1576
|
+
_writeNotesOrDefer,
|
|
1533
1577
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
function _parseObject(raw) {
|
|
2
|
+
try {
|
|
3
|
+
const value = JSON.parse(raw);
|
|
4
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
5
|
+
} catch {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
version: 26,
|
|
12
|
+
description: 'normalize exhausted pre-dispatch rejections out of execution failures',
|
|
13
|
+
up(db) {
|
|
14
|
+
const rows = db.prepare(`
|
|
15
|
+
SELECT rowid, data
|
|
16
|
+
FROM work_items
|
|
17
|
+
WHERE status = 'failed' AND archived = 0
|
|
18
|
+
`).all();
|
|
19
|
+
const update = db.prepare(`
|
|
20
|
+
UPDATE work_items
|
|
21
|
+
SET status = 'pending', data = ?, updated_at = ?
|
|
22
|
+
WHERE rowid = ? AND archived = 0
|
|
23
|
+
`);
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
for (const row of rows) {
|
|
26
|
+
const item = _parseObject(row.data);
|
|
27
|
+
if (!item || item._failureClass !== 'pre-dispatch-eval-stuck') continue;
|
|
28
|
+
const evaluation = item._preDispatchEval && typeof item._preDispatchEval === 'object'
|
|
29
|
+
? item._preDispatchEval
|
|
30
|
+
: {};
|
|
31
|
+
const evaluatedAt = evaluation.evaluatedAt || item.failedAt || new Date(now).toISOString();
|
|
32
|
+
const history = Array.isArray(evaluation.history) ? evaluation.history : [];
|
|
33
|
+
if (history.length === 0 && evaluation.reason) {
|
|
34
|
+
history.push({
|
|
35
|
+
reason: evaluation.reason,
|
|
36
|
+
evaluatedAt,
|
|
37
|
+
description: evaluation.description || '',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
item.status = 'pending';
|
|
41
|
+
item._pendingReason = 'pre_dispatch_eval_rejected';
|
|
42
|
+
item._preDispatchEval = {
|
|
43
|
+
...evaluation,
|
|
44
|
+
valid: false,
|
|
45
|
+
exhausted: true,
|
|
46
|
+
exhaustedAt: evaluation.exhaustedAt || evaluatedAt,
|
|
47
|
+
history,
|
|
48
|
+
};
|
|
49
|
+
delete item._failureClass;
|
|
50
|
+
delete item.failReason;
|
|
51
|
+
delete item.failedAt;
|
|
52
|
+
update.run(JSON.stringify(item), now, row.rowid);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
package/engine/dispatch.js
CHANGED
|
@@ -307,10 +307,10 @@ function addToDispatch(item) {
|
|
|
307
307
|
function _persistInvalidWorkItem(item, evaluation) {
|
|
308
308
|
const meta = item?.meta;
|
|
309
309
|
const itemId = meta?.item?.id;
|
|
310
|
-
if (!itemId) return {
|
|
310
|
+
if (!itemId) return { exhausted: false };
|
|
311
311
|
let wiScope;
|
|
312
312
|
try { wiScope = lifecycle().resolveWorkItemScope(meta); } catch { wiScope = null; }
|
|
313
|
-
if (!wiScope) return {
|
|
313
|
+
if (!wiScope) return { exhausted: false };
|
|
314
314
|
// W-mrcrh5hj0017d22f — repeat-rejection cap. Compare against the
|
|
315
315
|
// description snapshot from the LAST rejection so an operator edit to the
|
|
316
316
|
// WI resets the counter (it deserves a fresh evaluation), while an
|
|
@@ -319,7 +319,7 @@ function _persistInvalidWorkItem(item, evaluation) {
|
|
|
319
319
|
const maxRejections = queries.getConfig()?.engine?.preDispatchEvalMaxRejections
|
|
320
320
|
?? ENGINE_DEFAULTS.preDispatchEvalMaxRejections;
|
|
321
321
|
const currentDescription = String((meta.item && meta.item.description) || '');
|
|
322
|
-
let
|
|
322
|
+
let exhausted = false;
|
|
323
323
|
try {
|
|
324
324
|
mutateWorkItems(wiScope, (items) => {
|
|
325
325
|
if (!Array.isArray(items)) return items;
|
|
@@ -329,31 +329,79 @@ function _persistInvalidWorkItem(item, evaluation) {
|
|
|
329
329
|
const prior = wi._preDispatchEval;
|
|
330
330
|
const sameDescription = prior && prior.description === currentDescription;
|
|
331
331
|
const rejectCount = (sameDescription ? (prior.rejectCount || 0) : 0) + 1;
|
|
332
|
+
const history = Array.isArray(prior?.history) ? prior.history.slice(-19) : [];
|
|
333
|
+
const evaluatedAt = ts();
|
|
334
|
+
history.push({
|
|
335
|
+
reason: evaluation.reason || '',
|
|
336
|
+
evaluatedAt,
|
|
337
|
+
description: currentDescription,
|
|
338
|
+
});
|
|
332
339
|
wi._preDispatchEval = {
|
|
333
340
|
valid: false,
|
|
334
341
|
reason: evaluation.reason || '',
|
|
335
|
-
evaluatedAt
|
|
342
|
+
evaluatedAt,
|
|
336
343
|
rejectCount,
|
|
337
344
|
description: currentDescription,
|
|
345
|
+
history,
|
|
338
346
|
};
|
|
339
347
|
if (rejectCount >= maxRejections) {
|
|
340
|
-
|
|
341
|
-
wi.status = WI_STATUS.
|
|
342
|
-
wi.
|
|
343
|
-
wi.
|
|
344
|
-
|
|
345
|
-
wi.
|
|
346
|
-
wi.
|
|
348
|
+
exhausted = true;
|
|
349
|
+
wi.status = WI_STATUS.PENDING;
|
|
350
|
+
wi._preDispatchEval.exhausted = true;
|
|
351
|
+
wi._preDispatchEval.exhaustedAt = evaluatedAt;
|
|
352
|
+
wi._pendingReason = 'pre_dispatch_eval_rejected';
|
|
353
|
+
delete wi._failureClass;
|
|
354
|
+
delete wi.failReason;
|
|
355
|
+
delete wi.failedAt;
|
|
347
356
|
}
|
|
348
357
|
return items;
|
|
349
358
|
}, { skipWriteIfUnchanged: true });
|
|
350
359
|
} catch (e) {
|
|
351
360
|
log('warn', `pre-dispatch-eval: failed to persist reason on ${itemId}: ${e.message}`);
|
|
352
361
|
}
|
|
353
|
-
if (
|
|
354
|
-
log('warn', `pre-dispatch-eval: ${itemId} rejected ${maxRejections}+ times with unchanged description —
|
|
362
|
+
if (exhausted) {
|
|
363
|
+
log('warn', `pre-dispatch-eval: ${itemId} rejected ${maxRejections}+ times with unchanged description — paused pending operator edit`);
|
|
364
|
+
}
|
|
365
|
+
return { exhausted };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function _clearPreDispatchRejection(item, evaluation) {
|
|
369
|
+
const meta = item?.meta;
|
|
370
|
+
const itemId = meta?.item?.id;
|
|
371
|
+
if (!itemId) return;
|
|
372
|
+
let wiScope;
|
|
373
|
+
try { wiScope = lifecycle().resolveWorkItemScope(meta); } catch { wiScope = null; }
|
|
374
|
+
if (!wiScope) return;
|
|
375
|
+
try {
|
|
376
|
+
mutateWorkItems(wiScope, (items) => {
|
|
377
|
+
if (!Array.isArray(items)) return items;
|
|
378
|
+
const wi = items.find(w => w && w.id === itemId);
|
|
379
|
+
if (!wi || !wi._preDispatchEval) return items;
|
|
380
|
+
wi._preDispatchEval = {
|
|
381
|
+
...wi._preDispatchEval,
|
|
382
|
+
valid: true,
|
|
383
|
+
reason: evaluation?.reason || '',
|
|
384
|
+
evaluatedAt: ts(),
|
|
385
|
+
rejectCount: 0,
|
|
386
|
+
description: String(meta.item?.description || ''),
|
|
387
|
+
};
|
|
388
|
+
delete wi._preDispatchEval.exhausted;
|
|
389
|
+
delete wi._preDispatchEval.exhaustedAt;
|
|
390
|
+
if (wi._pendingReason === 'pre_dispatch_eval_rejected') delete wi._pendingReason;
|
|
391
|
+
return items;
|
|
392
|
+
}, { skipWriteIfUnchanged: true });
|
|
393
|
+
} catch (e) {
|
|
394
|
+
log('warn', `pre-dispatch-eval: failed to clear rejection on ${itemId}: ${e.message}`);
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
mutateDispatch((dispatch) => {
|
|
398
|
+
dispatch.review = (Array.isArray(dispatch.review) ? dispatch.review : [])
|
|
399
|
+
.filter(entry => entry?.meta?.item?.id !== itemId);
|
|
400
|
+
return dispatch;
|
|
401
|
+
});
|
|
402
|
+
} catch (e) {
|
|
403
|
+
log('warn', `pre-dispatch-eval: failed to clear review entry for ${itemId}: ${e.message}`);
|
|
355
404
|
}
|
|
356
|
-
return { stuck };
|
|
357
405
|
}
|
|
358
406
|
|
|
359
407
|
function _routeToReviewQueue(item, evaluation) {
|
|
@@ -438,6 +486,11 @@ async function addToDispatchWithValidation(item, opts = {}) {
|
|
|
438
486
|
if (!hasCriteria && !hasUsableDescription) {
|
|
439
487
|
return addToDispatch(item);
|
|
440
488
|
}
|
|
489
|
+
const priorEvaluation = wi?._preDispatchEval;
|
|
490
|
+
if (priorEvaluation?.exhausted === true
|
|
491
|
+
&& priorEvaluation.description === String(wi?.description || '')) {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
441
494
|
|
|
442
495
|
// W-mq9acoo800177bcb — PRD-sourced bypass. plan-to-prd already LLM-vets
|
|
443
496
|
// every item it lands in a PRD, so re-validating each materialized item
|
|
@@ -477,14 +530,13 @@ async function addToDispatchWithValidation(item, opts = {}) {
|
|
|
477
530
|
return addToDispatch(item);
|
|
478
531
|
}
|
|
479
532
|
|
|
480
|
-
if (!evaluation || evaluation.valid !== false)
|
|
533
|
+
if (!evaluation || evaluation.valid !== false) {
|
|
534
|
+
_clearPreDispatchRejection(item, evaluation);
|
|
535
|
+
return addToDispatch(item);
|
|
536
|
+
}
|
|
481
537
|
|
|
482
|
-
const {
|
|
483
|
-
|
|
484
|
-
// repeat-rejection, don't also push it into the review queue — the
|
|
485
|
-
// failed status already stops future discovery ticks from re-evaluating
|
|
486
|
-
// it, and a review-queue entry would just be a stale duplicate signal.
|
|
487
|
-
if (!stuck) _routeToReviewQueue(item, evaluation);
|
|
538
|
+
const { exhausted } = _persistInvalidWorkItem(item, evaluation);
|
|
539
|
+
if (!exhausted) _routeToReviewQueue(item, evaluation);
|
|
488
540
|
log('warn', `pre-dispatch-eval: blocked work item ${wi.id} — ${evaluation.reason || 'criteria not actionable'}`);
|
|
489
541
|
return null;
|
|
490
542
|
}
|