Skip to content
3 changes: 2 additions & 1 deletion docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ ak sync # apply it
| A Claude Code or Codex session in a non-Git folder shows in System → Runtime but not in Observability → Live | Outside a Git repository, Live links a process to a session only when both name exactly the same folder; a session that has not written its transcript yet has nothing to link | send the session a prompt so its transcript exists; it then appears in Live while the process runs. See [Observability](https://github.com/pacphi/agentic-kit/blob/main/docs/OBSERVABILITY.md#troubleshooting) |
| `status` shows `ruvnet-brain … not installed` | The RuvNet Brain (offline KB + `search_ruvnet` MCP) isn't on disk | `ak sync` (or `ak setup`) runs the installer; `npx ruvnet-brain --doctor` health-checks it |
| `status` shows `ruvnet-brain-plugin … Automatic hooks differ from the reviewed … contract` | ak compares the Brain plugin's automatic hooks with the exact hook sets it has reviewed, and the installed Brain adds, removes or changes one. The row names each change. Brain 4.3.28 adds `capacity-aware-parallel-work`, which keeps running when the Brain is switched off; ak has not accepted it and has asked the Brain maintainer to change that | `ak sync` cannot review a hook, so the warning has no sync action. Keep it until an ak release reviews the change; or disable the whole Claude plugin with `claude plugin disable ruvnet-brain@ruvnet-brain` (this also removes `search_ruvnet` from Claude); or set `ruvnetBrain: false` in `kit.json` to stop ak managing and reporting the Brain (the hooks stay installed) |
| `status` shows `ruvnet-brain … retained; the refresh to v… was refused` | The Brain's installer or its own updater refused the refresh (for example a private-overlay preflight), or ran without changing the installed release. The cause is on the Brain side, so `ak sync` stops retrying | Fix the named cause, then run `npx ruvnet-brain --update` yourself. `ak sync` tries again on its own once either the installed or the latest release changes. To stop ak managing the Brain, set `ruvnetBrain: false` in `kit.json` |
| `status` shows `ruvnet-brain … retained; the refresh to v… was refused` (most causes) | The Brain's installer or its own updater refused the refresh (for example a private-overlay preflight), or ran without changing the installed release. The cause is on the Brain side, so `ak sync` stops retrying | Fix the named cause, then run `npx ruvnet-brain --update` yourself. `ak sync` tries again on its own once either the installed or the latest release changes. To stop ak managing the Brain, set `ruvnetBrain: false` in `kit.json` |
| `status` shows `ruvnet-brain … retained; the refresh to v… was refused ([forge-update] ERROR: unresolved rollback state exists…)` | forge-update's legacy-backup reclaim (upstream issue #35) is refusing to create another full-KB rollback copy while old `kb.bak-*`/`kb.install-preserved-*` snapshots from a prior update remain on disk — verified (ADR-0061) that `--update` can never clear this on its own, because it never touches those snapshots | `npx ruvnet-brain --uninstall` (removes only the KB bundle, not the legacy snapshots), then `ak sync` reinstalls fresh — this clears the version block but does **not** free the disk the legacy snapshots use; see [stuinfla/ruvnet-brain#335](https://github.com/stuinfla/ruvnet-brain/issues/335) for reclaiming that. Or set `ruvnetBrain: false` in `kit.json` |
| A heal says `degraded` while the tool is still usable | The native repair failed and a fallback or older artifact remains available; exit status is authoritative | Use the reported repair command/error. The operation will not render green or advance a version stamp until a later repair exits successfully |
| Usage suddenly shows no data for one host, or a lower total than expected | Any of the four local sources (Claude/Codex transcript roots, OpenCode's SQLite store, the Codex thread ledger) can go absent, busy, corrupt, or query-incompatible; none of these are collapsed into an ordinary empty result | Inspect the branded host-icon pills in the dashboard's tabbar (top of every view, right-aligned — or `sourceHealth` in usage-index JSON) — one pill per host; the Codex pill folds its transcript-root and thread-ledger statuses together (worse status leads, both shown in the status side's tooltip). A degraded OpenCode scan retains in-window last-good cached sessions; repair the named source before treating zero as observed truth |
| The OpenCode pill's tooltip warns `usage-not-reported:N`, or a local-model session reads $0 with unpriced messages | A local model server (LM Studio, Ollama, llama.cpp) finished N responses without reporting token counts or a cost. The responses are counted, their usage is unknown, and no price is invented for a local model | Expected for servers that do not report usage; `costEvidence.unpricedMessages` on the session names the unpriced messages. Turn on usage reporting in the server if it offers it |
Expand Down
118 changes: 118 additions & 0 deletions docs/adr/0061-brain-reclaim-stuck-remediation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# ADR-0061 — RuvNet Brain "unresolved rollback state" remediation

- **Status:** Accepted
- **Date:** 2026-09-27
- **Deciders:** agentic-kit maintainers
- **Related:** [ADR-0025](0025-machine-footprint-metrics.md) (machine-footprint metrics; this
record deliberately does not add a new footprint subsystem there — see §4),
the [issues 237–239 audit record](../audits/2026-09-26-issues-237-238-239-verification-and-decisions.md)
(Decision 4, the Brain hook-contract warning this record does not touch), upstream
[stuinfla/ruvnet-brain#335](https://github.com/stuinfla/ruvnet-brain/issues/335) (filed
2026-09-27, with a follow-up comment recording the workaround verified here)

## Context

`ak sync` on this machine held a RuvNet Brain refresh (4.3.28 → 4.3.29) behind:

```text
[forge-update] ERROR: unresolved rollback state exists; refusing to create another full-KB copy.
```

Investigation (session 2026-09-27) found the cause: `~/.cache/ruvnet-brain/` had accumulated 12
legacy KB snapshot directories (`kb.bak-2026-07-*`, `kb.install-preserved-*`) totalling ~21GB.
The Brain's own `forge-update.mjs` reclaim logic (`reclaimBackups()`, tested against issue #35
upstream) evaluated all 12 for real — not a dry run — and kept every one (`freed: 0`), because
each held content the live `kb/` lacked, or an inventory it could not parse. That protection is
correct and intentional (the same logic exists specifically so a backup holding a repo's only
remaining store survives). But it also means `--update` **cannot ever resolve this on its own**:
retrying it, which is what `ak sync` currently does every cycle it holds a refusal, can never
succeed while any of those directories remain unresolved.

Two things were verified empirically before this record:

1. **Manual reclaim is possible but bounded.** Hashing every file across the 12 directories found
6 that were provably byte-identical duplicates of each other (safe to collapse after archiving
one canonical copy); the other 6 (5 pre-RVF `kb.bak-*` snapshots, 1 `kb.install-preserved-*`
holding content not reproducible from the current bundle) could not be proven safe by hand,
and forge-update's own algorithm could not clear them either. No CLI reclaim/prune command
exists (`--doctor`, `--what-changed`, `--uninstall` don't touch this) — filed as
stuinfla/ruvnet-brain#335.
2. **A distinct, verified escape hatch exists for the version block specifically.**
`npx ruvnet-brain --uninstall` removes only `~/.cache/ruvnet-brain/kb` (plus a handful of
installer-owned files); it never touches the legacy snapshot directories, the Claude Code
plugin, or the MCP registration. With `kb/` gone, the next install takes the **fresh-install
path** (`bin/install.mjs`'s `obtainBundle()`), not the incremental `--update` path
(`forge-update.mjs --apply`) — only the latter runs the reclaim check that gets stuck. Tested
live on this machine: landed cleanly on 4.3.29, `search_ruvnet` came back immediately (no
Claude Code restart — matches the documented "body-only releases go live on the next hook/MCP
call" contract), and `ak sync` re-verified clean afterward. **Disk usage was unchanged** — the
12 legacy directories were untouched throughout, and the fresh install even left behind one new
empty `kb.install-preserved-<random>` directory (removed by hand). The workaround trades "stuck
on an old version" for "still bloated," not a real fix for the bloat.

Today, `ak`'s own remediation text for *any* held Brain refusal (`brainReleaseRow()` in
`src/commands/status/sections/ruvnet-brain.mjs`) reads:

> fix the cause, then run `npx ruvnet-brain --update`; or set `"ruvnetBrain": false` in kit.json
> to stop ak managing the Brain

For most refusals (a private-overlay preflight, a stale updater bug — see
`tests/kit/brain-held-refresh.test.mjs`) that's correct: the cause is a one-off condition worth
retrying after a fix. For this specific refusal it is actively wrong — there is no "fix the
cause" available to the user, and telling them to re-run `--update` sends them back into the same
refusal forever.

## Decision

1. **Classify this refusal distinctly.** Add a narrow, exported pattern in `src/lib/heal.mjs`
(`BRAIN_RECLAIM_STUCK`) alongside the existing `BRAIN_REFUSAL`/`BRAIN_CAUSAL` patterns,
matching the two verified error strings
(`unresolved rollback state exists`, `refusing to create another full-KB copy`). This does
not change what gets held (`brainRefused()`/`recordRefusal` are unchanged) — only how a held
refusal of this specific shape is *described*.
2. **Give it distinct remediation text**, in `brainReleaseRow()`: when a held refusal's `detail`
matches `BRAIN_RECLAIM_STUCK`, the `fix` field names the verified workaround
(`npx ruvnet-brain --uninstall` then `ak sync`), states plainly that it clears the version
block but not the legacy-snapshot disk usage, and cites stuinfla/ruvnet-brain#335. Every other
held refusal keeps the existing generic text unchanged (regression-guarded by the existing
`tests/kit/brain-held-refresh.test.mjs` assertions).
3. **Report the legacy-snapshot count in that same message**, via a new pure helper
`legacySnapshotBytes()` in `src/lib/ruvnet-brain.mjs` (best-effort, bounded, matching this
file's existing null-on-failure conventions) — called only inside the reclaim-stuck branch, so
it costs nothing on every ordinary `ak status`/`ak sync` run.
4. **No automatic execution.** The remediation stays `repair: 'manual'`, exactly like every other
held-refusal row. `--uninstall` followed by a fresh `--force` install is a heavier, more
invasive action than a normal `--update` — and per `src/lib/heal.mjs`'s own documented
caution (`installRuvnetBrain`, citing issue #237 §4), a forced fresh install can itself be
refused for a Brain with private stores, *after* downloading the whole bundle. `ak sync`
auto-running this for every user hitting a reclaim-stuck hold would trade one silent failure
mode for a more expensive one. The user decides.
5. **No new install-routing logic.** `installRuvnetBrain()` already does the right thing once
`--uninstall` has run: `updaterPresent()` becomes false (no `forge-update.mjs` on disk),
`present()` stays true (the plugin cache survives), so the existing pinned
`--force --version v<tag>` fresh-install branch fires and `recordRelease` clears the hold on
success. This record adds a test that pins that existing behavior, not new code for it.
6. **No new footprint-metrics subsystem.** [ADR-0025](0025-machine-footprint-metrics.md) owns
machine footprint reporting; a one-sentence mention of a legacy-snapshot count inside an
already-existing warning row is not a new dashboard card, and this record does not attempt to
fold the two together — that's future work if the legacy-snapshot signal proves worth
surfacing outside this one warning.

## Consequences

- Once a user hits this specific hold, the row tells them something they can actually act on,
instead of a dead-end instruction.
- The disk-bloat problem remains open and upstream. If stuinfla/ruvnet-brain#335 ships a real
`--reclaim`/prune command, this record's remediation text should be revisited to prefer it
(cheaper — no full re-download, and it can reason about directories this session could not
prove safe by hand, like `kb.install-preserved-69VtSI`).
- `docs/TROUBLESHOOTING.md`'s existing row for "`ruvnet-brain … retained; the refresh … was
refused`" is updated in the same change to describe both the generic case and this specific
one, so the docs-alignment gate stays honest.

## Implementation status

Implemented in this branch: `BRAIN_RECLAIM_STUCK` export, `legacySnapshotBytes()`, the
`brainReleaseRow()` branch, `docs/TROUBLESHOOTING.md` update, and tests covering classification,
the unchanged generic-refusal path, the post-uninstall install-routing behavior, and
`legacySnapshotBytes()` against a fixture directory.
31 changes: 28 additions & 3 deletions src/commands/status/sections/ruvnet-brain.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,34 @@
// on disk, drift via GitHub releases, TTL-cached like `self`)
import {
activeHeldRefresh, drift as ruvnetBrainDrift, nightlyAgentPresent as rbNightlyPresent,
NIGHTLY_LABEL as RB_NIGHTLY_LABEL,
NIGHTLY_LABEL as RB_NIGHTLY_LABEL, legacySnapshotBytes,
} from '../../../lib/ruvnet-brain.mjs';
import { BRAIN_RECLAIM_STUCK } from '../../../lib/heal.mjs';
import { row } from '../row.mjs';
import { inspectClaudeBrainPlugin } from '../../../lib/ruvnet-brain-plugin.mjs';
import { releaseObservationLabel } from '../../../lib/versions.mjs';

/** ADR-0061: one sentence naming the legacy-snapshot cost of a reclaim-stuck
* hold, best-effort — never lets a formatting/fs edge case break the row. */
function legacySnapshotNote() {
try {
const s = legacySnapshotBytes();
if (!s.count) return '';
const gb = s.bytes != null ? ` (~${(s.bytes / 1024 ** 3).toFixed(1)}GB${s.exhausted ? '+' : ''})` : '';
return ` and does not free ${s.count} legacy snapshot dir(s)${gb} still on disk`;
} catch {
return '';
}
}

/** ADR-0061: --update can never clear this refusal (the legacy snapshots it is
* stuck on are never touched by --update); --uninstall + reinstall verified to. */
function reclaimStuckFix() {
return '`npx ruvnet-brain --uninstall` (removes only the KB bundle) then `ak sync` reinstalls fresh '
+ `and clears the version block${legacySnapshotNote()} — see upstream stuinfla/ruvnet-brain#335; `
+ 'or set "ruvnetBrain": false in kit.json to stop ak managing the Brain';
}

// What a user can do about an unreviewed Brain hook change. ak cannot review a
// hook for them, so the options are a manual fix, never a sync one (P5, Branch 0
// real-machine pass: without a fix the row had no manual tag and no count).
Expand Down Expand Up @@ -66,11 +88,14 @@ export function brainReleaseRow(b) {
// re-downloads the bundle, so sync does not act on the row until either
// release changes; the options are the user's, as a manual fix.
const have = b.installedRelease ? `release v${b.installedRelease}` : 'the existing unversioned install';
const fix = BRAIN_RECLAIM_STUCK.test(held.detail)
? reclaimStuckFix()
: 'fix the cause, then run `npx ruvnet-brain --update`; or set "ruvnetBrain": false in kit.json '
+ 'to stop ak managing the Brain';
return row('ruvnet-brain', 'warn',
`ruvnet-brain ${have} retained; the refresh to v${b.latest} was refused (${held.detail}). `
+ 'ak sync will not retry it until either release changes',
'fix the cause, then run `npx ruvnet-brain --update`; or set "ruvnetBrain": false in kit.json '
+ 'to stop ak managing the Brain', { repair: 'manual' });
fix, { repair: 'manual' });
}
if (b.outdated) {
const have = b.installedRelease ? `release v${b.installedRelease}` : 'present (unversioned install)';
Expand Down
10 changes: 10 additions & 0 deletions src/lib/heal.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,16 @@ const BRAIN_HINT = /^(?:Run|Fix:)\s.*\bnpx ruvnet-brain\b/i;
// bundle's updater ("[forge-update] ERROR:", "refusing to update", missing
// updater) — as opposed to a transient network or timeout failure.
const BRAIN_REFUSAL = /install stopped:|\[forge-update\]\s*ERROR:|refusing to update|can't update:/i;
// A specific, permanent subtype of BRAIN_REFUSAL (ADR-0061): forge-update's
// legacy-backup reclaim (reclaimBackups(), upstream issue #35) refuses to
// create another full-KB rollback copy while any kb.bak-*/kb.install-preserved-*
// snapshot from a prior update remains unresolved. Verified 2026-09-27: retrying
// --update can never clear this (the snapshots are never touched by --update),
// but deleting kb/ (npx ruvnet-brain --uninstall) and reinstalling fresh takes a
// different code path (obtainBundle(), not forge-update.mjs) that isn't blocked
// by it — see stuinfla/ruvnet-brain#335. Exported so status can give this one
// subtype of held refusal different, actionable remediation text.
export const BRAIN_RECLAIM_STUCK = /unresolved rollback state exists|refusing to create another full-KB copy/i;

/** Did the installer or updater refuse, rather than fail transiently? */
export function brainRefused(result) {
Expand Down
27 changes: 26 additions & 1 deletion src/lib/hook-audit/agentic-dependency-constraints.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schemaVersion": 5,
"lastVerifiedAt": "2026-09-26",
"lastVerifiedAt": "2026-09-27",
"recheckPolicy": {
"cadence": "weekly-and-before-upgrade",
"staleAfterDays": 14,
Expand Down Expand Up @@ -1863,6 +1863,31 @@
{ "date": "2026-09-26", "event": "registered" }
]
},
{
"id": "stuinfla/ruvnet-brain#335",
"url": "https://github.com/stuinfla/ruvnet-brain/issues/335",
"relation": "filed",
"kind": "issue",
"title": "forge-update permanently stuck once kb.bak-*/kb.install-preserved-* accumulate; no reclaim/prune command exists",
"dependency": "ruvnet-brain",
"doneWhen": {
"state": "closed-completed",
"release": { "channel": "github-release", "name": "stuinfla/ruvnet-brain", "minVersion": null }
},
"mapping": "mapped",
"kitImpact": {
"refs": ["ADR-0061: brain reclaim-stuck remediation"],
"files": ["src/commands/status/sections/ruvnet-brain.mjs", "src/lib/heal.mjs"]
},
"adjustment": "Once a released Brain ships a reclaim/prune command, prefer it in the reclaim-stuck remediation text over the --uninstall + reinstall workaround (ADR-0061) — it is cheaper (no full re-download) and can reason about snapshots this session could not prove safe by hand.",
"status": "watching",
"constraintIds": [],
"history": [
{ "date": "2026-09-27", "event": "filed" },
{ "date": "2026-09-27", "event": "commented" },
{ "date": "2026-09-27", "event": "registered" }
]
},
{
"id": "vercel-labs/agent-browser#1679",
"url": "https://github.com/vercel-labs/agent-browser/issues/1679",
Expand Down
Loading
Loading