@yemi33/minions 0.1.2353 → 0.1.2355
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 +71 -19
- package/docs/README.md +2 -0
- package/docs/kb-dedup-duplicate-pair-investigation.md +138 -0
- package/docs/kb-sweep.md +13 -1
- package/docs/live-checkout-mode.md +7 -4
- package/engine/cli.js +4 -0
- package/engine/kb-sweep.js +14 -4
- package/engine/lifecycle.js +55 -22
- package/engine/live-checkout.js +30 -5
- package/engine/playbook.js +1 -0
- package/engine/pr-action.js +12 -7
- package/engine/process-utils.js +700 -0
- package/engine/shared.js +39 -666
- package/engine.js +78 -0
- package/package.json +1 -1
- package/playbooks/plan-to-prd.md +2 -0
package/dashboard.js
CHANGED
|
@@ -6993,10 +6993,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
6993
6993
|
|
|
6994
6994
|
let result = null;
|
|
6995
6995
|
let agentChanged = false;
|
|
6996
|
-
// Set inside the source-file lock below when a project move is
|
|
6997
|
-
//
|
|
6998
|
-
//
|
|
6996
|
+
// Set inside the source-file lock below when a project move is
|
|
6997
|
+
// needed. W-mrbbmltq000fce9f: the move itself (target push + source
|
|
6998
|
+
// removal) is executed AFTER this lock releases, target-write-first,
|
|
6999
|
+
// so a failure never straddles "spliced from source, not yet in
|
|
7000
|
+
// target" — see the two-phase block below.
|
|
6999
7001
|
let movedItem = null;
|
|
7002
|
+
let needsMove = false;
|
|
7000
7003
|
mutateJsonFileLocked(wiPath, (items) => {
|
|
7001
7004
|
if (!Array.isArray(items)) items = [];
|
|
7002
7005
|
const idx = items.findIndex(i => i.id === id);
|
|
@@ -7034,29 +7037,78 @@ const server = http.createServer(async (req, res) => {
|
|
|
7034
7037
|
}
|
|
7035
7038
|
item.updatedAt = new Date().toISOString();
|
|
7036
7039
|
|
|
7037
|
-
// W-mrazefzz000358e3 — actually
|
|
7038
|
-
// files when the resolved target differs
|
|
7039
|
-
// rather than just stamping item.project
|
|
7040
|
-
// when the resolved wiPath actually
|
|
7041
|
-
// project the item already lives in
|
|
7040
|
+
// W-mrazefzz000358e3 — a cross-project move actually MOVEs the item
|
|
7041
|
+
// between work-items.json files when the resolved target differs
|
|
7042
|
+
// from where it lives today, rather than just stamping item.project
|
|
7043
|
+
// in place. Only trip this when the resolved wiPath actually
|
|
7044
|
+
// changes — re-assigning the same project the item already lives in
|
|
7045
|
+
// is a no-op move.
|
|
7046
|
+
//
|
|
7047
|
+
// W-mrbbmltq000fce9f — do NOT splice the item out of the source
|
|
7048
|
+
// array here. Doing the splice-then-push as two independent locked
|
|
7049
|
+
// calls (the historical bug) means a target-write failure after
|
|
7050
|
+
// this callback commits leaves the item spliced from source and
|
|
7051
|
+
// never written to target: silently lost from both stores. Instead
|
|
7052
|
+
// only STAGE the move — clone the (already field-edited) item with
|
|
7053
|
+
// its new `project` value — and leave the source array untouched.
|
|
7054
|
+
// The actual splice happens in a later, separate lock, only once
|
|
7055
|
+
// the target push below has durably committed.
|
|
7042
7056
|
if (projectMove && path.resolve(projectMove.wiPath) !== path.resolve(wiPath)) {
|
|
7043
|
-
|
|
7044
|
-
|
|
7045
|
-
movedItem =
|
|
7046
|
-
|
|
7057
|
+
needsMove = true;
|
|
7058
|
+
movedItem = { ...item };
|
|
7059
|
+
if (projectMove.projectName) movedItem.project = projectMove.projectName;
|
|
7060
|
+
else delete movedItem.project;
|
|
7047
7061
|
}
|
|
7048
7062
|
|
|
7049
|
-
result = { code: 200, body: { ok: true, item } };
|
|
7063
|
+
result = { code: 200, body: { ok: true, item: needsMove ? movedItem : item } };
|
|
7050
7064
|
return items;
|
|
7051
7065
|
});
|
|
7052
7066
|
if (!result) return jsonReply(res, 500, { error: 'unexpected state' });
|
|
7053
7067
|
|
|
7054
|
-
if (
|
|
7055
|
-
|
|
7056
|
-
|
|
7057
|
-
|
|
7058
|
-
|
|
7059
|
-
|
|
7068
|
+
if (needsMove && result.code === 200) {
|
|
7069
|
+
try {
|
|
7070
|
+
// Phase 1 of the move: durably write the item into the TARGET
|
|
7071
|
+
// store first. If this throws (lock timeout, storage error,
|
|
7072
|
+
// process crash), the source store still holds the item
|
|
7073
|
+
// untouched (aside from the field edits already committed above)
|
|
7074
|
+
// — nothing to roll back, nothing lost.
|
|
7075
|
+
mutateJsonFileLocked(projectMove.wiPath, (targetItems) => {
|
|
7076
|
+
if (!Array.isArray(targetItems)) targetItems = [];
|
|
7077
|
+
targetItems.push(movedItem);
|
|
7078
|
+
return targetItems;
|
|
7079
|
+
});
|
|
7080
|
+
} catch (targetErr) {
|
|
7081
|
+
return jsonReply(res, 500, {
|
|
7082
|
+
error: `project move failed while writing to target store; item left unchanged in its original project: ${targetErr.message}`,
|
|
7083
|
+
});
|
|
7084
|
+
}
|
|
7085
|
+
|
|
7086
|
+
try {
|
|
7087
|
+
// Phase 2: the target write is confirmed durable — now it's safe
|
|
7088
|
+
// to remove the item from the source store.
|
|
7089
|
+
mutateJsonFileLocked(wiPath, (items) => {
|
|
7090
|
+
if (!Array.isArray(items)) items = [];
|
|
7091
|
+
const spliceIdx = items.findIndex(i => i.id === id);
|
|
7092
|
+
if (spliceIdx !== -1) items.splice(spliceIdx, 1);
|
|
7093
|
+
return items;
|
|
7094
|
+
});
|
|
7095
|
+
} catch (spliceErr) {
|
|
7096
|
+
// Removing from source failed AFTER the target write already
|
|
7097
|
+
// committed. Compensate by removing the item from the target
|
|
7098
|
+
// store again, so the item ends up in exactly one store (its
|
|
7099
|
+
// original) instead of duplicated across two.
|
|
7100
|
+
try {
|
|
7101
|
+
mutateJsonFileLocked(projectMove.wiPath, (targetItems) => {
|
|
7102
|
+
if (!Array.isArray(targetItems)) targetItems = [];
|
|
7103
|
+
const ti = targetItems.findIndex(i => i.id === id);
|
|
7104
|
+
if (ti !== -1) targetItems.splice(ti, 1);
|
|
7105
|
+
return targetItems;
|
|
7106
|
+
});
|
|
7107
|
+
} catch { /* best-effort rollback; surfaced via the 500 below regardless */ }
|
|
7108
|
+
return jsonReply(res, 500, {
|
|
7109
|
+
error: `project move failed while removing from source store; move was rolled back: ${spliceErr.message}`,
|
|
7110
|
+
});
|
|
7111
|
+
}
|
|
7060
7112
|
}
|
|
7061
7113
|
|
|
7062
7114
|
// Clear stale pending dispatch entries outside lock
|
package/docs/README.md
CHANGED
|
@@ -30,6 +30,7 @@ Architecture, design proposals, and lifecycle references for people working on t
|
|
|
30
30
|
- [harness-mode.md](harness-mode.md) — Tri-Agent Harness Mode (`harness_mode: "tri_agent"` on scheduled tasks): Planner → Generator → Evaluator loop that iterates a shared on-disk artifact until a rubric passes or the iteration cap fires.
|
|
31
31
|
- [harness-propagation.md](harness-propagation.md) — How user-level and project-local harness assets (skills, slash-commands, MCP config, `CLAUDE.md` / `AGENTS.md`) propagate into an agent's worktree via `--add-dir`, the `harnessPropagateProjectLocal` flag, and the project-local-on-main worktree-visibility footgun.
|
|
32
32
|
- [harness-transparency.md](harness-transparency.md) — The `harnessUsed` self-report contract: capture (agent reports the skills / MCPs / commands / docs it used) → ground (engine cross-checks against `_harnessPropagated` and annotates `grounded:true\|false`, never dropping) → surface (PR comment, final agent note, work-item modal).
|
|
33
|
+
- [kb-dedup-duplicate-pair-investigation.md](kb-dedup-duplicate-pair-investigation.md) — Investigation-only pass diffing suspected duplicate KB filename groups (content-hash dedup for agent-authored reviews/build-reports) to confirm true full-body duplication before any fix is proposed.
|
|
33
34
|
- [kb-sweep.md](kb-sweep.md) — Knowledge-base consolidation sweep (hash dedup → LLM batch dedup/reclassify → per-entry compress) and the detached runner that keeps it alive across `minions restart`.
|
|
34
35
|
- [keep-processes.md](keep-processes.md) — `meta.keep_processes` sidecar contract: when to use it vs managed-spawn, sidecar schema, caps, and the [`engine/keep-process-sweep.js`](../engine/keep-process-sweep.js) lifecycle.
|
|
35
36
|
- [live-checkout-mode.md](live-checkout-mode.md) — Per-project opt-in `checkoutMode: 'live'`: skips `git worktree add` and dispatches in-place inside `project.localPath` for `repo`-managed trees, submodule-heavy repos, deep Windows paths, and native build state. Includes the refuse-on-dirty contract and the per-project mutating-concurrency cap of 1.
|
|
@@ -46,6 +47,7 @@ Architecture, design proposals, and lifecycle references for people working on t
|
|
|
46
47
|
- [rfc-completion-json.md](rfc-completion-json.md) — RFC for replacing stdout regex-scraping with a structured `completion.json` control-plane protocol.
|
|
47
48
|
- [runtime-adapters.md](runtime-adapters.md) — Runtime adapter contract (`engine/runtimes/*`): how the engine talks to Claude Code, Copilot CLI, and future CLIs through a single capability-flagged interface.
|
|
48
49
|
- [self-improvement.md](self-improvement.md) — The six self-improvement mechanisms (learnings inbox, per-agent history, review feedback, quality metrics, etc.) that form Minions' continuous feedback loop.
|
|
50
|
+
- [shared-lifecycle-module-map.md](shared-lifecycle-module-map.md) — Documentation-only module-boundary audit of `engine/shared.js` / `engine/lifecycle.js`: function inventory, cross-file call graph, and a proposed split map (no code moved).
|
|
49
51
|
- [skills.md](skills.md) — Skill block format: how agents emit reusable `\`\`\`skill` blocks and how the engine extracts them into native personal-skill directories.
|
|
50
52
|
- [slim-ux/concepts.md](slim-ux/concepts.md) — Slim-UX design notes: simplified surface concepts driving the project picker, inline project link, and decoupled folder picker.
|
|
51
53
|
- [slim-ux/architecture-suggestions.md](slim-ux/architecture-suggestions.md) — Slim-UX follow-up architecture suggestions paired with `concepts.md`.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# KB Duplicate-Entry Investigation (P-mrb8yg9x001jc364)
|
|
2
|
+
|
|
3
|
+
Investigation-only pass (per architecture-meeting scoping) diffing suspected
|
|
4
|
+
duplicate KB filename groups to confirm true full-body duplication vs.
|
|
5
|
+
distinct events sharing a naming pattern, before any fix is proposed.
|
|
6
|
+
|
|
7
|
+
## Background
|
|
8
|
+
|
|
9
|
+
`engine/consolidation.js:1156-1290` content-hashes and dedups
|
|
10
|
+
engine-authored SYSTEM ALERTS (via `shared.isEngineSystemAlert` +
|
|
11
|
+
`alertHash` frontmatter, see `_alertHashExists`) at **write time** —
|
|
12
|
+
recurring identical alerts never get a `-2`/`-3` full-body copy. Agent-authored
|
|
13
|
+
review/build-report notes have no equivalent write-time guard: they fall
|
|
14
|
+
through the plain `shared.uniquePath(...)` branch (`consolidation.js:1267`),
|
|
15
|
+
so any note that gets classified into `knowledge/<category>/` twice under the
|
|
16
|
+
same computed filename (`${date}-${agent}-${titleSlug}.md`) always produces a
|
|
17
|
+
full-body `-2`/`-3` copy at consolidation time.
|
|
18
|
+
|
|
19
|
+
Separately, `engine/kb-sweep.js` Pass 1 (`_hashDedup`, `kb-sweep.js:34-37,
|
|
20
|
+
155-176`) content-hashes **every** KB entry regardless of category and
|
|
21
|
+
archives byte-identical duplicates to `knowledge/_swept/`, keeping the most
|
|
22
|
+
recent. This pass is category-agnostic and *would* catch true agent-authored
|
|
23
|
+
duplicates too — but it only runs on a schedule (`AUTO_SWEEP_INTERVAL_MS = 4h`,
|
|
24
|
+
`kb-sweep.js:26`, or on-demand via the dashboard), not at consolidation time.
|
|
25
|
+
|
|
26
|
+
## Method
|
|
27
|
+
|
|
28
|
+
Diffed 3 suspected duplicate-filename groups directly on disk (byte compare
|
|
29
|
+
via `fc.exe /b`, line diff via `Compare-Object`, frontmatter inspection,
|
|
30
|
+
file timestamps, and cross-reference against `engine/kb-sweep-state.json`'s
|
|
31
|
+
last-completed-sweep timestamp).
|
|
32
|
+
|
|
33
|
+
## Findings
|
|
34
|
+
|
|
35
|
+
### Pair 1 — `knowledge/reviews/2026-07-07-ripley-review-…-740-fix-contam{,-2,-3}.md` (TRUE DUPLICATE, -2/-3 only)
|
|
36
|
+
|
|
37
|
+
- `-2.md` and `-3.md` are **byte-identical** (`fc.exe /b` → "no differences").
|
|
38
|
+
Both carry `source: ripley-ripley-review-mrb701ph0008506d-2026-07-07-2200.md`
|
|
39
|
+
in their `classifyToKnowledgeBase`-added frontmatter and wrap the same
|
|
40
|
+
embedded note (`id: NOTE-mrb701pz0009ed9c`).
|
|
41
|
+
- Created at `15:06:22` and `15:09:14` (same day, ~3 min apart) — i.e. the
|
|
42
|
+
*same* inbox note (`ripley-ripley-review-mrb701ph0008506d-2026-07-07-2200.md`)
|
|
43
|
+
was independently classified into KB twice by `classifyToKnowledgeBase`,
|
|
44
|
+
producing a `uniquePath` collision on the computed filename and hence a
|
|
45
|
+
full-body `-3` copy of `-2`.
|
|
46
|
+
- The base file (no suffix, `15:05:43`) is a **different, earlier, distinct
|
|
47
|
+
write** — different frontmatter shape entirely (`id/agent/date/pr/repo/
|
|
48
|
+
branch/verdict`, no `source:`/`category:`/`reusable:` fields that
|
|
49
|
+
`classifyToKnowledgeBase` always adds) and different body formatting
|
|
50
|
+
(`## Verdict: APPROVE` vs. `**Verdict:** APPROVE`, different bullet
|
|
51
|
+
structure). It is **not** part of the `-2`/`-3` duplicate chain — it's a
|
|
52
|
+
separate KB write that happens to share a title-derived filename stem.
|
|
53
|
+
- **Verdict: -2 and -3 are a true, confirmed full-body duplicate pair. The
|
|
54
|
+
base file is a distinct entry, not a duplicate.**
|
|
55
|
+
|
|
56
|
+
### Pair 2 — `knowledge/build-reports/2026-07-07-dallas-validate-fix-contaminated-originalref-in-live-chec{,-2,-3}.md` (TRUE DUPLICATE, -2/-3 only)
|
|
57
|
+
|
|
58
|
+
- Same pattern as Pair 1: `-2.md` and `-3.md` are byte-identical
|
|
59
|
+
(`fc.exe /b` → "no differences"), both stamped
|
|
60
|
+
`source: dallas-W-mrb6tfdn000oebc8-2026-07-07-2200.md` and wrapping the
|
|
61
|
+
same embedded note (`id: NOTE-mrb707jk000b1903`), created `15:17:51` and
|
|
62
|
+
`15:19:02` (~70s apart).
|
|
63
|
+
- The base file (`15:16:29`, 4451 bytes vs. 5373 bytes for -2/-3) again has
|
|
64
|
+
the plain original-note frontmatter (`id/agent/date/task`, no
|
|
65
|
+
`source:`/`category:`/`reusable:`) and is shorter/differently-worded —
|
|
66
|
+
a distinct earlier write, not part of the duplicate chain.
|
|
67
|
+
- **Verdict: -2 and -3 are a true, confirmed full-body duplicate pair. The
|
|
68
|
+
base file is a distinct entry, not a duplicate.**
|
|
69
|
+
|
|
70
|
+
### Pair 3 — `knowledge/project-notes/2026-07-07-engine-engine-skipped-worktree-wipe-live-dispatch-guard-f{,-2…-38}.md` (NOT DUPLICATES — confirmed distinct)
|
|
71
|
+
|
|
72
|
+
- ~40 files share the `…-live-dispatch-guard-f[-N]` filename stem. Sampled
|
|
73
|
+
`-f-27.md`: frontmatter carries `alertHash: 9bc818d6…` and
|
|
74
|
+
`source: engine-worktree-skip-live-W-mrb6tlze000pc39d-2026-07-07.md` — a
|
|
75
|
+
**distinct** work-item ID (`W-mrb6tlze000pc39d`) from other members of the
|
|
76
|
+
group (confirmed against `notes.md`/`knowledge/project-notes` entries
|
|
77
|
+
referencing different `W-…` IDs, e.g. `W-mrb70qnx000o59d5`,
|
|
78
|
+
`W-mrb7gwha001073e6`).
|
|
79
|
+
- These are engine-authored SYSTEM ALERTS (`shared.isEngineSystemAlert`
|
|
80
|
+
matches), so each is content-hashed and given a distinct `alertHash`
|
|
81
|
+
frontmatter value per distinct incident. `_alertHashExists`
|
|
82
|
+
(`consolidation.js:1188-1200`) already prevents a same-hash alert from
|
|
83
|
+
producing a duplicate — the large `-2…-38` filename fan-out here reflects
|
|
84
|
+
~40 **genuinely distinct** live-dispatch-guard incidents (one per
|
|
85
|
+
worktree/dispatch), not 40 copies of one incident. `uniquePath` is doing
|
|
86
|
+
exactly what it should: disambiguating distinct events that happen to
|
|
87
|
+
share a human-readable title.
|
|
88
|
+
- **Verdict: confirmed NOT duplicates. The existing engine-alert dedup path
|
|
89
|
+
(W-mr3pi9de) is working correctly here.**
|
|
90
|
+
|
|
91
|
+
## Root cause (confirmed, not yet fixed — out of scope for this pass)
|
|
92
|
+
|
|
93
|
+
1. `classifyToKnowledgeBase` (`consolidation.js:1231-1298`) has **no
|
|
94
|
+
write-time content-hash guard for agent-authored notes** — only
|
|
95
|
+
`isEngineSystemAlert` entries get one. When the same inbox note is
|
|
96
|
+
classified into KB more than once (e.g. a PR reviewed by two
|
|
97
|
+
independent dispatches within the same day, each producing its own
|
|
98
|
+
inbox note that hashes/formats similarly — or, per the `-2`/`-3`
|
|
99
|
+
evidence above, the identical inbox note re-processed), each pass
|
|
100
|
+
writes a fresh, full-body `-N` copy via `shared.uniquePath`.
|
|
101
|
+
2. `engine/kb-sweep.js` Pass 1 (`_hashDedup`) *does* eventually catch and
|
|
102
|
+
archive these byte-identical copies (category-agnostic, keeps most
|
|
103
|
+
recent) — but only on its own schedule (`AUTO_SWEEP_INTERVAL_MS = 4h`
|
|
104
|
+
default, `kb-sweep.js:26`) or on manual trigger, not at consolidation
|
|
105
|
+
time.
|
|
106
|
+
3. Cross-checked against `engine/kb-sweep-state.json`: the last completed
|
|
107
|
+
sweep finished `2026-07-07T21:49:00Z` (`14:49` local); both confirmed
|
|
108
|
+
duplicate pairs above were created *after* that sweep completed
|
|
109
|
+
(`15:05`-`15:19` local), so neither had yet had a chance to be caught by
|
|
110
|
+
the next scheduled run at the time of this investigation. This is
|
|
111
|
+
consistent with the sweep design, not a sweep bug — but it does mean a
|
|
112
|
+
duplicate pair can sit in `knowledge/<category>/` (and get injected into
|
|
113
|
+
every agent prompt that primes from `knowledge/`) for up to ~4h before
|
|
114
|
+
Pass 1 removes it.
|
|
115
|
+
|
|
116
|
+
## Recommendation (not implemented — meeting scoped this as investigation-only)
|
|
117
|
+
|
|
118
|
+
The `-2`/`-3` case is real and reproducible, but bounded by the existing
|
|
119
|
+
sweep's 4h cadence — a same-day double-classification costs extra disk +
|
|
120
|
+
prompt-priming tokens for at most one sweep interval, not indefinitely. A
|
|
121
|
+
future fix should extend the existing `alertHash`-style write-time
|
|
122
|
+
content-hash dedup in `classifyToKnowledgeBase` to non-system-alert items
|
|
123
|
+
too (skip the `uniquePath` full-body write when a byte-identical KB entry
|
|
124
|
+
already exists in the target category), rather than relying solely on the
|
|
125
|
+
delayed sweep pass. Distinct-event fan-out (Pair 3 pattern) must be
|
|
126
|
+
preserved — any fix must key on content hash, not filename/title
|
|
127
|
+
similarity, since many genuinely distinct events share a title stem.
|
|
128
|
+
|
|
129
|
+
## Sources
|
|
130
|
+
|
|
131
|
+
- `engine/consolidation.js:1156-1300` (`isEngineSystemAlert`/`alertHash`
|
|
132
|
+
dedup, `classifyToKnowledgeBase`)
|
|
133
|
+
- `engine/kb-sweep.js:20-37,155-176` (`_hashEntry`, `_hashDedup`, sweep
|
|
134
|
+
interval constant)
|
|
135
|
+
- `engine/kb-sweep-state.json` (`completedAtIso: 2026-07-07T21:49:00.186Z`)
|
|
136
|
+
- `knowledge/reviews/2026-07-07-ripley-review-github-opg-microsoft-minions-740-fix-contam{,-2,-3}.md`
|
|
137
|
+
- `knowledge/build-reports/2026-07-07-dallas-validate-fix-contaminated-originalref-in-live-chec{,-2,-3}.md`
|
|
138
|
+
- `knowledge/project-notes/2026-07-07-engine-engine-skipped-worktree-wipe-live-dispatch-guard-f{,-2…-38}.md`
|
package/docs/kb-sweep.md
CHANGED
|
@@ -58,7 +58,11 @@ Because originals are archived to `_swept/` (same 30-day retention as every othe
|
|
|
58
58
|
|
|
59
59
|
### Pass 2 — LLM Batch Sweep
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
Survivors are split into **changed** and **unchanged** entries before anything is sent to the LLM (source: [`engine/kb-sweep.js`](../engine/kb-sweep.js) `_classifyLlmScanFreshness`). An entry is `unchanged` only if it carries the `_llmScannedAt` frontmatter marker (`LLM_SCAN_FLAG_KEY`) AND its `mtime` hasn't advanced past that timestamp — the same mtime-gated pattern Pass 3 uses for `_swept`. Everything else (never scanned, or edited/reclassified since its last scan) is `changed`.
|
|
62
|
+
|
|
63
|
+
Only the `changed` entries are batched to Claude Haiku in groups of `LLM_BATCH_SIZE = 30` (source: [`engine/kb-sweep.js:29`](../engine/kb-sweep.js#L29)) with **full content**. `unchanged` entries are still appended to every prompt as a lightweight roster (title/date/category only, no content) so cross-entry duplicate detection against the whole KB stays correct — a changed entry can still be flagged as a duplicate of an older, already-scanned one — without re-spending tokens re-analyzing entries nothing has touched. If there are zero `changed` entries, the pass makes no LLM calls at all. This fixed an unbounded-cost bug where every 4h sweep cycle re-sent the *entire* KB manifest regardless of what had changed since the prior cycle (source: PR #738).
|
|
64
|
+
|
|
65
|
+
Each batch prompt asks for three lists in JSON:
|
|
62
66
|
|
|
63
67
|
| Field | What the LLM looks for |
|
|
64
68
|
|-------|------------------------|
|
|
@@ -68,10 +72,14 @@ The remaining survivors are sent to Claude Haiku in batches of `LLM_BATCH_SIZE =
|
|
|
68
72
|
|
|
69
73
|
Each action archives the file via the same `_archiveKbFile()` helper used by Pass 1; reclassification rewrites the `category:` frontmatter line and moves the file into the new category directory (source: [`engine/kb-sweep.js:323-340`](../engine/kb-sweep.js#L323)).
|
|
70
74
|
|
|
75
|
+
**changedIdx guard.** All three actions are only actioned against indices the LLM actually content-analyzed this batch (i.e. members of `changedIdx`). `remove` and `reclassify` require their own referenced `index` to be in `changedIdx`; a `duplicates` pair is actioned if the pair references at least one index in `changedIdx` (a pair where every referenced index is roster-only, `unchangedIdx`, is skipped). Roster-only entries are sent to the LLM with title/date/category only (no content — see the "existing entries" roster section), so an index outside `changedIdx` cannot have been genuinely compared/analyzed this batch; acting on it would mean acting on a hallucinated or stale LLM reference rather than a real comparison (source: [`engine/kb-sweep.js`](../engine/kb-sweep.js) `_applyLlmPlan`, PR #739, W-mrc9y5y000063033).
|
|
76
|
+
|
|
71
77
|
Reclassification targets are validated against `shared.KB_CATEGORIES` (`architecture`, `conventions`, `project-notes`, `build-reports`, `reviews` — source: [`engine/shared.js`](../engine/shared.js) `KB_CATEGORIES`); unknown categories are silently dropped.
|
|
72
78
|
|
|
73
79
|
If a batch returns invalid JSON or the runtime is unavailable, that batch is skipped with a warning and the rest of the sweep continues (source: [`engine/kb-sweep.js:203-215`](../engine/kb-sweep.js#L203)).
|
|
74
80
|
|
|
81
|
+
`_llmScannedAt` is stamped on every analyzed entry only at the very end of the whole sweep — after Pass 3's rewrite — to avoid a same-run mtime race with the rewrite pass's own content edit (source: [`engine/kb-sweep.js`](../engine/kb-sweep.js), stamping step called from `runKbSweep`).
|
|
82
|
+
|
|
75
83
|
### Pass 2.5 — Age-Based TTL Expiry (secondary safety net)
|
|
76
84
|
|
|
77
85
|
Cheap, deterministic. No LLM call. With Pass 1.5 now collapsing the bulk of the boilerplate structurally, this pass is a **secondary safety net**: it archives genuinely stale one-off notes that never clustered into a large enough group to be consolidated (e.g. a lone failure class that only occurred twice in a week). The dedup and rewrite passes can only reclaim *duplicate* or *empty* entries, so an age cutoff is still the backstop for long-tail one-offs.
|
|
@@ -103,6 +111,10 @@ Survivors of Passes 1, 1.5, and 2 that are still on disk get rewritten one entry
|
|
|
103
111
|
|
|
104
112
|
After a successful rewrite, the entry's frontmatter gains a `_swept: <ISO timestamp>` key (source: [`engine/kb-sweep.js:293`](../engine/kb-sweep.js#L293)).
|
|
105
113
|
|
|
114
|
+
## The `_llmScannedAt` Frontmatter Flag
|
|
115
|
+
|
|
116
|
+
Mirrors `_swept` but gates Pass 2 instead of Pass 3: an entry with `_llmScannedAt` set and an mtime that hasn't advanced past it is treated as `unchanged` and skipped from full-content LLM analysis on the next sweep (roster-only context still applies — see Pass 2 above). Editing an entry (or a category reclassify) bumps its mtime past the stamp, so the next sweep re-analyzes it. The key is exported as `LLM_SCAN_FLAG_KEY` (source: [`engine/kb-sweep.js:32`](../engine/kb-sweep.js#L32)).
|
|
117
|
+
|
|
106
118
|
## The `_swept` Frontmatter Flag
|
|
107
119
|
|
|
108
120
|
`_swept` makes Pass 3 idempotent across runs. Semantics:
|
|
@@ -64,11 +64,13 @@ By default the dirty tree refusal (Guarantee 2) is terminal — the engine never
|
|
|
64
64
|
3. If ON: `git fetch origin` + `git reset --hard origin/<branch>`, then **re-run the porcelain preflight once**. If the tree is now clean, dispatch proceeds normally. If the reset failed or the tree is *still* dirty, it falls back to the safe `{ ok:false, reason:'dirty' }` refusal — the engine never dispatches onto an unexpected tree.
|
|
65
65
|
4. On a successful reset it writes a single `live-checkout-autoreset-<wiId>` inbox note listing **exactly which paths were discarded**, so the operator can recover them from `git reflog` / `git fsck --lost-found`.
|
|
66
66
|
|
|
67
|
-
This is **DESTRUCTIVE** — it permanently discards the operator's uncommitted changes in the live checkout. As of W-mqzbbhn2 it
|
|
67
|
+
This is **DESTRUCTIVE** — it permanently discards the operator's uncommitted changes in the live checkout. As of W-mqzbbhn2 it was **ON by default**; as of **W-mrawgw4q000a6bff it is OFF by default again** (`ENGINE_DEFAULTS.liveCheckoutAutoReset: false`) — the destructive-by-default behavior caused unwanted data loss when an operator had legitimate local drift they expected the engine to leave alone. Set `liveCheckoutAutoReset: true` (per-project or fleet-wide) to opt back in to reset-to-remote recovery.
|
|
68
68
|
|
|
69
69
|
- **Fleet-wide:** Dashboard → Settings → `Live-checkout auto-reset (fleet-wide)` (`engine.liveCheckoutAutoReset`).
|
|
70
70
|
- **Per-project override:** set `liveCheckoutAutoReset: true|false` on the project object in `config.json` (see the config snippet under *Enabling live mode*). A per-project boolean overrides the fleet-wide default for that project. *(Per-project Settings-UI persistence is deferred — the per-project override is config.json-only for now.)*
|
|
71
71
|
|
|
72
|
+
**Precedence when both `liveCheckoutAutoStash` and `liveCheckoutAutoReset` are enabled (W-mrawgw4q000a6bff).** `engine.js spawnAgent` now tries the **non-destructive** auto-stash first: it resolves `liveCheckoutAutoStash` up front and, if enabled, calls the initial `prepareLiveCheckout` with `autoReset` forced `false` so the destructive reset never runs before stash gets a chance. `applyLiveCheckoutAutoStash` then attempts the stash (see [2b auto-stash](#2b-opt-in-auto-stash-on-dirty-w-mqtvnnj1000357fa) above) and its own internal re-preflight also forces `autoReset: false`. Only if the stash itself fails or was skipped (`outcome:'unchanged'`) **and** the tree is still confirmed dirty **and** `resolveLiveCheckoutAutoReset` resolves `true` does `spawnAgent` invoke `prepareLiveCheckout` a second time with `autoReset: true` as an explicit, last-resort destructive fallback. **Behavior change:** previously (reset-before-stash, reset ON by default) the common both-enabled case force-reset the branch to origin on every dirty dispatch; now the branch is only synced to origin when stash fails — the common case just parks the dirty changes non-destructively and leaves the branch where it was. There is deliberately **no automatic `git merge --ff-only origin/<branch>`** after a successful stash — auto-stash's contract is "park changes so dispatch proceeds," not "sync to origin"; that stronger guarantee remains `liveCheckoutAutoReset`'s job, available as an explicit opt-in fallback.
|
|
73
|
+
|
|
72
74
|
#### 2c. Stale `.git/index.lock` self-heal (W-mr3tayu4)
|
|
73
75
|
|
|
74
76
|
Before any preflight git command runs, `prepareLiveCheckout` calls the shared, age-gated `shared.removeStaleIndexLock(localPath)` to clear a leftover `.git/index.lock` from a crashed/killed git process. Live mode never resets/cleans the operator tree, so nothing else would clear it, and — since `LIVE_CHECKOUT_FAILED` is retryable — every automatic retry used to hit the same stale lock and fail identically forever until an operator deleted it by hand. The remover only touches locks older than 5 minutes, so a lock held by a currently-running git process is never removed out from under it. `engine.js`'s own worktree-mode lock cleanup now delegates to the same shared helper.
|
|
@@ -185,6 +187,7 @@ Live-mode agents run **in-place** in the operator's checkout, so when a dispatch
|
|
|
185
187
|
- **Retry-with-backoff on the WIP auto-save commit (#608).** The `git add -A` + `git commit` above (and the equivalent self-heal commit described below) go through `_commitAgentWipWithRetry`: a bounded retry (3 attempts, linear backoff) that fires only when the commit fails with an `index.lock` / "another git process" message — i.e. a transient collision with a concurrent git invocation — never on a genuine failure like "nothing to commit". Previously a single failed attempt (e.g. a `.git/index.lock` race) silently aborted the auto-save, leaving the tree dirty on the agent branch and letting the pollution below take hold.
|
|
186
188
|
- **Orphaned-agent-branch self-heal at dispatch start (#608).** If dispatch-end restore is ever skipped or interrupted (crash, forced kill, an engine restart racing the restore), the *next* dispatch for that project can start with HEAD already sitting on a stray `work/<wi-id>` branch from the previous run — and, before this fix, `prepareLiveCheckout`'s dirty-tree check ran *before* `originalRef` was even captured, so the function returned `{ reason: 'dirty' }` immediately and the branch was never noticed or cleaned, silently repeating for every subsequent dispatch. `prepareLiveCheckout` now recognizes this case: if the tree is still dirty after the existing auto-clean-artifacts recheck, and the current branch (read from the already-fetched `git status --porcelain -b` header, no extra git call) matches the `work/<id>` naming convention (`_looksLikeAgentBranch`) and differs from this dispatch's own target branch, the WIP is committed onto that stray branch (again via `_commitAgentWipWithRetry`) and HEAD is switched back to `mainRef` before the normal flow continues. This only ever touches a branch matching the engine's own naming convention — an operator's own feature branch (e.g. `feature/x`) is never mistaken for engine litter and is left untouched.
|
|
187
189
|
- **AUTO-RESTORE (best-effort, never `--force`).** At dispatch-end `restoreLiveCheckoutAtDispatchEnd` issues a **plain** `git checkout <originalRef>` — no `--force`, no `-B`, no reset, no clean, no stash. It no-ops when there is nothing to restore: no captured `originalRef`, the agent branch *is* the original ref, or HEAD already sits on the original ref (matched against the branch name *or* the raw sha so the detached-HEAD case is recognized). It is strictly best-effort: every error is swallowed and logged, and a restore never alters the dispatch result.
|
|
190
|
+
- **Read-site trust invariant (P-mrb8yg9x001hfa9f audit).** `restoreLiveCheckoutAtDispatchEnd` never re-derives or re-validates `originalRef` itself — it does not call `_looksLikeAgentBranch` or otherwise probe for contamination; it simply checks out whatever ref it is handed. This is safe *only* because `prepareLiveCheckout`'s own originalRef-contamination invariant (W-mrb6an8300058fd8, §"Original-ref capture" above) already corrects `originalRef` to `mainRef` at capture time whenever HEAD was on a stray engine-managed branch, for both the existing-branch (4b) and new-branch/STALE-BASE-GUARD (4d) code paths — and `engine.js` persists that corrected value verbatim (`engine.js:3106-3122`) for every restore call site (`onAgentClose`, the `engine/cli.js` restart re-attach path, and the `engine/timeout.js` reaping paths) to consume as-is. If the write-side correction in `prepareLiveCheckout` ever regressed, restore would silently check the operator back out onto the contaminated branch, since it has no independent check of its own to catch that. Characterized end-to-end in `test/unit/prepare-live-checkout.test.js` (chains `prepareLiveCheckout` → `restoreLiveCheckoutAtDispatchEnd` for both the 4b and 4d paths).
|
|
188
191
|
- **Fallback notify (only when a safe switch is impossible).** If git declines the plain checkout — most likely because the agent left uncommitted changes a checkout would overwrite — the refusal is **honored**: the tree is left exactly as the agent left it and a deduped `live-checkout-branch-<dispatchId>` inbox alert tells the operator how to switch back manually (`git -C <localPath> checkout <originalRef>`). The engine never forces the switch. An **unexpected** restore error (git missing, repo corruption, a GVFS blob fetch on the switch-back) now also writes this alert, so a non-refusal failure never silently strands the tree.
|
|
189
192
|
- **Terminal-failure alert.** When the dispatch ends in a non-success terminal state, a deduped `live-checkout-failed-<dispatchId>` inbox alert is written so the operator knows a live-mode run failed inside their own checkout (where any partial work is visible). This is independent of the restore and fires even when the restore itself succeeds.
|
|
190
193
|
|
|
@@ -294,7 +297,7 @@ The engine has no opinion about local branches; this hygiene is the operator's r
|
|
|
294
297
|
Live-checkout mode is deliberately small. These are NOT supported and will not be added:
|
|
295
298
|
|
|
296
299
|
- **No `auto` mode.** The choice between `worktree` and `live` is per-project and operator-set. The engine will not auto-detect submodules / `repo` workspaces and silently switch modes.
|
|
297
|
-
- **No auto-stash on dirty refusal _by default_.** Out of the box the engine refuses and exits; it never `git stash`es to "make room" for a dispatch, because that silently mutates the operator's tree and conflates engine state with operator state. The operator can **opt in** per-project (`project.liveCheckoutAutoStash`) or fleet-wide (`engine.liveCheckoutAutoStash`) to auto-stash instead of refusing — see [2b](#2b-opt-in-auto-stash-on-dirty-w-mqtvnnj1000357fa). Even then the engine never **pops** the stash on the operator's behalf. (The `liveCheckoutAutoReset` escape hatch —
|
|
300
|
+
- **No auto-stash on dirty refusal _by default_.** Out of the box the engine refuses and exits; it never `git stash`es to "make room" for a dispatch, because that silently mutates the operator's tree and conflates engine state with operator state. The operator can **opt in** per-project (`project.liveCheckoutAutoStash`) or fleet-wide (`engine.liveCheckoutAutoStash`) to auto-stash instead of refusing — see [2b](#2b-opt-in-auto-stash-on-dirty-w-mqtvnnj1000357fa). Even then the engine never **pops** the stash on the operator's behalf. (The `liveCheckoutAutoReset` escape hatch — **OFF by default** as of W-mrawgw4q000a6bff, and even when enabled now only runs as a last-resort fallback *after* auto-stash fails or is disabled — **discards** rather than stashes; set it to `true` per-project / fleet-wide to opt in. See [§2b](#2b-opt-in-auto-reset-on-dirty-livecheckoutautoreset-w-mqvejug6000eeb20).)
|
|
298
301
|
- **No concurrent dispatches per project.** The cap is 1; raising it would require per-WI subdirectories, which live mode explicitly does not provide.
|
|
299
302
|
- **No per-WI subdirectory isolation.** Live mode is one-checkout-per-project by design. If you need isolation, use `checkoutMode: 'worktree'` (the default).
|
|
300
303
|
- **No per-WI override.** `checkoutMode` is per-project only. There is no `meta.checkoutMode` on a work item that overrides the project setting.
|
|
@@ -306,7 +309,7 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
|
|
|
306
309
|
| File | Purpose |
|
|
307
310
|
|---|---|
|
|
308
311
|
| `engine/shared.js` — `CHECKOUT_MODES`, `validateCheckoutMode`, `resolveCheckoutMode`, `isLiveCheckoutProject` | Enum + validator + back-compat resolver (P-a3f9b201; consolidated W-mqiaw974). |
|
|
309
|
-
| `engine/shared.js` — `resolveLiveCheckoutAutoReset` + `ENGINE_DEFAULTS.liveCheckoutAutoReset` | Pure precedence resolver (per-project boolean > fleet-wide engine default > false) + the fleet-wide default (ON as of W-mqzbbhn2). Gates the dirty-tree auto-reset in `prepareLiveCheckout` (W-mqvejug6000eeb20). |
|
|
312
|
+
| `engine/shared.js` — `resolveLiveCheckoutAutoReset` + `ENGINE_DEFAULTS.liveCheckoutAutoReset` | Pure precedence resolver (per-project boolean > fleet-wide engine default > false) + the fleet-wide default (OFF as of W-mrawgw4q000a6bff — was ON as of W-mqzbbhn2). Gates the dirty-tree auto-reset in `prepareLiveCheckout` (W-mqvejug6000eeb20); in `engine.js spawnAgent` only fires as a fallback after auto-stash fails/is disabled (W-mrawgw4q000a6bff). |
|
|
310
313
|
| `engine/shared.js` — `resolveSpawnPaths` | Returns `{ cwd: localPath, worktreeRootDir: null, liveMode: true }` for live projects (P-a3f9b202). |
|
|
311
314
|
| `engine/live-checkout.js` — `prepareLiveCheckout` | Pure helper: dirty check, mid-operation / detached-HEAD preflight (incl. `BISECT_LOG`; throw-on-git-dir-failure; exit-1-only detached), original-ref capture, **already-on-branch fast path**, `refs/heads/<branch>` existence check, branch resolution from HEAD (no fetch — issue #226), **no-half-switch + `blob-fetch`/`worktree-conflict` classification** for partial-clone hydration failures and cross-worktree branch conflicts, **opt-in dirty auto-reset** (`git fetch origin` + `reset --hard origin/<branch>` + re-check + `live-checkout-autoreset-<wiId>` note when `liveCheckoutAutoReset` is on), **50 MB git maxBuffer** (P-a3f9b203; preflight + capture P-b2e8d4a6; hardening PL-live-checkout-reliability-hardening; auto-reset + maxBuffer W-mqvejug6000eeb20). |
|
|
312
315
|
| `engine/live-checkout.js` — `restoreLiveCheckoutAtDispatchEnd` | Dispatch-end auto-restore (plain `git checkout <originalRef>`, never `--force`/reset/clean/stash, best-effort) + **self-healing dirty recovery** (auto-commit agent WIP onto the agent branch) + `live-checkout-failed-<dispatchId>` terminal-failure alert + `live-checkout-branch-<dispatchId>` fallback notify (now also on unexpected restore errors) (P-d9e6b2c4; self-heal PL-live-checkout-reliability-hardening). |
|
|
@@ -324,7 +327,7 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
|
|
|
324
327
|
| `engine/create-pr-worktree.js` — `prepareCreatePrWorktree` step-4 restore | `reset --hard HEAD` (not the index-leaking `checkout -- .`) + retried untracked removal + `liveTreeDirty` surfaced + `shared.removeWorktree` teardown (PL-live-checkout-reliability-hardening). |
|
|
325
328
|
| `engine/pr-action.js` — `buildCreatePrFollowups({ …, mainBranch })` live-mode message | CC "Create PR" chip instruction. In live mode the PR is ALWAYS built on a fresh branch off `origin/<mainBranch>` (stash → fetch → `checkout -B` → pop → commit → push → PR → restore), never reusing the current branch as the base; stop-on-stash-pop-conflict. `dashboard.js` `POST /api/pr-action/offer-create-pr` resolves `mainBranch` via `shared.resolveMainBranch` and threads it in (W-mr3jbpru). |
|
|
326
329
|
| `dashboard/js/settings.js` — checkoutMode dropdown + chip + `set-liveCheckoutAutoReset` fleet toggle | Operator-facing UI; the fleet-wide auto-reset toggle persists to `engine.liveCheckoutAutoReset` (per-project UI deferred — config.json only) (P-a3f9b207; auto-reset toggle W-mqvejug6000eeb20). |
|
|
327
|
-
| `test/unit/{resolve-spawn-paths-live-mode,prepare-live-checkout,spawn-agent-live-mode-wiring,live-checkout-auto-stash}.test.js` | Wiring and contract tests (P-a3f9b208; auto-stash helpers W-mqtvnnj1000357fa). |
|
|
330
|
+
| `test/unit/{resolve-spawn-paths-live-mode,prepare-live-checkout,spawn-agent-live-mode-wiring,live-checkout-auto-stash,live-checkout-stash-before-reset}.test.js` | Wiring and contract tests (P-a3f9b208; auto-stash helpers W-mqtvnnj1000357fa; stash-before-reset ordering W-mrawgw4q000a6bff). |
|
|
328
331
|
| `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` | Non-retryable refusal class. |
|
|
329
332
|
| `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION` | Non-retryable refusal class for a mid-operation / detached-HEAD operator tree (in-progress merge/rebase/cherry-pick/revert/bisect or detached HEAD), distinct from the dirty-tree class. Emitted by `spawnAgent`'s mid-op / detached-HEAD refusal block (P-a7f3c1d9; wired P-c5a1f3b8). |
|
|
330
333
|
| `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_BLOB_FETCH` | Non-retryable refusal class for an existing-branch checkout that could not hydrate the tree on a blobless GVFS partial clone (auth-less cache fetch, headless) — deterministic, so excluded from mechanical retry (PL-live-checkout-reliability-hardening). |
|
package/engine/cli.js
CHANGED
|
@@ -1653,6 +1653,10 @@ const commands = {
|
|
|
1653
1653
|
return;
|
|
1654
1654
|
}
|
|
1655
1655
|
const targetProject = target?.project || (projects.length > 0 ? projects[0] : null);
|
|
1656
|
+
if (!targetProject) {
|
|
1657
|
+
console.log('No projects configured. Add a project before running work.');
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1656
1660
|
const wiPath = projectWorkItemsPath(targetProject);
|
|
1657
1661
|
const archivePath = wiPath.replace(/\.json$/, '-archive.json');
|
|
1658
1662
|
|
package/engine/kb-sweep.js
CHANGED
|
@@ -661,12 +661,18 @@ ${body}`;
|
|
|
661
661
|
function _applyLlmPlan(plan, manifest, changedIdx, opts = {}) {
|
|
662
662
|
let removed = 0, merged = 0, reclassified = 0;
|
|
663
663
|
// changedIdx is the set of manifest indices actually content-analyzed by
|
|
664
|
-
// the LLM this batch (see _llmBatchSweep's scannedIdx).
|
|
665
|
-
// referencing only unanalyzed
|
|
666
|
-
// mis-reference) must never be
|
|
667
|
-
//
|
|
664
|
+
// the LLM this batch (see _llmBatchSweep's scannedIdx). remove/reclassify/
|
|
665
|
+
// duplicates entries referencing only unanalyzed (roster-only) indices
|
|
666
|
+
// (LLM hallucination / stale index mis-reference) must never be acted on —
|
|
667
|
+
// remove/reclassify require their referenced index to be analyzed this
|
|
668
|
+
// batch, and a duplicate pair is only trustworthy if at least one side was
|
|
669
|
+
// actually analyzed this batch (W-mrb447th00062813, W-mrc9y5y000063033).
|
|
668
670
|
const changedSet = new Set(changedIdx || []);
|
|
669
671
|
for (const r of (plan.remove || [])) {
|
|
672
|
+
if (!changedSet.has(r.index)) {
|
|
673
|
+
log('warn', `[kb-sweep] skipping remove for index ${r.index} — not in analyzed (changedIdx) batch, likely a hallucinated/stale LLM reference`);
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
670
676
|
const entry = manifest[r.index];
|
|
671
677
|
if (!entry) continue;
|
|
672
678
|
if (opts.dryRun) { removed++; continue; }
|
|
@@ -686,6 +692,10 @@ function _applyLlmPlan(plan, manifest, changedIdx, opts = {}) {
|
|
|
686
692
|
}
|
|
687
693
|
}
|
|
688
694
|
for (const r of (plan.reclassify || [])) {
|
|
695
|
+
if (!changedSet.has(r.index)) {
|
|
696
|
+
log('warn', `[kb-sweep] skipping reclassify for index ${r.index} — not in analyzed (changedIdx) batch, likely a hallucinated/stale LLM reference`);
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
689
699
|
const entry = manifest[r.index];
|
|
690
700
|
if (!entry || !shared.KB_CATEGORIES.includes(r.to)) continue;
|
|
691
701
|
if (opts.dryRun) { reclassified++; continue; }
|
package/engine/lifecycle.js
CHANGED
|
@@ -8,7 +8,7 @@ const path = require('path');
|
|
|
8
8
|
const os = require('os');
|
|
9
9
|
const shared = require('./shared');
|
|
10
10
|
const { safeRead, safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, mutateJsonFileLocked, mutateWorkItems, execAsync, projectPrPath, getPrLinks,
|
|
11
|
-
log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PR_STATUS, REVIEW_STATUS, RETRY_DELAY_MS, DISPATCH_RESULT,
|
|
11
|
+
log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, REVIEW_STATUS, RETRY_DELAY_MS, DISPATCH_RESULT,
|
|
12
12
|
ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
|
|
13
13
|
const { resolveRuntime } = require('./runtimes');
|
|
14
14
|
const adoGitAuth = require('./ado-git-auth');
|
|
@@ -38,27 +38,6 @@ function checkPlanCompletion(meta, config) {
|
|
|
38
38
|
// Collect work items from ALL projects + central (PRD items can be in either)
|
|
39
39
|
const allWorkItems = queries.getWorkItems(config);
|
|
40
40
|
const planItems = allWorkItems.filter(w => w.sourcePlan === planFile && w.itemType !== 'pr' && w.itemType !== 'verify');
|
|
41
|
-
if (planItems.length === 0) return;
|
|
42
|
-
|
|
43
|
-
// W-mqk5ld3p: verify-creation must be a pure function of work-item state,
|
|
44
|
-
// independent of who/what set `status: completed`. We do NOT short-circuit on
|
|
45
|
-
// the raw `_completionNotified` boolean — a PRD can land on disk pre-completed
|
|
46
|
-
// with the flag already set (out-of-band write by the plan-to-prd / pipeline
|
|
47
|
-
// path), and the legacy top-of-function `if (_completionNotified) return` then
|
|
48
|
-
// permanently skipped the aggregate build/test verify gate. Instead the flag
|
|
49
|
-
// gates ONLY the one-shot completion summary (below); control always falls
|
|
50
|
-
// through to the verify-creation block, which re-checks for an existing verify
|
|
51
|
-
// WI under the file lock and is therefore safe to reach every scan. The one
|
|
52
|
-
// exception is the REOPEN sub-path (terminal verify → re-open): that is a
|
|
53
|
-
// plan-modification concern (the dashboard clears `_completionNotified` when it
|
|
54
|
-
// re-opens a plan), so it is gated on `!alreadyNotified` to avoid bouncing an
|
|
55
|
-
// already-done verify on every steady-state scan.
|
|
56
|
-
const alreadyNotified = plan.status === PLAN_STATUS.COMPLETED && !!plan._completionNotified;
|
|
57
|
-
|
|
58
|
-
// Hard completion gate: every PRD feature ID must have a corresponding work item in a terminal state.
|
|
59
|
-
const planFeatureIds = new Set((plan.missing_features || []).map(f => f.id).filter(Boolean));
|
|
60
|
-
const workItemById = {};
|
|
61
|
-
for (const w of planItems) { if (w.id) workItemById[w.id] = w; }
|
|
62
41
|
|
|
63
42
|
// Self-heal stale completion. checkPlanCompletion only ever ADVANCED a plan to
|
|
64
43
|
// `completed` — it never reverted. So a PRD that was completed and then gained
|
|
@@ -71,6 +50,8 @@ function checkPlanCompletion(meta, config) {
|
|
|
71
50
|
// materializer recreates the missing WI and the normal completion flow resumes.
|
|
72
51
|
// Idempotent: once reverted (status active, flag cleared) the guard no-ops, so
|
|
73
52
|
// it cannot flap on subsequent scans while the item is genuinely incomplete.
|
|
53
|
+
// Defined BEFORE the `planItems.length === 0` early return (below) so the
|
|
54
|
+
// zero-materialized-items dead end (W-mrboq1g900096f04) can call it too.
|
|
74
55
|
const reopenStaleCompletedPlan = (reason) => {
|
|
75
56
|
if (plan.status !== PLAN_STATUS.COMPLETED && !plan._completionNotified) return false;
|
|
76
57
|
log('warn', `Plan ${planFile}: re-opening stale completed PRD — ${reason}`);
|
|
@@ -83,6 +64,50 @@ function checkPlanCompletion(meta, config) {
|
|
|
83
64
|
return true;
|
|
84
65
|
};
|
|
85
66
|
|
|
67
|
+
if (planItems.length === 0) {
|
|
68
|
+
// W-mrboq1g900096f04: a PRD that reaches `completed` with ZERO materialized
|
|
69
|
+
// work items (agent authoring bug, race, or manual/API misuse writing
|
|
70
|
+
// status:'completed' directly instead of 'awaiting-approval') was a
|
|
71
|
+
// permanent, silent dead end — this function used to hard-return here
|
|
72
|
+
// before ever reaching reopenStaleCompletedPlan above, and
|
|
73
|
+
// materializePlansAsWorkItems unconditionally skips COMPLETED plans, so
|
|
74
|
+
// nothing ever re-triggered materialization. Self-heal: if the PRD is
|
|
75
|
+
// COMPLETED and still has feature IDs stuck at a non-terminal PRD-level
|
|
76
|
+
// status ('missing'/'updated', i.e. PRD_MATERIALIZABLE) with no
|
|
77
|
+
// corresponding work item, reopen it so the materializer picks it up.
|
|
78
|
+
// Do NOT reopen a genuinely-complete PRD (no missing_features, or every
|
|
79
|
+
// feature already at a terminal PRD-level status such as 'done').
|
|
80
|
+
if (plan.status === PLAN_STATUS.COMPLETED) {
|
|
81
|
+
const stuckIds = (plan.missing_features || [])
|
|
82
|
+
.filter(f => f?.id && PRD_MATERIALIZABLE.has(f.status))
|
|
83
|
+
.map(f => f.id);
|
|
84
|
+
if (stuckIds.length > 0) {
|
|
85
|
+
reopenStaleCompletedPlan(`${stuckIds.length} item(s) stuck at PRD-level status with zero materialized work items: ${stuckIds.join(', ')}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// W-mqk5ld3p: verify-creation must be a pure function of work-item state,
|
|
92
|
+
// independent of who/what set `status: completed`. We do NOT short-circuit on
|
|
93
|
+
// the raw `_completionNotified` boolean — a PRD can land on disk pre-completed
|
|
94
|
+
// with the flag already set (out-of-band write by the plan-to-prd / pipeline
|
|
95
|
+
// path), and the legacy top-of-function `if (_completionNotified) return` then
|
|
96
|
+
// permanently skipped the aggregate build/test verify gate. Instead the flag
|
|
97
|
+
// gates ONLY the one-shot completion summary (below); control always falls
|
|
98
|
+
// through to the verify-creation block, which re-checks for an existing verify
|
|
99
|
+
// WI under the file lock and is therefore safe to reach every scan. The one
|
|
100
|
+
// exception is the REOPEN sub-path (terminal verify → re-open): that is a
|
|
101
|
+
// plan-modification concern (the dashboard clears `_completionNotified` when it
|
|
102
|
+
// re-opens a plan), so it is gated on `!alreadyNotified` to avoid bouncing an
|
|
103
|
+
// already-done verify on every steady-state scan.
|
|
104
|
+
const alreadyNotified = plan.status === PLAN_STATUS.COMPLETED && !!plan._completionNotified;
|
|
105
|
+
|
|
106
|
+
// Hard completion gate: every PRD feature ID must have a corresponding work item in a terminal state.
|
|
107
|
+
const planFeatureIds = new Set((plan.missing_features || []).map(f => f.id).filter(Boolean));
|
|
108
|
+
const workItemById = {};
|
|
109
|
+
for (const w of planItems) { if (w.id) workItemById[w.id] = w; }
|
|
110
|
+
|
|
86
111
|
// Check 1: every feature must have a work item (materialized)
|
|
87
112
|
// Fallback: also accept features marked done directly in the PRD JSON (resolved externally)
|
|
88
113
|
const unmaterialized = [...planFeatureIds].filter(id => {
|
|
@@ -5647,9 +5672,16 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
5647
5672
|
const w = data.find(i => i.id === meta.item.id);
|
|
5648
5673
|
if (!w) return data;
|
|
5649
5674
|
const retries = w._retryCount || 0;
|
|
5675
|
+
// #893 gate failure reason — surfaced into the next dispatch's prompt via
|
|
5676
|
+
// buildWorkItemDispatchVars()'s retry_context var (W-mrbq53ra000lf2e5) so the
|
|
5677
|
+
// retried agent knows the PRD file was never found instead of silently
|
|
5678
|
+
// repeating the same mistake.
|
|
5679
|
+
const gateReason = `Validation gate (#893): no PRD JSON file was found on disk for planFile "${meta.item.planFile || w.planFile || '(unknown)'}" (expected "${expectedFile || '(unset)'}") after the previous attempt completed. Write the PRD JSON to the exact path specified in the playbook before finishing.`;
|
|
5650
5680
|
if (retries < ENGINE_DEFAULTS.maxRetries) {
|
|
5651
5681
|
w.status = WI_STATUS.PENDING;
|
|
5652
5682
|
w._retryCount = retries + 1;
|
|
5683
|
+
w._lastRetryReason = gateReason;
|
|
5684
|
+
w._lastRetryAt = ts();
|
|
5653
5685
|
// W-mpmwxn1j — bump per-agent counter so a planner that never
|
|
5654
5686
|
// writes the PRD gets reassigned after maxRetriesPerAgent hits.
|
|
5655
5687
|
const failedAgent = meta?._agentId || w.dispatched_to;
|
|
@@ -5661,6 +5693,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
5661
5693
|
w.status = WI_STATUS.FAILED;
|
|
5662
5694
|
w.failReason = 'PRD file not written after ' + ENGINE_DEFAULTS.maxRetries + ' attempts';
|
|
5663
5695
|
w.failedAt = ts();
|
|
5696
|
+
w._lastRetryReason = gateReason;
|
|
5664
5697
|
log('warn', `plan-to-prd ${meta.item.id} failed — no PRD file after ${ENGINE_DEFAULTS.maxRetries} retries`);
|
|
5665
5698
|
}
|
|
5666
5699
|
return data;
|