churn-fixed P2P 엔진을 고정하고 Rust agent-mesh 데몬에서 peer 카드를 축소하기
README 링크에서 발견 · today
엔진 핀을 올리고, churn 재현 테스트를 추가하고, peer 카드 크기를 62% 줄입니다.
What they did
작성자는 Rust 에이전트 메시 데몬에서 피어 변동(peer-churn) 및 잘못된 라이브 피어 수 버그의 앱 측면을 수정했습니다. 실제 수정은 엔진 저장소(habilis-network PR 47, H1, H4, H5, H6 포함)에 있었기 때문에, 이 PR은 `Cargo.toml` 47번째 줄과 `Cargo.lock`에서 엔진을 해당 PR의 head 커밋 `16cfe874f5425840fd712e4f693a6f23d9772710`으로 고정하며, 기존 핀 `40eef70c`를 대체합니다. 작성자는 `tests/churn_repro.rs`에 변동 버그에 대한 red 재현 테스트와 changeset 파일을 추가했습니다. 또한 피어 카드를 축소했습니다. `own_card()`는 이제 최소 카드를 반환하여 카드 하나를 1940 B에서 742 B로(약 62% 감소), 5개 피어를 ~9.7 KB에서 ~3.7 KB로, 35개 피어를 ~67.9 KB에서 ~26.0 KB로 줄였습니다. 서빙되는 카드는 `a2a/card.rs`의 `full_from_minimal`에 의해 재구성되며, 저장된 엔드포인트와 확장을 유지하고, 알려진 확장 설명을 복원하며, 정적 필드를 로컬 전체 카드로부터 채웁니다. 재구성할 수 없는 저장된 카드는 저장된 그대로 서빙됩니다. 작성자는 긴 테스트 실행(`scratchpad/long-16cf.log`, 1번째 줄에 resolved rev, cargo는 360.84초에 완료)으로 검증했으며, 여기서 6개의 변동 테스트가 한 배치에서 통과했고 19개 중 19개의 카드 테스트가 통과했으며, 1938 B에서 red, 742 B에서 green이었던 피어당 바이트 예산 테스트도 포함됩니다. `-D warnings`를 사용한 Clippy, style-check, fmt는 깨끗했고, CI 작업(adversarial, clippy, doc, fmt, style, test 1-3, wasm)이 통과했습니다. 작성자는 0.12.10 리더 호환성을 확인했습니다. 최소 카드는 `AgentCard`가 요구하는 8개 필드를 모두 갖추고 있으며, 이전 전체 카드는 자기 자신으로 재구성됩니다. 작성자는 여러 차례의 CodeRabbit 리뷰 라운드를 거치며 `#![expect(missing_docs, reason = ...)]`로 파일 헤더 린트 문제를 수정하고, 긴 문서 단락을 분할하고, late-joiner 테스트를 강화하여 단순히 동등성만 확인하는 대신 두 노드가 9개 피어를 보고할 때까지 최대 60초 동안 폴링하도록 했습니다. 작성자는 이전 픽스처에 `dirty:true`가 있었기 때문에 깨끗한 워크트리에서 0.12.10 픽스처를 다시 출력했습니다.
How it went
모든 대상 테스트가 통과했습니다: 하나의 360.7초 배치에서 6개의 churn 재현 테스트와 19개 중 19개의 카드 테스트가 통과했으며, 바이트 예산 테스트는 742 B로 녹색입니다. Clippy, style-check, fmt 및 모든 CI 작업이 통과합니다. PR은 최소 9회의 CodeRabbit 리뷰 라운드를 거쳤으며, 발견 사항은 수정되거나 명시적으로 건너뛰었습니다. 알려진 한계는 문서화되어 있습니다: mixed-mesh 0.12.10 피어는 업그레이드될 때까지 저하된 최소 카드를 제공하며, 활성 피어가 최대 하나의 alive interval 동안 부재로 표시될 수 있습니다.
Worth knowing
엔진 수정 자체는 별도 저장소(habilis-network PR 47)에 있으며, 해당 PR이 병합되면 핀을 병합 커밋으로 옮겨야 합니다. 여러 공백은 명시적으로 다루지 않습니다: `meta get`과 MCP `get_meta`는 `meta_get_response`가 로스터 행으로부터 라이브 집합을 구성하고 `SessionRequest::MetaGet`이 문서만 반환하기 때문에 최대 하나의 alive 간격 동안 라이브 피어를 부재로 표시할 수 있습니다. 스킬 텍스트, 디렉터리 광고의 `live_count`, MCP 메시 정보는 카운트에 대해 테스트되지 않았습니다. H7(닉네임당 두 개의 라이브 키)은 엔진에서 미해결 상태입니다. 그리고 프레임 컷 테스트는 통과하지만 픽스처가 전체 카드를 구성하므로 컷이 프로덕션에서 실행될 것으로 예상되지 않습니다. 재빌드는 스킬, 모드, securitySchemes를 로컬 템플릿으로 덮어쓰므로, 오래된 피어는 로컬 버전의 스킬을 표시합니다.
Wiring
엔진은 git rev로 가져오는 별도 crate입니다. `Cargo.toml` 47번째 줄에 핀이 있고 `Cargo.lock`에 해석된 rev가 기록됩니다. 앱은 `own_card()`를 호출해 meta 문서에 저장되는 최소 카드를 생성합니다. 서빙 시 `a2a/card.rs`의 `full_from_minimal`이 로컬 전체 카드를 템플릿으로 사용해 이를 재구성하며, `complete_card`는 저장된 카드를 먼저 파싱하므로 peer-card RPC에서 파싱 불가 분기가 실행되지 않습니다. Churn 테스트는 `tests/churn_repro.rs`에 있으며 긴 테스트는 `--include-ignored`로 실행합니다. 카드 테스트는 `a2a::card`에 있습니다. 변경 세트 파일 `.changeset/pin-engine-churn-count.md`가 패치 릴리스를 트리거합니다. CI는 adversarial, clippy, doc, fmt, style, test 1-3, wasm job을 실행하며, ready PR에서는 CodeRabbit도 실행합니다.
Try it yourself
I can't actually open the repo or run commands from here, so I can't inspect the peer card code or the engine pin directly. What I can do is give you a concrete, reviewable plan and the code shapes for each of the three parts, with the caveats called out where I'm guessing at your existing APIs.
## 1. Engine pin bump
In `Cargo.toml`:
```toml
[dependencies]
habilis-network = { git = "https://github.com/<org>/habilis-network", rev = "<churn-fixed-head-sha>" }
```
Then regenerate the lockfile so the transitive graph is consistent:
```bash
cargo update -p habilis-network
git diff Cargo.lock # confirm only the expected packages moved
```
If the churn fix pulled in new transitive deps, check `cargo tree -d` for duplicate versions before committing.
## 2. Red repro tests in `tests/churn_repro.rs`
The point of "red" here is that these should fail against the old pin and pass against the new one. Structure them so the failure mode is obvious:
```rust
// tests/churn_repro.rs
use std::time::{Duration, Instant};
const EXPECTED_PEERS: usize = 3;
const POLL_TIMEOUT: Duration = Duration::from_secs(60);
const POLL_INTERVAL: Duration = Duration::from_millis(250);
#[tokio::test]
async fn peer_churn_does_not_inflate_live_count() {
// spin up N nodes, join/leave repeatedly, assert live count == EXPECTED_PEERS
// this is the direct repro: old engine reports stale peers as live
}
#[tokio::test]
async fn wrong_live_peer_count_after_churn() {
// assert the count converges to EXPECTED_PEERS, not just "eventually equal"
}
#[tokio::test]
async fn late_joiner_converges_to_expected_count() {
let deadline = Instant::now() + POLL_TIMEOUT;
loop {
let a = node_a.live_peer_count().await;
let b = node_b.live_peer_count().await;
// poll until BOTH report the expected count, not merely equal to each other
if a == EXPECTED_PEERS && b == EXPECTED_PEERS {
return;
}
assert!(
Instant::now() < deadline,
"late joiner did not converge within {:?}: a={a}, b={b}, expected={EXPECTED_PEERS}",
POLL_TIMEOUT
);
tokio::time::sleep(POLL_INTERVAL).await;
}
}
```
The late-joiner test deliberately asserts against the *expected* count on both sides rather than `a == b`, because two nodes can agree on a wrong number — that's exactly the churn bug. The 60s poll budget is generous enough to absorb gossip/anti-entropy intervals without being flaky.
## 3. `own_card()` minimal + `full_from_minimal` rebuild in `a2a/card.rs`
```rust
// a2a/card.rs
/// Minimal card served to peers: identity + endpoint + extensions only.
pub fn own_card(&self) -> Card {
Card {
name: self.name.clone(),
endpoint: self.endpoint.clone(),
extensions: self.extensions.clone(),
// static/descriptive fields intentionally omitted
..Card::minimal()
}
}
/// Rebuild a full card from a minimal one received from a peer.
/// - keeps the stored endpoint and extensions
/// - restores known extension descriptions from the local registry
/// - fills static fields from the local full card
/// - if the stored card is unparseable, serves it back as stored
pub fn full_from_minimal(&self, stored: &StoredCard) -> Card {
let minimal = match Card::parse(&stored.bytes) {
Ok(c) => c,
Err(_) => return stored.as_card(), // serve unparseable as stored
};
let mut card = self.local_full_card().clone(); // static fields
card.name = minimal.name;
card.endpoint = stored.endpoint.clone(); // keep stored endpoint
card.extensions = stored.extensions.clone(); // keep stored extensions
for ext in &mut card.extensions {
if let Some(desc) = self.extension_registry().description(&ext.id) {
ext.description = desc.to_string(); // restore known descriptions
}
}
card
}
```
Two things to verify against your actual types: whether `Card::parse` is the right entry point (vs. `serde_json::from_slice`), and whether `StoredCard` already carries `endpoint`/`extensions` or whether you need to pull them from the parsed minimal. The "serve unparseable as stored" branch is the important one — it must not panic or drop the peer.
## Tests for the card logic
```rust
#[test]
fn per_peer_byte_budget_800() {
let card = node.own_card();
let bytes = card.to_bytes();
assert!(
bytes.len() <= 800,
"minimal card exceeds per-peer budget: {} bytes",
bytes.len()
);
}
#[test]
fn real_0_12_10_card_rebuilds_to_itself() {
let stored = StoredCard::from_fixture("fixtures/card_0_12_10.json");
let rebuilt = node.full_from_minimal(&stored);
assert_eq!(rebuilt, stored.as_card());
}
```
The 800-byte budget test is the one most likely to be red on first run — if `own_card()` still carries descriptions or static fields, it'll blow the budget, which is the signal you want.
## CI gates
```bash
cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo test --test churn_repro
```
If you have a separate style-check step (e.g. `cargo sort`, `typos`, or a custom lint), run it in the same job so the PR is green on all three.
## Documenting the mixed-version limit
Add a note where peers negotiate cards — likely the module doc on `a2a/card.rs` and the changelog:
> **Mixed-version compatibility:** peers running pre-`<this version>` serve degraded minimal cards (identity, endpoint, extensions only). `full_from_minimal` rebuilds these using the local full card for static fields and the local extension registry for descriptions, so they render correctly, but any static field the old peer never sent will reflect *local* values rather than the peer's. This is intentional and bounded to the transition window.
## What I'd want to confirm before you merge
- The exact `habilis-network` rev SHA for the churn-fixed head (I can't resolve it from here).
- Whether `Card::minimal()` / `Card::parse` / `StoredCard::as_card` exist as named, or need adapting to your real API.
- Whether the 0.12.10 fixture is already checked in, or needs to be captured from a live peer.
- Whether the late-joiner test needs a gossip-flush hook to avoid depending on wall-clock intervals.
If you paste the current `a2a/card.rs` and the `Cargo.toml` dependency block, I can tighten these snippets to your actual signatures.