@yemi33/minions 0.1.2345 → 0.1.2347
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 +87 -10
- package/docs/README.md +1 -0
- package/docs/command-center.md +3 -1
- 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
|
@@ -6973,12 +6973,35 @@ const server = http.createServer(async (req, res) => {
|
|
|
6973
6973
|
if (target.error) return jsonReply(res, 404, { error: target.error });
|
|
6974
6974
|
const wiPath = target.wiPath;
|
|
6975
6975
|
|
|
6976
|
+
// W-mrazefzz000358e3 — body.project reassigns which projects/<name>/
|
|
6977
|
+
// work-items.json (or central) file owns this item. Resolve + validate
|
|
6978
|
+
// it up front (mirrors resolveWorkItemsCreateTarget's create-path
|
|
6979
|
+
// contract) so an unknown project name 400s cleanly instead of the
|
|
6980
|
+
// historical silent no-op. `projectMove` stays null when body.project
|
|
6981
|
+
// is omitted (no location change requested).
|
|
6982
|
+
let projectMove = null;
|
|
6983
|
+
if (body.project !== undefined) {
|
|
6984
|
+
const projTarget = resolveProjectSourceTarget(body.project, PROJECTS);
|
|
6985
|
+
if (projTarget.error) {
|
|
6986
|
+
return jsonReply(res, 400, {
|
|
6987
|
+
error: projTarget.error,
|
|
6988
|
+
knownProjects: PROJECTS.map(p => p.name),
|
|
6989
|
+
});
|
|
6990
|
+
}
|
|
6991
|
+
projectMove = { wiPath: projTarget.wiPath, projectName: projTarget.project ? projTarget.project.name : null };
|
|
6992
|
+
}
|
|
6993
|
+
|
|
6976
6994
|
let result = null;
|
|
6977
6995
|
let agentChanged = false;
|
|
6996
|
+
// Set inside the source-file lock below when a project move is needed;
|
|
6997
|
+
// inserted into the target file's own lock scope afterward so we never
|
|
6998
|
+
// hold both files' locks at once.
|
|
6999
|
+
let movedItem = null;
|
|
6978
7000
|
mutateJsonFileLocked(wiPath, (items) => {
|
|
6979
7001
|
if (!Array.isArray(items)) items = [];
|
|
6980
|
-
const
|
|
6981
|
-
if (
|
|
7002
|
+
const idx = items.findIndex(i => i.id === id);
|
|
7003
|
+
if (idx === -1) { result = { code: 404, body: { error: 'item not found' } }; return items; }
|
|
7004
|
+
const item = items[idx];
|
|
6982
7005
|
if (item.status === WI_STATUS.DISPATCHED) { result = { code: 400, body: { error: 'Cannot edit dispatched items' } }; return items; }
|
|
6983
7006
|
|
|
6984
7007
|
if (title !== undefined) item.title = title;
|
|
@@ -7010,10 +7033,32 @@ const server = http.createServer(async (req, res) => {
|
|
|
7010
7033
|
}
|
|
7011
7034
|
}
|
|
7012
7035
|
item.updatedAt = new Date().toISOString();
|
|
7036
|
+
|
|
7037
|
+
// W-mrazefzz000358e3 — actually MOVE the item between work-items.json
|
|
7038
|
+
// files when the resolved target differs from where it lives today,
|
|
7039
|
+
// rather than just stamping item.project in place. Only trip this
|
|
7040
|
+
// when the resolved wiPath actually changes — re-assigning the same
|
|
7041
|
+
// project the item already lives in is a no-op move.
|
|
7042
|
+
if (projectMove && path.resolve(projectMove.wiPath) !== path.resolve(wiPath)) {
|
|
7043
|
+
if (projectMove.projectName) item.project = projectMove.projectName;
|
|
7044
|
+
else delete item.project;
|
|
7045
|
+
movedItem = item;
|
|
7046
|
+
items.splice(idx, 1);
|
|
7047
|
+
}
|
|
7048
|
+
|
|
7013
7049
|
result = { code: 200, body: { ok: true, item } };
|
|
7014
7050
|
return items;
|
|
7015
7051
|
});
|
|
7016
7052
|
if (!result) return jsonReply(res, 500, { error: 'unexpected state' });
|
|
7053
|
+
|
|
7054
|
+
if (movedItem) {
|
|
7055
|
+
mutateJsonFileLocked(projectMove.wiPath, (targetItems) => {
|
|
7056
|
+
if (!Array.isArray(targetItems)) targetItems = [];
|
|
7057
|
+
targetItems.push(movedItem);
|
|
7058
|
+
return targetItems;
|
|
7059
|
+
});
|
|
7060
|
+
}
|
|
7061
|
+
|
|
7017
7062
|
// Clear stale pending dispatch entries outside lock
|
|
7018
7063
|
if (agentChanged) cleanDispatchEntries(d => d.meta?.item?.id === id);
|
|
7019
7064
|
return jsonReply(res, result.code, result.body);
|
|
@@ -9886,10 +9931,20 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9886
9931
|
* test enforces engine.js does not import cc-worker-pool).
|
|
9887
9932
|
*/
|
|
9888
9933
|
function _invokeCcStream({ prompt, sessionId, liveState, toolUses, model, effort, maxTurns, engineConfig, systemPrompt = CC_STATIC_SYSTEM_PROMPT, tabId, images }) {
|
|
9889
|
-
|
|
9890
|
-
|
|
9891
|
-
|
|
9892
|
-
|
|
9934
|
+
// W-mray463s001zcee2 — image-bearing turns bypass the worker pool.
|
|
9935
|
+
// Copilot itself supports images fine (imageInput: true, `--attachment
|
|
9936
|
+
// <path>` in engine/runtimes/copilot.js buildArgs), but the ACP worker-pool
|
|
9937
|
+
// protocol (engine/cc-worker-pool.js session/prompt) only ever sends
|
|
9938
|
+
// `[{ type: 'text', text: prompt }]` — it has no image/attachment
|
|
9939
|
+
// content-block support today. That is a worker-pool PROTOCOL gap, not a
|
|
9940
|
+
// Copilot capability gap (the old comment here claiming "copilot has
|
|
9941
|
+
// imageInput:false" was factually wrong). Until the pool's ACP session
|
|
9942
|
+
// protocol is extended to carry attachments, route image turns around the
|
|
9943
|
+
// pool for THIS TURN ONLY: cold-spawn a one-off CLI process via the
|
|
9944
|
+
// already-working --attachment path below, while image-less turns keep
|
|
9945
|
+
// using the fast warm pool.
|
|
9946
|
+
const hasImages = Array.isArray(images) && images.length > 0;
|
|
9947
|
+
if (!hasImages && shared.resolveCcUseWorkerPool(engineConfig)) {
|
|
9893
9948
|
return _invokeCcStreamViaPool({ prompt, liveState, toolUses, model, effort, engineConfig, systemPrompt, tabId });
|
|
9894
9949
|
}
|
|
9895
9950
|
const { callLLMStreaming } = require('./engine/llm');
|
|
@@ -10050,13 +10105,28 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10050
10105
|
},
|
|
10051
10106
|
onDone: () => {
|
|
10052
10107
|
_emitTimingLog(_lifecycle, _tSessionReady, Date.now(), 'done');
|
|
10053
|
-
|
|
10108
|
+
// W-mrax0he4000kc90d — resolve with ONLY the trailing segment's
|
|
10109
|
+
// text, not the full accumulated turn. ccSegmentsFinalize's
|
|
10110
|
+
// contract (dashboard/js/render-utils.js) is that its finalText
|
|
10111
|
+
// argument is the trailing/last text segment only — the same
|
|
10112
|
+
// contract onChunk already honors via `accumulated.slice(segmentStart)`
|
|
10113
|
+
// (W-mqvakva3000i3325). Resolving with the full multi-segment
|
|
10114
|
+
// `accumulated` buffer here welds every earlier segment onto the
|
|
10115
|
+
// end of the message via _ccSegMergeText's "distinct — never weld"
|
|
10116
|
+
// branch, visibly duplicating the whole prior turn for any
|
|
10117
|
+
// response with >=1 mid-turn tool call. `raw` still carries the
|
|
10118
|
+
// full turn (logging/telemetry only — never rendered).
|
|
10119
|
+
resolveResult({ text: accumulated.slice(segmentStart), sessionId: sessionHandle.sessionId, code: 0, usage: {}, raw: accumulated, stderr: '' });
|
|
10054
10120
|
},
|
|
10055
10121
|
onError: (err) => {
|
|
10056
10122
|
_emitTimingLog(_lifecycle, _tSessionReady, Date.now(), cancelled ? 'cancelled' : 'error');
|
|
10057
10123
|
if (cancelled) {
|
|
10124
|
+
// Same trailing-segment contract as onDone above — a mid-stream
|
|
10125
|
+
// cancel (e.g. user hits "stop") can still reach the SSE 'done'
|
|
10126
|
+
// frame with text accumulated so far, so it must not leak the
|
|
10127
|
+
// full multi-segment buffer either.
|
|
10058
10128
|
resolveResult({
|
|
10059
|
-
text: accumulated,
|
|
10129
|
+
text: accumulated.slice(segmentStart),
|
|
10060
10130
|
sessionId: sessionHandle.sessionId,
|
|
10061
10131
|
code: 0,
|
|
10062
10132
|
usage: {},
|
|
@@ -10599,7 +10669,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10599
10669
|
finishMissingRuntime(result, liveState);
|
|
10600
10670
|
return;
|
|
10601
10671
|
}
|
|
10602
|
-
|
|
10672
|
+
// W-mrax0he4000kc90d — `result.text` from the pool path is now the
|
|
10673
|
+
// TRAILING segment only (see _invokeCcStreamViaPool's onDone), which
|
|
10674
|
+
// is legitimately empty when a turn ends right on a tool call with
|
|
10675
|
+
// no closing prose. That's a real success, not "no output" — so also
|
|
10676
|
+
// check `result.raw` (always the full accumulated turn) before
|
|
10677
|
+
// treating an empty `text` as a failure. A genuinely empty turn has
|
|
10678
|
+
// both text and raw empty.
|
|
10679
|
+
if ((!result.text && !result.raw) || result.error) {
|
|
10603
10680
|
if (req.destroyed) {
|
|
10604
10681
|
_ccStreamEnded = true;
|
|
10605
10682
|
_logCcStreamEnd(_ccTelemetry, 'llm-empty-client-gone', { code: result.code });
|
|
@@ -13492,7 +13569,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13492
13569
|
|
|
13493
13570
|
// Work items
|
|
13494
13571
|
{ method: 'POST', path: '/api/work-items', desc: 'Create a new work item', params: 'title, type?, description?, priority?, project?, agent?, agents?, scope?, references?, acceptanceCriteria?, skipPr?, oneShot?, meta?, meta.pr_followup?, meta.workdir?, X-Minions-Agent?, X-Minions-Origin-Wi?', handler: handleWorkItemsCreate },
|
|
13495
|
-
{ method: 'POST', path: '/api/work-items/update', desc: 'Edit a pending/failed work item', params: 'id, source?, title?, description?, type?, priority?, agent?, references?, acceptanceCriteria?, depends_on?, workdir?, meta?', handler: handleWorkItemsUpdate },
|
|
13572
|
+
{ method: 'POST', path: '/api/work-items/update', desc: 'Edit a pending/failed work item', params: 'id, source?, title?, description?, type?, priority?, agent?, project?, references?, acceptanceCriteria?, depends_on?, workdir?, meta?', handler: handleWorkItemsUpdate },
|
|
13496
13573
|
{ method: 'POST', path: '/api/work-items/retry', desc: 'Reset a failed/dispatched item to pending', params: 'id, source?', handler: handleWorkItemsRetry },
|
|
13497
13574
|
{ method: 'POST', path: '/api/work-items/delete', desc: 'Remove a work item, kill agent, clear dispatch', params: 'id, source?', handler: handleWorkItemsDelete },
|
|
13498
13575
|
{ method: 'POST', path: '/api/work-items/cancel', desc: 'Cancel a work item, kill agent, clear dispatch', params: 'id, source?, reason?', handler: handleWorkItemsCancel },
|
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.
|
package/docs/command-center.md
CHANGED
|
@@ -65,9 +65,11 @@ Any violation rejects the **whole turn** with a typed error envelope (`code: 'in
|
|
|
65
65
|
| Runtime | `imageInput` | Behavior |
|
|
66
66
|
|---------|--------------|----------|
|
|
67
67
|
| claude | `true` | Images delivered as an Anthropic Messages `content[]` envelope of base64 blocks over stream-json (`claude.buildPrompt` + `--input-format stream-json` in `claude.buildArgs`). |
|
|
68
|
-
| copilot | `true` | Images materialized to tmp files and passed as `--attachment <path>` (W-mqv7324u0021db5d).
|
|
68
|
+
| copilot | `true` | Images materialized to tmp files and passed as `--attachment <path>` (W-mqv7324u0021db5d). |
|
|
69
69
|
| codex | `false` | Degraded — same typed `model-unavailable` envelope. |
|
|
70
70
|
|
|
71
|
+
**Worker-pool interaction (W-mray463s001zcee2).** The opt-in Copilot ACP worker pool (`engine.ccUseWorkerPool: true`, default ON for Copilot CC) is a **separate protocol path** from the table above — `engine/cc-worker-pool.js`'s `session/prompt` call only ever sends a single text content block (`[{ type: 'text', text: prompt }]`) and has no image/attachment content-block support today. This used to mean image turns were silently dropped whenever the pool was active, with a comment incorrectly blaming a Copilot capability gap (`imageInput:false`) that doesn't exist — Copilot's `imageInput` is `true` and fully supports images on the direct spawn path. The fix: `_invokeCcStream` (dashboard.js) now checks for a non-empty `images` array **before** routing through `resolveCcUseWorkerPool` — image-bearing turns bypass the warm pool for that turn only and cold-spawn a one-off CLI process via the working `--attachment` path (same as the non-pooled path always used), while image-less turns keep using the fast warm pool. If `engine/cc-worker-pool.js`'s ACP session protocol is later extended to carry attachments (Option A), this per-turn bypass in `_invokeCcStream` should be revisited/removed.
|
|
72
|
+
|
|
71
73
|
`_resolveImageOpts` (in `engine/llm.js`) is pure and unit-tested: it forwards the `images` list only when `runtime.capabilities.imageInput` is truthy, otherwise returns the typed error so CC/doc-chat surface the envelope instead of a silently-text-only reply. `_spawnProcess` re-applies the same gate (`if (!caps.imageInput) adapterOpts.images = undefined`) as defense in depth. Image **filenames** are run through the untrusted-input fence (`buildSource('cc-image-filename', …)`) before they reach prompt text, since they are user-supplied.
|
|
72
74
|
|
|
73
75
|
**No new `engine.*` flag.** The limits above are module-level constants in `dashboard.js` (`CC_IMAGE_MAX_COUNT`, `CC_IMAGE_MAX_DECODED_BYTES`, `CC_IMAGE_MIME_ALLOWLIST`), not config keys; `engine/shared.js` `ENGINE_DEFAULTS` was not touched. Per CLAUDE.md Best Practice #9 (Settings parity), no Settings toggle is added because no new `engine.*` flag was introduced. Runtime selection that determines whether images are accepted is the existing `engine.ccCli` / `ccModel` override, which already has a Settings control.
|
|
@@ -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
|
]);
|