@yemi33/minions 0.1.2345 → 0.1.2346
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 +25 -3
- package/docs/README.md +1 -0
- package/docs/completion-reports.md +23 -0
- package/docs/kb-sweep.md +57 -24
- package/docs/live-checkout-mode.md +27 -5
- package/engine/dispatch.js +1 -0
- package/engine/kb-sweep.js +293 -10
- package/engine/live-checkout.js +157 -24
- package/engine/metrics-store.js +0 -0
- package/engine/shared.js +28 -2
- package/engine.js +77 -0
- package/package.json +1 -1
package/dashboard.js
CHANGED
|
@@ -10050,13 +10050,28 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10050
10050
|
},
|
|
10051
10051
|
onDone: () => {
|
|
10052
10052
|
_emitTimingLog(_lifecycle, _tSessionReady, Date.now(), 'done');
|
|
10053
|
-
|
|
10053
|
+
// W-mrax0he4000kc90d — resolve with ONLY the trailing segment's
|
|
10054
|
+
// text, not the full accumulated turn. ccSegmentsFinalize's
|
|
10055
|
+
// contract (dashboard/js/render-utils.js) is that its finalText
|
|
10056
|
+
// argument is the trailing/last text segment only — the same
|
|
10057
|
+
// contract onChunk already honors via `accumulated.slice(segmentStart)`
|
|
10058
|
+
// (W-mqvakva3000i3325). Resolving with the full multi-segment
|
|
10059
|
+
// `accumulated` buffer here welds every earlier segment onto the
|
|
10060
|
+
// end of the message via _ccSegMergeText's "distinct — never weld"
|
|
10061
|
+
// branch, visibly duplicating the whole prior turn for any
|
|
10062
|
+
// response with >=1 mid-turn tool call. `raw` still carries the
|
|
10063
|
+
// full turn (logging/telemetry only — never rendered).
|
|
10064
|
+
resolveResult({ text: accumulated.slice(segmentStart), sessionId: sessionHandle.sessionId, code: 0, usage: {}, raw: accumulated, stderr: '' });
|
|
10054
10065
|
},
|
|
10055
10066
|
onError: (err) => {
|
|
10056
10067
|
_emitTimingLog(_lifecycle, _tSessionReady, Date.now(), cancelled ? 'cancelled' : 'error');
|
|
10057
10068
|
if (cancelled) {
|
|
10069
|
+
// Same trailing-segment contract as onDone above — a mid-stream
|
|
10070
|
+
// cancel (e.g. user hits "stop") can still reach the SSE 'done'
|
|
10071
|
+
// frame with text accumulated so far, so it must not leak the
|
|
10072
|
+
// full multi-segment buffer either.
|
|
10058
10073
|
resolveResult({
|
|
10059
|
-
text: accumulated,
|
|
10074
|
+
text: accumulated.slice(segmentStart),
|
|
10060
10075
|
sessionId: sessionHandle.sessionId,
|
|
10061
10076
|
code: 0,
|
|
10062
10077
|
usage: {},
|
|
@@ -10599,7 +10614,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10599
10614
|
finishMissingRuntime(result, liveState);
|
|
10600
10615
|
return;
|
|
10601
10616
|
}
|
|
10602
|
-
|
|
10617
|
+
// W-mrax0he4000kc90d — `result.text` from the pool path is now the
|
|
10618
|
+
// TRAILING segment only (see _invokeCcStreamViaPool's onDone), which
|
|
10619
|
+
// is legitimately empty when a turn ends right on a tool call with
|
|
10620
|
+
// no closing prose. That's a real success, not "no output" — so also
|
|
10621
|
+
// check `result.raw` (always the full accumulated turn) before
|
|
10622
|
+
// treating an empty `text` as a failure. A genuinely empty turn has
|
|
10623
|
+
// both text and raw empty.
|
|
10624
|
+
if ((!result.text && !result.raw) || result.error) {
|
|
10603
10625
|
if (req.destroyed) {
|
|
10604
10626
|
_ccStreamEnded = true;
|
|
10605
10627
|
_logCcStreamEnd(_ccTelemetry, 'llm-empty-client-gone', { code: result.code });
|
package/docs/README.md
CHANGED
|
@@ -40,6 +40,7 @@ Architecture, design proposals, and lifecycle references for people working on t
|
|
|
40
40
|
- [pr-comment-followup.md](pr-comment-followup.md) — PR-comment follow-up dispatch contract: fix/review agents may spin off a new WI via `POST /api/work-items` with `meta.pr_followup` instead of broadening the current PR or rebutting the comment.
|
|
41
41
|
- [pr-review-fix-loop.md](pr-review-fix-loop.md) — How the engine moves a PR from creation through review, fix dispatch, and re-review, including stale-status guards.
|
|
42
42
|
- [project-skills.md](project-skills.md) — Project-local skill discovery (`.claude/skills/`, `.claude/commands/`, `CLAUDE.md` / `.github/copilot-instructions.md` slash-command mentions): how dispatched agents see and steer toward purpose-built tooling the project ships, plus the intent-vocabulary contract.
|
|
43
|
+
- [proposals/repo-pool-for-live-checkout.md](proposals/repo-pool-for-live-checkout.md) — Review-only artifact reproducing the approved plan + PRD for a repo pool covering live-checkout projects (multi-enlistment dispatch), so the design has a git-tracked surface for human comment before the dependent implementation work items land as code PRs.
|
|
43
44
|
- [qa-runbook-lifecycle.md](qa-runbook-lifecycle.md) — End-to-end QA runbook lifecycle (W-mpeiwz6k0005bf34): runbook + run-record storage, `POST /api/qa/runbooks/run` dispatch into the `qa-validate` playbook, artifact contract, and how the `/qa` page mirrors managed-spawn observability.
|
|
44
45
|
- [qa-runbooks.md](qa-runbooks.md) — Per-project QA runbook schema, storage layout (`projects/<name>/runbooks/<id>.json`), CRUD endpoints, run-record lifecycle, and the `qa-validate` agent sidecar contract.
|
|
45
46
|
- [rfc-completion-json.md](rfc-completion-json.md) — RFC for replacing stdout regex-scraping with a structured `completion.json` control-plane protocol.
|
|
@@ -278,6 +278,7 @@ Defined in `engine/shared.js` as `FAILURE_CLASS`. Use the canonical hyphenated s
|
|
|
278
278
|
|---|---|---|
|
|
279
279
|
| `config-error` | CLI not found, exit code 78, malformed config | Never retry |
|
|
280
280
|
| `permission-blocked` | Trust gate, permission denied, auth failure | Never retry |
|
|
281
|
+
| `auth` | Git/network credential failure (e.g. ADO bearer-token acquire or GCM prompt) — structural | Never retry |
|
|
281
282
|
| `merge-conflict` | Git merge conflict in worktree or dependency | Retry same agent |
|
|
282
283
|
| `build-failure` | Compilation, lint, or test failure introduced by the agent | Retry same agent |
|
|
283
284
|
| `timeout` | Hard runtime timeout or stale-orphan timeout | Retry with fresh session |
|
|
@@ -288,8 +289,30 @@ Defined in `engine/shared.js` as `FAILURE_CLASS`. Use the canonical hyphenated s
|
|
|
288
289
|
| `max-turns` | Claude CLI `error_max_turns` — work in progress | Retry same agent |
|
|
289
290
|
| `completion-nonce-mismatch` | Completion JSON missing or mismatched `nonce` (forged completion). See [Trust boundary](#trust-boundary). | Never retry (untrusted) |
|
|
290
291
|
| `worktree-preflight` | Pre-spawn worktree validation rejected the dispatch (nested-in-project, drive-root collapse, missing base). See [Pre-spawn preflight vs agent failure](#pre-spawn-preflight-vs-agent-failure). | Never retry |
|
|
292
|
+
| `worktree-dirty` | Reused worktree had uncommitted edits the engine could not auto-heal (#2996) | Never retry this dispatch (fresh worktree next cycle) |
|
|
293
|
+
| `worktree-divergent` | Reused worktree's local branch was ahead of origin — unsafe to reset (#2996) | Never retry this dispatch (worktree quarantined) |
|
|
294
|
+
| `worktree-quarantine-env-blocked` | Quarantine rename failed because the OS wouldn't release the worktree dir (Windows EBUSY/EPERM/EACCES) | Retry — re-queued without bumping the per-agent retry counter |
|
|
295
|
+
| `dependency-merge-setup` | Dependency pre-merge plumbing (stash/status/reset) failed before a real file conflict was verified | Retry — fresh worktree can recover |
|
|
296
|
+
| `invalid-keep-processes-workdir` | `keep-pids.json` declared a cwd that isn't a real git worktree | Never retry until the agent reruns in a real worktree |
|
|
297
|
+
| `invalid-keep-processes-schema` | `keep-pids.json` failed schema validation (pids-missing, ttl-too-long, etc.) | Never retry until the agent fixes the file |
|
|
298
|
+
| `invalid-managed-spawn` | `agents/<id>/managed-spawn.json` failed the validator (bad schema, workdir, allowlist, healthcheck shape) | Never retry as-is — agent must fix the file |
|
|
299
|
+
| `managed-spawn-healthcheck-failed` | A managed-spawn spec spawned but failed its healthcheck within `timeout_s`; failing PIDs killed, siblings kept alive | Flag for human review (log tail in inbox alert) |
|
|
300
|
+
| `injection-flagged` | Agent set `securityFlags.injectionAttempt:true` after spotting a prompt-injection attempt in an `<UNTRUSTED-INPUT>` fence | Never retry — human reviews the source |
|
|
291
301
|
| `live-checkout-dirty` | Live-checkout project tree had uncommitted changes; engine refused to spawn in-place. See [Pre-spawn preflight vs agent failure](#pre-spawn-preflight-vs-agent-failure). | Never retry |
|
|
302
|
+
| `live-checkout-failed` | `prepareLiveCheckout` threw before spawn (helper guard, ref validation, transient git error) — distinct from confirmed-dirty | Retry with bounded backoff (up to `maxRetries`) |
|
|
303
|
+
| `live-checkout-mid-operation` | Operator tree is mid-operation (in-progress merge/rebase/cherry-pick/bisect) or detached HEAD | Never retry — operator must finish/abort first |
|
|
304
|
+
| `live-checkout-blob-fetch` | `git checkout` failed materializing the tree on a blobless/GVFS-partial clone — deterministic, not transient | Never retry — operator hydrates the branch with their own credentials |
|
|
305
|
+
| `live-checkout-worktree-conflict` | Target branch is already checked out in a second worktree elsewhere — structural, doesn't clear on retry | Never retry — operator removes/reassigns the other worktree |
|
|
306
|
+
| `live-checkout-wrong-base` | New-branch fork requested but operator HEAD is on an unrelated branch, not the project base | Never retry — operator checks out `mainRef` first (or enable `liveCheckoutAutoBaseRepair`) |
|
|
307
|
+
| `invalid-workdir` | Dispatch carried a `meta.workdir` override that failed validation (absolute path, `..` segment, escape, etc.) | Never retry until the WI's `meta.workdir` is fixed |
|
|
308
|
+
| `model-unavailable` | Requested model returned `overloaded_error` / 503 / service unavailable | Retry — engine swaps in the runtime-appropriate fallback model |
|
|
292
309
|
| `workspace-manifest-repo-forbidden` | Dispatch routed an agent to a repo not in its `workspace_manifest.allowed_repos`. See [Pre-spawn preflight vs agent failure](#pre-spawn-preflight-vs-agent-failure). | Never retry |
|
|
310
|
+
| `workspace-manifest-tool-forbidden` | Out-of-scope tool call caught by manifest enforcement at the runtime gate | Never retry as-is |
|
|
311
|
+
| `workspace-manifest-url-forbidden` | Out-of-scope external URL fetch caught by manifest enforcement | Never retry as-is |
|
|
312
|
+
| `spawn-phase-stall` | Process spawned and ran startup-only events (MCP init/hooks) but never emitted real task progress before the grace window | Retry — fresh session clears a transient MCP wedge |
|
|
313
|
+
| `output-truncated` | Agent streamed more stdout than the engine's hard capture cap before the terminal `result` event arrived | Never retry — agent must reduce output volume or split the task |
|
|
314
|
+
| `verify-missing-pr` | A verify WI exited `done` but no PR was attached | Retry — agent may have phantom-crashed before pushing |
|
|
315
|
+
| `project_not_found` | A work item's `project` field names a project that isn't configured | Never retry until the WI's `project` field or config is fixed |
|
|
293
316
|
| `unknown` | Unclassified failure | Default retry logic |
|
|
294
317
|
|
|
295
318
|
Use `"N/A"` when `status` is `success` or `partial` without a failure.
|
package/docs/kb-sweep.md
CHANGED
|
@@ -4,31 +4,61 @@ How the `knowledge/` directory stays curated, deduplicated, and small enough to
|
|
|
4
4
|
|
|
5
5
|
## What the Sweep Does
|
|
6
6
|
|
|
7
|
-
The KB sweep is a
|
|
7
|
+
The KB sweep is a multi-pass cycle implemented in [`engine/kb-sweep.js`](../engine/kb-sweep.js) that prunes duplicate, boilerplate, stale, and oversized entries from `knowledge/`. It runs asynchronously, archives (rather than deletes) what it removes, and writes a `_swept` frontmatter flag so the expensive rewrite pass is idempotent.
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
Entries in knowledge/<category>/*.md
|
|
11
|
-
→ Pass 1:
|
|
12
|
-
→ Pass
|
|
13
|
-
→ Pass 2
|
|
14
|
-
→ Pass
|
|
15
|
-
→
|
|
11
|
+
→ Pass 1: Hash dedup (cheap, no LLM)
|
|
12
|
+
→ Pass 1.5: Structural consolidation (cheap, no LLM — PRIMARY balloon fix)
|
|
13
|
+
→ Pass 2: LLM batch sweep (Haiku, batches of 30)
|
|
14
|
+
→ Pass 2.5: Age-TTL expiry (per-category, no LLM — secondary safety net)
|
|
15
|
+
→ Pass 3: Per-entry rewrite (Haiku, concurrency 5)
|
|
16
|
+
→ Prune _swept/ archive (>30 days)
|
|
16
17
|
→ Invalidate KB cache, write engine/kb-swept.json
|
|
17
18
|
```
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
> **Why consolidation is the primary fix.** Hash dedup only catches *near-exact* re-postings, and the LLM batch pass only compares entries *within* a 30-entry batch. The dominant balloon source — hundreds of one-off boilerplate notes (agent-failure dumps, no-op / merge-conflict fix reports) that differ only by work-item / date / dispatch id — is never collapsed by those passes: each note is unique by PR#/date and is spread across dozens of batches. Pass 1.5 groups those notes *structurally* and rolls each large group into a single recoverable digest, shrinking the KB even for **recent** entries. The age-TTL pass (2.5) is retained only as a secondary safety net for genuinely stale one-offs that never clustered into a digest.
|
|
21
|
+
|
|
22
|
+
## The Pass Cycle
|
|
20
23
|
|
|
21
24
|
### Pass 1 — Hash Dedup
|
|
22
25
|
|
|
23
26
|
Cheap, deterministic. No LLM call.
|
|
24
27
|
|
|
25
|
-
For every entry, compute `sha256(normalize(content).slice(0,500) + ':' + content.length)` (source: [`engine/kb-sweep.js:
|
|
28
|
+
For every entry, compute `sha256(normalize(content).slice(0,500) + ':' + content.length)` (source: [`engine/kb-sweep.js:33-35`](../engine/kb-sweep.js#L33)). Group by hash. When two or more entries collide, keep the most recent (by `date:` frontmatter, then mtime) and archive the rest into `knowledge/_swept/` with a `<!-- swept: ... | reason: hash-duplicate of ... -->` header via `_archiveKbFile()` (source: [`engine/kb-sweep.js:60-69`](../engine/kb-sweep.js#L60), called at [`:165`](../engine/kb-sweep.js#L165)).
|
|
26
29
|
|
|
27
30
|
This catches near-identical re-postings across batches that the LLM pass might miss because they land in different batches.
|
|
28
31
|
|
|
32
|
+
### Pass 1.5 — Structural Consolidation (primary balloon fix)
|
|
33
|
+
|
|
34
|
+
Cheap, deterministic. No LLM call. This is the pass that actually stops `knowledge/` from ballooning: it collapses **structural duplication**, not just exact duplication.
|
|
35
|
+
|
|
36
|
+
The highest-volume category (`project-notes`) is dominated by near-identical boilerplate — e.g. hundreds of `# Agent Failed: …` dumps that differ only by work-item id, date, and dispatch id (a single agent/failure-class pair like `dallas` / `live-checkout-failed` can account for hundreds of files). Hash dedup can't collapse them (the leading bytes differ) and the LLM batch pass can't either (they're scattered across dozens of 30-entry batches). Left alone they survive every other pass forever.
|
|
37
|
+
|
|
38
|
+
Pass 1.5 groups eligible entries by a **structural signature** and rolls any group of at least `minGroupSize` notes into a single digest, archiving the originals to `knowledge/_swept/` (source: [`engine/kb-sweep.js`](../engine/kb-sweep.js) `_structuralConsolidate`).
|
|
39
|
+
|
|
40
|
+
- **Signature** = `category :: agent :: kind :: time-bucket`. `kind` is derived from the note (source: [`engine/kb-sweep.js`](../engine/kb-sweep.js) `_consolidationKind`):
|
|
41
|
+
- `failure-<failureClass>` when the note carries a `failureClass:` (or `result: error`) — the agent-failure dumps.
|
|
42
|
+
- `noop` / `merge-conflict` when the title matches a no-op or merge-conflict fix report.
|
|
43
|
+
- Any note that doesn't match a boilerplate kind returns `null` and is **left untouched** — unique, valuable notes are never grouped.
|
|
44
|
+
- **Time-bucket** = the start date of the `periodDays` window the note's authored date falls in (default one digest per agent/kind per **week** — source: `_periodBucket`).
|
|
45
|
+
- **Digest** is written to `<category>/_digest-<agent>-<kind>-<period>.md` with `source: kb-consolidation` and a `_digest:` frontmatter flag. Its body is a compact roll-up table (one row per collapsed note: date, work item, dispatch, failureClass, one-line reason, and the original filename for recovery). Re-running the sweep **merges** new rows into the existing digest keyed on the original filename, so repeated sweeps accumulate rather than clobber.
|
|
46
|
+
- **Digests are never re-consolidated or rewritten** — `_isDigestEntry()` excludes them from Pass 1.5 grouping and from the Pass 3 rewrite (which would otherwise destroy the table).
|
|
47
|
+
|
|
48
|
+
Only categories in `ENGINE_DEFAULTS.kbConsolidation.categories` are eligible; durable categories (`architecture`, `conventions`) and per-agent memory (`knowledge/agents/`, never scanned) are never consolidated. Config is overridable per-fleet via `config.engine.kbConsolidation` (merged over the defaults):
|
|
49
|
+
|
|
50
|
+
| Key | Default | Meaning |
|
|
51
|
+
|-----|---------|---------|
|
|
52
|
+
| `enabled` | `true` | Set `false` (or `categories: []`) to disable the pass. |
|
|
53
|
+
| `categories` | `['project-notes', 'build-reports', 'reviews']` | Only these are consolidated. |
|
|
54
|
+
| `minGroupSize` | `5` | A group collapses only once it reaches this many near-duplicate notes. |
|
|
55
|
+
| `periodDays` | `7` | One digest per (agent, kind) per this many days. |
|
|
56
|
+
|
|
57
|
+
Because originals are archived to `_swept/` (same 30-day retention as every other pass), consolidation is fully recoverable for ~30 days.
|
|
58
|
+
|
|
29
59
|
### Pass 2 — LLM Batch Sweep
|
|
30
60
|
|
|
31
|
-
The remaining survivors are sent to Claude Haiku in batches of `LLM_BATCH_SIZE = 30` (source: [`engine/kb-sweep.js:
|
|
61
|
+
The remaining survivors are sent to Claude Haiku in batches of `LLM_BATCH_SIZE = 30` (source: [`engine/kb-sweep.js:29`](../engine/kb-sweep.js#L29)). Each batch prompt asks for three lists in JSON:
|
|
32
62
|
|
|
33
63
|
| Field | What the LLM looks for |
|
|
34
64
|
|-------|------------------------|
|
|
@@ -36,15 +66,15 @@ The remaining survivors are sent to Claude Haiku in batches of `LLM_BATCH_SIZE =
|
|
|
36
66
|
| `reclassify` | Entries in the wrong category. Returned as `{index, from, to, reason}`. |
|
|
37
67
|
| `remove` | Stale or empty entries (boilerplate, "no changes needed", bail-out notes). Returned as `{index, reason}`. |
|
|
38
68
|
|
|
39
|
-
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:
|
|
69
|
+
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)).
|
|
40
70
|
|
|
41
71
|
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.
|
|
42
72
|
|
|
43
|
-
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:
|
|
73
|
+
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)).
|
|
44
74
|
|
|
45
|
-
### Pass 2.5 — Age-Based TTL Expiry
|
|
75
|
+
### Pass 2.5 — Age-Based TTL Expiry (secondary safety net)
|
|
46
76
|
|
|
47
|
-
Cheap, deterministic. No LLM call.
|
|
77
|
+
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.
|
|
48
78
|
|
|
49
79
|
For every surviving entry, the pass computes the entry's age and archives it to `knowledge/_swept/` when it exceeds that category's TTL (source: [`engine/kb-sweep.js`](../engine/kb-sweep.js) `_expireByAge`). Two safety properties:
|
|
50
80
|
|
|
@@ -64,30 +94,30 @@ Because expiry archives to `_swept/` (subject to the same 30-day retention as ev
|
|
|
64
94
|
|
|
65
95
|
### Pass 3 — Per-Entry Rewrite
|
|
66
96
|
|
|
67
|
-
Survivors of Passes 1 and 2 that are still on disk get rewritten one entry at a time, with up to `NORMALIZE_CONCURRENCY = 5` parallel workers (source: [`engine/kb-sweep.js:
|
|
97
|
+
Survivors of Passes 1, 1.5, and 2 that are still on disk get rewritten one entry at a time, with up to `NORMALIZE_CONCURRENCY = 5` parallel workers (source: [`engine/kb-sweep.js:30`](../engine/kb-sweep.js#L30)). Consolidation digests are excluded (rewriting would destroy their roll-up table). Each remaining entry is sent to Haiku with a fixed template prompt that:
|
|
68
98
|
|
|
69
99
|
- Caps output at ~800 words.
|
|
70
100
|
- Forces the structure `## Summary` → `## Key Findings` → `## Action Items` (omit if none) → `## References` (omit if none).
|
|
71
101
|
- Preserves all `file:line` references and code snippets.
|
|
72
102
|
- Drops boilerplate (full dates, agent IDs in the body, narrative scaffolding).
|
|
73
103
|
|
|
74
|
-
After a successful rewrite, the entry's frontmatter gains a `_swept: <ISO timestamp>` key (source: [`engine/kb-sweep.js:
|
|
104
|
+
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)).
|
|
75
105
|
|
|
76
106
|
## The `_swept` Frontmatter Flag
|
|
77
107
|
|
|
78
108
|
`_swept` makes Pass 3 idempotent across runs. Semantics:
|
|
79
109
|
|
|
80
|
-
- **Set** to `new Date().toISOString()` whenever the rewrite pass successfully rewrites the body (source: [`engine/kb-sweep.js:
|
|
81
|
-
- **Skipped** by Pass 3 on subsequent sweeps as long as the file's `mtimeMs` is `<= sweptAt + 1000` ms (1 s grace for FS timestamp jitter — source: [`engine/kb-sweep.js:
|
|
110
|
+
- **Set** to `new Date().toISOString()` whenever the rewrite pass successfully rewrites the body (source: [`engine/kb-sweep.js:293`](../engine/kb-sweep.js#L293)).
|
|
111
|
+
- **Skipped** by Pass 3 on subsequent sweeps as long as the file's `mtimeMs` is `<= sweptAt + 1000` ms (1 s grace for FS timestamp jitter — source: [`engine/kb-sweep.js:260-264`](../engine/kb-sweep.js#L260)).
|
|
82
112
|
- **Re-processed** if the file is edited after the sweep flag was set (mtime exceeds the grace window).
|
|
83
113
|
|
|
84
114
|
Hash dedup (Pass 1) and the LLM sweep (Pass 2) ignore `_swept` — those passes act on metadata and similarity, not on whether the entry was previously rewritten.
|
|
85
115
|
|
|
86
|
-
The flag key is exported as `SWEPT_FLAG_KEY` for tests (source: [`engine/kb-sweep.js:
|
|
116
|
+
The flag key is exported as `SWEPT_FLAG_KEY` for tests (source: defined [`engine/kb-sweep.js:31`](../engine/kb-sweep.js#L31), exported [`:785`](../engine/kb-sweep.js#L785)).
|
|
87
117
|
|
|
88
118
|
## 30-Day Archive Retention
|
|
89
119
|
|
|
90
|
-
Archived entries land in `knowledge/_swept/` with their original filename (deduped via `shared.uniquePath()` if it collides). They're not deleted immediately — `_pruneOldSwept()` runs at the end of every sweep and removes any file whose `mtimeMs` is older than `SWEPT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000` (30 days — source: [`engine/kb-sweep.js:
|
|
120
|
+
Archived entries land in `knowledge/_swept/` with their original filename (deduped via `shared.uniquePath()` if it collides). They're not deleted immediately — `_pruneOldSwept()` runs at the end of every sweep and removes any file whose `mtimeMs` is older than `SWEPT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000` (30 days — source: [`engine/kb-sweep.js:25`](../engine/kb-sweep.js#L25), pruning function [`:72-84`](../engine/kb-sweep.js#L72)).
|
|
91
121
|
|
|
92
122
|
This gives humans a recovery window: if a sweep archives something useful, you have ~30 days to copy it back out of `_swept/` before it's permanently pruned.
|
|
93
123
|
|
|
@@ -100,7 +130,7 @@ The dashboard exposes two endpoints:
|
|
|
100
130
|
| `POST` | `/api/knowledge/sweep` | Start a sweep in the background. Returns `202 { ok: true, started: true }` (source: [`dashboard.js:7351`](../dashboard.js#L7351), [`dashboard.js:4285`](../dashboard.js#L4285)). |
|
|
101
131
|
| `GET` | `/api/knowledge/sweep/status` | Poll `{ inFlight, startedAt, lastResult, lastCompletedAt }` (source: [`dashboard.js:7352`](../dashboard.js#L7352), [`dashboard.js:4335`](../dashboard.js#L4335)). |
|
|
102
132
|
|
|
103
|
-
POST body is optional. Pass `{ "pinnedKeys": ["knowledge/conventions/foo.md", ...] }` to add request-level pins on top of `pinned.md`. Pinned entries are excluded from every pass (source: [`engine/kb-sweep.js:
|
|
133
|
+
POST body is optional. Pass `{ "pinnedKeys": ["knowledge/conventions/foo.md", ...] }` to add request-level pins on top of `pinned.md`. Pinned entries are excluded from every pass (source: [`engine/kb-sweep.js:555-561`](../engine/kb-sweep.js#L555)).
|
|
104
134
|
|
|
105
135
|
```bash
|
|
106
136
|
# Trigger a sweep
|
|
@@ -116,7 +146,7 @@ curl http://localhost:7331/api/knowledge/sweep/status
|
|
|
116
146
|
|
|
117
147
|
Only one sweep runs at a time. The POST handler refuses to start a second sweep with `{ ok: true, alreadyRunning: true, startedAt }` while one is in flight (source: [`dashboard.js:4306-4311`](../dashboard.js#L4306)).
|
|
118
148
|
|
|
119
|
-
The guard auto-releases when the sweep has been running longer than `staleGuardMs(entryCount)` — `max(30 min, 1 s × entryCount)` — which protects against a dashboard process that died mid-sweep leaving the flag stuck (source: [`engine/kb-sweep.js:
|
|
149
|
+
The guard auto-releases when the sweep has been running longer than `staleGuardMs(entryCount)` — `max(30 min, 1 s × entryCount)` — which protects against a dashboard process that died mid-sweep leaving the flag stuck (source: [`engine/kb-sweep.js:758-761`](../engine/kb-sweep.js#L758), [`dashboard.js:4286-4305`](../dashboard.js#L4286)).
|
|
120
150
|
|
|
121
151
|
### Persistent State
|
|
122
152
|
|
|
@@ -133,7 +163,7 @@ Sweep state is mirrored to `engine/kb-sweep-state.json` so the dashboard can rec
|
|
|
133
163
|
{ "status": "failed", "startedAt": ..., "completedAt": ..., "error": "..." }
|
|
134
164
|
```
|
|
135
165
|
|
|
136
|
-
Memory still wins when present; the disk file is a fallback (source: [`engine/kb-sweep.js:
|
|
166
|
+
Memory still wins when present; the disk file is a fallback (source: [`engine/kb-sweep.js:372-410`](../engine/kb-sweep.js#L372) `readSweepLiveness()`, [`dashboard.js:4296-4354`](../dashboard.js#L4296)). A separate `engine/kb-swept.json` is written after each successful sweep with the human-readable summary line shown by the dashboard's "swept N days ago" badge (source: [`engine/kb-sweep.js:626`](../engine/kb-sweep.js#L626)).
|
|
137
167
|
|
|
138
168
|
## Automatic Periodic Sweep (opt-in)
|
|
139
169
|
|
|
@@ -164,6 +194,9 @@ The KB page is refreshed by the dashboard's main refresh loop **every third tick
|
|
|
164
194
|
entriesBefore, entriesAfter,
|
|
165
195
|
bytesBefore, bytesAfter,
|
|
166
196
|
hashDuplicatesArchived, // Pass 1
|
|
197
|
+
notesConsolidated, // Pass 1.5 (boilerplate notes rolled into digests)
|
|
198
|
+
consolidationGroups, // Pass 1.5 (groups collapsed)
|
|
199
|
+
digestsWritten, // Pass 1.5 (digest files written/updated)
|
|
167
200
|
llmDuplicatesArchived, // Pass 2 (within-batch dupes)
|
|
168
201
|
staleRemoved, // Pass 2 (LLM-flagged removals)
|
|
169
202
|
ageExpired, // Pass 2.5 (per-category age-TTL archival)
|
|
@@ -172,11 +205,11 @@ The KB page is refreshed by the dashboard's main refresh loop **every third tick
|
|
|
172
205
|
rewriteBytesBefore, rewriteBytesAfter,
|
|
173
206
|
sweptArchivePruned, // Files purged from _swept/ (>30 days)
|
|
174
207
|
durationMs,
|
|
175
|
-
summary: '<n> hash-dup, <n> llm-dup, <n> stale, <n> age-expired, <n> reclassified, <n> rewritten (<bytes> saved)',
|
|
208
|
+
summary: '<n> hash-dup, <n> consolidated (<n> digests), <n> llm-dup, <n> stale, <n> age-expired, <n> reclassified, <n> rewritten (<bytes> saved)',
|
|
176
209
|
}
|
|
177
210
|
```
|
|
178
211
|
|
|
179
|
-
After a non-dry-run sweep, `queries.invalidateKnowledgeBaseCache()` is called so the next `/api/knowledge` read sees the new state (source: [`engine/kb-sweep.js:
|
|
212
|
+
After a non-dry-run sweep, `queries.invalidateKnowledgeBaseCache()` is called so the next `/api/knowledge` read sees the new state (source: [`engine/kb-sweep.js:627`](../engine/kb-sweep.js#L627)).
|
|
180
213
|
|
|
181
214
|
## What NOT to Do
|
|
182
215
|
|
|
@@ -18,9 +18,9 @@ The default `worktree` mode spawns each dispatch inside its own git worktree und
|
|
|
18
18
|
|
|
19
19
|
For these repos, the operator usually already has one canonical checkout that builds correctly. Live-checkout mode lets the agent dispatch into that checkout instead of cloning a side-by-side copy.
|
|
20
20
|
|
|
21
|
-
## Contract (
|
|
21
|
+
## Contract (six guarantees)
|
|
22
22
|
|
|
23
|
-
Live mode is opinionated about what the engine will and will not do to the operator's tree. The contract has
|
|
23
|
+
Live mode is opinionated about what the engine will and will not do to the operator's tree. The contract has six guarantees; the engine enforces all six and fails dispatches that would violate them.
|
|
24
24
|
|
|
25
25
|
### 1. Per-project mutating-concurrency cap of 1
|
|
26
26
|
|
|
@@ -125,7 +125,28 @@ Pool short-circuits live in `engine.js:1368` and `engine/cleanup.js`; both gate
|
|
|
125
125
|
|
|
126
126
|
Either condition fails the dispatch non-retryably with `FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION` (`'live-checkout-mid-operation'`; added to `engine/dispatch.js`'s neverRetry set alongside `LIVE_CHECKOUT_DIRTY`). `spawnAgent` writes a `live-checkout-blocked-<wi-id>` inbox alert and stamps the work item `_pendingReason: 'live_checkout_mid_operation'` (or `'live_checkout_detached_head'` for the detached case). The recovery guidance tells the operator to finish or abort the in-progress op with their own commands (`git <op> --continue` / `git <op> --abort`), or checkout a branch, then re-dispatch. The engine never runs `git reset`, `git clean`, `git stash`, `git rebase --abort`, or moves HEAD on the operator's behalf.
|
|
127
127
|
|
|
128
|
-
### 6.
|
|
128
|
+
### 6. STALE-BASE GUARD — refuse to fork off a diverged local base (W-mr98op8w000ma4ad)
|
|
129
|
+
|
|
130
|
+
A **clean** tree can still hide **committed** contamination. When `prepareLiveCheckout` is about to create a **new** branch (`git checkout -b <branch>`) off the operator's current HEAD, and HEAD is sitting on the project base ref (`mainRef`, the common case), the local `mainRef` branch may carry commits that were never pushed to `origin/<mainRef>` — e.g. leftover in-progress work from a prior dispatch on a live-mode project (capped to one mutating dispatch, but shared across many sequential dispatches), an interrupted process, or a bad rebase. Forking a new branch off that base silently inherits those commits into the branch **and its PR**. This is exactly the scope-contamination class first seen on ADO PR 5411214 and recurring on AB#12016662 / ADO PR 5419146 (~20 unrelated OCM files baked into an otherwise-clean 2-file a11y fix).
|
|
131
|
+
|
|
132
|
+
It survives the dirty check (Guarantee 2) because the contamination is **committed**, not uncommitted, and it cannot be caught by a "HEAD is on the wrong branch" check because here HEAD **is** on `mainRef`.
|
|
133
|
+
|
|
134
|
+
The guard detects it **without a fetch** (preserving issue #226's no-forced-fetch guarantee) by comparing the local `refs/heads/<mainRef>` tip against the **already-present** `refs/remotes/origin/<mainRef>` remote-tracking ref:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
git rev-list --count refs/remotes/origin/<mainRef>..<mainRef> # > 0 ⇒ local base is ahead / contaminated
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
A count `> 0` returns `{ ok:false, reason:'stale-main', mainRef, ahead, localMainSha, originMainSha, branch, originalRef, originalRefType }`. Being merely *behind* `origin/<mainRef>` (fewer commits) is **not** flagged — that just yields a branch off an older tip, not contamination — so only the local-ahead side is counted. The guard **skips** (proceeds normally) when:
|
|
141
|
+
|
|
142
|
+
- HEAD is not on `mainRef` (the fork base is something the operator/engine deliberately parked there — out of scope);
|
|
143
|
+
- no local `origin/<mainRef>` remote-tracking ref exists (local-only / no-remote repo — comparison would require the forbidden fetch, and the contamination class only exists when an origin baseline is present to diverge from);
|
|
144
|
+
- the divergence count cannot be computed (a transient `rev-list` failure must not become a permanent refusal).
|
|
145
|
+
|
|
146
|
+
`spawnAgent` completes the dispatch non-retryably with the dedicated **`FAILURE_CLASS.LIVE_CHECKOUT_STALE_BASE`** (`'live-checkout-stale-base'`; in `dispatch.js`'s `neverRetry` set) plus a `live-checkout-stale-base-<wi-id>` inbox alert and a `_pendingReason: 'live_checkout_stale_base'` stamp — instead of `LIVE_CHECKOUT_FAILED` (retryable) retry-storming an identical structural failure to `maxRetries`. The engine **never** resets, cleans, or rebases the operator's local `mainRef` — surfacing it as a refusal keeps history rewrites in the operator's hands, consistent with the dirty and mid-operation refusals. The alert's recovery guidance offers two paths: salvage the extra commits onto their own branch then `git reset --hard origin/<mainRef>`, or discard them with the same reset — either leaves `mainRef` matching `origin/<mainRef>` so the next dispatch forks off a clean base.
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
### 7. Refuse on stale non-main base when forking a new branch (W-mr3lunnq000o9f41)
|
|
129
150
|
|
|
130
151
|
When a live-checkout dispatch needs a **new** branch, `git checkout -b <branch>` seeds it from the operator's **current HEAD** (issue #226 — live mode never fetches `origin/<mainRef>` to avoid auth-less GVFS blob fetches). That is safe only when HEAD is already on the project's base branch. If a human — or a prior dispatch — left the operator checkout sitting on some unrelated topic/feature branch, the new branch (and therefore its PR) would silently inherit **every commit already on that topic branch**. This is scope contamination baked into git history, not a stray uncommitted diff, so it survives the dirty-tree (Guarantee 2) and mid-operation (Guarantee 5) preflights, which only check for uncommitted changes / in-progress ops, never *"is HEAD on the expected base?"*. The motivating incident is ADO PR `!5411214`, which silently carried ~22 unrelated OCM files the PR author disavowed.
|
|
131
152
|
|
|
@@ -136,12 +157,12 @@ Immediately before the new-branch `git checkout -b`, `prepareLiveCheckout` compa
|
|
|
136
157
|
- **HEAD off-base, local `refs/heads/<mainRef>` exists** → PLAIN `git checkout <mainRef>` (no fetch, no reset, no `--force` — the tree was verified clean at Guarantee 2), then forks off it. No auth-less GVFS blob fetch is forced in the common case.
|
|
137
158
|
- **HEAD off-base, no usable local `mainRef`** (fresh-clone edge case) — or the local checkout itself fails — → **fail closed** with `{ ok:false, reason:'wrong-base' }` **unless auto-base-repair is enabled (see §6a).** The caller refuses **non-retryably** with `FAILURE_CLASS.LIVE_CHECKOUT_WRONG_BASE` (`'live-checkout-wrong-base'`; added to `engine/dispatch.js`'s neverRetry set), writes an inbox alert naming the off-base HEAD, the expected base, and the exact `git checkout <mainRef>` recovery command, and stamps the work item `_pendingReason`. This mirrors the dirty / mid-operation philosophy of refusing on ambiguous operator state rather than silently forking off the wrong base.
|
|
138
159
|
|
|
139
|
-
####
|
|
160
|
+
#### 7a. Opt-in auto-base-repair (`liveCheckoutAutoBaseRepair`, W-mr3lunnq000o9f41)
|
|
140
161
|
|
|
141
162
|
The fail-closed refusal above is safe but forces a manual `git checkout <mainRef>` before every re-dispatch. Enabling **`liveCheckoutAutoBaseRepair`** (per-project `project.liveCheckoutAutoBaseRepair` > fleet-wide `engine.liveCheckoutAutoBaseRepair` > **`false`**, resolved by `shared.resolveLiveCheckoutAutoBaseRepair`) lets the engine self-heal the *missing-local-base* case:
|
|
142
163
|
|
|
143
164
|
- When HEAD is off-base **and** there is no local `refs/heads/<mainRef>` **but** a `refs/remotes/origin/<mainRef>` remote-tracking ref exists, the guard materializes a local base branch from it — `git branch --no-track <mainRef> refs/remotes/origin/<mainRef>` — then proceeds exactly like the local-base path (plain `git checkout <mainRef>`, then fork).
|
|
144
|
-
- **Issue #226 is preserved:** `origin/<mainRef>` is consumed as a **local ref** — there is **no `git fetch`**. `origin/<mainRef>` is the *correct* project base, so there is **no scope-contamination risk** (the entire point of Guarantee
|
|
165
|
+
- **Issue #226 is preserved:** `origin/<mainRef>` is consumed as a **local ref** — there is **no `git fetch`**. `origin/<mainRef>` is the *correct* project base, so there is **no scope-contamination risk** (the entire point of Guarantee 7 survives — it never forks off the stale topic HEAD).
|
|
145
166
|
- **Bounded, degrades safely:** the subsequent `git checkout <mainRef>` may hydrate blobs on a partial clone (the one operation issue #226 flagged as hang-prone in headless sessions). It is bounded by the git-wrapper timeout; on failure/hang it falls through to the **same** fail-closed `wrong-base` refusal — strictly no worse than the non-repair path.
|
|
146
167
|
- **Still fail-closed** when neither a local base nor an `origin/<mainRef>` tracking ref exists, or when the base checkout itself fails. Default **OFF**.
|
|
147
168
|
|
|
@@ -308,6 +329,7 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
|
|
|
308
329
|
| `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). |
|
|
309
330
|
| `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). |
|
|
310
331
|
| `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_WORKTREE_CONFLICT` | Non-retryable refusal class for an existing-branch checkout that refused because the branch is already checked out in another worktree — structural, so excluded from mechanical retry (W-mr28h2j2000y0de1). |
|
|
332
|
+
| `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_STALE_BASE` | Non-retryable refusal class for a new-branch fork whose local `mainRef` base has diverged from `origin/<mainRef>` with unpushed commits (STALE-BASE GUARD) — detected fetch-free via `git rev-list --count origin/<mainRef>..<mainRef>`; deterministic, so excluded from mechanical retry (W-mr98op8w000ma4ad). |
|
|
311
333
|
| `engine.js` — `_liveCheckoutDirtyAttempts` counter | Dedicated two-strike dirty memory (survives the discovery + retry `_pendingReason` scrubs that defeated #434); first dirty failure retries once, second fails non-retryably (PL-live-checkout-reliability-hardening) — **unless** `liveCheckoutAutoReset`/`liveCheckoutAutoStash` is enabled, in which case the two-strike cap is skipped entirely and the dirty failure stays retryable up to `maxRetries` (W-mqzmkoqt000hbca2, #582). |
|
|
312
334
|
| `engine/dispatch.js` — `isRetryableFailureReason` neverRetry | Excludes `LIVE_CHECKOUT_DIRTY`, `LIVE_CHECKOUT_MID_OPERATION`, `LIVE_CHECKOUT_BLOB_FETCH`, and `LIVE_CHECKOUT_WORKTREE_CONFLICT` from mechanical retry. |
|
|
313
335
|
| `engine/timeout.js` header comment | Confirms no special live-mode kill handling. |
|
package/engine/dispatch.js
CHANGED
|
@@ -749,6 +749,7 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
|
|
|
749
749
|
FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION, // P-a7f3c1d9 — live-checkout refused to spawn because the operator tree is mid-operation (in-progress merge/rebase/cherry-pick/bisect or detached HEAD); mechanical retry won't fix it (operator must finish/abort the op or checkout a branch)
|
|
750
750
|
FAILURE_CLASS.LIVE_CHECKOUT_BLOB_FETCH, // PL-live-checkout-reliability-hardening — live-checkout `git checkout <existing-branch>` failed hydrating the tree through the auth-less GVFS cache server (blobless partial clone, headless); deterministic, so mechanical retry just reproduces it (operator must hydrate the branch with their own creds, then re-dispatch)
|
|
751
751
|
FAILURE_CLASS.LIVE_CHECKOUT_WORKTREE_CONFLICT, // W-mr28h2j2000y0de1 — live-checkout `git checkout <existing-branch>` failed because that branch is already checked out in another worktree; structural conflict, so mechanical retry just reproduces it (operator must `git worktree remove` the other tree or finish its WIP, then re-dispatch)
|
|
752
|
+
FAILURE_CLASS.LIVE_CHECKOUT_STALE_BASE, // W-mr98op8w000ma4ad — live-checkout STALE-BASE GUARD refused to fork a new branch off a local mainRef that has diverged from origin/<mainRef> with unpushed commits; contaminated local base does not change on retry (operator must reconcile the local base branch — the engine never resets it — then re-dispatch)
|
|
752
753
|
FAILURE_CLASS.LIVE_CHECKOUT_WRONG_BASE, // W-mr3lunnq000o9f41 — live-checkout refused to fork a new branch because the operator HEAD is on an unrelated branch (not the project base) and no local base ref was usable; forking off it would bake that branch's commits into the new PR (scope contamination). Deterministic — mechanical retry reproduces it (operator must `git checkout <mainRef>`, then re-dispatch)
|
|
753
754
|
FAILURE_CLASS.OUTPUT_TRUNCATED, // P-8e4c2a17 — agent stdout exceeded the hard capture cap before the terminal result event; mechanical retry just reproduces the overflow (agent must reduce output volume or the task must be split)
|
|
754
755
|
]);
|
package/engine/kb-sweep.js
CHANGED
|
@@ -121,6 +121,11 @@ function _entryAgeMs(entry, now) {
|
|
|
121
121
|
* (absent from the TTL map) are never touched. Pinned entries are already
|
|
122
122
|
* excluded upstream (they never enter the manifest), and knowledge/agents/ is
|
|
123
123
|
* never scanned into the manifest at all — so per-agent memory is inherently safe.
|
|
124
|
+
* Consolidation digests are also exempt — like the rewrite pass (`_isDigestEntry`
|
|
125
|
+
* guard below), a digest's `date` frontmatter is inherited from the old
|
|
126
|
+
* period-bucket of the boilerplate notes it rolled up, not its own write time, so
|
|
127
|
+
* age-based TTL would silently archive a freshly-written digest on the very next
|
|
128
|
+
* sweep if it were not excluded here.
|
|
124
129
|
*
|
|
125
130
|
* @returns {{ expired:number, survivors:object[] }}
|
|
126
131
|
*/
|
|
@@ -132,6 +137,7 @@ function _expireByAge(manifest, opts = {}) {
|
|
|
132
137
|
let expired = 0;
|
|
133
138
|
const survivors = [];
|
|
134
139
|
for (const e of manifest) {
|
|
140
|
+
if (_isDigestEntry(e)) { survivors.push(e); continue; }
|
|
135
141
|
const ttlDays = ttlByCategory[e.category];
|
|
136
142
|
if (!ttlDays) { survivors.push(e); continue; }
|
|
137
143
|
if (_entryAgeMs(e, now) <= ttlDays * DAY_MS) { survivors.push(e); continue; }
|
|
@@ -168,6 +174,252 @@ function _hashDedup(manifest, opts = {}) {
|
|
|
168
174
|
return { survivors, archived };
|
|
169
175
|
}
|
|
170
176
|
|
|
177
|
+
// ── Structural consolidation (W-mr973knu) ───────────────────────────────────
|
|
178
|
+
// The primary fix for the ballooning KB. Hash-dedup only catches near-exact
|
|
179
|
+
// duplicates and the LLM batch pass only compares entries *within* a 30-entry
|
|
180
|
+
// batch, so the dominant cost — hundreds of one-off boilerplate notes (agent
|
|
181
|
+
// failure dumps, no-op / merge-conflict fix reports) that differ only by
|
|
182
|
+
// WI/date/dispatch id — is never collapsed: each is unique by PR#/date and
|
|
183
|
+
// spread across dozens of batches. This pass groups those boilerplate notes by
|
|
184
|
+
// a *structural* signature (category + agent + kind + time bucket) rather than
|
|
185
|
+
// by content, and rolls each large group into a single recoverable digest,
|
|
186
|
+
// archiving the originals to knowledge/_swept/. It runs on RECENT entries too,
|
|
187
|
+
// so it shrinks the KB continuously instead of merely aging files out.
|
|
188
|
+
|
|
189
|
+
const CONSOLIDATION_DIGEST_PREFIX = '_digest-';
|
|
190
|
+
const CONSOLIDATION_DIGEST_FLAG = '_digest'; // frontmatter marker on a digest (never re-consolidated / re-written)
|
|
191
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Resolve the effective structural-consolidation config, merging
|
|
195
|
+
* `opts.engineConfig.kbConsolidation` over `ENGINE_DEFAULTS.kbConsolidation`.
|
|
196
|
+
* `enabled` is false when no categories are configured or an explicit
|
|
197
|
+
* `enabled: false` override is present.
|
|
198
|
+
*/
|
|
199
|
+
function _resolveConsolidationConfig(opts = {}) {
|
|
200
|
+
const defaults = (shared.ENGINE_DEFAULTS && shared.ENGINE_DEFAULTS.kbConsolidation) || {};
|
|
201
|
+
const override = (opts.engineConfig && opts.engineConfig.kbConsolidation) || {};
|
|
202
|
+
const merged = { ...defaults, ...override };
|
|
203
|
+
const categories = Array.isArray(merged.categories)
|
|
204
|
+
? merged.categories.filter(c => typeof c === 'string' && c)
|
|
205
|
+
: [];
|
|
206
|
+
const minGroupSize = Number(merged.minGroupSize);
|
|
207
|
+
const periodDays = Number(merged.periodDays);
|
|
208
|
+
return {
|
|
209
|
+
enabled: merged.enabled !== false && categories.length > 0,
|
|
210
|
+
categories: new Set(categories),
|
|
211
|
+
minGroupSize: Number.isFinite(minGroupSize) && minGroupSize >= 2 ? Math.floor(minGroupSize) : 5,
|
|
212
|
+
periodDays: Number.isFinite(periodDays) && periodDays > 0 ? periodDays : 7,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** First `^key: value$` match anywhere in the entry content (handles the
|
|
217
|
+
* double-frontmatter shape of agent-authored notes). '' when absent. */
|
|
218
|
+
function _extractHeadField(content, key) {
|
|
219
|
+
const re = new RegExp('^' + key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ':\\s*(.+)$', 'm');
|
|
220
|
+
const m = String(content || '').match(re);
|
|
221
|
+
return m ? m[1].trim() : '';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** True when an entry is itself a consolidation digest — such entries are never
|
|
225
|
+
* re-grouped and never rewritten. */
|
|
226
|
+
function _isDigestEntry(entry) {
|
|
227
|
+
if (!entry) return false;
|
|
228
|
+
if (typeof entry.file === 'string' && entry.file.startsWith(CONSOLIDATION_DIGEST_PREFIX)) return true;
|
|
229
|
+
if (entry._digest) return true;
|
|
230
|
+
const content = entry.content || '';
|
|
231
|
+
if (_extractHeadField(content, CONSOLIDATION_DIGEST_FLAG)) return true;
|
|
232
|
+
if (_extractHeadField(content, 'source') === 'kb-consolidation') return true;
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Classify an entry into a consolidatable "kind", or null when it is not
|
|
238
|
+
* boilerplate we roll up (unique/durable notes are left alone). Kinds target
|
|
239
|
+
* the two dominant balloon sources: agent-failure dumps (keyed by failureClass)
|
|
240
|
+
* and no-op / merge-conflict fix reports.
|
|
241
|
+
* @returns {{ key:string, label:string }|null}
|
|
242
|
+
*/
|
|
243
|
+
function _consolidationKind(entry) {
|
|
244
|
+
const content = entry.content || '';
|
|
245
|
+
const failureClass = _extractHeadField(content, 'failureClass');
|
|
246
|
+
if (failureClass) return { key: `failure-${failureClass}`, label: `${failureClass} failures` };
|
|
247
|
+
const result = _extractHeadField(content, 'result');
|
|
248
|
+
const title = String(entry.title || '').toLowerCase();
|
|
249
|
+
if (/\berror\b/i.test(result) || /agent fail/.test(title)) {
|
|
250
|
+
return { key: 'failure-unknown', label: 'agent failures' };
|
|
251
|
+
}
|
|
252
|
+
if (/\bno-?op\b/.test(title)) return { key: 'noop', label: 'no-op dispatches' };
|
|
253
|
+
if (/merge.?conflict/.test(title)) return { key: 'merge-conflict', label: 'merge-conflict fixes' };
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** ISO date (YYYY-MM-DD) of the start of the `periodDays` bucket an entry falls
|
|
258
|
+
* in, from its authored date (mtime fallback). 'undated' when neither known. */
|
|
259
|
+
function _periodBucket(entry, periodDays) {
|
|
260
|
+
const parsed = typeof entry.date === 'string' && entry.date ? Date.parse(entry.date) : NaN;
|
|
261
|
+
const ms = Number.isFinite(parsed)
|
|
262
|
+
? parsed
|
|
263
|
+
: (Number.isFinite(entry.mtimeMs) && entry.mtimeMs > 0 ? entry.mtimeMs : NaN);
|
|
264
|
+
if (!Number.isFinite(ms)) return 'undated';
|
|
265
|
+
const dayIdx = Math.floor(ms / DAY_MS);
|
|
266
|
+
const startMs = Math.floor(dayIdx / periodDays) * periodDays * DAY_MS;
|
|
267
|
+
return new Date(startMs).toISOString().slice(0, 10);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function _slugify(s) {
|
|
271
|
+
return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'x';
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function _digestFileName(agent, kindKey, period) {
|
|
275
|
+
return `${CONSOLIDATION_DIGEST_PREFIX}${_slugify(agent)}-${_slugify(kindKey)}-${period}.md`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Extract the compact per-note fields that become one digest table row. */
|
|
279
|
+
function _digestRow(entry) {
|
|
280
|
+
const content = entry.content || '';
|
|
281
|
+
const wi = _extractHeadField(content, 'sourceItem')
|
|
282
|
+
|| (content.match(/\bW-[a-z0-9]{6,}/i) || [''])[0]
|
|
283
|
+
|| '';
|
|
284
|
+
const dispatch = _extractHeadField(content, 'dispatchId');
|
|
285
|
+
const failureClass = _extractHeadField(content, 'failureClass');
|
|
286
|
+
let reason = _extractHeadField(content, 'Reason') || _extractHeadField(content, 'reason');
|
|
287
|
+
if (!reason) {
|
|
288
|
+
const rm = content.match(/(?:^|\n)\s*(?:\*\*)?Reason:(?:\*\*)?\s*(.+)/i);
|
|
289
|
+
if (rm) reason = rm[1];
|
|
290
|
+
}
|
|
291
|
+
if (!reason) {
|
|
292
|
+
// First prose line after the last frontmatter block / heading.
|
|
293
|
+
const body = content.replace(/^---\n[\s\S]*?\n---\n?/g, '').replace(/^#.*$/m, '');
|
|
294
|
+
const line = (body.split('\n').find(l => l.trim() && !l.trim().startsWith('#')) || '').trim();
|
|
295
|
+
reason = line;
|
|
296
|
+
}
|
|
297
|
+
reason = String(reason || '').replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim().slice(0, 160);
|
|
298
|
+
return {
|
|
299
|
+
date: entry.date || '',
|
|
300
|
+
wi,
|
|
301
|
+
dispatch,
|
|
302
|
+
failureClass,
|
|
303
|
+
reason: reason || '—',
|
|
304
|
+
file: entry.file,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Parse the `source file` column out of an existing digest so re-runs merge
|
|
309
|
+
* rather than clobber. Returns a Map<sourceFile, rowLine>. */
|
|
310
|
+
function _readExistingDigestRows(digestPath) {
|
|
311
|
+
const rows = new Map();
|
|
312
|
+
const content = safeReadOrNull(digestPath);
|
|
313
|
+
if (!content) return rows;
|
|
314
|
+
for (const line of content.split('\n')) {
|
|
315
|
+
if (!line.startsWith('| ')) continue;
|
|
316
|
+
const cells = line.split('|').map(c => c.trim());
|
|
317
|
+
// columns: '' | date | work item | dispatch | failureClass | reason | source file | ''
|
|
318
|
+
const src = cells[cells.length - 2];
|
|
319
|
+
if (!src || src === 'source file' || /^-+$/.test(src)) continue;
|
|
320
|
+
rows.set(src, line);
|
|
321
|
+
}
|
|
322
|
+
return rows;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function _formatDigestRowLine(r) {
|
|
326
|
+
return `| ${r.date} | ${r.wi || '—'} | ${r.dispatch || '—'} | ${r.failureClass || '—'} | ${r.reason} | ${r.file} |`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function _buildDigestContent(group, rowLines, count) {
|
|
330
|
+
const fm = [
|
|
331
|
+
`category: ${group.category}`,
|
|
332
|
+
`agent: ${group.agent}`,
|
|
333
|
+
`date: ${group.period}`,
|
|
334
|
+
`${CONSOLIDATION_DIGEST_FLAG}: ${new Date().toISOString()}`,
|
|
335
|
+
'source: kb-consolidation',
|
|
336
|
+
`_consolidatedCount: ${count}`,
|
|
337
|
+
].join('\n');
|
|
338
|
+
const header = `# Consolidated: ${group.agent} — ${group.kind.label} (from ${group.period})`;
|
|
339
|
+
const intro = `Rolled up ${count} near-identical ${group.kind.label} note(s) into this single digest. `
|
|
340
|
+
+ 'Originals were archived to `knowledge/_swept/` (recoverable for 30 days). '
|
|
341
|
+
+ 'Each row is one collapsed note; recover a specific one from `_swept/` by its `source file`.';
|
|
342
|
+
const table = [
|
|
343
|
+
'| date | work item | dispatch | failureClass | reason | source file |',
|
|
344
|
+
'|------|-----------|----------|--------------|--------|-------------|',
|
|
345
|
+
...rowLines,
|
|
346
|
+
].join('\n');
|
|
347
|
+
return `---\n${fm}\n---\n\n${header}\n\n${intro}\n\n${table}\n`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Structural consolidation pass. Groups boilerplate transient-category notes by
|
|
352
|
+
* (category, agent, kind, time-bucket) and collapses any group of at least
|
|
353
|
+
* `minGroupSize` into a single recoverable digest, archiving the originals.
|
|
354
|
+
* Digests, durable categories, pinned entries (already filtered upstream), and
|
|
355
|
+
* unclassifiable/unique notes are left untouched.
|
|
356
|
+
*
|
|
357
|
+
* @returns {{ survivors:object[], consolidated:number, groupsCollapsed:number, digestsWritten:number }}
|
|
358
|
+
*/
|
|
359
|
+
function _structuralConsolidate(manifest, opts = {}) {
|
|
360
|
+
const cfg = _resolveConsolidationConfig(opts);
|
|
361
|
+
if (!cfg.enabled) {
|
|
362
|
+
return { survivors: manifest.slice(), consolidated: 0, groupsCollapsed: 0, digestsWritten: 0 };
|
|
363
|
+
}
|
|
364
|
+
const groups = new Map(); // groupKey → { agent, category, kind, period, entries[] }
|
|
365
|
+
const survivors = [];
|
|
366
|
+
for (const e of manifest) {
|
|
367
|
+
if (!cfg.categories.has(e.category) || _isDigestEntry(e)) { survivors.push(e); continue; }
|
|
368
|
+
const kind = _consolidationKind(e);
|
|
369
|
+
if (!kind) { survivors.push(e); continue; }
|
|
370
|
+
const agent = e.agent || 'unknown';
|
|
371
|
+
const period = _periodBucket(e, cfg.periodDays);
|
|
372
|
+
const groupKey = `${e.category}::${agent}::${kind.key}::${period}`;
|
|
373
|
+
if (!groups.has(groupKey)) groups.set(groupKey, { agent, category: e.category, kind, period, entries: [] });
|
|
374
|
+
groups.get(groupKey).entries.push(e);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let consolidated = 0, groupsCollapsed = 0, digestsWritten = 0;
|
|
378
|
+
for (const g of groups.values()) {
|
|
379
|
+
if (g.entries.length < cfg.minGroupSize) { survivors.push(...g.entries); continue; }
|
|
380
|
+
g.entries.sort((a, b) => (b.date || '').localeCompare(a.date || '') || (b.mtimeMs - a.mtimeMs));
|
|
381
|
+
|
|
382
|
+
if (opts.dryRun) {
|
|
383
|
+
consolidated += g.entries.length;
|
|
384
|
+
groupsCollapsed++;
|
|
385
|
+
digestsWritten++;
|
|
386
|
+
// Nothing archived in dryRun — the originals remain on disk.
|
|
387
|
+
survivors.push(...g.entries);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const digestFile = _digestFileName(g.agent, g.kind.key, g.period);
|
|
392
|
+
const digestPath = path.join(KB_DIR, g.category, digestFile);
|
|
393
|
+
// Merge with any prior digest for this exact group so repeated sweeps
|
|
394
|
+
// accumulate rows instead of clobbering earlier ones.
|
|
395
|
+
const merged = _readExistingDigestRows(digestPath);
|
|
396
|
+
for (const e of g.entries) {
|
|
397
|
+
const r = _digestRow(e);
|
|
398
|
+
merged.set(r.file, _formatDigestRowLine(r));
|
|
399
|
+
}
|
|
400
|
+
const rowLines = [...merged.values()];
|
|
401
|
+
const digestContent = _buildDigestContent(g, rowLines, rowLines.length);
|
|
402
|
+
try {
|
|
403
|
+
const dir = path.dirname(digestPath);
|
|
404
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
405
|
+
safeWrite(digestPath, digestContent);
|
|
406
|
+
digestsWritten++;
|
|
407
|
+
} catch (err) {
|
|
408
|
+
log('warn', `[kb-sweep] consolidate write ${g.category}/${digestFile}: ${err.message}`);
|
|
409
|
+
survivors.push(...g.entries); // write failed — keep originals visible
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
let collapsed = 0;
|
|
413
|
+
for (const e of g.entries) {
|
|
414
|
+
const fp = path.join(KB_DIR, e.category, e.file);
|
|
415
|
+
if (!fs.existsSync(fp)) continue;
|
|
416
|
+
if (_archiveKbFile(fp, `structurally consolidated into ${g.category}/${digestFile}`)) { consolidated++; collapsed++; }
|
|
417
|
+
}
|
|
418
|
+
if (collapsed > 0) groupsCollapsed++;
|
|
419
|
+
}
|
|
420
|
+
return { survivors, consolidated, groupsCollapsed, digestsWritten };
|
|
421
|
+
}
|
|
422
|
+
|
|
171
423
|
/** Batched LLM sweep — finds within-batch dupes, reclassifies, removes stale. */
|
|
172
424
|
async function _llmBatchSweep(manifest, callLLM, trackEngineUsage, opts = {}) {
|
|
173
425
|
const plan = { duplicates: [], reclassify: [], remove: [] };
|
|
@@ -252,6 +504,9 @@ ${body}`;
|
|
|
252
504
|
|
|
253
505
|
const candidates = [];
|
|
254
506
|
for (const e of survivors) {
|
|
507
|
+
// Never rewrite a consolidation digest — the rewrite template would destroy
|
|
508
|
+
// its roll-up table.
|
|
509
|
+
if (_isDigestEntry(e)) continue;
|
|
255
510
|
const fp = path.join(KB_DIR, e.category, e.file);
|
|
256
511
|
const content = safeRead(fp);
|
|
257
512
|
if (content == null) continue;
|
|
@@ -540,6 +795,9 @@ async function _runKbSweepImpl(opts = {}) {
|
|
|
540
795
|
llmDuplicatesArchived: 0,
|
|
541
796
|
staleRemoved: 0,
|
|
542
797
|
ageExpired: 0,
|
|
798
|
+
notesConsolidated: 0,
|
|
799
|
+
consolidationGroups: 0,
|
|
800
|
+
digestsWritten: 0,
|
|
543
801
|
reclassified: 0,
|
|
544
802
|
rewritten: 0,
|
|
545
803
|
rewriteBytesBefore: 0,
|
|
@@ -579,27 +837,41 @@ async function _runKbSweepImpl(opts = {}) {
|
|
|
579
837
|
const { survivors: afterHash, archived: hashArchived } = _hashDedup(manifest, opts);
|
|
580
838
|
summary.hashDuplicatesArchived = hashArchived;
|
|
581
839
|
|
|
840
|
+
// 1.5. Structural consolidation (W-mr973knu) — PRIMARY balloon fix. Collapse
|
|
841
|
+
// large groups of near-identical boilerplate notes (agent-failure dumps, no-op
|
|
842
|
+
// / merge-conflict reports) into one recoverable digest per (agent, kind,
|
|
843
|
+
// week). Runs on recent entries too, so it shrinks the KB continuously rather
|
|
844
|
+
// than merely aging files out. Downstream passes operate on the survivors so
|
|
845
|
+
// the LLM/rewrite passes no longer waste calls on the collapsed notes.
|
|
846
|
+
const consolidateResult = _structuralConsolidate(afterHash, opts);
|
|
847
|
+
summary.notesConsolidated = consolidateResult.consolidated;
|
|
848
|
+
summary.consolidationGroups = consolidateResult.groupsCollapsed;
|
|
849
|
+
summary.digestsWritten = consolidateResult.digestsWritten;
|
|
850
|
+
const afterConsolidate = consolidateResult.survivors.filter(
|
|
851
|
+
e => e._digest || fs.existsSync(path.join(KB_DIR, e.category, e.file)),
|
|
852
|
+
);
|
|
853
|
+
|
|
582
854
|
// 2. LLM batch sweep — within-batch dupes + reclassify + remove stale
|
|
583
855
|
// Only runs against survivors, but we need indices that match the LIST sent to the LLM
|
|
584
|
-
const llmManifest =
|
|
856
|
+
const llmManifest = afterConsolidate;
|
|
585
857
|
const plan = await _llmBatchSweep(llmManifest, callLLM, trackEngineUsage, opts);
|
|
586
858
|
const llmActions = _applyLlmPlan(plan, llmManifest, opts);
|
|
587
859
|
summary.llmDuplicatesArchived = llmActions.merged;
|
|
588
860
|
summary.staleRemoved = llmActions.removed;
|
|
589
861
|
summary.reclassified = llmActions.reclassified;
|
|
590
862
|
|
|
591
|
-
// 2.5. Age-based TTL expiry (W-mr48yth5) —
|
|
592
|
-
// older than their per-category TTL to
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
const survivingAfterLlm =
|
|
863
|
+
// 2.5. Age-based TTL expiry (W-mr48yth5) — SECONDARY safety net. Archives
|
|
864
|
+
// transient-category entries older than their per-category TTL to
|
|
865
|
+
// knowledge/_swept/. Structural consolidation above is the primary reducer;
|
|
866
|
+
// this catches genuinely stale one-offs that never clustered into a digest.
|
|
867
|
+
// Durable categories (no TTL) are untouched.
|
|
868
|
+
const survivingAfterLlm = afterConsolidate.filter(e => fs.existsSync(path.join(KB_DIR, e.category, e.file)));
|
|
597
869
|
const ageResult = _expireByAge(survivingAfterLlm, opts);
|
|
598
870
|
summary.ageExpired = ageResult.expired;
|
|
599
871
|
|
|
600
872
|
// 3. Per-entry rewrite (compress + normalize)
|
|
601
|
-
// Filter to entries that survived hash + LLM + age passes (still on disk)
|
|
602
|
-
const stillOnDisk =
|
|
873
|
+
// Filter to entries that survived hash + consolidation + LLM + age passes (still on disk)
|
|
874
|
+
const stillOnDisk = afterConsolidate.filter(e => fs.existsSync(path.join(KB_DIR, e.category, e.file)));
|
|
603
875
|
const rewriteResult = await _rewritePass(stillOnDisk, callLLM, trackEngineUsage, opts);
|
|
604
876
|
summary.rewritten = rewriteResult.processed;
|
|
605
877
|
summary.rewriteBytesBefore = rewriteResult.bytesBefore;
|
|
@@ -620,7 +892,7 @@ async function _runKbSweepImpl(opts = {}) {
|
|
|
620
892
|
}
|
|
621
893
|
|
|
622
894
|
summary.durationMs = Date.now() - t0;
|
|
623
|
-
summary.summary = `${summary.hashDuplicatesArchived} hash-dup, ${summary.llmDuplicatesArchived} llm-dup, ${summary.staleRemoved} stale, ${summary.ageExpired} age-expired, ${summary.reclassified} reclassified, ${summary.rewritten} rewritten (${(summary.bytesBefore - summary.bytesAfter).toLocaleString()} bytes saved)`;
|
|
895
|
+
summary.summary = `${summary.hashDuplicatesArchived} hash-dup, ${summary.notesConsolidated} consolidated (${summary.digestsWritten} digests), ${summary.llmDuplicatesArchived} llm-dup, ${summary.staleRemoved} stale, ${summary.ageExpired} age-expired, ${summary.reclassified} reclassified, ${summary.rewritten} rewritten (${(summary.bytesBefore - summary.bytesAfter).toLocaleString()} bytes saved)`;
|
|
624
896
|
|
|
625
897
|
if (!opts.dryRun) {
|
|
626
898
|
try { shared.mutateKbSwept(() => ({ timestamp: ts(), summary: summary.summary, detail: summary })); } catch { /* ignore */ }
|
|
@@ -781,6 +1053,17 @@ module.exports = {
|
|
|
781
1053
|
_entryAgeMs,
|
|
782
1054
|
_resolveTtlByCategory,
|
|
783
1055
|
_archiveKbFile,
|
|
1056
|
+
_expireByAge,
|
|
1057
|
+
_entryAgeMs,
|
|
1058
|
+
_resolveTtlByCategory,
|
|
1059
|
+
_structuralConsolidate,
|
|
1060
|
+
_resolveConsolidationConfig,
|
|
1061
|
+
_consolidationKind,
|
|
1062
|
+
_periodBucket,
|
|
1063
|
+
_digestRow,
|
|
1064
|
+
_isDigestEntry,
|
|
1065
|
+
_extractHeadField,
|
|
1066
|
+
CONSOLIDATION_DIGEST_PREFIX,
|
|
784
1067
|
COMPRESS_THRESHOLD_BYTES,
|
|
785
1068
|
SWEPT_FLAG_KEY,
|
|
786
1069
|
};
|
package/engine/live-checkout.js
CHANGED
|
@@ -39,25 +39,30 @@
|
|
|
39
39
|
* NO fast-forward, NO reset). The operator owns the checkout
|
|
40
40
|
* state per the live-checkout contract; the engine never
|
|
41
41
|
* overwrites local commits.
|
|
42
|
-
* b. rev-parse fails → branch is new → fork it.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
42
|
+
* b. rev-parse fails → branch is new → fork it. Two ordered guards run
|
|
43
|
+
* before the fork:
|
|
44
|
+
* i. WRONG-BASE GUARD (W-mr3lunnq000o9f41) — UNLESS `allowNonMainBase`
|
|
45
|
+
* is set (PR-targeted fixes / shared-branch continuations opt out):
|
|
46
|
+
* if HEAD's branch is NOT the project base (`mainRef`), forking off
|
|
47
|
+
* it would silently bake every commit already on that branch into
|
|
48
|
+
* the new branch and its PR (the ADO PR 5411214 scope-contamination
|
|
49
|
+
* incident). Recovery is LOCAL-ONLY (issue #226's no-forced-fetch
|
|
50
|
+
* guarantee): if `refs/heads/<mainRef>` exists locally, PLAIN-checkout
|
|
51
|
+
* it (no fetch/reset/--force) and fork off it; else (optionally after
|
|
52
|
+
* opt-in auto-base-repair materializes a local `<mainRef>` from the
|
|
53
|
+
* existing `refs/remotes/origin/<mainRef>` ref, W-mr3lunnq000o9f41)
|
|
54
|
+
* FAIL CLOSED with { ok:false, reason:'wrong-base' }.
|
|
55
|
+
* ii. STALE-BASE GUARD (W-mr98op8w000ma4ad) — runs once HEAD is confirmed
|
|
56
|
+
* on `mainRef` (either it started there, or guard i left it there):
|
|
57
|
+
* if the local `mainRef` tip has diverged from `origin/<mainRef>`
|
|
58
|
+
* with unpushed commits, bail with { ok:false, reason:'stale-main', ... }
|
|
59
|
+
* rather than fork a contaminated base (see RETURN SHAPES).
|
|
60
|
+
* When both guards pass, `git checkout -b <branchName>` is created from
|
|
61
|
+
* the CURRENT HEAD (NOT origin/<mainRef>). In live mode the operator's
|
|
62
|
+
* checked-out HEAD IS the baseline; branching off origin/<mainRef> forces
|
|
63
|
+
* on-demand blob fetching on blobless partial clones (Scalar/GVFS-managed
|
|
64
|
+
* ADO repos), which fails in headless mode because the GVFS cache server
|
|
65
|
+
* receives no auth and credential.helper is disabled. See issue #226.
|
|
61
66
|
* 5. Returns { ok:true, branch, created:boolean, originalRef, originalRefType }.
|
|
62
67
|
*
|
|
63
68
|
* RETURN SHAPES:
|
|
@@ -80,6 +85,19 @@
|
|
|
80
85
|
* (W-mr28h2j2000y0de1) — the target branch is already checked out in another
|
|
81
86
|
* worktree; `git checkout <branch>` refuses deterministically. Non-retryable
|
|
82
87
|
* at the caller — a human/engine must remove or reassign the other worktree.
|
|
88
|
+
* { ok:false, reason:'stale-main', mainRef, ahead, localMainSha, originMainSha, branch, originalRef, originalRefType }
|
|
89
|
+
* (W-mr98op8w000ma4ad — STALE-BASE GUARD) — a NEW branch was about to be
|
|
90
|
+
* forked off the current HEAD while HEAD sits on the project base ref
|
|
91
|
+
* (mainRef), but the LOCAL mainRef tip has diverged from origin/<mainRef>
|
|
92
|
+
* with `ahead` unpushed commit(s). Forking would silently inherit that
|
|
93
|
+
* committed contamination into the new branch and its PR. Detected without a
|
|
94
|
+
* fetch (issue #226) by comparing local `refs/heads/<mainRef>` against the
|
|
95
|
+
* EXISTING `refs/remotes/origin/<mainRef>` ref via
|
|
96
|
+
* `git rev-list --count origin/<mainRef>..<mainRef>`. The guard is SKIPPED
|
|
97
|
+
* (proceeds normally) when HEAD is not on mainRef, when the origin/<mainRef>
|
|
98
|
+
* remote-tracking ref is absent (local-only/no-remote repo), or when the
|
|
99
|
+
* rev-list count cannot be computed (transient). Non-retryable at the caller
|
|
100
|
+
* — a human must reconcile the local base branch; the engine never resets it.
|
|
83
101
|
*
|
|
84
102
|
* NOTE: `mainRef` is still accepted (and validated) for caller-contract
|
|
85
103
|
* stability, but it is NOT used to seed the new branch in live mode — HEAD
|
|
@@ -565,13 +583,32 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
565
583
|
return { ok: false, reason: 'dirty', dirtyFiles, branchInfo };
|
|
566
584
|
}
|
|
567
585
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
586
|
+
// W-mrav3u0c0008ce38 — live-checkout WI types that never push a branch
|
|
587
|
+
// (setup/test/verify live-validation dispatches on checkoutMode:'live'
|
|
588
|
+
// or hybrid liveValidation projects) leave `origin/<branchName>`
|
|
589
|
+
// unresolvable, so a bare `reset --hard origin/<branchName>` fails every
|
|
590
|
+
// time with "ambiguous argument … unknown revision" and the tree stays
|
|
591
|
+
// dirty forever. Probe `origin/<branchName>` first (after the fetch, so
|
|
592
|
+
// it reflects the latest remote state); only fall back to
|
|
593
|
+
// `origin/<mainRef>` when that branch genuinely doesn't exist upstream.
|
|
594
|
+
// PR-continuation flows (fix/implement WIs resuming an existing pushed
|
|
595
|
+
// branch) keep resetting to `origin/<branchName>` exactly as before.
|
|
571
596
|
let resetOk = true;
|
|
597
|
+
let resetTarget = `origin/${branchName}`;
|
|
572
598
|
try {
|
|
573
599
|
await git(['fetch', 'origin'], baseOpts);
|
|
574
|
-
|
|
600
|
+
try {
|
|
601
|
+
await git(['rev-parse', '--verify', resetTarget], baseOpts);
|
|
602
|
+
} catch {
|
|
603
|
+
resetTarget = `origin/${mainRef}`;
|
|
604
|
+
if (typeof log === 'function') {
|
|
605
|
+
log(`live-checkout auto-reset: origin/${branchName} does not exist (never pushed) — falling back to '${resetTarget}'`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (typeof log === 'function') {
|
|
609
|
+
log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) on '${branchName}' via fetch + reset --hard ${resetTarget}`);
|
|
610
|
+
}
|
|
611
|
+
await git(['reset', '--hard', resetTarget], baseOpts);
|
|
575
612
|
} catch (e) {
|
|
576
613
|
resetOk = false;
|
|
577
614
|
if (typeof log === 'function') {
|
|
@@ -613,7 +650,7 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
613
650
|
'',
|
|
614
651
|
`⚠️ The live-checkout tree at \`${localPath}\` was dirty at dispatch time and`,
|
|
615
652
|
'`liveCheckoutAutoReset` is enabled, so it was force-reset to',
|
|
616
|
-
|
|
653
|
+
`\`${resetTarget}\`. **The following uncommitted changes were DISCARDED**`,
|
|
617
654
|
'(recover from `git reflog` / `git fsck --lost-found` if needed):',
|
|
618
655
|
'',
|
|
619
656
|
'```',
|
|
@@ -949,6 +986,102 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
949
986
|
}
|
|
950
987
|
}
|
|
951
988
|
|
|
989
|
+
|
|
990
|
+
// ── Step 4d: STALE-BASE GUARD (W-mr98op8w000ma4ad). ───────────────────
|
|
991
|
+
// We are about to fork a NEW branch off the CURRENT HEAD. In the common
|
|
992
|
+
// live-mode case HEAD sits on the project base ref (mainRef). Step 1 (dirty
|
|
993
|
+
// check) and Step 2 (mid-operation/detached preflight) both passed, but a
|
|
994
|
+
// CLEAN tree can still hide COMMITTED contamination: a prior/parallel
|
|
995
|
+
// dispatch (or a human) may have left extra commits on the local mainRef
|
|
996
|
+
// branch that were never pushed to origin/<mainRef>. `git checkout -b` would
|
|
997
|
+
// then silently inherit those commits into the new branch (and its PR) — the
|
|
998
|
+
// exact scope-contamination class this guard prevents (original incident ADO
|
|
999
|
+
// PR 5411214; recurrence on AB#12016662 / ADO PR 5419146, ~20 unrelated OCM
|
|
1000
|
+
// files baked into a 2-file a11y fix). This survives the dirty check (the
|
|
1001
|
+
// contamination is COMMITTED, not uncommitted) and — unlike a HEAD parked on
|
|
1002
|
+
// some OTHER branch — cannot be spotted by a headBranch !== mainRef check,
|
|
1003
|
+
// because here headBranch IS mainRef.
|
|
1004
|
+
//
|
|
1005
|
+
// Detect it by comparing the local mainRef tip against the EXISTING
|
|
1006
|
+
// origin/<mainRef> remote-tracking ref (NO fetch — issue #226's
|
|
1007
|
+
// no-forced-fetch guarantee): any commit reachable from local mainRef but not
|
|
1008
|
+
// from origin/<mainRef> (`git rev-list --count origin/<mainRef>..<mainRef>` >
|
|
1009
|
+
// 0) means the base is contaminated with unpushed commits. Local mainRef
|
|
1010
|
+
// being merely BEHIND origin (fewer commits) is fine — that just yields a
|
|
1011
|
+
// branch based on an older tip, not contamination — so we count only the
|
|
1012
|
+
// local-ahead side, not strict equality.
|
|
1013
|
+
//
|
|
1014
|
+
// Scope: only when HEAD is actually on mainRef (that IS the base we fork
|
|
1015
|
+
// off). If HEAD is on some other branch the operator/engine deliberately
|
|
1016
|
+
// parked it there (e.g. the orphan-branch self-heal above) — out of scope.
|
|
1017
|
+
//
|
|
1018
|
+
// AMBIGUITY / local-only repos: if the origin/<mainRef> remote-tracking ref
|
|
1019
|
+
// does not exist locally we CANNOT compare without fetching, which #226
|
|
1020
|
+
// forbids. Rather than fail closed on legitimate local-only / no-remote repos
|
|
1021
|
+
// (which have no origin ref by design and for which forking off HEAD is the
|
|
1022
|
+
// only and expected behavior), we SKIP the check and log. Failing closed
|
|
1023
|
+
// there would regress those setups; the contamination class this targets only
|
|
1024
|
+
// exists when an origin baseline is present to diverge from. A transient
|
|
1025
|
+
// rev-list failure (origin ref present but the count could not be computed)
|
|
1026
|
+
// likewise proceeds without the check rather than converting a transient git
|
|
1027
|
+
// hiccup into a permanent non-retryable refusal.
|
|
1028
|
+
if (curBranch && curBranch === mainRef) {
|
|
1029
|
+
let originMainSha = '';
|
|
1030
|
+
try {
|
|
1031
|
+
const originRaw = await git(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${mainRef}`], baseOpts);
|
|
1032
|
+
originMainSha = (typeof originRaw === 'string' ? originRaw : '').trim();
|
|
1033
|
+
} catch {
|
|
1034
|
+
originMainSha = ''; // remote-tracking ref absent → ambiguous, skip below
|
|
1035
|
+
}
|
|
1036
|
+
if (originMainSha) {
|
|
1037
|
+
let aheadCount = -1;
|
|
1038
|
+
try {
|
|
1039
|
+
const cntRaw = await git(['rev-list', '--count', `refs/remotes/origin/${mainRef}..${mainRef}`], baseOpts);
|
|
1040
|
+
aheadCount = parseInt((typeof cntRaw === 'string' ? cntRaw : '').trim(), 10);
|
|
1041
|
+
if (Number.isNaN(aheadCount)) aheadCount = -1;
|
|
1042
|
+
} catch {
|
|
1043
|
+
aheadCount = -1; // rev-list failed → transient/ambiguous; proceed without the check
|
|
1044
|
+
}
|
|
1045
|
+
if (aheadCount > 0) {
|
|
1046
|
+
let localMainSha = '';
|
|
1047
|
+
try {
|
|
1048
|
+
const localRaw = await git(['rev-parse', '--verify', '--quiet', `refs/heads/${mainRef}`], baseOpts);
|
|
1049
|
+
localMainSha = (typeof localRaw === 'string' ? localRaw : '').trim();
|
|
1050
|
+
} catch { /* best-effort — informational only */ }
|
|
1051
|
+
logFn(
|
|
1052
|
+
`[live-checkout] STALE-BASE GUARD: refusing to fork '${branchName}' off local '${mainRef}' — ` +
|
|
1053
|
+
`local '${mainRef}' is ${aheadCount} commit(s) ahead of 'origin/${mainRef}' (unpushed/contaminated base). ` +
|
|
1054
|
+
`A human must reconcile the local base branch before dispatch; the engine never resets it for you.`,
|
|
1055
|
+
'warn'
|
|
1056
|
+
);
|
|
1057
|
+
return {
|
|
1058
|
+
ok: false,
|
|
1059
|
+
reason: 'stale-main',
|
|
1060
|
+
mainRef,
|
|
1061
|
+
ahead: aheadCount,
|
|
1062
|
+
localMainSha,
|
|
1063
|
+
originMainSha,
|
|
1064
|
+
branch: branchName,
|
|
1065
|
+
originalRef,
|
|
1066
|
+
originalRefType,
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
if (aheadCount < 0) {
|
|
1070
|
+
logFn(
|
|
1071
|
+
`[live-checkout] STALE-BASE GUARD: could not compute divergence of local '${mainRef}' vs 'origin/${mainRef}' ` +
|
|
1072
|
+
`(rev-list failed); proceeding without the base-divergence check.`,
|
|
1073
|
+
'debug'
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
} else {
|
|
1077
|
+
logFn(
|
|
1078
|
+
`[live-checkout] STALE-BASE GUARD: no local 'origin/${mainRef}' remote-tracking ref — ` +
|
|
1079
|
+
`skipping base-divergence check (local-only/no-remote repo; #226 no-fetch).`,
|
|
1080
|
+
'debug'
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
952
1085
|
// Create from HEAD — now guaranteed to be the project base branch unless
|
|
953
1086
|
// allowNonMainBase bypassed the guard. NOT origin/<mainRef>: in live mode the
|
|
954
1087
|
// operator's HEAD is the baseline, and seeding from a remote ref forces
|
package/engine/metrics-store.js
CHANGED
|
Binary file
|
package/engine/shared.js
CHANGED
|
@@ -3193,6 +3193,24 @@ const ENGINE_DEFAULTS = {
|
|
|
3193
3193
|
'build-reports': 14,
|
|
3194
3194
|
'reviews': 30,
|
|
3195
3195
|
},
|
|
3196
|
+
// W-mr973knu — structural consolidation for the KB sweep (PRIMARY balloon
|
|
3197
|
+
// fix; TTL above is the secondary safety net). The sweep groups boilerplate
|
|
3198
|
+
// transient-category notes by (category, agent, kind, time-bucket) — where
|
|
3199
|
+
// kind is the agent-failure `failureClass`, or a no-op / merge-conflict fix
|
|
3200
|
+
// report — and rolls any group of at least `minGroupSize` notes into a single
|
|
3201
|
+
// recoverable digest (`_digest-<agent>-<kind>-<period>.md`), archiving the
|
|
3202
|
+
// originals to knowledge/_swept/. Unlike TTL expiry this collapses RECENT
|
|
3203
|
+
// duplication too, so the KB shrinks continuously instead of only aging out.
|
|
3204
|
+
// Only `categories` listed here are consolidated; durable categories
|
|
3205
|
+
// (architecture, conventions) and per-agent memory are never touched.
|
|
3206
|
+
// config.engine.kbConsolidation is merged over these defaults; set
|
|
3207
|
+
// `enabled: false` or `categories: []` to disable.
|
|
3208
|
+
kbConsolidation: {
|
|
3209
|
+
enabled: true,
|
|
3210
|
+
categories: ['project-notes', 'build-reports', 'reviews'],
|
|
3211
|
+
minGroupSize: 5, // collapse a group only once it reaches this many near-duplicate notes
|
|
3212
|
+
periodDays: 7, // one digest per (agent, kind) per this many days
|
|
3213
|
+
},
|
|
3196
3214
|
// W-mqtvnnj1000357fa — fleet-wide fallback for live-checkout auto-stash. When
|
|
3197
3215
|
// true, a dirty live-checkout tree is `git stash push --include-untracked`'d
|
|
3198
3216
|
// before dispatch instead of failing with FAILURE_CLASS.LIVE_CHECKOUT_DIRTY,
|
|
@@ -4698,12 +4716,19 @@ function mutateWatches(mutator) {
|
|
|
4698
4716
|
function mutateMetrics(mutator) {
|
|
4699
4717
|
const metricsPath = path.join(MINIONS_DIR, 'engine', 'metrics.json');
|
|
4700
4718
|
const store = require('./metrics-store');
|
|
4719
|
+
// W-mradg74d000rfd45 — the JSON mirror is now written INSIDE
|
|
4720
|
+
// applyMetricsMutation's SQL transaction (before commit), not here
|
|
4721
|
+
// after the fact. Mirroring post-commit left a cross-process window
|
|
4722
|
+
// where a sibling process (dashboard vs. engine — separate Node
|
|
4723
|
+
// processes, each with its own in-memory mirror-hash cache) could
|
|
4724
|
+
// observe the newly committed SQL rows before the JSON file caught up
|
|
4725
|
+
// and read stale/missing metrics off the sidecar. See
|
|
4726
|
+
// metrics-store.js#applyMetricsMutation for the full rationale.
|
|
4701
4727
|
const { wrote, result } = store.applyMetricsMutation((m) => {
|
|
4702
4728
|
if (!m || typeof m !== 'object') m = {};
|
|
4703
4729
|
return mutator(m) || m;
|
|
4704
|
-
});
|
|
4730
|
+
}, { mirrorPath: metricsPath });
|
|
4705
4731
|
if (wrote) {
|
|
4706
|
-
try { store._mirrorJsonFromSql(metricsPath); } catch { /* mirror best-effort */ }
|
|
4707
4732
|
try { require('./db-events').emitStateEvent('metrics'); } catch { /* optional */ }
|
|
4708
4733
|
}
|
|
4709
4734
|
return result;
|
|
@@ -4877,6 +4902,7 @@ const FAILURE_CLASS = {
|
|
|
4877
4902
|
LIVE_CHECKOUT_MID_OPERATION: 'live-checkout-mid-operation', // P-a7f3c1d9 (live-checkout dispatch mode): spawnAgent could not switch/create the target branch in project.localPath because the operator tree is mid-operation — an in-progress merge/rebase/cherry-pick/bisect or a detached HEAD. Distinct from LIVE_CHECKOUT_DIRTY (uncommitted changes): here the tree may be clean but the branch op cannot proceed. Engine refuses to spawn (it never runs `git reset`/`git clean`/`git rebase --abort` against the operator tree). Non-retryable — operator must finish or abort the in-progress operation, or checkout a branch, before re-dispatch.
|
|
4878
4903
|
LIVE_CHECKOUT_BLOB_FETCH: 'live-checkout-blob-fetch', // PL-live-checkout-reliability-hardening (live-checkout dispatch mode): `git checkout <existing-branch>` in project.localPath failed because the tree could not be materialized — on a Scalar/GVFS-managed ADO partial (blobless) clone, switching onto a branch whose tree differs from HEAD hydrates the changed paths' blobs through the GVFS cache server (`*.gvfscache.dev.azure.com`), a different endpoint than the main git remote that receives NO auth in the headless engine shell, so the fetch fails DETERMINISTICALLY. Distinct from LIVE_CHECKOUT_FAILED (transient) because retrying reproduces it identically — surfaced once as an operator-actionable refusal instead of retry-storming to the cap. Non-retryable — the operator hydrates the branch once with their own credentials (`git checkout <branch>` interactively, or `scalar prefetch` / `git -C <repo> fetch`), then re-dispatches. The engine NEVER forces, resets, cleans, or stashes the operator tree, and best-effort switches HEAD back to the original ref so the tree is not stranded half-populated.
|
|
4879
4904
|
LIVE_CHECKOUT_WORKTREE_CONFLICT: 'live-checkout-worktree-conflict', // W-mr28h2j2000y0de1 (live-checkout dispatch mode): `git checkout <existing-branch>` in project.localPath failed because that exact branch is ALSO checked out in a SECOND worktree elsewhere (a leftover from a prior isolated-worktree dispatch, a manually-created worktree, or a stale worktree left by a checkoutMode change). git refuses deterministically with `fatal: '<branch>' is already used by worktree at '<path>'`. Distinct from LIVE_CHECKOUT_FAILED (transient) because this is a STRUCTURAL conflict — it does NOT clear on retry, ever, until a human or the engine removes/reassigns the other worktree; retrying just reproduces it identically and burns maxRetries. Non-retryable — the operator either `git worktree remove <path>` (freeing the branch) if that worktree is stale, or finishes/commits/pushes from it directly if it holds real WIP. The engine NEVER forces, resets, cleans, or stashes the operator tree, and best-effort switches HEAD back to the original ref so the tree is not stranded.
|
|
4905
|
+
LIVE_CHECKOUT_STALE_BASE: 'live-checkout-stale-base', // W-mr98op8w000ma4ad (live-checkout dispatch mode, STALE-BASE GUARD): spawnAgent was about to fork a NEW branch off the operator's current HEAD while HEAD sits on the project base ref (mainRef), but the LOCAL mainRef tip has DIVERGED from origin/<mainRef> with unpushed commit(s) (`git rev-list --count origin/<mainRef>..<mainRef>` > 0). Forking would silently inherit that COMMITTED contamination into the new branch and its PR — the exact scope-contamination class first seen on ADO PR 5411214 and recurring on AB#12016662 / ADO PR 5419146. Survives the dirty check (contamination is committed, not uncommitted) and cannot be caught by a headBranch !== mainRef check (headBranch IS mainRef here). Detected WITHOUT a fetch (issue #226) via the existing origin/<mainRef> remote-tracking ref. Distinct from LIVE_CHECKOUT_FAILED (transient) because the contaminated local base does NOT change on retry — it reproduces identically until a human reconciles the local base branch. Non-retryable — the engine NEVER resets/cleans the operator's local mainRef; the operator must reconcile it (push/reset/rebase their own way), then re-dispatch. The guard SKIPS (proceeds normally) when HEAD is not on mainRef, when no local origin/<mainRef> ref exists (local-only/no-remote repo), or when the divergence count cannot be computed.
|
|
4880
4906
|
LIVE_CHECKOUT_WRONG_BASE: 'live-checkout-wrong-base', // W-mr3lunnq000o9f41 (live-checkout dispatch mode): a NEW-branch fork was requested but the operator's checkout HEAD is on an unrelated topic/feature branch, NOT the project base (mainRef), and prepareLiveCheckout could not switch to a LOCAL base ref (no `refs/heads/<mainRef>`, or the local checkout itself failed). Forking off the stale HEAD would silently bake every commit already on that branch into the new branch AND its PR — committed-history scope contamination that survives the dirty/mid-operation preflights (the ADO PR 5411214 incident: ~22 unrelated OCM files, author-disavowed). The engine never fetches origin/<mainRef> in live mode (issue #226) so it cannot self-heal a missing local base. Non-retryable — the operator must `git checkout <mainRef>` in the operator checkout before re-dispatch. Does NOT fire for PR-targeted fixes or shared-branch continuations (those pass allowNonMainBase, intentionally continuing a non-main branch), nor when HEAD is already on mainRef, nor when a local mainRef ref exists (the guard switches to it and forks cleanly). OPT-IN AUTO-RECOVERY (`liveCheckoutAutoBaseRepair`, per-project > fleet-wide, default OFF): when enabled AND a `refs/remotes/origin/<mainRef>` remote-tracking ref exists, the guard materializes a local `<mainRef>` branch from it (a LOCAL ref op — still no `git fetch`, issue #226 preserved; origin/<mainRef> IS the base so no contamination) and forks cleanly instead of failing — eliminating the manual `git checkout <mainRef>` step. Still fails closed when neither a local base nor an origin-tracking base ref exists, or when the base checkout itself fails/hangs.
|
|
4881
4907
|
INVALID_WORKDIR: 'invalid-workdir', // P-714ef144: dispatch carried a meta.workdir override that failed validation — non-string, absolute path, drive-letter prefix, null byte, ".." segment, or post-resolve containment escape against project.localPath / worktree root. Engine refuses to spawn (the subpath would either be unreachable on disk or point outside the operator's allowed surface). Non-retryable — operator must fix the WI's meta.workdir before re-dispatch. Inbox alert lists the offending value + the resolved-vs-base mismatch.
|
|
4882
4908
|
MODEL_UNAVAILABLE: 'model-unavailable', // W-mpg6isvy000xca4d: requested model returned overloaded_error / 503 / service_unavailable. Retriable — engine swaps in the runtime-appropriate fallback model on next spawn (Claude leans on --fallback-model already plumbed; Copilot overrides --model with engine.copilotFallbackModel).
|
package/engine.js
CHANGED
|
@@ -2934,6 +2934,83 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2934
2934
|
cleanupTempAgent(agentId);
|
|
2935
2935
|
return null;
|
|
2936
2936
|
}
|
|
2937
|
+
// W-mr98op8w000ma4ad: STALE-BASE GUARD refusal. prepareLiveCheckout was about
|
|
2938
|
+
// to fork a NEW branch off the operator's current HEAD while HEAD sits on the
|
|
2939
|
+
// project base ref (mainRef), but the LOCAL mainRef tip has diverged from
|
|
2940
|
+
// origin/<mainRef> with unpushed commit(s). Forking would silently bake that
|
|
2941
|
+
// committed contamination into the new branch and its PR (the scope-leak class
|
|
2942
|
+
// first seen on ADO PR 5411214, recurring on AB#12016662 / ADO PR 5419146).
|
|
2943
|
+
// Deterministic — a contaminated local base reproduces identically on retry —
|
|
2944
|
+
// so it gets its own non-retryable FAILURE_CLASS instead of LIVE_CHECKOUT_FAILED's
|
|
2945
|
+
// bounded retry-storm. The engine NEVER resets/cleans the operator's local
|
|
2946
|
+
// mainRef; the operator must reconcile it themselves, then re-dispatch.
|
|
2947
|
+
if (_liveResult && _liveResult.ok === false && _liveResult.reason === 'stale-main') {
|
|
2948
|
+
const _staleMainRef = typeof _liveResult.mainRef === 'string' && _liveResult.mainRef ? _liveResult.mainRef : 'main';
|
|
2949
|
+
const _staleAhead = Number.isFinite(_liveResult.ahead) ? _liveResult.ahead : 0;
|
|
2950
|
+
const _localSha = typeof _liveResult.localMainSha === 'string' && _liveResult.localMainSha ? _liveResult.localMainSha : '(unknown)';
|
|
2951
|
+
const _originSha = typeof _liveResult.originMainSha === 'string' && _liveResult.originMainSha ? _liveResult.originMainSha : '(unknown)';
|
|
2952
|
+
const _alertBody = [
|
|
2953
|
+
'# Live-checkout blocked: local base branch has diverged (stale/contaminated base)',
|
|
2954
|
+
'',
|
|
2955
|
+
`**Project:** ${project.name || '(unknown)'}`,
|
|
2956
|
+
`**Local path:** ${cwd}`,
|
|
2957
|
+
`**New branch (refused):** ${branchName}`,
|
|
2958
|
+
`**Base ref:** ${_staleMainRef}`,
|
|
2959
|
+
`**Local ${_staleMainRef}:** ${_localSha}`,
|
|
2960
|
+
`**origin/${_staleMainRef}:** ${_originSha}`,
|
|
2961
|
+
`**Unpushed commits on local ${_staleMainRef}:** ${_staleAhead}`,
|
|
2962
|
+
`**Work item:** ${_wiIdForAlert}`,
|
|
2963
|
+
`**Dispatch:** ${id}`,
|
|
2964
|
+
'',
|
|
2965
|
+
`The engine refused to fork \`${branchName}\` off local \`${_staleMainRef}\` because your local \`${_staleMainRef}\` is **${_staleAhead} commit(s) ahead of \`origin/${_staleMainRef}\`**. Those commits were never pushed — forking a new branch (and its PR) off this base would silently inherit them, contaminating an otherwise-clean change with unrelated commits. The working tree was clean (no uncommitted changes), so the dirty check could not catch this: the contamination is COMMITTED. Your tree was left untouched — the engine never resets, cleans, or rebases your \`${_staleMainRef}\`.`,
|
|
2966
|
+
'',
|
|
2967
|
+
'## Recovery',
|
|
2968
|
+
'',
|
|
2969
|
+
`Reconcile your local \`${_staleMainRef}\` so it no longer carries unpushed commits, then re-dispatch. Pick ONE, depending on whether those commits are real work:`,
|
|
2970
|
+
'',
|
|
2971
|
+
`1. **If the extra commits are real work you want to keep** — move them onto their own branch, then reset \`${_staleMainRef}\` to the remote:`,
|
|
2972
|
+
'',
|
|
2973
|
+
'```',
|
|
2974
|
+
`git -C "${cwd}" branch salvage-${_staleMainRef} ${_staleMainRef}`,
|
|
2975
|
+
`git -C "${cwd}" checkout ${_staleMainRef}`,
|
|
2976
|
+
`git -C "${cwd}" reset --hard origin/${_staleMainRef}`,
|
|
2977
|
+
'```',
|
|
2978
|
+
'',
|
|
2979
|
+
`2. **If the extra commits are leftover junk** (e.g. a prior dispatch's abandoned WIP) — discard them by resetting \`${_staleMainRef}\` to the remote (this DISCARDS those commits):`,
|
|
2980
|
+
'',
|
|
2981
|
+
'```',
|
|
2982
|
+
`git -C "${cwd}" checkout ${_staleMainRef}`,
|
|
2983
|
+
`git -C "${cwd}" reset --hard origin/${_staleMainRef}`,
|
|
2984
|
+
'```',
|
|
2985
|
+
'',
|
|
2986
|
+
`The salvage branch in option 1 keeps the commits recoverable; both leave \`${_staleMainRef}\` matching \`origin/${_staleMainRef}\` so the next dispatch forks off a clean base.`,
|
|
2987
|
+
].join('\n');
|
|
2988
|
+
try { writeInboxAlert(`live-checkout-stale-base-${_wiIdForAlert}`, _alertBody); }
|
|
2989
|
+
catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
|
|
2990
|
+
try {
|
|
2991
|
+
const _wiPath = resolveWorkItemPath(dispatchItem.meta);
|
|
2992
|
+
if (_wiPath && dispatchItem.meta?.item?.id) {
|
|
2993
|
+
mutateJsonFileLocked(_wiPath, (data) => {
|
|
2994
|
+
if (!Array.isArray(data)) return data;
|
|
2995
|
+
const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
|
|
2996
|
+
if (wi) wi._pendingReason = 'live_checkout_stale_base';
|
|
2997
|
+
return data;
|
|
2998
|
+
});
|
|
2999
|
+
}
|
|
3000
|
+
} catch (e) { log('warn', `live-checkout: failed to stamp _pendingReason: ${e.message}`); }
|
|
3001
|
+
const _shortMsg = `live-checkout blocked: local ${_staleMainRef} is ${_staleAhead} commit(s) ahead of origin/${_staleMainRef} (would contaminate ${branchName})`;
|
|
3002
|
+
log('error', `spawnAgent: ${_shortMsg}`);
|
|
3003
|
+
_cleanupPromptFiles();
|
|
3004
|
+
completeDispatch(
|
|
3005
|
+
id,
|
|
3006
|
+
DISPATCH_RESULT.ERROR,
|
|
3007
|
+
_shortMsg.slice(0, 800),
|
|
3008
|
+
`Live-checkout STALE-BASE GUARD: forking ${branchName} off local ${_staleMainRef} would inherit ${_staleAhead} unpushed commit(s) not on origin/${_staleMainRef}. Deterministic — operator must reconcile the local base branch (the engine never resets it) before re-dispatch. Retrying is provably useless.`,
|
|
3009
|
+
{ failureClass: FAILURE_CLASS.LIVE_CHECKOUT_STALE_BASE, agentRetryable: false },
|
|
3010
|
+
);
|
|
3011
|
+
cleanupTempAgent(agentId);
|
|
3012
|
+
return null;
|
|
3013
|
+
}
|
|
2937
3014
|
log('info', `live-checkout: ${_liveResult.created ? 'created' : 'switched to'} branch ${branchName} in ${cwd} (in-place; no worktree)`);
|
|
2938
3015
|
// PL-live-checkout-reliability-hardening: a clean spawn clears the two-strike
|
|
2939
3016
|
// dirty counter so a project that was transiently dirty once isn't treated as
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2346",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|