Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .claude/skills/resolving-issues/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Why this shape:
Fresh agent per issue, scoped to one issue + master branch ref. Steps:

1. Read the issue. Decide if more context needed.
2. **Research (optional, parallel OK):** spawn research subagents for codebase grep, related-file reads, spec lookups. Synthesize before coding.
2. **Research (optional, parallel OK):** spawn research subagents for codebase grep, related-file reads, spec lookups. Synthesize before coding. Re-grep cited line numbers / LOC counts at HEAD before working from issue-body literal positions — they drift fast across refactors and may be off by hundreds of lines after a recent file move.
3. **Complexity gate — automated brainstorm + plan when warranted:**
- **Trigger any of:** issue spans > 1 crate, fix touches state machine / wire format / migration paths, ≥ 2 reasonable approaches exist, root cause not obvious from issue text, fix likely > 5 files OR > 200 LOC, "it depends" question on scope.
- **Skip when:** issue is a one-liner / config swap / typo / clearly mechanical (single rg-pattern site) / has explicit "Suggested fix" the implementer can follow verbatim.
Expand Down Expand Up @@ -102,7 +102,9 @@ Fresh agent per issue, scoped to one issue + master branch ref. Steps:

11. **Stale-audit-with-residual-gap path:** if pre-flight investigation shows the audit's literal premise is stale (e.g. "zero tests" — but a later PR added some) but its underlying concern is partially valid (some specific gap remains), narrow scope to the residual gap and ship that. Note the audit's stale framing + cite the upstream PR that resolved most of it in the commit body. Coordinator still records under `Fixes #N` because the audit issue is the right closer.

12. **Report back** to coordinator: commit SHA on master branch, sites touched, anything unusual.
12. **Structural-deps follow-up family path:** dependency-multi-version audits (rand, getrandom, convert_case, bincode, etc.) often look "obvious" but are pinned by transitive crates we don't own — no workspace pin / `[patch]` can collapse them without lying about semver. The first 1–2 finds get individual follow-up trackers. On the **3rd** structural-deps follow-up in this family, file or update a single **upstream-domino meta-tracker issue** instead of another standalone TD-NN follow-up — list the holdout crates, the upstream releases that would unblock each version (e.g. `aes-gcm 0.11` stable, `derive_more 3.x`, `iroh ≥ N`), and link prior individual follow-ups under it. Future runs check the meta-tracker, don't refile the same shape.

13. **Report back** to coordinator: commit SHA on master branch, sites touched, anything unusual.

## Lessons Learned

Expand Down
82 changes: 63 additions & 19 deletions crates/client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1260,6 +1260,26 @@ pub async fn test_client_on_hub(
mod tests {
use super::*;

/// Poll a derived view until `predicate` returns `true`, yielding
/// between attempts. Used in place of fixed `sleep(50ms)` waits after
/// mutations on a *source* actor whose change must propagate through
/// a `DerivedActor` recompute (Notify → spawn → UpdateCache hop).
/// Panics on a 5-second deadline so a real regression surfaces fast.
async fn await_view<F, Fut>(mut probe: F)
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = bool>,
{
let deadline = std::time::Duration::from_secs(5);
let ok = tokio::time::timeout(deadline, async {
while !probe().await {
tokio::task::yield_now().await;
}
})
.await;
assert!(ok.is_ok(), "derived view did not converge within 5s");
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn actor_system_creates_and_responds() {
let (client, _rx) = test_client();
Expand All @@ -1271,8 +1291,9 @@ mod tests {
async fn send_message_and_read_back() {
let (client, _rx) = test_client();
client.send_message("general", "hello").await.unwrap();
// Give actor time to process
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// send_message awaits state::mutate(event_state) inside apply_event,
// and messages() reads the same actor — the FIFO mailbox guarantees
// the mutation is visible without a wall-clock wait.
let msgs = client.messages("general").await;
assert!(!msgs.is_empty());
assert_eq!(msgs.last().unwrap().body, "hello");
Expand Down Expand Up @@ -1318,18 +1339,19 @@ mod tests {
.send_message("general", "hello server2")
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;

// Verify the message is on the current (server2) state.
// Verify the message is on the current (server2) state. send_message
// awaits the event_state mutation; messages() reads the same actor.
let msgs = client.messages("general").await;
assert!(
msgs.iter().any(|m| m.body == "hello server2"),
"message should be on server2"
);

// Switch back to server1.
// Switch back to server1. switch_server awaits every actor mutation
// it issues (dag, event_state, server_registry, chat_meta), so the
// subsequent messages() read sees the restored state immediately.
client.switch_server(&server1_id).await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;

// After switching, messages should NOT contain server2's message.
let msgs = client.messages("general").await;
Expand Down Expand Up @@ -1361,12 +1383,11 @@ mod tests {
"Bob should not have SendMessages before invite"
);

// Alice generates an invite for Bob.
// Alice generates an invite for Bob. The GrantPermission event is
// built and applied via apply_event, which awaits the event_state
// mutate; the subsequent select() on the same actor sees the grant.
alice.generate_invite(&bob_id).await.unwrap();

// Give the actor system a tick to process the mutation.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;

// Now Bob should have SendMessages permission in Alice's state.
let has_after = willow_actor::state::select(&alice.event_state_addr, move |es| {
es.has_permission(&bob_id, &willow_state::Permission::SendMessages)
Expand Down Expand Up @@ -1851,8 +1872,13 @@ mod tests {
client
.set_self_presence(presence::PresenceOverride::Away)
.await;
// Give the derived view a tick to recompute.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// The derived presence view must recompute (Notify → selector →
// UpdateCache) before reflecting the source mutation. Poll the
// cached value rather than hard-waiting on wall-clock time.
await_view(|| async {
client.view_handle.presence.get().await.self_state == presence::PresenceState::Away
})
.await;

let after = client.view_handle.presence.get().await;
assert_eq!(after.self_state, presence::PresenceState::Away);
Expand All @@ -1866,7 +1892,12 @@ mod tests {
let bob = willow_identity::Identity::generate().endpoint_id();

client.mutations().peer_connected(bob).await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Wait for the derived presence view to pick up bob from the
// updated chat_meta source — same race the old sleep papered over.
await_view(|| async {
client.observe_peer_presence(bob).await == presence::PresenceState::Here
})
.await;

let state = client.observe_peer_presence(bob).await;
assert_eq!(state, presence::PresenceState::Here);
Expand Down Expand Up @@ -1894,9 +1925,15 @@ mod tests {
connect::tick_once_for_test(&client.presence_meta_addr, &client.chat_meta_addr).await;
// Drop bob offline and advance a few ticks.
client.mutations().peer_disconnected(bob).await;
// Allow the derived view to recompute.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// queue > 0 + reachable = false ⇒ Queued before gone threshold.
// Poll the derived view until it reflects the source mutation.
await_view(|| async {
matches!(
client.observe_peer_presence(bob).await,
presence::PresenceState::Queued(_)
)
})
.await;
let before = client.observe_peer_presence(bob).await;
assert!(
matches!(before, presence::PresenceState::Queued(_)),
Expand All @@ -1907,8 +1944,11 @@ mod tests {
for _ in 0..6 {
connect::tick_once_for_test(&client.presence_meta_addr, &client.chat_meta_addr).await;
}
// Let the derived view settle after the mutation burst.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Wait for the derived view to settle on Gone after the burst.
await_view(|| async {
client.observe_peer_presence(bob).await == presence::PresenceState::Gone
})
.await;
let pm = willow_actor::state::get(&client.presence_meta_addr).await;
let elapsed = pm
.now
Expand Down Expand Up @@ -1976,8 +2016,10 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mutate_channel_mute_emits_event_and_flips_stats() {
let (client, _broker) = test_client();
// create_channel awaits apply_event (event_state mutate). No need
// to wait for broker delivery here — subscribe_events comes after,
// so the prior CreateChannel publish cannot leak into the receiver.
client.create_channel("quiet").await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;

let mut rx = client.subscribe_events().await;
client.mutate_channel_mute("quiet", true).await.unwrap();
Expand Down Expand Up @@ -2061,8 +2103,10 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mutate_channel_mute_toggle_off_clears_set() {
let (client, _broker) = test_client();
// Both create_channel and the two mutate_channel_mute calls await
// their apply_event → state::mutate(event_state), so the final get
// sees the toggled-off set without a wall-clock delay.
client.create_channel("noisy").await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;

client.mutate_channel_mute("noisy", true).await.unwrap();
client.mutate_channel_mute("noisy", false).await.unwrap();
Expand Down
23 changes: 22 additions & 1 deletion crates/state/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,28 @@
//! networking — just DAG operations and deterministic state projection.

#[cfg(test)]
mod tests;
#[path = "tests/dag.rs"]
mod tests_dag;

#[cfg(test)]
#[path = "tests/materialize.rs"]
mod tests_materialize;

#[cfg(test)]
#[path = "tests/permissions.rs"]
mod tests_permissions;

#[cfg(test)]
#[path = "tests/stress.rs"]
mod tests_stress;

#[cfg(test)]
#[path = "tests/sync.rs"]
mod tests_sync;

#[cfg(test)]
#[path = "tests/voting.rs"]
mod tests_voting;

pub mod dag;
pub mod ephemeral;
Expand Down
Loading