Skip to content

feat(boot): end-to-end boot loop — super-set of #104 - #110

Merged
couragehong merged 7 commits into
feat/go-migrationfrom
couragehong/feat/boot-loop-fixes
May 7, 2026
Merged

couragehong merged 7 commits into
feat/go-migrationfrom
couragehong/feat/boot-loop-fixes

Conversation

@couragehong

Copy link
Copy Markdown
Contributor

#104 commit 3개를 cherry-pick하고 그 위에 critical bug 수정, Python parity 정합성 체크 및 테스트 추가했습니다.

  • Python _init_pipelines / _set_dormant_with_reason 와 cross-check 한 결과 6건의 bug + 5건의 hygiene 이슈를 발견했고 모두 수정했습니다.
# 위치 문제 → 해결
1 keymanager/keys.go EncKey 가 이미 libevi envelope (KeyEnvelope.hpp) 인데 base64 + 자체 wrapping → libevi evi_km_unwrap_enc_key 가 거부. byte-verbatim write 로 수정
2 boot.go envector ClientConfig KeyDim 누락 → OpenKeysFromFile().validate() 가 Dim<=0 거부. DefaultKeyDim=1024 추가 (Qwen3-Embedding-0.6B)
3 boot.go envector ClientConfig KeyPath: runedir+"/keys" → envector 가 keyID 디렉토리 직접 기대. keymanager.KeyDir(bundle.KeyID) 사용
4 boot.go embedder embedder.New("") // TODO → 빈 socket path. embedder.ResolveSocketPath() priority chain 구현 (env → config → ~/.runed/embedding.sock)
5 tools.go Inject* adapter client 만 propagate, AgentID/AgentDEK/IndexName/KeyID 누락 → capture AES sealing 빈 DEK 으로 fail. Deps.ApplyVaultBundle 신규
6 In-memory status dormant 가 in-memory 만 → 다음 spawn 때 forget. config.MarkDormant(reason) 로 disk 영구 기록 (Python _set_dormant_with_reason 동등)

Python parity 정합성

항목 일치
state != "active" strict check (server.py:L1544) ✅ cfg.State != "active" 통합 분기 ("" / 알 수 없는 값도 dormant)
disk-persisted dormant + reason + RFC3339 since ✅ MarkDormant
동일 reason idempotent (timestamp 보존) ✅ guard 동등
0600 perm + atomic-truncate write ✅
Vault transient retry 시 disk 안 건드림 ✅ in-memory 만

의도된 spec divergence (변경 없음)

  • Vault dial 실패: Python = 한 번 + dormant. / Go = exp backoff retry
  • envector creds disk cache: Python yes / Go no
  • agent_dek 위치: Python = EnVectorClient. Go = service layer
  • Tool 진입 시 boot 미완: Python = 120s wait. Go = 즉시 PIPELINE_NOT_READY

Hygiene

bootOnce helper 분리 (retry 시 conn cleanup) / bootResult enum (retry/active/dormant) / ctx-aware sleep 통일 / attempt counter 일관성 / Manager.LastError() reader

Tests

  • keymanager/keys_test.go (6) — byte-verbatim, perms, KeyDir, idempotency
  • embedder/socket_test.go (5) — env > config > default priority
  • mcp/apply_bundle_test.go (6) — Capture/Recall/Lifecycle propagation, nil-safety
  • config/dormant_test.go (10) — fresh install / 기존 vault 보존 / idempotent / overwrite / perm / timestamp window / Save roundtrip

Verification

  • go build ./... ✅
  • go test ./... ✅

couragehong and others added 7 commits May 7, 2026 14:34
The Vault GetAgentManifest manifest_json carries 'EncKey.json' as a string
that is already a pyenvector-compatible KeyEnvelope (provider_meta + entries
per third_party/evi/include/km/KeyEnvelope.hpp), produced by libevi's
evi_km_wrap_enc_key when keys were generated. The envector SDK loads it via
evi_km_unwrap_enc_key which expects that exact envelope shape.

The previous SaveKeys re-wrapped the bytes ('{"enc_key": "<base64>"}')
which would always fail the cgo unwrap downstream — boot would dial fine,
fetch the manifest, persist a corrupted file, then envector OpenKeysFromFile
would reject it and the daemon would never reach Active.

Renames SaveKeys → SaveEncKey to reflect the single-key scope (Vault no
longer ships EvalKey/SecKey to the plugin). Adds KeyDir(keyID) helper
returning the per-key directory path that envector's WithKeyPath expects
(parent of EncKey.json), so callers don't reconstruct the path inline.
Per spec/components/embedder.md §소켓 경로, the runed daemon socket lookup
order is (1) env RUNE_EMBEDDER_SOCKET, (2) config.embedder.socket_path,
(3) ~/.runed/embedding.sock default. Centralises that priority chain so
both the boot loop and any test/diagnostic harness use the same resolution.

ResolveSocketPath(configPath string) string — pass empty string when the
config layer doesn't carry an explicit override; returns absolute path
under $HOME for the default branch.
The Inject{Vault,Embedder,Envector} methods only set adapter clients on
services. Per-token Vault metadata (AgentID / AgentDEK / IndexName / KeyID)
also needs to reach CaptureService / RecallService / LifecycleService —
without it, capture's AES envelope sealing runs with a zero-value DEK and
recall / lifecycle diagnostics surface zero-value IndexName.

ApplyVaultBundle(*vault.Bundle) does the metadata propagation in one call:
  - Capture     gets AgentID, AgentDEK, IndexName
  - Recall      gets IndexName
  - Lifecycle   gets IndexName, KeyID, AgentDEK, EncKeyLoaded=len(EncKey)>0

Nil-bundle is a no-op for symmetry with other Inject* methods.
…ctor config

Rewrites the boot loop to address five integration bugs in #104's first
draft, plus a handful of hygiene fixes.

Critical:
1. envector.ClientConfig.KeyDim is now set (DefaultKeyDim=1024 const matching
   Qwen3-Embedding-0.6B per spec). Without it, OpenKeysFromFile().validate()
   rejects the call with 'WithKeyDim required'.
2. envector.ClientConfig.KeyPath now points to the per-key directory
   (~/.rune/keys/<keyID>/) via keymanager.KeyDir, not its parent. envector
   resolves EncKey.json directly under WithKeyPath.
3. embedder.New now uses embedder.ResolveSocketPath("") instead of a TODO
   empty-string placeholder.
4. After Vault.GetAgentManifest, deps.ApplyVaultBundle(bundle) propagates
   AgentID / AgentDEK / IndexName / KeyID to the service structs (formerly
   missing → capture's AES envelope would seal with empty DEK).
5. keymanager.SaveEncKey replaces SaveKeys (matching the rename).

Hygiene:
- bootOnce helper isolates one boot attempt so partial-success cleanup
  becomes uniform: any failure after a NewClient succeeds closes the
  partial conns (vault/embedder/envector) before retrying. No more gRPC
  conn leak on retry.
- RunBootLoop runs ctx.Err() at the top of each iteration so SIGTERM
  during sleep stops cleanly.
- empty-config / config-load failures now record into m.lastError so
  diagnostics surfaces the cause; attempt counter increments uniformly.
- 'persistent failure' log fires every 20 attempts based on
  attempts (was checked inline only on Vault path).
- Added Manager.LastError() public reader for diagnostics tools.
- BootAdapterInjector gains ApplyVaultBundle method for the metadata
  propagation step.

Spec note (open follow-up): RunBootLoop returns on first Active. Re-init
after dormant↔active transitions is the responsibility of
service.LifecycleService.ReloadPipelines (which spawns a fresh
RunBootLoop goroutine) — the boot loop itself does not loop forever after
success, contrary to a literal reading of rune-mcp.md §부팅 시퀀스. Will
revisit if reload behavior turns out wrong.
Adds 18 test cases / 3 packages:

internal/adapters/keymanager/keys_test.go (6 fn):
  - WritesBytesVerbatim         — file content byte-identical to input
                                  (the regression that motivated this PR)
  - EmptyIsNoop                  — nil / empty []byte both no-op, no dir created
  - FilePerm0600                 — written file is 0600
  - DirPerm0700                  — created keyDir is 0700
  - KeyDir_ReturnsExpectedPath   — $HOME/.rune/keys/<keyID>
  - OverwritesExisting           — repeat call replaces content

internal/adapters/embedder/socket_test.go (5 fn):
  - EnvVarWins                   — RUNE_EMBEDDER_SOCKET trumps configPath
  - ConfigUsedWhenEnvUnset       — explicit config path used when env empty
  - DefaultWhenNothingProvided   — $HOME/.runed/embedding.sock fallback
  - DefaultEndsInRunedDir        — invariant on default suffix
  - EmptyConfigStringFalsThrough — explicit empty string ≠ valid path

internal/mcp/apply_bundle_test.go (6 fn):
  - PropagatesToCapture          — AgentID / AgentDEK / IndexName set
  - PropagatesToRecall           — IndexName set
  - PropagatesToLifecycle        — IndexName / KeyID / AgentDEK / EncKeyLoaded
  - EncKeyLoadedFalseWhenEmpty   — len(EncKey)==0 → EncKeyLoaded=false
  - NilBundleNoOp                — nil bundle leaves services untouched
  - NilServicesNoOp              — nil-safe: empty Deps doesn't panic

All pass under $HOME redirection via t.Setenv (no real $HOME side
effects).
…hon parity)

Mirrors Python server.py _set_dormant_with_reason: when boot loop
encounters a terminal failure (config missing, vault creds missing, or
config.state already dormant), update ~/.rune/config.json with
state="dormant" + dormant_reason + dormant_since (RFC3339 UTC). The
next process spawn picks up the same state without re-attempting boot
until the user explicitly runs /rune:configure or /rune:reload_pipelines.

Adds:
- config.Save / SaveToPath — atomic-ish 0600 write (mirrors Python's
  O_WRONLY|O_CREAT|O_TRUNC pattern).
- config.MarkDormant(reason) — load → check idempotency → update fields
  → save. Creates a fresh Config when config.json is missing (fresh
  install path).

Wires three boot.go bootDormant branches:
- config.json missing             → MarkDormant("not_configured")
- config.state already "dormant" → MarkDormant(refresh) — reaffirms
                                    timestamp + reason on each spawn so
                                    diagnostics shows the most recent
                                    cause
- vault endpoint/token missing    → MarkDormant("vault_unconfigured")

Vault transient failures (dial / GetAgentManifest) still retry in-memory
without touching config.json — those are recoverable network blips, not
user-actionable terminal states. Diverges from Python on this point
(Python = always disk-persist on Vault fail) per spec/components/rune-mcp.md
§부팅 시퀀스 retry policy.

Tests (9 fn / config_test): fresh-install create, existing-config field
preservation, idempotent same-reason, new-reason overwrite, 0600 perm,
DormantSince timestamp window, Save roundtrip, dir auto-create, error
prefix sanity.
config.Load wraps the underlying os.PathError via fmt.Errorf("...: %w", err),
so os.IsNotExist (which only checks the immediate error sentinel, not the
unwrap chain) returned false on fresh installs. The result: boot loop
treated missing config.json as a transient parse error and retried
forever instead of going dormant("not_configured").

Caught by smoke scenario #3 (rm ~/.rune/config.json → run binary):
  before: ERROR boot: failed to load config err="config: read ...: no such file"
   after: WARN  boot: config.json not found — entering dormant
          + ~/.rune/config.json created with state=dormant reason=not_configured

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@couragehong couragehong self-assigned this May 7, 2026
@couragehong
couragehong merged commit 3d0afee into feat/go-migration May 7, 2026
1 check passed
@couragehong
couragehong deleted the couragehong/feat/boot-loop-fixes branch May 7, 2026 10:37
couragehong added a commit that referenced this pull request May 8, 2026
The Go rune-mcp at internal/* + cmd/rune-mcp/ has reached parity (PR
#102 + #110 + #117) and end-to-end verification (#118, #122, #124),
which means the Python tree is now dead weight. Carrying both
implementations is actively misleading: a fresh contributor following
the in-repo install instructions would still land in mcp/ + agents/
and try to set up a venv that no longer ships, and parity audits keep
re-discovering the Python source instead of treating the Go side as
the source of truth.

Removed
-------

  agents/common/        12 files  (config, embedding, llm, schemas)
  agents/retriever/      4 files  (query_processor, searcher, synthesizer)
  agents/scribe/        12 files  (detector, llm_extractor, handlers, server)
  agents/tests/         16 files  (pytest suite)
  agents/__init__.py
  agents/README.md            (Python agents intro — gone with the impl)
  agents/SLACK_SETUP.md       (Slack notifier setup for Python scribe)
  mcp/                  18 files  (Python adapter + server + tests)
  requirements.txt            (root Python dependency list)
  scripts/migrate_embeddings.py (one-off Python migration helper)

  Total: 67 files, 17,815 lines.

Kept (intentional)
------------------

  agents/claude/{scribe,retriever}.md   referenced by .claude-plugin/
                                        plugin.json — agent prompts that
                                        the runtime loads
  agents/codex/scribe.md                Codex-side agent prompt
  agents/gemini/{scribe,retriever}.md   Gemini-side agent prompts
  benchmark/                            deferred (separate decision —
                                        rewrite in Go vs delete entirely)
  docs/v04/spec/python-mapping.md       parity blueprint that maps Python
                                        source to Go destinations; useful
                                        as a historical record post-deletion
  docs/migration/*.md                   migration plan + audit trail —
                                        intentional history
  scripts/bootstrap-mcp.sh and other    referenced by gemini-extension.json;
    Python-era shell scripts            removal blocked on Gemini support
                                        decision (separate PR)

Not in scope
------------

  .github/workflows/pr-tests.yml + pr-comment.yml — Python pytest CI;
    handled by PR #125 (ci-drop-python).
  README.md, CLAUDE.md, SKILL.md, AGENT_INTEGRATION.md, GEMINI.md,
    CONTRIBUTING.md — top-level docs still describe the v0.3 install
    flow; rewrite scheduled separately so this commit stays focused
    on code deletion.

Verification
------------

  go build ./...   passes
  go vet ./...     passes
  go test ./...    full suite passes (no test referenced deleted paths)
  grep across remaining .{go,md,json,sh,toml,yml,yaml} for the deleted
    paths returned zero hits — no dangling references.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant