feat(plugin): dual-backend fallback (remote primary + local fallback) - #1129
feat(plugin): dual-backend fallback (remote primary + local fallback)#1129lightzt99 wants to merge 5 commits into
Conversation
Add optional local fallback server so the hook keeps working when the remote PowerMem backend is unreachable. When POWERMEM_FALLBACK_BASE_URL is unset, behavior is byte-identical to single-backend mode. - Go: doRequestWithFallback routes search/write to primary, retries on fallback for net errors / timeouts / 5xx (5xx opt-in via POWERMEM_FALLBACK_TRIGGER_5XX, default on). Circuit-breaker state persisted to ~/.powermem/fallback-state.json with up/down TTLs to avoid probing primary on every hook event. - Shell: write_runtime_dual in common.sh + dual-mode branch in init.sh (POWERMEM_INIT_FALLBACK_BASE_URL or interactive prompt). - POWERMEM_FALLBACK_DISABLED=1 is a runtime kill switch. - Replay/sync deferred: needs server-side metadata filter support.
…preload-model.sh Removed sections that duplicate script behavior or conflict with SKILL.md: - PRE-CHECK & PREREQUISITES (uv/Python detection lives in common.sh) - STEP BY STEP PROVEN PATH (Method A conflicts with SKILL.md's "do not run source/developer flow"; Method B uses outdated `claude mcp add -- powermem-mcp stdio` — init.sh now uses `--scope user --transport http`; Method C duplicates the E0xx error guide) - FINAL VALIDATION STEPS (status.sh already covers health; write/search round-trip belongs in status.sh, not setup docs) - SUMMARY (placeholder text, no information value) E006 now points to `scripts/preload-model.sh` instead of inline ModelScope/HuggingFace commands — the script already auto-detects region and bridges into the HF hub cache. Net: 1076 → 947 lines. Remaining sections are either canonical reference (masking rules, dev-mode, dual-backend env vars, E001-E014 troubleshooting, systemd unit) or the SKILL.md entry point.
Add "Remote + local fallback" as a fourth Question 0 option and a dual flow that: - Asks 4 questions in one round (Server URL, API Key, Connection, Fallback URL). Connection drops the MCP option in dual mode — fallback only applies to the hook's REST calls, not the MCP transport. - Reuses Question 1-3 (Storage / LLM / Embedding) verbatim to configure the fallback server, so the agent runs the same question flow as a standalone local server. - Defaults the fallback URL to http://localhost:8848 (same port as a standalone local server; port is the user's decision). - Detects port conflicts by probing health on the fallback URL: if a healthy server is already there, state the conflict plainly and let the user decide (reuse vs. pick a different URL). Never silently restart or kill the existing server. - Runs two init.sh invocations: (1) start the fallback server, (2) write the dual runtime.env with POWERMEM_INIT_FALLBACK_BASE_URL. - Notes v1 limitations (no replication, stale reads, no conflict resolution, MCP not covered) and the kill switch (POWERMEM_FALLBACK_DISABLED=1). - Documents the removal path: re-run init picking "Remote" (single backend) to overwrite runtime.env and clear the fallback URL.
Adds an isolated regression test that runs the hook binary against two in-process fake PowerMem HTTP servers (primary + fallback) and verifies the circuit-breaker routing end-to-end. No LLM, no network (Docker --network none). Coverage: - both up → request routes to primary, state file primary_down=false - primary down → request routes to fallback, state primary_down=true - cached down within TTL → skip primary probe, go straight to fallback - recovery → state seeded with stale last_probe_at, primary back up → probe hits primary, state marks primary_down=false - primary 5xx (TRIGGER_5XX=1 default) → fallback triggered, state down - primary 5xx (TRIGGER_5XX=0) → no trigger, error surfaces, no state - POWERMEM_FALLBACK_DISABLED=1 → kill switch, no fallback, no state - POWERMEM_FALLBACK_BASE_URL empty → single-backend fast path, no state The fake server subclasses RecordingHTTPServer so the handler's self.server resolves to the controllable instance (with label / fail_search / fail_health), and supports stop()/restart() on the same port for down→up transitions. Adds docker/Dockerfile.claude-hook-dual (same uv+go multi-stage pattern as the no-LLM regression image) and the make target test-claude-hook-dual.
|
I found one draft blocker in the dual fallback init flow. If the user chooses |
init.sh: POWERMEM_INIT_RECONFIGURE=1 stops the healthy managed server, removes .env, recreates it from current POWERMEM_INIT_* env, and starts a fresh server. Without the flag, init prints a hint and exits 0. SKILL.md: when status.sh reports the server healthy (local or remote), ask via AskUserQuestion whether to keep current setup or reconfigure. Local managed mode runs init.sh with RECONFIGURE=1; remote mode re-asks the Server URL / API key / Connection / Fallback questions and re-runs init.sh (remote server is not managed by the plugin, so no RECONFIGURE flag needed). Also rebuilds the 5 platform hook binaries to include the dual-backend code.
|
Thanks for the dual-backend fallback work. I do not think this is ready to merge yet because the fallback init flow can still write a URL that does not match the server that was actually started. If the user selects a fallback URL such as |
Summary
POWERMEM_FALLBACK_BASE_URLis unset, behavior is byte-identical to single-backend mode (fast path) — no state file written, no probe.~/.powermem/fallback-state.jsonwith up/down TTLs (clamp 5..300s) to avoid probing primary on every hook event.POWERMEM_FALLBACK_DISABLED=1is a runtime kill switch that decays to primary-only without editingruntime.env.init.shlearns dual mode:POWERMEM_INIT_FALLBACK_BASE_URL(non-interactive) or a TTY prompt.write_runtime_dualwrites both URLs + keys with shell-safe quoting.skills/init/SKILL.mdgains a 4th Question 0 option "Remote + local fallback" with a dedicated onboarding round (fallback URL, port-conflict handling, MCP disabled note).SETUP.mdtrimmed (~1076 → ~947 lines): dropped PRE-CHECK / STEP BY STEP / FINAL VALIDATION / SUMMARY sections that were redundant withinit.shor conflicted with SKILL.md; E006 now points toscripts/preload-model.sh.Env vars
POWERMEM_FALLBACK_BASE_URLPOWERMEM_FALLBACK_API_KEYPOWERMEM_FALLBACK_DISABLED0POWERMEM_FALLBACK_DOWN_TTL_SECONDS30POWERMEM_FALLBACK_UP_TTL_SECONDS30POWERMEM_FALLBACK_TRIGGER_5XX1POWERMEM_FALLBACK_LOG_FILE$DATA_DIR/powermem-hook.logPOWERMEM_INIT_FALLBACK_BASE_URLFallback triggers
context.DeadlineExceeded)net.Error)TRIGGER_5XX=1(default)TRIGGER_5XX=0Known limitations (v1)
Test plan
go test ./...inapps/claude-code-plugin/cmd/powermem-hook— 14 new unit tests (helpers, state TTL, routing, 5xx trigger, kill switch, single-backend fast path)python3 apps/claude-code-plugin/tests/test_runtime_dual.py— 5 cases including shell-metachar quoting forwrite_runtime_dualmake test-claude-hook-dockerregression — single-backend fast path preserved, unaffectedmake test-claude-hook-dual— new Docker dual-backend regression (docker/Dockerfile.claude-hook-dual,tests/regression/test_claude_hook_dual_backend.py, 8 tests: both-up → primary, primary-down → fallback, cached-down skips probe, recovery marks up, 5xx trigger on/off, kill switch, single-backend no state file). Passes in 11s with--network none.POWERMEM_INIT_BASE_URL=http://remote:8001 POWERMEM_INIT_FALLBACK_BASE_URL=http://localhost:8848 sh init.shwrites dualruntime.env; subsequent hook events route to primary, fall back on primary failure, recover after DOWN_TTL.Non-goals (v1)
Files
apps/claude-code-plugin/cmd/powermem-hook/fallback.go(new) — routing, state, helpersapps/claude-code-plugin/cmd/powermem-hook/fallback_test.go(new) — Go unit testsapps/claude-code-plugin/cmd/powermem-hook/main.go—searchMemoriesForPrompt/postMemoryRawrouted viadoRequestWithFallbackapps/claude-code-plugin/scripts/init.sh— dual mode branch,write_runtime_dual, reconfigure promptapps/claude-code-plugin/scripts/common.sh—write_runtime_dualhelperapps/claude-code-plugin/skills/init/SKILL.md— 4th Question 0 option + dual onboarding roundapps/claude-code-plugin/SETUP.md— trimmed; new "Remote primary + local fallback" sectionapps/claude-code-plugin/tests/test_runtime_dual.py(new) — shell teststests/regression/test_claude_hook_dual_backend.py(new) — Docker regressiondocker/Dockerfile.claude-hook-dual(new) — regression imageMakefile—test-claude-hook-dualtarget