diff --git a/.github/workflows/_ci-rust.yml b/.github/workflows/_ci-rust.yml index e25639d3ac7..3cffbff1373 100644 --- a/.github/workflows/_ci-rust.yml +++ b/.github/workflows/_ci-rust.yml @@ -141,6 +141,8 @@ jobs: for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do touch "desktop/src-tauri/binaries/${bin}-${TARGET}.exe" done + - name: Test (ACP checked retirement on Windows) + run: python scripts/check-acp-retirement.py - name: Clippy (workspace) run: cargo clippy --workspace --all-targets --target $env:TARGET -- -D warnings - name: Check (workspace) @@ -206,11 +208,27 @@ jobs: env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" + acp-retirement-macos: + name: ACP checked retirement (macOS) + runs-on: macos-latest + timeout-minutes: 30 + if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.rust) + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + key: acp-retirement-macos + save-if: ${{ github.event_name != 'pull_request' }} + - name: Test (ACP checked retirement on macOS) + run: python3 scripts/check-acp-retirement.py results: name: Results if: ${{ always() }} - needs: [rust-lint, unit-tests, server-cross-compile, windows-rust] + needs: [rust-lint, unit-tests, server-cross-compile, windows-rust, acp-retirement-macos] runs-on: ubuntu-latest timeout-minutes: 5 permissions: {} diff --git a/AGENTS.md b/AGENTS.md index a82712fc061..ab9434dc6d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -427,6 +427,10 @@ See [TESTING.md](TESTING.md) for the full multi-agent E2E guide. For mobile simulator screenshots, save the PNGs in a local directory and run `./scripts/post-screenshots.sh ` or use the third argument with a markdown template containing `{{filename}}` placeholders. +For a fork, select both destinations explicitly: +`./scripts/post-screenshots.sh --repo owner/repo --remote fork `. +The script preserves screenshot history with a normal push and signs off its +asset commit using the effective Git identity. A rejected push stops publication. The desktop app requires the E2E mock bridge to render — it cannot run in a plain browser. Use `just desktop-screenshot` to capture screenshots (builds frontend, diff --git a/Cargo.lock b/Cargo.lock index acab0f295b1..737b40ff9b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -974,6 +974,7 @@ dependencies = [ "chrono", "clap", "evalexpr", + "fs2", "futures-util", "git-credential-nostr", "git-sign-nostr", @@ -981,6 +982,7 @@ dependencies = [ "httparse", "nix 0.31.3", "nostr 0.44.7", + "process-wrap 10.0.1", "reqwest 0.13.4", "rustls", "serde", @@ -996,6 +998,7 @@ dependencies = [ "tracing-subscriber", "url", "uuid", + "win32job", "zeroize", ] @@ -11721,6 +11724,16 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "win32job" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6a6724ccfbf34154a8691bd868b0fcd2be2ca3f7b47b32614654f1a01b191c" +dependencies = [ + "thiserror 1.0.69", + "windows 0.61.3", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/FORK_PATCHES.md b/FORK_PATCHES.md index 6a961e6259d..b8d47c3536e 100644 --- a/FORK_PATCHES.md +++ b/FORK_PATCHES.md @@ -2196,11 +2196,13 @@ New module: `true`. ## `desktop/src/features/agents/AGENTS.md` -Device sharing creation UI, authoritative home presentation and saved-definition recovery +Device sharing creation UI, authoritative home presentation and saved-definition recovery Document scoped session permission, routing, restart warning, original command/result authority and batched actor behavior. Thread-composer agent-first context placement, bounded raw participant roster, disabled-editor availability and captured scope reset. New module: `false`. +- Required symbol: `## Scoped session controls` - Verify: `just desktop-test desktop-typecheck` +- Verify: `CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts` ## `desktop/src/features/agents/lib/savedDefinitionRecovery.ts` @@ -2626,6 +2628,8 @@ Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches New module: `false`. +- Required symbol: `python scripts/check-acp-retirement.py` +- Required symbol: `python3 scripts/check-acp-retirement.py` - Verify: `just ci` ## `.github/workflows/ci.yml` @@ -2712,10 +2716,26 @@ New module: `false`. ## `crates/buzz-acp/src/acp.rs` -Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches +Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches Route standard usage_update occupancy to the status binding of its own session ID; the harness owns BUZZ_SESSION_STATUS_DIR in adapter spawns. New module: `false`. +- Required symbol: `pub async fn session_resume(` +- Required symbol: `self.resume_supported = result` +- Required symbol: `pub(crate) fn bind_session_status(` +- Required symbol: `pub(crate) fn clear_session_status(` +- Required symbol: `DispatchRetired,` +- Required symbol: `"session": {"compaction": {}}` +- Required symbol: `if update_type == "compaction_update"` +- Invocation: `crates/buzz-acp/src/acp.rs` → `record_usage_update`; exact call `crate::session_status::record_usage_update(&self.session_status, msg);`; behavior test `claude_usage_updates_route_by_their_own_session_id`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/acp.rs` → `apply_status_dir_env`; exact call `crate::session_status::apply_status_dir_env(&mut cmd, extra_env);`; behavior test `status_dir_env_is_owned_by_the_harness`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/acp.rs` → `update`; exact call `self.compactions.update(session, update)`; behavior test `compaction_standard_dispatch_terminal_only_duplicate_and_next_request`; verify `cargo test --locked -p buzz-acp --lib compaction_standard_dispatch_terminal_only_duplicate_and_next_request -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/acp.rs` → `compacted`; exact call `binding.compacted(origin);`; behavior test `typed_compaction_changes_only_transition_and_manual_actor`; verify `cargo test --locked -p buzz-acp --lib typed_compaction_changes_only_transition_and_manual_actor -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/acp.rs` → `prepare_resume`; exact call `self.compactions.prepare_resume(session_id)`; behavior test `resumed_pre_ack_compaction_cannot_become_a_manual_completion_or_actor`; verify `cargo test --locked -p buzz-acp --lib resumed_pre_ack_compaction_cannot_become_a_manual_completion_or_actor -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/acp.rs` → `complete_resume`; exact call `.complete_resume(session_id, generation, response.is_ok());`; behavior test `resumed_pre_ack_compaction_cannot_become_a_manual_completion_or_actor`; verify `cargo test --locked -p buzz-acp --lib resumed_pre_ack_compaction_cannot_become_a_manual_completion_or_actor -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/acp.rs` → `bind`; exact call `self.compactions.bind(session_id, binding.generation_id());`; behavior test `resumed_pre_ack_compaction_cannot_become_a_manual_completion_or_actor`; verify `cargo test --locked -p buzz-acp --lib resumed_pre_ack_compaction_cannot_become_a_manual_completion_or_actor -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/acp.rs` → `prepare_new`; exact call `self.compactions.prepare_new();`; behavior test `compact_uses_pre_ack_new_session_advertisement_after_ordinary_completion`; verify `cargo test --locked -p buzz-acp --lib compact_uses_pre_ack_new_session_advertisement_after_ordinary_completion -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/acp.rs` → `complete_new`; exact call `self.compactions.complete_new(generation, returned_session);`; behavior test `failed_or_malformed_new_response_cannot_adopt_pre_ack_advertisement`; verify `cargo test --locked -p buzz-acp --lib failed_or_malformed_new_response_cannot_adopt_pre_ack_advertisement -- --test-threads=1` - Verify: `just ci` ## `crates/buzz-acp/src/acp/launch.rs` @@ -2756,6 +2776,8 @@ Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches New module: `false`. +- Required symbol: `BUZZ_ACP_SESSION_STORE_DIR` +- Required symbol: `BUZZ_ACP_NO_SESSION_RESUME` - Verify: `just ci` ## `crates/buzz-acp/src/edit_routing.rs` @@ -2776,11 +2798,53 @@ New module: `false`. ## `crates/buzz-acp/src/lib.rs` -Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches Preserve the heartbeat collection call with a textual drift guard; full live-service-loop behavioral mutation coverage is not claimed. +Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches Preserve the heartbeat collection call with a textual drift guard; full live-service-loop behavioral mutation coverage is not claimed. Refresh idle clocks when a non-heartbeat prompt result is handled (port of block/buzz#7173). Enable session status once per harness and retire status on owner rotate and channel removal; forward the status directory to the managed MCP server. New module: `false`. - Required symbol: `pool.queue_finished_background_work(&mut queue);` +- Required symbol: `fn result_refreshes_idle_clock(source: &PromptSource) -> bool` +- Required symbol: `mod session_status;` +- Required symbol: `name == session_status::SESSION_STATUS_DIR_ENV` +- Required symbol: `ctx.session_controls.compact_pending(&scope) && batch.continuation.is_none()` +- Required symbol: `fn handle_owner_cancel_for_scope` +- Invocation: `crates/buzz-acp/src/lib.rs` → `result_refreshes_idle_clock`; exact call `if result_refreshes_idle_clock(&result.source) {`; behavior test `error_outcome_emission_tests::channel_result_refreshes_idle_clock_wiring`; verify `cargo test -p buzz-acp --lib error_outcome_emission_tests::channel_result_refreshes_idle_clock_wiring -- --exact` +- Invocation: `crates/buzz-acp/src/lib.rs` → `depart_idle_sessions`; exact call `pool.depart_idle_sessions(session_store::Departure::IdleSleep);`; behavior test `pool_sleep_marks_every_idle_session`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `restart`; exact call `match ctx.session_controls.restart(job) {`; behavior test `restart_during_delayed_acquisition_fences_old_dispatch`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `discard_durable_channel`; exact call `pool.discard_durable_channel(ch);`; behavior test `channel_removal_then_readd_starts_new_session`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `open_session_store`; exact call `let session_store = pool::open_session_store(`; behavior test `resume_advertised_uses_session_resume_not_new`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `set_session_store`; exact call `pool.set_session_store(session_store.clone());`; behavior test `rotate_before_first_resume_discards_disk_session`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `enable_session_status`; exact call `runtime.enable_session_status();`; behavior test `every_spawn_path_and_managed_mcp_get_the_current_status_dir`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `retire_scope`; exact call `pool::session_visibility::retire_scope(`; behavior test `rotate_during_a_delayed_turn_retires_before_late_usage`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `retire_channel`; exact call `pool::session_visibility::retire_channel(`; behavior test `channel_removal_retires_every_scope_of_that_channel`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `admit_authorized`; exact call `session_controls::admit_authorized(`; behavior test `controls_use_author_capability_before_subscription_and_reserve_wrong_targets`; verify `cargo test -p buzz-acp --lib controls_use_author_capability_before_subscription_and_reserve_wrong_targets -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `capture`; exact call `agent.state.control_dispatch = Some(ctx.session_controls.capture(&scope));`; behavior test `busy_one_shot_already_consumed_keeps_restart_gate_and_newer_queue`; verify `cargo test -p buzz-acp --lib busy_one_shot_already_consumed_keeps_restart_gate_and_newer_queue -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `retirement_pending`; exact call `if ctx.session_controls.retirement_pending(&scope) + || ctx.session_controls.status_pending(&scope) + || (ctx.session_controls.compact_pending(&scope) && batch.continuation.is_none())`; behavior test `busy_one_shot_already_consumed_keeps_restart_gate_and_newer_queue`; verify `cargo test -p buzz-acp --lib busy_one_shot_already_consumed_keeps_restart_gate_and_newer_queue -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `current`; exact call ` if result + .agent + .state + .control_dispatch + .as_ref() + .is_some_and(|dispatch| !dispatch.current())`; behavior test `late_cancelled_result_cannot_requeue_old_batch_after_restart`; verify `cargo test -p buzz-acp --lib late_cancelled_result_cannot_requeue_old_batch_after_restart -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `discard_retired_output`; exact call `result.outcome = result.outcome.discard_retired_output();`; behavior test `retired_transport_error_replaces_the_worker_through_result_handling`; verify `cargo test -p buzz-acp --lib retired_transport_error_replaces_the_worker_through_result_handling -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `drive`; exact call `worker_retirement::drive(`; behavior test `checked_retirement_executor_waits_for_verified_kill_and_excludes_refill`; verify `cargo test -p buzz-acp --lib checked_retirement_executor_waits_for_verified_kill_and_excludes_refill -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `finish`; exact call `worker_retirement::finish(`; behavior test `checked_retirement_executor_waits_for_verified_kill_and_excludes_refill`; verify `cargo test -p buzz-acp --lib checked_retirement_executor_waits_for_verified_kill_and_excludes_refill -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `return_for_retirement`; exact call `pool.return_for_retirement(result.agent);`; behavior test `retirement_return_boundary_bypasses_crash_budget_for_fatal_old_outcome`; verify `cargo test -p buzz-acp --lib retirement_return_boundary_bypasses_crash_budget_for_fatal_old_outcome -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `publish_result`; exact call `session_controls::publish::publish_result(&rest, &publication).await`; behavior test `control_result_publication_has_three_total_signed_attempts`; verify `cargo test -p buzz-acp --lib control_result_publication_has_three_total_signed_attempts -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `plan_retirement`; exact call `ctx.session_controls.plan_retirement(&scope, targets);`; behavior test `checked_retirement_requires_every_clean_report_and_retries_after_uncertainty`; verify `cargo test -p buzz-acp --lib checked_retirement_requires_every_clean_report_and_retries_after_uncertainty -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `drive`; exact call `orphan_reconciliation.drive(&ctx.session_controls, publication_capacity);`; behavior test `orphan_reconciliation_retries_failed_writes_with_capped_backoff_without_capacity_spin`; verify `cargo test -p buzz-acp --lib orphan_reconciliation_retries_failed_writes_with_capped_backoff_without_capacity_spin -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `wake`; exact call `_ = orphan_reconciliation.wake() => {`; behavior test `orphan_reconciliation_retries_failed_writes_with_capped_backoff_without_capacity_spin`; verify `cargo test -p buzz-acp --lib orphan_reconciliation_retries_failed_writes_with_capped_backoff_without_capacity_spin -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `join_next`; exact call `Some(result) = result_publications.join_next(), if !result_publications.is_empty() => {`; behavior test `orphan_reconciliation_retries_failed_writes_with_capped_backoff_without_capacity_spin`; verify `cargo test -p buzz-acp --lib orphan_reconciliation_retries_failed_writes_with_capped_backoff_without_capacity_spin -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `lose_retirement_evidence`; exact call `controls.lose_retirement_evidence(i);`; behavior test `retirement_foreground_panic_cannot_attest_replacement_or_forget_original_on_retry`; verify `cargo test -p buzz-acp --lib retirement_foreground_panic_cannot_attest_replacement_or_forget_original_on_retry -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `dispatch`; exact call `control_tasks::dispatch(&mut pool, &ctx, &control_tx);`; behavior test `compact_waits_for_exact_busy_owner_without_cancel_or_fork`; verify `cargo test --locked -p buzz-acp --lib compact_waits_for_exact_busy_owner_without_cancel_or_fork -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `wake_pending`; exact call `control_tasks::wake_pending(&mut queue, &ctx.session_controls);`; behavior test `compact_only_lazy_work_requires_existing_session`; verify `cargo test --locked -p buzz-acp --lib compact_only_lazy_work_requires_existing_session -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `compaction_panicked`; exact call `controls.compaction_panicked(i);`; behavior test `compact_actual_panic_terminalizes_started_without_ordinary_retry`; verify `cargo test --locked -p buzz-acp --lib compact_actual_panic_terminalizes_started_without_ordinary_retry -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `compaction_target`; exact call `pool.compaction_target(&job.scope, &ctx.session_controls, config.session_resume)`; behavior test `compact_resume_disabled_disk_refusal_and_replay_never_wake`; verify `cargo test --locked -p buzz-acp --lib compact_resume_ -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `handle_owner_cancel_for_scope`; exact call `handle_owner_cancel_for_scope( + &mut pool, &mut queue, &ctx.session_controls, &scope, observer.as_ref(), + );`; behavior test `uncertain_owner_cancel_has_no_chat_sender_and_fences_held_feedback`; verify `cargo test -p buzz-acp --lib uncertain_owner_cancel_has_no_chat_sender_and_fences_held_feedback -- --test-threads=1` - Verify: `just ci` ## `crates/buzz-acp/src/pool.rs` @@ -2789,6 +2853,38 @@ Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches New module: `false`. +- Required symbol: `pub(crate) fn discard_retired_output(self) -> Self` +- Invocation: `crates/buzz-acp/src/pool.rs` → `discard_durable`; exact call `self.discard_durable(scope);`; behavior test `invalidate_scope_writes_tombstone`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool.rs` → `depart_durable_all`; exact call `self.depart_durable_all(crate::session_store::Departure::WorkerCrash);`; behavior test `invalidate_all_marks_worker_crash_without_tombstone`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool.rs` → `persist_delivery`; exact call `self.persist_delivery(`; behavior test `delivery_success_persists_ids`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool.rs` → `acquire_session`; exact call `match session_continuity::acquire_session(`; behavior test `resume_advertised_uses_session_resume_not_new`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool.rs` → `persist_turns`; exact call `agent.state.persist_turns(scope);`; behavior test `max_turns_survives_resume`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool.rs` → `insert_session_marker`; exact call `session_continuity::insert_session_marker(`; behavior test `marker_present_once_after_start`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool.rs` → `invalidate_after_error`; exact call `agent.state.invalidate_after_error(&source, &e);`; behavior test `idle_worker_transport_death_keeps_session_resumable`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool.rs` → `publish_session_start`; exact call `agent.publish_session_start(`; behavior test `new_session_status_precedes_the_prompt_and_spans_ordinary_turns`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool.rs` → `rebind_session_status`; exact call `agent.rebind_session_status(status_runtime, scope, &sid);`; behavior test `new_session_status_precedes_the_prompt_and_spans_ordinary_turns`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool.rs` → `history_coverage`; exact call `binding.set_history(session_visibility::history_coverage(`; behavior test `pinned_assignment_outside_the_window_counts_supplied_events_once`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool.rs` → `reconcile_session_status_scope`; exact call `if agent.reconcile_session_status_scope(`; behavior test `retained_conversation_revokes_generation_before_rebind_or_late_updates`; verify `cargo test -p buzz-acp --lib session_visibility::tests::policy -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool.rs` → `selected`; exact call `session_history::selected(&context_target, b.channel_id, depth, &ctx).await`; behavior test `selected_restart_depths_reach_actual_prompt_and_supplied_ids`; verify `cargo test -p buzz-acp --lib selected_restart_depths_reach_actual_prompt_and_supplied_ids -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool.rs` → `reserved_raw`; exact call `if crate::session_controls::reserved_raw(obj) {`; behavior test `human_target_reserved_syntax_is_filtered_but_not_executed`; verify `cargo test -p buzz-acp --lib human_target_reserved_syntax_is_filtered_but_not_executed -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool.rs` → `foreground_success`; exact call `dispatch.foreground_success();`; behavior test `bootstrap_success_and_failed_foreground_preserve_restart_policy`; verify `cargo test -p buzz-acp --lib bootstrap_success_and_failed_foreground_preserve_restart_policy -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool.rs` → `current`; exact call ` let stale = agent + .state + .control_dispatch + .as_ref() + .is_some_and(|dispatch| !dispatch.current())`; behavior test `second_restart_survives_old_result`; verify `cargo test -p buzz-acp --lib second_restart_survives_old_result -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool.rs` → `supplied_mention`; exact call `first_mention = session_history::supplied_mention( + first_mention, + &context_target, + conversation_context.as_ref(), + &rendered_batch_ids, + );`; behavior test `none_assignment_omitted_after_profile_render_is_explicitly_not_found`; verify `cargo test -p buzz-acp --lib none_assignment_omitted_after_profile_render_is_explicitly_not_found -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool.rs` → `discard_retired_output`; exact call `(outcome.discard_retired_output(), None)`; behavior test `retired_cancel_drain_expiry_replaces_the_worker_through_result_handling`; verify `cargo test -p buzz-acp --lib retired_cancel_drain_expiry_replaces_the_worker_through_result_handling -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool.rs` → `discard_retired_output`; exact call `let outcome = match prompt_result { + Ok(reason) => PromptOutcome::Ok(reason), + Err(error) => PromptOutcome::Error(error), + } + .discard_retired_output();`; behavior test `retired_adapter_death_replaces_the_worker_through_result_handling`; verify `cargo test -p buzz-acp --lib retired_adapter_death_replaces_the_worker_through_result_handling -- --test-threads=1` - Verify: `just ci` ## `crates/buzz-acp/src/queue.rs` @@ -3490,11 +3586,15 @@ New module: `false`. ## `desktop/playwright.config.ts` -Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches +Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches. Register the session-status smoke spec. Register the scoped session-control smoke workflow. New module: `false`. +- Required symbol: `"**/session-status.spec.ts",` +- Required symbol: `"**/session-controls.spec.ts",` - Verify: `just ci` +- Verify: `cd desktop && pnpm test:e2e:smoke` +- Verify: `CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts` ## `desktop/src-tauri/icons/icon.icns` @@ -4306,11 +4406,13 @@ New module: `false`. ## `desktop/src/testing/e2eBridge.ts` -Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches +Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches Align only native-command mock reply p-tags with the native builder while preserving thread routing. New module: `false`. +- Required symbol: `).filter((tag) => tag[0] !== "p" || tag[1] !== mockPubkey),` - Verify: `just ci` +- Verify: `CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts` ## `desktop/tests/e2e/community-rail.spec.ts` @@ -5378,11 +5480,14 @@ New module: `true`. ## `TESTING.md` -Document production-writer recovery, replica-floor premises, authenticated CLI/relay regression commands and evidence boundaries. +Document production-writer recovery, replica-floor premises, authenticated CLI/relay regression commands and evidence boundaries. Document fresh-build session-control smoke workflow and shared actual-parser fixture coverage. Document typed activity workflow, observed identity boundaries and unchanged observer ownership. Thread-composer context workflow and bounded jsdom failure diagnostics with external process-tree containment. New module: `false`. +- Required symbol: `### Desktop scoped session controls` +- Required symbol: `Observer access remains owner-scoped;` - Verify: `BUZZ_TEST_BIN_DIR="$PWD/target/debug" cargo test -p buzz-relay --lib handlers::ingest::postgres_tests::managed_delivery_tests:: -- --ignored --test-threads=1` +- Verify: `CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts` ## `crates/buzz-acp/TESTING.md` @@ -5413,15 +5518,21 @@ New module: `false`. ## `crates/buzz-acp/src/pool/background_work.rs` -Retain collection authority in the original worker/session, poll child exit independently of pool sleep, and compose stable-key final-report instructions. +Retain collection authority in the original worker/session, poll child exit independently of pool sleep, and compose stable-key final-report instructions. Release uncertainty-only holds without inventing collection; retain observed exit evidence across Unknown. New module: `true`. - Required symbol: `pub(crate) struct RuntimeHold` - Required symbol: `pub fn queue_finished_background_work` - Required symbol: `pub(super) fn followup_prompt` +- Required symbol: `if hold.blocking_pids.is_empty() && !hold.collection_pending {` - Invocation: `crates/buzz-acp/src/pool.rs` → `followup_prompt`; exact call `background_work::followup_prompt(batch.channel_id, continuation, &ctx.cwd)`; behavior test `background_managed_delivery_key_stays_on_collection_across_error_and_new_message`; verify `env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp background_managed_delivery_key_stays_on_collection_across_error_and_new_message -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool/background_work.rs` → `contains`; exact call `!self.retire_on_return.contains(&index)`; behavior test `retirement_targets_include_checked_out_copies_and_block_idle_claims`; verify `cargo test -p buzz-acp --lib retirement_targets_include_checked_out_copies_and_block_idle_claims -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool.rs` → `refresh_runtime_hold`; exact call `self.refresh_runtime_hold(&mut agent);`; behavior test `background_unknown_then_empty_releases_hold_without_inventing_collection`; verify `cargo test -p buzz-acp --lib background_unknown_then_empty_releases_hold_without_inventing_collection -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool/background_work.rs` → `background_child_pids`; exact call `let Some(children) = agent.acp.background_child_pids() else {`; behavior test `background_observed_then_unknown_then_empty_collects_exactly_once`; verify `cargo test -p buzz-acp --lib background_observed_then_unknown_then_empty_collects_exactly_once -- --test-threads=1` - Verify: `env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp background_work_tests -- --test-threads=1` +- Verify: `cargo test -p buzz-acp --lib background_unknown_then_empty_releases_hold_without_inventing_collection -- --test-threads=1` +- Verify: `cargo test -p buzz-acp --lib background_observed_then_unknown_then_empty_collects_exactly_once -- --test-threads=1` ## `crates/buzz-acp/src/pool/background_work_tests.rs` @@ -5455,16 +5566,23 @@ New module: `false`. ## `crates/buzz-acp/src/process_tree.rs` -Bounded process snapshots and resident-helper classification; macOS/Linux executable confirmation releases completed Codex turns while preserving actual child work. +Bounded process snapshots and resident-helper classification; macOS/Linux executable confirmation releases completed Codex turns while preserving actual child work. Exact Codex default Bash snapshots remain transient bookkeeping; their ordinary descendants and launchers remain work. Scoped missing executable evidence remains uncertainty while definite descendants remain observable. New module: `true`. - Required symbol: `pub fn snapshot()` - Required symbol: `pub fn blocking_pids(` - Required symbol: `pub fn is_resident_helper(` -- Invocation: `crates/buzz-acp/src/acp.rs` → `snapshot`; exact call `let tree = crate::process_tree::snapshot()?;`; behavior test `prompt_work_is_tracked_under_worker_and_resident_provider`; verify `env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp prompt_work_is_tracked_under_worker_and_resident_provider -- --test-threads=1` +- Required symbol: `npm_exec_mcp_chain_is_resident` +- Required symbol: `launcher_into_scoped_mcp_package_is_resident` +- Required symbol: `pub(crate) fn is_snapshot_helper(` +- Required symbol: `pub(crate) fn startup_resident_pids(` +- Required symbol: `pub(crate) fn is_snapshot_candidate(` +- Required symbol: `pub(crate) fn is_uncertain_snapshot(` +- Invocation: `crates/buzz-acp/src/acp.rs` → `snapshot`; exact call `crate::process_tree::snapshot()`; behavior test `prompt_work_is_tracked_under_worker_and_resident_provider`; verify `env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp prompt_work_is_tracked_under_worker_and_resident_provider -- --test-threads=1` - Invocation: `crates/buzz-acp/src/acp.rs` → `blocking_pids`; exact call `blocking.extend(tree.blocking_pids(parent, &self.resident_pids));`; behavior test `work_under_late_codex_host_holds_slot_until_child_exits`; verify `env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp resident_helper_tests -- --test-threads=1` - Invocation: `crates/buzz-acp/src/acp.rs` → `is_resident_helper`; exact call `self.resident_pids.contains(&child.pid) || tree.is_resident_helper(child)`; behavior test `work_under_late_codex_host_holds_slot_until_child_exits`; verify `env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp resident_helper_tests -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/acp.rs` → `startup_resident_pids`; exact call `self.resident_pids.extend(tree.startup_resident_pids(pid));`; behavior test `codex_snapshot_child_work_survives_late_and_session_capture`; verify `cargo test -p buzz-acp --lib snapshot_helper_tests -- --test-threads=1` - Verify: `env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp process_tree -- --test-threads=1` ## `crates/buzz-acp/src/process_tree/snapshot_tests.rs` @@ -5500,12 +5618,13 @@ New module: `false`. ## `crates/buzz-cli/TESTING.md` -Specify managed publisher scope, journal durability and retention, recovery opt-in/upgrade requirements and real versus mock-relay checks. Document mixed PDF, ZIP and Markdown attachment checks and authenticated byte comparisons. +Specify managed publisher scope, journal durability and retention, recovery opt-in/upgrade requirements and real versus mock-relay checks. Document session-status enrichment, its absence/failure behavior and its automated checks. Document mixed PDF, ZIP and Markdown attachment checks and authenticated byte comparisons. New module: `false`. - Verify: `cargo test -p buzz-cli managed_ -- --test-threads=1` - Verify: `BUZZ_TEST_BIN_DIR="$PWD/target/debug" cargo test -p buzz-relay --lib handlers::ingest::postgres_tests::managed_delivery_tests:: -- --ignored --test-threads=1` +- Verify: `cargo test -p buzz-cli session_status -- --test-threads=1` - Verify: `cargo test -p buzz-cli` ## `crates/buzz-cli/src/commands/delivery.rs` @@ -5536,7 +5655,7 @@ New module: `true`. ## `crates/buzz-cli/src/commands/messages.rs` -Expose messages deliver and preserve original route, stable UUID and signed callback mentions without suppressing ordinary progress or explicit edits. Emit generic attachments as filename links rather than image markup. +Expose messages deliver and preserve original route, stable UUID and signed callback mentions without suppressing ordinary progress or explicit edits. Attach the sender's own best-effort session status to newly prepared kind-9 messages before signing, in the shared send/first-deliver preparation. Emit generic attachments as filename links rather than image markup. New module: `false`. @@ -5544,23 +5663,31 @@ New module: `false`. - Required symbol: `pub(super) fn event_mention_pubkeys(` - Required symbol: `async fn resolve_content_mentions(` - Invocation: `crates/buzz-cli/src/commands/delivery.rs` → `event_mention_pubkeys`; exact call `messages::event_mention_pubkeys(&chosen)`; behavior test `managed_recovery_mention_history_survives_lost_ack`; verify `BUZZ_TEST_BIN_DIR="$PWD/target/debug" cargo test -p buzz-relay --lib managed_recovery_mention_history_survives_lost_ack -- --ignored --test-threads=1` +- Invocation: `crates/buzz-cli/src/commands/messages.rs` → `message_status_tags`; exact call `.tags(super::session_status::message_status_tags( + client, + channel_uuid, + thread_ref.as_ref().map(|t| &t.root_event_id), + ))`; behavior test `status_reply_carries_thread_status_and_keeps_existing_tags`; verify `cargo test -p buzz-cli session_status -- --test-threads=1` - Invocation: `crates/buzz-cli/src/commands/messages.rs` → `attachment_markdown`; exact call `media_content.push_str(&crate::attachments::attachment_markdown(&desc));`; behavior test `messages_emit_file_links_for_multiple_attachments_and_keep_media_markup`; verify `cargo test -p buzz-cli` - Verify: `cargo test -p buzz-cli managed_ -- --test-threads=1` - Verify: `cargo test -p buzz-cli mention -- --test-threads=1` +- Verify: `cargo test -p buzz-cli session_status -- --test-threads=1` - Verify: `cargo test -p buzz-cli` ## `crates/buzz-cli/src/commands/mod.rs` -Wire the managed delivery module into the CLI message dispatcher. +Wire the managed delivery module into the CLI message dispatcher. Register the read-only session-status lookup. New module: `false`. - Required symbol: `mod delivery;` +- Required symbol: `pub(crate) mod session_status;` - Verify: `cargo test -p buzz-cli managed_ -- --test-threads=1` +- Verify: `cargo test -p buzz-cli session_status -- --test-threads=1` ## `crates/buzz-cli/src/delivery_tests.rs` -Production CLI regressions for same-key replay, namespace/route integrity, concurrency, durable ancestor links, bounded retention/retries and legacy wrapper rejection. +Production CLI regressions for same-key replay, namespace/route integrity, concurrency, durable ancestor links, bounded retention/retries and legacy wrapper rejection. Shares its mock relay (palette, upload and HTTP-error modes) with session-status regressions. New module: `true`. @@ -5568,6 +5695,7 @@ New module: `true`. - Required symbol: `managed_expired_journal_never_treats_legacy_wrapper_ack_as_delivery` - Required symbol: `managed_existing_nested_directory_requires_ancestor_link_sync_before_publication` - Verify: `cargo test -p buzz-cli managed_ -- --test-threads=1` +- Verify: `cargo test -p buzz-cli session_status -- --test-threads=1` ## `crates/buzz-cli/src/error.rs` @@ -5580,17 +5708,69 @@ New module: `false`. ## `crates/buzz-cli/src/lib.rs` -Define messages deliver arguments and compile production-publisher regression tests. Compile the attachment formatter and production upload regressions. +Define messages deliver arguments and compile production-publisher regression tests. Configure the client with the harness-advertised session-status directory and compile session-status regressions. Compile the attachment formatter and production upload regressions. New module: `false`. - Required symbol: `Deliver {` - Required symbol: `mod delivery_tests;` +- Required symbol: `mod session_status_tests;` - Required symbol: `mod attachments;` - Required symbol: `mod attachment_tests;` +- Invocation: `crates/buzz-cli/src/lib.rs` → `status_dir_from_env`; exact call `.with_session_status_dir(commands::session_status::status_dir_from_env());`; behavior test `binary_reads_the_status_directory_from_the_environment`; verify `cargo test -p buzz-cli --test session_status_env` - Verify: `cargo test -p buzz-cli managed_ -- --test-threads=1` +- Verify: `cargo test -p buzz-cli session_status -- --test-threads=1` +- Verify: `cargo test -p buzz-cli --test session_status_env` +- Verify: `cargo test -p buzz-cli` + +## `crates/buzz-cli/src/client.rs` + +Carry the optional session-status instance directory with the client's relay URL and signing key. Upload generic documents and archives and retain a sanitized basename across both Blossom endpoint paths. + +New module: `false`. + +- Required symbol: `pub fn with_session_status_dir(` +- Required symbol: `pub fn session_status_dir(&self)` +- Invocation: `crates/buzz-cli/src/client.rs` → `validate_upload`; exact call `validate_upload(&mime, bytes.len() as u64)?;`; behavior test `rejects_detected_executables_before_upload`; verify `cargo test -p buzz-cli` +- Verify: `cargo test -p buzz-cli session_status -- --test-threads=1` - Verify: `cargo test -p buzz-cli` +## `crates/buzz-cli/src/commands/session_status.rs` + +Bounded read-only status lookup: absolute instance directory, nonblocking instance and generation lock probes, identity/scope/instance validation, thread-or-conversation destination with ambiguity rejection; any failure yields no tags. + +New module: `true`. + +- Required symbol: `pub(crate) const SESSION_STATUS_DIR_ENV: &str = "BUZZ_SESSION_STATUS_DIR";` +- Required symbol: `pub(crate) fn status_tags(` +- Required symbol: `pub(crate) fn message_status_tags(` +- Required symbol: `fn held_by_producer(` +- Required symbol: `status_dir_value_must_be_a_nonempty_absolute_path` +- Verify: `cargo test -p buzz-cli session_status -- --test-threads=1` +- Verify: `cargo test -p buzz-cli --test session_status_env` + +## `crates/buzz-cli/src/session_status_tests.rs` + +Production send/deliver regressions against a harness-shaped status directory: destination scopes, absence/invalid/stale snapshots, released generation and instance locks, no directory mutation, failed sends, kind-9 only and managed retry/recovery keeping the original tags. + +New module: `true`. + +- Required symbol: `status_released_generation_lock_rejects_remaining_snapshot` +- Required symbol: `status_released_instance_lock_rejects_live_looking_snapshot` +- Required symbol: `status_publication_into_another_thread_never_inherits_status` +- Required symbol: `managed_status_retry_keeps_the_originally_prepared_tags` +- Required symbol: `managed_status_timestamp_recovery_preserves_original_tags` +- Verify: `cargo test -p buzz-cli session_status -- --test-threads=1` + +## `crates/buzz-cli/tests/session_status_env.rs` + +Prove the built buzz binary reads BUZZ_SESSION_STATUS_DIR from its environment. + +New module: `true`. + +- Required symbol: `binary_reads_the_status_directory_from_the_environment` +- Verify: `cargo test -p buzz-cli --test session_status_env` + ## `crates/buzz-core/src/kind.rs` Reserve ephemeral managed recovery kind 20003 for signed canonical election; upgraded relays route it through shared ingest. @@ -5605,12 +5785,44 @@ New module: `false`. ## `crates/buzz-core/src/lib.rs` -Export the shared signed managed-report recovery protocol. +Export the shared signed managed-report recovery protocol and the shared session-status contract. Export the pure session command, result and actor contract. New module: `false`. - Required symbol: `pub mod managed_delivery;` +- Required symbol: `pub mod session_status;` +- Required symbol: `pub mod session_control;` - Verify: `cargo test -p buzz-core managed_delivery -- --test-threads=1` +- Verify: `cargo test -p buzz-core session_status -- --test-threads=1` +- Verify: `cargo test -p buzz-core -- --test-threads=1` + +## `crates/buzz-core/src/session_status.rs` + +Pure shared session-status contract: canonical status identity, scope file names, instance layout names, bounded reason vocabulary, snapshot format v1 with size/version validation, and the buzz-session wire tags. Read legacy v1 and current v2 snapshots, keep primary/info wire shapes, validate optional manual origin and add bounded control/departure reasons. + +New module: `true`. + +- Required symbol: `pub fn canonical_identity(` +- Required symbol: `pub fn scope_file_name(` +- Required symbol: `pub fn generation_lock_path(` +- Required symbol: `pub fn parse_snapshot(` +- Required symbol: `pub fn event_tags(` +- Required symbol: `pub struct StatusEnvelope` +- Required symbol: `pub struct ContextOccupancy` +- Required symbol: `pub struct HistoryCoverage` +- Required symbol: `pub const STATUS_FORMAT_VERSION: u8 = 2;` +- Required symbol: `pub origin: Option` +- Invocation: `crates/buzz-core/src/session_status.rs` → `event_tag`; exact call `tags.push( + ControlAction { + transition_id: self.status.transition_id, + origin: origin.clone(), + } + .event_tag() + .ok_or(StatusError::InvalidOrigin)?, + );`; behavior test `snapshot_legacy_v1_and_v2_actor_preserve_wire_dimensions`; verify `cargo test -p buzz-core --test session_control snapshot_legacy_v1_and_v2_actor_preserve_wire_dimensions -- --test-threads=1` +- Invocation: `crates/buzz-core/src/session_status.rs` → `validate`; exact call `envelope.validate()?;`; behavior test `snapshot_rejects_invalid_actor_and_automatic_actor`; verify `cargo test -p buzz-core --test session_control snapshot_rejects_invalid_actor_and_automatic_actor -- --test-threads=1` +- Verify: `cargo test -p buzz-core session_status -- --test-threads=1` +- Verify: `cargo test -p buzz-core -- --test-threads=1` ## `crates/buzz-core/src/managed_delivery.rs` @@ -5807,12 +6019,13 @@ New module: `false`. ## `crates/buzz-acp/src/process_tree/codex_helpers.rs` -Confirm late Codex code-mode host identity with one bounded ps call: macOS comm and Linux exe; missing identity and work arguments cannot free slots. +Confirm Codex code-mode host and exact Bash snapshot shell/parent identities with one bounded ps call; uncertain identities remain work. New module: `true`. - Required symbol: `pub(super) fn classify(` - Required symbol: `fn confirmed_hosts(` +- Required symbol: `pub(super) fn apply_snapshot_identities(` - Invocation: `crates/buzz-acp/src/process_tree.rs` → `classify`; exact call `let tree = codex_helpers::classify(tree);`; behavior test `six_finished_threads_reuse_five_slots_with_late_codex_hosts`; verify `env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp resident_helper_tests -- --test-threads=1` - Verify: `env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp resident_helper_tests -- --test-threads=1` @@ -5864,363 +6077,1692 @@ New module: `false`. - Required symbol: `(player as HTMLAudioElement).loop = true;` - Verify: `cd desktop && pnpm exec playwright test --project=smoke tests/e2e/voice-note.spec.ts --retries=0` -## `Cargo.toml` +## `crates/buzz-acp/src/session_store.rs` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Community-and-agent isolated, lease-fenced session records with bounded locks, identity validation and channel-wide failure fencing. Preserve restart intent and bounded receipts through claims, commits and tombstones; revision-fence tombstones, enforce byte ceilings, and recover only accepted control metadata from provider quarantine. Lease-fenced automatic attempt cleanup preserves accepted restart policy; explicit scope/channel revocation clears it. Distinguish Alive/Dead/Unknown owner evidence while retaining conservative resume admission. -New module: `false`. +New module: `true`. -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Required symbol: `pub struct SessionStore` +- Required symbol: `pub fn claim(` +- Required symbol: `pub fn commit(` +- Required symbol: `fn store_namespace(` +- Required symbol: `poisoned_channels` +- Required symbol: `pub revision: Option` +- Required symbol: `pub pending_restart: Option` +- Required symbol: `pub control_receipts: Vec` +- Required symbol: `if json.len() as u64 > MAX_RECORD_BYTES || !controls::valid_controls(record) {` +- Required symbol: `record.pending_restart = owned.pending_restart;` +- Required symbol: `enum InstanceLiveness` +- Required symbol: `fn instance_liveness(` +- Invocation: `crates/buzz-acp/src/session_store.rs` → `read_record_file`; exact call `read_record_file(&with_suffix(path, "stale"), namespace)?`; behavior test `quarantine_retains_control_metadata_without_recovering_provider_state`; verify `cargo test -p buzz-acp quarantine_retains_control_metadata_without_recovering_provider_state -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/session_store.rs` → `valid_controls`; exact call `controls::valid_controls(&record)`; behavior test `malformed_or_oversized_receipt_records_are_rejected`; verify `cargo test -p buzz-acp malformed_or_oversized_receipt_records_are_rejected -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/session_store.rs` → `valid_controls`; exact call `controls::valid_controls(record)`; behavior test `malformed_or_oversized_receipt_records_are_rejected`; verify `cargo test -p buzz-acp malformed_or_oversized_receipt_records_are_rejected -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/session_store.rs` → `tombstone`; exact call `self.tombstone(Some(&owned))`; behavior test `failed_attempt_discard_preserves_restart_until_matching_completion`; verify `cargo test -p buzz-acp failed_attempt_discard_preserves_restart_until_matching_completion -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/session_store.rs` → `instance_liveness`; exact call `self.instance_liveness(instance) != InstanceLiveness::Dead`; behavior test `instance_publication_cannot_be_reaped_before_locking`; verify `cargo test -p buzz-acp --lib instance_publication_cannot_be_reaped_before_locking -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` +- Verify: `cargo test -p buzz-acp --lib instance_publication_cannot_be_reaped_before_locking -- --test-threads=1` -## `crates/buzz-agent/Cargo.toml` +## `crates/buzz-acp/src/session_store_tests.rs` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Durable record ownership, concurrency, corruption and write-failure regressions. Capture revision in the existing tombstone expectation assertion. -New module: `false`. +New module: `true`. -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Required symbol: `busy_does_not_quarantine_owner_record` +- Required symbol: `quarantine_requires_matching_lease` +- Required symbol: `instance_publication_cannot_be_reaped_before_locking` +- Required symbol: `namespace_normalizes_url_spelling_without_aliasing_communities` +- Required symbol: `copied_foreign_namespace_record_is_not_resumable` +- Required symbol: `legacy_unscoped_records_are_not_adopted` +- Required symbol: `channel_enumeration_failure_poisons_unknown_scopes_until_restart` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `crates/buzz-agent/src/hints.rs` +## `crates/buzz-acp/src/pool/session_continuity.rs` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Resume or create provider sessions through bounded claim/RPC/commit rounds and preserve delivery state. Retire status generations on the existing discard and depart paths. Keep acquisition fallback exhaustive for the store control error vocabulary. -New module: `false`. +New module: `true`. -- Required symbol: `yaml_serde::from_str(yaml_block).ok()?` -- Invocation: `crates/buzz-agent/src/hints.rs` → `from_str`; exact call `yaml_serde::from_str(yaml_block).ok()?`; behavior test `hints::yaml_tests::skill_yaml_preserves_words_tags_aliases_and_multiline_text`; verify `cargo test --locked -p buzz-agent --lib hints::yaml_tests` -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` -- Verify: `cargo test --locked -p buzz-agent --lib hints::yaml_tests` +- Required symbol: `pub(super) async fn acquire_session(` +- Required symbol: `pub(crate) fn resume_decision(` +- Required symbol: `pub(crate) fn insert_session_marker(` +- Required symbol: `Err(AcpError::DispatchRetired)` +- Invocation: `crates/buzz-acp/src/pool/session_continuity.rs` → `retire_status`; exact call `self.retire_status(scope);`; behavior test `new_session_status_precedes_the_prompt_and_spans_ordinary_turns`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool/session_continuity.rs` → `retire_all_status`; exact call `self.retire_all_status();`; behavior test `worker_crash_retires_status_before_the_replacement_prompt`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool/session_continuity.rs` → `ensure_dispatch_current`; exact call `ensure_dispatch_current(agent)?;`; behavior test `restart_during_delayed_acquisition_fences_old_dispatch`; verify `cargo test -p buzz-acp --lib restart_during_delayed_acquisition_fences_old_dispatch -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool/session_continuity.rs` → `ensure_dispatch_current`; exact call `if ensure_dispatch_current(agent).is_err() {`; behavior test `restart_during_failed_resume_never_calls_fallback_new`; verify `cargo test -p buzz-acp --lib restart_during_failed_resume_never_calls_fallback_new -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool/session_continuity.rs` → `Err`; exact call `return Err(err); + } + let code = resume_failure_code(&err);`; behavior test `retired_resume_transport_preserves_health_without_fallback_new`; verify `cargo test -p buzz-acp --lib retired_resume_transport_preserves_health_without_fallback_new -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool/session_continuity.rs` → `recycled_reason`; exact call `ctx.session_controls.recycled_reason(scope)`; behavior test `checked_retirement_neighbors_resume_or_start_new_with_recycled_reason`; verify `cargo test -p buzz-acp --lib checked_retirement_neighbors_resume_or_start_new_with_recycled_reason -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` +- Verify: `cargo test -p buzz-acp --lib retired_successful_acquisition_keeps_worker_health_and_fences_old_state -- --test-threads=1` -## `crates/buzz-agent/src/hints_yaml_tests.rs` +## `crates/buzz-acp/src/pool/session_continuity_tests.rs` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Production acquisition, delivery delta, rotation, crash and idle-sleep persistence regressions. Supply default new control fields in existing legacy-record fixtures. New module: `true`. -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Required symbol: `resume_result_after_rotate_is_dropped` +- Required symbol: `max_turns_survives_resume` +- Required symbol: `rotate_before_first_resume_discards_disk_session` +- Required symbol: `one_reply_window_keeps_first_mention_over_agent_reply` +- Required symbol: `idle_worker_transport_death_keeps_session_resumable` +- Required symbol: `acquisition_does_not_resume_another_community_or_agent` +- Required symbol: `acquisition_resumes_canonical_same_community_after_restart` +- Required symbol: `failed_channel_removal_during_sleep_never_resumes_after_readd` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `crates/buzz-backend-kubernetes/src/client.rs` +## `crates/buzz-acp/src/pool/first_mention.rs` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Bounded forward thread scan with a composite cursor and first-mention context pinning. -New module: `false`. +New module: `true`. -- Required symbol: `let config = config_for(context).await?;` -- Invocation: `crates/buzz-backend-kubernetes/src/client.rs` → `config_for`; exact call `let config = config_for(context).await?;`; behavior test `client::yaml_tests::kubeconfig_connect_and_exec_lookup_use_the_selected_context`; verify `cargo test --locked -p buzz-backend-kubernetes` -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` -- Verify: `cargo test --locked -p buzz-backend-kubernetes` +- Required symbol: `pub(crate) async fn find_first_mention` +- Required symbol: `pub(crate) fn pin_first_mention` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `crates/buzz-backend-kubernetes/src/client_yaml_tests.rs` +## `crates/buzz-acp/src/pool/first_mention_tests.rs` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +First-mention discovery across pages, timestamp ties and scan-budget exhaustion. New module: `true`. -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `crates/buzz-backend-kubernetes/src/cluster.rs` +## `crates/buzz-acp/src/acp/session_resume_tests.rs` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +ACP resume capability, request parameter and failure-code checks. -New module: `false`. +New module: `true`. -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `crates/buzz-backend-kubernetes/src/gc.rs` +## `crates/buzz-acp/src/pool/real_resume_tests.rs` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Opt-in real-provider memory continuity after adapter process replacement. -New module: `false`. +New module: `true`. -- Required symbol: `let age_nanos = now_nanos - created.0.as_nanosecond();` -- Invocation: `crates/buzz-backend-kubernetes/src/gc.rs` → `as_nanosecond`; exact call `let age_nanos = now_nanos - created.0.as_nanosecond();`; behavior test `gc::tests::age_gate_boundary_is_exact`; verify `cargo test --locked -p buzz-backend-kubernetes` -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` -- Verify: `cargo test --locked -p buzz-backend-kubernetes` +- Required symbol: `real_claude_resume_remembers_previous_turn` +- Required symbol: `real_codex_resume_remembers_previous_turn` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `crates/buzz-backend-kubernetes/src/observe.rs` +## `crates/buzz-acp/Cargo.toml` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Declare the filesystem-lock dependency used by durable session ownership. New module: `false`. -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Required symbol: `fs2 = "0.4"` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `crates/buzz-backend-kubernetes/src/reconcile.rs` +## `crates/buzz-acp/src/session_status.rs` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Session status runtime: private instance directory held by an instance lock, atomic bounded per-scope snapshots, generation locks with shared revocable guards, per-scope failure disabling, usage routing by notification session ID and harness-owned status directory env. Serialize live binding display tags through the shared envelope, refusing retired or replaced generations. -New module: `false`. +New module: `true`. -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Required symbol: `pub(crate) const SESSION_STATUS_DIR_ENV: &str = "BUZZ_SESSION_STATUS_DIR";` +- Required symbol: `pub(crate) struct SessionStatusRuntime` +- Required symbol: `pub(crate) fn begin(` +- Required symbol: `pub(crate) fn bind_existing(` +- Required symbol: `pub(crate) fn retire_channel(` +- Required symbol: `fn revoke_locked(` +- Required symbol: `pub(crate) fn record_usage_update(` +- Required symbol: `pub(crate) fn apply_status_dir_env(` +- Required symbol: `pub(crate) fn open_for_config(` +- Required symbol: `pub(crate) fn compacted(` +- Required symbol: `pub(crate) fn event_tags(` +- Required symbol: `let mut next = state.status.clone();` +- Invocation: `crates/buzz-acp/src/pool/restart_status.rs` → `event_tags`; exact call `.and_then(crate::session_status::StatusBinding::event_tags)`; behavior test `first_signed_status_carries_selected_history_actor_and_current_or_unknown_occupancy`; verify `cargo test -p buzz-acp --lib first_signed_status_carries_selected_history_actor_and_current_or_unknown_occupancy -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `crates/buzz-persona/Cargo.toml` +## `crates/buzz-acp/src/session_status_tests.rs` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Status runtime layout, lock, revocation, failed removal/write and wire usage-routing regressions. -New module: `false`. +New module: `true`. -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Required symbol: `open_holds_instance_lock_in_private_layout` +- Required symbol: `successor_generation_revokes_predecessor` +- Required symbol: `retire_releases_lock_and_late_callbacks_never_recreate` +- Required symbol: `failed_snapshot_removal_still_revokes_generation` +- Required symbol: `write_failure_disables_only_that_scope` +- Required symbol: `retire_waits_for_a_delayed_writer_then_removes_snapshot` +- Required symbol: `status_dir_env_is_owned_by_the_harness` +- Required symbol: `claude_usage_updates_route_by_their_own_session_id` +- Required symbol: `codex_usage_updates_route_by_their_own_session_id` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `crates/buzz-persona/src/persona.rs` +## `desktop/src/features/messages/lib/sessionStatus.ts` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Strictly parse an agent's self-reported buzz-session and buzz-session-info tags, map the bounded state/reason vocabulary to fixed English copy with truthful exact, lower-bound and omitted-history wording, and attach one transition explanation per loaded (signer, channel, scope, transition) to its earliest message; other messages keep only their reported percentage. Valid primary-only status keeps a per-message explanation without inferred grouping or history. Validate matching bounded manual actor tags, include actor/state/reason in grouping and render fixed compaction/restart/worker-recycle copy. -New module: `false`. +New module: `true`. -- Required symbol: `yaml_serde::from_str(fm_str)?` -- Invocation: `crates/buzz-persona/src/persona.rs` → `from_str`; exact call `yaml_serde::from_str(fm_str)?`; behavior test `persona_yaml_preserves_scalar_types_aliases_and_multiline_unicode`; verify `cargo test --locked -p buzz-persona --test yaml_compatibility` -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` -- Verify: `cargo test --locked -p buzz-persona --test yaml_compatibility` +- Required symbol: `export function parseSessionStatusTags(` +- Required symbol: `export function attachSessionStatuses(` +- Required symbol: `export const CONTEXT_HIGHLIGHT_PERCENT = 80;` +- Required symbol: `normalizePubkey(message.pubkey) !== message.signerPubkey` +- Required symbol: `if (key === null || owners.get(key) === message)` +- Required symbol: `const action = uniqueTag(tags, "buzz-session-action");` +- Required symbol: `action[2] === info.transitionId` +- Verify: `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionStatus.test.mjs src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs src/features/messages/lib/messageRowEquality.test.mjs src/features/messages/ui/MessageSessionStatus.test.mjs` +- Verify: `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionControl.test.mjs src/features/messages/lib/sessionControlPermissions.test.mjs` -## `crates/buzz-persona/src/validate.rs` +## `desktop/src/features/messages/lib/sessionStatus.test.mjs` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Parser and copy regressions: states, known and unknown reasons, resume failure codes, percent boundaries, malformed numbers, duplicate/conflicting tags, malformed supporting metadata and hostile reason strings. Retain neutral fallback for unknown reasons and cover implemented bounded control transition copy. -New module: `false`. +New module: `true`. -- Required symbol: `yaml_serde::from_str(fm_str)` -- Invocation: `crates/buzz-persona/src/validate.rs` → `from_str`; exact call `yaml_serde::from_str(fm_str)`; behavior test `pack_validation_preserves_skill_name_warnings_with_unknown_yaml_values`; verify `cargo test --locked -p buzz-persona --test yaml_compatibility` -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` -- Verify: `cargo test --locked -p buzz-persona --test yaml_compatibility` +- Required symbol: `conflicting primary tags yield no status` +- Required symbol: `unknown but well-formed reasons get neutral copy without the raw string` +- Required symbol: `copy: bounded compact and restart reasons describe the actual transition` +- Verify: `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionStatus.test.mjs src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs src/features/messages/lib/messageRowEquality.test.mjs src/features/messages/ui/MessageSessionStatus.test.mjs` +- Verify: `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionControl.test.mjs src/features/messages/lib/sessionControlPermissions.test.mjs` -## `crates/buzz-persona/tests/yaml_compatibility.rs` +## `desktop/src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Projection regressions through the production timeline formatter: agents, scopes, channels, interleaving, same-second order, deletion, pagination, edit overlays, signer/author identity and missing supporting metadata. New module: `true`. -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Required symbol: `first message of a transition explains it; later ones keep only the percentage` +- Required symbol: `an edit cannot add or change status; the original signed tags win` +- Required symbol: `a relay-signed event attributed to an agent cannot claim its session state` +- Required symbol: `valid primary-only status keeps its explanation per message without counts` +- Verify: `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionStatus.test.mjs src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs src/features/messages/lib/messageRowEquality.test.mjs src/features/messages/ui/MessageSessionStatus.test.mjs` -## `crates/buzz-workflow/Cargo.toml` +## `desktop/src/features/messages/ui/MessageSessionStatus.tsx` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Render the derived session status as a muted, noninteractive line with screen-reader pauses and warning emphasis plus readable text from 80%. -New module: `false`. +New module: `true`. -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Required symbol: `export function MessageSessionStatus(` +- Required symbol: `font-medium text-warning` +- Verify: `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionStatus.test.mjs src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs src/features/messages/lib/messageRowEquality.test.mjs src/features/messages/ui/MessageSessionStatus.test.mjs` -## `crates/buzz-workflow/src/error.rs` +## `desktop/src/features/messages/ui/MessageSessionStatus.test.mjs` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Rendered status line text, emphasis threshold, absence of tab stops, live regions or labels, and MessageRow memo invalidation when only the derived status moves. -New module: `false`. +New module: `true`. -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Required symbol: `MessageRow memo rerenders when only the derived status moves` +- Required symbol: `primary-only signed metadata renders its projected explanation with unknown occupancy` +- Verify: `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionStatus.test.mjs src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs src/features/messages/lib/messageRowEquality.test.mjs src/features/messages/ui/MessageSessionStatus.test.mjs` -## `crates/buzz-workflow/src/schema.rs` +## `desktop/tests/e2e/session-status.spec.ts` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Mock-bridge smoke workflow for session status: new session with exact history, continuation rows at 79% and 80%, unknown context, thread navigation and one explanation per loaded transition. Capture the current quiet status paragraph without a message-row control trigger. Preserve all quiet-status/history/percentage/count assertions while verifying the removed row trigger and the thread-composer context agent/action/Back/Escape flow with natural hover/click. + +New module: `true`. + +- Required symbol: `session status explains each transition once and shows reported context` +- Required symbol: `quiet-status.png` +- Required symbol: `await row(timeline, root).hover();` +- Required symbol: `name: "Manage context"` +- Verify: `CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts` + +## `desktop/src/features/messages/lib/formatTimelineMessages.ts` + +Attach the derived agent session status to formatted rows from each event's original signed tags. Derive compact controls from original events and raw reactions, and collect validated manual actors for batched profile reads. New module: `false`. -- Required symbol: `yaml_serde::from_str(yaml)?` -- Invocation: `crates/buzz-workflow/src/schema.rs` → `from_str`; exact call `yaml_serde::from_str(yaml)?`; behavior test `schema::yaml_tests::workflow_yaml_preserves_trigger_aliases_and_message_text`; verify `cargo test --locked -p buzz-workflow --lib schema::yaml_tests` -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` -- Verify: `cargo test --locked -p buzz-workflow --lib schema::yaml_tests` +- Required symbol: `export function collectSessionActorPubkeys(` +- Invocation: `desktop/src/features/messages/lib/formatTimelineMessages.ts` → `attachSessionStatuses`; exact call `return attachSessionStatuses( + messages, + visibleEvents, + channel?.id ?? null, + profiles, + );`; behavior test `first message of a transition explains it; later ones keep only the percentage`; verify `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test --test-name-pattern="first message of a transition" src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs` +- Invocation: `desktop/src/features/messages/lib/formatTimelineMessages.ts` → `attachSessionControls`; exact call `attachSessionControls( + messages, + visibleEvents,`; behavior test `formatter reads original signed kind9 body`; verify `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionControl.test.mjs src/features/messages/lib/sessionControlPermissions.test.mjs` +- Verify: `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionStatus.test.mjs src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs src/features/messages/lib/messageRowEquality.test.mjs src/features/messages/ui/MessageSessionStatus.test.mjs` +- Verify: `just desktop-test desktop-typecheck` +- Verify: `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionControl.test.mjs src/features/messages/lib/sessionControlPermissions.test.mjs` -## `crates/buzz-workflow/src/schema_yaml_tests.rs` +## `desktop/src/features/messages/ui/MessageRow.tsx` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Render quiet session status and signed compact command/result rows with value-based memo invalidation; context actions live only in the thread composer. -New module: `true`. +New module: `false`. -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Invocation: `desktop/src/features/messages/ui/MessageRow.tsx` → `MessageSessionStatus`; exact call ``; behavior test `session status explains each transition once and shows reported context`; verify `CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts` +- Invocation: `desktop/src/features/messages/ui/MessageRow.tsx` → `sessionStatusEqual`; exact call `sessionStatusEqual( + prev.message.sessionStatus, + next.message.sessionStatus, + )`; behavior test `MessageRow memo rerenders when only the derived status moves`; verify `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test --test-name-pattern="MessageRow memo rerenders" src/features/messages/ui/MessageSessionStatus.test.mjs` +- Invocation: `desktop/src/features/messages/ui/MessageRow.tsx` → `MessageSessionControlRow`; exact call `(yaml)` -- Invocation: `desktop/src-tauri/src/commands/workflows.rs` → `from_str`; exact call `yaml_serde::from_str::(yaml)`; behavior test `commands::workflows::tests::workflow_yaml_json_projection_preserves_words_and_fallbacks`; verify `cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib commands::workflows::tests::workflow_yaml_json_projection_preserves_words_and_fallbacks` -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` -- Verify: `cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib commands::workflows::tests::workflow_yaml_json_projection_preserves_words_and_fallbacks` +- Required symbol: `export type MessageSessionStatus = {` +- Required symbol: `sessionStatus?: MessageSessionStatus;` +- Required symbol: `sessionControl?: MessageSessionControl;` +- Verify: `just desktop-test desktop-typecheck` +- Verify: `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionControl.test.mjs src/features/messages/lib/sessionControlPermissions.test.mjs` -## `desktop/src-tauri/src/commands/workflows_tests.rs` +## `desktop/src/features/messages/lib/messageRowEquality.ts` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Value equality for the derived session status used by the MessageRow memo. Compare compact per-target control outcomes by value. New module: `false`. -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Required symbol: `export function sessionStatusEqual(` +- Required symbol: `export function sessionControlEqual(` +- Required symbol: `target.expiresAt === other.expiresAt` +- Verify: `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionStatus.test.mjs src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs src/features/messages/lib/messageRowEquality.test.mjs src/features/messages/ui/MessageSessionStatus.test.mjs` +- Verify: `cd desktop && node --import ./test-jsdom-setup.mjs --import ./test-loader.mjs --experimental-strip-types --test-force-exit --test src/features/messages/lib/useSessionControlAction.jsdom-test.mjs src/features/messages/ui/MessageSessionControlRow.jsdom-test.mjs` -## `desktop/src-tauri/src/managed_agents/config_bridge/goose.rs` +## `desktop/src/features/messages/lib/messageRowEquality.test.mjs` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Session status value-equality regressions. New module: `false`. -- Required symbol: `yaml_serde::from_str(yaml_str).ok()?` -- Invocation: `desktop/src-tauri/src/managed_agents/config_bridge/goose.rs` → `from_str`; exact call `yaml_serde::from_str(yaml_str).ok()?`; behavior test `managed_agents::config_bridge::goose::yaml_tests::goose_yaml_preserves_nested_providers_and_ignores_unknown_tagged_fields`; verify `cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib managed_agents::config_bridge::goose::yaml_tests` -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` -- Verify: `cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib managed_agents::config_bridge::goose::yaml_tests` +- Required symbol: `sessionStatusEqual: value equality over explanation, history and percent` +- Verify: `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionStatus.test.mjs src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs src/features/messages/lib/messageRowEquality.test.mjs src/features/messages/ui/MessageSessionStatus.test.mjs` -## `desktop/src-tauri/src/managed_agents/config_bridge/goose_yaml_tests.rs` +## `crates/buzz-acp/src/pool/session_visibility.rs` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Adapt canonical session scope, acquisition result and final cold-start prompt history to the status runtime; worker-level retirement hooks. Validate display-scope provenance using configured policy and already resolved metadata, revoking untrusted Conversation generations. Map newly bounded manual/automatic compact, restart and worker-recycled reasons to shared status types. New module: `true`. -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Required symbol: `pub(crate) fn status_scope(` +- Required symbol: `pub(crate) fn status_reason(` +- Required symbol: `pub(super) fn publish_session_start(` +- Required symbol: `pub(super) fn rebind_session_status(` +- Required symbol: `pub(super) fn retire_status(` +- Required symbol: `pub(super) fn retire_all_status(` +- Required symbol: `pub(super) fn history_coverage(` +- Required symbol: `pub(crate) fn retire_scope(` +- Required symbol: `pub(crate) fn retire_channel(` +- Required symbol: `const KNOWN_FAILURE_CODES: &[&str]` +- Required symbol: `pub(super) fn reconcile_session_status_scope(` +- Invocation: `crates/buzz-acp/src/pool/session_visibility.rs` → `begin_with_origin`; exact call `Some(origin) => runtime.begin_with_origin(`; behavior test `selected_restart_depths_reach_actual_prompt_and_supplied_ids`; verify `cargo test -p buzz-acp --lib selected_restart_depths_reach_actual_prompt_and_supplied_ids -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `desktop/src-tauri/src/migration_yaml_tests.rs` +## `crates/buzz-acp/src/pool/session_visibility_tests.rs` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Production run_prompt_task status lifecycle, usage routing, history coverage and spawn/MCP environment regressions. New module: `true`. -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Required symbol: `new_session_status_precedes_the_prompt_and_spans_ordinary_turns` +- Required symbol: `resumed_session_reports_idle_sleep` +- Required symbol: `failed_resume_reports_the_actual_fallback_reason` +- Required symbol: `interleaved_sessions_on_one_worker_keep_their_own_occupancy` +- Required symbol: `heartbeat_turn_never_touches_scope_status` +- Required symbol: `rotate_during_a_delayed_turn_retires_before_late_usage` +- Required symbol: `pool_sleep_retires_status_and_wake_starts_a_new_transition` +- Required symbol: `worker_crash_retires_status_before_the_replacement_prompt` +- Required symbol: `successor_acquisition_revokes_the_old_generation_before_its_prompt` +- Required symbol: `status_write_failure_never_fails_the_turn` +- Required symbol: `pinned_assignment_outside_the_window_counts_supplied_events_once` +- Required symbol: `counts_need_no_extra_history_walk` +- Required symbol: `every_spawn_path_and_managed_mcp_get_the_current_status_dir` +- Required symbol: `fallback_codes_outside_the_allowlist_become_unknown` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `docs/fork/YAML_COMPATIBILITY.md` +## `crates/buzz-acp/src/runtime.rs` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Open the instance session status directory once and route it through the shared adapter spawn environment and prompt context. + +New module: `false`. + +- Required symbol: `pub(crate) fn enable_session_status(` +- Invocation: `crates/buzz-acp/src/runtime.rs` → `open_for_config`; exact call `let runtime = session_status::open_for_config(&self.config)?;`; behavior test `every_spawn_path_and_managed_mcp_get_the_current_status_dir`; verify `cargo test -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/runtime.rs` → `session_policy`; exact call `session_policy: config.session_policy,`; behavior test `configured_policy_reaches_prompt_status_context_without_base_prompt`; verify `cargo test -p buzz-acp --lib session_visibility::tests::policy -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/pool/session_visibility_policy_tests.rs` + +Production prompt/status provenance regressions for uncertain Conversation acquisition, recovered metadata, failed sibling-thread status, retained-session revocation and configured-policy forwarding. New module: `true`. -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Required symbol: `conversation_status_requires_channel_policy_or_verified_dm` +- Required symbol: `uncertain_conversation_cannot_outlive_metadata_recovery_and_failed_thread_status` +- Required symbol: `retained_conversation_revokes_generation_before_rebind_or_late_updates` +- Required symbol: `configured_policy_reaches_prompt_status_context_without_base_prompt` +- Verify: `cargo test -p buzz-acp --lib session_visibility::tests::policy -- --test-threads=1` -## `scripts/fork/tests/test_yaml_dependencies.py` +## `crates/buzz-core/src/session_control.rs` -Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). +Exact kind-9 command grammar, typed terminal/pending reaction results with precedence, strict actor enrichment and freshness constants; no I/O. New module: `true`. -- Verify: `just ci` -- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Required symbol: `pub enum RestartDepth` +- Required symbol: `pub enum SessionCommand` +- Required symbol: `pub struct ControlOrigin` +- Required symbol: `pub enum ControlFailure` +- Required symbol: `pub type ControlOutcome` +- Required symbol: `pub enum ControlResult` +- Required symbol: `pub struct SessionResult` +- Required symbol: `pub struct ControlAction` +- Required symbol: `pub fn control_is_fresh(` +- Verify: `cargo test -p buzz-core -- --test-threads=1` -## `AGENTS.md` +## `crates/buzz-core/src/session_status_tests.rs` -Fork rule: public text is in English and carries no internal workspace details +Extract unchanged status regression coverage from production module and update schema-version assertions for v2. -New module: `false`. +New module: `true`. -- Required symbol: `## Fork: public text is in English` -- Verify: `python3 scripts/fork/sync.py validate` +- Required symbol: `fn snapshot_round_trips()` +- Required symbol: `fn snapshot_rejects_unknown_versions_and_bad_shapes()` +- Verify: `cargo test -p buzz-core -- --test-threads=1` -## `crates/buzz-cli/src/attachments.rs` +## `crates/buzz-core/tests/session_control.rs` -Preserve sanitized attachment basenames, enforce generic-file upload policy, and format download links while retaining image and video markup. Bound names to 255 UTF-8 bytes at character boundaries and preserve extensions. +Behavioral contract regressions for exact grammar, strict result/action parsing, precedence, v1/v2 origin rules and shared fixtures. Bind the shared command corpus to the actual Rust parser. New module: `true`. -- Required symbol: `fn sanitize_filename(` -- Required symbol: `fn validate_upload(` -- Required symbol: `fn attachment_markdown(` -- Required symbol: `pub filename: Option` -- Verify: `cargo test -p buzz-cli` +- Required symbol: `fn command_grammar_is_exact()` +- Required symbol: `fn shared_reason_and_origin_fixture_corpus()` +- Required symbol: `fn snapshot_rejects_invalid_actor_and_automatic_actor()` +- Required symbol: `for case in cases["commands"].as_array().unwrap() {` +- Verify: `cargo test -p buzz-core -- --test-threads=1` +- Verify: `cargo test -p buzz-core --test session_control` -## `crates/buzz-cli/src/attachment_tests.rs` +## `crates/buzz-core/tests/fixtures/session_controls.json` -Exercise document, archive and Markdown uploads, primary and legacy metadata, message links, media compatibility, and executable and size rejection. +Shared Rust/TypeScript fixture corpus for bounded reasons, canonical actor origins and pending/terminal precedence. Cover all exact command spellings, ordinary near-misses and the cross-language whitespace boundary. New module: `true`. -- Required symbol: `uploads_documents_archives_and_markdown_with_original_names` -- Required symbol: `messages_emit_file_links_for_multiple_attachments_and_keep_media_markup` -- Required symbol: `unicode_filenames_fit_relay_byte_limit_and_keep_extensions` -- Verify: `cargo test -p buzz-cli` +- Required symbol: `"commands": [` +- Verify: `cargo test -p buzz-core -- --test-threads=1` +- Verify: `cargo test -p buzz-core --test session_control` -## `crates/buzz-cli/src/client.rs` +## `crates/buzz-acp/src/session_store_controls.rs` -Upload generic documents and archives and retain a sanitized basename across both Blossom endpoint paths. +Atomic restart tombstone/intent/Started receipts, bounded fresh deduplication, token-fenced completion and explicit owner-generation orphan reconciliation. -New module: `false`. +New module: `true`. -- Invocation: `crates/buzz-cli/src/client.rs` → `validate_upload`; exact call `validate_upload(&mime, bytes.len() as u64)?;`; behavior test `rejects_detected_executables_before_upload`; verify `cargo test -p buzz-cli` -- Verify: `cargo test -p buzz-cli` +- Required symbol: `pub struct RestartIntent` +- Required symbol: `pub enum ControlAttempt` +- Required symbol: `pub fn request_restart(` +- Required symbol: `pub fn complete_restart(` +- Required symbol: `pub fn begin_control_attempt(` +- Required symbol: `pub fn finish_control_attempt(` +- Required symbol: `pub(crate) fn reconcile_control_attempt(` +- Required symbol: `pub(crate) fn retry_control_outcome(` +- Required symbol: `if expected.owner_instance != self.instance` +- Required symbol: `receipt.owner_generation != expected.owner_generation` +- Required symbol: `if let ControlReceiptState::Finished(prior) = receipt.state` +- Required symbol: `pub(crate) fn begin_admitted_control_with_receipt(` +- Required symbol: `if !control_is_fresh(created_at, admitted_at)` +- Invocation: `crates/buzz-acp/src/session_store_controls.rs` → `add_started`; exact call `add_started( + &mut record, + &intent.origin.command_event_id, + now, + self.instance, + );`; behavior test `restart_started_receipt_is_written_with_tombstone_intent`; verify `cargo test -p buzz-acp restart_started_receipt_is_written_with_tombstone_intent -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/session_store_controls.rs` → `add_started`; exact call `observed = Some(add_started( + &mut record, + command_id, + created_at, + self.instance, + ));`; behavior test `started_compact_redelivery_never_reexecutes`; verify `cargo test -p buzz-acp started_compact_redelivery_never_reexecutes -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/session_store_controls.rs` → `update_with_quarantine`; exact call `self.update_with_quarantine(path, decide, false)`; behavior test `restart_started_receipt_is_written_with_tombstone_intent`; verify `cargo test -p buzz-acp restart_started_receipt_is_written_with_tombstone_intent -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `desktop/tests/e2e/file-attachment.spec.ts` +## `crates/buzz-acp/src/session_store_control_tests.rs` -Verify Markdown and ZIP picker uploads, file-card labels and sizes, and native download filenames alongside existing attachment flows. +Durable controls regression coverage including concurrency, atomic failures, legacy records, ABA, expiry/capacity, orphan fences and quarantine boundaries. Prove failed-attempt intent retention and explicit policy revocation with stale-lease rejection. -New module: `false`. +New module: `true`. -- Verify: `cd desktop && pnpm test:e2e:smoke -- file-attachment.spec.ts` +- Required symbol: `fn restart_intent_is_atomic()` +- Required symbol: `fn orphan_started_becomes_terminal_without_reexecution()` +- Required symbol: `fn quarantine_retains_control_metadata_without_recovering_provider_state()` +- Required symbol: `fn malformed_or_oversized_receipt_records_are_rejected()` +- Required symbol: `fn failed_attempt_discard_preserves_restart_until_matching_completion()` +- Required symbol: `fn explicit_scope_and_channel_revocation_clear_intent_but_keep_receipts()` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `desktop/src-tauri/src/commands/media_download.rs` +## `crates/buzz-acp/src/session_controls.rs` -Fetch generic attachments through a bounded 100 MiB native path while retaining 50 MiB image/editor/clipboard caps and existing auth, redirect, and cancellation protections. +Authorized session-control admission, per-dispatch generation fences and bounded selected restart history -New module: `false`. +New module: `true`. -- Required symbol: `MAX_FILE_DOWNLOAD_BYTES` -- Required symbol: `mod file_download_tests;` -- Invocation: `desktop/src-tauri/src/commands/media_download.rs` → `fetch_blob_bytes_with_cap`; exact call `fetch_blob_bytes_with_cap(url, state, MAX_FILE_DOWNLOAD_BYTES, None).await`; behavior test `generic_download_above_fifty_mib_is_byte_exact`; verify `cargo test --manifest-path desktop/src-tauri/Cargo.toml --lib commands::media_download::file_download_tests::generic_download_above_fifty_mib_is_byte_exact -- --exact` -- Verify: `cargo test --manifest-path desktop/src-tauri/Cargo.toml --workspace` +- Required symbol: `pub(crate) fn restart_intent` +- Required symbol: `pub(crate) fn complete_restart` +- Required symbol: `pub(crate) fn restart(` +- Required symbol: `pub(crate) fn retirement_pending` +- Required symbol: `pub(crate) fn capture(` +- Required symbol: `pub(crate) fn current(` +- Required symbol: `pub(crate) fn revoke_channel` +- Required symbol: `pub(crate) fn finish(` +- Required symbol: `self.runtime.epoch(&self.scope) == self.epoch` +- Required symbol: `restart_status_token: Some(intent.token)` +- Invocation: `crates/buzz-acp/src/session_controls.rs` → `request_restart_status`; exact call `let superseded = store.request_restart_status(&job.scope, &intent, status)?;`; behavior test `checked_retirement_supersedes_started_compact_without_a_compact_rpc`; verify `cargo test -p buzz-acp --lib checked_retirement_supersedes_started_compact_without_a_compact_rpc -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `desktop/src-tauri/src/commands/media_download_file_tests.rs` +## `crates/buzz-acp/src/pool/session_history.rs` -Exercise the native HTTP file fetch above 50 MiB, the 100 MiB boundary, declared and streamed oversize rejection, preserved image caps, and an opt-in real-relay hash check. +Authorized session-control admission, per-dispatch generation fences and bounded selected restart history New module: `true`. -- Required symbol: `generic_download_above_fifty_mib_is_byte_exact` -- Required symbol: `generic_download_rejects_over_hundred_mib_with_or_without_length` -- Required symbol: `native_download_from_relay_matches_expected_sha256` -- Verify: `cargo test --manifest-path desktop/src-tauri/Cargo.toml --workspace` +- Required symbol: `HISTORY_BYTES: usize = 256 * 1024` +- Required symbol: `RESPONSE_BYTES: usize = 4 * 1024 * 1024` +- Required symbol: `MAX_PAGES: usize = 20` +- Required symbol: `MAX_CANDIDATES: usize = 10_000` +- Required symbol: `pub(super) async fn selected` +- Required symbol: `pub(super) fn enforce_render_budget` +- Required symbol: `while crate::queue::format_conversation_context(context, profiles).len() > HISTORY_BYTES {` +- Required symbol: `const RETAINED_HEAD_BYTES: usize = HISTORY_BYTES + MAX_CANDIDATES * 5;` +- Required symbol: `pub(super) fn supplied_mention` +- Invocation: `crates/buzz-acp/src/pool/session_history.rs` → `query_raw_bounded`; exact call `.query_raw_bounded(filters, RESPONSE_BYTES),`; behavior test `bounded_history_reader_rejects_chunked_actual_bytes_and_times_out`; verify `cargo test -p buzz-acp --lib bounded_history_reader_rejects_chunked_actual_bytes_and_times_out -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool/session_history.rs` → `saturating_sub`; exact call `if requested > MAX_CANDIDATES.saturating_sub(self.candidates) {`; behavior test `all_history_request_budget_reserves_tail_without_overfetching_candidates`; verify `cargo test -p buzz-acp --lib all_history_request_budget_reserves_tail_without_overfetching_candidates -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool/session_history.rs` → `saturating_sub`; exact call `rendered.len().saturating_sub(empty.len()) + 5`; behavior test `all_history_many_small_messages_fit_the_actual_rendered_budget`; verify `cargo test -p buzz-acp --lib all_history_many_small_messages_fit_the_actual_rendered_budget -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool/session_history.rs` → `sort_by`; exact call `events.sort_by(|a, b| { + b["created_at"] + .as_u64() + .cmp(&a["created_at"].as_u64()) + .then_with(|| a["id"].as_str().cmp(&b["id"].as_str())) + });`; behavior test `dm_same_second_all_fits_with_older_assignment_under_real_relay_cursor`; verify `cargo test -p buzz-acp --lib dm_same_second_all_fits_with_older_assignment_under_real_relay_cursor -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool/session_history.rs` → `key`; exact call `root.is_none() && key(&event) < key(prior)`; behavior test `dm_same_second_none_finds_earliest_assignment_under_real_relay_cursor`; verify `cargo test -p buzz-acp --lib dm_same_second_none_finds_earliest_assignment_under_real_relay_cursor -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `desktop/src-tauri/src/commands/media_filename.rs` +## `crates/buzz-acp/src/pool/session_history_tests.rs` -Bound sanitized filenames by UTF-8 bytes at character boundaries while preserving the extension for imeta ingest. +Production-seam regressions for authorized session controls and bounded selected history -New module: `false`. +New module: `true`. -- Required symbol: `unicode_labels_fit_relay_utf8_byte_limit_without_losing_extensions` -- Verify: `cargo test --manifest-path desktop/src-tauri/Cargo.toml --workspace` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `crates/buzz-mesh-smoke/Cargo.toml` +## `crates/buzz-acp/src/pool/session_control_runtime_tests.rs` -Use the same immutable patched MeshLLM source as the desktop consumer to avoid duplicate runtime types. +Production-seam regressions for authorized session controls and bounded selected history -New module: `false`. +New module: `true`. -- Verify: `cargo test -p buzz-mesh-smoke` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `desktop/src-tauri/src/commands/agent_discovery/managed_node.rs` +## `crates/buzz-acp/src/pool/session_control_history_tests.rs` -Preserve confined Node.js extraction while propagating ZIP 9 entry-name decoding errors. +Production-seam regressions for authorized session controls and bounded selected history -New module: `false`. +New module: `true`. -- Verify: `just desktop-tauri-test desktop-tauri-check` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs` +## `crates/buzz-acp/src/filter.rs` -Exercise the production version probe with immutable scripts and explicit interpreter PATH inputs. +Authorized session-control admission, per-dispatch generation fences and bounded selected restart history New module: `false`. -- Required symbol: `probe_codex_acp_version_uses_augmented_path_for_env_shebang_interpreter` -- Verify: `just desktop-tauri-test desktop-tauri-check` +- Invocation: `crates/buzz-acp/src/filter.rs` → `reserved_raw`; exact call `crate::session_controls::reserved_raw(&raw)`; behavior test `stale_replay_does_not_overwrite_completed_ack`; verify `cargo test -p buzz-acp --lib stale_replay_does_not_overwrite_completed_ack -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` -## `desktop/src-tauri/src/managed_agents/discovery/tests/codex_version_fixture.rs` +## `crates/buzz-acp/src/relay.rs` -Provide immutable executable symlinks, mutable version data and cleanup for the deliberately surviving descendant. +Authorized session-control admission, per-dispatch generation fences and bounded selected restart history + +New module: `false`. + +- Required symbol: `pub async fn query_raw_bounded` +- Required symbol: `chunk.len() > max_bytes.saturating_sub(bytes.len())` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/acp/terminate.rs` + +Checked bounded Unix process-group and Windows contained-job retirement + +New module: `true`. + +- Required symbol: `pub enum TerminationReport` +- Required symbol: `pub async fn terminate(` +- Required symbol: `fn terminate_with_snapshot` +- Required symbol: `fn probe_alive` +- Invocation: `crates/buzz-acp/src/acp/terminate.rs` → `snapshot`; exact call `tokio::task::spawn_blocking(crate::process_tree::retirement::snapshot)`; behavior test `checked_termination_kills_snapshotted_separate_shell_group`; verify `cargo test -p buzz-acp --lib checked_termination_kills_snapshotted_separate_shell_group -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/acp/terminate.rs` → `verified_groups`; exact call `let mut groups = first.verified_groups(&last, pid, own_group);`; behavior test `checked_termination_kills_snapshotted_separate_shell_group`; verify `cargo test -p buzz-acp --lib checked_termination_kills_snapshotted_separate_shell_group -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/acp/terminate.rs` → `probe_alive`; exact call `probe_alive(killpg(Pid::from_raw(group), None))`; behavior test `permission_denied_process_group_is_alive`; verify `cargo test -p buzz-acp --lib permission_denied_process_group_is_alive -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/acp/terminate.rs` → `worker_present`; exact call `snapshot().await.filter(|tree| tree.worker_present(pid))`; behavior test `checked_termination_missing_worker_in_initial_snapshot_never_starts`; verify `cargo test -p buzz-acp --lib checked_termination_missing_worker_in_initial_snapshot_never_starts -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/acp/terminate_tests.rs` + +Actual subprocess retirement escalation and no-signal regressions + +New module: `true`. + +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/process_tree/retirement.rs` + +Bounded PGID snapshots and escaped-process identity validation + +New module: `true`. + +- Required symbol: `struct RetirementSnapshot` +- Required symbol: `fn verified_groups` +- Required symbol: `fn snapshot()` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/process_tree/retirement_tests.rs` + +Process-group parsing, harness exclusion and PID identity regressions + +New module: `true`. + +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/pool/session_retirement.rs` + +Reserve every cached worker copy and retire only after foreign work releases + +New module: `true`. + +- Required symbol: `fn retirement_targets` +- Required symbol: `fn mark_retire_on_return` +- Required symbol: `fn take_retirement_target` +- Required symbol: `fn finish_worker_retirement` +- Invocation: `crates/buzz-acp/src/pool/session_retirement.rs` → `mark_departed`; exact call `match store.mark_departed(`; behavior test `checked_retirement_cannot_depart_neighbor_owned_by_a_newer_worker`; verify `cargo test -p buzz-acp --lib checked_retirement_cannot_depart_neighbor_owned_by_a_newer_worker -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/pool/session_retirement_tests.rs` + +Pool reservation, foreign foreground/background and task-return retirement regressions + +New module: `true`. + +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/pool/session_retirement_runtime_tests.rs` + +Controller/executor, supersession, delayed completion and neighbor lifecycle regressions + +New module: `true`. + +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/session_controls/retirement.rs` + +Controller-owned checked gates, all-target results and retained failure retry + +New module: `true`. + +- Required symbol: `fn plan_retirement` +- Required symbol: `fn retirement_report` +- Required symbol: `fn take_publications_up_to` +- Required symbol: `fn lose_retirement_evidence` +- Required symbol: `fn retirement_evidence_lost` +- Required symbol: `fn owns_retirement` +- Required symbol: `let status_current = entry.epoch == job.dispatch_epoch;` +- Invocation: `crates/buzz-acp/src/session_controls/retirement.rs` → `finish_control_attempt`; exact call `store.finish_control_attempt(scope, &job.origin.command_event_id, &outcome)`; behavior test `checked_retirement_terminal_store_failure_retains_gate_and_reports_uncertainty`; verify `cargo test -p buzz-acp --lib checked_retirement_terminal_store_failure_retains_gate_and_reports_uncertainty -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/session_controls/retirement.rs` → `status_ready`; exact call `if release && outcome.is_ok() && status_current { + Self::status_ready(state, store, scope);`; behavior test `unavailable_cancel_during_retirement_cannot_recreate_the_followup_after_clean_exit`; verify `cargo test -p buzz-acp --lib unavailable_cancel_during_retirement_cannot_recreate_the_followup_after_clean_exit -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/session_controls/publish.rs` + +Signed bounded restart and compaction result publication + +New module: `true`. + +- Required symbol: `fn build_result` +- Required symbol: `async fn publish_result` +- Invocation: `crates/buzz-acp/src/session_controls/publish.rs` → `submit_event_once`; exact call `rest.submit_event_once(&event)`; behavior test `control_result_publication_has_three_total_signed_attempts`; verify `cargo test -p buzz-acp --lib control_result_publication_has_three_total_signed_attempts -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/session_store_orphans.rs` + +Dead-owner durable Started receipt reconciliation at startup Retain autonomous revisit work for live or uncertain foreign Started owners. + +New module: `true`. + +- Required symbol: `fn sweep_orphan_controls` +- Required symbol: `pub revisit: bool` +- Required symbol: `progress.revisit = true;` +- Invocation: `crates/buzz-acp/src/session_store_orphans.rs` → `canonical_key`; exact call `let Some(path) = canonical_key(&entry_path) else {`; behavior test `startup_sweep_recovers_stale_only_started_receipt_and_deduplicates_record_key`; verify `cargo test -p buzz-acp --lib startup_sweep_recovers_stale_only_started_receipt_and_deduplicates_record_key -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/session_store_orphans.rs` → `instance_liveness`; exact call `if self.instance_liveness(receipt.owner_instance) + != InstanceLiveness::Dead + {`; behavior test `startup_sweep_terminalizes_dead_started_controls_but_preserves_live_owner`; verify `cargo test -p buzz-acp --lib startup_sweep_terminalizes_dead_started_controls_but_preserves_live_owner -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/session_store_orphans.rs` → `instance_liveness`; exact call `match self.instance_liveness(receipt.owner_instance) {`; behavior test `orphan_registry_unknown_revisits_after_lock_release_without_external_wake`; verify `cargo test -p buzz-acp --lib orphan_registry_unknown_revisits_after_lock_release_without_external_wake -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` +- Verify: `cargo test -p buzz-acp --lib orphan_registry_unknown_revisits_after_lock_release_without_external_wake -- --test-threads=1` + +## `crates/buzz-acp/src/worker_retirement.rs` + +Nonblocking checked worker replacement and retained ownership on uncertainty Explicitly drain parked and cancellation-returned original ownership at orderly shutdown using retained checked evidence. + +New module: `true`. + +- Required symbol: `struct RetirementReturn` +- Required symbol: `struct RetirementGuard` +- Required symbol: `fn drive(` +- Required symbol: `fn finish(` +- Required symbol: `pub(crate) async fn shutdown(` +- Invocation: `crates/buzz-acp/src/worker_retirement.rs` → `terminate`; exact call `Some(agent) => agent.acp.terminate().await,`; behavior test `checked_retirement_executor_waits_for_verified_kill_and_excludes_refill`; verify `cargo test -p buzz-acp --lib checked_retirement_executor_waits_for_verified_kill_and_excludes_refill -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/worker_retirement.rs` → `retirement_report`; exact call `controls.retirement_report(result.index, TerminationReport::Clean);`; behavior test `checked_retirement_executor_waits_for_verified_kill_and_excludes_refill`; verify `cargo test -p buzz-acp --lib checked_retirement_executor_waits_for_verified_kill_and_excludes_refill -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/worker_retirement.rs` → `prepare_retired_neighbors`; exact call `agent.prepare_retired_neighbors(&scopes, &controls)`; behavior test `checked_retirement_cannot_depart_neighbor_owned_by_a_newer_worker`; verify `cargo test -p buzz-acp --lib checked_retirement_cannot_depart_neighbor_owned_by_a_newer_worker -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/worker_retirement.rs` → `lose_retirement_evidence`; exact call `controls.lose_retirement_evidence(result.index);`; behavior test `retirement_foreground_panic_cannot_attest_replacement_or_forget_original_on_retry`; verify `cargo test -p buzz-acp --lib retirement_foreground_panic_cannot_attest_replacement_or_forget_original_on_retry -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `shutdown`; exact call `worker_retirement::shutdown(`; behavior test `retirement_shutdown_retries_original_parked_after_snapshot_failure`; verify `cargo test -p buzz-acp --lib retirement_shutdown_retries_original_parked_after_snapshot_failure -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/worker_retirement.rs` → `terminate`; exact call `let report = agent.acp.terminate().await;`; behavior test `retirement_shutdown_drains_original_returned_by_aborted_guard`; verify `cargo test -p buzz-acp --lib retirement_shutdown_drains_original_returned_by_aborted_guard -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` +- Verify: `cargo test -p buzz-acp --lib retirement_shutdown_retries_original_parked_after_snapshot_failure -- --test-threads=1` +- Verify: `cargo test -p buzz-acp --lib retirement_shutdown_drains_original_returned_by_aborted_guard -- --test-threads=1` + +## `crates/buzz-acp/src/session_controls/orphans.rs` + +Idle-waking bounded-backoff orphan reconciliation with partial durable publication Back off live/unknown revisits without declaring startup reconciliation complete. + +New module: `true`. + +- Required symbol: `struct OrphanReconciliation` +- Required symbol: `fn sweep_orphans` +- Required symbol: `fn drive(` +- Required symbol: `fn wake(` +- Required symbol: `controls.has_receipt_writes()` +- Required symbol: `revisit: progress.revisit,` +- Required symbol: `} else if progress.revisit {` +- Invocation: `crates/buzz-acp/src/session_controls/orphans.rs` → `sweep_orphan_controls`; exact call `store.sweep_orphan_controls(capacity.saturating_sub(local.completed))?`; behavior test `startup_sweep_keeps_healthy_and_committed_prefix_outcomes_across_bad_records`; verify `cargo test -p buzz-acp --lib startup_sweep_keeps_healthy_and_committed_prefix_outcomes_across_bad_records -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/session_controls/orphans.rs` → `publish`; exact call `state.publish(Publication {`; behavior test `startup_sweep_keeps_healthy_and_committed_prefix_outcomes_across_bad_records`; verify `cargo test -p buzz-acp --lib startup_sweep_keeps_healthy_and_committed_prefix_outcomes_across_bad_records -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/session_controls/orphans.rs` → `retry_receipt_writes`; exact call `self.retry_receipt_writes(capacity)`; behavior test `compact_terminal_write_retries_after_repair_without_reexecuting_effect`; verify `cargo test --locked -p buzz-acp --lib compact_terminal_write_retries_after_repair_without_reexecuting_effect -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/session_controls/orphans.rs` → `sweep_orphans`; exact call `controls.sweep_orphans(capacity.min(128))`; behavior test `orphan_alive_foreign_owner_is_revisited_after_it_dies`; verify `cargo test -p buzz-acp --lib orphan_alive_foreign_owner_is_revisited_after_it_dies -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` +- Verify: `cargo test -p buzz-acp --lib orphan_alive_foreign_owner_is_revisited_after_it_dies -- --test-threads=1` + +## `crates/buzz-acp/src/pool/session_retirement_review_tests.rs` + +Actual executor no-resume neighbor race and panic escaped-group false-Clean regressions + +New module: `true`. + +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/acp/windows_retirement_tests.rs` + +Hosted Windows actual ACP containment and checked termination regressions + +New module: `true`. + +- Verify: `python3 scripts/fork/sync.py validate` + +## `crates/buzz-acp/src/pool/portable_retirement_tests.rs` + +Portable actual fake-ACP all-target controller/store/foreign-return gates + +New module: `true`. + +- Verify: `python3 scripts/fork/sync.py validate` + +## `scripts/check-acp-retirement.py` + +Native hosted ACP retirement gate with test discovery and exact execution counts + +New module: `true`. + +- Verify: `python3 scripts/fork/sync.py validate` + +## `Cargo.toml` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-agent/Cargo.toml` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-agent/src/hints.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Required symbol: `yaml_serde::from_str(yaml_block).ok()?` +- Invocation: `crates/buzz-agent/src/hints.rs` → `from_str`; exact call `yaml_serde::from_str(yaml_block).ok()?`; behavior test `hints::yaml_tests::skill_yaml_preserves_words_tags_aliases_and_multiline_text`; verify `cargo test --locked -p buzz-agent --lib hints::yaml_tests` +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Verify: `cargo test --locked -p buzz-agent --lib hints::yaml_tests` + +## `crates/buzz-agent/src/hints_yaml_tests.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `true`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-backend-kubernetes/src/client.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Required symbol: `let config = config_for(context).await?;` +- Invocation: `crates/buzz-backend-kubernetes/src/client.rs` → `config_for`; exact call `let config = config_for(context).await?;`; behavior test `client::yaml_tests::kubeconfig_connect_and_exec_lookup_use_the_selected_context`; verify `cargo test --locked -p buzz-backend-kubernetes` +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Verify: `cargo test --locked -p buzz-backend-kubernetes` + +## `crates/buzz-backend-kubernetes/src/client_yaml_tests.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `true`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-backend-kubernetes/src/cluster.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-backend-kubernetes/src/gc.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Required symbol: `let age_nanos = now_nanos - created.0.as_nanosecond();` +- Invocation: `crates/buzz-backend-kubernetes/src/gc.rs` → `as_nanosecond`; exact call `let age_nanos = now_nanos - created.0.as_nanosecond();`; behavior test `gc::tests::age_gate_boundary_is_exact`; verify `cargo test --locked -p buzz-backend-kubernetes` +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Verify: `cargo test --locked -p buzz-backend-kubernetes` + +## `crates/buzz-backend-kubernetes/src/observe.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-backend-kubernetes/src/reconcile.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-persona/Cargo.toml` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-persona/src/persona.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Required symbol: `yaml_serde::from_str(fm_str)?` +- Invocation: `crates/buzz-persona/src/persona.rs` → `from_str`; exact call `yaml_serde::from_str(fm_str)?`; behavior test `persona_yaml_preserves_scalar_types_aliases_and_multiline_unicode`; verify `cargo test --locked -p buzz-persona --test yaml_compatibility` +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Verify: `cargo test --locked -p buzz-persona --test yaml_compatibility` + +## `crates/buzz-persona/src/validate.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Required symbol: `yaml_serde::from_str(fm_str)` +- Invocation: `crates/buzz-persona/src/validate.rs` → `from_str`; exact call `yaml_serde::from_str(fm_str)`; behavior test `pack_validation_preserves_skill_name_warnings_with_unknown_yaml_values`; verify `cargo test --locked -p buzz-persona --test yaml_compatibility` +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Verify: `cargo test --locked -p buzz-persona --test yaml_compatibility` + +## `crates/buzz-persona/tests/yaml_compatibility.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `true`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-workflow/Cargo.toml` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-workflow/src/error.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-workflow/src/schema.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Required symbol: `yaml_serde::from_str(yaml)?` +- Invocation: `crates/buzz-workflow/src/schema.rs` → `from_str`; exact call `yaml_serde::from_str(yaml)?`; behavior test `schema::yaml_tests::workflow_yaml_preserves_trigger_aliases_and_message_text`; verify `cargo test --locked -p buzz-workflow --lib schema::yaml_tests` +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Verify: `cargo test --locked -p buzz-workflow --lib schema::yaml_tests` + +## `crates/buzz-workflow/src/schema_yaml_tests.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `true`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `desktop/src-tauri/src/commands/workflows.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Required symbol: `yaml_serde::from_str::(yaml)` +- Invocation: `desktop/src-tauri/src/commands/workflows.rs` → `from_str`; exact call `yaml_serde::from_str::(yaml)`; behavior test `commands::workflows::tests::workflow_yaml_json_projection_preserves_words_and_fallbacks`; verify `cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib commands::workflows::tests::workflow_yaml_json_projection_preserves_words_and_fallbacks` +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Verify: `cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib commands::workflows::tests::workflow_yaml_json_projection_preserves_words_and_fallbacks` + +## `desktop/src-tauri/src/commands/workflows_tests.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `desktop/src-tauri/src/managed_agents/config_bridge/goose.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Required symbol: `yaml_serde::from_str(yaml_str).ok()?` +- Invocation: `desktop/src-tauri/src/managed_agents/config_bridge/goose.rs` → `from_str`; exact call `yaml_serde::from_str(yaml_str).ok()?`; behavior test `managed_agents::config_bridge::goose::yaml_tests::goose_yaml_preserves_nested_providers_and_ignores_unknown_tagged_fields`; verify `cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib managed_agents::config_bridge::goose::yaml_tests` +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Verify: `cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib managed_agents::config_bridge::goose::yaml_tests` + +## `desktop/src-tauri/src/managed_agents/config_bridge/goose_yaml_tests.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `true`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `desktop/src-tauri/src/migration_yaml_tests.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `true`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `docs/fork/YAML_COMPATIBILITY.md` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `true`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `scripts/fork/tests/test_yaml_dependencies.py` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `true`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `AGENTS.md` + +Fork rule: public text is in English and carries no internal workspace details Fork screenshot publication requires explicit paired repository/remote selection and normal pushes with DCO. + +New module: `false`. + +- Required symbol: `## Fork: public text is in English` +- Verify: `python3 scripts/fork/sync.py validate` + +## `crates/buzz-acp/src/acp/compaction.rs` + +Bound compaction IDs and typed lifecycles by provider session and local generation; terminal enrichment cannot satisfy a later manual operation. Retain bounded creation advertisements under the current in-flight token and adopt only the successfully returned session. + +New module: `true`. + +- Required symbol: `if self.entries.len() >= MAX_IDS` +- Required symbol: `after: self.sequence,` +- Required symbol: `.is_some_and(|route| route.generation == generation)` +- Required symbol: `.filter(|route| route.adoptable_binding)` +- Required symbol: `route.adoptable_binding = false;` +- Required symbol: ` if self + .routes + .get(session) + .is_none_or(|route| route.generation != generation) + { + return; + }` +- Required symbol: `pub(super) fn prepare_new(` +- Required symbol: `pub(super) fn complete_new(` +- Required symbol: `pending.advertisements.len() < MAX_IDS` +- Required symbol: `bytes.saturating_add(session.len()) <= MAX_CREATION_BYTES` +- Required symbol: `self.creation = None;` +- Required symbol: `.is_none_or(|pending| pending.generation != generation)` +- Invocation: `crates/buzz-acp/src/acp/compaction.rs` → `retain`; exact call `self.entries + .retain(|entry| sessions.contains(&entry.session));`; behavior test `compaction_standard_dispatch_never_guesses_success`; verify `cargo test --locked -p buzz-acp --lib compaction_standard_dispatch_never_guesses_success -- --test-threads=1` +- Verify: `cargo test --locked -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/acp/compaction_tests.rs` + +Exercise actual initialize frames, standard compaction dispatch, tracker bounds, and status transition ownership. + +New module: `true`. + +- Verify: `cargo test --locked -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/pool/compaction.rs` + +Acquire only the exact existing provider session, persist Started before a single compact prompt, and return separately typed control health/results. Reset stale worker observer context to the admitted channel, existing provider session and command identity before resume or compact I/O. Reject disk-only targets before lazy wake when provider resume is disabled, while preserving existing in-memory compaction. + +New module: `true`. + +- Required symbol: `pub(crate) struct ExistingSession` +- Required symbol: `pub(crate) async fn acquire_existing_session_for_compaction(` +- Required symbol: `pub(crate) async fn run_compaction_task(` +- Required symbol: `if live.lease != *lease || live.session_id != *session_id` +- Required symbol: `Ok(_) => terminal,` +- Required symbol: `let execution = tokio::time::timeout_at(job.deadline, async {` +- Required symbol: `job.job.origin.command_event_id.clone(),` +- Required symbol: `allow_disk_resume: bool,` +- Required symbol: `if !allow_disk_resume {` +- Invocation: `crates/buzz-acp/src/pool/compaction.rs` → `begin_compaction_effect`; exact call `ctx.session_controls.begin_compaction_effect(&job)`; behavior test `compact_exact_prompt_and_durable_started_preserve_delivery_and_turns`; verify `cargo test --locked -p buzz-acp --lib compact_exact_prompt_and_durable_started_preserve_delivery_and_turns -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool/compaction.rs` → `session_resume`; exact call `.session_resume(session_id, &ctx.cwd, ctx.mcp_servers.clone(), None, None)`; behavior test `compact_never_calls_session_new_memory_or_disk_success`; verify `cargo test --locked -p buzz-acp --lib compact_never_calls_session_new_memory_or_disk_success -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool/compaction.rs` → `session_prompt_blocks_with_idle_timeout`; exact call `agent.acp.session_prompt_blocks_with_idle_timeout(&session.session_id, &["/compact"], ctx.idle_timeout, ctx.max_turn_duration)`; behavior test `compact_exact_prompt_and_durable_started_preserve_delivery_and_turns`; verify `cargo test --locked -p buzz-acp --lib compact_exact_prompt_and_durable_started_preserve_delivery_and_turns -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/pool/compaction.rs` → `set_observer_context`; exact call `agent.acp.set_observer_context(observer::context_for_turn(`; behavior test `compact_observer_routes_resume_prompt_and_updates_to_admitted_scope`; verify `cargo test --locked -p buzz-acp compact_ -- --test-threads=1` +- Verify: `cargo test --locked -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/pool/compaction_tests.rs` + +Exercise existing-only acquisition and actual compact RPC, durable markers, signed result submission and failure/redelivery behavior. Regress actual disk-resume observer envelope routing from stale prior scope. + +New module: `true`. + +- Required symbol: `compact_observer_routes_resume_prompt_and_updates_to_admitted_scope` +- Verify: `cargo test --locked -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/pool/compaction_runtime_tests.rs` + +Exercise authorized controller admission, exact-owner scheduling, lazy wake, collection protection, bounded pending work and lifecycle termination. Regress actual cached-worker control-dispatch observer envelope routing. + +New module: `true`. + +- Required symbol: `compact_cached_worker_replaces_previous_observer_scope` +- Required symbol: `mod resume_policy_tests;` +- Verify: `cargo test --locked -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/pool/compaction_resume_policy_tests.rs` + +Bind authorized target selection and scheduler wake to disabled disk resume, durable refusal replay, enabled disk resume and successful no-store in-memory compaction. + +New module: `true`. + +- Required symbol: `compact_resume_disabled_disk_refusal_and_replay_never_wake` +- Required symbol: `compact_resume_enabled_disk_wakes_and_resumes_without_new_session` +- Required symbol: `compact_resume_disabled_memory_owner_still_compacts_without_resume` +- Verify: `cargo test --locked -p buzz-acp --lib compact_resume_ -- --test-threads=1` + +## `crates/buzz-acp/src/session_controls/compaction.rs` + +Keep pending manual compactions, bounded terminal outcomes and durable redelivery independent of disposable pools. + +New module: `true`. + +- Required symbol: `pub(crate) struct AdmittedSessionControl` +- Required symbol: `if scopes.len() + state.receipt_writes.len() >= 128` +- Required symbol: `if self.outcomes.len() >= 128` +- Required symbol: `if pending_scopes + state.receipt_writes.len() >= 128` +- Required symbol: `pending.started = Some(receipt);` +- Required symbol: `Err(_) => true,` +- Required symbol: `outcome = Err(ControlFailure::StoreUnavailable);` +- Required symbol: `COMPACT_QUEUE_WAIT` +- Required symbol: `COMPACT_EXECUTION_WAIT` +- Required symbol: `pub(crate) fn report_compaction_wait(` +- Invocation: `crates/buzz-acp/src/session_controls/compaction.rs` → `begin_admitted_control_with_receipt`; exact call `.begin_admitted_control_with_receipt( + &job.job.scope, + &job.job.origin.command_event_id, + job.job.event.created_at.as_secs(), + job.job.admitted_at, + )`; behavior test `compact_exact_prompt_and_durable_started_preserve_delivery_and_turns`; verify `cargo test --locked -p buzz-acp --lib compact_exact_prompt_and_durable_started_preserve_delivery_and_turns -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/session_controls/compaction.rs` → `begin_admitted_control_with_receipt`; exact call `.begin_admitted_control_with_receipt( + &job.job.scope, + &job.job.origin.command_event_id, + job.job.event.created_at.as_secs(), + job.job.admitted_at, + )`; behavior test `compact_finalized_started_survives_both_locked_finalization_calls`; verify `cargo test --locked -p buzz-acp --lib compact_finalized_started_survives_both_locked_finalization_calls -- --test-threads=1` +- Verify: `cargo test --locked -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/control_tasks.rs` + +Dispatch typed control tasks through shared worker ownership and retirement, with separate result accounting and completion activity. + +New module: `true`. + +- Required symbol: `queue.has_flushable_work() + || !controls.pending_compactions().is_empty() + || !controls.pending_statuses().is_empty()` +- Required symbol: `} else if tokio::time::Instant::now() >= job.deadline {` +- Required symbol: `*last_activity = tokio::time::Instant::now();` +- Invocation: `crates/buzz-acp/src/control_tasks.rs` → `run_compaction_task`; exact call `run_compaction_task(agent, job, ctx_clone, control_rx).await`; behavior test `compact_waits_for_exact_busy_owner_without_cancel_or_fork`; verify `cargo test --locked -p buzz-acp --lib compact_waits_for_exact_busy_owner_without_cancel_or_fork -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/control_tasks.rs` → `run_compaction_task`; exact call `run_compaction_task(agent, job, ctx_clone, control_rx).await`; behavior test `compact_valid_admission_crosses_wire_expiry_while_waiting_for_owner`; verify `cargo test --locked -p buzz-acp --lib compact_valid_admission_crosses_wire_expiry_while_waiting_for_owner -- --test-threads=1` +- Verify: `cargo test --locked -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/session_controls/receipt_writes.rs` + +Bound known-finalized local terminal receipt writes, fence persistence by exact owner/generation, and reuse reconciliation backoff without repeating provider effects. Use exact Started evidence captured before effects; retaining repair never requires a later store read. + +New module: `true`. + +- Required symbol: `pub(super) fn retain_receipt_write(` +- Required symbol: `pub(super) fn retry_receipt_writes(` +- Required symbol: `receipt: ControlReceipt,` +- Required symbol: `receipt.owner_instance != store.instance()` +- Invocation: `crates/buzz-acp/src/session_controls/receipt_writes.rs` → `retry_control_outcome`; exact call `store.retry_control_outcome(&entry.scope, &entry.receipt, &entry.outcome)`; behavior test `compact_terminal_write_retries_after_repair_without_reexecuting_effect`; verify `cargo test --locked -p buzz-acp --lib compact_terminal_write_retries_after_repair_without_reexecuting_effect -- --test-threads=1` +- Verify: `cargo test --locked -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/acp/windows_job.rs` + +Suspended Windows worker containment with independent zero-job and direct-child retirement proof + +New module: `true`. + +- Required symbol: `struct AssignQueryJob` +- Required symbol: `pub(super) fn spawn` +- Required symbol: `async fn terminate_windows` +- Required symbol: `query_process_id_list` +- Required symbol: `graceful_deadline` +- Required symbol: `verify_deadline` +- Required symbol: `async fn terminate_windows_with_query` +- Invocation: `crates/buzz-acp/src/acp.rs` → `spawn`; exact call `let spawned = windows_job::spawn(cmd);`; behavior test `checked_termination_windows_harness_drop_kills_detached_tree`; verify `cargo test -p buzz-acp --lib checked_termination_windows -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/acp/terminate.rs` → `terminate_windows`; exact call `self.terminate_windows().await`; behavior test `checked_termination_windows_detached_grandchild_blocks_clean_after_parent_exit`; verify `cargo test -p buzz-acp --lib checked_termination_windows -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/acp/windows_job.rs` → `terminate_windows_with_query`; exact call `self.terminate_windows_with_query(|job| {`; behavior test `checked_termination_windows_empty_query_requires_parent_reap`; verify `python scripts/check-acp-retirement.py` +- Verify: `cargo test -p buzz-acp --lib checked_termination_windows -- --test-threads=1` + +## `desktop/src/features/messages/lib/sessionControl.ts` + +Parse the shared exact grammar with Rust whitespace parity; project original signed command requests and independent raw target-signed bounded outcomes. + +New module: `true`. + +- Required symbol: `export function parseSessionCommand(` +- Required symbol: `export function attachSessionControls(` +- Required symbol: `event.pubkey === target` +- Required symbol: `parseSessionCommand(event.content)` +- Required symbol: `const EXTERNAL_WHITESPACE = /^\p{White_Space}+|\p{White_Space}+$/gu;` +- Required symbol: `content.replace(EXTERNAL_WHITESPACE, "")` +- Required symbol: `event.tags.some((tag) => tag[0] === "buzz-session-result")` +- Required symbol: `expiresAt?: number;` +- Verify: `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionControl.test.mjs src/features/messages/lib/sessionControlPermissions.test.mjs` + +## `desktop/src/features/messages/lib/sessionControlPermissions.ts` + +Authorize exact current scope from verified directory policy, owner/sibling coordinates, channel type and membership; preserve DM restrictions and remote cross-owner admission without local inventory authority. + +New module: `true`. + +- Required symbol: `export function canManageSession(` +- Required symbol: `!agent.channelIds.includes(channelId)` +- Required symbol: `if (channel.channelType === "dm") return false;` +- Verify: `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionControl.test.mjs src/features/messages/lib/sessionControlPermissions.test.mjs` + +## `desktop/src/features/messages/lib/useSessionControlAction.ts` + +Capture exact target, thread, relay and signer, refresh verified permission, fence stale async actions and publish one exact kind-9 control without composer or lifecycle mutations. + +New module: `true`. + +- Required symbol: `export function useSessionControlAction(` +- Required symbol: `if (!isCurrent()) return false;` +- Required symbol: `currentKey.current.generation === capturedGeneration` +- Verify: `cd desktop && node --import ./test-jsdom-setup.mjs --import ./test-loader.mjs --experimental-strip-types --test-force-exit --test src/features/messages/lib/useSessionControlAction.jsdom-test.mjs src/features/messages/ui/MessageSessionControlRow.jsdom-test.mjs` + +## `desktop/src/features/messages/lib/sessionControl.test.mjs` + +Production formatter and shared grammar corpus regressions for original versus edited bodies, raw target results, conflicts, multiple targets, expiry, actor metadata and known bot membership. + +New module: `true`. + +- Required symbol: `shared exact grammar matches the actual core parser whitespace corpus` +- Required symbol: `formatter reads original signed kind9 body` +- Required symbol: `results require exact target raw signer` +- Verify: `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionControl.test.mjs src/features/messages/lib/sessionControlPermissions.test.mjs` + +## `desktop/src/features/messages/lib/sessionControlPermissions.test.mjs` + +Permission matrix covers owner/sibling, allowlist/anyone, DM restrictions, missing and mismatched scope evidence and local inventory denial. + +New module: `true`. + +- Required symbol: `verified scope and exact policy evidence admit owner` +- Required symbol: `unknown, stale local inventory` +- Verify: `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionControl.test.mjs src/features/messages/lib/sessionControlPermissions.test.mjs` + +## `desktop/src/features/messages/lib/useSessionControlAction.jsdom-test.mjs` + +Exercise the production hook for revocation, query/publication failures, scope switches, round trips, unmount, duplicate clicks and actual channel profile batching. + +New module: `true`. + +- Required symbol: `switching away and back cannot revive an old action` +- Required symbol: `manual actor joins the actual channel surface batch` +- Verify: `cd desktop && node --import ./test-jsdom-setup.mjs --import ./test-loader.mjs --experimental-strip-types --test-force-exit --test src/features/messages/lib/useSessionControlAction.jsdom-test.mjs src/features/messages/ui/MessageSessionControlRow.jsdom-test.mjs` + +## `desktop/src/features/messages/ui/MessageSessionControls.tsx` + +Selected-agent action surface with explicit captured scope, Back navigation, exact restart/compact grammar, single restart warning, shared-session disclosure and fresh fenced publication. + +New module: `true`. + +- Required symbol: `export function MessageSessionControls(` +- Required symbol: `Stops current work, including subagents. File changes are` +- Required symbol: `Back to agents` +- Invocation: `desktop/src/features/messages/ui/ThreadContextControls.tsx` → `MessageSessionControls`; exact call ` 128` +- Required symbol: `const terminal = prior && prior.status !== "in_progress";` +- Required symbol: `if (typedScopes.has(hookScope))` +- Required symbol: `Number.isSafeInteger(rpc.id)` +- Required symbol: `const firstNew = candidates.find(` +- Required symbol: `item.id === firstNew?.id` +- Required symbol: `sessionId: params.sessionId,` +- Required symbol: `update.summary.slice(0, MAX_SUMMARY)` +- Required symbol: `activity.status === "failed" || activity.conflict` +- Required symbol: `rpc.error === undefined &&` +- Required symbol: `const newer = next.pendingAttachments.some(` +- Required symbol: `next = rememberBoundary(next, {` +- Required symbol: `if (!newer)` +- Required symbol: `entry.id === boundary.id && entry.agent === boundary.agent` +- Invocation: `desktop/src/features/agents/ui/agentSessionCompaction.ts` → `parseCompactionUpdate`; exact call `parseCompactionUpdate(event.payload)`; behavior test `production transcript mutates one compaction row to failed`; verify `node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/observerCompaction.test.mjs` +- Verify: `node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/observerCompaction.test.mjs` + +## `desktop/src/features/agents/ui/agentSessionTranscript.ts` + +Consume the typed compaction lifecycle projection in actual incremental and rebuilt transcripts. + +New module: `false`. + +- Required symbol: `compactions: emptyCompactionActivityState(),` +- Required symbol: `updateType !== "compaction_update"` +- Invocation: `desktop/src/features/agents/ui/agentSessionTranscript.ts` → `processCompactionActivity`; exact call `processCompactionActivity(d.compactions, event, d.items)`; behavior test `production transcript mutates one compaction row to failed`; verify `node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/observerCompaction.test.mjs` +- Verify: `node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/observerCompaction.test.mjs` + +## `desktop/src/features/agents/ui/agentSessionTypes.ts` + +Carry typed compaction state on existing lifecycle items and a scoped legacy-hook correlation key. + +New module: `false`. + +- Required symbol: `compaction?: CompactionActivity;` +- Required symbol: `compactionScope?: string;` +- Verify: `node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/observerCompaction.test.mjs` + +## `desktop/src/features/agents/ui/agentSessionCompaction.test.mjs` + +Bind compaction semantics to production incremental/build, grouping and meaningful-item seams. + +New module: `true`. + +- Required symbol: `production transcript mutates one compaction row to` +- Required symbol: `real worker initialization and session resume separate generations; turns do not` +- Verify: `node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/observerCompaction.test.mjs` + +## `desktop/src/features/agents/ui/agentSessionCompaction.jsdom-test.mjs` + +Render actual parsed/grouped compaction failure through the production activity presenter with literal untrusted summary text. Mount the production channel panel against the actual observer store to verify worker context and absence of duplicate legacy rows. + +New module: `true`. + +- Required symbol: `typed compaction failure survives production grouping and renders salient literal summary` +- Required symbol: `production channel panel reads real observer store with worker context and hides only the correlated legacy duplicate` +- Verify: `node --import ./desktop/test-jsdom-setup.mjs --import ./desktop/test-loader.mjs --experimental-strip-types --test-force-exit --test desktop/src/features/agents/ui/agentSessionCompaction.jsdom-test.mjs` + +## `desktop/src/features/agents/observerCompaction.test.mjs` + +Exercise production observer-store out-of-order rebuild, reconnect replay dedup, raw retention and reset. + +New module: `true`. + +- Required symbol: `production observer store handles replay and out-of-order rebuild, retains raw frames and resets lifecycle state` +- Invocation: `desktop/src/features/agents/observerRelayStore.ts` → `processTranscriptEvent`; exact call `processTranscriptEvent(transcriptState, event)`; behavior test `production observer store handles replay and out-of-order rebuild, retains raw frames and resets lifecycle state`; verify `node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/observerCompaction.test.mjs` +- Verify: `node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/observerCompaction.test.mjs` + +## `desktop/src/features/agents/ui/agentSessionPanelLayout.ts` + +Keep global worker initialization as non-rendering context only when deriving a channel transcript; raw rail remains channel-scoped. + +New module: `false`. + +- Required symbol: `includeWorkerBoundaries = false,` +- Required symbol: `includeWorkerBoundaries &&` +- Verify: `node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs desktop/src/features/agents/observerCompaction.test.mjs` + +## `desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs` + +Regress scoped lifecycle generation and scroll-key preservation while raw channel filtering remains exact. + +New module: `false`. + +- Required symbol: `channel transcript retains global worker boundaries without expanding the raw channel rail` +- Verify: `node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs desktop/src/features/agents/observerCompaction.test.mjs` + +## `desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx` + +Use worker boundary context for channel transcript generation identity while preserving the raw/header channel window. + +New module: `false`. + +- Invocation: `desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx` → `scopeByChannel`; exact call `scopeByChannel(events, channelId, true)`; behavior test `production channel panel reads real observer store with worker context and hides only the correlated legacy duplicate`; verify `node --import ./desktop/test-jsdom-setup.mjs --import ./desktop/test-loader.mjs --experimental-strip-types --test-force-exit --test desktop/src/features/agents/ui/agentSessionCompaction.jsdom-test.mjs` +- Verify: `node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs desktop/src/features/agents/observerCompaction.test.mjs` + +## `desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx` + +Use worker boundary context for channel transcript generation identity while preserving the raw/header channel window. + +New module: `false`. + +- Invocation: `desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx` → `scopeByChannel`; exact call `scopeByChannel(events, sessionChannelId, true)`; behavior test `production channel panel reads real observer store with worker context and hides only the correlated legacy duplicate`; verify `node --import ./desktop/test-jsdom-setup.mjs --import ./desktop/test-loader.mjs --experimental-strip-types --test-force-exit --test desktop/src/features/agents/ui/agentSessionCompaction.jsdom-test.mjs` +- Verify: `node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs desktop/src/features/agents/observerCompaction.test.mjs` + +## `desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts` + +Exclude only proven typed-compaction duplicate legacy hooks from polished presentation; retain other suppressed rows and raw observer evidence. + +New module: `false`. + +- Required symbol: `item.descriptor?.reason !== "typed-compaction-duplicate"` +- Invocation: `desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts` → `splitIntoSessionRuns`; exact call `splitIntoSessionRuns(`; behavior test `grouped production transcript removes correlated legacy hook instead of rendering a quiet duplicate`; verify `node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs desktop/src/features/agents/observerCompaction.test.mjs` +- Verify: `node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs desktop/src/features/agents/observerCompaction.test.mjs` + +## `scripts/post-screenshots.sh` + +Publish immutable screenshot URLs to an explicit fork and remote with normal nonforce Git push, trusted DCO/signing, and no comment after a rejected push; preserve upstream defaults. + +New module: `false`. + +- Required symbol: `EXPLICIT_REPO=false` +- Required symbol: `TRACKING_REF=` +- Required symbol: `Signed-off-by: %s` +- Required symbol: `git push "$REMOTE"` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -p test_post_screenshots.py -v` + +## `scripts/fork/tests/test_post_screenshots.py` + +Exercise actual screenshot workflow with a local bare fork, real Git history/DCO/immutable URLs and rejected-push publication ordering, without network publication. + +New module: `true`. + +- Required symbol: `test_explicit_fork_preserves_remote_history_and_signs_off` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -p test_post_screenshots.py -v` + +## `crates/buzz-acp/src/session_controls/orphan_owner_tests.rs` + +Production-driver foreign owner reconciliation with real registry-lock uncertainty, later owner death and capped idle wake backoff. + +New module: `true`. + +- Required symbol: `orphan_registry_unknown_revisits_after_lock_release_without_external_wake` +- Required symbol: `orphan_alive_foreign_owner_is_revisited_after_it_dies` +- Required symbol: `orphan_live_owner_revisit_uses_capped_backoff_and_never_spins_without_capacity` +- Verify: `cargo test -p buzz-acp --lib orphan_registry_unknown_revisits_after_lock_release_without_external_wake -- --test-threads=1` +- Verify: `cargo test -p buzz-acp --lib orphan_alive_foreign_owner_is_revisited_after_it_dies -- --test-threads=1` +- Verify: `cargo test -p buzz-acp --lib orphan_live_owner_revisit_uses_capped_backoff_and_never_spins_without_capacity -- --test-threads=1` + +## `crates/buzz-acp/src/worker_retirement_shutdown_tests.rs` + +Production orderly-shutdown regressions for parked originals, abort guard returns and reused original retirement evidence. + +New module: `true`. + +- Required symbol: `retirement_shutdown_retries_original_parked_after_snapshot_failure` +- Required symbol: `retirement_shutdown_drains_original_returned_by_aborted_guard` +- Required symbol: `retirement_shutdown_reuses_original_evidence_after_parent_reaped` +- Required symbol: `retirement_shutdown_retains_uncertain_original_without_fabricating_clean` +- Verify: `cargo test -p buzz-acp --lib retirement_shutdown_retries_original_parked_after_snapshot_failure -- --test-threads=1` +- Verify: `cargo test -p buzz-acp --lib retirement_shutdown_drains_original_returned_by_aborted_guard -- --test-threads=1` +- Verify: `cargo test -p buzz-acp --lib retirement_shutdown_reuses_original_evidence_after_parent_reaped -- --test-threads=1` +- Verify: `cargo test -p buzz-acp --lib retirement_shutdown_retains_uncertain_original_without_fabricating_clean -- --test-threads=1` + +## `crates/buzz-acp/src/pool/background_uncertainty_tests.rs` + +Actual return/hold/poll regressions distinguish discovery uncertainty from observed completion. + +New module: `true`. + +- Required symbol: `background_unknown_then_empty_releases_hold_without_inventing_collection` +- Required symbol: `background_observed_then_unknown_then_empty_collects_exactly_once` +- Required symbol: `background_observed_exit_during_return_survives_later_unknown` +- Verify: `cargo test -p buzz-acp --lib background_unknown_then_empty_releases_hold_without_inventing_collection -- --test-threads=1` +- Verify: `cargo test -p buzz-acp --lib background_observed_then_unknown_then_empty_collects_exactly_once -- --test-threads=1` +- Verify: `cargo test -p buzz-acp --lib background_observed_exit_during_return_survives_later_unknown -- --test-threads=1` + +## `desktop/tests/e2e/message-copy-link.spec.ts` + +Unambiguous huddle event identity/kind/content with unchanged pending/huddle copy-link exclusions. + +New module: `false`. + +- Required symbol: `expect(huddleKind).toBe(KIND_HUDDLE_STARTED);` +- Required symbol: `getByTestId("huddle-attachment")` +- Verify: `CI=1 pnpm --dir desktop test:e2e:smoke message-copy-link.spec.ts` + +## `desktop/tests/e2e/reaction-order.spec.ts` + +Preserve chronological reaction ordering, exact six-pixel body adjacency for ordinary and metadata-free agent messages, and absence of removed per-message session controls. + +New module: `false`. + +- Required symbol: `ordinary non-agent reactions stay adjacent to the message body` +- Required symbol: `getByTestId("manage-current-session")` +- Verify: `CI=1 pnpm --dir desktop test:e2e:smoke reaction-order.spec.ts` + +## `desktop/src/features/messages/ui/MessageActionBar.tsx` + +Bound the reaction picker host to Radix available viewport height while preserving session controls and picker navigation. + +New module: `false`. + +- Required symbol: `[&_em-emoji-picker]:max-h-[var(--radix-popover-content-available-height)]` +- Required symbol: `[&_em-emoji-picker]:min-h-0` +- Required symbol: `collisionPadding={8}` +- Invocation: `desktop/src/features/messages/ui/MessageActionBar.tsx` → `EmojiPicker`; exact call `` +- Verify: `cargo test -p buzz-cli` + +## `crates/buzz-cli/src/attachment_tests.rs` + +Exercise document, archive and Markdown uploads, primary and legacy metadata, message links, media compatibility, and executable and size rejection. + +New module: `true`. + +- Required symbol: `uploads_documents_archives_and_markdown_with_original_names` +- Required symbol: `messages_emit_file_links_for_multiple_attachments_and_keep_media_markup` +- Required symbol: `unicode_filenames_fit_relay_byte_limit_and_keep_extensions` +- Verify: `cargo test -p buzz-cli` + +## `desktop/tests/e2e/file-attachment.spec.ts` + +Verify Markdown and ZIP picker uploads, file-card labels and sizes, and native download filenames alongside existing attachment flows. + +New module: `false`. + +- Verify: `cd desktop && pnpm test:e2e:smoke -- file-attachment.spec.ts` + +## `desktop/src-tauri/src/commands/media_download.rs` + +Fetch generic attachments through a bounded 100 MiB native path while retaining 50 MiB image/editor/clipboard caps and existing auth, redirect, and cancellation protections. + +New module: `false`. + +- Required symbol: `MAX_FILE_DOWNLOAD_BYTES` +- Required symbol: `mod file_download_tests;` +- Invocation: `desktop/src-tauri/src/commands/media_download.rs` → `fetch_blob_bytes_with_cap`; exact call `fetch_blob_bytes_with_cap(url, state, MAX_FILE_DOWNLOAD_BYTES, None).await`; behavior test `generic_download_above_fifty_mib_is_byte_exact`; verify `cargo test --manifest-path desktop/src-tauri/Cargo.toml --lib commands::media_download::file_download_tests::generic_download_above_fifty_mib_is_byte_exact -- --exact` +- Verify: `cargo test --manifest-path desktop/src-tauri/Cargo.toml --workspace` + +## `desktop/src-tauri/src/commands/media_download_file_tests.rs` + +Exercise the native HTTP file fetch above 50 MiB, the 100 MiB boundary, declared and streamed oversize rejection, preserved image caps, and an opt-in real-relay hash check. + +New module: `true`. + +- Required symbol: `generic_download_above_fifty_mib_is_byte_exact` +- Required symbol: `generic_download_rejects_over_hundred_mib_with_or_without_length` +- Required symbol: `native_download_from_relay_matches_expected_sha256` +- Verify: `cargo test --manifest-path desktop/src-tauri/Cargo.toml --workspace` + +## `desktop/src-tauri/src/commands/media_filename.rs` + +Bound sanitized filenames by UTF-8 bytes at character boundaries while preserving the extension for imeta ingest. + +New module: `false`. + +- Required symbol: `unicode_labels_fit_relay_utf8_byte_limit_without_losing_extensions` +- Verify: `cargo test --manifest-path desktop/src-tauri/Cargo.toml --workspace` + +## `crates/buzz-mesh-smoke/Cargo.toml` + +Use the same immutable patched MeshLLM source as the desktop consumer to avoid duplicate runtime types. + +New module: `false`. + +- Verify: `cargo test -p buzz-mesh-smoke` + +## `desktop/src-tauri/src/commands/agent_discovery/managed_node.rs` + +Preserve confined Node.js extraction while propagating ZIP 9 entry-name decoding errors. + +New module: `false`. + +- Verify: `just desktop-tauri-test desktop-tauri-check` + +## `desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs` + +Exercise the production version probe with immutable scripts and explicit interpreter PATH inputs. + +New module: `false`. + +- Required symbol: `probe_codex_acp_version_uses_augmented_path_for_env_shebang_interpreter` +- Verify: `just desktop-tauri-test desktop-tauri-check` + +## `desktop/src-tauri/src/managed_agents/discovery/tests/codex_version_fixture.rs` + +Provide immutable executable symlinks, mutable version data and cleanup for the deliberately surviving descendant. New module: `true`. @@ -6267,3 +7809,293 @@ Immutable executable fixture for production version-probe regression tests. New module: `true`. - Verify: `just desktop-tauri-test desktop-tauri-check` + +## `crates/buzz-acp/src/acp/claude_context.rs` + +Use provider-confirmed Claude context capacity with same-session generation fencing; suppress provisional denominators, retain confirmed capacity across ordinary prompts, and consume filtered SDK results before diagnostic publication. + +New module: `true`. + +- Required symbol: `fn authoritative_window(result: &Value) -> Option` +- Required symbol: `state.confirmed_window` +- Required symbol: `state.generation != binding.generation_id()` +- Required symbol: `models.len() > 64` +- Invocation: `crates/buzz-acp/src/acp.rs` → `consume_claude_context_frame`; exact call `if self.consume_claude_context_frame(&msg) {`; behavior test `claude_context_uses_provider_window_and_the_final_context_numerator`; verify `cargo test --locked -p buzz-acp -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/acp.rs` → `begin_claude_context_turn`; exact call `self.begin_claude_context_turn(session_id);`; behavior test `claude_confirmed_capacity_survives_an_ordinary_prompt_for_tool_published_answers`; verify `cargo test --locked -p buzz-acp -- --test-threads=1` +- Verify: `cargo test --locked -p buzz-acp -- --test-threads=1` + +## `desktop/tests/e2e/thread-head-stale-edit.spec.ts` + +Exercise the edited thread head only after making its hover-only Reply action actionable; retain gated backfill and edited-content regression assertions. + +New module: `false`. + +- Required symbol: `await timelineRow.hover();` +- Required symbol: `await replyButton.click();` +- Required symbol: `deferThreadReplies: true` +- Verify: `pnpm --dir desktop exec playwright test --project=smoke tests/e2e/thread-head-stale-edit.spec.ts` + +## `desktop/src/features/messages/ui/ThreadContextControls.tsx` + +Thread-scoped accessible agent roster from bounded canonical raw history, verified directory and current authority; agent-first picker, scope reset, independent context toolbar trigger and selected action lifetime. Passively subscribe to the active owning validated thread query without replacing query/retry options; ownerless read-only forum previews retain an isolated bounded fallback, while other absent/disabled owners are explicitly unavailable. Resolve eligible-agent profile avatars with decorative shared squircle rendering and initials fallback, preserving accessible collision labels. + +New module: `true`. + +- Required symbol: `export function ThreadContextControls(` +- Required symbol: `key={JSON.stringify([relayUrl, signerPubkey, channel.id, rootId])}` +- Required symbol: `aria-label="Manage context"` +- Required symbol: `return query?.isActive() ? query.state : undefined;` +- Invocation: `desktop/src/features/messages/ui/MessageThreadPanel.tsx` → `ThreadContextControls`; exact call ``; behavior test `known_agent_without_telemetry_still_has_current_scope_controls`; verify `CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts` +- Invocation: `desktop/src/features/messages/ui/ThreadContextControls.tsx` → `useSyncExternalStore`; exact call `const ownedReplies = React.useSyncExternalStore(`; behavior test `opening the picker preserves the owning notification query validation across gated retries (stream)`; verify `cd desktop && node --import ./test-jsdom-setup.mjs --import ./test-loader.mjs --experimental-strip-types --test-force-exit --test src/features/messages/ui/ThreadContextControls.jsdom-test.mjs` +- Invocation: `desktop/src/features/messages/ui/ThreadContextControls.tsx` → `projectThreadContextAgents`; exact call `projectThreadContextAgents({`; behavior test `same-name agents remain distinct and a relay switch closes selected actions`; verify `cd desktop && node --import ./test-jsdom-setup.mjs --import ./test-loader.mjs --experimental-strip-types --test-force-exit --test src/features/messages/ui/ThreadContextControls.jsdom-test.mjs` +- Invocation: `desktop/src/features/messages/ui/ThreadContextControls.tsx` → `loadThreadReplies`; exact call `queryFn: () => loadThreadReplies(queryClient, channel.id, rootId)`; behavior test `read-only forum previews retain a bounded roster without replacing the disabled owner`; verify `cd desktop && node --import ./test-jsdom-setup.mjs --import ./test-loader.mjs --experimental-strip-types --test-force-exit --test src/features/messages/ui/ThreadContextControls.jsdom-test.mjs` +- Invocation: `desktop/src/features/messages/ui/ThreadContextControls.tsx` → `useUsersBatchQuery`; exact call `const profiles = useUsersBatchQuery(`; behavior test `production roster resolves profile images and falls back without duplicating accessible labels`; verify `cd desktop && node --import ./test-jsdom-setup.mjs --import ./test-loader.mjs --experimental-strip-types --test-force-exit --test src/features/messages/ui/ThreadContextControls.jsdom-test.mjs` +- Invocation: `desktop/src/features/messages/ui/ThreadContextControls.tsx` → `UserAvatar`; exact call ``; behavior test `known_agent_without_telemetry_still_has_current_scope_controls`; verify `CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts` +- Verify: `CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts` + +## `crates/buzz-acp/src/acp/restart_status.rs` + +Bounded exact-session text capture for a status-only ACP turn, excluding thoughts, tools and overflow. + +New module: `true`. + +- Required symbol: `fn begin_status_capture` +- Required symbol: `fn take_status_text` +- Required symbol: `fn capture_status_update` +- Invocation: `crates/buzz-acp/src/acp.rs` → `capture_status_update`; exact call `self.capture_status_update(msg);`; behavior test `overflow_empty_thought_and_rate_limited_output_never_publish_partial_answers`; verify `cargo test -p buzz-acp --lib overflow_empty_thought_and_rate_limited_output_never_publish_partial_answers -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/status_tasks.rs` + +Typed finite-priority restart-status scheduling without human queue mutation or unrelated-scope blocking. + +New module: `true`. + +- Required symbol: `fn dispatch(` +- Required symbol: `fn finish(` +- Invocation: `crates/buzz-acp/src/lib.rs` → `status_tasks::dispatch`; exact call `status_tasks::dispatch(&mut pool, &ctx, &status_tx);`; behavior test `checked_restart_depths_publish_one_signed_status_and_resume_without_bootstrap`; verify `cargo test -p buzz-acp --lib checked_restart_depths_publish_one_signed_status_and_resume_without_bootstrap -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/pool/restart_status.rs` + +First replacement-session status turn with selected context, exact-authority signed publication and separately prepared failure feedback. Attach shared live generation/history/actor/occupancy tags before signing the first successful reply, retaining exact Prepared bytes across retries and leaving failure notices untagged. + +New module: `true`. + +- Required symbol: `fn run_status_task` +- Required symbol: `fn publish_prepared` +- Required symbol: `fn report_expired` +- Required symbol: `.tags(status_tags)` +- Invocation: `crates/buzz-acp/src/pool/restart_status.rs` → `tags`; exact call `.tags(status_tags)`; behavior test `first_signed_status_carries_selected_history_actor_and_current_or_unknown_occupancy`; verify `cargo test -p buzz-acp --lib first_signed_status_carries_selected_history_actor_and_current_or_unknown_occupancy -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/status_tasks.rs` → `run_status_task`; exact call `run_status_task(agent, job, ctx, task_dispatch, control_rx).await`; behavior test `checked_restart_depths_publish_one_signed_status_and_resume_without_bootstrap`; verify `cargo test -p buzz-acp --lib checked_restart_depths_publish_one_signed_status_and_resume_without_bootstrap -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/status_tasks.rs` → `report_expired`; exact call `pool::restart_status::report_expired(job, ctx, dispatch)`; behavior test `expiry_scheduler_keeps_its_fence_when_cancellation_persistence_recovers`; verify `cargo test -p buzz-acp --lib expiry_scheduler_keeps_its_fence_when_cancellation_persistence_recovers -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `report_expired`; exact call `pool::restart_status::report_expired(job, ctx, dispatch)`; behavior test `a_known_local_panic_reports_failure_without_restarting_the_model`; verify `cargo test -p buzz-acp --lib a_known_local_panic_reports_failure_without_restarting_the_model -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/pool/status_language.rs` + +Bounded signed conversation-language inference under owner/requester trust policy; numbered prose retained, substantive unsupported prose replaces old certainty, verified agents excluded. + +New module: `true`. + +- Required symbol: `struct Evidence` +- Required symbol: `fn trusted_authors` +- Required symbol: `fn infer(` +- Invocation: `crates/buzz-acp/src/pool/session_history.rs` → `language.observe`; exact call `language.observe(event, humans, channel, target);`; behavior test `numbered_and_unsupported_latest_prose_replace_old_english_at_every_depth`; verify `cargo test -p buzz-acp --lib numbered_and_unsupported_latest_prose_replace_old_english_at_every_depth -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/session_controls/status.rs` + +Exact accepted-token local ownership and immediate cancellation fencing independent of store readability, fresh admission checks, observer cancellation targets and finite status priority. + +New module: `true`. + +- Required symbol: `struct StatusJob` +- Required symbol: `fn status_current` +- Required symbol: `fn cancel_status_for_token` +- Required symbol: `enum StatusCancellation` +- Required symbol: `fn cancellable_status_scopes` +- Invocation: `crates/buzz-acp/src/session_controls/retirement.rs` → `status_ready`; exact call `Self::status_ready(state, store, scope);`; behavior test `checked_restart_depths_publish_one_signed_status_and_resume_without_bootstrap`; verify `cargo test -p buzz-acp --lib checked_restart_depths_publish_one_signed_status_and_resume_without_bootstrap -- --test-threads=1` +- Invocation: `crates/buzz-acp/src/lib.rs` → `cancellable_status_scopes`; exact call `.cancellable_status_scopes()`; behavior test `unavailable_pending_record_cancel_retires_local_fence_before_a_worker_is_free`; verify `cargo test -p buzz-acp --lib unavailable_pending_record_cancel_retires_local_fence_before_a_worker_is_free -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/session_store_status.rs` + +Bounded backward-compatible restart-status metadata, immutable prepared events and no-effect dead-owner reconciliation. + +New module: `true`. + +- Required symbol: `struct RestartStatus` +- Required symbol: `fn status_transition` +- Required symbol: `fn recover_status` +- Invocation: `crates/buzz-acp/src/session_store_orphans.rs` → `recover_status`; exact call `match self.recover_status(saved) {`; behavior test `reconstruction_claims_only_dead_owners_and_never_replays_started_model_attempts`; verify `cargo test -p buzz-acp --lib reconstruction_claims_only_dead_owners_and_never_replays_started_model_attempts -- --test-threads=1` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/pool/restart_status_runtime_tests.rs` + +Production-bound authorized Restart, scheduler, fake ACP, signed HTTP, language, failure, cancellation and reconstruction regressions. + +New module: `true`. + +- Required symbol: `fn checked_restart_depths_publish_one_signed_status_and_resume_without_bootstrap` +- Required symbol: `fn reconstruction_claims_only_dead_owners_and_never_replays_started_model_attempts` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/pool/restart_status_edge_tests.rs` + +Production-bound overflow, rate-limit, reply routing, deadline, persistence failure and verified-agent language regressions. + +New module: `true`. + +- Required symbol: `fn overflow_empty_thought_and_rate_limited_output_never_publish_partial_answers` +- Required symbol: `fn acknowledgment_without_a_completion_write_retains_prepared_bytes_and_token` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/SESSION_CONTROLS.md` + +Documented status-only Restart behavior, signed delivery, cancellation and crash uncertainty, language trust policy and provider limits. + +New module: `true`. + +- Required symbol: `# Restart status turns` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/session_model_thread.md` + +Restart-status runtime guidance and acceptance contract. + +New module: `false`. + +- Required symbol: `` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/session_model_channel.md` + +Restart-status runtime guidance and acceptance contract. + +New module: `false`. + +- Required symbol: `` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/pool/restart_status_review_tests.rs` + +Production-path review regressions for numbered/unsupported language across history depths and true nonowner admission, pre-first-poll status/expiry fences, uncertain owner cancellation and durable failure delivery. + +New module: `true`. + +- Required symbol: `fn numbered_and_unsupported_latest_prose_replace_old_english_at_every_depth` +- Required symbol: `fn scheduler_keeps_its_original_fence_before_the_first_status_poll` +- Required symbol: `fn expiry_scheduler_keeps_its_fence_when_cancellation_persistence_recovers` +- Required symbol: `fn uncertain_owner_cancel_has_no_chat_sender_and_fences_held_feedback` +- Required symbol: `fn owned_failure_feedback_requires_persistence_and_retries_only_its_saved_bytes` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/pool/restart_status_unavailable_tests.rs` + +Actual checked-retirement and scheduler regressions for owner/observer cancellation with missing/unreadable records, WaitingRetirement write failure, held expiry feedback, queued human preservation, stale-token protection and confirmed terminal status. + +New module: `true`. + +- Required symbol: `fn unavailable_pending_record_cancel_retires_local_fence_before_a_worker_is_free` +- Required symbol: `fn unavailable_cancel_during_retirement_cannot_recreate_the_followup_after_clean_exit` +- Required symbol: `fn unavailable_record_cancel_fences_already_scheduled_workerless_expiry_feedback` +- Required symbol: `fn stale_token_cleanup_cannot_fence_newer_pending_or_retiring_work_when_reads_fail` +- Required symbol: `fn cancellation_preserves_a_confirmed_terminal_status_even_when_the_busy_cache_lags` +- Verify: `cargo test -p buzz-acp -- --test-threads=1` + +## `crates/buzz-acp/src/pool/restart_status_metadata_tests.rs` + +Actual authorized Restart admission, scheduler, ACP usage notifications and signed HTTP publication regressions for first-reply history/actor/current-generation tags, current or unavailable occupancy and exact prepared event persistence across all selected depths. + +New module: `true`. + +- Required symbol: `fn first_signed_status_carries_selected_history_actor_and_current_or_unknown_occupancy` +- Verify: `cargo test -p buzz-acp --lib first_signed_status_carries_selected_history_actor_and_current_or_unknown_occupancy -- --test-threads=1` diff --git a/TESTING.md b/TESTING.md index e70f5ac66b2..cec67e73375 100644 --- a/TESTING.md +++ b/TESTING.md @@ -389,3 +389,52 @@ CLI-side, only two matter for testing: | ACP logs `discovered 0 channel(s)` / `no channel subscriptions resolved` | Agent identity isn't a member of any channel | `buzz channels add-member --channel "$CHANNEL" --pubkey "$AGENT_PUBKEY" --role member` from another identity | | `GOOSE_MODE` warning, agent hangs | Not set | `export GOOSE_MODE=auto` | | Tests pass locally but CI fails | Forgot to run `just ci` | `just ci` runs the gate (fmt, clippy, unit tests, desktop/web builds) | + +### Desktop scoped session controls + +Native Desktop QA must run in a verified private PID namespace, or behind an +explicit process-reaper fence reviewed before launch. A demo app identifier, +separate XDG directories and a private DBus session do not isolate process +reaping: startup and periodic cleanup can inspect and stop agents from other +Desktop instances. Do not mount the host `/proc` into a native QA container; +verify its visible process set and mount list before exercising the app. + +The registered mock-bridge smoke workflow drives the thread composer's +Manage context → agent list → selected agent actions flow, +verified directory refresh, native send boundary, canonical thread routing and +compact request row. It also checks keyboard submenu/Escape focus return, +permission revocation and draft preservation. Run it with a fresh E2E build and +refuse an existing server: + +```sh +CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts +``` + +For jsdom absence assertions, compare a boolean with a descriptive message +(for example, `assert.ok(!view.queryByRole("menuitem"), "No actions after revocation")`). +Do not pass a live DOM element to `assert.equal(element, null)`: Node's failure +diff can traverse the jsdom/React graph and exhaust memory. Await asynchronous +query-cache notifications before asserting their rendered effect. Run test and +build process trees under an external hard memory limit, zero swap and a runtime +deadline; fail closed if containment cannot be established. A V8 heap flag or +in-process test timeout alone cannot bound native allocation or a stalled event +loop. + +Raw result and actor projection regressions live beside the production formatter +in `desktop/src/features/messages/lib/sessionControl.test.mjs`. The shared +`crates/buzz-core/tests/fixtures/session_controls.json` command corpus runs against +both actual parsers, including the Rust/JavaScript Unicode whitespace boundary. +Mock-bridge coverage does not attest real-provider or native-platform behavior. +The final browser matrix also checks independent target agents, denied access, +twenty-minute-old request expiry and validated manual actor copy. Stored raw +tags distinguish canonical native requests from generic seeded reply helpers. + +The same registered workflow also opens an owned agent's production activity +panel and ingests raw observer frames through `syncAgentObserverEvents`. It +checks in-place typed compaction outcomes, replay, two operations in one turn, +legacy hook deduplication, visible failures and the original raw rail. The +focused `agentSessionCompaction` and `observerCompaction` tests cover exact wire +validation, UTF-8 ID bounds, observed worker/resume and successful new-session ACK boundaries, missing-history +unknown provenance, summary limits, terminal monotonicity and store rebuild/reset. +Observer access remains owner-scoped; permission to send a remote session control +does not authorize reading that agent's observer feed. diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index 244bae144ed..f283b66c12f 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -25,6 +25,8 @@ buzz-persona = { path = "../buzz-persona" } git-credential-nostr = { path = "../git-credential-nostr" } git-sign-nostr = { path = "../git-sign-nostr" } tempfile = "3" +# Advisory locks for the durable session store. +fs2 = "0.4" zeroize = { workspace = true } # Nostr @@ -82,6 +84,10 @@ evalexpr = { workspace = true } [target.'cfg(unix)'.dependencies] nix = { version = "0.31", default-features = false, features = ["signal", "user"] } +[target.'cfg(windows)'.dependencies] +process-wrap = { version = "=10.0.1", default-features = false, features = ["tokio1", "job-object", "creation-flags", "kill-on-drop"] } +win32job = "=2.0.3" + [dev-dependencies] axum = { workspace = true } tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/buzz-acp/SESSION_CONTROLS.md b/crates/buzz-acp/SESSION_CONTROLS.md new file mode 100644 index 00000000000..43781c7c692 --- /dev/null +++ b/crates/buzz-acp/SESSION_CONTROLS.md @@ -0,0 +1,73 @@ +# Restart status turns + +An authorized Restart first durably resets the selected scope and waits for +checked termination of its old workers. Only successful retirement schedules a +short task-status turn. The Restart acknowledgment confirms that reset; it does +not claim that a provider started or that a status reply reached the relay. + +The status is the replacement session's first foreground turn. It uses the +selected None, Recent or All history policy, the normal standing instructions +and the original assignment where available. None does not reconstruct omitted +progress from files, logs or another provider session. The automatic bootstrap +initial message is suppressed. Later human messages keep their original queue +entries and instructions; they cannot steer or merge into this status turn. +Other scopes remain available. Status acquisition and execution have at most +120 seconds, subject to shorter configured task limits. Failure feedback has a +separate five-second grace and does not extend queue priority. + +The provider returns one short text answer, without tools, subagents or task +execution. The controller captures answer chunks for that exact session, signs +one ordinary kind-9 reply as the selected agent, and posts it to the original +canonical thread or DM. It adds no mentions or broadcast tags. The controller +requires an exact relay acknowledgment before consuming the captured restart +intent. Refusal, limits, empty output, partial failed output, observed tools, +startup errors and unconfirmed delivery cannot become successful task status. +They leave an independent scoped failure notice when it can be delivered. +Restart success remains unchanged. + +The turn has a durable Started marker before provider acquisition or model +execution. Captured text is limited to 12 KiB, and each saved signed event to +20 KiB, within the existing 256 KiB record cap. Signed status bytes are saved +before sending; at most three same-owner submissions reuse those exact bytes. +Failure-notice bytes are saved separately and never replace an uncertain actual +reply. New Restart, cancellation, shutdown and channel removal fence late +results. Publication authorization is the last controller check before HTTP +submission: a request already sent to the relay cannot be retracted. + +After the owning controller dies, recovery never starts another model attempt +or automatically resubmits prepared bytes. It preserves those bytes and marks +the follow-up unconfirmed. This also prevents a failed cancellation write plus +a crash from authorizing stale output. A crash before dispatch or acknowledgment +can therefore lose the automatic status; a fresh Restart explicitly requests a +new attempt, and ordinary human input can still continue using the retained +history policy. Cancellation persistence failure is reported as unconfirmed, +while the live controller immediately fences further effects. If cancellation +cannot be saved, the owner/observer control result is unconfirmed and no chat +notice is sent: a failed write cannot establish publication ownership. Status +and failure-feedback jobs retain the exact scope fence captured before +scheduling, including expiry and known-local-panic feedback. A temporarily +missing or unreadable record cannot retain local status authority: cancellation +fences the accepted local token before reading the store. This also covers +cancellation +while retirement is still pending; later checked retirement can confirm the +reset and retain its history policy without recreating the cancelled follow-up. +A previously confirmed terminal status is preserved. + +Language inference deliberately supports Russian, English and unknown. The +bounded selected-history reader keeps only a language code from evidence that +None otherwise omits. It checks signed channel/thread routing and ignores +controls, code, quotes and timestamp/severity log prefixes. Numbered progress +prose remains evidence. Newer substantive unsupported prose replaces an older +recognized hint with unknown; excluded non-prose does not replace it. The +configured owner and the exact authenticated requester are trusted conversation +authors by policy; this does +not prove human identity. Verified signed agent attestations exclude an author. +Absent profiles do not establish human identity. Unsupported or ambiguous +conversation evidence keeps the generic same-conversation-language instruction, +without inventing an English hint. No additional model call or unbounded +history lookup is used. + +ACP permission requests for the status session are denied, and observed tool +calls fail the turn. Some providers can execute native tools before reporting +them, so this is not a universal tool sandbox. Real provider validation must +confirm the adapter's actual behavior. diff --git a/crates/buzz-acp/TESTING.md b/crates/buzz-acp/TESTING.md index 3efe33a66ef..8280db3a5a3 100644 --- a/crates/buzz-acp/TESTING.md +++ b/crates/buzz-acp/TESTING.md @@ -197,3 +197,43 @@ report/event ID, in the original thread, with its callback mention. A second background group must produce a second report/key. Explicit cancel/rotate and membership removal still invalidate collection according to the lifecycle runbook above. Agent fixtures cannot mark this human check complete. + +## Restart status + +The `restart_status_tests` filter covers authorized admission, checked retirement, +the typed scheduler, fake ACP processes, selected history and a signature-checking +HTTP relay. It covers context depths, conversation-language hints, reply routing, +queued input, duplicates, bounded failures, cancellation and crash uncertainty. +See [SESSION_CONTROLS.md](SESSION_CONTROLS.md) for the contract and its limits. +Use the full package suite before accepting the change; a focused filter is an +iteration aid, not a substitute for package validation. + +For each supported real adapter, use a disposable session and the actual build +under review: + +1. Start an English assignment, then discuss progress in Russian. Restart with + None, Recent and All. Each successful reset should produce one useful Russian + status without another mention. With None, omitted progress must be reported + as unknown. Repeat an English conversation and expect English. +2. Repeat while the old worker is busy. Wait for checked reset completion; no + status may run before that boundary. Queue a real new instruction during the + status turn: it must run afterward in its original form. A sibling thread + must continue independently. +3. Cancel, remove the agent, or issue a newer Restart during status generation. + Late output must not begin a newly authorized send. A send already submitted + may still become visible; lost acknowledgment remains unconfirmed. Inspect + adapter activity and verify that no task tool or subagent executed. +4. Check that the reply is signed by the selected agent, in the canonical thread + or DM, with no mention/broadcast tags. Repeat the same signed Restart and + confirm it does not create another model turn or message. +5. Send an ordinary follow-up after a confirmed status. It should reuse the new + session while ordinary provider-resume and rotation limits still apply. + Simulate provider failure and loss of acknowledgment separately: the reset + acknowledgment stays successful, and failure/uncertainty is reported honestly. +6. Restart the disposable harness with a pending or prepared follow-up. Recovery + must preserve uncertainty and prepared bytes without invoking the model or + automatically posting that old reply. Later ordinary input remains available. + +Record build identity, adapter, selected depth, actual language, signed reply +provenance and observed tool activity. Browser fixtures alone cannot certify +provider behavior. Human acceptance remains separate from agent validation. diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index d6969408738..e6f2be4f4b1 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -5,16 +5,20 @@ //! 1. [`AcpClient::spawn`] — launch agent binary as subprocess //! 2. [`AcpClient::initialize`] — protocol version negotiation //! 3. [`AcpClient::session_new`] — create session with MCP server config -//! 4. [`AcpClient::session_prompt_with_idle_timeout`] — send prompt with idle/hard deadline, return stop reason -//! 5. [`AcpClient::session_cancel`] / [`AcpClient::cancel_with_cleanup`] — cancel in-flight turn +mod claude_context; mod launch; +mod restart_status; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use futures_util::StreamExt; -use tokio::process::{Child, ChildStdin, ChildStdout}; +#[cfg(not(windows))] +use tokio::process::Child; +use tokio::process::{ChildStdin, ChildStdout}; use tokio_util::codec::{FramedRead, LinesCodec, LinesCodecError}; +#[cfg(windows)] +use windows_job::ContainedChild as Child; use crate::observer::{ObserverContext, ObserverHandle}; use crate::usage::{ @@ -97,6 +101,10 @@ pub enum AcpError { #[error("Agent process exited unexpectedly")] AgentExited, + /// Internal generation cancellation; it says nothing about transport health. + #[error("Dispatch retired by session control")] + DispatchRetired, + #[error("Idle timeout — no agent activity for {0:?}")] IdleTimeout(std::time::Duration), @@ -148,6 +156,14 @@ fn build_initialize_params() -> serde_json::Value { /// One `AcpClient` per agent process. Multiple sessions can be created on the /// same client via repeated calls to [`session_new`](AcpClient::session_new). pub struct AcpClient { + status_capture: Option, + termination: terminate::TerminationState, + #[cfg(test)] + pub(crate) background_child_snapshots: std::collections::VecDeque>>, + #[cfg(test)] + pub(crate) process_tree_snapshots: + std::collections::VecDeque>, + compactions: compaction::CompactionTracker, /// The agent child process (kept alive to prevent zombie). child: Child, /// Sole stdin writer; None after an interrupted/failed frame closes it. @@ -209,6 +225,9 @@ pub struct AcpClient { /// a JSON-RPC *success*, not `-32601` — which the main loop would read as /// a delivered steer and drop the user's message from the queue. steering_supported: bool, + /// Whether `initialize` advertised `sessionCapabilities.resume`. Set once + /// by [`initialize`](Self::initialize); gates `session/resume`. + resume_supported: bool, /// Per-turn channel for receiving goose-native non-cancelling steer /// requests from the main loop. Installed by /// [`install_steer_rx`](Self::install_steer_rx) at dispatch and @@ -221,6 +240,8 @@ pub struct AcpClient { goose_usage: UsageTracker, /// Per-turn prompt-response usage and Claude's optional cumulative cost. standard_usage: StandardUsageTracker, + /// Claude capacity evidence is local to a live status generation. + claude_context: HashMap, /// Known adapter identity for prompt-response usage mapping. standard_adapter: Option, /// Ask Claude Code for summarized thinking on `session/new`. Opus omits @@ -232,6 +253,10 @@ pub struct AcpClient { /// Seeded at `initialize`, extended by newly created `session/new` /// processes and MCP helpers. Their own turn-work children still block sleep. resident_pids: HashSet, + /// Status snapshots of this worker's provider sessions, keyed by the + /// provider session ID. Usage notifications select an entry by their own + /// `params.sessionId`, never by the current turn. + session_status: HashMap, } /// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape @@ -416,6 +441,7 @@ fn build_client_capabilities() -> serde_json::Value { "auth": { "terminal": true }, + "session": {"compaction": {}}, // Signal to goose that we handle `_goose/unstable/session/update` // notifications. Without this the custom notification is suppressed // on goose's side and usage data is never emitted. @@ -431,10 +457,17 @@ fn build_client_capabilities() -> serde_json::Value { }) } +#[path = "acp/compaction.rs"] +mod compaction; + +#[cfg(all(test, unix))] +#[path = "acp/compaction_tests.rs"] +mod compaction_tests; + impl AcpClient { /// Kill the agent subprocess and wait for it to exit (no zombies). /// - /// `Drop` only calls `start_kill()` (sends SIGKILL but doesn't reap). + /// `Drop` cannot await guaranteed reaping. /// Call this when you need guaranteed cleanup — e.g., in `run_models` /// before process exit. pub async fn shutdown(&mut self) { @@ -443,8 +476,8 @@ impl AcpClient { // ensures subprocesses (MCP servers, tool processes) are cleaned up // rather than orphaned to init. // - // Falls back to start_kill() (direct child only) on non-Unix or if - // the child has been polled to completion (id() returns None). + // Falls back to platform child cleanup (the whole job on Windows) + // on non-Unix or when id() returns None after the parent exits. match self.child.id() { Some(pid) if kill_process_group(pid) => {} _ => { @@ -506,9 +539,9 @@ impl AcpClient { .stdout(Stdio::piped()) // Inherit stderr so agent logs are visible in the harness terminal. .stderr(Stdio::inherit()) - // Ensure the child is killed when the AcpClient is dropped (best-effort). - // Callers MUST still call shutdown().await for guaranteed cleanup. - .kill_on_drop(true); + // AcpClient owns cleanup. Tokio must not independently signal a + // checked retirement whose initial snapshot could not be verified. + .kill_on_drop(false); // Per-persona env vars (e.g., GOOSE_PROVIDER, BUZZ_AGENT_PROVIDER). // For most keys, operator precedence wins: skip injection if already set @@ -570,6 +603,7 @@ impl AcpClient { // over the harness-owned agent identity in native shells. cmd.env_remove("GIT_CONFIG_PARAMETERS"); cmd.env_remove("NOSTR_PRIVATE_KEY"); + crate::session_status::apply_status_dir_env(&mut cmd, extra_env); if extra_env.iter().any(|(name, _)| name == "GIT_CONFIG_COUNT") { // Native shells inherit these overrides, while buzz-agent clears // them for MCP. Let both paths use the harness's agent identity. @@ -583,14 +617,11 @@ impl AcpClient { } } - // Spawn the agent in its own process group so SIGKILL doesn't propagate - // to the harness's own process group on Unix. - // tokio::process::Command::process_group is a stable tokio API (no extra imports needed). + // Unix isolates signals; Windows assigns containment before resuming. #[cfg(unix)] cmd.process_group(0); - // Suppress the console window that Windows otherwise allocates for every - // console-subsystem child process spawned from a GUI/non-console parent. + // Preserve the hidden-window policy on Windows. configure_no_window(&mut cmd); let standard_adapter = @@ -606,10 +637,15 @@ impl AcpClient { let claude_thinking_summaries = standard_adapter == Some(StandardAdapterKind::Claude) && !launch_prefixed && claude_cli_accepts_thinking_display(&cmd).await; - let mut child = cmd.spawn().map_err(|error| { + let program = cmd.as_std().get_program().to_owned(); + #[cfg(windows)] + let spawned = windows_job::spawn(cmd); + #[cfg(not(windows))] + let spawned = cmd.spawn(); + let mut child = spawned.map_err(|error| { std::io::Error::new( error.kind(), - format!("failed to spawn {:?}: {error}", cmd.as_std().get_program()), + format!("failed to spawn {program:?}: {error}"), ) })?; @@ -623,6 +659,11 @@ impl AcpClient { .ok_or_else(|| AcpError::Protocol("failed to open agent stdout".into()))?; Ok(Self { + termination: terminate::TerminationState::default(), + #[cfg(test)] + background_child_snapshots: std::collections::VecDeque::new(), + #[cfg(test)] + process_tree_snapshots: std::collections::VecDeque::new(), child, stdin: Some(stdin), reader: FramedRead::new(stdout, LinesCodec::new_with_max_length(MAX_LINE_SIZE)), @@ -636,12 +677,17 @@ impl AcpClient { observer_context: ObserverContext::default(), active_run_id: None, steering_supported: false, + resume_supported: false, steer_rx: None, goose_usage: UsageTracker::default(), standard_usage: StandardUsageTracker::default(), + claude_context: HashMap::new(), standard_adapter, claude_thinking_summaries, resident_pids: HashSet::new(), + session_status: HashMap::new(), + status_capture: None, + compactions: compaction::CompactionTracker::default(), }) } @@ -650,28 +696,43 @@ impl AcpClient { self.child.id() } + fn process_tree_snapshot(&mut self) -> Option { + #[cfg(test)] + if let Some(snapshot) = self.process_tree_snapshots.pop_front() { + return snapshot; + } + crate::process_tree::snapshot() + } + /// Remember processes created before the first turn, including providers' /// own startup children. Call only during the initial protocol handshake. pub fn capture_startup_children(&mut self) { - if let (Some(pid), Some(tree)) = (self.process_id(), crate::process_tree::snapshot()) { - self.resident_pids.extend(tree.descendant_pids(pid)); + if let (Some(pid), Some(tree)) = (self.process_id(), self.process_tree_snapshot()) { + self.resident_pids.extend(tree.startup_resident_pids(pid)); } } /// Background work under the worker or any resident provider/helper. /// - /// `None` means enumeration is uncertain and the worker must remain awake. + /// Nonempty results retain definite work even when another candidate is uncertain. + /// `None` means no definite work was found but discovery remains uncertain; + /// only a known empty result permits releasing an uncertainty-only hold. /// Each poll traverses current residents so newly created work is visible. pub fn background_child_pids(&mut self) -> Option> { + #[cfg(test)] + if let Some(snapshot) = self.background_child_snapshots.pop_front() { + return snapshot; + } let Some(pid) = self.process_id() else { return Some(HashSet::new()); }; - let tree = crate::process_tree::snapshot()?; + let tree = self.process_tree_snapshot()?; let live = tree.descendant_pids(pid); self.resident_pids.retain(|pid| live.contains(pid)); let mut pending = vec![pid]; let mut visited = HashSet::new(); let mut blocking = HashSet::new(); + let mut uncertain = false; while let Some(parent) = pending.pop() { if !visited.insert(parent) { continue; @@ -679,13 +740,24 @@ impl AcpClient { let children = tree.children(parent); blocking.extend(tree.blocking_pids(parent, &self.resident_pids)); for child in children { + if tree.is_uncertain_snapshot(child.pid) { + uncertain = true; + pending.push(child.pid); + continue; + } if self.resident_pids.contains(&child.pid) || tree.is_resident_helper(child) { - self.resident_pids.insert(child.pid); + if !tree.is_snapshot_helper(child.pid) { + self.resident_pids.insert(child.pid); + } pending.push(child.pid); } } } - Some(blocking) + if blocking.is_empty() && uncertain { + None + } else { + Some(blocking) + } } /// Adopt processes born during successful session setup, traversing only @@ -695,7 +767,7 @@ impl AcpClient { let Some(before) = before else { return; // Unknown baseline must never authorize adopting turn work. }; - let (Some(pid), Some(tree)) = (self.process_id(), crate::process_tree::snapshot()) else { + let (Some(pid), Some(tree)) = (self.process_id(), self.process_tree_snapshot()) else { return; }; let mut pending = vec![pid]; @@ -705,6 +777,9 @@ impl AcpClient { continue; } for child in tree.children(parent) { + if tree.is_snapshot_candidate(child.pid) { + continue; + } if self.resident_pids.contains(&child.pid) || !before.contains(&child.pid) { self.resident_pids.insert(child.pid); pending.push(child.pid); @@ -764,6 +839,9 @@ impl AcpClient { .pointer("/_meta/steering/supported") .and_then(|v| v.as_bool()) .unwrap_or(false); + self.resume_supported = result + .pointer("/agentCapabilities/sessionCapabilities/resume") + .is_some_and(serde_json::Value::is_object); self.capture_startup_children(); tracing::debug!(target: "acp::init", "initialize response: {result}"); Ok(result) @@ -806,6 +884,41 @@ impl AcpClient { system_prompt: Option>, session_title: Option<&str>, ) -> Result { + let params = self.build_session_params(cwd, mcp_servers, system_prompt, session_title); + let before = self.process_id().and_then(|pid| { + self.process_tree_snapshot() + .map(|tree| tree.descendant_pids(pid)) + }); + let generation = self.compactions.prepare_new(); + let response = self.send_request("session/new", params).await; + let returned_session = response + .as_ref() + .ok() + .and_then(|result| result["sessionId"].as_str()); + self.compactions.complete_new(generation, returned_session); + let result = response?; + let session_id = result["sessionId"] + .as_str() + .ok_or_else(|| AcpError::Protocol("session/new response missing sessionId".into()))? + .to_owned(); + self.capture_session_children(before); + tracing::info!(target: "acp::session", "session created: {session_id}"); + Ok(SessionNewResponse { + session_id, + raw: result, + }) + } + + /// Parameters shared by `session/new` and `session/resume`: working + /// directory, MCP servers, and the `_meta` members (system prompt, title, + /// Claude CLI options), merged into one object. + fn build_session_params( + &self, + cwd: &str, + mcp_servers: Vec, + system_prompt: Option>, + session_title: Option<&str>, + ) -> serde_json::Value { let mut params = serde_json::json!({ "cwd": cwd, "mcpServers": mcp_servers, @@ -827,23 +940,44 @@ impl AcpClient { // Merge — _meta may already carry a system prompt from an adapter extension. params["_meta"]["sessionTitle"] = serde_json::Value::String(title.to_owned()); } + if self.standard_adapter == Some(StandardAdapterKind::Claude) { + params["_meta"]["claudeCode"]["emitRawSDKMessages"] = + serde_json::json!([{ "type": "result" }]); + } if self.claude_thinking_summaries { // Merge — claude-agent-acp spreads these into the CLI's argv. params["_meta"]["claudeCode"]["options"]["extraArgs"]["thinking-display"] = serde_json::Value::String("summarized".to_owned()); } + params + } + + /// Send `session/resume` for a session this agent created earlier, with + /// the same parameters `session/new` would send. Call only when + /// [`resume_supported`](Self::resume_supported) is true. Any error means + /// the session cannot be continued; see [`resume_failure_code`]. + pub async fn session_resume( + &mut self, + session_id: &str, + cwd: &str, + mcp_servers: Vec, + system_prompt: Option>, + session_title: Option<&str>, + ) -> Result { + let mut params = self.build_session_params(cwd, mcp_servers, system_prompt, session_title); + params["sessionId"] = serde_json::Value::String(session_id.to_owned()); let before = self .process_id() .and_then(|pid| crate::process_tree::snapshot().map(|tree| tree.descendant_pids(pid))); - let result = self.send_request("session/new", params).await?; - let session_id = result["sessionId"] - .as_str() - .ok_or_else(|| AcpError::Protocol("session/new response missing sessionId".into()))? - .to_owned(); + let generation = self.compactions.prepare_resume(session_id); + let response = self.send_request("session/resume", params).await; + self.compactions + .complete_resume(session_id, generation, response.is_ok()); + let result = response?; self.capture_session_children(before); - tracing::info!(target: "acp::session", "session created: {session_id}"); + tracing::info!(target: "acp::session", "session resumed: {session_id}"); Ok(SessionNewResponse { - session_id, + session_id: session_id.to_owned(), raw: result, }) } @@ -944,6 +1078,7 @@ impl AcpClient { idle_timeout: std::time::Duration, max_duration: std::time::Duration, ) -> Result { + self.begin_claude_context_turn(session_id); let params = build_prompt_params(session_id, prompt_blocks); let hard_deadline = tokio::time::Instant::now() + max_duration; self.current_hard_deadline = Some(hard_deadline); @@ -998,7 +1133,11 @@ impl AcpClient { self.current_hard_deadline = None; } } - self.parse_prompt_response(session_id, &result?) + let parsed = result.and_then(|result| self.parse_prompt_response(session_id, &result)); + if !matches!(parsed, Ok(StopReason::EndTurn)) { + self.reset_claude_context(session_id, Some(false)); + } + parsed } /// Send a `session/cancel` **notification** (no `id` field, no response expected). @@ -1010,6 +1149,7 @@ impl AcpClient { /// /// Note: async because writing to stdin requires async I/O. pub async fn session_cancel(&mut self, session_id: &str) -> Result<(), AcpError> { + self.reset_claude_context(session_id, Some(false)); let params = serde_json::json!({ "sessionId": session_id, }); @@ -1044,6 +1184,11 @@ impl AcpClient { self.steering_supported } + /// Whether the agent advertised `sessionCapabilities.resume`. + pub fn resume_supported(&self) -> bool { + self.resume_supported + } + /// Consume per-turn usage for NIP-AM publishing. Goose/buzz-agent is an /// exclusive cumulative path; standard ACP prompt usage is used only when /// goose emitted nothing for this turn. @@ -1064,6 +1209,85 @@ impl AcpClient { self.standard_usage.seed_zero_baseline(session_id); } + /// Route context-occupancy notifications of `session_id` to `binding`. + pub(crate) fn bind_session_status( + &mut self, + session_id: &str, + binding: crate::session_status::StatusBinding, + ) { + self.session_status.retain(|_, bound| bound.is_live()); + self.claude_context + .retain(|session, _| self.session_status.contains_key(session)); + if self.standard_adapter == Some(StandardAdapterKind::Claude) { + self.claude_context.insert( + session_id.to_owned(), + claude_context::State::new(binding.generation_id()), + ); + } + self.compactions.bind(session_id, binding.generation_id()); + self.session_status.insert(session_id.to_owned(), binding); + } + + /// Stop routing context-occupancy notifications of `session_id`. + pub(crate) fn clear_session_status(&mut self, session_id: &str) { + self.session_status.remove(session_id); + self.claude_context.remove(session_id); + self.compactions.clear(session_id); + } + + /// Whether `session_id` still routes to a live status generation. + pub(crate) fn has_session_status(&self, session_id: &str) -> bool { + self.session_status + .get(session_id) + .is_some_and(crate::session_status::StatusBinding::is_live) + } + + pub(crate) fn retain_compaction_sessions( + &mut self, + sessions: &std::collections::HashSet, + ) { + self.compactions.retain_sessions(sessions); + self.session_status + .retain(|session, binding| sessions.contains(session) && binding.is_live()); + self.claude_context + .retain(|session, _| self.session_status.contains_key(session)); + } + + pub(crate) fn ensure_compaction_route(&mut self, session: &str) { + self.compactions.ensure_route(session); + } + #[cfg(all(test, unix))] + pub(crate) fn advertise_compaction(&mut self, session: &str) { + self.compactions.set_advertised(session); + } + + #[cfg(all(test, unix))] + pub(crate) fn route_compaction(&mut self, session: &str, generation: uuid::Uuid) { + self.compactions.route(session, generation); + } + + pub(crate) fn compact_supported(&self, session: &str) -> bool { + matches!( + self.standard_adapter, + Some(StandardAdapterKind::Claude | StandardAdapterKind::Codex) + ) || self.compactions.supported(session) + } + + pub(crate) fn begin_compaction( + &mut self, + session: &str, + origin: buzz_core::session_control::ControlOrigin, + ) -> Result<(), buzz_core::session_control::ControlFailure> { + self.compactions.begin(session, origin) + } + + pub(crate) fn finish_compaction( + &mut self, + session: &str, + ) -> buzz_core::session_control::ControlOutcome { + self.compactions.finish(session) + } + /// Install a per-turn steer request channel for goose-native /// non-cancelling mid-turn delivery. /// @@ -1375,9 +1599,6 @@ impl AcpClient { continue; } - // Only log and reset idle after we have a valid non-empty line. - tracing::debug!(target: "acp::wire", "← {trimmed}"); - let msg: serde_json::Value = match serde_json::from_str(trimmed) { Ok(v) => v, Err(e) => { @@ -1395,6 +1616,10 @@ impl AcpClient { continue; } }; + if self.consume_claude_context_frame(&msg) { + continue; + } + tracing::debug!(target: "acp::wire", "← {trimmed}"); self.observe("acp_read", msg.clone()); // Check if this is a response to our expected request (has matching id @@ -1699,8 +1924,6 @@ impl AcpClient { continue; } - tracing::debug!(target: "acp::wire", "← {trimmed}"); - let msg: serde_json::Value = match serde_json::from_str(trimmed) { Ok(v) => v, Err(e) => { @@ -1718,6 +1941,10 @@ impl AcpClient { continue; } }; + if self.consume_claude_context_frame(&msg) { + continue; + } + tracing::debug!(target: "acp::wire", "← {trimmed}"); self.observe("acp_read", msg.clone()); let activity_now = Instant::now(); @@ -1862,6 +2089,12 @@ impl AcpClient { last_activity_at = activity_now; tracing::debug!("idle clock reset: tool call started"); } + if self.status_capture_invalid() { + return Err(AcpError::Protocol( + "status turn attempted a tool or exceeded its output limit" + .into(), + )); + } } "_goose/unstable/session/update" => { self.handle_goose_usage_update(&msg); @@ -1907,12 +2140,34 @@ impl AcpClient { /// leave it `None` and are steered via `_session/steering` instead, which /// needs no run id. fn handle_session_update(&mut self, msg: &serde_json::Value) -> bool { + self.capture_status_update(msg); let update = &msg["params"]["update"]; let update_type = update .get("sessionUpdate") .and_then(|v| v.as_str()) .unwrap_or("unknown"); + let session_id = msg["params"]["sessionId"].as_str(); + if matches!(update_type, "config_option_update" | "current_model_update") { + if let Some(session) = session_id { + self.reset_claude_context(session, None); + } + } + if update_type == "compaction_update" { + if let Some(session) = session_id { + if let Some(origin) = self.compactions.update(session, update) { + if let Some(binding) = self.session_status.get(session) { + binding.compacted(origin); + } + } + } + return false; + } + if update_type == "available_commands_update" { + if let Some(session) = session_id { + self.compactions.advertise(session, update); + } + } match update_type { "agent_message_chunk" => { if let Some(text) = update["content"]["text"].as_str() { @@ -2018,6 +2273,13 @@ impl AcpClient { /// Claude. Unlike Goose's payload, `used`/`size` are context occupancy and /// are intentionally not mapped to token accounting. fn handle_standard_usage_update(&mut self, msg: &serde_json::Value) { + // Context occupancy is display status, routed by the notification's + // own session ID before (and independently of) cost accounting. + if self.standard_adapter == Some(StandardAdapterKind::Claude) { + self.record_claude_context_usage(msg); + } else { + crate::session_status::record_usage_update(&self.session_status, msg); + } if self.standard_adapter != Some(StandardAdapterKind::Claude) { return; } @@ -2099,6 +2361,19 @@ impl AcpClient { .cloned() .ok_or_else(|| AcpError::Protocol("permission request missing id".into()))?; + if self.status_capture.as_ref().is_some_and(|capture| { + msg["params"]["sessionId"].as_str() == Some(capture.session.as_str()) + }) { + if let Some(capture) = &mut self.status_capture { + capture.invalid = true; + } + let response = permission_response_cancelled(&id); + self.write_ndjson(&response).await?; + return Err(AcpError::Protocol( + "tools are forbidden during restart status".into(), + )); + } + // Store pending permission id so cancel_with_cleanup can respond to it. self.pending_permission_id = Some(id.clone()); // Mark as not yet responded — guards against double-response race. @@ -2455,10 +2730,12 @@ pub fn model_in_catalog( }) } -// ─── Drop: kill child process ───────────────────────────────────────────────── - impl Drop for AcpClient { fn drop(&mut self) { + if self.termination.checked { + let _ = self.child.try_wait(); + return; + } // Best-effort SIGKILL + reap. We cannot `await` in Drop (sync context). // Kill the process group when possible so subprocesses don't leak. // Callers SHOULD still call `shutdown().await` for guaranteed reaping. @@ -2604,6 +2881,19 @@ fn configure_no_window(cmd: &mut tokio::process::Command) { let _ = cmd; } +/// Stable code for a failed `session/resume`, reported to the agent as +/// `resume_failed:`. +pub fn resume_failure_code(err: &AcpError) -> String { + /// JSON-RPC "resource not found", sent for an unknown session/transcript. + const RESOURCE_NOT_FOUND: i64 = -32002; + match err { + AcpError::AgentError { code, .. } if *code == RESOURCE_NOT_FOUND => "not_found".into(), + AcpError::AgentError { code, .. } => format!("rpc_{code}"), + AcpError::Timeout(_) => "timeout".into(), + _ => "protocol".into(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -4004,6 +4294,10 @@ sleep 1"#, serde_json::json!({ "append": "be brief" }) ); assert_eq!(meta["sessionTitle"], "Fizz"); + assert_eq!( + meta["claudeCode"]["emitRawSDKMessages"], + serde_json::json!([{ "type": "result" }]) + ); for (case, cli) in [ ("old CLI", "echo '2.1.93 (Claude Code)'"), @@ -4011,7 +4305,15 @@ sleep 1"#, ("unclean version", "echo '2.1.284.garbage'"), ] { let meta = claude_session_meta_with_cli(cli).await; - assert!(meta.get("claudeCode").is_none(), "{case}: {meta}"); + assert!( + meta["claudeCode"].get("options").is_none(), + "{case}: {meta}" + ); + assert_eq!( + meta["claudeCode"]["emitRawSDKMessages"], + serde_json::json!([{ "type": "result" }]), + "{case}: result metadata does not depend on thinking-display support" + ); assert_eq!(meta["sessionTitle"], "Fizz", "{case}"); } } @@ -4019,6 +4321,7 @@ sleep 1"#, // ── claude-agent-acp _meta.systemPrompt transport ───────────────────── include!("acp/system_prompt_tests.rs"); + include!("acp/session_resume_tests.rs"); #[tokio::test] async fn active_run_id_sets_on_string() { @@ -5302,3 +5605,15 @@ sleep 1"#, #[cfg(all(test, unix))] mod process_tracking_tests; + +#[cfg(all(test, unix))] +mod terminate_tests; + +#[cfg(all(test, windows))] +mod windows_retirement_tests; + +mod terminate; +pub use terminate::TerminationReport; + +#[cfg(windows)] +mod windows_job; diff --git a/crates/buzz-acp/src/acp/claude_context.rs b/crates/buzz-acp/src/acp/claude_context.rs new file mode 100644 index 00000000000..360ddd2fc48 --- /dev/null +++ b/crates/buzz-acp/src/acp/claude_context.rs @@ -0,0 +1,201 @@ +//! Claude's standard usage size is provisional until the SDK reports a window. +//! +//! The installed adapter supports filtered SDK result notifications. Pair a +//! validated result window with its following final standard usage update; +//! never use aggregate SDK usage as the context-occupancy numerator. +use super::{AcpClient, StandardAdapterKind}; +use serde_json::Value; +use uuid::Uuid; + +pub(super) struct State { + generation: Uuid, + enabled: bool, + saw_usage: bool, + pending_window: Option, + confirmed_window: Option, + awaiting_final: bool, +} + +impl State { + pub(super) fn new(generation: Uuid) -> Self { + Self { + generation, + enabled: true, + saw_usage: false, + pending_window: None, + confirmed_window: None, + awaiting_final: false, + } + } +} + +impl AcpClient { + /// Start a new numerator measurement while retaining capacity established + /// for this same provider-session generation. A tool-published reply may + /// precede the next provider result, so confirmed capacity must survive. + pub(super) fn begin_claude_context_turn(&mut self, session: &str) { + if self.standard_adapter != Some(StandardAdapterKind::Claude) { + return; + } + if let Some(state) = self.claude_context.get_mut(session) { + state.enabled = true; + state.saw_usage = false; + state.pending_window = None; + state.awaiting_final = false; + } + if let Some(binding) = self.session_status.get(session) { + // The old numerator belongs to the previous turn. The next usage + // notification can immediately use the retained confirmed window. + binding.clear_occupancy(); + } + } + + pub(super) fn reset_claude_context(&mut self, session: &str, enabled: Option) { + if self.standard_adapter != Some(StandardAdapterKind::Claude) { + return; + } + if let Some(state) = self.claude_context.get_mut(session) { + if let Some(enabled) = enabled { + state.enabled = enabled; + } + state.saw_usage = false; + state.pending_window = None; + state.confirmed_window = None; + state.awaiting_final = false; + } + if let Some(binding) = self.session_status.get(session) { + binding.clear_occupancy(); + } + } + + /// This notification can contain result text. Consume it before tracing or + /// observer publication, including malformed or unsolicited SDK messages. + pub(super) fn consume_claude_context_frame(&mut self, frame: &Value) -> bool { + if self.standard_adapter != Some(StandardAdapterKind::Claude) + || frame.get("method").and_then(Value::as_str) != Some("_claude/sdkMessage") + || frame.get("id").is_some() + { + return false; + } + let Some(session) = frame.pointer("/params/sessionId").and_then(Value::as_str) else { + return true; + }; + let Some(binding) = self.session_status.get(session).filter(|b| b.is_live()) else { + return true; + }; + let Some(state) = self.claude_context.get_mut(session) else { + return true; + }; + if state.generation != binding.generation_id() { + return true; + } + // The adapter emits this SDK frame before processing the result, then + // emits the result's final standard usage update. A preceding streaming + // reading proves the current result can have a context numerator. + let saw_usage = std::mem::take(&mut state.saw_usage); + state.pending_window = None; + state.confirmed_window = None; + state.awaiting_final = true; + if state.enabled && saw_usage { + state.pending_window = authoritative_window(&frame["params"]["message"]); + } + binding.clear_occupancy(); + true + } + + pub(super) fn record_claude_context_usage(&mut self, frame: &Value) { + let Some(session) = frame.pointer("/params/sessionId").and_then(Value::as_str) else { + return; + }; + let Some(binding) = self.session_status.get(session).filter(|b| b.is_live()) else { + return; + }; + let Some(state) = self.claude_context.get_mut(session) else { + return; + }; + if state.generation != binding.generation_id() || !state.enabled { + return; + } + let used = frame.pointer("/params/update/used").and_then(Value::as_u64); + let final_update = std::mem::take(&mut state.awaiting_final); + let window = if final_update { + state.confirmed_window = state.pending_window.take().filter(|_| used.is_some()); + state.confirmed_window + } else { + state.confirmed_window + }; + state.saw_usage = used.is_some() && !final_update; + match used.zip(window) { + Some((used, size)) => binding.record_occupancy(used, size), + None => binding.clear_occupancy(), + } + } +} + +/// With result-only filtering there is no safe way to select a primary model +/// from a mixed-model tally. Accept a window only when every reported model +/// agrees. Aliases and subagents cannot cause a first-entry or prefix guess. +fn authoritative_window(result: &Value) -> Option { + if result.get("type")?.as_str()? != "result" + || result.get("subtype")?.as_str()? != "success" + || result.get("is_error")?.as_bool()? + || result.get("num_turns")?.as_u64()? == 0 + { + return None; + } + let models = result.get("modelUsage")?.as_object()?; + if models.is_empty() || models.len() > 64 { + return None; + } + let mut window = None; + for model in models.values() { + let size = model + .get("contextWindow")? + .as_u64() + .filter(|size| *size > 0)?; + if window.is_some_and(|previous| previous != size) { + return None; + } + window = Some(size); + } + window +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn only_successful_unambiguous_provider_windows_are_authoritative() { + let valid = json!({"type":"result", "subtype":"success", "is_error":false, + "num_turns":1, "modelUsage":{"alias":{"contextWindow":1_000_000}}}); + assert_eq!(authoritative_window(&valid), Some(1_000_000)); + for bad in [ + Value::Null, + json!(0), + json!(-1), + json!(1.5), + json!("1000000"), + ] { + let mut frame = valid.clone(); + frame["modelUsage"]["alias"]["contextWindow"] = bad; + assert_eq!(authoritative_window(&frame), None); + } + let mut frame = valid.clone(); + frame["modelUsage"]["subagent"] = json!({"contextWindow":200_000}); + assert_eq!(authoritative_window(&frame), None); + frame["modelUsage"]["subagent"]["contextWindow"] = json!(1_000_000); + assert_eq!(authoritative_window(&frame), Some(1_000_000)); + for (key, value) in [ + ("num_turns", json!(0)), + ("is_error", json!(true)), + ("subtype", json!("error_during_execution")), + ("modelUsage", json!({})), + ] { + let mut frame = valid.clone(); + frame[key] = value; + assert_eq!(authoritative_window(&frame), None); + } + } +} diff --git a/crates/buzz-acp/src/acp/compaction.rs b/crates/buzz-acp/src/acp/compaction.rs new file mode 100644 index 00000000000..c698cb2e289 --- /dev/null +++ b/crates/buzz-acp/src/acp/compaction.rs @@ -0,0 +1,311 @@ +//! Typed compaction lifecycles, fenced by provider session and local generation. +use std::collections::{HashMap, VecDeque}; + +use buzz_core::session_control::{ControlFailure, ControlOrigin, ControlOutcome}; +use serde::Deserialize; +use uuid::Uuid; + +const MAX_IDS: usize = 128; +const MAX_CREATION_BYTES: usize = 16 * 1024; + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CompactionStatus { + InProgress, + Completed, + Failed, + Cancelled, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CompactionUpdate { + compaction_id: String, + status: CompactionStatus, +} + +#[derive(Default)] +pub(super) struct CompactionTracker { + routes: HashMap, + entries: VecDeque, + sequence: u64, + creation: Option, +} + +struct Creation { + generation: Uuid, + advertisements: HashMap, +} + +struct Route { + generation: Uuid, + advertised: bool, + manual: Option, + adoptable_binding: bool, +} +struct Manual { + after: u64, + id: Option, + origin: ControlOrigin, +} +struct Entry { + session: String, + generation: Uuid, + id: String, + sequence: u64, + status: CompactionStatus, +} + +impl CompactionTracker { + pub(super) fn ensure_route(&mut self, session: &str) { + if !self.routes.contains_key(session) { + self.route(session, Uuid::new_v4()); + } + } + #[cfg(all(test, unix))] + pub(super) fn set_advertised(&mut self, session: &str) { + if let Some(route) = self.routes.get_mut(session) { + route.advertised = true; + } + } + pub(super) fn route(&mut self, session: &str, generation: Uuid) { + if self + .routes + .get(session) + .is_some_and(|route| route.generation == generation) + { + return; + } + self.clear(session); + self.routes.insert( + session.into(), + Route { + generation, + advertised: false, + manual: None, + adoptable_binding: false, + }, + ); + } + + pub(super) fn prepare_new(&mut self) -> Uuid { + let generation = Uuid::new_v4(); + self.creation = Some(Creation { + generation, + advertisements: HashMap::new(), + }); + generation + } + + pub(super) fn complete_new(&mut self, generation: Uuid, session: Option<&str>) { + if self + .creation + .as_ref() + .is_none_or(|pending| pending.generation != generation) + { + return; + } + let Some(pending) = self.creation.take() else { + return; + }; + let Some(session) = session.filter(|session| !session.is_empty()) else { + return; + }; + let advertised = pending + .advertisements + .get(session) + .copied() + .unwrap_or(false); + self.route(session, generation); + if let Some(route) = self.routes.get_mut(session) { + route.advertised = advertised; + route.adoptable_binding = true; + } + } + + pub(super) fn prepare_resume(&mut self, session: &str) -> Uuid { + let generation = Uuid::new_v4(); + self.route(session, generation); + generation + } + + pub(super) fn complete_resume(&mut self, session: &str, generation: Uuid, success: bool) { + if self + .routes + .get(session) + .is_none_or(|route| route.generation != generation) + { + return; + } + if success { + if let Some(route) = self.routes.get_mut(session) { + route.adoptable_binding = true; + } + } else { + self.clear(session); + } + } + + /// Attaching display metadata to this successful attachment is not a new + /// provider generation. Keep its pre-ACK IDs and capability intact. + pub(super) fn bind(&mut self, session: &str, generation: Uuid) { + if let Some(route) = self + .routes + .get_mut(session) + .filter(|route| route.adoptable_binding) + { + let prior = route.generation; + route.generation = generation; + route.adoptable_binding = false; + for entry in &mut self.entries { + if entry.session == session && entry.generation == prior { + entry.generation = generation; + } + } + } else { + self.route(session, generation); + } + } + + pub(super) fn retain_sessions(&mut self, sessions: &std::collections::HashSet) { + self.creation = None; + self.routes.retain(|session, _| sessions.contains(session)); + self.entries + .retain(|entry| sessions.contains(&entry.session)); + } + + pub(super) fn clear(&mut self, session: &str) { + self.creation = None; + self.routes.remove(session); + self.entries.retain(|entry| entry.session != session); + } + + pub(super) fn advertise(&mut self, session: &str, update: &serde_json::Value) { + let advertised = update["availableCommands"] + .as_array() + .is_some_and(|commands| commands.iter().any(|command| command["name"] == "compact")); + if let Some(route) = self.routes.get_mut(session) { + route.advertised = advertised; + } else if let Some(pending) = &mut self.creation { + // Unknown notifications never establish a route. Only this bounded + // in-flight creation may later adopt its successfully returned ID. + let existing = pending.advertisements.contains_key(session); + let bytes: usize = pending.advertisements.keys().map(String::len).sum(); + if existing + || (pending.advertisements.len() < MAX_IDS + && bytes.saturating_add(session.len()) <= MAX_CREATION_BYTES) + { + pending.advertisements.insert(session.into(), advertised); + } + } + } + + pub(super) fn supported(&self, session: &str) -> bool { + self.routes + .get(session) + .is_some_and(|route| route.advertised) + } + + pub(super) fn begin( + &mut self, + session: &str, + origin: ControlOrigin, + ) -> Result<(), ControlFailure> { + if self.entries.len() >= MAX_IDS + && self + .entries + .iter() + .all(|entry| entry.status == CompactionStatus::InProgress) + { + return Err(ControlFailure::Busy); + } + let route = self + .routes + .get_mut(session) + .ok_or(ControlFailure::StaleTarget)?; + if route.manual.is_some() { + return Err(ControlFailure::Busy); + } + route.manual = Some(Manual { + after: self.sequence, + id: None, + origin, + }); + Ok(()) + } + + /// Return a transition only on the first completed terminal for this ID. + pub(super) fn update( + &mut self, + session: &str, + value: &serde_json::Value, + ) -> Option> { + let update: CompactionUpdate = serde_json::from_value(value.clone()).ok()?; + if update.compaction_id.is_empty() || update.compaction_id.len() > 128 { + return None; + } + let route = self.routes.get_mut(session)?; + let existing = self.entries.iter().position(|entry| { + entry.session == session + && entry.generation == route.generation + && entry.id == update.compaction_id + }); + let index = match existing { + Some(index) => index, + None => { + if self.entries.len() >= MAX_IDS { + let terminal = self + .entries + .iter() + .position(|entry| entry.status != CompactionStatus::InProgress)?; + self.entries.remove(terminal); + } + self.sequence = self.sequence.saturating_add(1); + self.entries.push_back(Entry { + session: session.into(), + generation: route.generation, + id: update.compaction_id.clone(), + sequence: self.sequence, + status: CompactionStatus::InProgress, + }); + self.entries.len() - 1 + } + }; + let entry = &mut self.entries[index]; + if let Some(manual) = &mut route.manual { + if entry.sequence > manual.after && manual.id.is_none() { + manual.id = Some(entry.id.clone()); + } + } + if entry.status != CompactionStatus::InProgress { + return None; + } + entry.status = update.status; + (update.status == CompactionStatus::Completed).then(|| { + route + .manual + .as_ref() + .filter(|manual| manual.id.as_deref() == Some(entry.id.as_str())) + .map(|manual| manual.origin.clone()) + }) + } + + pub(super) fn finish(&mut self, session: &str) -> ControlOutcome { + let route = self + .routes + .get_mut(session) + .ok_or(ControlFailure::StaleTarget)?; + let manual = route.manual.take().ok_or(ControlFailure::Unconfirmed)?; + let entry = self.entries.iter().find(|entry| { + entry.session == session + && entry.generation == route.generation + && Some(entry.id.as_str()) == manual.id.as_deref() + }); + match entry.map(|entry| entry.status) { + Some(CompactionStatus::Completed) => Ok(()), + Some(CompactionStatus::Failed) => Err(ControlFailure::CompactFailed), + Some(CompactionStatus::Cancelled) => Err(ControlFailure::Cancelled), + _ => Err(ControlFailure::Unconfirmed), + } + } +} diff --git a/crates/buzz-acp/src/acp/compaction_tests.rs b/crates/buzz-acp/src/acp/compaction_tests.rs new file mode 100644 index 00000000000..2b8980372a2 --- /dev/null +++ b/crates/buzz-acp/src/acp/compaction_tests.rs @@ -0,0 +1,400 @@ +use super::*; +use std::time::Duration; + +#[tokio::test] +async fn initialize_advertises_compaction_without_losing_existing_capabilities() { + let script = r#"import sys,json +for line in sys.stdin: + r=json.loads(line) + if r.get('method')=='initialize': + c=r['params']['clientCapabilities'] + assert c['session']['compaction']=={} + assert c['auth']['terminal'] is True + assert c['_meta']['goose']['customNotifications'] is True + assert c['_meta']['terminal-auth'] is True + print(json.dumps({'jsonrpc':'2.0','id':r['id'],'result':{'protocolVersion':2,'agentInfo':{'name':'probe','version':'1'}}}),flush=True) +"#; + let mut client = AcpClient::spawn( + "/usr/bin/python3", + &["-c".into(), script.into()], + &[], + false, + ) + .await + .unwrap(); + let result = client.initialize().await; + client.shutdown().await; + assert!( + result.is_ok(), + "fake ACP rejected actual initialize frame: {result:?}" + ); +} + +fn origin() -> buzz_core::session_control::ControlOrigin { + buzz_core::session_control::ControlOrigin { + command_event_id: "a".repeat(64), + actor_pubkey: "b".repeat(64), + } +} +fn update(session: &str, id: &str, status: &str) -> serde_json::Value { + serde_json::json!({"params":{"sessionId":session,"update":{"sessionUpdate":"compaction_update","compactionId":id,"status":status}}}) +} +async fn client() -> AcpClient { + AcpClient::spawn( + "/usr/bin/python3", + &["-c".into(), "import sys;sys.stdin.read()".into()], + &[], + false, + ) + .await + .unwrap() +} +#[tokio::test] +async fn compaction_standard_dispatch_terminal_only_duplicate_and_next_request() { + let mut c = client().await; + c.route_compaction("s", uuid::Uuid::new_v4()); + c.begin_compaction("s", origin()).unwrap(); + c.handle_session_update(&update("s", "one", "completed")); + assert_eq!(c.finish_compaction("s"), Ok(())); + c.begin_compaction("s", origin()).unwrap(); + c.handle_session_update(&update("s", "one", "completed")); + assert_eq!( + c.finish_compaction("s"), + Err(buzz_core::session_control::ControlFailure::Unconfirmed) + ); + c.shutdown().await; +} +#[tokio::test] +async fn compaction_standard_dispatch_never_guesses_success() { + use buzz_core::session_control::ControlFailure as F; + let mut c = client().await; + for (status, expected) in [ + ("failed", F::CompactFailed), + ("cancelled", F::Cancelled), + ("future_status", F::Unconfirmed), + ] { + c.route_compaction("s", uuid::Uuid::new_v4()); + c.begin_compaction("s", origin()).unwrap(); + c.handle_session_update(&update("wrong", "one", "completed")); + c.handle_session_update(&update("s", "one", status)); + assert_eq!(c.finish_compaction("s"), Err(expected)); + } + c.clear_session_status("s"); + c.handle_session_update(&update("s", "old", "completed")); + c.route_compaction("s", uuid::Uuid::new_v4()); + c.begin_compaction("s", origin()).unwrap(); + assert_eq!(c.finish_compaction("s"), Err(F::Unconfirmed)); + c.shutdown().await; +} +#[tokio::test] +async fn compaction_tracker_never_evicts_active() { + use buzz_core::session_control::ControlFailure as F; + let mut c = client().await; + c.route_compaction("s", uuid::Uuid::new_v4()); + for n in 0..128 { + c.handle_session_update(&update("s", &n.to_string(), "in_progress")); + } + assert_eq!(c.begin_compaction("s", origin()), Err(F::Busy)); + c.handle_session_update(&update("s", "0", "completed")); + c.begin_compaction("s", origin()).unwrap(); + c.handle_session_update(&update("s", "new", "completed")); + assert_eq!(c.finish_compaction("s"), Ok(())); + c.shutdown().await; +} + +#[tokio::test] +async fn typed_compaction_changes_only_transition_and_manual_actor() { + use buzz_core::session_status::*; + let root = tempfile::tempdir().unwrap(); + let runtime = crate::session_status::SessionStatusRuntime::open( + root.path(), + canonical_identity( + "wss://relay.example", + &nostr::Keys::generate().public_key().to_hex(), + ) + .unwrap(), + ) + .unwrap(); + let scope = StatusScope { + channel_id: uuid::Uuid::new_v4(), + thread_root: None, + }; + let binding = runtime + .begin( + scope.clone(), + "s", + SessionState::Resumed, + StatusReason::IdleSleep, + ) + .unwrap(); + binding.record_occupancy(23, 100); + binding.set_history(HistoryCoverage { + read: Some(4), + total: Some(4), + exact: true, + incomplete: false, + }); + let mut c = client().await; + c.bind_session_status("s", binding.clone()); + let read = || { + parse_snapshot( + &std::fs::read(runtime.dir().join(scope_file_name(&scope).unwrap())).unwrap(), + ) + .unwrap() + }; + let before = read(); + c.begin_compaction("s", origin()).unwrap(); + c.handle_session_update(&update("s", "manual", "completed")); + let manual = read(); + assert_eq!(manual.generation, before.generation); + assert_eq!(manual.status.occupancy, before.status.occupancy); + assert_eq!(manual.status.history, before.status.history); + assert_eq!(manual.status.state, SessionState::Compacted); + assert_eq!(manual.status.reason, StatusReason::UserCompact); + assert_eq!(manual.origin, Some(origin())); + let mut enriched = update("s", "manual", "completed"); + enriched["params"]["update"]["summary"] = serde_json::json!({"text":"enriched"}); + c.handle_session_update(&enriched); + assert_eq!(read().status.transition_id, manual.status.transition_id); + c.finish_compaction("s").unwrap(); + c.handle_session_update(&update("s", "auto", "completed")); + let auto = read(); + assert_ne!(auto.status.transition_id, manual.status.transition_id); + assert_eq!(auto.status.reason, StatusReason::AutoCompact); + assert_eq!(auto.origin, None); + binding.retire(); + c.handle_session_update(&update("s", "retired", "completed")); + assert!(!runtime + .dir() + .join(scope_file_name(&scope).unwrap()) + .exists()); + c.shutdown().await; +} + +#[tokio::test] +async fn resumed_pre_ack_compaction_cannot_become_a_manual_completion_or_actor() { + use buzz_core::session_status::*; + let script = r#"import sys,json +for line in sys.stdin: + r=json.loads(line); method=r.get('method') + if 'id' not in r: continue + if method=='initialize': result={'protocolVersion':2,'agentCapabilities':{'sessionCapabilities':{'resume':{}}}} + elif method=='session/resume': + print(json.dumps({'jsonrpc':'2.0','method':'session/update','params':{'sessionId':'s','update':{'sessionUpdate':'compaction_update','compactionId':'before-bind','status':'in_progress'}}}),flush=True) + result={} + elif method=='session/prompt': + print(json.dumps({'jsonrpc':'2.0','method':'session/update','params':{'sessionId':'s','update':{'sessionUpdate':'compaction_update','compactionId':'before-bind','status':'completed'}}}),flush=True) + result={'stopReason':'end_turn'} + else: result={} + print(json.dumps({'jsonrpc':'2.0','id':r['id'],'result':result}),flush=True) +"#; + let mut c = AcpClient::spawn( + "/usr/bin/python3", + &["-c".into(), script.into()], + &[], + false, + ) + .await + .unwrap(); + c.initialize().await.unwrap(); + c.route_compaction("s", uuid::Uuid::new_v4()); + c.session_resume("s", "/tmp", vec![], None, None) + .await + .unwrap(); + let root = tempfile::tempdir().unwrap(); + let runtime = crate::session_status::SessionStatusRuntime::open( + root.path(), + canonical_identity( + "wss://relay.example", + &nostr::Keys::generate().public_key().to_hex(), + ) + .unwrap(), + ) + .unwrap(); + let scope = StatusScope { + channel_id: uuid::Uuid::new_v4(), + thread_root: None, + }; + let binding = runtime + .begin( + scope.clone(), + "s", + SessionState::Resumed, + StatusReason::IdleSleep, + ) + .unwrap(); + c.bind_session_status("s", binding); + c.begin_compaction("s", origin()).unwrap(); + c.session_prompt_blocks_with_idle_timeout( + "s", + &["/compact"], + Duration::from_secs(2), + Duration::from_secs(2), + ) + .await + .unwrap(); + let outcome = c.finish_compaction("s"); + c.shutdown().await; + assert_eq!( + outcome, + Err(buzz_core::session_control::ControlFailure::Unconfirmed) + ); + let snapshot = parse_snapshot( + &std::fs::read(runtime.dir().join(scope_file_name(&scope).unwrap())).unwrap(), + ) + .unwrap(); + assert_eq!(snapshot.status.reason, StatusReason::AutoCompact); + assert_eq!(snapshot.origin, None); +} + +#[test] +fn compaction_resume_adoption_requires_success_and_the_same_current_route() { + let mut tracker = compaction::CompactionTracker::default(); + let old = tracker.prepare_resume("s"); + let current = tracker.prepare_resume("s"); + tracker.set_advertised("s"); + tracker.complete_resume("s", old, true); + tracker.bind("s", uuid::Uuid::new_v4()); + assert!( + !tracker.supported("s"), + "stale completion adopted a newer route" + ); + tracker.complete_resume("s", current, true); + tracker.bind("s", uuid::Uuid::new_v4()); + assert!(!tracker.supported("s")); + + let current = tracker.prepare_resume("s"); + tracker.set_advertised("s"); + tracker.complete_resume("s", current, false); + tracker.bind("s", uuid::Uuid::new_v4()); + assert!(!tracker.supported("s"), "failed resume remained adoptable"); + + let current = tracker.prepare_resume("s"); + tracker.set_advertised("s"); + tracker.complete_resume("s", current, true); + tracker.clear("s"); + tracker.bind("s", uuid::Uuid::new_v4()); + assert!( + !tracker.supported("s"), + "retired route revived its metadata" + ); +} + +#[test] +fn compaction_creation_adoption_is_bounded_and_fenced_by_success_token_and_retirement() { + use buzz_core::session_control::ControlFailure; + let mut tracker = compaction::CompactionTracker::default(); + let advert = serde_json::json!({"availableCommands":[{"name":"compact"}]}); + tracker.advertise("arbitrary-old", &advert); + assert_eq!( + tracker.begin("arbitrary-old", origin()), + Err(ControlFailure::StaleTarget) + ); + let token = tracker.prepare_new(); + tracker.advertise("unreturned", &advert); + tracker.advertise("created", &advert); + tracker.complete_new(token, Some("created")); + tracker.bind("created", uuid::Uuid::new_v4()); + assert!(tracker.supported("created")); + assert_eq!( + tracker.begin("unreturned", origin()), + Err(ControlFailure::StaleTarget) + ); + + let token = tracker.prepare_new(); + tracker.advertise("failed", &advert); + tracker.complete_new(token, None); + tracker.ensure_route("failed"); + assert!(!tracker.supported("failed")); + let stale = tracker.prepare_new(); + tracker.advertise("stale", &advert); + let current = tracker.prepare_new(); + tracker.advertise("current", &advert); + tracker.complete_new(stale, Some("stale")); + tracker.complete_new(current, Some("current")); + assert!(tracker.supported("current")); + assert_eq!( + tracker.begin("stale", origin()), + Err(ControlFailure::StaleTarget) + ); + + let token = tracker.prepare_new(); + tracker.advertise("retired", &advert); + tracker.clear("retired"); + tracker.complete_new(token, Some("retired")); + assert_eq!( + tracker.begin("retired", origin()), + Err(ControlFailure::StaleTarget) + ); + let token = tracker.prepare_new(); + tracker.advertise("unowned", &advert); + tracker.retain_sessions(&Default::default()); + tracker.complete_new(token, Some("unowned")); + assert_eq!( + tracker.begin("unowned", origin()), + Err(ControlFailure::StaleTarget) + ); + + let token = tracker.prepare_new(); + for n in 0..128 { + tracker.advertise(&format!("candidate-{n}"), &advert); + } + tracker.advertise("overflow", &advert); + tracker.complete_new(token, Some("overflow")); + assert!(!tracker.supported("overflow")); + let token = tracker.prepare_new(); + let oversized = "s".repeat(16 * 1024 + 1); + tracker.advertise(&oversized, &advert); + tracker.complete_new(token, Some(&oversized)); + assert!(!tracker.supported(&oversized)); +} + +#[tokio::test] +async fn failed_or_malformed_new_response_cannot_adopt_pre_ack_advertisement() { + for malformed in [false, true] { + let first = if malformed { + "reply['result'] = {}" + } else { + "reply['error'] = {'code':-32000,'message':'creation failed'}" + }; + let script = format!( + r#"import sys,json +created=0 +for line in sys.stdin: + r=json.loads(line) + if 'id' not in r: continue + reply={{'jsonrpc':'2.0','id':r['id']}} + if r.get('method')=='initialize': reply['result']={{'protocolVersion':2,'agentCapabilities':{{}}}} + elif r.get('method')=='session/new': + created+=1 + if created==1: + print(json.dumps({{'jsonrpc':'2.0','method':'session/update','params':{{'sessionId':'reused','update':{{'sessionUpdate':'available_commands_update','availableCommands':[{{'name':'compact'}}]}}}}}}),flush=True) + {first} + else: reply['result']={{'sessionId':'reused'}} + else: reply['result']={{}} + print(json.dumps(reply),flush=True) +"# + ); + let mut c = AcpClient::spawn("/usr/bin/python3", &["-c".into(), script], &[], false) + .await + .unwrap(); + c.initialize().await.unwrap(); + assert!(c + .session_new_full("/tmp", vec![], None, None) + .await + .is_err()); + assert!(!c.compact_supported("reused")); + assert_eq!( + c.session_new_full("/tmp", vec![], None, None) + .await + .unwrap() + .session_id, + "reused" + ); + assert!( + !c.compact_supported("reused"), + "failed creation must not leak advertisement to a later success" + ); + c.shutdown().await; + } +} diff --git a/crates/buzz-acp/src/acp/launch/tests.rs b/crates/buzz-acp/src/acp/launch/tests.rs index 6356542b798..40c95b98b6b 100644 --- a/crates/buzz-acp/src/acp/launch/tests.rs +++ b/crates/buzz-acp/src/acp/launch/tests.rs @@ -231,10 +231,15 @@ async fn spawn_child() { .raw["meta"] .clone(); let expected = match std::env::var_os(PREFIX_ENV) { - Some(_) => Value::Null, - None => json!({"claudeCode": {"options": {"extraArgs": { - "thinking-display": "summarized" - }}}}), + Some(_) => json!({"claudeCode": { + "emitRawSDKMessages": [{"type": "result"}] + }}), + None => json!({"claudeCode": { + "emitRawSDKMessages": [{"type": "result"}], + "options": {"extraArgs": { + "thinking-display": "summarized" + }} + }}), }; assert_eq!(meta, expected, "prefix leaves summaries off"); } diff --git a/crates/buzz-acp/src/acp/restart_status.rs b/crates/buzz-acp/src/acp/restart_status.rs new file mode 100644 index 00000000000..9c0d0011c62 --- /dev/null +++ b/crates/buzz-acp/src/acp/restart_status.rs @@ -0,0 +1,51 @@ +//! Bounded answer capture for one controller-owned, text-only prompt. +use super::*; + +pub(super) struct StatusCapture { + pub(super) session: String, + text: String, + pub(super) invalid: bool, +} +impl AcpClient { + pub(crate) fn begin_status_capture(&mut self, session: &str) { + self.status_capture = Some(StatusCapture { + session: session.into(), + text: String::new(), + invalid: false, + }); + } + pub(crate) fn take_status_text(&mut self) -> Option { + let capture = self.status_capture.take()?; + (!capture.invalid && !capture.text.trim().is_empty()) + .then(|| capture.text.trim().to_owned()) + } + pub(super) fn capture_status_update(&mut self, msg: &serde_json::Value) { + let Some(capture) = &mut self.status_capture else { + return; + }; + if msg["params"]["sessionId"].as_str() != Some(capture.session.as_str()) { + return; + } + let update = &msg["params"]["update"]; + match update["sessionUpdate"].as_str() { + Some("agent_message_chunk") => { + if let Some(text) = update["content"]["text"].as_str() { + if capture.text.len().saturating_add(text.len()) + > crate::session_store::STATUS_TEXT_BYTES + { + capture.invalid = true; + } else if !capture.invalid { + capture.text.push_str(text); + } + } + } + Some("tool_call" | "tool_call_update") => capture.invalid = true, + _ => {} + } + } + pub(super) fn status_capture_invalid(&self) -> bool { + self.status_capture + .as_ref() + .is_some_and(|capture| capture.invalid) + } +} diff --git a/crates/buzz-acp/src/acp/session_resume_tests.rs b/crates/buzz-acp/src/acp/session_resume_tests.rs new file mode 100644 index 00000000000..ef685752bb1 --- /dev/null +++ b/crates/buzz-acp/src/acp/session_resume_tests.rs @@ -0,0 +1,85 @@ +#[tokio::test] +async fn initialize_reads_resume_capability() { + let script = r#" + read -t 2 _init + echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{"sessionCapabilities":{"resume":{}}}}}' + sleep 1 + "#; + let mut client = spawn_script(script).await; + client.initialize().await.expect("initialize"); + assert!(client.resume_supported()); + + let script = r#" + read -t 2 _init + echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}}' + sleep 1 + "#; + let mut client = spawn_script(script).await; + client.initialize().await.expect("initialize"); + assert!(!client.resume_supported()); +} + +#[tokio::test] +async fn session_resume_sends_same_params_as_new() { + let script = r#" + read -t 2 _init + echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{"sessionCapabilities":{"resume":{}}}}}' + read -t 2 REQ + echo '{"jsonrpc":"2.0","id":1,"result":{"_receivedRequest":'"$REQ"'}}' + sleep 1 + "#; + let mut client = spawn_script(script).await; + client.initialize().await.expect("initialize"); + let resp = client + .session_resume( + "ses_prev", + "/tmp", + vec![], + Some(SystemPromptTransport::ClaudeMeta("be brief")), + Some("Fizz"), + ) + .await + .expect("session_resume"); + assert_eq!(resp.session_id, "ses_prev"); + let request = &resp.raw["_receivedRequest"]; + assert_eq!(request["method"], "session/resume"); + assert_eq!(request["params"]["sessionId"], "ses_prev"); + assert_eq!(request["params"]["cwd"], "/tmp"); + assert_eq!(request["params"]["_meta"]["systemPrompt"]["append"], "be brief"); + assert_eq!(request["params"]["_meta"]["sessionTitle"], "Fizz"); +} + +#[tokio::test] +async fn resume_not_found_maps_to_code() { + let script = r#" + read -t 2 _init + echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{"sessionCapabilities":{"resume":{}}}}}' + read -t 2 _REQ + echo '{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"Resource not found: ses_gone"}}' + sleep 1 + "#; + let mut client = spawn_script(script).await; + client.initialize().await.expect("initialize"); + let Err(err) = client + .session_resume("ses_gone", "/tmp", vec![], None, None) + .await + else { + panic!("unknown session must fail to resume"); + }; + assert_eq!(resume_failure_code(&err), "not_found"); + assert_eq!( + resume_failure_code(&AcpError::AgentError { + code: -32603, + message: "internal".into() + }), + "rpc_-32603" + ); + assert_eq!( + resume_failure_code(&AcpError::Timeout(std::time::Duration::from_secs(60))), + "timeout" + ); + assert_eq!( + resume_failure_code(&AcpError::Protocol("bad".into())), + "protocol" + ); +} diff --git a/crates/buzz-acp/src/acp/terminate.rs b/crates/buzz-acp/src/acp/terminate.rs new file mode 100644 index 00000000000..4be6c0dc753 --- /dev/null +++ b/crates/buzz-acp/src/acp/terminate.rs @@ -0,0 +1,182 @@ +//! Checked, bounded retirement, separate from crash/shutdown cleanup. +use super::AcpClient; +#[cfg(unix)] +use std::time::Duration; + +/// Bound before a hung adapter receives SIGTERM. +#[cfg(unix)] +pub const TERMINATE_TERM_AFTER: Duration = Duration::from_secs(3); +/// Total graceful process-group wait before SIGKILL. +#[cfg(unix)] +pub const TERMINATE_KILL_AFTER: Duration = Duration::from_secs(15); +#[cfg(unix)] +const VERIFY_AFTER_KILL: Duration = Duration::from_secs(10); + +/// Confirmed outcome of explicitly retiring a worker and its observed work. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TerminationReport { + /// Every targeted process group or contained job emptied and the parent was reaped. + Clean, + /// Managed processes or process groups remain after bounded verification. + Leftover { count: usize }, + /// Ownership or enumeration could not be verified; no fresh session is safe. + Unverified, +} + +#[derive(Default)] +pub(super) struct TerminationState { + pub(super) checked: bool, + #[cfg(windows)] + pub(super) windows: super::windows_job::Retirement, + #[cfg(unix)] + gone_groups: std::collections::HashSet, + #[cfg(unix)] + evidence: Option<(u32, crate::process_tree::retirement::RetirementSnapshot)>, +} + +impl AcpClient { + /// Retire this worker without claiming success until its work has stopped. + /// On uncertainty the caller must retain this client and keep dispatch gated. + pub async fn terminate(&mut self) -> TerminationReport { + #[cfg(unix)] + { + self.terminate_with_snapshot(|| async { + tokio::task::spawn_blocking(crate::process_tree::retirement::snapshot) + .await + .ok() + .flatten() + }) + .await + } + #[cfg(windows)] + { + self.terminate_windows().await + } + #[cfg(not(any(unix, windows)))] + { + self.termination.checked = true; + TerminationReport::Unverified + } + } + + #[cfg(unix)] + pub(crate) async fn terminate_with_snapshot( + &mut self, + mut snapshot: F, + ) -> TerminationReport + where + F: FnMut() -> Fut, + Fut: std::future::Future< + Output = Option, + >, + { + use nix::sys::signal::Signal; + use nix::unistd::getpgrp; + use tokio::time::{sleep, Instant}; + self.termination.checked = true; + let own_group = getpgrp().as_raw() as u32; + if self.termination.evidence.is_none() { + let Some(pid) = self.child.id().filter(|pid| *pid > 1 && *pid != own_group) else { + return TerminationReport::Unverified; + }; + for _ in 0..3 { + if let Some(first) = snapshot().await.filter(|tree| tree.worker_present(pid)) { + self.termination.evidence = Some((pid, first)); + break; + } + } + } + let Some((pid, first)) = self.termination.evidence.clone() else { + return TerminationReport::Unverified; + }; + // Do not close stdin until the pre-shutdown snapshot is retained. + self.stdin.take(); + let started = Instant::now(); + let mut group_gone = self.termination.gone_groups.contains(&pid); + let mut term_sent = false; + while !group_gone && started.elapsed() < TERMINATE_KILL_AFTER { + // Reap a zombie leader before probing its process group. + let adapter_alive = !matches!(self.child.try_wait(), Ok(Some(_))); + if !group_alive(pid) { + group_gone = true; + self.termination.gone_groups.insert(pid); + break; + } + if adapter_alive && !term_sent && started.elapsed() >= TERMINATE_TERM_AFTER { + signal_group(pid, own_group, Signal::SIGTERM); + term_sent = true; + } + sleep(Duration::from_millis(25)).await; + } + let Some(last) = snapshot().await else { + return TerminationReport::Unverified; + }; + let mut groups = first.verified_groups(&last, pid, own_group); + groups.retain(|group| !self.termination.gone_groups.contains(group)); + if group_gone { + // ESRCH remains permanent across explicit retries: never signal a reused PGID. + groups.remove(&pid); + } else { + groups.insert(pid); + } + for group in &groups { + signal_group(*group, own_group, Signal::SIGKILL); + } + let deadline = Instant::now() + VERIFY_AFTER_KILL; + loop { + let reaped = matches!(self.child.try_wait(), Ok(Some(_))); + groups.retain(|group| { + if group_alive(*group) { + true + } else { + self.termination.gone_groups.insert(*group); + false + } + }); + if groups.is_empty() && reaped { + return TerminationReport::Clean; + } + if Instant::now() >= deadline { + return TerminationReport::Leftover { + count: groups.len().max(usize::from(!reaped)), + }; + } + sleep(Duration::from_millis(25)).await; + } + } +} + +#[cfg(unix)] +fn signal_group(group: u32, own_group: u32, signal: nix::sys::signal::Signal) { + if group > 1 && group != own_group { + if let Ok(group) = i32::try_from(group) { + let _ = nix::sys::signal::killpg(nix::unistd::Pid::from_raw(group), signal); + } + } +} + +#[cfg(unix)] +fn group_alive(group: u32) -> bool { + use nix::{sys::signal::killpg, unistd::Pid}; + i32::try_from(group).map_or(true, |group| { + // EPERM and every error other than ESRCH are conservative evidence of life. + probe_alive(killpg(Pid::from_raw(group), None)) + }) +} + +#[cfg(unix)] +fn probe_alive(result: Result<(), nix::errno::Errno>) -> bool { + result != Err(nix::errno::Errno::ESRCH) +} + +#[cfg(all(test, unix))] +mod probe_tests { + #[test] + fn permission_denied_process_group_is_alive() { + use nix::errno::Errno; + for result in [Ok(()), Err(Errno::EPERM), Err(Errno::EINVAL)] { + assert!(super::probe_alive(result)); + } + assert!(!super::probe_alive(Err(Errno::ESRCH))); + } +} diff --git a/crates/buzz-acp/src/acp/terminate_tests.rs b/crates/buzz-acp/src/acp/terminate_tests.rs new file mode 100644 index 00000000000..eca178bf17b --- /dev/null +++ b/crates/buzz-acp/src/acp/terminate_tests.rs @@ -0,0 +1,198 @@ +use super::*; +use std::os::unix::process::ExitStatusExt; +use std::time::{Duration, Instant}; + +async fn probe(setup: &str, body: &str) -> AcpClient { + let script = format!( + "import os\n{setup}\nprint('BUZZ_PROBE_READY:' + str(os.getpid()), flush=True)\n{body}" + ); + let mut client = AcpClient::spawn( + "/usr/bin/python3", + &["-u".into(), "-c".into(), script], + &[], + false, + ) + .await + .unwrap(); + let expected = format!("BUZZ_PROBE_READY:{}", client.process_id().unwrap()); + let ready = tokio::time::timeout(Duration::from_secs(10), client.reader.next()).await; + if !matches!(&ready, Ok(Some(Ok(line))) if line == &expected) { + let report = client.terminate().await; + client.shutdown().await; + panic!("probe setup did not reach its readiness barrier: {ready:?}; cleanup={report:?}"); + } + assert!( + client.child.try_wait().unwrap().is_none(), + "probe exited after readiness" + ); + client +} + +#[tokio::test] +async fn checked_termination_waits_for_the_group_after_leader_exits() { + let mut client = probe( + "import subprocess,sys; child=subprocess.Popen(['sleep','2']); assert child.poll() is None", + "sys.stdin.read()", + ) + .await; + let started = Instant::now(); + assert_eq!(client.terminate().await, TerminationReport::Clean); + assert!(started.elapsed() >= Duration::from_secs(1)); + assert!(started.elapsed() < Duration::from_secs(5)); + assert!(client.child.try_wait().unwrap().is_some()); +} + +#[tokio::test] +async fn checked_termination_escalates_to_term_at_three_seconds() { + let mut client = probe( + "import signal,time; assert signal.getsignal(signal.SIGTERM) == signal.SIG_DFL", + "time.sleep(60)", + ) + .await; + let started = Instant::now(); + let report = client.terminate().await; + let elapsed = started.elapsed(); + let status = client.child.try_wait().unwrap(); + assert_eq!( + report, + TerminationReport::Clean, + "elapsed={elapsed:?}, exit={status:?}" + ); + assert!( + elapsed >= Duration::from_secs(3), + "elapsed={elapsed:?}, exit={status:?}" + ); + assert!( + elapsed < Duration::from_secs(6), + "elapsed={elapsed:?}, exit={status:?}" + ); + assert_eq!( + status.and_then(|exit| exit.signal()), + Some(nix::sys::signal::Signal::SIGTERM as i32), + "elapsed={elapsed:?}, exit={status:?}" + ); +} + +#[tokio::test] +async fn checked_termination_escalates_to_kill_at_fifteen_seconds() { + let mut client = + probe("import signal,time; signal.signal(signal.SIGTERM,signal.SIG_IGN); assert signal.getsignal(signal.SIGTERM) == signal.SIG_IGN", "time.sleep(60)") + .await; + let started = Instant::now(); + let report = client.terminate().await; + let elapsed = started.elapsed(); + let status = client.child.try_wait().unwrap(); + assert_eq!( + report, + TerminationReport::Clean, + "elapsed={elapsed:?}, exit={status:?}" + ); + assert!( + elapsed >= Duration::from_secs(15), + "elapsed={elapsed:?}, exit={status:?}" + ); + assert!( + elapsed < Duration::from_secs(18), + "elapsed={elapsed:?}, exit={status:?}" + ); + assert_eq!( + status.and_then(|exit| exit.signal()), + Some(nix::sys::signal::Signal::SIGKILL as i32), + "elapsed={elapsed:?}, exit={status:?}" + ); +} + +#[tokio::test] +async fn checked_termination_three_bounded_snapshot_failures_preserve_worker() { + let mut client = probe("import sys", "sys.stdin.read()").await; + let started = Instant::now(); + let mut attempts = 0; + let report = client + .terminate_with_snapshot(|| { + attempts += 1; + async { + tokio::task::spawn_blocking(|| { + let mut command = tokio::process::Command::new("/bin/sleep"); + command.arg("60"); + assert!(crate::process_tree::bounded_output( + command, + Duration::from_secs(2), + 1024 + ) + .is_none()); + None + }) + .await + .unwrap() + } + }) + .await; + assert_eq!(report, TerminationReport::Unverified); + assert_eq!(attempts, 3); + assert!(started.elapsed() >= Duration::from_secs(6)); + assert!(started.elapsed() < Duration::from_secs(9)); + assert!( + client.stdin.is_some(), + "uncertainty must not close the provider's stdin" + ); + assert!( + client.child.try_wait().unwrap().is_none(), + "no signal on failed initial snapshots" + ); + client.shutdown().await; +} + +#[tokio::test] +async fn checked_termination_kills_snapshotted_separate_shell_group() { + let marker = tempfile::NamedTempFile::new().unwrap(); + let setup = format!("import subprocess,sys\nchild=subprocess.Popen(['sleep','60'],start_new_session=True)\nwith open({:?},'w') as marker: marker.write(str(child.pid))\nassert child.poll() is None", marker.path().to_string_lossy()); + let mut client = probe(&setup, "sys.stdin.read()").await; + let pid: i32 = std::fs::read_to_string(marker.path()) + .unwrap() + .parse() + .unwrap(); + assert_eq!(client.terminate().await, TerminationReport::Clean); + assert_eq!( + nix::sys::signal::killpg(nix::unistd::Pid::from_raw(pid), None), + Err(nix::errno::Errno::ESRCH) + ); + assert!(client.child.try_wait().unwrap().is_some()); + assert!(nix::sys::signal::killpg(nix::unistd::getpgrp(), None).is_ok()); +} + +#[tokio::test] +async fn checked_termination_missing_worker_in_initial_snapshot_never_starts() { + let mut client = probe("import sys", "sys.stdin.read()").await; + let mut attempts = 0; + let report = client + .terminate_with_snapshot(|| { + attempts += 1; + std::future::ready(crate::process_tree::retirement::RetirementSnapshot::parse( + "1 0 1 S init\n", + )) + }) + .await; + assert_eq!(attempts, 3); + assert_eq!(report, TerminationReport::Unverified); + assert!(client.stdin.is_some()); + assert!(client.child.try_wait().unwrap().is_none()); + client.shutdown().await; +} + +#[tokio::test] +async fn checked_termination_zombie_only_group_does_not_consume_kill_grace() { + let mut client = probe("import sys", "sys.stdin.read()").await; + let started = Instant::now(); + assert_eq!(client.terminate().await, TerminationReport::Clean); + assert!(started.elapsed() < Duration::from_secs(2)); + assert!(client.child.try_wait().unwrap().is_some()); +} + +#[tokio::test] +async fn checked_termination_retains_disappeared_groups_across_explicit_retry() { + let mut client = probe("import sys", "sys.stdin.read()").await; + assert_eq!(client.terminate().await, TerminationReport::Clean); + let started = Instant::now(); + assert_eq!(client.terminate().await, TerminationReport::Clean); + assert!(started.elapsed() < Duration::from_secs(2)); +} diff --git a/crates/buzz-acp/src/acp/windows_job.rs b/crates/buzz-acp/src/acp/windows_job.rs new file mode 100644 index 00000000000..a76e28b4690 --- /dev/null +++ b/crates/buzz-acp/src/acp/windows_job.rs @@ -0,0 +1,154 @@ +//! Suspended Windows spawn, owned job containment and independently queried retirement. +use super::{AcpClient, TerminationReport}; +use process_wrap::tokio::{ + ChildWrapper, CommandWrap, CommandWrapper, CreationFlags, JobObject, KillOnDrop, +}; +use std::{io, os::windows::io::AsRawHandle, process::ExitStatus, sync::Arc, time::Duration}; +use tokio::{ + process::Command, + time::{sleep, Instant}, +}; +use win32job::{ExtendedLimitInfo, Job}; + +pub(super) struct ContainedChild { + inner: Box, + job: Arc, + pub(super) stdin: Option, + pub(super) stdout: Option, +} + +impl ContainedChild { + pub(super) fn id(&self) -> Option { + self.inner.id() + } + + pub(super) fn start_kill(&mut self) -> io::Result<()> { + self.inner.start_kill() + } + + pub(super) fn try_wait(&mut self) -> io::Result> { + // Only JobObject wraps the native child. Its safe inner_mut view avoids + // completion-port events entirely: this reaps just the direct parent. + self.inner.inner_mut().try_wait() + } + + pub(super) async fn wait(&mut self) -> io::Result { + self.inner.inner_mut().wait().await + } +} + +#[derive(Debug)] +struct AssignQueryJob(Arc); + +impl CommandWrapper for AssignQueryJob { + fn post_spawn( + &mut self, + _command: &mut Command, + child: &mut tokio::process::Child, + _core: &CommandWrap, + ) -> io::Result<()> { + // All post_spawn hooks precede JobObject::wrap_child, which resumes the + // suspended child. Use the borrowed PROCESS handle, never its PID. + let handle = child + .process_handle() + .ok_or_else(|| io::Error::other("missing suspended process handle"))?; + self.0.assign_process(handle.as_raw_handle() as isize)?; + Ok(()) + } +} + +pub(super) fn spawn(command: Command) -> io::Result { + let mut limits = ExtendedLimitInfo::new(); + limits.limit_kill_on_job_close(); + // Neither breakaway flag is enabled. Nested jobs cannot escape this job. + let job = Arc::new(Job::create_with_limit_info(&limits)?); + let mut command = CommandWrap::from(command); + let mut flags = CreationFlags(Default::default()); + flags.0 .0 = 0x0800_0000; // CREATE_NO_WINDOW, preserved through suspended spawn. + command + .wrap(KillOnDrop) + .wrap(flags) + .wrap(AssignQueryJob(Arc::clone(&job))) + .wrap(JobObject); + let mut inner = command.spawn()?; + Ok(ContainedChild { + stdin: inner.stdin().take(), + stdout: inner.stdout().take(), + inner, + job, + }) +} + +#[derive(Default)] +pub(super) struct Retirement { + graceful_deadline: Option, + verify_deadline: Option, + kill_sent: bool, +} + +fn parent_reaped(child: &mut ContainedChild) -> io::Result { + // Read only the direct child. process-wrap wait/completion packets are not + // evidence that the job has zero descendants. + child.try_wait().map(|status| status.is_some()) +} + +impl AcpClient { + pub(super) async fn terminate_windows(&mut self) -> TerminationReport { + self.terminate_windows_with_query(|job| { + job.query_process_id_list() + .map(|processes| processes.len()) + .map_err(Into::into) + }) + .await + } + + // Keep failure injection on the production retirement loop. Native Windows + // tests still spawn real contained children and independently observe exits. + pub(super) async fn terminate_windows_with_query( + &mut self, + mut query: impl FnMut(&Job) -> io::Result, + ) -> TerminationReport { + self.termination.checked = true; + let state = &mut self.termination.windows; + // Save deadlines before any await, so cancellation/reentry never extends + // either the graceful wait or the post-termination verification bound. + let graceful = *state + .graceful_deadline + .get_or_insert_with(|| Instant::now() + Duration::from_secs(15)); + self.stdin.take(); + loop { + let reaped = match parent_reaped(&mut self.child) { + Ok(reaped) => reaped, + Err(_) => return TerminationReport::Unverified, + }; + let count = match query(&self.child.job) { + Ok(count) => count, + // The library's bounded query also fails for oversized jobs. + Err(_) => return TerminationReport::Unverified, + }; + if count == 0 && reaped { + return TerminationReport::Clean; + } + let now = Instant::now(); + if now >= graceful { + let deadline = *state + .verify_deadline + .get_or_insert_with(|| now + Duration::from_secs(10)); + // Terminate once after grace; a successful kill survives + // cancellation, while a failed attempt remains explicitly retryable. + if !state.kill_sent { + if self.child.start_kill().is_err() { + return TerminationReport::Unverified; + } + state.kill_sent = true; + } + if now >= deadline { + return TerminationReport::Leftover { + count: count.max(usize::from(!reaped)), + }; + } + } + sleep(Duration::from_millis(25)).await; + } + } +} diff --git a/crates/buzz-acp/src/acp/windows_retirement_tests.rs b/crates/buzz-acp/src/acp/windows_retirement_tests.rs new file mode 100644 index 00000000000..35c2aab5b21 --- /dev/null +++ b/crates/buzz-acp/src/acp/windows_retirement_tests.rs @@ -0,0 +1,310 @@ +//! Hosted Windows runtime probes: real ACP spawn and independent process observation. +use super::*; +use std::path::Path; +use std::time::{Duration, Instant}; + +// A nested detached process outlives both its launcher and the adapter. Its +// bounded lifetime and the fixture's taskkill guard also clean up a RED run. +const ADAPTER: &str = r#" +import ctypes,json,os,subprocess,sys,time +mode,marker=sys.argv[1:] +if mode=='leaf': + time.sleep(45) + sys.exit(0) +if mode=='middle': + child=subprocess.Popen([sys.executable,'-u',__file__,'leaf',marker], + creationflags=0x00000008|0x00000200,stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + open(marker,'w').write(json.dumps({'descendant':child.pid})) + sys.exit(0) +data={'adapter':os.getpid(),'console':int(ctypes.windll.kernel32.GetConsoleWindow())} +if mode=='detached': + middle=subprocess.Popen([sys.executable,'-u',__file__,'middle',marker], + stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + middle.wait(timeout=5) + data.update(json.load(open(marker))) +if mode=='breakaway': + try: + child=subprocess.Popen([sys.executable,'-u',__file__,'leaf',marker], + creationflags=0x01000000|0x00000008,stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + data['descendant']=child.pid + except OSError as error: + data['refused']=error.winerror +open(marker,'w').write(json.dumps(data)) +for line in sys.stdin: + request=json.loads(line) + if 'id' in request: + print(json.dumps({'jsonrpc':'2.0','id':request['id'], + 'result':{'protocolVersion':2,'agentCapabilities':{}}}),flush=True) +if mode=='ignore': + time.sleep(45) +"#; + +struct Probe { + client: Option, + data: serde_json::Value, + _dir: tempfile::TempDir, +} + +impl Probe { + async fn new(mode: &str) -> Self { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("adapter.py"); + let marker = dir.path().join("ready.json"); + std::fs::write(&script, ADAPTER).unwrap(); + let mut client = AcpClient::spawn( + "python", + &[ + "-u".into(), + script.to_string_lossy().into_owned(), + mode.into(), + marker.to_string_lossy().into_owned(), + ], + &[], + false, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(10), client.initialize()) + .await + .unwrap() + .unwrap(); + let data = read_marker(&marker); + assert_eq!(data["adapter"].as_u64(), client.process_id().map(u64::from)); + Self { + client: Some(client), + data, + _dir: dir, + } + } + + fn client(&mut self) -> &mut AcpClient { + self.client.as_mut().unwrap() + } + + fn pid(&self, key: &str) -> u32 { + self.data[key].as_u64().unwrap().try_into().unwrap() + } +} + +impl Drop for Probe { + fn drop(&mut self) { + // Own only these fixture PIDs; never a broad name-based cleanup. + for key in ["descendant", "adapter"] { + if let Some(pid) = self.data[key].as_u64() { + let _ = std::process::Command::new("taskkill") + .args(["/F", "/T", "/PID", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } + } + } +} + +fn read_marker(path: &Path) -> serde_json::Value { + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() +} + +fn alive(pid: u32) -> bool { + // Ask Windows independently of process-wrap/job completion notifications. + let output = std::process::Command::new("python") + .args([ + "-c", + r#" +import ctypes,sys +k=ctypes.WinDLL('kernel32',use_last_error=True) +k.OpenProcess.restype=ctypes.c_void_p +k.WaitForSingleObject.argtypes=[ctypes.c_void_p,ctypes.c_uint32] +k.CloseHandle.argtypes=[ctypes.c_void_p] +h=k.OpenProcess(0x00100000,False,int(sys.argv[1])) +if not h: + assert ctypes.get_last_error()==87,ctypes.get_last_error() + print('dead') +else: + result=k.WaitForSingleObject(h,0) + k.CloseHandle(h) + assert result in (0,258),result + print('alive' if result==258 else 'dead') +"#, + &pid.to_string(), + ]) + .output() + .unwrap(); + assert!(output.status.success(), "{:?}", output); + match String::from_utf8(output.stdout).unwrap().trim() { + "alive" => true, + "dead" => false, + other => panic!("unexpected process probe: {other}"), + } +} + +async fn assert_dead(pid: u32) { + let deadline = Instant::now() + Duration::from_secs(5); + while alive(pid) && Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(25)).await; + } + assert!(!alive(pid), "fixture process {pid} survived containment"); +} + +#[tokio::test] +async fn checked_termination_windows_adapter_exits_on_eof() { + let mut probe = Probe::new("eof").await; + let pid = probe.pid("adapter"); + let started = Instant::now(); + assert_eq!(probe.client().terminate().await, TerminationReport::Clean); + assert!(started.elapsed() < Duration::from_secs(5)); + assert_dead(pid).await; +} + +#[tokio::test] +async fn checked_termination_windows_ignores_eof_until_fifteen_seconds() { + let mut probe = Probe::new("ignore").await; + let pid = probe.pid("adapter"); + let started = Instant::now(); + assert_eq!(probe.client().terminate().await, TerminationReport::Clean); + assert!(started.elapsed() >= Duration::from_secs(15)); + assert!(started.elapsed() < Duration::from_secs(25)); + assert_dead(pid).await; +} + +#[tokio::test] +async fn checked_termination_windows_detached_grandchild_blocks_clean_after_parent_exit() { + let mut probe = Probe::new("detached").await; + let parent = probe.pid("adapter"); + let child = probe.pid("descendant"); + assert!(alive(child)); + // Cancellation/reentry must not turn a cached parent exit into job proof. + assert!( + tokio::time::timeout(Duration::from_secs(5), probe.client().terminate()) + .await + .is_err() + ); + assert_dead(parent).await; + assert!( + alive(child), + "the detached grandchild must outlive the adapter" + ); + let started = Instant::now(); + let report = tokio::time::timeout(Duration::from_secs(12), probe.client().terminate()) + .await + .expect("reentry restarted the graceful deadline"); + assert_eq!(report, TerminationReport::Clean); + assert!(started.elapsed() >= Duration::from_secs(8)); + assert!(started.elapsed() < Duration::from_secs(12)); + assert_dead(child).await; + // A second explicit attempt must retain actual zero-job proof. + assert_eq!(probe.client().terminate().await, TerminationReport::Clean); +} + +#[tokio::test] +async fn checked_termination_windows_breakaway_is_refused_or_contained() { + let mut probe = Probe::new("breakaway").await; + if let Some(error) = probe.data["refused"].as_u64() { + assert_eq!(error, 5, "expected job breakaway access denial"); + } else { + assert!(alive(probe.pid("descendant"))); + } + assert_eq!(probe.client().terminate().await, TerminationReport::Clean); + if probe.data["descendant"].is_number() { + assert_dead(probe.pid("descendant")).await; + } +} + +#[tokio::test] +async fn checked_termination_windows_preserves_create_no_window() { + let mut probe = Probe::new("eof").await; + assert_eq!( + probe.data["console"], 0, + "adapter acquired a console window" + ); + assert_eq!(probe.client().terminate().await, TerminationReport::Clean); +} + +#[tokio::test] +async fn checked_termination_windows_harness_drop_kills_detached_tree() { + let mut probe = Probe::new("detached").await; + let parent = probe.pid("adapter"); + let child = probe.pid("descendant"); + assert!(alive(parent)); + assert!(alive(child)); + drop(probe.client.take()); + assert_dead(parent).await; + assert_dead(child).await; +} + +#[tokio::test] +async fn checked_termination_windows_query_error_after_parent_reap_is_unverified() { + let mut probe = Probe::new("detached").await; + let parent = probe.pid("adapter"); + let descendant = probe.pid("descendant"); + probe.client().stdin.take(); + assert_dead(parent).await; + assert!(matches!(probe.client().child.try_wait(), Ok(Some(_)))); + assert!(alive(descendant)); + assert_eq!( + probe + .client() + .terminate_windows_with_query(|_| { + Err(std::io::Error::other("fixture job query failed")) + }) + .await, + TerminationReport::Unverified + ); + assert!( + alive(descendant), + "query failure is not proof of stopped work" + ); + drop(probe.client.take()); + assert_dead(descendant).await; +} + +#[tokio::test] +async fn checked_termination_windows_empty_query_requires_parent_reap() { + let mut probe = Probe::new("ignore").await; + let parent = probe.pid("adapter"); + // An empty query alone cannot authorize replacement of a live parent. + assert!( + tokio::time::timeout( + Duration::from_secs(1), + probe.client().terminate_windows_with_query(|_| Ok(0)) + ) + .await + .is_err(), + "empty job query accepted an unreaped parent" + ); + assert!(alive(parent)); + drop(probe.client.take()); + assert_dead(parent).await; +} + +#[tokio::test] +async fn checked_termination_windows_verification_deadline_survives_cancellation() { + let mut probe = Probe::new("ignore").await; + let parent = probe.pid("adapter"); + let started = Instant::now(); + // Hold the queried job nonempty after the real kill to exercise the entire + // verification window, including cancellation five seconds into that phase. + assert!(tokio::time::timeout( + Duration::from_secs(20), + probe.client().terminate_windows_with_query(|_| Ok(1)) + ) + .await + .is_err()); + assert_dead(parent).await; + assert!(matches!(probe.client().child.try_wait(), Ok(Some(_)))); + let resumed = Instant::now(); + let report = tokio::time::timeout( + Duration::from_secs(6), + probe.client().terminate_windows_with_query(|_| Ok(1)), + ) + .await + .expect("reentry restarted a retirement deadline"); + assert_eq!(report, TerminationReport::Leftover { count: 1 }); + assert!(resumed.elapsed() < Duration::from_secs(6)); + assert!(started.elapsed() >= Duration::from_secs(25)); + assert!(started.elapsed() < Duration::from_secs(27)); + // The real empty query and reaped parent are required even on a later retry. + assert_eq!(probe.client().terminate().await, TerminationReport::Clean); +} diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 24d59c2c933..7a8a7671543 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -397,6 +397,17 @@ pub struct CliArgs { value_parser = clap::value_parser!(u32))] pub max_turns_per_session: u32, + /// Directory for durable session records, used to resume provider + /// sessions after a pool sleep, worker crash, or harness restart. + /// Root defaults to `/.buzz-sessions`. Both default and configured + /// roots are namespaced by canonical relay URL and agent public key. + #[arg(long, env = "BUZZ_ACP_SESSION_STORE_DIR")] + pub session_store_dir: Option, + + /// Disable resuming provider sessions; every lost session starts fresh. + #[arg(long, env = "BUZZ_ACP_NO_SESSION_RESUME")] + pub no_session_resume: bool, + /// Disable automatic presence (online/offline) status. #[arg(long, env = "BUZZ_ACP_NO_PRESENCE")] pub no_presence: bool, @@ -574,6 +585,10 @@ pub struct Config { pub context_message_limit: u32, /// Maximum turns per session before proactive rotation. 0 = disabled. pub max_turns_per_session: u32, + /// Durable session record directory override; `None` = default under cwd. + pub session_store_dir: Option, + /// Whether lost provider sessions are resumed from the session store. + pub session_resume: bool, pub presence_enabled: bool, pub typing_enabled: bool, /// Whether NIP-AE agent core memory injection is enabled. When false, @@ -1186,6 +1201,8 @@ impl Config { config_path: args.config, context_message_limit: args.context_message_limit, max_turns_per_session: args.max_turns_per_session, + session_store_dir: args.session_store_dir, + session_resume: !args.no_session_resume, presence_enabled: !args.no_presence, typing_enabled: !args.no_typing, memory_enabled: args.memory && !args.no_memory, @@ -1567,6 +1584,8 @@ mod tests { config_path: PathBuf::from("./buzz-acp.toml"), context_message_limit: 12, max_turns_per_session: 0, + session_store_dir: None, + session_resume: true, presence_enabled: true, typing_enabled: true, memory_enabled: true, @@ -2337,6 +2356,30 @@ channels = "ALL" assert_eq!(configured.idle_pool_sleep, 300); } + #[test] + fn session_store_flags_default_to_resume_in_working_dir() { + let key = format!("{:0>64}", "1"); + let config = Config::from_args(CliArgs::parse_from(["buzz-acp", "--private-key", &key])) + .expect("config"); + assert!(config.session_resume); + assert_eq!(config.session_store_dir, None); + + let config = Config::from_args(CliArgs::parse_from([ + "buzz-acp", + "--private-key", + &key, + "--no-session-resume", + "--session-store-dir", + "/tmp/sessions", + ])) + .expect("config"); + assert!(!config.session_resume); + assert_eq!( + config.session_store_dir, + Some(PathBuf::from("/tmp/sessions")) + ); + } + #[test] fn lazy_pool_cli_flag_enables_deferred_startup() { let key = "0".repeat(64); diff --git a/crates/buzz-acp/src/control_tasks.rs b/crates/buzz-acp/src/control_tasks.rs new file mode 100644 index 00000000000..3a23de293d7 --- /dev/null +++ b/crates/buzz-acp/src/control_tasks.rs @@ -0,0 +1,147 @@ +//! Schedule typed controls without chat queue accounting or affinity forks. +use super::*; +use buzz_core::session_control::ControlFailure; +use pool::compaction::{run_compaction_task, ControlTaskResult}; + +pub(crate) fn wake_pending( + queue: &mut EventQueue, + controls: &session_controls::SessionControlRuntime, +) -> bool { + queue.has_flushable_work() + || !controls.pending_compactions().is_empty() + || !controls.pending_statuses().is_empty() +} + +pub(crate) fn dispatch( + pool: &mut AgentPool, + ctx: &Arc, + tx: &mpsc::UnboundedSender, +) { + for mut job in ctx.session_controls.pending_compactions() { + let refusal = if !job.dispatch.current() { + Some(ControlFailure::Superseded) + } else if tokio::time::Instant::now() >= job.deadline { + // No provider operation started: the exact owner stayed busy. + Some(ControlFailure::Busy) + } else { + None + }; + if let Some(failure) = refusal { + ctx.session_controls + .finish_compaction_job(&job, Err(failure)); + continue; + } + let mut agent = match pool.claim_compaction(&job) { + Ok(Some(agent)) => agent, + Ok(None) => { + ctx.session_controls.report_compaction_wait(&job); + continue; + } + Err(failure) => { + ctx.session_controls + .finish_compaction_job(&job, Err(failure)); + continue; + } + }; + // Resume and the compact RPC share one budget, starting only once the + // existing owner is acquired. Waiting never cancels its current work. + job.deadline = + tokio::time::Instant::now() + session_controls::compaction::COMPACT_EXECUTION_WAIT; + if !ctx.session_controls.start_compaction(&job, agent.index) { + pool.return_agent(agent); + continue; + } + agent.state.control_dispatch = Some(job.dispatch.clone()); + let (control_tx, control_rx) = tokio::sync::oneshot::channel(); + let ctx_clone = ctx.clone(); + let tx = tx.clone(); + let index = agent.index; + let scope = job.job.scope.clone(); + let dispatch = job.dispatch.clone(); + let turn_id = job.job.origin.command_event_id.clone(); + let handle = pool.join_set.spawn(async move { + let result = run_compaction_task(agent, job, ctx_clone, control_rx).await; + if tx.send(result).is_err() { + tracing::error!(agent = index, "compaction completion receiver closed"); + } + }); + pool.task_map_mut().insert( + handle.id(), + pool::TaskMeta { + agent_index: index, + channel_id: Some(scope.channel_id()), + scope: Some(scope), + turn_id, + recoverable_batch: None, + control_tx: Some(control_tx), + steer_tx: None, + control_dispatch: Some(dispatch), + successful_steer_deliveries: HashSet::new(), + }, + ); + } +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn finish( + pool: &mut AgentPool, + ctx: &PromptContext, + mut result: ControlTaskResult, + removed: &HashSet, + config: &Config, + circuits: &mut [SlotCircuit], + respawn_tx: &mpsc::Sender, + tasks: &mut tokio::task::JoinSet<()>, + observer: Option, + last_activity: &mut tokio::time::Instant, +) { + let index = result.agent.index; + pool.task_map_mut() + .retain(|_, meta| meta.agent_index != index); + ctx.session_controls + .finish_compaction_job(&result.job, result.outcome); + if !result.job.dispatch.current() { + result.agent.state.invalidate_scope(&result.job.job.scope); + } + if let Some(dispatch) = result.agent.state.control_dispatch.take() { + dispatch.finish(); + } + for channel in removed { + result.agent.state.invalidate_channel(channel); + } + *last_activity = tokio::time::Instant::now(); + if pool.needs_retirement(&result.agent) { + pool.return_for_retirement(result.agent); + } else if result.transport_failed { + result + .agent + .state + .depart_all(crate::session_store::Departure::WorkerCrash); + pool.clear_runtime_hold(index); + spawn_respawn_task( + result.agent, + config, + &mut circuits[index], + respawn_tx, + tasks, + observer, + ); + } else { + // Disk-only acquisition becomes authoritative only after its leased + // provider attachment succeeded. It never increments chat turn counters. + if result + .agent + .state + .sessions + .contains_key(&result.job.job.scope) + && !pool.has_session_for(&result.job.job.scope) + { + let generation = pool.record_scope_owner(result.job.job.scope.clone(), index); + result + .agent + .state + .set_scope_owner_generation(result.job.job.scope.clone(), generation); + } + pool.return_agent(result.agent); + } +} diff --git a/crates/buzz-acp/src/filter.rs b/crates/buzz-acp/src/filter.rs index 43edd969dd3..917b2189ee5 100644 --- a/crates/buzz-acp/src/filter.rs +++ b/crates/buzz-acp/src/filter.rs @@ -371,6 +371,13 @@ pub async fn match_event( rules: &[SubscriptionRule], agent_pubkey_hex: &str, ) -> Option { + if serde_json::to_value(event) + .ok() + .is_some_and(|raw| crate::session_controls::reserved_raw(&raw)) + { + return None; + } + let filter_ctx = FilterContext::from_event(event, channel_id); for (index, rule) in rules.iter().enumerate() { diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index a32345ec912..461847fd82f 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -22,9 +22,15 @@ mod relay; mod run_task; mod runtime; use runtime::{AgentRuntime, PoolStartup, SessionMode}; +mod control_tasks; mod scope; +mod session_controls; +mod session_status; +mod session_store; mod setup_mode; +mod status_tasks; mod usage; +mod worker_retirement; pub use usage::TurnUsage; @@ -370,6 +376,9 @@ mod inbound_author_gate { } impl AuthorizedListenerEvent { + pub(crate) fn event(&self) -> &relay::BuzzEvent { + &self.buzz_event + } pub(crate) fn into_parts(self) -> (relay::BuzzEvent, String) { (self.buzz_event, self.effective_author) } @@ -653,12 +662,18 @@ impl QueuedNormalListenerEvent { fn steer_or_interrupt( self, handling: MultipleEventHandling, + controls: &session_controls::SessionControlRuntime, owner: Option<&str>, pool: &mut AgentPool, queue: &mut EventQueue, steer_ack_tx: &mpsc::UnboundedSender, ) { - if !self.accepted || !queue.is_scope_in_flight(&self.scope) { + if controls.retirement_pending(&self.scope) + || controls.compact_pending(&self.scope) + || controls.status_pending(&self.scope) + || !self.accepted + || !queue.is_scope_in_flight(&self.scope) + { return; } let Some(signal) = mode_gate_signal(handling, &self.effective_author, owner) else { @@ -1616,6 +1631,7 @@ async fn publish_relay_observer_event( /// Maximum age (seconds) for an observer control frame to be considered fresh. const OBSERVER_CONTROL_FRESHNESS_SECS: i64 = 300; +#[allow(clippy::too_many_arguments)] fn handle_relay_observer_control_event( keys: &nostr::Keys, event: nostr::Event, @@ -1624,6 +1640,7 @@ fn handle_relay_observer_control_event( observer: Option<&observer::ObserverHandle>, owner_pubkey_hex: &str, event_publisher: RelayEventPublisher, + controls: &session_controls::SessionControlRuntime, ) { // Defense-in-depth: verify signature even though the relay already checked. if let Err(e) = buzz_core::verify_event(&event) { @@ -1664,7 +1681,7 @@ fn handle_relay_observer_control_event( let command_type = payload.get("type").and_then(|value| value.as_str()); match command_type { Some("cancel_turn") => { - handle_cancel_turn_control(&payload, pool, queue, observer); + handle_cancel_turn_control_with_status(&payload, pool, queue, observer, Some(controls)); } Some("switch_model") => { handle_switch_model_control(&payload, pool, observer); @@ -1827,11 +1844,22 @@ fn emit_project_owner_control_result( /// Cancel the unambiguous active turn, revoking its background authority even /// when a prior ordinary control already consumed the one-shot sender. +#[cfg(test)] fn handle_cancel_turn_control( payload: &serde_json::Value, pool: &mut AgentPool, queue: &mut EventQueue, observer: Option<&observer::ObserverHandle>, +) { + handle_cancel_turn_control_with_status(payload, pool, queue, observer, None); +} + +fn handle_cancel_turn_control_with_status( + payload: &serde_json::Value, + pool: &mut AgentPool, + queue: &mut EventQueue, + observer: Option<&observer::ObserverHandle>, + controls: Option<&session_controls::SessionControlRuntime>, ) { let Some(channel_id) = payload .get("channelId") @@ -1842,7 +1870,16 @@ fn handle_cancel_turn_control( return; }; - let status = if pool.channel_control_is_ambiguous(channel_id) { + let pending_status: Vec<_> = controls + .map(|controls| { + controls + .cancellable_status_scopes() + .into_iter() + .filter(|scope| scope.channel_id() == channel_id) + .collect() + }) + .unwrap_or_default(); + let status = if pool.channel_control_is_ambiguous(channel_id) || pending_status.len() > 1 { "ambiguous_target" } else { // Use only the active task's actual scope. A channel-only frame cannot @@ -1855,8 +1892,16 @@ fn handle_cancel_turn_control( let revoked = active_scope .as_ref() .is_some_and(|scope| cancel_background_collection_for_scope(pool, queue, scope)); + let status_scope = active_scope.as_ref().or_else(|| pending_status.first()); + let cancellation = status_scope + .zip(controls) + .map(|(scope, controls)| controls.cancel_status_outcome(scope)) + .unwrap_or(session_controls::status::StatusCancellation::None); + let status_cancelled = cancellation != session_controls::status::StatusCancellation::None; let sent = signal_in_flight_task(pool, channel_id, ControlSignal::Cancel); - if sent || revoked { + if cancellation == session_controls::status::StatusCancellation::Unconfirmed { + "unconfirmed" + } else if sent || revoked || status_cancelled { "sent" } else { "no_active_turn" @@ -2113,6 +2158,7 @@ fn any_respawn_in_flight(crash_history: &[SlotCircuit]) -> bool { /// Result of a background respawn task. struct RespawnResult { index: usize, + retirement: Option, /// Tuple: (initialized client, protocol version, agent name). result: Result<(AcpClient, u32, String)>, } @@ -2127,6 +2173,7 @@ struct RespawnResult { /// `EventQueue::withheld_native_steer`: `channel_id` is the routing key, /// `event_id` is the hex id of the single event the steer carried. struct SteerAckEvent { + control_dispatch: Option, channel_id: Uuid, /// Session scope of the steered event — the queue-side withhold/release /// and deadline extension target this, not the whole channel. @@ -2168,6 +2215,7 @@ impl RespawnGuard { // respawn_in_flight guard has drifted — that's a bug, not a transient. match self.tx.try_send(RespawnResult { index: self.index, + retirement: None, result, }) { Ok(()) => self.sent = true, @@ -2192,6 +2240,7 @@ impl Drop for RespawnGuard { // Best-effort: try_send in Drop (can't await). let _ = self.tx.try_send(RespawnResult { index: self.index, + retirement: None, result: Err(anyhow::anyhow!("respawn task panicked or was cancelled")), }); } @@ -2246,6 +2295,46 @@ fn idle_pool_sleep_due( && inactivity_expired(last_activity, now, bound, turn_in_flight) } +/// Whether a completed prompt result counts as activity for the idle clocks +/// (`idle_pool_sleep_secs` and `exit_after_inactivity_secs`). +/// +/// Turn completion must refresh `last_activity`: measured from dispatch, a +/// turn longer than the idle bound would look idle on the first tick after it +/// completes, and teardown would take the worker's children with it. +/// Heartbeat results are excluded: heartbeats are dispatched only into an idle +/// pool, so a pool kept alive by its own heartbeats would never sleep. +fn result_refreshes_idle_clock(source: &PromptSource) -> bool { + // Exhaustive on purpose: a new PromptSource variant must decide + // explicitly whether its completion keeps the pool awake. + match source { + PromptSource::Channel(_) => true, + PromptSource::Heartbeat => false, + } +} + +#[cfg(test)] +mod idle_clock_refresh_tests { + use super::*; + + #[test] + fn channel_turn_completion_refreshes_idle_clock() { + // A completed channel turn is activity: otherwise a turn longer than + // the idle bound is torn down on the first tick after it completes. + assert!(result_refreshes_idle_clock(&PromptSource::Channel( + scope::SessionScope::Conversation { + channel_id: Uuid::new_v4() + } + ))); + } + + #[test] + fn heartbeat_completion_does_not_refresh_idle_clock() { + // Heartbeats fire only into an idle pool; counting them as activity + // would keep a heartbeat-enabled pool awake forever. + assert!(!result_refreshes_idle_clock(&PromptSource::Heartbeat)); + } +} + #[cfg(test)] mod inactivity_tests { use super::*; @@ -2658,7 +2747,8 @@ async fn run_harness( mut shutdown_rx: watch::Receiver<()>, startup_ready: tokio::sync::oneshot::Sender<()>, ) -> Result<()> { - let runtime = AgentRuntime::prepare(config)?; + let mut runtime = AgentRuntime::prepare(config)?; + runtime.enable_session_status(); let config = runtime.config(); tracing::info!("buzz-acp starting: {}", config.summary()); @@ -2681,11 +2771,18 @@ async fn run_harness( ); } + let session_store = pool::open_session_store( + config.session_store_dir.clone(), + config.session_resume, + &config.keys.public_key().to_hex(), + &config.relay_url, + ); let mut pool = if config.lazy_pool { AgentPool::from_slots((0..config.agents).map(|_| None).collect()) } else { initialize_agent_pool(&runtime.startup(observer.clone()), None).await? }; + pool.set_session_store(session_store.clone()); let mut pool_ready = !config.lazy_pool; let mut pool_lifecycle: PoolLifecycle = PoolLifecycle::listening(); @@ -2876,11 +2973,28 @@ async fn run_harness( ); } - let ctx = Arc::new(runtime.prompt_context( + let mut prompt_context = runtime.prompt_context( relay.rest_client(), channel_info_map, SessionMode::Conversation, - )?); + )?; + // Controls remain durable when provider resume is disabled. Never attach this + // control-only store to workers, so it cannot retain provider IDs or deliveries. + let control_store = session_store.clone().or_else(|| { + pool::open_session_store( + config.session_store_dir.clone(), + true, + &pubkey_hex, + &config.relay_url, + ) + }); + prompt_context.session_controls = session_controls::SessionControlRuntime::new(control_store); + prompt_context.session_controls.configure_status_authority( + config.respond_to.clone(), + config.respond_to_allowlist.clone(), + startup_owner.clone(), + ); + let ctx = Arc::new(prompt_context); if !config.memory_enabled { tracing::info!( @@ -2974,6 +3088,11 @@ async fn run_harness( let (respawn_tx, mut respawn_rx) = mpsc::channel::(config.agents as usize); // JoinSet for respawn tasks so shutdown can abort them. let mut respawn_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); + let mut parked_retirements = HashMap::new(); + let (control_tx, mut control_rx) = + mpsc::unbounded_channel::(); + let mut result_publications = tokio::task::JoinSet::new(); + let mut orphan_reconciliation = session_controls::OrphanReconciliation::default(); let (wake_tx, mut wake_rx) = mpsc::channel::<(u32, Result)>(1); let mut wake_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); @@ -3013,15 +3132,8 @@ async fn run_harness( // Cleared on re-add (KIND_MEMBER_ADDED_NOTIFICATION) so re-joined channels // regain session affinity. // - // Known limitation: if a batch is in-flight when the channel is removed AND - // re-added before the batch returns, the stale batch may be requeued. This - // is acceptable because: (a) the agent is a member again and has access, - // (b) the events are from the agent's authorized history, (c) the window - // is extremely narrow (membership changes are rare, prompt turns are seconds), - // and (d) fixing this would require per-channel epoch tracking on TaskMeta - // and PromptResult — significant complexity for a benign edge case. If strict - // causal invalidation is needed, add a monotonic epoch counter per channel - // and capture it in TaskMeta at dispatch time. + // The control dispatch epoch also fences remove/re-add while a task is + // checked out; a late result cannot requeue revoked work into the rejoin. let mut removed_channels: HashSet = HashSet::new(); // @@ -3043,6 +3155,8 @@ async fn run_harness( // borrow, yielding a typed enum so the outer code can dispatch cleanly. enum PoolEvent { Result(Box), + Control(Box), + Status(Box), Panic(tokio::task::JoinError), SteerAck(SteerAckEvent), Wake(u32, Result), @@ -3050,7 +3164,38 @@ async fn run_harness( Recovery(recovery_wake::RecoveryWake), } + let (status_tx, mut status_rx) = mpsc::unbounded_channel(); loop { + status_tasks::dispatch(&mut pool, &ctx, &status_tx); + control_tasks::dispatch(&mut pool, &ctx, &control_tx); + while let Some(Some(result)) = result_publications.join_next().now_or_never() { + if let Err(error) = result { + tracing::warn!(%error, "control result publication task failed; durable outcome retained"); + } + } + let publication_capacity = 128usize.saturating_sub(result_publications.len()); + orphan_reconciliation.drive(&ctx.session_controls, publication_capacity); + worker_retirement::drive( + &mut pool, + &ctx.session_controls, + &mut parked_retirements, + config, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + observer.clone(), + ); + for publication in ctx + .session_controls + .take_publications_up_to(128usize.saturating_sub(result_publications.len())) + { + let rest = ctx.rest_client.clone(); + result_publications.spawn(async move { + if let Err(error) = session_controls::publish::publish_result(&rest, &publication).await { + tracing::warn!(command = %publication.command_id, %error, "session control result delivery failed; durable outcome retained"); + } + }); + } // Whether buffered work is waiting on a lazy pool. Also gates the // retry-deadline sleep arm below: a `Failed` lifecycle keeps its // (possibly past) `retry_at` until the next wake, so sleeping on it @@ -3058,7 +3203,7 @@ async fn run_harness( // busy spin — whenever the queued work drained after a failed wake. let mut lazy_wake_work_pending = false; if config.lazy_pool && !pool_ready { - lazy_wake_work_pending = queue.has_flushable_work(); + lazy_wake_work_pending = control_tasks::wake_pending(&mut queue, &ctx.session_controls); if let Some(attempt) = pool_lifecycle .start_wake_if_due(lazy_wake_work_pending, tokio::time::Instant::now()) { @@ -3165,6 +3310,23 @@ async fn run_harness( tracing::info!("shutting down"); break; } + Some(result) = status_rx.recv() => Some(PoolEvent::Status(Box::new(result))), + Some(result) = control_rx.recv() => Some(PoolEvent::Control(Box::new(result))), + _ = async { + match [ctx.session_controls.compaction_deadline(), ctx.session_controls.status_deadline()].into_iter().flatten().min() { + Some(deadline) => tokio::time::sleep_until(deadline).await, + None => std::future::pending().await, + } + } => None, + Some(result) = result_publications.join_next(), if !result_publications.is_empty() => { + let _ = result_rx; + if let Err(error) = result { tracing::warn!(%error, "control result publication task failed; durable outcome retained"); } + None + } + _ = orphan_reconciliation.wake() => { + let _ = result_rx; + None + } wake = recovery_wake::wait(&mut respawn_rx, retry_at, maintenance_at) => { Some(PoolEvent::Recovery(wake)) } @@ -3255,6 +3417,7 @@ async fn run_harness( observer.as_ref(), owner_hex, relay.event_publisher(), + &ctx.session_controls, ); } else { tracing::warn!("observer control frame received but no owner resolved — dropping"); @@ -3354,6 +3517,14 @@ async fn run_harness( // complete normally (the relay may reject actions if // the agent lost access). let drained_ids = queue.drain_channel(ch); + if let Err(error) = ctx.session_controls.revoke_channel(ch) { + tracing::warn!(%error, "session control revocation failed"); + } + pool.discard_durable_channel(ch); + pool::session_visibility::retire_channel( + ctx.session_status.as_ref(), + ch, + ); let invalidated = if pool_ready { pool.invalidate_channel_sessions(ch) } else { @@ -3453,83 +3624,9 @@ async fn run_harness( .await, &buzz_event.event, ); - let background_pending = cancel_background_collection_for_scope( - &mut pool, &mut queue, &scope, - ); - let fired = signal_in_flight_task_for_scope( - &mut pool, - &scope, - ControlSignal::Cancel, - ); - if !fired && !background_pending { - tracing::warn!( - channel_id = %buzz_event.channel_id, - scope = %scope.telemetry_label(), - "!cancel received but no in-flight task — no-op" - ); - } - continue; // consume event — do NOT push to queue - } - // Not from owner — fall through to normal prompt handling. - } - - // Mirrors !shutdown / !cancel: kind:9, content - // "!rotate", from owner, mentions THIS agent. - // - // Rotation is explicit owner intent to start the - // next turn in this channel with a fresh ACP - // session. It is consumed by the harness and never - // forwarded to the agent. If a turn is in-flight, - // cancel it, drop its triggering batch, and - // invalidate the channel session when the task - // returns. If idle, invalidate the cached channel - // session immediately. Queued future events remain - // queued and will create a fresh session on dispatch. - let is_rotate = is_owner_control_command( - &buzz_event.event, - kind_u32, - "!rotate", - &pubkey_hex, - ); - if is_rotate { - let from_owner = owner_cache.get().is_some_and(|owner| { - buzz_event.event.pubkey.to_hex() == *owner - }); - if from_owner { - // Scope-exact: rotate only the thread the - // owner's !rotate belongs to. Under the - // default channel policy the scope is the - // channel's sole conversation, matching the - // prior channel-wide rotate. - let scope = scope::SessionScope::derive( - config.session_policy, - buzz_event.channel_id, - is_dm_channel(buzz_event.channel_id, &ctx.channel_info) - .await, - &buzz_event.event, - ); - queue.remove_continuation(&scope); - let fired = signal_in_flight_task_for_scope( - &mut pool, - &scope, - ControlSignal::Rotate, + handle_owner_cancel_for_scope( + &mut pool, &mut queue, &ctx.session_controls, &scope, observer.as_ref(), ); - if fired { - tracing::info!( - channel_id = %buzz_event.channel_id, - scope = %scope.telemetry_label(), - "!rotate received — cancelling in-flight turn and rotating session" - ); - } else { - let invalidated = - pool.invalidate_scope_session(&scope); - tracing::info!( - channel_id = %buzz_event.channel_id, - scope = %scope.telemetry_label(), - invalidated, - "!rotate received — invalidated idle session for scope" - ); - } continue; // consume event — do NOT push to queue } // Not from owner — fall through to normal prompt handling. @@ -3559,6 +3656,53 @@ async fn run_harness( else { continue; }; + let authorized_event = match session_controls::admit_authorized( + authorized_event, &ctx, nostr::Timestamp::now().as_secs(), + ).await { + session_controls::Admission::Normal(event) => event, + session_controls::Admission::Ignored => continue, + session_controls::Admission::Refused { event, origin, failure } => { + tracing::warn!(command = %event.id, actor = %origin.actor_pubkey, code = failure.as_wire(), "session control refused"); + ctx.session_controls.queue_result(event.id.to_hex(), buzz_core::session_control::ControlResult::Terminal(Err(failure))); + continue; + } + session_controls::Admission::Control(job) => { + if matches!(job.command, buzz_core::session_control::SessionCommand::Restart(_)) { + let scope = job.scope.clone(); + let command_id = job.origin.command_event_id.clone(); + match ctx.session_controls.restart(job) { + Ok(true) => { + queue.remove_continuation(&scope); + typing_channels.remove(&scope); + pool::session_visibility::retire_scope(ctx.session_status.as_ref(), &scope); + let targets = pool.retirement_targets(&scope); + let waiting = targets.iter().any(|index| pool.retirement_waits_for_foreign(*index, &scope)); + for index in &targets { pool.mark_retire_on_return(*index); } + ctx.session_controls.plan_retirement(&scope, targets); + if waiting { ctx.session_controls.publish_pending(&scope); } + signal_in_flight_task_for_scope(&mut pool, &scope, ControlSignal::Rotate); + } + Ok(false) => { + if let Some(result) = ctx.session_controls.prior_result(&scope, &command_id) { ctx.session_controls.queue_result(command_id, result); } + } + Err(error) => { + use buzz_core::session_control::{ControlFailure, ControlResult}; + let failure = match error { session_store::StoreError::Busy => ControlFailure::Busy, session_store::StoreError::StaleRequest => ControlFailure::StaleRequest, _ => ControlFailure::StoreUnavailable }; + ctx.session_controls.queue_result(command_id, ControlResult::Terminal(Err(failure))); + tracing::warn!(%error, "session restart admission failed"); + } + } + } + else if matches!(job.command, buzz_core::session_control::SessionCommand::Compact) { + let command_id = job.origin.command_event_id.clone(); + let target = pool.compaction_target(&job.scope, &ctx.session_controls, config.session_resume); + if let Err(failure) = ctx.session_controls.queue_compaction(job, target) { + ctx.session_controls.queue_result(command_id, buzz_core::session_control::ControlResult::Terminal(Err(failure))); + } + } + continue; + } + }; let Some(ingress) = AuthorizedNormalListenerEvent(authorized_event) .match_subscription(&rules, &pubkey_hex) @@ -3604,6 +3748,7 @@ async fn run_harness( // decision. queued.steer_or_interrupt( config.multiple_event_handling, + &ctx.session_controls, owner_cache.get(), &mut pool, &mut queue, @@ -3639,7 +3784,7 @@ async fn run_harness( last_activity, tokio::time::Instant::now(), inactivity_bound, - queue.has_in_flight() || heartbeat_in_flight || pool.has_runtime_hold() + !ctx.session_controls.pending_scopes().is_empty() || queue.has_in_flight() || heartbeat_in_flight || pool.has_runtime_hold() || queue.has_undispatched_work(), ) { tracing::info!( @@ -3672,7 +3817,7 @@ async fn run_harness( idle_pool_sleep_bound, queue.has_in_flight() || heartbeat_in_flight, !pool.join_set.is_empty(), - queue.has_undispatched_work(), + queue.has_undispatched_work() || !ctx.session_controls.pending_scopes().is_empty(), !wake_tasks.is_empty() || any_respawn_in_flight(&crash_history), pool.has_runtime_hold(), @@ -3681,6 +3826,7 @@ async fn run_harness( idle_pool_sleep_seconds = config.idle_pool_sleep_secs, "idle pool sleep bound reached — tearing pool back to lazy state" ); + pool.depart_idle_sessions(session_store::Departure::IdleSleep); shutdown_agent_pool(&mut pool).await; // Return to the exact pre-wake lazy state: empty slots, // Listening lifecycle. The top-of-loop wake path re-wakes @@ -3688,6 +3834,7 @@ async fn run_harness( pool = AgentPool::from_slots( (0..config.agents).map(|_| None).collect(), ); + pool.set_session_store(session_store.clone()); pool_ready = false; pool_lifecycle = PoolLifecycle::listening(); last_activity = tokio::time::Instant::now(); @@ -3775,14 +3922,23 @@ async fn run_harness( match pool_event { Some(PoolEvent::Recovery(wake)) => { match wake { - recovery_wake::RecoveryWake::Respawn(rr) => { + recovery_wake::RecoveryWake::Respawn(mut rr) => { + if worker_retirement::finish( + &mut pool, + &ctx.session_controls, + &mut parked_retirements, + &mut rr, + ) { + continue; + } crash_history[rr.index].respawn_in_flight = false; match rr.result { Ok((acp, protocol_version, agent_name)) => { + pool.finish_worker_retirement(rr.index); let agent = OwnedAgent { index: rr.index, acp, - state: SessionState::default(), + state: SessionState::with_store(session_store.clone()), model_capabilities: None, desired_model: config.model.clone(), model_overridden: false, @@ -3820,6 +3976,52 @@ async fn run_harness( typing_channels.insert(scope, thread_tags); } } + Some(PoolEvent::Status(result)) => { + status_tasks::finish( + &mut pool, + &ctx, + *result, + &removed_channels, + config, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + observer.clone(), + &mut last_activity, + ); + for (scope, tags) in dispatch_pending( + &mut pool, + &mut queue, + &ctx, + &mut last_activity, + observer.as_ref(), + ) { + typing_channels.insert(scope, tags); + } + } + Some(PoolEvent::Control(result)) => { + control_tasks::finish( + &mut pool, + &ctx, + *result, + &removed_channels, + config, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + observer.clone(), + &mut last_activity, + ); + for (scope, tags) in dispatch_pending( + &mut pool, + &mut queue, + &ctx, + &mut last_activity, + observer.as_ref(), + ) { + typing_channels.insert(scope, tags); + } + } Some(PoolEvent::Result(result)) => { // Stop the typing indicator for the completed turn's exact scope, // not the whole channel — a sibling thread still running in the @@ -3839,6 +4041,7 @@ async fn run_harness( &mut respawn_tasks, observer.clone(), Some(&ctx.rest_client), + &mut last_activity, ) == LoopAction::Exit { break; @@ -3847,6 +4050,7 @@ async fn run_harness( &mut pool, &mut queue, config, + &ctx.session_controls, &mut heartbeat_in_flight, &removed_channels, &mut typing_channels, @@ -3854,14 +4058,11 @@ async fn run_harness( &respawn_tx, &mut respawn_tasks, observer.clone(), + Some(ctx.clone()), ) == LoopAction::Exit { break; } - // Quiet time starts when the turn finishes. Measuring it from - // dispatch made a turn longer than the sleep bound look idle - // on the next tick, and `killpg` then took its children with it. - last_activity = tokio::time::Instant::now(); for (scope, thread_tags) in dispatch_pending( &mut pool, &mut queue, @@ -3878,6 +4079,7 @@ async fn run_harness( &mut pool, &mut queue, config, + &ctx.session_controls, join_error, &mut heartbeat_in_flight, &removed_channels, @@ -3886,6 +4088,7 @@ async fn run_harness( &respawn_tx, &mut respawn_tasks, observer.clone(), + Some(ctx.clone()), ); if pool.live_count() == 0 && !any_respawn_in_flight(&crash_history) { tracing::error!("all agents dead — exiting"); @@ -3902,6 +4105,7 @@ async fn run_harness( } } Some(PoolEvent::SteerAck(SteerAckEvent { + control_dispatch, channel_id, scope, event_id, @@ -3982,6 +4186,16 @@ async fn run_harness( // pending_steer on every return path. If it does, // treat as PromptCompletedNeutral to avoid leaking // the withheld event in `withheld_native_steer`. + if control_dispatch + .as_ref() + .is_some_and(|dispatch| !dispatch.current()) + || ctx.session_controls.retirement_pending(&scope) + || ctx.session_controls.compact_pending(&scope) + || ctx.session_controls.status_pending(&scope) + { + queue.release_native_steer(&scope, &event_id); + continue; + } let (release_withheld, drop_withheld, signal_fallback) = match &ack { Ok(pool::SteerAck::Success { .. }) => (false, true, false), // -32601 = method_not_found: agent does not implement the @@ -4072,6 +4286,7 @@ async fn run_harness( pool = pool_lifecycle .take_ready() .expect("successful wake stores a ready pool"); + pool.set_session_store(session_store.clone()); pool_ready = true; emit_runtime_lifecycle( observer.as_ref(), @@ -4123,6 +4338,21 @@ async fn run_harness( } } + let status_scopes = ctx.session_controls.shutdown_statuses(); + let compact_workers = ctx.session_controls.shutdown_compactions(); + for meta in pool.task_map_mut().values_mut() { + if compact_workers.contains(&meta.agent_index) + || meta + .scope + .as_ref() + .is_some_and(|scope| status_scopes.contains(scope)) + { + if let Some(tx) = meta.control_tx.take() { + let _ = tx.send(ControlSignal::Cancel); + } + } + } + // Drain wake tasks gracefully rather than aborting: an in-flight // initialize_agent_pool observes the shutdown watch at its biased per-slot // select and reaps its partially-spawned agents itself. `shutdown()` here @@ -4167,6 +4397,11 @@ async fn run_harness( None => break, // join_set empty } } + Some(mut result) = status_rx.recv() => { result.agent.acp.shutdown().await; } + Some(mut result) = control_rx.recv() => { + ctx.session_controls.finish_compaction_job(&result.job, Err(buzz_core::session_control::ControlFailure::Unconfirmed)); + result.agent.acp.shutdown().await; + } maybe_result = rx_ref.recv() => { if let Some(mut pr) = maybe_result { let idx = pr.agent.index; @@ -4190,6 +4425,12 @@ async fn run_harness( pr.agent.acp.shutdown().await; tracing::debug!(agent = idx, "reaped late-arriving agent on shutdown"); } + while let Ok(mut result) = status_rx.try_recv() { + result.agent.acp.shutdown().await; + } + while let Ok(mut result) = control_rx.try_recv() { + result.agent.acp.shutdown().await; + } // Explicitly shut down idle agents still sitting in their slots. for slot in pool.agents_mut().iter_mut() { if let Some(agent) = slot.take() { @@ -4201,20 +4442,9 @@ async fn run_harness( } drop(pool); - // Abort any in-flight respawn tasks. They may be sleeping in backoff or - // running spawn_and_init — either way, we don't want them spawning new - // children after the main loop has exited. RespawnGuard::Drop sends a - // failure result for aborted tasks, so respawn_in_flight is cleared. - respawn_tasks.shutdown().await; - - // Drain any respawn results that completed before the abort. Explicitly - // shut down returned agents instead of relying on AcpClient::Drop. - while let Ok(rr) = respawn_rx.try_recv() { - if let Ok((mut acp, _, _)) = rr.result { - acp.shutdown().await; - tracing::debug!(agent = rr.index, "reaped respawned agent on shutdown"); - } - } + let _uncertain_retirements = + worker_retirement::shutdown(&mut parked_retirements, &mut respawn_tasks, &mut respawn_rx) + .await; // Cancel any in-flight presence heartbeat before sending offline. if let Some(h) = presence_task.take() { @@ -4325,6 +4555,14 @@ fn signal_in_flight_task( .find(|m| m.channel_id == Some(channel_id)); if let Some(meta) = entry { + if matches!(mode, ControlSignal::Steer | ControlSignal::Interrupt) + && meta + .control_dispatch + .as_ref() + .is_some_and(|dispatch| !dispatch.current()) + { + return false; + } if matches!(mode, ControlSignal::Steer | ControlSignal::Interrupt) && meta .recoverable_batch @@ -4345,6 +4583,39 @@ fn signal_in_flight_task( false } +// An uncertain cancellation cannot establish durable publication ownership. +// Keep its observer outcome and prior attempt record; this seam has no sender. +fn handle_owner_cancel_for_scope( + pool: &mut AgentPool, + queue: &mut EventQueue, + controls: &session_controls::SessionControlRuntime, + scope: &scope::SessionScope, + observer: Option<&observer::ObserverHandle>, +) -> session_controls::status::StatusCancellation { + let cancellation = controls.cancel_status_outcome(scope); + let status_pending = cancellation != session_controls::status::StatusCancellation::None; + if cancellation == session_controls::status::StatusCancellation::Unconfirmed { + if let Some(observer) = observer { + observer.emit( + "control_result", + None, + &observer::ObserverContext { + channel_id: Some(scope.channel_id().to_string()), + ..Default::default() + }, + serde_json::json!({"type":"cancel_turn", "status":"unconfirmed", "requestId":null}), + ); + } + } + let background_pending = cancel_background_collection_for_scope(pool, queue, scope); + let fired = signal_in_flight_task_for_scope(pool, scope, ControlSignal::Cancel); + if !fired && !background_pending && !status_pending { + tracing::warn!(channel_id = %scope.channel_id(), scope = %scope.telemetry_label(), + "!cancel received but no in-flight task — no-op"); + } + cancellation +} + /// Revoke background collection independently of the task's one-shot sender. /// Clearing its recoverable token also suppresses a late cancellation result; /// an idle scope with no background work retains its existing session. @@ -4484,6 +4755,17 @@ fn try_native_steer( ack_tx, }; + let control_dispatch = pool + .task_map() + .values() + .find(|meta| meta.scope.as_ref() == Some(&scope)) + .and_then(|meta| meta.control_dispatch.clone()); + if control_dispatch + .as_ref() + .is_some_and(|dispatch| !dispatch.current()) + { + return false; + } match pool.send_steer(&scope, request) { Ok(()) => { // Withhold the queued event synchronously BEFORE spawning @@ -4513,6 +4795,7 @@ fn try_native_steer( tokio::spawn(async move { let ack = ack_rx.await; let _ = ack_tx_clone.send(SteerAckEvent { + control_dispatch, channel_id, scope: scope_for_watcher, event_id: event_id_for_watcher, @@ -4689,6 +4972,7 @@ mod try_native_steer_fallback_log_tests { recoverable_batch: None, control_tx: None, steer_tx, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -4870,6 +5154,13 @@ fn dispatch_pending( }; let channel_id = batch.channel_id; let scope = batch.scope.clone(); + if ctx.session_controls.retirement_pending(&scope) + || ctx.session_controls.status_pending(&scope) + || (ctx.session_controls.compact_pending(&scope) && batch.continuation.is_none()) + { + held.push(batch); + continue; + } if let Some(continuation) = &batch.continuation { match pool.continuation_state(&scope, continuation) { pool::ContinuationState::Ready => {} @@ -5000,6 +5291,7 @@ fn dispatch_pending( None }; + agent.state.control_dispatch = Some(ctx.session_controls.capture(&scope)); let result_tx = pool.result_tx(); let ctx_clone = Arc::clone(ctx); let agent_index = agent.index; @@ -5022,6 +5314,7 @@ fn dispatch_pending( let (control_tx, control_rx) = tokio::sync::oneshot::channel::(); let turn_id = Uuid::new_v4().to_string(); let task_turn_id = turn_id.clone(); + let control_dispatch = agent.state.control_dispatch.clone(); let abort_handle = pool.join_set.spawn(async move { pool::run_prompt_task( @@ -5046,6 +5339,7 @@ fn dispatch_pending( recoverable_batch, control_tx: Some(control_tx), steer_tx, + control_dispatch, successful_steer_deliveries: HashSet::new(), }, ); @@ -5175,7 +5469,30 @@ fn handle_prompt_result( respawn_tasks: &mut tokio::task::JoinSet<()>, observer: Option, rest_client: Option<&relay::RestClient>, + last_activity: &mut tokio::time::Instant, ) -> LoopAction { + // Quiet time starts when the turn finishes. Measuring it from dispatch + // made a turn longer than the sleep bound look idle on the next tick, and + // `killpg` then took its children with it. Refresh before anything else, + // in particular before the in-flight guard is released below. + if result_refreshes_idle_clock(&result.source) { + *last_activity = tokio::time::Instant::now(); + } + if result + .agent + .state + .control_dispatch + .as_ref() + .is_some_and(|dispatch| !dispatch.current()) + { + result.agent.state.invalidate(&result.source); + result.agent.background_context = None; + result.batch = None; + result.outcome = result.outcome.discard_retired_output(); + } + if let Some(dispatch) = result.agent.state.control_dispatch.take() { + dispatch.finish(); + } let before = pool.task_map().len(); let agent_index = result.agent.index; // Continuations always have a recovery token at dispatch. Explicit owner @@ -5425,6 +5742,11 @@ fn handle_prompt_result( } }; + if pool.needs_retirement(&result.agent) { + pool.return_for_retirement(result.agent); + return LoopAction::Continue; + } + match result.outcome { // Successful prompt — return agent to pool. PromptOutcome::Ok(_) => { @@ -5617,6 +5939,7 @@ fn recover_panicked_agent( pool: &mut AgentPool, queue: &mut EventQueue, config: &Config, + controls: &session_controls::SessionControlRuntime, join_error: tokio::task::JoinError, heartbeat_in_flight: &mut bool, removed_channels: &HashSet, @@ -5625,13 +5948,34 @@ fn recover_panicked_agent( respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, observer: Option, + status_feedback: Option>, ) { let task_id = join_error.id(); - let Some(meta) = pool.task_map_mut().remove(&task_id) else { + let Some(mut meta) = pool.task_map_mut().remove(&task_id) else { tracing::error!("panic for unknown task {task_id:?} — bug"); return; }; + let status_token = meta + .control_dispatch + .as_ref() + .and_then(|dispatch| dispatch.intent.as_ref().map(|intent| intent.token)); + if let Some(dispatch) = meta.control_dispatch.take() { + if !dispatch.current() { + meta.recoverable_batch = None; + } + dispatch.finish(); + } let i = meta.agent_index; + controls.compaction_panicked(i); + if let (Some(scope), Some(token)) = (&meta.scope, status_token) { + if let (Some(ctx), Some(job)) = (status_feedback, controls.status_panicked(scope, token)) { + let dispatch = controls.capture(scope); + pool.join_set + .spawn(pool::restart_status::report_expired(job, ctx, dispatch)); + } else { + controls.cancel_status_for_token(scope, token); + } + } pool.clear_runtime_hold(i); // Requeue BEFORE mark_complete (same rationale as handle_prompt_result). @@ -5687,6 +6031,19 @@ fn recover_panicked_agent( ); } + if controls.owns_retirement(i) { + // run_prompt_task's client has already unwound. The existing completion, + // liveness and reaction guards retain no process ownership or snapshot. + pool.mark_retire_on_return(i); + controls.lose_retirement_evidence(i); + crash_history[i].respawn_in_flight = true; + tracing::error!( + agent = i, + "foreground panic lost original retirement evidence; gate remains unconfirmed" + ); + return; + } + // Panics count as crashes for the circuit breaker. // The panicked task already dropped the AcpClient, so we just need to // check the circuit and spawn a fresh agent in the background. @@ -5732,6 +6089,7 @@ fn drain_ready_join_results( pool: &mut AgentPool, queue: &mut EventQueue, config: &Config, + controls: &session_controls::SessionControlRuntime, heartbeat_in_flight: &mut bool, removed_channels: &HashSet, typing_channels: &mut HashMap, @@ -5739,6 +6097,7 @@ fn drain_ready_join_results( respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, observer: Option, + status_feedback: Option>, ) -> LoopAction { while let Some(Some(join_result)) = pool.join_set.join_next().now_or_never() { if let Err(join_error) = join_result { @@ -5747,6 +6106,7 @@ fn drain_ready_join_results( pool, queue, config, + controls, join_error, heartbeat_in_flight, removed_channels, @@ -5755,6 +6115,7 @@ fn drain_ready_join_results( respawn_tx, respawn_tasks, observer.clone(), + status_feedback.clone(), ); if pool.live_count() == 0 && !any_respawn_in_flight(crash_history) { return LoopAction::Exit; @@ -5772,7 +6133,7 @@ fn dispatch_heartbeat( if *heartbeat_in_flight { return; } - let agent = match pool.try_claim(None) { + let mut agent = match pool.try_claim(None) { Some(a) => a, None => return, }; @@ -5781,6 +6142,7 @@ fn dispatch_heartbeat( .heartbeat_prompt .clone() .unwrap_or_else(default_heartbeat_prompt); + agent.state.control_dispatch = None; let result_tx = pool.result_tx(); let ctx_clone = Arc::clone(ctx); let agent_index = agent.index; @@ -5810,6 +6172,7 @@ fn dispatch_heartbeat( recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -6464,7 +6827,7 @@ fn build_mcp_servers(config: &Config) -> Vec { } } for (name, value) in &config.persona_env_vars { - if git::is_managed_env(name) { + if git::is_managed_env(name) || name == session_status::SESSION_STATUS_DIR_ENV { env.retain(|entry| entry.name != *name); env.push(EnvVar { name: name.clone(), @@ -6622,6 +6985,7 @@ mod owner_control_command_tests { recoverable_batch: None, control_tx: Some(control_tx), steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -6668,6 +7032,7 @@ mod owner_control_command_tests { recoverable_batch: None, control_tx: Some(control_tx), steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -9718,6 +10083,8 @@ mod build_mcp_servers_tests { config_path: std::path::PathBuf::from("./buzz-acp.toml"), context_message_limit: 12, max_turns_per_session: 0, + session_store_dir: None, + session_resume: true, presence_enabled: true, typing_enabled: true, memory_enabled: false, @@ -10265,6 +10632,7 @@ mod edit_native_steer_tests { recoverable_batch: None, control_tx: Some(control_tx), steer_tx: Some(steer_tx), + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -10285,6 +10653,7 @@ mod edit_native_steer_tests { .push(&mut queue, scope.clone()) .steer_or_interrupt( MultipleEventHandling::Steer, + &Default::default(), None, &mut pool, &mut queue, @@ -10380,7 +10749,7 @@ mod error_outcome_emission_tests { use nostr::{EventBuilder, Keys, Kind}; use std::collections::HashSet; - fn test_config() -> Config { + pub(super) fn test_config() -> Config { Config { keys: nostr::Keys::generate(), relay_url: "ws://localhost:3000".into(), @@ -10410,6 +10779,8 @@ mod error_outcome_emission_tests { config_path: std::path::PathBuf::from("./buzz-acp.toml"), context_message_limit: 12, max_turns_per_session: 0, + session_store_dir: None, + session_resume: true, presence_enabled: true, typing_enabled: true, memory_enabled: false, @@ -10483,6 +10854,89 @@ mod error_outcome_emission_tests { agent.state.set_scope_owner_generation(scope, generation); } + /// Runs one successful result through `handle_prompt_result` with an idle + /// clock last refreshed an hour ago. Returns the stale reading, the clock + /// after handling, and whether a heartbeat is still marked in flight. + async fn idle_clock_after_result( + source: PromptSource, + mut heartbeat_in_flight: bool, + ) -> (tokio::time::Instant, tokio::time::Instant, bool) { + let scope = source.scope().cloned(); + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: source.channel_id(), + scope, + turn_id: "idle-clock-turn".to_string(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + control_dispatch: None, + successful_steer_deliveries: HashSet::new(), + }, + ); + let mut queue = EventQueue::new(config::DedupMode::Queue); + let config = test_config(); + let removed_channels = HashSet::new(); + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let result = PromptResult { + agent: dummy_agent(0).await, + source, + turn_id: "idle-clock-turn".to_string(), + outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), + batch: None, + }; + let stale = tokio::time::Instant::now() - Duration::from_secs(3600); + let mut last_activity = stale; + handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat_in_flight, + &removed_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + None, + &mut last_activity, + ); + (stale, last_activity, heartbeat_in_flight) + } + + #[tokio::test] + async fn channel_result_refreshes_idle_clock_wiring() { + // Guards the call inside handle_prompt_result itself, not only the + // result_refreshes_idle_clock mapper: dropping the call fails here. + let channel_id = Uuid::new_v4(); + let (stale, last_activity, _) = idle_clock_after_result( + PromptSource::Channel(scope::SessionScope::Conversation { channel_id }), + false, + ) + .await; + assert!(last_activity > stale); + } + + #[tokio::test] + async fn heartbeat_result_leaves_idle_clock_stale() { + // A pool kept alive only by its own heartbeats must still be able to + // sleep, so heartbeat completions leave the clock untouched. + let (stale, last_activity, heartbeat_in_flight) = + idle_clock_after_result(PromptSource::Heartbeat, true).await; + assert_eq!(last_activity, stale); + assert!(!heartbeat_in_flight); + } + #[tokio::test] async fn successful_native_steer_is_transferred_to_live_session_delivery_state() { let channel_id = Uuid::new_v4(); @@ -10514,6 +10968,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::from([ crate::pool::SuccessfulSteerDelivery { event_id: steer_event_id.into(), @@ -10554,6 +11009,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + &mut tokio::time::Instant::now(), ); let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); @@ -10594,6 +11050,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::from([ crate::pool::SuccessfulSteerDelivery { event_id: "stale-event".into(), @@ -10634,6 +11091,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + &mut tokio::time::Instant::now(), ); let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); @@ -10716,6 +11174,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::from([ crate::pool::SuccessfulSteerDelivery { event_id: "stale-event".into(), @@ -10755,6 +11214,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + &mut tokio::time::Instant::now(), ); let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); @@ -10785,6 +11245,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -10824,6 +11285,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), None, + &mut tokio::time::Instant::now(), ); let turn_errors: Vec<_> = observer @@ -10865,6 +11327,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -10890,6 +11353,7 @@ mod error_outcome_emission_tests { &mut pool, &mut queue, &config, + &Default::default(), join_error, &mut heartbeat_in_flight, &removed_channels, @@ -10898,6 +11362,7 @@ mod error_outcome_emission_tests { &respawn_tx, &mut respawn_tasks, Some(observer.clone()), + None, ); let panic = observer @@ -10958,6 +11423,7 @@ mod error_outcome_emission_tests { recoverable_batch: Some(batch), control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -10983,6 +11449,7 @@ mod error_outcome_emission_tests { &mut pool, &mut queue, &config, + &Default::default(), join_error, &mut heartbeat_in_flight, &removed_channels, @@ -10991,6 +11458,7 @@ mod error_outcome_emission_tests { &respawn_tx, &mut respawn_tasks, None, + None, ); // The exact Thread scope is freed and the requeued batch is flushable @@ -11057,6 +11525,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -11093,6 +11562,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), None, + &mut tokio::time::Instant::now(), ); let events = observer.snapshot(); let turn_error = events.iter().find(|e| e.kind == "turn_error").unwrap(); @@ -11156,6 +11626,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -11189,6 +11660,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + &mut tokio::time::Instant::now(), ); ( queue.pending_channels(), @@ -11266,6 +11738,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -11299,6 +11772,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + &mut tokio::time::Instant::now(), ); ( queue.pending_channels(), @@ -11344,6 +11818,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -11395,6 +11870,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), None, + &mut tokio::time::Instant::now(), ); let events = observer.snapshot(); @@ -11443,6 +11919,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -11493,6 +11970,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), None, + &mut tokio::time::Instant::now(), ); let events = observer.snapshot(); @@ -11567,6 +12045,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -11615,6 +12094,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), None, + &mut tokio::time::Instant::now(), ); // Batch preserved as a cancelled merge, not dead-lettered — same @@ -11710,6 +12190,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -11751,6 +12232,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), None, + &mut tokio::time::Instant::now(), ); // No batch to merge — the queue has nothing pending for any channel. @@ -11846,6 +12328,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -11883,6 +12366,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + &mut tokio::time::Instant::now(), ), LoopAction::Continue )); @@ -12003,6 +12487,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -12036,6 +12521,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + &mut tokio::time::Instant::now(), ); // The batch must not be requeued: pending_channels returns 0. @@ -12112,6 +12598,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -12145,6 +12632,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), Some(&rest), + &mut tokio::time::Instant::now(), ); // The batch must not be requeued: pending_channels returns 0. @@ -12384,6 +12872,7 @@ mod error_outcome_emission_tests { recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -12417,6 +12906,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + &mut tokio::time::Instant::now(), ); // Non-auth application error: batch IS requeued (first attempt, retry budget > 0). diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 0c7d2124c70..b2e3e37c108 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -58,9 +58,6 @@ const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(60); /// behavior is preferable to an ever-growing channel-policy session ledger. const MAX_HYDRATED_THREAD_ROOTS_PER_SCOPE: usize = 1024; -// FlushBatch and BatchEvent derive Clone (added in queue.rs) so we can store -// a recoverable copy in TaskMeta for panic recovery in Queue mode. - /// Metadata stored per in-flight task for panic recovery. #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct SuccessfulSteerDelivery { @@ -92,6 +89,7 @@ pub struct TaskMeta { /// Successful non-cancelling steers acknowledged while this task owned the /// live session. The session ID prevents a late ack from contaminating a /// replacement session after task return. + pub(crate) control_dispatch: Option, pub successful_steer_deliveries: HashSet, } @@ -132,6 +130,8 @@ pub struct ChannelDeliveryState { /// spawning a real agent subprocess. #[derive(Default)] pub struct SessionState { + /// Task-captured restart generation and token; independent of live lease. + pub(crate) control_dispatch: Option, /// session scope → session_id pub sessions: HashMap, pub heartbeat_session: Option, @@ -161,6 +161,12 @@ pub struct SessionState { /// worker becomes claimable again. scope_owner_generations: HashMap, background_rotation_pending: HashSet, + /// Durable session records; `None` disables resume. + pub(crate) store: Option>, + /// Lease of every durable record this state owns, by scope. + leases: HashMap, + /// Status generation of every session this worker acquired, by scope. + pub(crate) status: HashMap, } impl SessionState { @@ -185,6 +191,7 @@ impl SessionState { /// Invalidate a single session scope's session and turn counter. /// Returns `true` if the scope had an active session. pub fn invalidate_scope(&mut self, scope: &SessionScope) -> bool { + self.discard_durable(scope); self.background_rotation_pending.remove(scope); self.turn_counts.remove(scope); self.core_sections.remove(scope); @@ -206,6 +213,7 @@ impl SessionState { .chain(self.canvas_sections.keys()) .chain(self.deliveries.keys()) .chain(self.scope_owner_generations.keys()) + .chain(self.status.keys()) .filter(|s| s.channel_id() == *channel_id) .cloned() .collect::>() @@ -221,7 +229,12 @@ impl SessionState { } /// Invalidate all sessions and turn counters (e.g. after agent exit). + /// + /// Every caller handles a dead or unrecoverable worker process, so the + /// provider sessions themselves stay resumable: their durable records are + /// marked departed rather than discarded. pub fn invalidate_all(&mut self) { + self.depart_durable_all(crate::session_store::Departure::WorkerCrash); self.sessions.clear(); self.background_rotation_pending.clear(); self.turn_counts.clear(); @@ -241,6 +254,14 @@ impl SessionState { event_ids: impl IntoIterator, hydrated_thread_roots: impl IntoIterator, ) { + let event_ids: Vec = event_ids.into_iter().collect(); + let hydrated_thread_roots: Vec = hydrated_thread_roots.into_iter().collect(); + self.persist_delivery( + &scope, + standing_context_sent, + &event_ids, + &hydrated_thread_roots, + ); let delivery = self.deliveries.entry(scope).or_default(); delivery.standing_context_sent |= standing_context_sent; delivery.delivered_event_ids.extend(event_ids); @@ -316,6 +337,12 @@ pub struct OwnedAgent { mod background_work; pub(crate) use background_work::{ContinuationState, RuntimeHold}; +pub(crate) mod compaction; +mod first_mention; +mod session_continuity; +mod session_history; +pub(crate) use session_continuity::{open_session_store, SessionStart}; +pub(crate) mod session_visibility; /// Package name reported by `claude-agent-acp` in its `initialize` response. /// Any adapter reporting this name supports `_meta.systemPrompt: {append: ...}` @@ -385,6 +412,8 @@ pub struct AgentPool { /// Best-effort: stale entries (rotation, crash/respawn) self-heal on the /// next dispatch and are pruned on channel-wide session invalidation. session_owners: HashMap, + scope_workers: HashMap>, + retire_on_return: HashSet, /// Monotonic validity fence assigned whenever a scope is dispatched. The /// generation distinguishes a newly forked owner from every older copy of /// that scope's provider session. @@ -399,6 +428,8 @@ pub struct AgentPool { /// children. Keyed by slot index. Cleared when the slot is checked out /// for a real prompt or when the children exit and a follow-up is sent. runtime_holds: HashMap, + /// Durable session records, independent of the slots (empty after sleep). + store: Option>, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -747,6 +778,31 @@ pub enum PromptOutcome { CancelDrainTimeout(Duration), } +impl PromptOutcome { + /// Remove obsolete task output without erasing worker-health evidence needed + /// by the main loop's existing crash/transport recovery paths. + pub(crate) fn discard_retired_output(self) -> Self { + match self { + Self::Error(AcpError::AgentExited) => Self::AgentExited, + Self::Error(AcpError::IdleTimeout(_)) => Self::Timeout(TimeoutKind::Idle), + Self::Error(AcpError::HardTimeout { silence }) => Self::Timeout(TimeoutKind::Hard { + recently_active: silence < RECENT_ACTIVITY_WINDOW, + }), + Self::Error(AcpError::CancelDrainTimeout(grace)) => Self::CancelDrainTimeout(grace), + Self::AgentExited + | Self::Timeout(_) + | Self::CancelDrainTimeout(_) + | Self::Error( + AcpError::Io(_) + | AcpError::WriteTimeout(_) + | AcpError::Timeout(_) + | AcpError::Protocol(_), + ) => self, + _ => Self::Cancelled, + } + } +} + /// Immutable config subset shared (via `Arc`) by all spawned prompt tasks. /// /// Built once from `Config` at startup. Avoids cloning the full config @@ -952,6 +1008,11 @@ pub struct PromptContext { /// the desktop keys per (agent, relay) pair, e.g. `session_config_captured`, /// mirroring the `managed_agent_runtime_lifecycle` frames. pub relay_url: String, + /// Configured execution policy, also used to validate display-scope provenance. + pub session_policy: crate::scope::SessionPolicy, + /// Per-scope session status snapshots; `None` disables status publication. + pub(crate) session_status: Option, + pub(crate) session_controls: crate::session_controls::SessionControlRuntime, } impl AgentPool { @@ -970,9 +1031,12 @@ impl AgentPool { join_set: JoinSet::new(), task_map: HashMap::new(), session_owners: HashMap::new(), + scope_workers: HashMap::new(), + retire_on_return: HashSet::new(), next_scope_owner_generation: 1, held_since: HashMap::new(), runtime_holds: HashMap::new(), + store: None, } } @@ -990,6 +1054,10 @@ impl AgentPool { self.next_scope_owner_generation = 1; self.session_owners.clear(); } + self.scope_workers + .entry(scope.clone()) + .or_default() + .insert(agent_index); self.session_owners.insert( scope, SessionOwner { @@ -1098,8 +1166,10 @@ impl AgentPool { // Pass 2: first idle agent that is not waiting on background children. // Stealing that worker would kill the process group under it. let idx = self.agents.iter().position(|slot| { - slot.as_ref() - .is_some_and(|agent| !self.runtime_holds.contains_key(&agent.index)) + slot.as_ref().is_some_and(|agent| { + !self.runtime_holds.contains_key(&agent.index) + && !self.retire_on_return.contains(&agent.index) + }) }); idx.map(|i| self.agents[i].take().unwrap()) } @@ -1120,7 +1190,11 @@ impl AgentPool { "discarding stale session after ownership changed" ); agent.state.invalidate_scope(&scope); + if let Some(workers) = self.scope_workers.get_mut(&scope) { + workers.remove(&agent.index); + } } + self.scope_workers.retain(|_, workers| !workers.is_empty()); let live_scopes: HashSet = agent.state.sessions.keys().cloned().collect(); agent .state @@ -1137,6 +1211,9 @@ impl AgentPool { "BUG: return_agent called for slot {idx} which is already occupied — overwriting" ); } + agent + .acp + .retain_compaction_sessions(&agent.state.sessions.values().cloned().collect()); self.refresh_runtime_hold(&mut agent); self.agents[idx] = Some(agent); } @@ -1370,6 +1447,9 @@ impl AgentPool { if let Some(agent) = slot.as_mut() { // Channel-wide: clears every child thread scope for the channel. count += agent.state.invalidate_channel(&channel_id); + agent + .acp + .retain_compaction_sessions(&agent.state.sessions.values().cloned().collect()); } } // Drop every scope-owner entry for this channel so the directory does @@ -1377,6 +1457,8 @@ impl AgentPool { // owner after the channel's sessions are gone. self.session_owners .retain(|scope, _| scope.channel_id() != channel_id); + self.scope_workers + .retain(|scope, _| scope.channel_id() != channel_id); // Prune held-since stamps for the same channel so an expiring hold cannot // reference a scope whose sessions are gone. self.held_since @@ -1399,9 +1481,13 @@ impl AgentPool { if agent.state.invalidate_scope(scope) { count += 1; } + agent + .acp + .retain_compaction_sessions(&agent.state.sessions.values().cloned().collect()); } } self.session_owners.remove(scope); + self.scope_workers.remove(scope); self.held_since.remove(scope); count } @@ -1605,6 +1691,7 @@ async fn resolve_new_session_channel_context( /// On error from `session_new_full()`, returns the `AcpError` — caller handles /// error reporting. Model-switch failures are logged and gracefully ignored /// (the agent proceeds with its default model). +#[derive(Clone, Copy)] struct NewSessionChannelContext<'a> { huddle_instructions: Option<&'a str>, canvas: Option<&'a str>, @@ -1618,6 +1705,19 @@ async fn create_session_and_apply_model( ctx: &PromptContext, agent_core: Option<&str>, channel: NewSessionChannelContext<'_>, +) -> Result { + create_or_resume_session_and_apply_model(agent, ctx, agent_core, channel, None).await +} + +/// [`create_session_and_apply_model`], or with `resume` set, `session/resume` +/// of that earlier session with the same request parameters; model, effort, +/// and permission mode are applied to the result either way. +async fn create_or_resume_session_and_apply_model( + agent: &mut OwnedAgent, + ctx: &PromptContext, + agent_core: Option<&str>, + channel: NewSessionChannelContext<'_>, + resume: Option<&str>, ) -> Result { // Build base_prompt + system_prompt + agent core + canvas metadata into a // single prompt. Standard protocol-v2 agents receive it in `session/new`; @@ -1658,20 +1758,37 @@ async fn create_session_and_apply_model( ctx.session_title.as_deref(), ); - let resp = agent - .acp - .session_new_full( - &ctx.cwd, - mcp_servers, - session_new_system_prompt( - is_goose, - agent.protocol_version, - &agent.agent_name, - combined_system_prompt.as_deref(), - ), - session_title.as_deref(), - ) - .await?; + let system_prompt = session_new_system_prompt( + is_goose, + agent.protocol_version, + &agent.agent_name, + combined_system_prompt.as_deref(), + ); + let resp = match resume { + Some(session_id) => { + agent + .acp + .session_resume( + session_id, + &ctx.cwd, + mcp_servers, + system_prompt, + session_title.as_deref(), + ) + .await? + } + None => { + agent + .acp + .session_new_full( + &ctx.cwd, + mcp_servers, + system_prompt, + session_title.as_deref(), + ) + .await? + } + }; if is_goose && agent.goose_system_prompt_supported != Some(false) { if let Some(prompt) = combined_system_prompt.as_deref() { @@ -2424,6 +2541,18 @@ fn send_prompt_result( outcome: PromptOutcome, batch: Option, ) { + let stale = agent + .state + .control_dispatch + .as_ref() + .is_some_and(|dispatch| !dispatch.current()); + let (outcome, batch) = if stale { + agent.state.invalidate(&source); + agent.background_context = None; + (outcome.discard_retired_output(), None) + } else { + (outcome, batch) + }; agent.acp.clear_steer_rx(); if !matches!(outcome, PromptOutcome::Ok(_)) && !agent.has_background_collection() { if let Some(context) = &mut agent.background_context { @@ -2464,6 +2593,11 @@ pub async fn run_prompt_task( Some(batch) => PromptSource::Channel(batch.scope.clone()), None => PromptSource::Heartbeat, }; + if let Some(scope) = source.scope() { + if agent.state.control_dispatch.is_none() { + agent.state.control_dispatch = Some(ctx.session_controls.capture(scope)); + } + } let prompt_text = batch .as_ref() .and_then(|batch| { @@ -2705,16 +2839,35 @@ pub async fn run_prompt_task( PromptSource::Heartbeat => None, }; + // Reuse this turn's resolved metadata; display eligibility must not change + // admission or the canonical provider scope, nor issue another REST lookup. + let status_runtime = match &source { + PromptSource::Channel(scope) + if agent.reconcile_session_status_scope( + ctx.session_status.as_ref(), + scope, + ctx.session_policy, + resolved_channel_info.as_ref(), + ) => + { + ctx.session_status.as_ref() + } + _ => None, + }; + + // How this turn's session came to be, when this turn created or resumed it. + let mut session_start: Option = None; let (session_id, is_new_session) = match &source { PromptSource::Channel(scope) => { let cid = &scope.channel_id(); - if let Some(sid) = agent.state.sessions.get(scope) { - (sid.clone(), false) + if let Some(sid) = agent.state.sessions.get(scope).cloned() { + agent.rebind_session_status(status_runtime, scope, &sid); + (sid, false) } else { // The title includes channel and, for thread sessions, the // canonical root prefix so sibling sessions are distinguishable. // DMs, unresolved, and unnamed channels omit the channel name. - match create_session_and_apply_model( + match session_continuity::acquire_session( &mut agent, &ctx, agent_core.as_deref(), @@ -2725,28 +2878,56 @@ pub async fn run_prompt_task( scope: Some(scope), channel_type: origin_channel_type.as_deref(), }, + scope, ) .await { - Ok(sid) => { + Ok((sid, start)) => { + if agent + .state + .control_dispatch + .as_ref() + .is_some_and(|dispatch| !dispatch.current()) + { + send_prompt_result( + &result_tx, + &turn_id, + agent, + source, + PromptOutcome::Cancelled, + None, + ); + return; + } tracing::info!( target: "pool::session", - "created session {sid} for channel {cid} (scope {})", - scope.telemetry_label() + "{} session {sid} for channel {cid} (scope {}, {})", + if start.is_resumed() { "resumed" } else { "created" }, + scope.telemetry_label(), + match &start { + SessionStart::New { reason } | SessionStart::Resumed { reason } => + reason.as_wire(), + } ); agent.state.sessions.insert(scope.clone(), sid.clone()); - agent - .state - .deliveries - .insert(scope.clone(), ChannelDeliveryState::default()); - // Seed a zero usage baseline: buzz-acp spawned this session - // so prior usage is zero by definition — first turn is reliable. - agent.acp.notify_session_spawned(&sid); + agent.publish_session_start(status_runtime, scope, &sid, &start); + let resumed = start.is_resumed(); + session_start = Some(start); + if !resumed { + agent + .state + .deliveries + .insert(scope.clone(), ChannelDeliveryState::default()); + // Seed a zero usage baseline: buzz-acp spawned this session + // so prior usage is zero by definition — first turn is reliable. + agent.acp.notify_session_spawned(&sid); + } // Commit canvas only after session creation succeeds (I3). if let Some((pending_scope, section)) = pending_canvas.take() { agent.state.canvas_sections.insert(pending_scope, section); } - (sid, true) + // A resumed session already received its initial message. + (sid, !resumed) } Err(AcpError::AgentExited) => { agent.state.invalidate_all(); @@ -2983,7 +3164,7 @@ pub async fn run_prompt_task( target: "pool::session", "cancel_with_cleanup failed during initial_message timeout: {e}" ); - agent.state.invalidate(&source); + agent.state.invalidate_after_error(&source, &e); } } send_prompt_result( @@ -3019,7 +3200,7 @@ pub async fn run_prompt_task( target: "pool::session", "initial_message failed for channel {cid}: {e} — invalidating session" ); - agent.state.invalidate(&source); + agent.state.invalidate_after_error(&source, &e); send_prompt_result( &result_tx, &turn_id, @@ -3079,7 +3260,19 @@ pub async fn run_prompt_task( .as_ref() .map(|info| info.channel_type == "dm") .unwrap_or(false); - let context_target = resolve_context_target(b, is_dm); + let selected_depth = agent + .state + .control_dispatch + .as_ref() + .and_then(|dispatch| dispatch.intent.as_ref()) + .map(|intent| intent.depth); + let context_target = if selected_depth + .is_some_and(|depth| depth != buzz_core::session_control::RestartDepth::Recent) + { + session_history::target(b, is_dm) + } else { + resolve_context_target(b, is_dm) + }; let hydrated_thread_root = match &context_target { ContextTarget::Thread(root) => Some(root), ContextTarget::Dm | ContextTarget::None => None, @@ -3104,18 +3297,57 @@ pub async fn run_prompt_task( .map(|delivery| &delivery.delivered_event_ids) .cloned() .unwrap_or_default(); - let conversation_context = if ctx.context_message_limit > 0 { - fetch_conversation_context_for_target( - b.channel_id, - &context_target, - &ctx, - thread_context_is_hydrated, - &delivered_ids, - ) - .await + let (conversation_context, mut first_mention) = if let Some( + depth @ (buzz_core::session_control::RestartDepth::None + | buzz_core::session_control::RestartDepth::All), + ) = selected_depth + { + session_history::selected(&context_target, b.channel_id, depth, &ctx).await } else { - None + let mut context = if ctx.context_message_limit > 0 { + fetch_conversation_context_for_target( + b.channel_id, + &context_target, + &ctx, + thread_context_is_hydrated, + &delivered_ids, + ) + .await + } else { + None + }; + let mention = match (&context_target, context.as_mut()) { + (ContextTarget::Thread(root), Some(context)) if !thread_context_is_hydrated => { + Some(first_mention::cold_start_pin(root, b.channel_id, context, &ctx).await) + } + _ => None, + }; + (context, mention) }; + if selected_depth.is_some() { + if let ContextTarget::Thread(root) = &context_target { + if b.events.iter().any(|event| { + event.event.id.to_hex() == *root + && event.event.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.first().is_some_and(|part| part == "p") + && parts.get(1).is_some_and(|part| { + *part == ctx.agent_keys.public_key().to_hex() + }) + }) + }) { + first_mention = Some(first_mention::FirstMention::InRoot); + } + } + } + // A new session's first prompt reports what it was given. + let fetched_history = matches!(session_start, Some(SessionStart::New { .. })).then(|| { + session_visibility::FetchedHistory::capture( + (selected_depth.is_some() || ctx.context_message_limit > 0) + && context_target != ContextTarget::None, + conversation_context.as_ref(), + ) + }); if let Some(root) = fetched_thread_root_to_hydrate(&context_target, conversation_context.as_ref(), is_dm) { @@ -3127,16 +3359,50 @@ pub async fn run_prompt_task( .iter() .any(|event_id| delivered_ids.contains(event_id)) }); - let conversation_context = + let mut conversation_context = conversation_context_delta(conversation_context, &delivered_ids, &rendered_batch_ids); + let profile_lookup = + fetch_prompt_profile_lookup(b, conversation_context.as_ref(), &ctx.rest_client).await; + if selected_depth + .is_some_and(|depth| depth != buzz_core::session_control::RestartDepth::Recent) + { + let mut pins = HashSet::new(); + if let ContextTarget::Thread(root) = &context_target { + pins.insert(root.clone()); + } + if let Some(first_mention::FirstMention::Found(event)) = &first_mention { + if let Some(id) = event["id"].as_str() { + pins.insert(id.to_owned()); + } + } + session_history::enforce_render_budget( + &mut conversation_context, + profile_lookup.as_ref(), + &pins, + ); + first_mention = session_history::supplied_mention( + first_mention, + &context_target, + conversation_context.as_ref(), + &rendered_batch_ids, + ); + } + if let (Some(fetched), Some(binding)) = (&fetched_history, agent.state.status.get(&b.scope)) + { + binding.set_history(session_visibility::history_coverage( + fetched, + conversation_context.as_ref(), + &rendered_batch_ids, + matches!(first_mention, Some(first_mention::FirstMention::NotFound)), + &b.scope, + is_dm, + )); + } pending_delivered_event_ids.extend(rendered_batch_ids); pending_delivered_event_ids.extend(conversation_context_event_ids( conversation_context.as_ref(), )); - let profile_lookup = - fetch_prompt_profile_lookup(b, conversation_context.as_ref(), &ctx.rest_client).await; - let known_names: Vec<&str> = profile_lookup .iter() .flat_map(|lookup| lookup.values()) @@ -3153,7 +3419,7 @@ pub async fn run_prompt_task( ); } - crate::queue::format_prompt( + let mut sections = crate::queue::format_prompt( b, &crate::queue::FormatPromptArgs { agent_core: standing.agent_core, @@ -3169,7 +3435,14 @@ pub async fn run_prompt_task( agent_canvas: standing.agent_canvas, standing_context_sent, }, - ) + ); + if let Some(start) = &session_start { + session_continuity::insert_session_marker( + &mut sections, + session_continuity::format_session_marker(start, first_mention.as_ref()), + ); + } + sections } else { // Should not happen — batch is None only for heartbeats which have prompt_text. // Return the agent to the pool to prevent a permanent slot leak. @@ -3185,6 +3458,22 @@ pub async fn run_prompt_task( return; }; + if agent + .state + .control_dispatch + .as_ref() + .is_some_and(|dispatch| !dispatch.current()) + { + send_prompt_result( + &result_tx, + &turn_id, + agent, + source, + PromptOutcome::Cancelled, + None, + ); + return; + } // 💬 — fire-and-forget so the prompt fires immediately. // The guard's cleanup (spawned on drop) removes 💬 after the turn completes. // A brief race where 💬 appears slightly after the agent starts is acceptable. @@ -3393,6 +3682,9 @@ pub async fn run_prompt_task( log_stop_reason(&source, &StopReason::EndTurn); if let PromptSource::Channel(scope) = &source { let standing_sent = !agent.has_system_prompt_support(); + if batch.as_ref().is_some_and(|batch| batch.continuation.is_none()) { + if let Some(dispatch) = &agent.state.control_dispatch { dispatch.foreground_success(); } + } record_scope_delivery_success( &mut agent, scope.clone(), @@ -3431,12 +3723,34 @@ pub async fn run_prompt_task( } }; + if agent + .state + .control_dispatch + .as_ref() + .is_some_and(|dispatch| !dispatch.current()) + { + let outcome = match prompt_result { + Ok(reason) => PromptOutcome::Ok(reason), + Err(error) => PromptOutcome::Error(error), + } + .discard_retired_output(); + send_prompt_result(&result_tx, &turn_id, agent, source, outcome, None); + return; + } match prompt_result { Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); if let PromptSource::Channel(scope) = &source { let standing_sent = !agent.has_system_prompt_support(); + if batch + .as_ref() + .is_some_and(|batch| batch.continuation.is_none()) + { + if let Some(dispatch) = &agent.state.control_dispatch { + dispatch.foreground_success(); + } + } record_scope_delivery_success( &mut agent, scope.clone(), @@ -3470,7 +3784,9 @@ pub async fn run_prompt_task( PromptSource::Channel(scope) => { let count = agent.state.turn_counts.entry(scope.clone()).or_insert(0); *count += 1; - *count >= limit + let rotate = *count >= limit; + agent.state.persist_turns(scope); + rotate } PromptSource::Heartbeat => { agent.state.heartbeat_turn_count += 1; @@ -3609,7 +3925,7 @@ pub async fn run_prompt_task( target: "pool::prompt", "cancel_with_cleanup error: {e} — invalidating session" ); - agent.state.invalidate(&source); + agent.state.invalidate_after_error(&source, &e); let usage = agent.acp.take_turn_usage(); publish_agent_turn_metric( &ctx, @@ -3664,7 +3980,7 @@ pub async fn run_prompt_task( // session state (e.g. bad LLM response). The session is healthy — // don't invalidate it. Other errors may have corrupted state. if !matches!(e, AcpError::AgentError { .. }) { - agent.state.invalidate(&source); + agent.state.invalidate_after_error(&source, &e); } let usage = agent.acp.take_turn_usage(); publish_agent_turn_metric( @@ -4725,6 +5041,9 @@ fn parse_dm_response(json: serde_json::Value, limit: u32) -> Option Option { + if crate::session_controls::reserved_raw(obj) { + return None; + } let content = obj.get("content").and_then(|v| v.as_str())?; let pubkey = obj .get("pubkey") @@ -5097,6 +5416,11 @@ fn record_scope_delivery_success( event_ids: &HashSet, hydrated_thread_roots: &HashSet, ) { + if let Some(dispatch) = &agent.state.control_dispatch { + if !dispatch.current() { + return; + } + } tracing::info!( target: "pool::prompt", "{}", @@ -8735,6 +9059,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -9947,6 +10272,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" recoverable_batch: None, control_tx: None, steer_tx, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -10557,6 +10883,9 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" memory_enabled: false, harness_name: "goose".to_string(), relay_url: "ws://127.0.0.1:3000".to_string(), + session_policy: crate::scope::SessionPolicy::Channel, + session_status: None, + session_controls: Default::default(), } } @@ -12524,5 +12853,20 @@ done"# #[path = "pool/pi_prompt_tests.rs"] mod pi_prompt_tests; +#[cfg(all(test, unix))] +#[path = "pool/real_resume_tests.rs"] +mod real_resume_tests; + #[cfg(all(test, unix))] mod delivery_runtime_tests; + +#[cfg(test)] +#[path = "pool/session_control_history_tests.rs"] +mod session_control_history_regressions; + +#[cfg(test)] +mod portable_retirement_tests; +mod session_retirement; + +pub(crate) mod restart_status; +mod status_language; diff --git a/crates/buzz-acp/src/pool/background_uncertainty_tests.rs b/crates/buzz-acp/src/pool/background_uncertainty_tests.rs new file mode 100644 index 00000000000..4e3f7770f8e --- /dev/null +++ b/crates/buzz-acp/src/pool/background_uncertainty_tests.rs @@ -0,0 +1,87 @@ +//! Script only discovery results; exercise real remembered context, return and poll. +use super::*; + +async fn ordinary_return( + snapshots: Vec>>, +) -> (AgentPool, EventQueue, SessionScope) { + let scope = scope(); + let mut pool = AgentPool::from_slots(vec![Some(worker(0).await)]); + bind(&mut pool, &scope, 0); + let mut agent = pool.try_claim(Some(&scope)).unwrap(); + let session = agent.state.sessions[&scope].clone(); + let source = PromptSource::Channel(scope.clone()); + agent.remember_background_context(&source, None, &session); + agent.acp.background_child_snapshots = snapshots.into(); + // This is the prompt-completion observation before result delivery. + let _ = agent.background_work_running(&source); + pool.return_agent(agent); + (pool, EventQueue::new(DedupMode::Queue), scope) +} + +#[tokio::test] +async fn background_unknown_then_empty_releases_hold_without_inventing_collection() { + let (mut pool, mut queue, scope) = + ordinary_return(vec![None, None, Some(HashSet::new())]).await; + assert!( + pool.has_runtime_hold(), + "Unknown must conservatively protect the original worker" + ); + pool.queue_finished_background_work(&mut queue); + let invented = queue.has_pending_continuation(&scope); + let held = pool.has_runtime_hold(); + pool.agents[0].as_mut().unwrap().acp.shutdown().await; + assert!( + !invented, + "no child was ever observed, so no result collection may be invented" + ); + assert!( + !held, + "resolved empty discovery must release an uncertainty-only hold" + ); +} + +#[tokio::test] +async fn background_observed_then_unknown_then_empty_collects_exactly_once() { + let (mut pool, mut queue, scope) = ordinary_return(vec![ + Some(HashSet::from([12345])), + None, + Some(HashSet::new()), + Some(HashSet::new()), + ]) + .await; + assert!(pool.has_runtime_hold()); + pool.queue_finished_background_work(&mut queue); + assert!(queue.has_pending_continuation(&scope)); + pool.queue_finished_background_work(&mut queue); + let batch = queue.flush_next().unwrap(); + assert!(batch.continuation.is_some()); + assert!( + queue.flush_next().is_none(), + "one observed completion must queue exactly once" + ); + assert!( + pool.has_runtime_hold(), + "pending collection keeps original session protected" + ); + pool.agents[0].as_mut().unwrap().acp.shutdown().await; +} + +#[tokio::test] +async fn background_observed_exit_during_return_survives_later_unknown() { + let (mut pool, mut queue, scope) = ordinary_return(vec![ + Some(HashSet::from([12345])), + Some(HashSet::new()), + None, + Some(HashSet::new()), + ]) + .await; + pool.queue_finished_background_work(&mut queue); + assert!(!queue.has_pending_continuation(&scope)); + pool.queue_finished_background_work(&mut queue); + let collected = queue.has_pending_continuation(&scope); + pool.agents[0].as_mut().unwrap().acp.shutdown().await; + assert!( + collected, + "return must retain previously observed completion evidence across Unknown" + ); +} diff --git a/crates/buzz-acp/src/pool/background_work.rs b/crates/buzz-acp/src/pool/background_work.rs index 928acdc8168..1cc7e9d6249 100644 --- a/crates/buzz-acp/src/pool/background_work.rs +++ b/crates/buzz-acp/src/pool/background_work.rs @@ -132,9 +132,11 @@ impl AgentPool { } pub(super) fn runtime_claim_allowed(&self, index: usize, scope: Option<&SessionScope>) -> bool { - self.runtime_holds - .get(&index) - .is_none_or(|hold| scope == Some(&hold.scope)) + !self.retire_on_return.contains(&index) + && self + .runtime_holds + .get(&index) + .is_none_or(|hold| scope == Some(&hold.scope)) } fn continuation_matches( @@ -189,8 +191,9 @@ impl AgentPool { { return } - Some(children) => hold.blocking_pids = children, - None => {} // Unknown state must not authorize killing live work. + Some(children) if !children.is_empty() => hold.blocking_pids = children, + Some(_) => {} // Preserve observed exit until polling queues collection. + None => {} // Unknown state must not authorize killing live work. } if hold.continuation.as_ref().is_some_and(|continuation| { !self.continuation_matches(agent, &hold.scope, continuation) @@ -220,9 +223,19 @@ impl AgentPool { } continue; } + if self.retire_on_return.contains(&index) { + self.runtime_holds.remove(&index); + continue; + } let Some(hold) = self.runtime_holds.get(&index) else { continue; }; + if hold.blocking_pids.is_empty() && !hold.collection_pending { + // Unknown discovery held this worker conservatively, but no + // workload was observed. Empty now is not a completion event. + self.runtime_holds.remove(&index); + continue; + } let valid = hold.continuation.as_ref().is_some_and(|continuation| { self.agents[index].as_ref().is_some_and(|agent| { self.continuation_matches(agent, &hold.scope, continuation) diff --git a/crates/buzz-acp/src/pool/background_work_tests.rs b/crates/buzz-acp/src/pool/background_work_tests.rs index 97b183e6878..85ce3cc8930 100644 --- a/crates/buzz-acp/src/pool/background_work_tests.rs +++ b/crates/buzz-acp/src/pool/background_work_tests.rs @@ -7,6 +7,10 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[path = "resident_helper_tests.rs"] mod resident_helper_tests; +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[path = "snapshot_helper_tests.rs"] +mod snapshot_helper_tests; + async fn worker(index: usize) -> OwnedAgent { OwnedAgent { index, @@ -89,6 +93,7 @@ async fn background_hold_does_not_arm_an_unserviceable_fork_deadline() { recoverable_batch: None, control_tx: None, steer_tx: None, + control_dispatch: None, successful_steer_deliveries: HashSet::new(), }, ); @@ -569,6 +574,7 @@ pub(crate) fn apply_result( &mut respawn_tasks, None, rest, + &mut tokio::time::Instant::now(), ); assert!( respawn_tasks.is_empty(), @@ -851,7 +857,14 @@ async fn background_collection_completes_under_repeated_ordinary_preemption() { edit: None, }, } - .steer_or_interrupt(mode, None, &mut pool, &mut queue, &ack_tx); + .steer_or_interrupt( + mode, + &Default::default(), + None, + &mut pool, + &mut queue, + &ack_tx, + ); assert!( pool.task_map.values().next().unwrap().control_tx.is_some(), "repeated ordinary traffic must give collection an execution opportunity" @@ -1078,6 +1091,7 @@ async fn background_lost_prompt_task_releases_pool_side_hold_before_respawn() { &mut pool, &mut queue, &config, + &Default::default(), error, &mut false, &HashSet::new(), @@ -1086,6 +1100,7 @@ async fn background_lost_prompt_task_releases_pool_side_hold_before_respawn() { &respawn_tx, &mut respawn_tasks, None, + None, ); assert!( !pool.has_runtime_hold(), @@ -1150,3 +1165,6 @@ async fn background_observer_cancel_after_ordinary_control_never_resumes_collect .contains_key(&scope)); } } + +#[path = "background_uncertainty_tests.rs"] +mod uncertainty_tests; diff --git a/crates/buzz-acp/src/pool/compaction.rs b/crates/buzz-acp/src/pool/compaction.rs new file mode 100644 index 00000000000..3805f735d2e --- /dev/null +++ b/crates/buzz-acp/src/pool/compaction.rs @@ -0,0 +1,361 @@ +//! Existing-only compaction acquisition and separately typed task completion. +use super::*; +use crate::session_controls::compaction::store_failure; +use crate::session_controls::{AdmittedSessionControl, CompactionTarget}; +use crate::session_store::{ControlAttempt, Expect, LoadOutcome}; +use buzz_core::session_control::{ControlFailure, ControlOutcome}; + +/// Only an existing, validated provider session can construct this witness. +pub(crate) struct ExistingSession { + session_id: String, +} + +pub(crate) struct ControlTaskResult { + pub(crate) agent: OwnedAgent, + pub(crate) job: AdmittedSessionControl, + pub(crate) outcome: ControlOutcome, + pub(crate) transport_failed: bool, +} + +impl AgentPool { + pub(crate) fn compaction_target( + &self, + scope: &SessionScope, + controls: &crate::session_controls::SessionControlRuntime, + allow_disk_resume: bool, + ) -> Result { + if let Some(owner) = self.session_owners.get(scope) { + if let Some(agent) = self.agents.get(owner.agent_index).and_then(Option::as_ref) { + if let Some(session_id) = agent.state.sessions.get(scope) { + return Ok(CompactionTarget::Memory { + worker: owner.agent_index, + generation: owner.generation, + session_id: session_id.clone(), + }); + } + } else { + // A checked-out owner retains authoritative identity on disk; + // never send compact to an arbitrary free worker meanwhile. + return Ok(CompactionTarget::Memory { + worker: owner.agent_index, + generation: owner.generation, + session_id: String::new(), + }); + } + } + // Controller-only storage preserves receipts even when provider resume + // is disabled; its live record must not wake a worker for disk resume. + if !allow_disk_resume { + return Err(ControlFailure::NoSession); + } + let store = controls + .control_store() + .ok_or(ControlFailure::StoreUnavailable)?; + if store.is_poisoned(scope) { + return Err(ControlFailure::StoreUnavailable); + } + match store.load(scope) { + LoadOutcome::Found(record) => record + .live + .filter(|live| !live.session_id.is_empty()) + .map(|live| CompactionTarget::Disk { + lease: live.lease, + session_id: live.session_id, + }) + .ok_or(ControlFailure::NoSession), + LoadOutcome::Missing => Err(ControlFailure::NoSession), + LoadOutcome::Unreadable => Err(ControlFailure::StoreUnavailable), + } + } + + /// No hold timeout, foreground cancellation or background-collection steal. + pub(crate) fn claim_compaction( + &mut self, + job: &AdmittedSessionControl, + ) -> Result, ControlFailure> { + let scope = &job.job.scope; + if self + .task_map + .values() + .any(|meta| meta.scope.as_ref() == Some(scope)) + || self.runtime_holds.values().any(|hold| &hold.scope == scope) + { + return Ok(None); + } + match &job.target { + CompactionTarget::Memory { + worker, + generation, + session_id, + } => { + if !self.session_owners.get(scope).is_some_and(|owner| { + owner.agent_index == *worker && owner.generation == *generation + }) { + return Err(ControlFailure::StaleTarget); + } + if self.retire_on_return.contains(worker) || self.runtime_holds.contains_key(worker) + { + return Ok(None); + } + let Some(agent) = self.agents.get_mut(*worker).and_then(Option::as_mut) else { + return Ok(None); + }; + if !agent + .state + .sessions + .get(scope) + .is_some_and(|sid| session_id.is_empty() || sid == session_id) + { + return Err(ControlFailure::StaleTarget); + } + Ok(self.agents[*worker].take()) + } + CompactionTarget::Disk { .. } => { + // If an owner appeared since admission, it is a different target. + if self.session_owners.contains_key(scope) { + return Err(ControlFailure::StaleTarget); + } + Ok(self.try_claim(None)) + } + } + } +} + +pub(crate) async fn acquire_existing_session_for_compaction( + agent: &mut OwnedAgent, + job: &AdmittedSessionControl, + ctx: &PromptContext, +) -> Result { + if !job.dispatch.current() { + return Err(ControlFailure::Superseded); + } + let scope = &job.job.scope; + match &job.target { + CompactionTarget::Memory { + worker, + generation, + session_id, + } => { + if agent.index != *worker + || agent.state.scope_owner_generations.get(scope) != Some(generation) + { + return Err(ControlFailure::StaleTarget); + } + let sid = agent + .state + .sessions + .get(scope) + .filter(|sid| session_id.is_empty() || *sid == session_id) + .cloned() + .ok_or(ControlFailure::NoSession)?; + if let Some(store) = &agent.state.store { + if store.is_poisoned(scope) { + return Err(ControlFailure::StoreUnavailable); + } + match store.load(scope) { + LoadOutcome::Found(record) + if record.live.as_ref().is_some_and(|live| { + live.session_id == sid + && live.instance == store.instance() + && live.worker_index == agent.index + && agent.state.leases.get(scope) == Some(&live.lease) + }) => {} + LoadOutcome::Unreadable => return Err(ControlFailure::StoreUnavailable), + _ => return Err(ControlFailure::StaleTarget), + } + } + agent.acp.ensure_compaction_route(&sid); + Ok(ExistingSession { session_id: sid }) + } + CompactionTarget::Disk { lease, session_id } => { + let store = agent.state.store.clone().ok_or(ControlFailure::NoSession)?; + if store.is_poisoned(scope) { + return Err(ControlFailure::StoreUnavailable); + } + let outcome = store.load(scope); + let live = match &outcome { + LoadOutcome::Found(record) => { + record.live.as_ref().ok_or(ControlFailure::NoSession)? + } + LoadOutcome::Unreadable => return Err(ControlFailure::StoreUnavailable), + LoadOutcome::Missing => return Err(ControlFailure::NoSession), + }; + if live.lease != *lease || live.session_id != *session_id { + return Err(ControlFailure::StaleTarget); + } + if live.adapter != agent.agent_name + || live.cwd != ctx.cwd + || live.model != agent.desired_model + { + return Err(ControlFailure::StaleTarget); + } + if !agent.acp.resume_supported() { + return Err(ControlFailure::Unsupported); + } + if (live.instance != store.instance() && store.instance_alive(live.instance)) + || (live.instance == store.instance() + && live.departed.is_none() + && live.worker_index != agent.index) + { + return Err(ControlFailure::ScopeUnavailable); + } + let mut attached = live.clone(); + attached.instance = store.instance(); + attached.worker_index = agent.index; + attached.departed = None; + let new_lease = store + .claim(scope, &Expect::from_outcome(&outcome), attached.clone()) + .map_err(store_failure)?; + agent.state.leases.insert(scope.clone(), new_lease); + let resume = tokio::time::timeout_at( + job.deadline + .min(tokio::time::Instant::now() + ctx.max_turn_duration), + agent + .acp + .session_resume(session_id, &ctx.cwd, ctx.mcp_servers.clone(), None, None), + ) + .await; + match resume { + Ok(Ok(_)) => {} + Err(_) => return Err(ControlFailure::Timeout), + Ok(Err( + AcpError::Timeout(_) | AcpError::IdleTimeout(_) | AcpError::HardTimeout { .. }, + )) => return Err(ControlFailure::Timeout), + Ok(Err(_)) => return Err(ControlFailure::ResumeFailed), + } + if !job.dispatch.current() { + return Err(ControlFailure::Superseded); + } + store + .commit(scope, new_lease, |_| {}) + .map_err(store_failure)?; + agent.state.restore_delivery(scope, attached); + agent + .state + .sessions + .insert(scope.clone(), session_id.clone()); + agent.publish_session_start( + ctx.session_status.as_ref(), + scope, + session_id, + &SessionStart::Resumed { + reason: crate::session_store::SessionReason::HarnessRestart, + }, + ); + Ok(ExistingSession { + session_id: session_id.clone(), + }) + } + } +} + +pub(crate) async fn run_compaction_task( + mut agent: OwnedAgent, + job: AdmittedSessionControl, + ctx: Arc, + mut control: tokio::sync::oneshot::Receiver, +) -> ControlTaskResult { + let mut transport_failed = false; + let mut active_session = None; + let execution = tokio::time::timeout_at(job.deadline, async { + if tokio::time::Instant::now() >= job.deadline { return Err(ControlFailure::Timeout); } + if !job.dispatch.current() { return Err(ControlFailure::Superseded); } + // A cached worker may have last served another channel. Fence every + // resume and raw compact frame to this admitted operation before I/O. + let observer_session = match &job.target { + CompactionTarget::Memory { session_id, .. } | CompactionTarget::Disk { session_id, .. } + if !session_id.is_empty() => Some(session_id.clone()), + _ => agent.state.sessions.get(&job.job.scope).cloned(), + }; + agent.acp.set_observer_context(observer::context_for_turn( + Some(job.job.scope.channel_id()), + observer_session, + job.job.origin.command_event_id.clone(), + chrono::Utc::now().to_rfc3339(), + )); + let session = tokio::select! { + result = acquire_existing_session_for_compaction(&mut agent, &job, &ctx) => match result { + Ok(session) => session, + Err(failure) => { + transport_failed = matches!(failure, ControlFailure::Timeout | ControlFailure::ResumeFailed); + return Err(failure); + } + }, + _ = &mut control => { + transport_failed = true; + return Err(if job.dispatch.current() { ControlFailure::Cancelled } else { ControlFailure::Superseded }); + }, + }; + active_session = Some(session.session_id.clone()); + if !agent.acp.compact_supported(&session.session_id) { return Err(ControlFailure::Unsupported); } + agent.acp.begin_compaction(&session.session_id, job.job.origin.clone())?; + let attempt = match ctx.session_controls.begin_compaction_effect(&job) { + Ok(attempt) => attempt, + Err(failure) => { agent.acp.finish_compaction(&session.session_id).ok(); return Err(failure); } + }; + match attempt { + ControlAttempt::PriorFinished(prior) => { agent.acp.finish_compaction(&session.session_id).ok(); return prior; } + ControlAttempt::PriorStarted => { agent.acp.finish_compaction(&session.session_id).ok(); return Err(ControlFailure::Unconfirmed); } + ControlAttempt::New => {} + } + if !job.dispatch.current() { agent.acp.finish_compaction(&session.session_id).ok(); return Err(ControlFailure::Superseded); } + if tokio::time::Instant::now() >= job.deadline { agent.acp.finish_compaction(&session.session_id).ok(); return Err(ControlFailure::Timeout); } + let rpc = tokio::select! { + result = agent.acp.session_prompt_blocks_with_idle_timeout(&session.session_id, &["/compact"], ctx.idle_timeout, ctx.max_turn_duration) => result, + _ = &mut control => { + let result = agent.acp.cancel_with_cleanup_grace(&session.session_id, Duration::from_secs(5)).await; + transport_failed = result.is_err(); + agent.acp.finish_compaction(&session.session_id).ok(); + return Err(if job.dispatch.current() { ControlFailure::Cancelled } else { ControlFailure::Superseded }); + } + }; + let terminal = agent.acp.finish_compaction(&session.session_id); + // Controls never publish an ordinary delivered turn or reuse its usage. + let _ = agent.acp.take_turn_usage(); + match rpc { + Ok(StopReason::Cancelled) => Err(ControlFailure::Cancelled), + Ok(_) => terminal, + Err(error) => { + transport_failed = matches!(error, AcpError::AgentExited | AcpError::Io(_) | AcpError::Protocol(_) | AcpError::WriteTimeout(_) | AcpError::Timeout(_) | AcpError::IdleTimeout(_) | AcpError::HardTimeout { .. }); + Err(match error { + AcpError::Timeout(_) | AcpError::IdleTimeout(_) | AcpError::HardTimeout { .. } => ControlFailure::Timeout, + _ if terminal.is_ok() => ControlFailure::Unconfirmed, + _ => ControlFailure::CompactFailed, + }) + } + } + }).await; + let outcome = match execution { + Ok(outcome) => outcome, + Err(_) => { + // The timed-out future borrowed the client; ownership remains here. + // Bound cleanup separately and never return an uncertain wire as healthy. + transport_failed = true; + if let Some(session) = active_session { + if agent.acp.has_in_flight_prompt() { + transport_failed = agent + .acp + .cancel_with_cleanup_grace(&session, Duration::from_secs(5)) + .await + .is_err(); + } else { + transport_failed = false; + } + let _ = agent.acp.finish_compaction(&session); + } + let _ = agent.acp.take_turn_usage(); + Err(ControlFailure::Timeout) + } + }; + let outcome = if job.dispatch.current() { + outcome + } else { + Err(ControlFailure::Superseded) + }; + ControlTaskResult { + agent, + job, + outcome, + transport_failed, + } +} diff --git a/crates/buzz-acp/src/pool/compaction_resume_policy_tests.rs b/crates/buzz-acp/src/pool/compaction_resume_policy_tests.rs new file mode 100644 index 00000000000..0feaa626a6b --- /dev/null +++ b/crates/buzz-acp/src/pool/compaction_resume_policy_tests.rs @@ -0,0 +1,227 @@ +use super::*; +use crate::session_store::ControlReceiptState; + +#[tokio::test] +async fn compact_resume_disabled_disk_refusal_and_replay_never_wake() { + let fixture = Fixture::new(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope().channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + fixture.departed_record(&scope(), &ctx, &["delivered"]); + let original_live = fixture.record(&scope()).live; + let pool = crate::pool::AgentPool::from_slots(vec![None]); + let request = authorized_control(&ctx, &human, &scope(), "!compact").await; + let failure = ctx + .session_controls + .queue_compaction( + request.clone(), + pool.compaction_target(&scope(), &ctx.session_controls, false), + ) + .expect_err("a retained control-store session cannot authorize disk resume"); + assert_eq!(failure, ControlFailure::NoSession); + // Match the listener's refusal publication without putting chat work in the queue. + ctx.session_controls.queue_result( + request.origin.command_event_id.clone(), + ControlResult::Terminal(Err(failure)), + ); + let publication = ctx + .session_controls + .take_publications() + .pop_front() + .unwrap(); + assert_eq!(publication.command_id, request.origin.command_event_id); + assert_eq!( + publication.result, + ControlResult::Terminal(Err(ControlFailure::NoSession)) + ); + let record = fixture.record(&scope()); + assert_eq!(record.live, original_live); + assert_eq!(record.control_receipts.len(), 1); + assert_eq!( + record.control_receipts[0].command_id, + request.origin.command_event_id + ); + assert_eq!( + record.control_receipts[0].state, + ControlReceiptState::Finished(Err(ControlFailure::NoSession)) + ); + let mut queue = crate::EventQueue::new(crate::DedupMode::Queue); + let mut lifecycle = crate::pool_lifecycle::PoolLifecycle::<()>::listening(); + assert!(ctx.session_controls.pending_compactions().is_empty()); + assert!(!crate::control_tasks::wake_pending( + &mut queue, + &ctx.session_controls + )); + assert_eq!( + lifecycle.start_wake_if_due( + crate::control_tasks::wake_pending(&mut queue, &ctx.session_controls), + tokio::time::Instant::now() + ), + None + ); + + // Rebuild the controller so only the durable receipt can answer redelivery. + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + ctx.session_controls + .queue_compaction( + request.clone(), + pool.compaction_target(&scope(), &ctx.session_controls, false), + ) + .unwrap(); + let replay = ctx + .session_controls + .take_publications() + .pop_front() + .unwrap(); + assert_eq!(replay.command_id, request.origin.command_event_id); + assert_eq!(replay.result, publication.result); + assert_eq!(fixture.record(&scope()), record); + assert!(ctx.session_controls.pending_compactions().is_empty()); + assert!(!crate::control_tasks::wake_pending( + &mut queue, + &ctx.session_controls + )); + assert_eq!( + lifecycle.start_wake_if_due( + crate::control_tasks::wake_pending(&mut queue, &ctx.session_controls), + tokio::time::Instant::now() + ), + None + ); + assert!( + fixture.methods().is_empty(), + "no provider worker was initialized" + ); + server.abort(); +} + +#[tokio::test] +async fn compact_resume_enabled_disk_wakes_and_resumes_without_new_session() { + let fixture = Fixture::new(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope().channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + fixture.departed_record(&scope(), &ctx, &["delivered"]); + let mut pool = crate::pool::AgentPool::from_slots(vec![None]); + let request = authorized_control(&ctx, &human, &scope(), "!compact").await; + ctx.session_controls + .queue_compaction( + request, + pool.compaction_target(&scope(), &ctx.session_controls, true), + ) + .unwrap(); + assert_eq!(ctx.session_controls.pending_compactions().len(), 1); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let ctx = Arc::new(ctx); + crate::control_tasks::dispatch(&mut pool, &ctx, &tx); + assert!(rx.try_recv().is_err()); + assert!(fixture.methods().is_empty()); + let mut queue = crate::EventQueue::new(crate::DedupMode::Queue); + let mut lifecycle = crate::pool_lifecycle::PoolLifecycle::listening(); + assert_eq!( + lifecycle.start_wake_if_due( + crate::control_tasks::wake_pending(&mut queue, &ctx.session_controls), + tokio::time::Instant::now() + ), + Some(1) + ); + let prompts = fixture.dir.path().join("policy-prompts.jsonl"); + let prompt_path = prompts.display().to_string(); + let agent = fixture + .agent(&[ + ("RESUME_CAP", "1"), + ("COMPACT_STATUS", "completed"), + ("PROMPTS", prompt_path.as_str()), + ]) + .await; + lifecycle + .complete_wake( + 1, + Ok(crate::pool::AgentPool::from_slots(vec![Some(agent)])), + tokio::time::Instant::now(), + ) + .unwrap(); + pool = lifecycle.take_ready().unwrap(); + pool.set_session_store(Some(fixture.store.clone())); + crate::control_tasks::dispatch(&mut pool, &ctx, &tx); + let mut result = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(result.outcome, Ok(())); + assert_eq!(result.agent.state.sessions[&scope()], "old-1"); + assert_eq!(result.agent.state.turn_counts[&scope()], 1); + assert!(result.agent.state.deliveries[&scope()] + .delivered_event_ids + .contains("delivered")); + let methods = fixture.methods(); + assert_eq!(methods, ["initialize", "session/resume", "session/prompt"]); + let prompt: serde_json::Value = + serde_json::from_str(std::fs::read_to_string(prompts).unwrap().trim()).unwrap(); + assert_eq!(prompt["sessionId"], "old-1"); + assert_eq!( + prompt["prompt"], + serde_json::json!([{"type":"text","text":"/compact"}]) + ); + result.agent.acp.shutdown().await; + pool.join_set.shutdown().await; + server.abort(); +} + +#[tokio::test] +async fn compact_resume_disabled_memory_owner_still_compacts_without_resume() { + let (fixture, ctx, mut pool, human, server) = setup().await; + pool.set_session_store(None); + let mut owner = pool.try_claim(Some(&scope())).unwrap(); + assert!(owner.state.store.is_none()); + owner.state.turn_counts.insert(scope(), 7); + owner.state.mark_scope_delivery_success( + scope(), + true, + ["delivered".into()], + [scope().root_event_id().unwrap().into()], + ); + let observer = crate::observer::ObserverHandle::in_process(); + owner.acp.set_observer(Some(observer.clone()), 0); + pool.return_agent(owner); + let request = authorized_control(&ctx, &human, &scope(), "!compact").await; + ctx.session_controls + .queue_compaction( + request, + pool.compaction_target(&scope(), &ctx.session_controls, false), + ) + .unwrap(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + crate::control_tasks::dispatch(&mut pool, &ctx, &tx); + let mut result = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(result.outcome, Ok(())); + assert!(result.agent.state.store.is_none()); + assert_eq!(result.agent.state.sessions[&scope()], "old-1"); + assert_eq!(result.agent.state.turn_counts[&scope()], 7); + assert!(result.agent.state.deliveries[&scope()] + .delivered_event_ids + .contains("delivered")); + assert_eq!(fixture.methods(), ["initialize", "session/prompt"]); + let events = observer.snapshot(); + let prompts: Vec<_> = events + .iter() + .filter(|event| event.payload["method"] == "session/prompt") + .collect(); + assert_eq!(prompts.len(), 1); + assert_eq!(prompts[0].payload["params"]["sessionId"], "old-1"); + assert_eq!( + prompts[0].payload["params"]["prompt"], + serde_json::json!([{"type":"text","text":"/compact"}]) + ); + result.agent.acp.shutdown().await; + pool.join_set.shutdown().await; + server.abort(); +} diff --git a/crates/buzz-acp/src/pool/compaction_runtime_tests.rs b/crates/buzz-acp/src/pool/compaction_runtime_tests.rs new file mode 100644 index 00000000000..c97f2ea9f3b --- /dev/null +++ b/crates/buzz-acp/src/pool/compaction_runtime_tests.rs @@ -0,0 +1,870 @@ +use super::*; +use buzz_core::session_control::{ControlFailure, ControlResult}; + +async fn setup() -> ( + Fixture, + Arc, + crate::pool::AgentPool, + Keys, + tokio::task::JoinHandle<()>, +) { + let fixture = Fixture::new(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope().channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let mut agent = fixture.agent(&[("COMPACT_STATUS", "completed")]).await; + let (state, _) = owned_state(&fixture, &scope(), "old-1"); + agent.state = state; + let mut pool = crate::pool::AgentPool::from_slots(vec![Some(agent)]); + let generation = pool.record_scope_owner(scope(), 0); + let mut agent = pool.try_claim(Some(&scope())).unwrap(); + agent.state.set_scope_owner_generation(scope(), generation); + agent.acp.ensure_compaction_route("old-1"); + agent.acp.advertise_compaction("old-1"); + pool.return_agent(agent); + (fixture, Arc::new(ctx), pool, human, server) +} + +async fn enqueue( + ctx: &PromptContext, + pool: &crate::pool::AgentPool, + human: &Keys, + scope: &SessionScope, +) -> crate::session_controls::ControlJob { + let job = authorized_control(ctx, human, scope, "!compact").await; + let target = pool.compaction_target(scope, &ctx.session_controls, true); + ctx.session_controls + .queue_compaction(job.clone(), target) + .unwrap(); + job +} + +#[tokio::test] +async fn compact_waits_for_exact_busy_owner_without_cancel_or_fork() { + let (fixture, ctx, mut pool, human, server) = setup().await; + let owner = pool.try_claim(Some(&scope())).unwrap(); + let request = enqueue(&ctx, &pool, &human, &scope()).await; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + crate::control_tasks::dispatch(&mut pool, &ctx, &tx); + assert!(rx.try_recv().is_err()); + assert!(pool.task_map().is_empty()); + assert_eq!( + ctx.session_controls + .take_publications() + .iter() + .filter(|publication| { + publication.command_id == request.origin.command_event_id + && publication.result == ControlResult::Pending + }) + .count(), + 1, + "a busy compact reports its wait instead of looking unconfirmed" + ); + let admitted_deadline = ctx.session_controls.pending_compactions()[0].deadline; + ctx.session_controls + .queue_compaction( + request.clone(), + pool.compaction_target(&scope(), &ctx.session_controls, true), + ) + .unwrap(); + assert_eq!( + ctx.session_controls.pending_compactions()[0].deadline, + admitted_deadline, + "redelivery must not extend queue admission" + ); + assert_eq!( + ctx.session_controls.take_publications()[0].result, + ControlResult::Pending, + "redelivery can retry the wait receipt without executing compact" + ); + tokio::time::pause(); + tokio::time::advance(Duration::from_secs(899)).await; + tokio::time::resume(); + crate::control_tasks::dispatch(&mut pool, &ctx, &tx); + assert!( + ctx.session_controls.take_publications().is_empty(), + "waiting is reported once" + ); + assert!(ctx.session_controls.compact_pending(&scope())); + assert!(!fixture.methods().contains(&"session/cancel".into())); + assert!(!fixture.methods().contains(&"session/new".into())); + let dispatch_started = tokio::time::Instant::now(); + pool.return_agent(owner); + crate::control_tasks::dispatch(&mut pool, &ctx, &tx); + let result = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(result.job.job.origin, request.origin); + let execution_wait = crate::session_controls::compaction::COMPACT_EXECUTION_WAIT; + assert!(result.job.deadline >= dispatch_started + execution_wait); + assert!(result.job.deadline < dispatch_started + execution_wait + Duration::from_secs(1)); + assert_eq!(result.outcome, Ok(())); + assert_eq!(result.agent.state.sessions[&scope()], "old-1"); + let config = crate::error_outcome_emission_tests::test_config(); + let (respawn_tx, _respawn_rx) = tokio::sync::mpsc::channel(8); + let mut tasks = tokio::task::JoinSet::new(); + let mut circuits = vec![crate::SlotCircuit { + crash_times: vec![], + open_until: None, + respawn_in_flight: false, + }]; + let old = tokio::time::Instant::now() - Duration::from_secs(60); + let mut activity = old; + crate::control_tasks::finish( + &mut pool, + &ctx, + result, + &Default::default(), + &config, + &mut circuits, + &respawn_tx, + &mut tasks, + None, + &mut activity, + ); + assert!( + activity > old, + "real control completion refreshes idle clocks" + ); + assert!(!pool + .task_map() + .values() + .any(|meta| meta.scope.as_ref() == Some(&scope()))); + let mut returned = pool.try_claim(Some(&scope())).unwrap(); + assert_eq!( + returned.state.turn_counts.get(&scope()), + None, + "control must not increment ordinary delivered turns" + ); + returned.acp.shutdown().await; + pool.join_set.shutdown().await; + server.abort(); +} + +#[tokio::test] +async fn compact_valid_admission_crosses_wire_expiry_while_waiting_for_owner() { + let (fixture, ctx, mut pool, human, server) = setup().await; + let owner = pool.try_claim(Some(&scope())).unwrap(); + let request = authorized_control_at( + &ctx, + &human, + &scope(), + "!compact", + nostr::Timestamp::from(nostr::Timestamp::now().as_secs() - 899), + ) + .await; + ctx.session_controls + .queue_compaction( + request.clone(), + pool.compaction_target(&scope(), &ctx.session_controls, true), + ) + .unwrap(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + crate::control_tasks::dispatch(&mut pool, &ctx, &tx); + assert!(rx.try_recv().is_err()); + assert!(pool.task_map().is_empty()); + assert!(!fixture.methods().contains(&"session/prompt".into())); + tokio::time::sleep(Duration::from_millis(2100)).await; + assert!(!buzz_core::session_control::control_is_fresh( + request.event.created_at.as_secs(), + nostr::Timestamp::now().as_secs(), + )); + pool.return_agent(owner); + crate::control_tasks::dispatch(&mut pool, &ctx, &tx); + let result = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .unwrap() + .expect("validly admitted compact must execute within its control deadline"); + assert_eq!(result.outcome, Ok(())); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/prompt") + .count(), + 1 + ); + let mut agent = result.agent; + agent.acp.shutdown().await; + pool.join_set.shutdown().await; + server.abort(); +} + +#[tokio::test] +async fn compact_wait_does_not_block_unrelated_scope_dispatch() { + let (fixture, ctx, mut pool, human, server) = setup().await; + let mut owner = pool.try_claim(Some(&scope())).unwrap(); + let mut second = fixture.agent(&[]).await; + second.index = 1; + // Preserve the checked-out owner's directory while adding unrelated capacity. + pool.agents_mut().push(Some(second)); + enqueue(&ctx, &pool, &human, &scope()).await; + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + crate::control_tasks::dispatch(&mut pool, &ctx, &tx); + let other = SessionScope::Thread { + channel_id: scope().channel_id(), + root_event_id: "c".repeat(64), + }; + let event = mention( + &human, + other.channel_id(), + other.root_event_id(), + "ordinary task", + ); + let mut queue = crate::EventQueue::new(crate::DedupMode::Queue); + queue.push(crate::QueuedEvent { + scope: other.clone(), + channel_id: other.channel_id(), + event, + received_at: std::time::Instant::now(), + prompt_tag: "test".into(), + edit: None, + }); + let dispatched = crate::dispatch_pending( + &mut pool, + &mut queue, + &ctx, + &mut tokio::time::Instant::now(), + None, + ); + assert_eq!(dispatched.len(), 1); + assert_eq!(dispatched[0].0, other); + owner.acp.shutdown().await; + pool.join_set.shutdown().await; + server.abort(); +} + +#[tokio::test] +async fn compact_protects_background_collection_and_pending_blocks_idle() { + let (_fixture, ctx, mut pool, human, server) = setup().await; + enqueue(&ctx, &pool, &human, &scope()).await; + let generation = pool.agents_mut()[0] + .as_ref() + .unwrap() + .state + .scope_owner_generations[&scope()]; + let continuation = crate::queue::RuntimeContinuation { + delivery_key: Uuid::new_v4(), + ordinary_preempted: false, + worker_index: 0, + session_id: "old-1".into(), + generation, + thread_tags: crate::ThreadTags::default(), + received_at: std::time::Instant::now(), + }; + pool.set_runtime_hold( + 0, + crate::pool::RuntimeHold { + scope: scope(), + blocking_pids: Default::default(), + continuation: Some(continuation.clone()), + collection_pending: true, + }, + ); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + crate::control_tasks::dispatch(&mut pool, &ctx, &tx); + assert!(pool.task_map().is_empty()); + assert_eq!(ctx.session_controls.pending_scopes(), vec![scope()]); + assert!(!crate::idle_pool_sleep_due( + true, + tokio::time::Instant::now() - Duration::from_secs(60), + tokio::time::Instant::now(), + Duration::from_secs(1), + false, + false, + !ctx.session_controls.pending_scopes().is_empty(), + false, + false + )); + let mut queue = crate::EventQueue::new(crate::DedupMode::Queue); + queue.enqueue_continuation(scope(), continuation); + let dispatched = crate::dispatch_pending( + &mut pool, + &mut queue, + &ctx, + &mut tokio::time::Instant::now(), + None, + ); + assert_eq!( + dispatched.len(), + 1, + "waiting compact must let protected collection complete" + ); + assert!(pool.task_map().values().all(|meta| meta + .recoverable_batch + .as_ref() + .is_some_and(|batch| batch.continuation.is_some()))); + pool.join_set.shutdown().await; + server.abort(); +} + +#[tokio::test] +async fn compact_duplicate_busy_deadline_restart_removal_and_shutdown_are_terminal() { + for ending in ["deadline", "restart", "removal", "shutdown", "panic"] { + let (_fixture, ctx, mut pool, human, server) = setup().await; + let request = enqueue(&ctx, &pool, &human, &scope()).await; + let target = pool.compaction_target(&scope(), &ctx.session_controls, true); + ctx.session_controls + .queue_compaction(request.clone(), target) + .unwrap(); + let mut other = request.clone(); + other.origin.command_event_id = "d".repeat(64); + assert_eq!( + ctx.session_controls.queue_compaction( + other, + pool.compaction_target(&scope(), &ctx.session_controls, true) + ), + Err(ControlFailure::Busy) + ); + match ending { + "deadline" => { + // The production scheduler consumes its actual stored deadline. + tokio::time::pause(); + tokio::time::advance( + crate::session_controls::compaction::COMPACT_QUEUE_WAIT + + Duration::from_secs(1), + ) + .await; + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + crate::control_tasks::dispatch(&mut pool, &ctx, &tx); + } + "restart" => { + let restart = authorized_control(&ctx, &human, &scope(), "!restart all").await; + ctx.session_controls.restart(restart).unwrap(); + } + "removal" => ctx + .session_controls + .revoke_channel(scope().channel_id()) + .unwrap(), + "shutdown" => { + ctx.session_controls.shutdown_compactions(); + } + "panic" => { + let job = ctx.session_controls.pending_compactions().pop().unwrap(); + assert!(ctx.session_controls.start_compaction(&job, 0)); + ctx.session_controls.begin_compaction_effect(&job).unwrap(); + ctx.session_controls.compaction_panicked(0); + } + _ => unreachable!(), + } + assert!(!ctx.session_controls.compact_pending(&scope()), "{ending}"); + let expected = match ending { + "deadline" => ControlFailure::Busy, + "restart" => ControlFailure::Superseded, + "panic" => ControlFailure::Unconfirmed, + _ => ControlFailure::Cancelled, + }; + assert!( + ctx.session_controls + .take_publications() + .iter() + .any( + |publication| publication.command_id == request.origin.command_event_id + && publication.result == ControlResult::Terminal(Err(expected)) + ), + "{ending}" + ); + if ending == "deadline" { + tokio::time::resume(); + } + if let Some(mut agent) = pool.try_claim(None) { + agent.acp.shutdown().await; + } + server.abort(); + } +} + +#[tokio::test] +async fn compact_only_lazy_work_requires_existing_session() { + let (fixture, ctx, mut pool, human, server) = setup().await; + let absent = SessionScope::Thread { + channel_id: scope().channel_id(), + root_event_id: "c".repeat(64), + }; + let job = authorized_control(&ctx, &human, &absent, "!compact").await; + assert_eq!( + ctx.session_controls.queue_compaction( + job, + pool.compaction_target(&absent, &ctx.session_controls, true) + ), + Err(ControlFailure::NoSession) + ); + assert!(ctx.session_controls.pending_compactions().is_empty()); + let mut queue = crate::EventQueue::new(crate::DedupMode::Queue); + let mut lifecycle = crate::pool_lifecycle::PoolLifecycle::<()>::listening(); + assert_eq!( + lifecycle.start_wake_if_due( + crate::control_tasks::wake_pending(&mut queue, &ctx.session_controls), + tokio::time::Instant::now() + ), + None + ); + fixture + .store + .mark_departed( + &scope(), + fixture.record(&scope()).live.unwrap().lease, + Departure::IdleSleep, + ) + .unwrap(); + let mut old = pool.try_claim(Some(&scope())).unwrap(); + old.acp.shutdown().await; + let empty_pool = crate::pool::AgentPool::from_slots(vec![]); + enqueue(&ctx, &empty_pool, &human, &scope()).await; + assert_eq!(ctx.session_controls.pending_compactions().len(), 1); + assert_eq!( + lifecycle.start_wake_if_due( + crate::control_tasks::wake_pending(&mut queue, &ctx.session_controls), + tokio::time::Instant::now() + ), + Some(1) + ); + assert!(!fixture.methods().contains(&"session/new".into())); + server.abort(); +} + +#[tokio::test] +async fn compact_resume_and_rpc_share_one_execution_deadline() { + for (remaining_ms, resume_delay, prompt_delay, prompts) in + [(1000, "0.1", "1.1", 1), (100, "0.3", "0", 0)] + { + let fixture = Fixture::new(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope().channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.max_turn_duration = Duration::from_secs(5); + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + fixture.departed_record(&scope(), &ctx, &[]); + let agent = fixture + .agent(&[ + ("RESUME_CAP", "1"), + ("COMPACT_STATUS", "completed"), + ("DELAY", resume_delay), + ("PROMPT_DELAY", prompt_delay), + ]) + .await; + let mut pool = crate::pool::AgentPool::from_slots(vec![Some(agent)]); + let ctx = Arc::new(ctx); + enqueue(&ctx, &pool, &human, &scope()).await; + let mut job = ctx.session_controls.pending_compactions().pop().unwrap(); + assert!( + !fixture.methods().contains(&"session/resume".into()), + "queued waiting has not touched the provider" + ); + let mut agent = pool.claim_compaction(&job).unwrap().unwrap(); + job.deadline = tokio::time::Instant::now() + Duration::from_millis(remaining_ms); + assert!(ctx.session_controls.start_compaction(&job, agent.index)); + agent.state.control_dispatch = Some(job.dispatch.clone()); + let (_control_tx, control_rx) = tokio::sync::oneshot::channel(); + let mut result = tokio::time::timeout( + Duration::from_secs(3), + crate::pool::compaction::run_compaction_task(agent, job, ctx.clone(), control_rx), + ) + .await + .unwrap(); + let outcome = result.outcome; + result.agent.acp.shutdown().await; + pool.join_set.shutdown().await; + server.abort(); + assert_eq!( + outcome, + Err(ControlFailure::Timeout), + "resume and RPC must not each receive a fresh execution budget" + ); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/prompt") + .count(), + prompts + ); + assert!(!fixture.methods().contains(&"session/new".into())); + } +} + +#[tokio::test] +async fn compact_scope_cap_never_evicts_pending_and_restart_can_supersede_at_capacity() { + let (fixture, ctx, mut pool, human, server) = setup().await; + let base = authorized_control(&ctx, &human, &scope(), "!compact").await; + for n in 0..128 { + let mut request = base.clone(); + request.scope = SessionScope::Thread { + channel_id: scope().channel_id(), + root_event_id: format!("{n:064x}"), + }; + request.origin.command_event_id = format!("{n:064x}"); + ctx.session_controls + .queue_compaction( + request, + Ok(crate::session_controls::CompactionTarget::Disk { + lease: Uuid::new_v4(), + session_id: "existing".into(), + }), + ) + .unwrap(); + } + assert_eq!( + ctx.session_controls.queue_compaction( + base.clone(), + pool.compaction_target(&scope(), &ctx.session_controls, true) + ), + Err(ControlFailure::Busy) + ); + assert_eq!(ctx.session_controls.pending_compactions().len(), 128); + assert!( + fixture.record(&scope()).control_receipts.is_empty(), + "capacity refusal must not allocate another durable Started reservation" + ); + let restart = authorized_control(&ctx, &human, &scope(), "!restart").await; + assert!(matches!( + ctx.session_controls.restart(restart), + Err(crate::session_store::StoreError::Busy) + )); + let target = SessionScope::Thread { + channel_id: scope().channel_id(), + root_event_id: format!("{:064x}", 0), + }; + let restart = authorized_control(&ctx, &human, &target, "!restart all").await; + assert!(ctx.session_controls.restart(restart).unwrap()); + assert_eq!(ctx.session_controls.pending_compactions().len(), 127); + assert_eq!(ctx.session_controls.pending_scopes().len(), 128); + let mut agent = pool.try_claim(None).unwrap(); + agent.acp.shutdown().await; + server.abort(); +} + +#[tokio::test] +async fn compact_pending_receipt_writes_reserve_capacity_and_preserve_newer_supersession() { + let (fixture, ctx, mut pool, human, server) = setup().await; + let base = authorized_control(&ctx, &human, &scope(), "!compact").await; + for n in 0..128 { + let mut request = base.clone(); + request.scope = SessionScope::Thread { + channel_id: scope().channel_id(), + root_event_id: format!("{n:064x}"), + }; + request.origin.command_event_id = format!("{n:064x}"); + ctx.session_controls + .queue_compaction( + request.clone(), + Ok(crate::session_controls::CompactionTarget::Disk { + lease: Uuid::new_v4(), + session_id: "existing".into(), + }), + ) + .unwrap(); + let admitted = ctx + .session_controls + .pending_compactions() + .into_iter() + .find(|job| job.job.scope == request.scope) + .unwrap(); + ctx.session_controls + .begin_compaction_effect(&admitted) + .unwrap(); + fixture.store.fail_writes(true); + ctx.session_controls + .finish_compaction_job(&admitted, Err(ControlFailure::Unconfirmed)); + fixture.store.fail_writes(false); + } + assert_eq!(ctx.session_controls.receipt_write_count(), 128); + assert_eq!( + ctx.session_controls.queue_compaction( + base.clone(), + pool.compaction_target(&scope(), &ctx.session_controls, true) + ), + Err(ControlFailure::Busy) + ); + assert!(ctx.session_controls.pending_compactions().is_empty()); + assert_eq!(ctx.session_controls.receipt_write_count(), 128); + let target = SessionScope::Thread { + channel_id: scope().channel_id(), + root_event_id: format!("{:064x}", 0), + }; + let restart = authorized_control(&ctx, &human, &target, "!restart all").await; + assert!(ctx.session_controls.restart(restart).unwrap()); + ctx.session_controls.sweep_orphans(128).unwrap(); + assert_eq!(ctx.session_controls.receipt_write_count(), 0); + let prior = fixture + .record(&target) + .control_receipts + .into_iter() + .find(|receipt| receipt.command_id == format!("{:064x}", 0)) + .unwrap(); + assert_eq!( + prior.state, + crate::session_store::ControlReceiptState::Finished(Err(ControlFailure::Superseded)) + ); + assert!(!fixture.methods().contains(&"session/prompt".into())); + let mut agent = pool.try_claim(None).unwrap(); + agent.acp.shutdown().await; + server.abort(); +} + +#[tokio::test] +async fn compact_actual_panic_terminalizes_started_without_ordinary_retry() { + let (fixture, ctx, mut pool, human, server) = setup().await; + let request = enqueue(&ctx, &pool, &human, &scope()).await; + let admitted = ctx.session_controls.pending_compactions().pop().unwrap(); + assert!(ctx.session_controls.start_compaction(&admitted, 0)); + ctx.session_controls + .begin_compaction_effect(&admitted) + .unwrap(); + let agent = pool.try_claim(Some(&scope())).unwrap(); + let handle = pool.join_set.spawn(async move { + let _agent = agent; + panic!("compaction panic fixture"); + }); + pool.task_map_mut().insert( + handle.id(), + crate::pool::TaskMeta { + agent_index: 0, + channel_id: Some(scope().channel_id()), + scope: Some(scope()), + turn_id: request.origin.command_event_id.clone(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + control_dispatch: Some(admitted.dispatch.clone()), + successful_steer_deliveries: Default::default(), + }, + ); + let error = pool.join_set.join_next().await.unwrap().unwrap_err(); + let config = crate::error_outcome_emission_tests::test_config(); + let (tx, _rx) = tokio::sync::mpsc::channel(8); + let mut tasks = tokio::task::JoinSet::new(); + let mut circuits = vec![crate::SlotCircuit { + crash_times: vec![], + open_until: None, + respawn_in_flight: false, + }]; + let mut queue = crate::EventQueue::new(crate::DedupMode::Queue); + crate::recover_panicked_agent( + &mut pool, + &mut queue, + &config, + &ctx.session_controls, + error, + &mut false, + &Default::default(), + &mut Default::default(), + &mut circuits, + &tx, + &mut tasks, + None, + None, + ); + assert!(!ctx.session_controls.compact_pending(&scope())); + assert!(!queue.has_undispatched_work()); + assert_eq!( + fixture.record(&scope()).control_receipts[0].state, + crate::session_store::ControlReceiptState::Finished(Err(ControlFailure::Unconfirmed)) + ); + tasks.shutdown().await; + server.abort(); +} + +#[tokio::test] +async fn compact_in_flight_restart_uses_retirement_return_and_reports_superseded() { + let fixture = Fixture::new(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope().channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let mut agent = fixture + .agent(&[("COMPACT_STATUS", "completed"), ("HANG_PROMPT", "1")]) + .await; + agent.state = owned_state(&fixture, &scope(), "old-1").0; + agent.acp.ensure_compaction_route("old-1"); + agent.acp.advertise_compaction("old-1"); + let mut pool = crate::pool::AgentPool::from_slots(vec![Some(agent)]); + let generation = pool.record_scope_owner(scope(), 0); + pool.agents_mut()[0] + .as_mut() + .unwrap() + .state + .set_scope_owner_generation(scope(), generation); + let ctx = Arc::new(ctx); + let request = enqueue(&ctx, &pool, &human, &scope()).await; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + crate::control_tasks::dispatch(&mut pool, &ctx, &tx); + tokio::time::timeout(Duration::from_secs(5), async { + while !fixture.methods().contains(&"session/prompt".into()) { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + let restart = authorized_control(&ctx, &human, &scope(), "!restart all").await; + ctx.session_controls.restart(restart).unwrap(); + let targets = pool.retirement_targets(&scope()); + for target in &targets { + pool.mark_retire_on_return(*target); + } + ctx.session_controls.plan_retirement(&scope(), targets); + assert!(crate::signal_in_flight_task_for_scope( + &mut pool, + &scope(), + crate::pool::ControlSignal::Rotate + )); + let mut result = tokio::time::timeout(Duration::from_secs(8), rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(result.outcome, Err(ControlFailure::Superseded)); + assert!( + result.transport_failed, + "cancel-drain uncertainty must survive retirement fencing" + ); + assert!(pool.needs_retirement(&result.agent)); + assert!(ctx.session_controls.retirement_pending(&scope())); + assert!(ctx + .session_controls + .take_publications() + .iter() + .any( + |publication| publication.command_id == request.origin.command_event_id + && publication.result == ControlResult::Terminal(Err(ControlFailure::Superseded)) + )); + result.agent.acp.shutdown().await; + pool.join_set.shutdown().await; + server.abort(); +} + +#[tokio::test] +async fn compact_uses_pre_ack_new_session_advertisement_after_ordinary_completion() { + let fixture = Fixture::new(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope().channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.context_message_limit = 0; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + ctx.session_status = Some( + crate::session_status::SessionStatusRuntime::open( + fixture.dir.path(), + buzz_core::session_status::canonical_identity( + "wss://relay.example", + &ctx.agent_keys.public_key().to_hex(), + ) + .unwrap(), + ) + .unwrap(), + ); + let prompts = fixture.dir.path().join("new-advertised-prompts.jsonl"); + let mut agent = fixture + .agent(&[ + ("NEW_ADVERTISE", "1"), + ("COMPACT_STATUS", "completed"), + ("COMPACT_ONLY_SLASH", "1"), + ("PROMPTS", prompts.to_str().unwrap()), + ]) + .await; + agent.state.control_dispatch = Some(ctx.session_controls.capture(&scope())); + let ctx = Arc::new(ctx); + let trigger = mention( + &human, + scope().channel_id(), + scope().root_event_id(), + "ordinary task", + ); + let agent = run_turn(agent, &ctx, batch(&scope(), trigger)).await; + assert_eq!(agent.state.sessions[&scope()], "new-1"); + let mut pool = crate::pool::AgentPool::from_slots(vec![Some(agent)]); + let generation = pool.record_scope_owner(scope(), 0); + let mut agent = pool.try_claim(Some(&scope())).unwrap(); + agent.state.set_scope_owner_generation(scope(), generation); + pool.return_agent(agent); + enqueue(&ctx, &pool, &human, &scope()).await; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + crate::control_tasks::dispatch(&mut pool, &ctx, &tx); + let mut result = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .unwrap() + .unwrap(); + result.agent.acp.shutdown().await; + pool.join_set.shutdown().await; + server.abort(); + assert_eq!( + result.outcome, + Ok(()), + "actual pre-ACK advertised compact must survive ordinary completion and binding" + ); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/new") + .count(), + 1 + ); + let frames: Vec = std::fs::read_to_string(prompts) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(frames.len(), 2, "one ordinary and one compact prompt"); + assert_eq!(frames[1]["sessionId"], "new-1"); + assert_eq!( + frames[1]["prompt"], + serde_json::json!([{"type":"text","text":"/compact"}]) + ); +} + +#[tokio::test] +async fn compact_cached_worker_replaces_previous_observer_scope() { + let (_fixture, ctx, mut pool, human, server) = setup().await; + let observer = crate::observer::ObserverHandle::in_process(); + let mut agent = pool.try_claim(Some(&scope())).unwrap(); + agent.acp.set_observer(Some(observer.clone()), 0); + agent + .acp + .set_observer_context(crate::observer::ObserverContext { + channel_id: Some("previous-channel".into()), + session_id: Some("previous-session".into()), + turn_id: Some("previous-turn".into()), + started_at: None, + }); + pool.return_agent(agent); + let request = enqueue(&ctx, &pool, &human, &scope()).await; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + crate::control_tasks::dispatch(&mut pool, &ctx, &tx); + let mut result = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(result.outcome, Ok(())); + let events = observer.snapshot(); + let frames: Vec<_> = events + .iter() + .filter(|event| { + event.payload["method"] == "session/prompt" + || event.payload["method"] == "session/update" + }) + .collect(); + assert!(frames.len() >= 2); + for frame in frames { + assert_eq!( + frame.channel_id.as_deref(), + Some(scope().channel_id().to_string().as_str()) + ); + assert_eq!(frame.session_id.as_deref(), Some("old-1")); + assert_eq!( + frame.turn_id.as_deref(), + Some(request.origin.command_event_id.as_str()) + ); + assert!(frame.started_at.is_some()); + } + result.agent.acp.shutdown().await; + pool.join_set.shutdown().await; + server.abort(); +} + +#[path = "compaction_resume_policy_tests.rs"] +mod resume_policy_tests; diff --git a/crates/buzz-acp/src/pool/compaction_tests.rs b/crates/buzz-acp/src/pool/compaction_tests.rs new file mode 100644 index 00000000000..513eec61599 --- /dev/null +++ b/crates/buzz-acp/src/pool/compaction_tests.rs @@ -0,0 +1,802 @@ +use super::*; +use crate::pool::compaction::*; +use crate::session_controls::{AdmittedSessionControl, CompactionTarget}; +use buzz_core::session_control::{ControlOrigin, SessionCommand}; + +fn job( + ctx: &PromptContext, + scope: &SessionScope, + target: CompactionTarget, +) -> AdmittedSessionControl { + let event = EventBuilder::new(Kind::Custom(9), "!compact") + .sign_with_keys(&Keys::generate()) + .unwrap(); + AdmittedSessionControl { + job: crate::session_controls::ControlJob { + command: SessionCommand::Compact, + origin: ControlOrigin { + command_event_id: event.id.to_hex(), + actor_pubkey: event.pubkey.to_hex(), + }, + scope: scope.clone(), + dispatch_epoch: Uuid::nil(), + admitted_at: nostr::Timestamp::now().as_secs(), + event, + }, + dispatch: ctx.session_controls.capture(scope), + target, + deadline: tokio::time::Instant::now() + Duration::from_secs(120), + } +} + +async fn run_compact( + status: &str, + rpc_error: bool, +) -> ( + ControlTaskResult, + Fixture, + Arc, + std::path::PathBuf, +) { + run_compact_observed(status, rpc_error, None).await +} + +async fn run_compact_observed( + status: &str, + rpc_error: bool, + observer: Option, +) -> ( + ControlTaskResult, + Fixture, + Arc, + std::path::PathBuf, +) { + let fixture = Fixture::new(); + let scope = scope(); + let mut ctx = ctx(); + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + fixture.departed_record(&scope, &ctx, &["delivered"]); + let target = CompactionTarget::Disk { + lease: fixture.record(&scope).live.unwrap().lease, + session_id: "old-1".into(), + }; + let job = job(&ctx, &scope, target.clone()); + ctx.session_controls + .queue_compaction(job.job.clone(), Ok(target)) + .unwrap(); + let admitted = ctx.session_controls.pending_compactions().pop().unwrap(); + let prompts = fixture.dir.path().join("compact-prompts.jsonl"); + let prompt_path = prompts.display().to_string(); + let root = fixture.dir.path().join("store").display().to_string(); + let mut env = vec![ + ("RESUME_CAP", "1"), + ("COMPACT_STATUS", status), + ("PROMPTS", prompt_path.as_str()), + ("CHECK_STARTED_ROOT", root.as_str()), + ("COMMAND_ID", admitted.job.origin.command_event_id.as_str()), + ]; + if rpc_error { + env.push(("FAIL_PROMPT", "1")); + } + let mut agent = fixture.agent(&env).await; + agent.acp.set_observer(observer, 0); + agent + .acp + .set_observer_context(crate::observer::ObserverContext { + channel_id: Some("previous-channel".into()), + session_id: Some("previous-session".into()), + turn_id: Some("previous-turn".into()), + started_at: None, + }); + let (_tx, rx) = tokio::sync::oneshot::channel(); + let ctx = Arc::new(ctx); + let result = run_compaction_task(agent, admitted, ctx.clone(), rx).await; + (result, fixture, ctx, prompts) +} + +#[tokio::test] +async fn compact_exact_prompt_and_durable_started_preserve_delivery_and_turns() { + let (mut result, fixture, ctx, prompts) = run_compact("completed", false).await; + assert_eq!(result.outcome, Ok(())); + assert_eq!(result.agent.state.turn_counts[&scope()], 1); + assert!(result.agent.state.deliveries[&scope()] + .delivered_event_ids + .contains("delivered")); + let bodies: Vec = std::fs::read_to_string(prompts) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!( + bodies, + vec![serde_json::json!({"sessionId":"old-1","prompt":[{"type":"text","text":"/compact"}]})] + ); + assert!(!fixture.methods().contains(&"session/new".into())); + ctx.session_controls + .finish_compaction_job(&result.job, result.outcome); + let receipt = fixture.record(&scope()).control_receipts.pop().unwrap(); + assert_eq!( + receipt.state, + crate::session_store::ControlReceiptState::Finished(Ok(())) + ); + result.agent.acp.shutdown().await; +} + +#[tokio::test] +async fn prompt_end_without_terminal_is_unconfirmed() { + let (mut result, fixture, ctx, _) = run_compact("missing", false).await; + assert_eq!( + result.outcome, + Err(buzz_core::session_control::ControlFailure::Unconfirmed) + ); + ctx.session_controls + .finish_compaction_job(&result.job, result.outcome); + assert!(fixture.record(&scope()).live.is_some()); + result.agent.acp.shutdown().await; +} + +#[tokio::test] +async fn failed_terminal_with_end_turn_is_failure() { + let (mut result, fixture, ctx, _) = run_compact("failed", false).await; + assert_eq!( + result.outcome, + Err(buzz_core::session_control::ControlFailure::CompactFailed) + ); + ctx.session_controls + .finish_compaction_job(&result.job, result.outcome); + assert!(fixture.record(&scope()).live.is_some()); + result.agent.acp.shutdown().await; +} + +#[tokio::test] +async fn completed_terminal_with_rpc_error_is_unconfirmed_without_retry() { + let (mut result, fixture, ctx, _) = run_compact("completed", true).await; + assert_eq!( + result.outcome, + Err(buzz_core::session_control::ControlFailure::Unconfirmed) + ); + ctx.session_controls + .finish_compaction_job(&result.job, result.outcome); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/prompt") + .count(), + 1 + ); + result.agent.acp.shutdown().await; +} + +#[tokio::test] +async fn compact_actual_signed_result_submission_matches_exact_command_and_safe_reason() { + use tokio::io::AsyncWriteExt; + for (status, expected) in [ + ("completed", Ok(())), + ( + "failed", + Err(buzz_core::session_control::ControlFailure::CompactFailed), + ), + ] { + let (mut result, _fixture, ctx, _) = run_compact(status, false).await; + ctx.session_controls + .finish_compaction_job(&result.job, result.outcome); + let publication = ctx + .session_controls + .take_publications() + .pop_front() + .unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut rest = ctx.rest_client.clone(); + rest.base_url = format!("http://{}", listener.local_addr().unwrap()); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let data = read_http_request(&mut socket).await; + let n = data.len(); + if let Some(offset) = data[..n].windows(4).position(|bytes| bytes == b"\r\n\r\n") { + if let Ok(event) = serde_json::from_slice::(&data[offset + 4..n]) + { + tx.send(event).unwrap(); + } + } + let body = "{\"accepted\":true}"; + socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + } + }); + crate::session_controls::publish::publish_result(&rest, &publication) + .await + .unwrap(); + let event = rx.recv().await.unwrap(); + event.verify().unwrap(); + assert_eq!(event.kind.as_u16(), 7); + assert_eq!(event.pubkey, ctx.agent_keys.public_key()); + let tags: Vec<_> = event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(); + let parsed = + buzz_core::session_control::SessionResult::from_tags(&event.content, &tags).unwrap(); + assert_eq!(parsed.command_id, result.job.job.origin.command_event_id); + assert_eq!( + parsed.result, + buzz_core::session_control::ControlResult::Terminal(expected) + ); + result.agent.acp.shutdown().await; + server.abort(); + } +} + +#[tokio::test] +async fn compact_finished_receipt_prevents_redelivery_after_controller_restart() { + let (mut result, fixture, ctx, _) = run_compact("completed", false).await; + ctx.session_controls + .finish_compaction_job(&result.job, result.outcome); + let restarted = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + restarted + .queue_compaction( + result.job.job.clone(), + Err(buzz_core::session_control::ControlFailure::NoSession), + ) + .unwrap(); + assert!(restarted.pending_compactions().is_empty()); + assert_eq!( + restarted.take_publications().pop_front().unwrap().result, + buzz_core::session_control::ControlResult::Terminal(Ok(())) + ); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/prompt") + .count(), + 1 + ); + result.agent.acp.shutdown().await; +} + +#[tokio::test] +async fn compact_publication_failure_is_observable_and_redelivery_never_reexecutes() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let (mut result, fixture, ctx, _) = run_compact("completed", false).await; + ctx.session_controls + .finish_compaction_job(&result.job, result.outcome); + let publication = ctx + .session_controls + .take_publications() + .pop_front() + .unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut rest = ctx.rest_client.clone(); + rest.base_url = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut data = [0; 8192]; + let _ = socket.read(&mut data).await.unwrap(); + socket.write_all(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}").await.unwrap(); + } + }); + assert!( + crate::session_controls::publish::publish_result(&rest, &publication) + .await + .is_err() + ); + ctx.session_controls + .queue_compaction( + result.job.job.clone(), + Err(buzz_core::session_control::ControlFailure::NoSession), + ) + .unwrap(); + assert!(ctx.session_controls.pending_compactions().is_empty()); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/prompt") + .count(), + 1 + ); + assert_eq!( + ctx.session_controls + .take_publications() + .pop_front() + .unwrap() + .result, + buzz_core::session_control::ControlResult::Terminal(Ok(())) + ); + result.agent.acp.shutdown().await; + server.abort(); +} + +#[tokio::test] +async fn compact_never_calls_session_new_memory_or_disk_success() { + for memory in [true, false] { + let fixture = Fixture::new(); + let scope = scope(); + let mut ctx = ctx(); + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + fixture.departed_record(&scope, &ctx, &["delivered"]); + let mut agent = fixture.agent(&[("RESUME_CAP", "1")]).await; + let target = if memory { + agent.state.store = None; + agent.state.sessions.insert(scope.clone(), "old-1".into()); + agent.state.turn_counts.insert(scope.clone(), 7); + agent.state.set_scope_owner_generation(scope.clone(), 5); + CompactionTarget::Memory { + worker: 0, + generation: 5, + session_id: "old-1".into(), + } + } else { + CompactionTarget::Disk { + lease: fixture.record(&scope).live.unwrap().lease, + session_id: "old-1".into(), + } + }; + let job = job(&ctx, &scope, target); + let existing = acquire_existing_session_for_compaction(&mut agent, &job, &ctx).await; + assert!(existing.is_ok()); + assert_eq!(agent.state.sessions[&scope], "old-1"); + assert_eq!(agent.state.turn_counts[&scope], if memory { 7 } else { 1 }); + assert!(!fixture + .methods() + .iter() + .any(|method| method == "session/new" || method == "session/prompt")); + if !memory { + assert!(agent.state.deliveries[&scope] + .delivered_event_ids + .contains("delivered")); + } + agent.acp.shutdown().await; + } +} + +#[tokio::test] +async fn compact_never_calls_session_new_failure_matrix() { + for mode in [ + "missing", + "corrupt", + "poison", + "conflict", + "config", + "unsupported", + "resume_error", + "live_elsewhere", + "timeout", + ] { + let fixture = Fixture::new(); + let scope = scope(); + let mut ctx = ctx(); + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + if mode != "missing" { + fixture.departed_record(&scope, &ctx, &[]); + } + let lease = if mode == "missing" { + Uuid::new_v4() + } else { + fixture.record(&scope).live.unwrap().lease + }; + let mut env = vec![("RESUME_CAP", "1")]; + if mode == "unsupported" { + env.clear(); + } + if mode == "resume_error" { + env.push(("RESUME_ERROR", "1")); + } + if mode == "timeout" { + env.push(("DELAY", "0.1")); + ctx.max_turn_duration = Duration::from_millis(20); + } + let mut agent = fixture.agent(&env).await; + if mode == "config" { + agent.desired_model = Some("changed".into()); + } + if mode == "live_elsewhere" { + fixture + .store + .commit(&scope, lease, |live| { + live.departed = None; + live.worker_index = 7; + }) + .unwrap(); + } + if mode == "corrupt" { + let path = std::fs::read_dir(fixture.store.channel_dir(scope.channel_id())) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| { + path.extension() + .is_some_and(|extension| extension == "json") + }) + .unwrap(); + std::fs::write(path, "corrupt").unwrap(); + } + if mode == "poison" { + fixture.store.fail_writes(true); + assert!(fixture.store.commit(&scope, lease, |_| {}).is_err()); + fixture.store.fail_writes(false); + assert!(fixture.store.is_poisoned(&scope)); + } + if mode == "conflict" { + let current = fixture.store.load(&scope); + let live = fixture.record(&scope).live.unwrap(); + fixture + .store + .claim(&scope, &Expect::from_outcome(¤t), live) + .unwrap(); + } + let job = job( + &ctx, + &scope, + CompactionTarget::Disk { + lease, + session_id: "old-1".into(), + }, + ); + assert!( + acquire_existing_session_for_compaction(&mut agent, &job, &ctx) + .await + .is_err(), + "{mode}" + ); + assert!( + !fixture + .methods() + .iter() + .any(|method| method == "session/new" || method == "session/prompt"), + "{mode}" + ); + agent.acp.shutdown().await; + } +} + +#[tokio::test] +async fn compact_unknown_runtime_without_advertised_command_is_unsupported() { + let (mut result, fixture, _ctx, _) = run_compact("", false).await; + assert_eq!( + result.outcome, + Err(buzz_core::session_control::ControlFailure::Unsupported) + ); + assert!(!fixture.methods().contains(&"session/prompt".into())); + result.agent.acp.shutdown().await; +} + +#[tokio::test] +async fn compact_started_write_failure_sends_no_prompt_and_releases_tracker() { + let fixture = Fixture::new(); + let scope = scope(); + let mut ctx = ctx(); + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let (state, _) = owned_state(&fixture, &scope, "old-1"); + let mut agent = fixture.agent(&[]).await; + agent.state = state; + agent.state.set_scope_owner_generation(scope.clone(), 1); + agent.acp.ensure_compaction_route("old-1"); + agent.acp.advertise_compaction("old-1"); + let target = CompactionTarget::Memory { + worker: 0, + generation: 1, + session_id: "old-1".into(), + }; + let admitted = job(&ctx, &scope, target.clone()); + ctx.session_controls + .queue_compaction(admitted.job, Ok(target)) + .unwrap(); + let admitted = ctx.session_controls.pending_compactions().pop().unwrap(); + fixture.store.fail_writes(true); + let (_tx, rx) = tokio::sync::oneshot::channel(); + let mut result = run_compaction_task(agent, admitted, Arc::new(ctx), rx).await; + assert_eq!( + result.outcome, + Err(buzz_core::session_control::ControlFailure::StoreUnavailable) + ); + assert!(!fixture.methods().contains(&"session/prompt".into())); + assert_eq!( + result + .agent + .acp + .begin_compaction("old-1", result.job.job.origin.clone()), + Ok(()) + ); + result.agent.acp.finish_compaction("old-1").ok(); + result.agent.acp.shutdown().await; +} + +#[tokio::test] +async fn compact_terminal_write_retries_after_repair_without_reexecuting_effect() { + let (mut result, fixture, ctx, _) = run_compact("completed", false).await; + let mut reconciliation = crate::session_controls::OrphanReconciliation::default(); + reconciliation.drive(&ctx.session_controls, 128); + reconciliation.wake().await; + reconciliation.drive(&ctx.session_controls, 128); + fixture.store.fail_writes(true); + ctx.session_controls + .finish_compaction_job(&result.job, result.outcome); + assert_eq!( + fixture.record(&scope()).control_receipts[0].state, + crate::session_store::ControlReceiptState::Started + ); + assert_eq!(ctx.session_controls.receipt_write_count(), 1); + reconciliation.drive(&ctx.session_controls, 128); + tokio::time::timeout(Duration::from_secs(3), reconciliation.wake()) + .await + .expect("new local receipt work must wake completed reconciliation"); + reconciliation.drive(&ctx.session_controls, 128); + assert!( + tokio::time::timeout(Duration::from_millis(50), reconciliation.wake()) + .await + .is_err(), + "failed receipt writes use the shared backoff rather than spinning" + ); + fixture.store.fail_writes(false); + let repaired = tokio::time::timeout(Duration::from_secs(3), async { + loop { + reconciliation.drive(&ctx.session_controls, 128); + tokio::select! { _ = reconciliation.wake() => {}, _ = tokio::time::sleep(Duration::from_millis(10)) => {} } + if fixture.record(&scope()).control_receipts[0].state != crate::session_store::ControlReceiptState::Started { break; } + } + }).await; + result.agent.acp.shutdown().await; + assert!( + repaired.is_ok(), + "known-finalized local Started must repair after persistence recovers" + ); + assert_eq!( + fixture.record(&scope()).control_receipts[0].state, + crate::session_store::ControlReceiptState::Finished(Err( + buzz_core::session_control::ControlFailure::StoreUnavailable + )) + ); + ctx.session_controls + .queue_compaction( + result.job.job.clone(), + Err(buzz_core::session_control::ControlFailure::NoSession), + ) + .unwrap(); + assert!(ctx.session_controls.pending_compactions().is_empty()); + assert_eq!(ctx.session_controls.receipt_write_count(), 0); + let publication = ctx + .session_controls + .take_publications() + .pop_front() + .unwrap(); + let signed = submit_one_result(&ctx, &publication).await; + signed.verify().unwrap(); + assert_eq!(signed.kind.as_u16(), 7); + assert_eq!(signed.content, "❌"); + let tags: Vec<_> = signed + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(); + let parsed = + buzz_core::session_control::SessionResult::from_tags(&signed.content, &tags).unwrap(); + assert_eq!(parsed.command_id, result.job.job.origin.command_event_id); + assert_eq!( + parsed.result, + buzz_core::session_control::ControlResult::Terminal(Err( + buzz_core::session_control::ControlFailure::StoreUnavailable + )) + ); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/prompt") + .count(), + 1 + ); +} + +async fn submit_one_result( + ctx: &PromptContext, + publication: &crate::session_controls::Publication, +) -> nostr::Event { + use tokio::io::AsyncWriteExt; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut rest = ctx.rest_client.clone(); + rest.base_url = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut socket).await; + let n = request.len(); + let offset = request[..n] + .windows(4) + .position(|bytes| bytes == b"\r\n\r\n") + .unwrap(); + let event = serde_json::from_slice::(&request[offset + 4..n]).unwrap(); + let body = "{\"accepted\":true}"; + socket + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await + .unwrap(); + event + }); + crate::session_controls::publish::publish_result(&rest, publication) + .await + .unwrap(); + server.await.unwrap() +} + +async fn read_http_request(socket: &mut tokio::net::TcpStream) -> Vec { + use tokio::io::AsyncReadExt; + let mut request = Vec::new(); + loop { + let mut chunk = [0; 4096]; + let n = socket.read(&mut chunk).await.unwrap(); + assert!(n > 0, "HTTP request ended before its complete body"); + request.extend_from_slice(&chunk[..n]); + assert!(request.len() <= 65536); + if let Some(offset) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") { + let headers = std::str::from_utf8(&request[..offset]).unwrap(); + let size = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + if request.len() >= offset + 4 + size { + return request; + } + } + } +} + +#[tokio::test] +async fn compact_finalized_started_survives_both_locked_finalization_calls() { + finalized_started_survives_unavailable_store("locked").await; +} + +#[tokio::test] +async fn compact_finalized_started_survives_unreadable_finalization_record() { + finalized_started_survives_unavailable_store("unreadable").await; +} + +async fn finalized_started_survives_unavailable_store(mode: &str) { + use crate::session_store::{ControlReceiptState, STORE_LOCK_WAIT}; + use buzz_core::session_control::{ControlFailure, ControlResult, SessionResult}; + let (mut result, fixture, ctx, _) = run_compact("completed", false).await; + let record = fixture.record(&scope()); + let expected = record.control_receipts[0].clone(); + assert_eq!(expected.state, ControlReceiptState::Started); + let path = std::fs::read_dir(fixture.store.channel_dir(scope().channel_id())) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| path.extension().is_some_and(|ext| ext == "json")) + .unwrap(); + let saved = std::fs::read(&path).unwrap(); + let lock_path = std::path::PathBuf::from(format!("{}.lock", path.display())); + let lock = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(lock_path) + .unwrap(); + if mode == "locked" { + fs2::FileExt::lock_exclusive(&lock).unwrap(); + } else { + std::fs::remove_file(&path).unwrap(); + std::fs::create_dir(&path).unwrap(); + } + let started = std::time::Instant::now(); + ctx.session_controls + .finish_compaction_job(&result.job, result.outcome); + let elapsed = started.elapsed(); + if mode == "locked" { + fs2::FileExt::unlock(&lock).unwrap(); + assert!( + elapsed >= STORE_LOCK_WAIT * 2, + "both real record-lock attempts must fail: {elapsed:?}" + ); + } else { + if path.is_dir() { + std::fs::remove_dir(&path).unwrap(); + } + std::fs::write(&path, saved).unwrap(); + } + assert_eq!(ctx.session_controls.receipt_write_count(), 1, "exact locally Started intent must survive failed finalization without a fresh successful read"); + let error = Err(ControlFailure::StoreUnavailable); + let publication = ctx + .session_controls + .take_publications() + .pop_front() + .unwrap(); + assert_eq!(publication.result, ControlResult::Terminal(error)); + let mut reconciliation = crate::session_controls::OrphanReconciliation::default(); + fixture.store.fail_writes(true); + reconciliation.drive(&ctx.session_controls, 128); + reconciliation.wake().await; + reconciliation.drive(&ctx.session_controls, 128); + assert!( + tokio::time::timeout(Duration::from_millis(50), reconciliation.wake()) + .await + .is_err(), + "repair must retain shared backoff" + ); + fixture.store.fail_writes(false); + tokio::time::timeout(Duration::from_secs(3), async { + loop { + reconciliation.drive(&ctx.session_controls, 128); + if ctx.session_controls.receipt_write_count() == 0 { break; } + tokio::select! { _ = reconciliation.wake() => {}, _ = tokio::time::sleep(Duration::from_millis(10)) => {} } + } + }).await.expect("known finalized receipt must repair after storage recovers"); + let repaired = fixture.record(&scope()).control_receipts[0].clone(); + assert_eq!(repaired.owner_instance, expected.owner_instance); + assert_eq!(repaired.owner_generation, expected.owner_generation); + assert_eq!(repaired.state, ControlReceiptState::Finished(error)); + ctx.session_controls + .queue_compaction(result.job.job.clone(), Err(ControlFailure::NoSession)) + .unwrap(); + assert!(ctx.session_controls.pending_compactions().is_empty()); + let publication = ctx + .session_controls + .take_publications() + .pop_front() + .unwrap(); + let signed = submit_one_result(&ctx, &publication).await; + signed.verify().unwrap(); + assert_eq!(signed.kind.as_u16(), 7); + let tags: Vec<_> = signed + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(); + let parsed = SessionResult::from_tags(&signed.content, &tags).unwrap(); + assert_eq!(parsed.command_id, result.job.job.origin.command_event_id); + assert_eq!(parsed.result, ControlResult::Terminal(error)); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/prompt") + .count(), + 1 + ); + result.agent.acp.shutdown().await; +} + +#[tokio::test] +async fn compact_observer_routes_resume_prompt_and_updates_to_admitted_scope() { + let observer = crate::observer::ObserverHandle::in_process(); + let (mut result, _, _, _) = + run_compact_observed("completed", false, Some(observer.clone())).await; + assert_eq!(result.outcome, Ok(())); + let events = observer.snapshot(); + for method in ["session/resume", "session/prompt", "session/update"] { + let frames: Vec<_> = events + .iter() + .filter(|event| event.payload["method"] == method) + .collect(); + assert!(!frames.is_empty(), "missing {method} observation"); + for frame in frames { + assert_eq!( + frame.channel_id.as_deref(), + Some(scope().channel_id().to_string().as_str()), + "{method} must route to admitted channel" + ); + assert_eq!(frame.session_id.as_deref(), Some("old-1")); + assert_eq!( + frame.turn_id.as_deref(), + Some(result.job.job.origin.command_event_id.as_str()) + ); + assert!(frame.started_at.is_some()); + } + } + result.agent.acp.shutdown().await; +} diff --git a/crates/buzz-acp/src/pool/first_mention.rs b/crates/buzz-acp/src/pool/first_mention.rs new file mode 100644 index 00000000000..7de1c0a0b56 --- /dev/null +++ b/crates/buzz-acp/src/pool/first_mention.rs @@ -0,0 +1,200 @@ +//! Cold start: find the first message in a thread that mentions the agent, +//! so a new provider session always sees why it was called in, even when +//! that message is older than the recent-message window. +//! +//! The scan walks the thread forward from its start through the relay's +//! thread pagination (`depth_limit` filter with a composite +//! `(created_at, id)` cursor), so it makes progress across replies that share +//! one second and stops at the first match. It is bounded; an incomplete scan +//! never reports a mention as found. + +use super::{json_to_context_message, ConversationContext, PromptContext, CONTEXT_FETCH_TIMEOUT}; +use buzz_core::kind::{KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2}; +use tokio::time::timeout; +use uuid::Uuid; + +/// Replies per page of the forward thread scan. +pub(crate) const FIRST_MENTION_PAGE: usize = 500; +/// Pages scanned before giving up. +pub(crate) const FIRST_MENTION_MAX_PAGES: usize = 20; +/// Any real thread depth; the filter field selects forward thread paging. +const THREAD_DEPTH_LIMIT: u32 = 1_000_000; + +#[derive(Debug)] +pub(crate) enum FirstMention { + /// The first reply that mentions the agent. + Found(serde_json::Value), + /// The thread root mentions the agent; it is always in context. + InRoot, + /// The whole thread was scanned and nothing mentions the agent. + NotMentioned, + /// The scan failed or ran out of budget before reaching a mention. + NotFound, +} + +/// Scan `root_event_id`'s thread from the start for the first mention of +/// `agent`. `query` posts raw filters to the relay's `/query`. +pub(crate) async fn find_first_mention( + root_event_id: &str, + channel_id: Uuid, + agent: nostr::PublicKey, + query: Query, +) -> FirstMention +where + Query: Fn(Vec) -> QueryFut, + QueryFut: std::future::Future>, +{ + let agent_hex = agent.to_hex(); + let channel = channel_id.to_string(); + let mut cursor: Option<(u64, String)> = None; + for page in 0..FIRST_MENTION_MAX_PAGES { + let mut filter = serde_json::json!({ + "kinds": [KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2], + "#e": [root_event_id], + "#h": [channel], + "depth_limit": THREAD_DEPTH_LIMIT, + "limit": FIRST_MENTION_PAGE, + }); + if let Some((created_at, id)) = &cursor { + filter["thread_cursor"] = serde_json::json!(created_at); + filter["thread_cursor_id"] = serde_json::json!(id); + } + let mut request = vec![filter]; + if page == 0 { + request.push(serde_json::json!({ + "kinds": [KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2], + "ids": [root_event_id], + "#h": [channel], + "limit": 1, + })); + } + let events = match timeout(CONTEXT_FETCH_TIMEOUT, query(request)).await { + Ok(Ok(serde_json::Value::Array(events))) => events, + _ => { + tracing::warn!( + channel_id = %channel_id, + root = root_event_id, + "first-mention scan failed; cold start proceeds without it" + ); + return FirstMention::NotFound; + } + }; + let (roots, mut replies): (Vec<_>, Vec<_>) = events + .into_iter() + .partition(|event| event["id"].as_str() == Some(root_event_id)); + if roots + .iter() + .any(|root| !crate::session_controls::reserved_raw(root) && mentions(root, &agent_hex)) + { + return FirstMention::InRoot; + } + replies.sort_by(|a, b| { + (a["created_at"].as_u64(), a["id"].as_str()) + .cmp(&(b["created_at"].as_u64(), b["id"].as_str())) + }); + if let Some(mention) = replies.iter().find(|event| { + is_message(event) + && !crate::session_controls::reserved_raw(event) + && mentions(event, &agent_hex) + }) { + return FirstMention::Found(mention.clone()); + } + let Some(last) = replies + .last() + .filter(|_| replies.len() >= FIRST_MENTION_PAGE) + else { + return FirstMention::NotMentioned; + }; + let (Some(created_at), Some(id)) = (last["created_at"].as_u64(), last["id"].as_str()) + else { + return FirstMention::NotFound; + }; + cursor = Some((created_at, id.to_owned())); + } + tracing::warn!( + channel_id = %channel_id, + root = root_event_id, + pages = FIRST_MENTION_MAX_PAGES, + "first mention not found within the scan budget" + ); + FirstMention::NotFound +} + +/// Find the first mention of the agent in `root`'s thread and pin it into +/// the fetched `context`. +pub(super) async fn cold_start_pin( + root: &str, + channel_id: Uuid, + context: &mut ConversationContext, + ctx: &PromptContext, +) -> FirstMention { + let agent = ctx.agent_keys.public_key(); + let rest = &ctx.rest_client; + let mention = find_first_mention(root, channel_id, agent, |filters| async move { + rest.query_raw(&filters).await + }) + .await; + if let FirstMention::Found(event) = &mention { + pin_first_mention(context, event, ctx.context_message_limit, &agent.to_hex()); + } + mention +} + +fn is_message(event: &serde_json::Value) -> bool { + event["kind"].as_u64().is_some_and(|kind| { + kind == u64::from(KIND_STREAM_MESSAGE) || kind == u64::from(KIND_STREAM_MESSAGE_V2) + }) +} + +fn mentions(event: &serde_json::Value, agent_hex: &str) -> bool { + event["tags"].as_array().is_some_and(|tags| { + tags.iter().any(|tag| { + tag[0].as_str() == Some("p") + && tag[1] + .as_str() + .is_some_and(|pubkey| pubkey.eq_ignore_ascii_case(agent_hex)) + }) + }) +} + +/// Put the first mention into a thread window that lacks it, right after the +/// root. The window keeps at most `limit` replies: the oldest reply not +/// written by the agent makes room, so the agent's own pinned reply stays. +pub(crate) fn pin_first_mention( + context: &mut ConversationContext, + mention: &serde_json::Value, + limit: u32, + agent_hex: &str, +) { + let ConversationContext::Thread { + messages, + root_present, + .. + } = context + else { + return; + }; + let Some(id) = mention["id"].as_str() else { + return; + }; + if messages.iter().any(|message| message.event_id == id) { + return; + } + let Some(message) = json_to_context_message(mention) else { + return; + }; + let first_reply = usize::from(*root_present); + if messages.len() - first_reply >= limit as usize { + let victim = messages[first_reply..] + .iter() + .position(|message| !message.pubkey.eq_ignore_ascii_case(agent_hex)); + // The assignment takes priority when both pins cannot fit. Prefer + // evicting a non-agent reply, otherwise the oldest agent reply. + messages.remove(first_reply + victim.unwrap_or(0)); + } + messages.insert(first_reply, message); +} + +#[cfg(test)] +#[path = "first_mention_tests.rs"] +mod tests; diff --git a/crates/buzz-acp/src/pool/first_mention_tests.rs b/crates/buzz-acp/src/pool/first_mention_tests.rs new file mode 100644 index 00000000000..9ebf3a41c88 --- /dev/null +++ b/crates/buzz-acp/src/pool/first_mention_tests.rs @@ -0,0 +1,322 @@ +use super::*; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use std::sync::Mutex; + +const CHANNEL: Uuid = Uuid::from_u128(42); + +struct Thread { + root: serde_json::Value, + /// Ascending by `(created_at, id)`, as the relay pages them. + replies: Vec, +} + +fn event( + keys: &Keys, + created_at: u64, + root: Option<&str>, + mention: Option<&Keys>, + text: &str, +) -> serde_json::Value { + let mut tags = vec![Tag::parse(["h", CHANNEL.to_string().as_str()]).unwrap()]; + if let Some(root) = root { + tags.push(Tag::parse(["e", root, "", "reply"]).unwrap()); + } + if let Some(agent) = mention { + tags.push(Tag::parse(["p", agent.public_key().to_hex().as_str()]).unwrap()); + } + let event = EventBuilder::new(Kind::Custom(9), text) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(keys) + .unwrap(); + serde_json::to_value(event).unwrap() +} + +/// `count` replies; reply `mention_at` mentions `agent`. With `same_second` +/// every reply shares one `created_at`. +fn thread(agent: &Keys, count: usize, mention_at: Option, same_second: bool) -> Thread { + thread_with_root(agent, count, mention_at, same_second, false) +} + +fn thread_with_root( + agent: &Keys, + count: usize, + mention_at: Option, + same_second: bool, + root_mentions: bool, +) -> Thread { + let human = Keys::generate(); + let root = event(&human, 1_000, None, root_mentions.then_some(agent), "root"); + let root_id = root["id"].as_str().unwrap().to_owned(); + let mut replies: Vec<_> = (0..count) + .map(|n| { + let at = if same_second { 2_000 } else { 2_000 + n as u64 }; + let mention = (mention_at == Some(n)).then_some(agent); + event(&human, at, Some(&root_id), mention, &format!("reply {n}")) + }) + .collect(); + replies.sort_by(|a, b| { + (a["created_at"].as_u64(), a["id"].as_str()) + .cmp(&(b["created_at"].as_u64(), b["id"].as_str())) + }); + Thread { root, replies } +} + +impl Thread { + fn root_id(&self) -> String { + self.root["id"].as_str().unwrap().to_owned() + } + + /// Relay stand-in: the bridge's forward thread pagination for the thread + /// filter, plus the root for an `ids` filter in the same request. + fn answer(&self, filters: &[serde_json::Value]) -> serde_json::Value { + let mut events = Vec::new(); + for filter in filters { + if filter.get("ids").is_some() { + events.push(self.root.clone()); + } else if let serde_json::Value::Array(page) = self.page(filter) { + events.extend(page); + } + } + serde_json::Value::Array(events) + } + + fn page(&self, filter: &serde_json::Value) -> serde_json::Value { + let limit = filter["limit"].as_u64().unwrap() as usize; + let cursor = filter["thread_cursor"].as_u64().map(|at| { + ( + at, + filter["thread_cursor_id"].as_str().unwrap_or("").to_owned(), + ) + }); + let page: Vec<_> = self + .replies + .iter() + .filter(|reply| match &cursor { + None => true, + Some((at, id)) => { + ( + reply["created_at"].as_u64().unwrap(), + reply["id"].as_str().unwrap().to_owned(), + ) > (*at, id.clone()) + } + }) + .take(limit) + .cloned() + .collect(); + serde_json::Value::Array(page) + } + + fn mentioned_id(&self, n: usize) -> String { + self.replies + .iter() + .find(|reply| reply["content"] == format!("reply {n}")) + .unwrap()["id"] + .as_str() + .unwrap() + .to_owned() + } +} + +/// Returns the thread filter of every request. +async fn search(thread: &Thread, agent: &Keys) -> (FirstMention, Vec) { + let filters = Mutex::new(Vec::new()); + let found = find_first_mention(&thread.root_id(), CHANNEL, agent.public_key(), |request| { + let thread_filter = request + .iter() + .find(|filter| filter.get("ids").is_none()) + .cloned() + .unwrap(); + filters.lock().unwrap().push(thread_filter); + let answer = thread.answer(&request); + async move { Ok(answer) } + }) + .await; + (found, filters.into_inner().unwrap()) +} + +fn found_id(found: &FirstMention) -> String { + match found { + FirstMention::Found(event) => event["id"].as_str().unwrap().to_owned(), + other => panic!("expected a mention, got {other:?}"), + } +} + +#[tokio::test] +async fn first_mention_found_on_second_page() { + let agent = Keys::generate(); + let thread = thread(&agent, 600, Some(520), false); + let (found, filters) = search(&thread, &agent).await; + assert_eq!(found_id(&found), thread.mentioned_id(520)); + assert_eq!(filters.len(), 2); + assert_eq!(filters[0]["#e"][0], thread.root_id()); + assert_eq!(filters[0]["#h"][0], CHANNEL.to_string()); + assert!(filters[0]["depth_limit"].is_u64()); + assert_eq!(filters[0]["limit"], FIRST_MENTION_PAGE); + assert_eq!( + filters[1]["thread_cursor"], + thread.replies[499]["created_at"] + ); + assert_eq!(filters[1]["thread_cursor_id"], thread.replies[499]["id"]); +} + +#[tokio::test] +async fn same_second_page_boundary_advances() { + // 600 replies in one second: a timestamp-only cursor would repeat page one. + let agent = Keys::generate(); + let thread = thread(&agent, 600, None, true); + let (found, filters) = search(&thread, &agent).await; + assert!(matches!(found, FirstMention::NotMentioned)); + assert_eq!(filters.len(), 2); + assert_eq!( + filters[1]["thread_cursor"], + thread.replies[499]["created_at"] + ); + assert_eq!(filters[1]["thread_cursor_id"], thread.replies[499]["id"]); +} + +#[tokio::test] +async fn same_second_mention_is_found() { + let agent = Keys::generate(); + let thread = thread(&agent, 600, Some(550), true); + let (found, _) = search(&thread, &agent).await; + assert_eq!(found_id(&found), thread.mentioned_id(550)); +} + +#[tokio::test] +async fn budget_exhausted_reports_not_found() { + let agent = Keys::generate(); + let pages = FIRST_MENTION_MAX_PAGES + 1; + let thread = thread(&agent, pages * FIRST_MENTION_PAGE, None, false); + let (found, filters) = search(&thread, &agent).await; + assert!(matches!(found, FirstMention::NotFound)); + assert_eq!(filters.len(), FIRST_MENTION_MAX_PAGES); +} + +#[tokio::test] +async fn root_mention_needs_no_pin() { + let agent = Keys::generate(); + let thread = thread_with_root(&agent, 30, Some(3), false, true); + let (found, filters) = search(&thread, &agent).await; + assert!(matches!(found, FirstMention::InRoot)); + assert_eq!(filters.len(), 1); +} + +#[tokio::test] +async fn short_page_without_mention_is_not_mentioned() { + let agent = Keys::generate(); + let thread = thread(&agent, 30, None, false); + let (found, filters) = search(&thread, &agent).await; + assert!(matches!(found, FirstMention::NotMentioned)); + assert_eq!(filters.len(), 1); +} + +fn window(thread: &Thread, newest: usize) -> ConversationContext { + let start = thread.replies.len() - newest; + let mut messages = vec![json_to_context_message(&thread.root).unwrap()]; + messages.extend( + thread.replies[start..] + .iter() + .map(|reply| json_to_context_message(reply).unwrap()), + ); + ConversationContext::Thread { + messages, + total: thread.replies.len() + 1, + root_present: true, + truncated: start > 0, + } +} + +fn ids(context: &ConversationContext) -> Vec { + match context { + ConversationContext::Thread { messages, .. } => { + messages.iter().map(|m| m.event_id.clone()).collect() + } + ConversationContext::Dm { .. } => unreachable!(), + } +} + +#[test] +fn thread_context_pins_first_mention_outside_window() { + let agent = Keys::generate(); + let thread = thread(&agent, 30, Some(2), false); + let mut context = window(&thread, 12); + let mention = thread.replies[2].clone(); + pin_first_mention(&mut context, &mention, 12, &agent.public_key().to_hex()); + let ids = ids(&context); + assert_eq!(ids.len(), 13, "root + 12 replies"); + assert_eq!(ids[0], thread.root_id()); + assert_eq!( + ids[1], + thread.mentioned_id(2), + "pinned right after the root" + ); + assert!(!ids.contains(&thread.replies[18]["id"].as_str().unwrap().to_owned())); + let ConversationContext::Thread { + truncated, total, .. + } = context + else { + unreachable!() + }; + assert!(truncated); + assert_eq!(total, 31); +} + +#[test] +fn first_mention_inside_window_is_not_duplicated() { + let agent = Keys::generate(); + let thread = thread(&agent, 30, Some(25), false); + let mut context = window(&thread, 12); + let before = ids(&context); + pin_first_mention( + &mut context, + &thread.replies[25].clone(), + 12, + &agent.public_key().to_hex(), + ); + assert_eq!(ids(&context), before); +} + +#[test] +fn first_mention_equal_to_root_is_not_pinned() { + let agent = Keys::generate(); + let thread = thread(&agent, 30, None, false); + let mut context = window(&thread, 12); + let before = ids(&context); + pin_first_mention( + &mut context, + &thread.root.clone(), + 12, + &agent.public_key().to_hex(), + ); + assert_eq!(ids(&context), before); +} + +#[tokio::test] +async fn restart_is_never_first_assignment_or_root() { + let agent = Keys::generate(); + let human = Keys::generate(); + let root = event(&human, 1000, None, Some(&agent), "!restart"); + let root_id = root["id"].as_str().unwrap().to_owned(); + let control = event(&human, 1001, Some(&root_id), Some(&agent), "!restart all"); + let assignment = event( + &human, + 1002, + Some(&root_id), + Some(&agent), + "real assignment", + ); + let fixture = Thread { + root, + replies: vec![control, assignment.clone()], + }; + let found = find_first_mention(&root_id, CHANNEL, agent.public_key(), |filters| { + let response = fixture.answer(&filters); + async move { Ok(response) } + }) + .await; + match found { + FirstMention::Found(value) => assert_eq!(value["id"], assignment["id"]), + other => panic!("expected actual assignment, got {other:?}"), + } +} diff --git a/crates/buzz-acp/src/pool/portable_retirement_tests.rs b/crates/buzz-acp/src/pool/portable_retirement_tests.rs new file mode 100644 index 00000000000..cee0aa2d301 --- /dev/null +++ b/crates/buzz-acp/src/pool/portable_retirement_tests.rs @@ -0,0 +1,224 @@ +//! Portable controller/store/pool gates, with real fake-ACP workers on every OS. +use super::*; +use crate::acp::TerminationReport; +use crate::session_controls::{ControlJob, SessionControlRuntime}; +use crate::session_store::SessionStore; +use buzz_core::session_control::{ + ControlFailure, ControlOrigin, ControlResult, RestartDepth, SessionCommand, +}; +use nostr::{EventBuilder, Keys, Kind, Tag}; + +const ADAPTER: &str = r#" +import json,sys +for line in sys.stdin: + request=json.loads(line) + if 'id' in request: + print(json.dumps({'jsonrpc':'2.0','id':request['id'], + 'result':{'protocolVersion':2,'agentCapabilities':{}}}),flush=True) +"#; + +struct Fixture { + _dir: tempfile::TempDir, + controls: SessionControlRuntime, + scope: SessionScope, +} + +impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let store = SessionStore::open( + dir.path().join("store"), + "wss://relay.example", + &Keys::generate().public_key().to_hex(), + ) + .unwrap(); + Self { + _dir: dir, + controls: SessionControlRuntime::new(Some(Arc::new(store))), + scope: SessionScope::Conversation { + channel_id: Uuid::new_v4(), + }, + } + } + + // These tests start at the controller boundary. Listener authorization is + // covered separately; production never constructs jobs from unsigned input. + fn job(&self, depth: RestartDepth) -> ControlJob { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), SessionCommand::Restart(depth).as_content()) + .tags([ + Tag::parse(["h", &self.scope.channel_id().to_string()]).unwrap(), + Tag::parse(["nonce", &Uuid::new_v4().to_string()]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + ControlJob { + command: SessionCommand::Restart(depth), + origin: ControlOrigin { + command_event_id: event.id.to_hex(), + actor_pubkey: keys.public_key().to_hex(), + }, + scope: self.scope.clone(), + dispatch_epoch: Uuid::new_v4(), + admitted_at: nostr::Timestamp::now().as_secs(), + event, + } + } + + async fn agent(&self, index: usize) -> OwnedAgent { + let python = if cfg!(windows) { "python" } else { "python3" }; + let mut acp = AcpClient::spawn( + python, + &["-u".into(), "-c".into(), ADAPTER.into()], + &[], + false, + ) + .await + .unwrap(); + acp.initialize().await.unwrap(); + OwnedAgent { + index, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, + agent_name: "portable-fake".into(), + goose_system_prompt_supported: None, + protocol_version: 2, + background_context: None, + } + } +} + +#[tokio::test] +async fn portable_retirement_all_targets_must_stop_before_gate_opens() { + let f = Fixture::new(); + let mut a = f.agent(0).await; + let mut b = f.agent(1).await; + let job = f.job(RestartDepth::None); + let id = job.origin.command_event_id.clone(); + assert!(f.controls.restart(job).unwrap()); + f.controls.plan_retirement(&f.scope, [0, 1]); + assert!(f.controls.retirement_pending(&f.scope)); + let report = a.acp.terminate().await; + assert_eq!(report, TerminationReport::Clean); + f.controls.retirement_report(0, report); + assert!(f.controls.retirement_pending(&f.scope)); + assert!(f.controls.take_publications().is_empty()); + let report = b.acp.terminate().await; + assert_eq!(report, TerminationReport::Clean); + f.controls.retirement_report(1, report); + assert!(!f.controls.retirement_pending(&f.scope)); + assert_eq!( + f.controls.prior_result(&f.scope, &id), + Some(ControlResult::Terminal(Ok(()))) + ); + assert_eq!( + f.controls.restart_intent(&f.scope).unwrap().depth, + RestartDepth::None + ); +} + +#[tokio::test] +async fn portable_retirement_failure_retry_and_supersession_keep_gate_and_intent() { + let f = Fixture::new(); + let mut a = f.agent(0).await; + let first = f.job(RestartDepth::None); + let first_id = first.origin.command_event_id.clone(); + assert!(f.controls.restart(first).unwrap()); + f.controls.plan_retirement(&f.scope, [0]); + let second = f.job(RestartDepth::Recent); + assert!(f.controls.restart(second).unwrap()); + f.controls.plan_retirement(&f.scope, [0]); + assert_eq!( + f.controls.prior_result(&f.scope, &first_id), + Some(ControlResult::Terminal(Err(ControlFailure::Superseded))) + ); + f.controls + .retirement_report(0, TerminationReport::Unverified); + assert!(f.controls.retirement_pending(&f.scope)); + assert!(f.controls.retiring_workers().is_empty()); + let retry = f.job(RestartDepth::All); + let id = retry.origin.command_event_id.clone(); + assert!(f.controls.restart(retry).unwrap()); + f.controls.plan_retirement(&f.scope, []); + assert_eq!(f.controls.retiring_workers().len(), 1); + assert!(f.controls.retirement_pending(&f.scope)); + let report = a.acp.terminate().await; + assert_eq!(report, TerminationReport::Clean); + f.controls.retirement_report(0, report); + assert!(!f.controls.retirement_pending(&f.scope)); + assert_eq!( + f.controls.prior_result(&f.scope, &id), + Some(ControlResult::Terminal(Ok(()))) + ); + assert_eq!( + f.controls.restart_intent(&f.scope).unwrap().depth, + RestartDepth::All + ); +} + +#[tokio::test] +async fn portable_retirement_lost_evidence_rejects_late_clean_and_explicit_retry() { + let f = Fixture::new(); + assert!(f.controls.restart(f.job(RestartDepth::None)).unwrap()); + f.controls.plan_retirement(&f.scope, [0]); + f.controls.lose_retirement_evidence(0); + f.controls.retirement_report(0, TerminationReport::Clean); + let retry = f.job(RestartDepth::All); + let id = retry.origin.command_event_id.clone(); + assert!(f.controls.restart(retry).unwrap()); + f.controls.plan_retirement(&f.scope, []); + f.controls.retirement_report(0, TerminationReport::Clean); + assert!(f.controls.retirement_evidence_lost(0)); + assert!(f.controls.retirement_pending(&f.scope)); + assert!(f.controls.retiring_workers().is_empty()); + assert_eq!( + f.controls.prior_result(&f.scope, &id), + Some(ControlResult::Terminal(Err(ControlFailure::Unconfirmed))) + ); +} + +#[tokio::test] +async fn portable_retirement_foreign_foreground_reservation_waits_for_return() { + let f = Fixture::new(); + let a = f.agent(0).await; + let mut pool = AgentPool::from_slots(vec![Some(a)]); + pool.record_scope_owner(f.scope.clone(), 0); + pool.mark_retire_on_return(0); + assert!(pool.try_claim(None).is_none()); + let foreign = SessionScope::Conversation { + channel_id: Uuid::new_v4(), + }; + let handle = pool.join_set.spawn(std::future::pending::<()>()); + pool.task_map.insert( + handle.id(), + TaskMeta { + agent_index: 0, + channel_id: Some(foreign.channel_id()), + scope: Some(foreign), + turn_id: "foreign-foreground".into(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + control_dispatch: None, + successful_steer_deliveries: Default::default(), + }, + ); + let scopes = HashSet::from([f.scope.clone()]); + assert!(pool.take_retirement_target(0, &scopes, true).is_none()); + pool.task_map.clear(); + handle.abort(); + let agent = pool.agents[0].take().unwrap(); + assert!(pool.needs_retirement(&agent)); + pool.return_for_retirement(agent); + assert!(pool.try_claim(None).is_none()); + let mut agent = pool.take_retirement_target(0, &scopes, true).unwrap(); + assert_eq!(agent.acp.terminate().await, TerminationReport::Clean); + pool.finish_worker_retirement(0); + assert!(pool.retirement_targets(&f.scope).is_empty()); +} diff --git a/crates/buzz-acp/src/pool/real_resume_tests.rs b/crates/buzz-acp/src/pool/real_resume_tests.rs new file mode 100644 index 00000000000..a56661cfaa5 --- /dev/null +++ b/crates/buzz-acp/src/pool/real_resume_tests.rs @@ -0,0 +1,89 @@ +//! Opt-in provider checks: the remembered value must survive adapter death. + +use std::time::Duration; + +use crate::acp::AcpClient; +use crate::observer::ObserverHandle; + +async fn spawn_adapter(command: &str, observer: &ObserverHandle) -> AcpClient { + // Provider authentication stays in the normal runtime environment. These + // tests never grant the model a managed Buzz identity or relay credentials. + let mut args = Vec::new(); + for key in ["BUZZ_PRIVATE_KEY", "BUZZ_AUTH_TAG", "BUZZ_RELAY_URL"] { + args.extend(["-u".to_owned(), key.to_owned()]); + } + args.push(command.to_owned()); + let mut acp = AcpClient::spawn("/usr/bin/env", &args, &[], false) + .await + .expect("spawn installed adapter"); + acp.set_observer(Some(observer.clone()), 0); + acp.initialize().await.expect("initialize real adapter"); + assert!(acp.resume_supported(), "adapter must advertise resume"); + acp +} + +async fn real_adapter_remembers(variable: &str, default_command: &str) { + let command = std::env::var(variable).unwrap_or_else(|_| default_command.to_owned()); + let workspace = tempfile::tempdir().expect("temporary provider workspace"); + let cwd = workspace.path().to_str().expect("UTF-8 temporary path"); + let word = format!("GRANITE_{}", uuid::Uuid::new_v4().simple()); + let observer = ObserverHandle::in_process(); + let mut first = spawn_adapter(&command, &observer).await; + let sid = first + .session_new(cwd, vec![], None, None) + .await + .expect("create provider session"); + first + .session_prompt_with_idle_timeout( + &sid, + &format!("Remember the code word {word}. Reply only ACK. Do not use tools."), + Duration::from_secs(90), + Duration::from_secs(180), + ) + .await + .expect("first provider turn"); + // End the actual adapter and its process group before constructing another. + first.shutdown().await; + drop(first); + + let mut second = spawn_adapter(&command, &observer).await; + second + .session_resume(&sid, cwd, vec![], None, None) + .await + .expect("resume in a fresh adapter process"); + let after_resume = observer.snapshot().last().map_or(0, |event| event.seq); + let result = second + .session_prompt_with_idle_timeout( + &sid, + "What code word did I give you in the previous message? Reply only with that code word. Do not read files or use tools.", + Duration::from_secs(90), + Duration::from_secs(180), + ) + .await; + second.shutdown().await; + result.expect("second provider turn"); + let answer: String = observer + .snapshot() + .iter() + .filter(|event| event.seq > after_resume && event.kind == "acp_read") + .map(|event| &event.payload["params"]["update"]) + .filter(|update| update["sessionUpdate"] == "agent_message_chunk") + .filter_map(|update| update["content"]["text"].as_str()) + .collect(); + assert!( + answer.contains(&word), + "resumed answer lost the random code word: {answer}" + ); +} + +#[tokio::test] +#[ignore = "requires an installed authenticated Claude adapter; makes provider calls"] +async fn real_claude_resume_remembers_previous_turn() { + real_adapter_remembers("BUZZ_TEST_CLAUDE_ACP", "claude-agent-acp").await; +} + +#[tokio::test] +#[ignore = "requires an installed authenticated Codex adapter; makes provider calls"] +async fn real_codex_resume_remembers_previous_turn() { + real_adapter_remembers("BUZZ_TEST_CODEX_ACP", "codex-acp").await; +} diff --git a/crates/buzz-acp/src/pool/restart_status.rs b/crates/buzz-acp/src/pool/restart_status.rs new file mode 100644 index 00000000000..0d746933d95 --- /dev/null +++ b/crates/buzz-acp/src/pool/restart_status.rs @@ -0,0 +1,541 @@ +//! The restart status is a typed operational turn, never a synthetic human event. +use super::*; +use crate::session_controls::status::StatusJob; +use crate::session_store::RestartStatusPhase; +use tokio::time::{timeout_at, Instant}; + +pub(crate) struct StatusTaskResult { + pub agent: OwnedAgent, + pub job: StatusJob, + pub transport_failed: bool, +} + +const REQUEST: &str = "The session has been restarted. Briefly report the current task status using only the context supplied to this session: the task as you understand it, what is known to be done or still open, and the next step if clear. State any missing context explicitly. Distinguish missing task details from missing messages; use the supplied messages and history coverage markers (included, total, truncated) for any coverage claim. Never call a supplied message absent. Omitted history does not prove that no work was done or that a prior request was unfulfilled; report unknown outcomes as unknown. Do not invent progress. Use the same language as the human conversation in this thread, even though this operational request is in English. Write one short message. This is a status request only: do not continue task execution, inspect files or logs, reconstruct omitted history, launch tools or subagents, or send messages yourself. Return only the status text. The harness will sign and deliver your text to this thread; no delivery tool is needed."; + +fn protocol(message: &str) -> AcpError { + AcpError::Protocol(message.into()) +} + +pub(crate) async fn run_status_task( + mut agent: OwnedAgent, + mut job: StatusJob, + ctx: Arc, + dispatch: crate::session_controls::ControlDispatch, + mut cancel: tokio::sync::oneshot::Receiver, +) -> StatusTaskResult { + let controls = &ctx.session_controls; + agent.state.control_dispatch = Some(dispatch.clone()); + let result = tokio::select! { + biased; + _ = &mut cancel => { controls.cancel_status_for_token(&job.saved.scope, job.saved.token); Err(protocol("restart status cancelled")) }, + result = tokio::time::timeout_at(job.deadline, execute(&mut agent, &mut job, &dispatch, &ctx)) => { + result.unwrap_or_else(|_| Err(protocol("restart status deadline expired"))) + } + }; + // Capture must never leak into later normal turns, including timeout paths. + agent.acp.take_status_text(); + let mut transport_failed = result.is_err(); + if let Err(error) = result { + tracing::warn!(%error, "restart succeeded but status follow-up was not confirmed"); + if agent.acp.has_in_flight_prompt() { + transport_failed |= agent + .acp + .cancel_with_cleanup_grace( + agent + .state + .sessions + .get(&job.saved.scope) + .map(String::as_str) + .unwrap_or_default(), + CONTROL_CANCEL_GRACE, + ) + .await + .is_err(); + } + // Failure feedback has its own small delivery grace. It releases no new + // model authority and cannot hold human queue priority past 120 seconds. + if job.saved.active() && dispatch.current() { + job.deadline = job.deadline.max(Instant::now() + Duration::from_secs(5)); + let _ = timeout_at( + Instant::now() + Duration::from_secs(5), + publish_failure(&mut job, &dispatch, &ctx), + ) + .await; + } + if job.saved.active() { + let prepared = job.saved.prepared.clone(); + let failure_notice = job.saved.failure_notice; + if let Err(persist_error) = controls.transition_status( + &mut job, + RestartStatusPhase::Unconfirmed, + prepared, + failure_notice, + false, + ) { + tracing::warn!(%persist_error, "restart status uncertainty remains in its durable attempt record"); + } + } + } + controls.finish_status(&job); + StatusTaskResult { + agent, + job, + transport_failed, + } +} + +async fn execute( + agent: &mut OwnedAgent, + job: &mut StatusJob, + dispatch: &crate::session_controls::ControlDispatch, + ctx: &PromptContext, +) -> Result<(), AcpError> { + let controls = &ctx.session_controls; + if !controls.status_current(job, dispatch) + || !controls.status_authorized(&job.saved, ctx).await + || !controls.status_current(job, dispatch) + { + return Err(protocol("restart status authority changed")); + } + if job.saved.phase == RestartStatusPhase::Prepared { + return publish_prepared(job, dispatch, ctx).await; + } + if job.saved.phase != RestartStatusPhase::Pending { + return Err(protocol("restart status attempt already started")); + } + // Persist before acquisition or model dispatch. An uncertain Started record + // cannot authorize another model attempt after reconstruction. + controls + .transition_status(job, RestartStatusPhase::Started, None, false, false) + .map_err(|_| protocol("restart status attempt could not be persisted"))?; + let scope = job.saved.scope.clone(); + let channel_id = scope.channel_id(); + let channel_info = ctx.channel_info.resolve_channel_metadata(channel_id).await; + let (is_dm, title, channel_type) = + resolve_new_session_channel_context(channel_info.as_ref()).await; + let core = if ctx.memory_enabled { + if let Some(owner) = &ctx.agent_owner_pubkey { + timeout( + Duration::from_secs(3), + crate::engram_fetch::build_core_section(&ctx.rest_client, &ctx.agent_keys, owner), + ) + .await + .ok() + .flatten() + } else { + None + } + } else { + None + }; + let canvas = if !is_dm { + fetch_canvas_section(channel_id, &ctx.rest_client).await + } else { + None + }; + let huddle = if let Some(owner) = &ctx.agent_owner_pubkey { + fetch_huddle_instructions(channel_id, owner, &ctx.rest_client).await + } else { + None + }; + let (session, start) = session_continuity::acquire_session( + agent, + ctx, + core.as_deref(), + NewSessionChannelContext { + huddle_instructions: huddle.as_deref(), + canvas: canvas.as_deref(), + name: title.as_deref(), + scope: Some(&scope), + channel_type: channel_type.as_deref(), + }, + &scope, + ) + .await?; + agent.state.sessions.insert(scope.clone(), session.clone()); + agent + .state + .deliveries + .insert(scope.clone(), ChannelDeliveryState::default()); + if let Some(core) = &core { + agent + .state + .core_sections + .insert(scope.clone(), core.clone()); + } + if let Some(canvas) = &canvas { + agent + .state + .canvas_sections + .insert(scope.clone(), canvas.clone()); + } + agent.publish_session_start(ctx.session_status.as_ref(), &scope, &session, &start); + agent.acp.notify_session_spawned(&session); + let target = context_target(job, is_dm); + let depth = dispatch + .intent + .as_ref() + .filter(|intent| intent.token == job.saved.token) + .map(|intent| intent.depth) + .ok_or_else(|| protocol("restart status lost its captured token"))?; + let humans = super::status_language::trusted_authors(&job.saved.origin.actor_pubkey, ctx).await; + // The selected-history reader already has a bounded newest page even for + // None. Only a language code survives evidence excluded by that policy. + let (mut context, mention, language) = + session_history::selected_with_language(&target, channel_id, depth, ctx, &humans).await; + if depth == buzz_core::session_control::RestartDepth::Recent { + let recent = if ctx.context_message_limit > 0 { + fetch_conversation_context_for_target(channel_id, &target, ctx, false, &HashSet::new()) + .await + } else { + None + }; + merge_recent(&mut context, recent); + } + let pins = match &mention { + Some(first_mention::FirstMention::Found(event)) => event["id"] + .as_str() + .map(|id| HashSet::from([id.to_owned()])) + .unwrap_or_default(), + _ => HashSet::new(), + }; + session_history::enforce_render_budget(&mut context, None, &pins); + let ids = conversation_context_event_ids(context.as_ref()); + if let Some(binding) = agent.state.status.get(&scope) { + let fetched = session_visibility::FetchedHistory::capture(true, context.as_ref()); + binding.set_history(session_visibility::history_coverage( + &fetched, + context.as_ref(), + &HashSet::new(), + matches!(mention, Some(first_mention::FirstMention::NotFound)), + &scope, + is_dm, + )); + } + let standing = crate::queue::StandingContext { + base_prompt: ctx.base_prompt.as_deref(), + system_prompt: ctx.system_prompt.as_deref(), + team_instructions: ctx.team_instructions.as_deref(), + agent_core: core.as_deref(), + huddle_instructions: huddle.as_deref(), + agent_canvas: canvas.as_deref(), + }; + let mut sections = Vec::new(); + if !agent.has_system_prompt_support() { + sections.extend(standing.sections()); + } + sections.push(session_continuity::format_session_marker( + &start, + mention.as_ref(), + )); + if let Some(context) = &context { + sections.push(crate::queue::format_conversation_context(context, None)); + } + let language_instruction = match language { + "ru" => " Write this status in Russian.", + "en" => " Write this status in English.", + _ => "", + }; + sections.push(format!( + "\n{REQUEST}{language_instruction}\n" + )); + if !controls.status_current(job, dispatch) { + return Err(protocol("restart status superseded before prompt")); + } + agent.acp.begin_status_capture(&session); + let reason = agent + .acp + .session_prompt_with_idle_timeout( + &session, + §ions.join("\n\n"), + ctx.idle_timeout.min(Duration::from_secs(120)), + ctx.max_turn_duration.min(Duration::from_secs(120)), + ) + .await?; + let text = agent + .acp + .take_status_text() + .ok_or_else(|| protocol("restart status returned no complete text"))?; + if reason != StopReason::EndTurn { + return Err(protocol("restart status did not complete normally")); + } + if !controls.status_current(job, dispatch) { + return Err(protocol("restart status superseded after prompt")); + } + let thread_ref = if is_dm { + None + } else { + let root = nostr::EventId::from_hex(&crate::queue::reply_thread(&job.saved.command, None)) + .map_err(|_| protocol("restart status has no valid thread root"))?; + Some(buzz_sdk::ThreadRef { + root_event_id: root, + parent_event_id: root, + }) + }; + let status_tags = agent + .state + .status + .get(&scope) + .and_then(crate::session_status::StatusBinding::event_tags) + .unwrap_or_default() + .into_iter() + .map(nostr::Tag::parse) + .collect::, _>>() + .map_err(|_| protocol("restart status metadata could not be serialized"))?; + let event = + buzz_sdk::build_message(channel_id, &text, thread_ref.as_ref(), &[], false, &[], &[]) + .map_err(|_| protocol("restart status could not be built"))? + .tags(status_tags) + .sign_with_keys(&ctx.agent_keys) + .map_err(|_| protocol("restart status could not be signed"))?; + controls + .transition_status(job, RestartStatusPhase::Prepared, Some(event), false, false) + .map_err(|_| protocol("restart status bytes could not be persisted"))?; + publish_prepared(job, dispatch, ctx).await?; + let standing_sent = !agent.has_system_prompt_support(); + record_scope_delivery_success( + agent, + scope.clone(), + standing_sent, + &ids, + &match target { + ContextTarget::Thread(root) => HashSet::from([root]), + _ => HashSet::new(), + }, + ); + if ctx.max_turns_per_session > 0 { + let count = agent.state.turn_counts.entry(scope.clone()).or_insert(0); + *count = count.saturating_add(1); + agent.state.persist_turns(&scope); + } + let usage = agent.acp.take_turn_usage(); + publish_agent_turn_metric( + ctx, + usage, + Some(channel_id), + &session, + &format!("{}:status", job.saved.origin.command_event_id), + Some(buzz_core::agent_turn_metric::StopReason::EndTurn), + ) + .await; + Ok(()) +} + +fn context_target(job: &StatusJob, is_dm: bool) -> ContextTarget { + if is_dm { + ContextTarget::Dm + } else { + ContextTarget::Thread(crate::queue::reply_thread(&job.saved.command, None)) + } +} +fn reply_tags(job: &StatusJob, is_dm: bool) -> ThreadTags { + if is_dm { + return ThreadTags::default(); + } + let root = crate::queue::reply_thread(&job.saved.command, None); + ThreadTags { + root_event_id: Some(root.clone()), + parent_event_id: Some(root), + ..Default::default() + } +} +fn merge_recent(context: &mut Option, recent: Option) { + let Some(recent) = recent else { + return; + }; + let ( + Some(ConversationContext::Thread { + messages, + total, + truncated, + .. + }) + | Some(ConversationContext::Dm { + messages, + total, + truncated, + }), + ConversationContext::Thread { + messages: tail, + total: count, + truncated: cut, + .. + } + | ConversationContext::Dm { + messages: tail, + total: count, + truncated: cut, + }, + ) = (context.as_mut(), recent) + else { + return; + }; + let mut ids: HashSet<_> = messages + .iter() + .map(|message| message.event_id.clone()) + .collect(); + messages.extend( + tail.into_iter() + .filter(|message| ids.insert(message.event_id.clone())), + ); + messages.sort_by(|a, b| (&a.timestamp, &a.event_id).cmp(&(&b.timestamp, &b.event_id))); + *total = (*total).max(count).max(messages.len()); + *truncated = *truncated || cut || *total > messages.len(); +} +async fn publish_prepared( + job: &mut StatusJob, + dispatch: &crate::session_controls::ControlDispatch, + ctx: &PromptContext, +) -> Result<(), AcpError> { + let event = if job.saved.failure_notice { + job.saved.notice.clone() + } else { + job.saved.prepared.clone() + } + .ok_or_else(|| protocol("restart status has no prepared bytes"))?; + if event.pubkey != ctx.agent_keys.public_key() || event.verify().is_err() { + return Err(protocol( + "restart status prepared signer does not match target agent", + )); + } + let channel = job.saved.scope.channel_id().to_string(); + let route = crate::queue::parse_thread_tags(&event); + let expected_root = if job.saved.scope.is_thread() { + job.saved.scope.root_event_id().map(str::to_owned) + } else { + ctx.channel_info + .resolve_channel_metadata(job.saved.scope.channel_id()) + .await + .filter(|info| info.channel_type != "dm") + .map(|_| crate::queue::reply_thread(&job.saved.command, None)) + }; + if route.root_event_id != expected_root + || !route.mentioned_pubkeys.is_empty() + || event + .tags + .iter() + .any(|tag| tag.as_slice().first().is_some_and(|key| key == "broadcast")) + || !event + .tags + .iter() + .any(|tag| tag.as_slice() == ["h", channel.as_str()]) + { + return Err(protocol( + "restart status prepared route does not match its scope", + )); + } + for attempt in 0..3 { + if !ctx.session_controls.status_current(job, dispatch) + || !ctx + .session_controls + .status_authorized(&job.saved, ctx) + .await + { + return Err(protocol("restart status publication authority changed")); + } + // This synchronous fence is the publication authorization point. An + // already sent HTTP request cannot be retracted by later cancellation. + if !ctx.session_controls.status_current(job, dispatch) { + return Err(protocol("restart status publication superseded")); + } + if timeout_at( + job.deadline.min(Instant::now() + Duration::from_secs(5)), + ctx.rest_client.submit_event_once_exact(&event), + ) + .await + .is_ok_and(|result| result.is_ok()) + { + if !ctx.session_controls.status_current(job, dispatch) { + return Err(protocol("restart status superseded before confirmation")); + } + let mut next = job.saved.clone(); + let consume = !next.failure_notice; + next.phase = if next.failure_notice { + RestartStatusPhase::Failed + } else { + RestartStatusPhase::Completed + }; + next.notice_confirmed = next.failure_notice; + ctx.session_controls + .persist_status(job, next, consume) + .map_err(|_| protocol("restart status confirmation could not be persisted"))?; + return Ok(()); + } + if attempt < 2 { + tokio::time::sleep_until( + job.deadline + .min(Instant::now() + Duration::from_millis(250 << attempt)), + ) + .await; + } + } + Err(protocol("restart status delivery unconfirmed")) +} +async fn publish_failure( + job: &mut StatusJob, + dispatch: &crate::session_controls::ControlDispatch, + ctx: &PromptContext, +) -> Result<(), AcpError> { + if !ctx.session_controls.status_current(job, dispatch) + || !ctx + .session_controls + .status_authorized(&job.saved, ctx) + .await + || !ctx.session_controls.status_current(job, dispatch) + { + return Err(protocol("restart status feedback authority changed")); + } + let metadata = ctx + .channel_info + .resolve_channel_metadata(job.saved.scope.channel_id()) + .await; + let is_dm = metadata + .as_ref() + .is_some_and(|info| info.channel_type == "dm"); + if !ctx.session_controls.status_current(job, dispatch) { + return Err(protocol( + "restart status feedback superseded before preparation", + )); + } + let event = build_failure_notice_event(&ctx.agent_keys, job.saved.scope.channel_id(), &reply_tags(job, is_dm), "The session restarted, but I couldn't confirm its task-status reply. Your next message can continue the conversation.").ok_or_else(|| protocol("restart status failure notice could not be signed"))?; + let mut next = job.saved.clone(); + next.phase = RestartStatusPhase::Prepared; + next.failure_notice = true; + if next.notice.is_none() { + next.notice = Some(event); + } + ctx.session_controls + .persist_status(job, next, false) + .map_err(|_| protocol("restart status failure notice could not be persisted"))?; + tokio::time::timeout_at(job.deadline, publish_prepared(job, dispatch, ctx)) + .await + .map_err(|_| protocol("restart status failure notice deadline expired"))? +} + +/// Expiry or a known local panic reports failure without acquiring a provider. +pub(crate) async fn report_expired( + mut job: StatusJob, + ctx: Arc, + dispatch: crate::session_controls::ControlDispatch, +) { + job.deadline = Instant::now() + Duration::from_secs(5); + if timeout_at(job.deadline, publish_failure(&mut job, &dispatch, &ctx)) + .await + .is_err() + || job.saved.active() + { + let prepared = job.saved.prepared.clone(); + let failure = job.saved.failure_notice; + if let Err(error) = ctx.session_controls.transition_status( + &mut job, + RestartStatusPhase::Unconfirmed, + prepared, + failure, + false, + ) { + tracing::warn!(%error, "expired restart status feedback remains uncertain"); + } + } + ctx.session_controls.finish_status(&job); + dispatch.finish(); +} diff --git a/crates/buzz-acp/src/pool/restart_status_edge_tests.rs b/crates/buzz-acp/src/pool/restart_status_edge_tests.rs new file mode 100644 index 00000000000..96ee51f1b8f --- /dev/null +++ b/crates/buzz-acp/src/pool/restart_status_edge_tests.rs @@ -0,0 +1,299 @@ +//! Additional production-path failure and routing boundaries. +use super::*; + +#[tokio::test] +async fn overflow_empty_thought_and_rate_limited_output_never_publish_partial_answers() { + let overflow = "x".repeat(crate::session_store::STATUS_TEXT_BYTES + 1); + for env in [ + vec![("STATUS_TEXT", overflow.as_str())], + vec![("STATUS_TEXT", "")], + vec![("STATUS_THOUGHT", "1")], + vec![ + ("STATUS_TEXT", "partial"), + ("STATUS_STOP", "max_turn_requests"), + ], + vec![ + ("STATUS_TEXT", "partial"), + ("FAIL_PROMPT", "1"), + ("FAIL_PROMPT_CODE", "429"), + ], + ] { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, relay) = setup(vec![], scope.channel_id()).await; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, []); + let mut result = dispatch(&fixture, Arc::new(ctx), &env).await; + let posts = relay.posts.lock().unwrap().clone(); + assert_eq!(posts.len(), 1); + assert!(posts[0].content.contains("couldn't confirm")); + assert!(!posts[0].content.contains("partial")); + assert_eq!( + fixture.record(&scope).restart_status.unwrap().phase, + RestartStatusPhase::Failed + ); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/prompt") + .count(), + 1 + ); + result.agent.acp.shutdown().await; + } +} + +#[tokio::test] +async fn dm_and_channel_policy_keep_the_canonical_original_reply_route() { + for dm in [true, false] { + let fixture = Fixture::new(); + let human = Keys::generate(); + let agent = Keys::generate(); + let channel = Uuid::new_v4(); + let root = event( + &human, + channel, + None, + "Please explain the current task", + Some(&agent), + 1, + ); + let root_id = root.id.to_hex(); + let (mut ctx, relay) = setup(vec![root], channel).await; + ctx.agent_keys = agent; + ctx.session_policy = if dm { + crate::scope::SessionPolicy::Thread + } else { + crate::scope::SessionPolicy::Channel + }; + let info = crate::relay::ChannelInfo { + name: "conversation".into(), + channel_type: if dm { "dm" } else { "stream" }.into(), + description: None, + }; + ctx.channel_info = + ChannelInfoResolver::new(HashMap::from([(channel, info)]), ctx.rest_client.clone()); + let scope = SessionScope::Conversation { + channel_id: channel, + }; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, []); + let mut result = dispatch( + &fixture, + Arc::new(ctx), + &[("STATUS_TEXT", "The retained assignment is understood")], + ) + .await; + let posts = relay.posts.lock().unwrap().clone(); + assert_eq!(posts.len(), 1); + let route = crate::queue::parse_thread_tags(&posts[0]); + assert!(route.mentioned_pubkeys.is_empty()); + assert_eq!( + route.root_event_id, + if dm { None } else { Some("a".repeat(64)) } + ); + // The channel-policy control fixture explicitly replies to its own + // canonical root; a DM ignores thread tags and scopes the conversation. + assert_ne!(route.root_event_id, Some(root_id)); + result.agent.acp.shutdown().await; + } +} + +#[tokio::test] +async fn status_acquisition_respects_a_shorter_existing_task_deadline() { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, relay) = setup(vec![], scope.channel_id()).await; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, []); + ctx.max_turn_duration = Duration::from_millis(50); + let started = std::time::Instant::now(); + let mut result = dispatch( + &fixture, + Arc::new(ctx), + &[("DELAY", "0.5"), ("STATUS_TEXT", "must not run")], + ) + .await; + assert!(started.elapsed() < Duration::from_secs(8)); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/prompt") + .count(), + 0 + ); + assert_ne!( + fixture.record(&scope).restart_status.unwrap().phase, + RestartStatusPhase::Completed + ); + assert!(relay + .posts + .lock() + .unwrap() + .iter() + .all(|post| post.content.contains("couldn't confirm"))); + result.agent.acp.shutdown().await; +} + +#[tokio::test] +async fn a_failed_preparation_write_keeps_the_attempt_record_without_publishing_any_text() { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, relay) = setup(vec![], scope.channel_id()).await; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, []); + let controls = ctx.session_controls.clone(); + let ctx = Arc::new(ctx); + let log = fixture.log(); + let env = [ + ("PROMPT_DELAY", "0.2"), + ("STATUS_TEXT", "A complete but unpersisted answer"), + ]; + let work = dispatch(&fixture, ctx, &env); + let fail = async { + timeout(Duration::from_secs(5), async { + while !std::fs::read_to_string(&log) + .unwrap_or_default() + .contains("session/prompt") + { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + fixture.store.fail_writes(true); + }; + let (mut result, ()) = tokio::join!(work, fail); + assert!(relay.posts.lock().unwrap().is_empty()); + assert_eq!( + fixture.record(&scope).restart_status.unwrap().phase, + RestartStatusPhase::Started + ); + assert!(!controls.status_pending(&scope)); + fixture.store.fail_writes(false); + result.agent.acp.shutdown().await; +} + +#[tokio::test] +async fn a_verified_agent_requester_cannot_supply_the_human_language_hint() { + let fixture = Fixture::new(); + let requester = Keys::generate(); + let owner = Keys::generate(); + let agent = Keys::generate(); + let channel = Uuid::new_v4(); + let root = event( + &requester, + channel, + None, + "Проверка завершена, осталось собрать результат", + Some(&agent), + 1, + ); + let root_id = root.id.to_hex(); + let tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &requester.public_key(), "").unwrap(); + let profile = EventBuilder::new(Kind::Metadata, "{}") + .tags([buzz_sdk::nip_oa::parse_auth_tag(&tag).unwrap()]) + .sign_with_keys(&requester) + .unwrap(); + let (mut ctx, _relay) = setup(vec![root, profile], channel).await; + ctx.agent_keys = agent; + let scope = SessionScope::Thread { + channel_id: channel, + root_event_id: root_id, + }; + admitted(&fixture, &mut ctx, &requester, &scope, "!restart none").await; + ctx.agent_owner_pubkey = None; + ctx.session_controls.plan_retirement(&scope, []); + let prompts = fixture.dir.path().join("agent-language"); + let mut result = dispatch( + &fixture, + Arc::new(ctx), + &[ + ("STATUS_TEXT", "A short status"), + ("PROMPTS", prompts.to_str().unwrap()), + ], + ) + .await; + let wire = std::fs::read_to_string(prompts).unwrap(); + let params: serde_json::Value = serde_json::from_str(wire.lines().next().unwrap()).unwrap(); + assert!(params["prompt"][0]["text"] + .as_str() + .unwrap() + .contains("conversation-language=\"unknown\"")); + result.agent.acp.shutdown().await; +} + +#[tokio::test] +async fn acknowledgment_without_a_completion_write_retains_prepared_bytes_and_token() { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, relay) = setup(vec![], scope.channel_id()).await; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, []); + *relay.on_post.lock().unwrap() = Some(fixture.store.clone()); + let mut result = dispatch( + &fixture, + Arc::new(ctx), + &[("STATUS_TEXT", "Actual acknowledged model text")], + ) + .await; + let posts = relay.posts.lock().unwrap().clone(); + assert_eq!(posts.len(), 1); + let saved = fixture.record(&scope); + assert!(saved.pending_restart.is_some()); + let status = saved.restart_status.unwrap(); + assert_eq!(status.phase, RestartStatusPhase::Prepared); + assert_eq!(status.prepared, Some(posts[0].clone())); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/prompt") + .count(), + 1 + ); + fixture.store.fail_writes(false); + *relay.on_post.lock().unwrap() = None; + result.agent.acp.shutdown().await; +} + +#[tokio::test] +async fn a_known_local_panic_reports_failure_without_restarting_the_model() { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, relay) = setup(vec![], scope.channel_id()).await; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, []); + let mut job = ctx.session_controls.pending_statuses().pop().unwrap(); + ctx.session_controls + .transition_status(&mut job, RestartStatusPhase::Started, None, false, false) + .unwrap(); + let token = job.saved.token; + let panic = ctx.session_controls.status_panicked(&scope, token).unwrap(); + assert!(!ctx.session_controls.status_pending(&scope)); + let dispatch = ctx.session_controls.capture(&scope); + crate::pool::restart_status::report_expired(panic, Arc::new(ctx), dispatch).await; + assert_eq!( + fixture.record(&scope).restart_status.unwrap().phase, + RestartStatusPhase::Failed + ); + assert_eq!(relay.posts.lock().unwrap().len(), 1); + assert!(relay.posts.lock().unwrap()[0] + .content + .contains("couldn't confirm")); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/prompt") + .count(), + 0 + ); +} diff --git a/crates/buzz-acp/src/pool/restart_status_metadata_tests.rs b/crates/buzz-acp/src/pool/restart_status_metadata_tests.rs new file mode 100644 index 00000000000..a9e732178ea --- /dev/null +++ b/crates/buzz-acp/src/pool/restart_status_metadata_tests.rs @@ -0,0 +1,132 @@ +//! First successful automatic reply carries the live generation's display tags. +use super::*; +use buzz_core::session_control::ControlAction; +use buzz_core::session_status::{ + canonical_identity, parse_snapshot, scope_file_name, ContextOccupancy, SessionState, + StatusReason, SESSION_INFO_TAG, SESSION_TAG, +}; + +#[tokio::test] +async fn first_signed_status_carries_selected_history_actor_and_current_or_unknown_occupancy() { + for (command, depth, read) in [ + ("!restart none", "none", "1"), + ("!restart", "recent", "2"), + ("!restart all", "all", "2"), + ] { + for usage in [Some("[400,1000]"), None] { + let fixture = Fixture::new(); + let human = Keys::generate(); + let agent = Keys::generate(); + let channel = Uuid::new_v4(); + let root = event(&human, channel, None, "Inspect the task", Some(&agent), 1); + let root_id = root.id.to_hex(); + let progress = event( + &human, + channel, + Some(&root_id), + "Implementation done; check next", + None, + 2, + ); + let (mut ctx, relay) = setup(vec![root, progress], channel).await; + ctx.agent_keys = agent; + let scope = SessionScope::Thread { + channel_id: channel, + root_event_id: root_id, + }; + let status_scope = crate::pool::session_visibility::status_scope(&scope).unwrap(); + let runtime = crate::session_status::SessionStatusRuntime::open( + &fixture.dir.path().join("visibility"), + canonical_identity("wss://relay.example", &ctx.agent_keys.public_key().to_hex()) + .unwrap(), + ) + .unwrap(); + // A previous session's known capacity must never leak into this reply. + let old = runtime + .begin( + status_scope.clone(), + "old-session", + SessionState::New, + StatusReason::FirstInThread, + ) + .unwrap(); + old.record_occupancy(95, 100); + ctx.session_status = Some(runtime.clone()); + let admitted = admitted(&fixture, &mut ctx, &human, &scope, command).await; + ctx.session_controls.plan_retirement(&scope, []); + let mut env = vec![("STATUS_TEXT", "The task is understood; checks are next.")]; + if let Some(usage) = usage { + env.push(("STATUS_USAGE", usage)); + } + let mut result = dispatch(&fixture, Arc::new(ctx), &env).await; + let posts = relay.posts.lock().unwrap().clone(); + assert_eq!( + posts.len(), + 1, + "the first reply must already contain metadata" + ); + posts[0].verify().unwrap(); + let tags: Vec> = posts[0] + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(); + let primary = tags.iter().find(|tag| tag[0] == SESSION_TAG).unwrap(); + assert_eq!( + primary, + &vec![ + SESSION_TAG.to_owned(), + "restarted".to_owned(), + format!("user_restart:{depth}"), + if usage.is_some() { "40" } else { "" }.to_owned() + ] + ); + let info = tags.iter().find(|tag| tag[0] == SESSION_INFO_TAG).unwrap(); + assert_eq!( + &info[3..], + &[ + "thread", + read, + "2", + if depth == "all" { + "exact" + } else { + "lower_bound" + } + ] + ); + let envelope = parse_snapshot( + &std::fs::read(runtime.dir().join(scope_file_name(&status_scope).unwrap())) + .unwrap(), + ) + .unwrap(); + assert_eq!( + envelope.status.occupancy, + usage.map(|_| ContextOccupancy::new(400, 1000).unwrap()) + ); + assert_eq!( + envelope.status.history.unwrap().read, + Some(read.parse().unwrap()) + ); + assert_eq!(envelope.origin.as_ref(), Some(&admitted.origin)); + for expected in envelope.event_tags().unwrap() { + assert!(tags.contains(&expected)); + } + let action = ControlAction::from_tags(&tags).unwrap(); + assert_eq!(action.transition_id, envelope.status.transition_id); + assert_eq!(action.origin, admitted.origin); + assert!(old.event_tags().is_none()); + assert_eq!( + fixture + .record(&scope) + .restart_status + .unwrap() + .prepared + .unwrap() + .id, + posts[0].id + ); + result.agent.acp.shutdown().await; + } + } +} diff --git a/crates/buzz-acp/src/pool/restart_status_review_tests.rs b/crates/buzz-acp/src/pool/restart_status_review_tests.rs new file mode 100644 index 00000000000..3c63c759ffe --- /dev/null +++ b/crates/buzz-acp/src/pool/restart_status_review_tests.rs @@ -0,0 +1,388 @@ +//! Review regressions bind language admission and pre-first-poll ownership. +use super::*; + +async fn admit_nonowner( + fixture: &Fixture, + ctx: &mut PromptContext, + owner: &Keys, + requester: &Keys, + scope: &SessionScope, + command: &str, +) { + assert_ne!(owner.public_key(), requester.public_key()); + let allowlist = HashSet::from([requester.public_key().to_hex()]); + ctx.agent_owner_pubkey = Some(owner.public_key()); + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + ctx.session_controls.configure_status_authority( + crate::RespondTo::Allowlist, + allowlist.clone(), + Some(owner.public_key().to_hex()), + ); + let event = event( + requester, + scope.channel_id(), + scope.root_event_id(), + command, + Some(&ctx.agent_keys), + nostr::Timestamp::now().as_secs(), + ); + let owner_cache = crate::OwnerCache::new(Some(owner.public_key().to_hex())); + let mut gate = crate::InboundAuthorGate::connect( + &ctx.rest_client, + &ctx.agent_keys.public_key().to_hex(), + "status language test", + ) + .await; + let authorized = crate::authorize_normal_listener_event( + &mut gate, + crate::relay::BuzzEvent { + channel_id: scope.channel_id(), + event, + connection_generation: 0, + }, + &crate::RespondTo::Allowlist, + &allowlist, + &owner_cache, + &ctx.channel_info, + &ctx.rest_client, + ) + .await + .unwrap(); + let crate::session_controls::Admission::Control(job) = + crate::session_controls::admit_authorized( + authorized, + ctx, + nostr::Timestamp::now().as_secs(), + ) + .await + else { + panic!("allowlisted nonowner must be admitted"); + }; + assert_eq!(job.origin.actor_pubkey, requester.public_key().to_hex()); + assert!(ctx.session_controls.restart(job).unwrap()); +} + +#[tokio::test] +async fn numbered_and_unsupported_latest_prose_replace_old_english_at_every_depth() { + for command in ["!restart none", "!restart", "!restart all"] { + for nonowner in [false, true] { + for (text, language) in [ + ("1. Реализация готова\n2. Остались проверки.", "ru"), + ( + "La réalisation est terminée; quelques vérifications restent nécessaires.", + "unknown", + ), + ] { + let fixture = Fixture::new(); + let owner = Keys::generate(); + let requester = if nonowner { + Keys::generate() + } else { + owner.clone() + }; + let agent = Keys::generate(); + let channel = Uuid::new_v4(); + let root = event( + &owner, + channel, + None, + "Please inspect the task and report progress", + Some(&agent), + 1, + ); + let root_id = root.id.to_hex(); + let update = event(&requester, channel, Some(&root_id), text, None, 2); + let non_evidence = event(&requester, channel, Some(&root_id), + "```text\nThe task is done and we are ready\n```\n> The task is done\n2026-01-01T12:00:00Z INFO the task is done\n[12:01:02] INFO the task is done\n!restart all\n$ echo the task is done", None, 3); + let sibling = event( + &requester, + channel, + Some(&"f".repeat(64)), + "The task is done and we are ready", + None, + 4, + ); + let (mut ctx, relay) = + setup(vec![root, update, non_evidence, sibling], channel).await; + ctx.agent_keys = agent; + let scope = SessionScope::Thread { + channel_id: channel, + root_event_id: root_id, + }; + if nonowner { + admit_nonowner(&fixture, &mut ctx, &owner, &requester, &scope, command).await; + } else { + admitted(&fixture, &mut ctx, &owner, &scope, command).await; + } + ctx.session_controls.plan_retirement(&scope, []); + let prompts = fixture.dir.path().join("review-language-prompts"); + let mut result = dispatch( + &fixture, + Arc::new(ctx), + &[ + ("STATUS_TEXT", "A useful status response"), + ("PROMPTS", prompts.to_str().unwrap()), + ], + ) + .await; + let wire = std::fs::read_to_string(prompts).unwrap(); + let params: serde_json::Value = + serde_json::from_str(wire.lines().next().unwrap()).unwrap(); + let prompt = params["prompt"][0]["text"].as_str().unwrap(); + assert!( + prompt.contains(&format!("conversation-language=\"{language}\"")), + "{command} nonowner={nonowner}: {prompt}" + ); + if command == "!restart none" { + assert!(!prompt.contains(text)); + } + assert_eq!(relay.posts.lock().unwrap().len(), 1); + assert_eq!( + fixture.record(&scope).restart_status.unwrap().phase, + RestartStatusPhase::Completed + ); + result.agent.acp.shutdown().await; + } + } + } +} + +#[tokio::test] +async fn scheduler_keeps_its_original_fence_before_the_first_status_poll() { + for action in ["failed_cancel", "restart", "remove", "authority"] { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, relay) = setup(vec![], scope.channel_id()).await; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, []); + let later = + super::super::control_tests::authorized_control(&ctx, &human, &scope, "!restart all") + .await; + let old_token = ctx.session_controls.restart_intent(&scope).unwrap().token; + let agent = fixture + .agent(&[("STATUS_TEXT", "An obsolete answer")]) + .await; + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + let ctx = Arc::new(ctx); + let (tx, mut rx) = mpsc::unbounded_channel(); + crate::status_tasks::dispatch(&mut pool, &ctx, &tx); + // No await: the actual scheduled future cannot have been polled yet. + match action { + "failed_cancel" => { + fixture.store.fail_writes(true); + assert_eq!( + ctx.session_controls.cancel_status_outcome(&scope), + crate::session_controls::status::StatusCancellation::Unconfirmed + ); + // Persistence recovers before the stale future is first polled. + fixture.store.fail_writes(false); + } + "restart" => { + assert!(ctx.session_controls.restart(later).unwrap()); + } + "remove" => ctx + .session_controls + .revoke_channel(scope.channel_id()) + .unwrap(), + "authority" => ctx.session_controls.configure_status_authority( + crate::RespondTo::Nobody, + HashSet::new(), + Some(human.public_key().to_hex()), + ), + _ => unreachable!(), + } + let mut result = timeout(Duration::from_secs(8), rx.recv()) + .await + .unwrap() + .unwrap(); + pool.join_set.join_next().await.unwrap().unwrap(); + assert!(relay.posts.lock().unwrap().is_empty(), "{action}"); + assert!( + !fixture + .methods() + .iter() + .any(|method| method == "session/prompt" || method == "session/new"), + "{action}" + ); + if action == "restart" { + assert_ne!( + ctx.session_controls.restart_intent(&scope).unwrap().token, + old_token + ); + assert_eq!( + fixture.record(&scope).restart_status.unwrap().phase, + RestartStatusPhase::WaitingRetirement + ); + } + result.agent.acp.shutdown().await; + } +} + +#[tokio::test] +async fn expiry_scheduler_keeps_its_fence_when_cancellation_persistence_recovers() { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, relay) = setup(vec![], scope.channel_id()).await; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, []); + let ctx = Arc::new(ctx); + let mut pool = AgentPool::from_slots(vec![]); + let (tx, _rx) = mpsc::unbounded_channel(); + tokio::time::pause(); + tokio::time::advance(Duration::from_secs(121)).await; + crate::status_tasks::dispatch(&mut pool, &ctx, &tx); + fixture.store.fail_writes(true); + assert_eq!( + ctx.session_controls.cancel_status_outcome(&scope), + crate::session_controls::status::StatusCancellation::Unconfirmed + ); + fixture.store.fail_writes(false); + tokio::time::resume(); + timeout(Duration::from_secs(8), pool.join_set.join_next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(relay.posts.lock().unwrap().is_empty()); + assert!(fixture + .record(&scope) + .restart_status + .unwrap() + .notice + .is_none()); +} + +#[tokio::test] +async fn uncertain_owner_cancel_has_no_chat_sender_and_fences_held_feedback() { + for action in ["cancel", "restart", "remove", "authority"] { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, relay) = setup(vec![], scope.channel_id()).await; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, []); + let job = ctx.session_controls.pending_statuses().pop().unwrap(); + let dispatch = ctx.session_controls.capture(&scope); + let old_token = job.saved.token; + let later = + super::super::control_tests::authorized_control(&ctx, &human, &scope, "!restart all") + .await; + let ctx = Arc::new(ctx); + let held = crate::pool::restart_status::report_expired(job, ctx.clone(), dispatch.clone()); + let mut pool = AgentPool::from_slots(vec![]); + let mut queue = crate::EventQueue::new(crate::DedupMode::Queue); + let observer = crate::observer::ObserverHandle::in_process(); + fixture.store.fail_writes(true); + assert_eq!( + crate::handle_owner_cancel_for_scope( + &mut pool, + &mut queue, + &ctx.session_controls, + &scope, + Some(&observer) + ), + crate::session_controls::status::StatusCancellation::Unconfirmed + ); + assert!(!dispatch.current()); + assert_eq!( + observer.snapshot().last().unwrap().payload["status"], + "unconfirmed" + ); + assert_eq!( + fixture.record(&scope).restart_status.unwrap().phase, + RestartStatusPhase::Pending + ); + fixture.store.fail_writes(false); + match action { + "cancel" => { + ctx.session_controls.cancel_status_outcome(&scope); + } + "restart" => { + assert!(ctx.session_controls.restart(later).unwrap()); + } + "remove" => ctx + .session_controls + .revoke_channel(scope.channel_id()) + .unwrap(), + "authority" => ctx.session_controls.configure_status_authority( + crate::RespondTo::Nobody, + HashSet::new(), + Some(human.public_key().to_hex()), + ), + _ => unreachable!(), + } + held.await; + // Drain a turn too: an accidentally detached owner notice must surface. + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(relay.posts.lock().unwrap().is_empty(), "{action}"); + assert!(fixture + .record(&scope) + .restart_status + .unwrap() + .notice + .is_none()); + if action == "restart" { + assert_ne!( + ctx.session_controls.restart_intent(&scope).unwrap().token, + old_token + ); + assert_eq!( + fixture.record(&scope).restart_status.unwrap().phase, + RestartStatusPhase::WaitingRetirement + ); + } + } +} + +#[tokio::test] +async fn owned_failure_feedback_requires_persistence_and_retries_only_its_saved_bytes() { + for failure in ["persist", "lost", "wrong", "reject"] { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, relay) = setup(vec![], scope.channel_id()).await; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, []); + let mut job = ctx.session_controls.pending_statuses().pop().unwrap(); + ctx.session_controls + .transition_status(&mut job, RestartStatusPhase::Started, None, false, false) + .unwrap(); + let job = ctx + .session_controls + .status_panicked(&scope, job.saved.token) + .unwrap(); + let dispatch = ctx.session_controls.capture(&scope); + if failure == "persist" { + fixture.store.fail_writes(true); + } else { + *relay.mode.lock().unwrap() = failure.into(); + } + crate::pool::restart_status::report_expired(job, Arc::new(ctx), dispatch).await; + let saved = fixture.record(&scope); + assert!(saved.pending_restart.is_some()); + let status = saved.restart_status.unwrap(); + let posts = relay.posts.lock().unwrap().clone(); + if failure == "persist" { + assert!(posts.is_empty()); + assert_eq!(status.phase, RestartStatusPhase::Started); + assert!(status.notice.is_none()); + } else { + assert_eq!(posts.len(), 3); + assert!(posts + .iter() + .all(|post| Some(post) == status.notice.as_ref())); + assert!(posts[0].content.contains("couldn't confirm")); + assert_eq!(status.phase, RestartStatusPhase::Unconfirmed); + assert!(!status.notice_confirmed); + assert!(status.prepared.is_none()); + } + assert!(!fixture + .methods() + .iter() + .any(|method| method == "session/prompt")); + fixture.store.fail_writes(false); + } +} diff --git a/crates/buzz-acp/src/pool/restart_status_runtime_tests.rs b/crates/buzz-acp/src/pool/restart_status_runtime_tests.rs new file mode 100644 index 00000000000..cddd1eeade4 --- /dev/null +++ b/crates/buzz-acp/src/pool/restart_status_runtime_tests.rs @@ -0,0 +1,985 @@ +//! Tests bind authorized admission, checked retirement, the scheduler, real ACP +//! framing, durable ownership and signature-checking HTTP publication. +use super::*; +use crate::session_store::RestartStatusPhase; +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::mpsc; +use tokio::time::timeout; + +struct Relay { + posts: Arc>>, + mode: Arc>, + on_post: Arc>>>, + task: tokio::task::JoinHandle<()>, +} +impl Drop for Relay { + fn drop(&mut self) { + self.task.abort(); + } +} +async fn setup(events: Vec, channel: Uuid) -> (PromptContext, Relay) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let posts = Arc::new(Mutex::new(Vec::new())); + let mode = Arc::new(Mutex::new(String::new())); + let on_post: Arc>>> = Arc::new(Mutex::new(None)); + let failure_hook = on_post.clone(); + let captured = posts.clone(); + let behavior = mode.clone(); + let task = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let events = events.clone(); + let captured = captured.clone(); + let behavior = behavior.clone(); + let failure_hook = failure_hook.clone(); + tokio::spawn(async move { + let mut request = Vec::new(); + let mut block = [0u8; 8192]; + let header = loop { + let n = socket.read(&mut block).await.unwrap_or_default(); + if n == 0 { + return; + } + request.extend_from_slice(&block[..n]); + if request.len() > 128 * 1024 { + return; + } + if let Some(end) = request.windows(4).position(|part| part == b"\r\n\r\n") { + break end + 4; + } + }; + let headers = String::from_utf8_lossy(&request[..header]).into_owned(); + let length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|n| n.trim().parse::().ok()) + }) + .unwrap_or_default(); + if length > 128 * 1024 { + return; + } + while request.len() < header + length { + let n = socket.read(&mut block).await.unwrap_or_default(); + if n == 0 { + return; + } + request.extend_from_slice(&block[..n]); + } + let body: serde_json::Value = + serde_json::from_slice(&request[header..header + length]).unwrap_or_default(); + let response = if headers.starts_with("POST /events ") { + let event: nostr::Event = serde_json::from_value(body).unwrap(); + event.verify().unwrap(); + let id = event.id.to_hex(); + captured.lock().unwrap().push(event); + if let Some(store) = failure_hook.lock().unwrap().as_ref() { + store.fail_writes(true); + } + let mode = behavior.lock().unwrap().clone(); + if mode == "lost" { + return; + } + if mode == "reject" { + serde_json::json!({"accepted":false,"event_id":id}) + } else if mode == "wrong" { + serde_json::json!({"accepted":true,"event_id":"0".repeat(64)}) + } else { + serde_json::json!({"accepted":true,"event_id":id}) + } + } else if headers.starts_with("POST /query ") { + let filters = body["filters"] + .as_array() + .cloned() + .or_else(|| body.as_array().cloned()) + .unwrap_or_default(); + let selected: Vec<_> = events + .iter() + .filter(|event| { + filters.iter().any(|filter| { + if filter["kinds"].as_array().is_some_and(|kinds| { + !kinds.iter().any(|kind| { + kind.as_u64() == Some(event.kind.as_u16() as u64) + }) + }) { + return false; + } + if filter["ids"].as_array().is_some_and(|ids| { + !ids.iter() + .any(|id| id.as_str() == Some(event.id.to_hex().as_str())) + }) { + return false; + } + for (name, tag) in [("#h", "h"), ("#e", "e")] { + if filter[name].as_array().is_some_and(|values| { + !event.tags.iter().any(|t| { + t.as_slice().first().is_some_and(|v| v == tag) + && t.as_slice().get(1).is_some_and(|v| { + values.iter().any(|expected| { + expected.as_str() == Some(v.as_str()) + }) + }) + }) + }) { + return false; + } + } + true + }) + }) + .cloned() + .collect(); + serde_json::to_value(selected).unwrap() + } else { + serde_json::json!({}) + }; + let response = response.to_string(); + let wire = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", response.len(), response); + let _ = socket.write_all(wire.as_bytes()).await; + }); + } + }); + let mut ctx = ctx(); + ctx.context_message_limit = 12; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.rest_client.base_url = url.clone(); + ctx.channel_info = ChannelInfoResolver::new( + HashMap::from([( + channel, + crate::relay::ChannelInfo { + name: "status".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + RestClient { + http: reqwest::Client::new(), + base_url: url, + keys: ctx.agent_keys.clone(), + auth_tag_json: None, + }, + ); + ( + ctx, + Relay { + posts, + mode, + on_post, + task, + }, + ) +} +fn event( + keys: &Keys, + channel: Uuid, + root: Option<&str>, + text: &str, + agent: Option<&Keys>, + at: u64, +) -> nostr::Event { + let mut tags = vec![Tag::parse(["h", channel.to_string().as_str()]).unwrap()]; + if let Some(root) = root { + tags.push(Tag::parse(["e", root, "", "reply"]).unwrap()); + } + if let Some(agent) = agent { + tags.push(Tag::parse(["p", agent.public_key().to_hex().as_str()]).unwrap()); + } + EventBuilder::new(Kind::Custom(9), text) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(at)) + .sign_with_keys(keys) + .unwrap() +} +async fn admitted( + fixture: &Fixture, + ctx: &mut PromptContext, + human: &Keys, + scope: &SessionScope, + command: &str, +) -> crate::session_controls::ControlJob { + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + ctx.session_controls.configure_status_authority( + crate::RespondTo::OwnerOnly, + HashSet::new(), + Some(human.public_key().to_hex()), + ); + ctx.agent_owner_pubkey = Some(human.public_key()); + let job = super::control_tests::authorized_control(ctx, human, scope, command).await; + assert!(ctx.session_controls.restart(job.clone()).unwrap()); + job +} +async fn dispatch( + fixture: &Fixture, + ctx: Arc, + env: &[(&str, &str)], +) -> crate::pool::restart_status::StatusTaskResult { + let agent = fixture.agent(env).await; + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + let (tx, mut rx) = mpsc::unbounded_channel(); + crate::status_tasks::dispatch(&mut pool, &ctx, &tx); + let result = timeout(Duration::from_secs(12), rx.recv()) + .await + .unwrap() + .unwrap(); + pool.join_set.join_next().await; + result +} + +#[tokio::test] +async fn checked_restart_depths_publish_one_signed_status_and_resume_without_bootstrap() { + for (command, depth) in [ + ("!restart none", "none"), + ("!restart", "recent"), + ("!restart all", "all"), + ] { + let fixture = Fixture::new(); + let human = Keys::generate(); + let agent = Keys::generate(); + let channel = Uuid::new_v4(); + let root = event( + &human, + channel, + None, + "Please inspect the task and report progress", + Some(&agent), + 1, + ); + let root_id = root.id.to_hex(); + let progress = event( + &human, + channel, + Some(&root_id), + "The implementation is done; validation remains open", + None, + 2, + ); + let (mut ctx, relay) = setup(vec![root, progress], channel).await; + ctx.agent_keys = agent; + ctx.initial_message = Some("DO-NOT-RUN-BOOTSTRAP".into()); + let scope = SessionScope::Thread { + channel_id: channel, + root_event_id: root_id.clone(), + }; + let job = admitted(&fixture, &mut ctx, &human, &scope, command).await; + assert!(ctx.session_controls.pending_statuses().is_empty()); + ctx.session_controls.plan_retirement(&scope, [7]); + assert!(ctx.session_controls.pending_statuses().is_empty()); + ctx.session_controls + .retirement_report(7, crate::acp::TerminationReport::Clean); + assert_eq!(ctx.session_controls.pending_statuses().len(), 1); + let prompts = fixture.dir.path().join("status-prompts"); + let checks = fixture.dir.path().join("store"); + let ctx = Arc::new(ctx); + let mut result = dispatch( + &fixture, + ctx.clone(), + &[ + ( + "STATUS_TEXT", + "The task is understood; validation is the next step.", + ), + ("PROMPTS", prompts.to_str().unwrap()), + ("CHECK_STATUS_STARTED_ROOT", checks.to_str().unwrap()), + ], + ) + .await; + let posts = relay.posts.lock().unwrap().clone(); + assert_eq!(posts.len(), 1); + assert_eq!(posts[0].pubkey, ctx.agent_keys.public_key()); + assert_eq!( + posts[0].content, + "The task is understood; validation is the next step." + ); + let tags = crate::queue::parse_thread_tags(&posts[0]); + assert_eq!(tags.root_event_id, Some(root_id.clone())); + assert!(tags.mentioned_pubkeys.is_empty()); + let record = fixture.record(&scope); + assert_eq!( + record.restart_status.unwrap().phase, + RestartStatusPhase::Completed + ); + assert!(record.pending_restart.is_none()); + assert!(!ctx.session_controls.restart(job).unwrap()); + assert!(ctx.session_controls.pending_statuses().is_empty()); + let prompt = std::fs::read_to_string(&prompts).unwrap(); + assert!(!prompt.contains("DO-NOT-RUN-BOOTSTRAP")); + assert!(prompt.contains(&format!("user_restart:{depth}"))); + assert!(!prompt.contains("!restart")); + assert!(prompt.contains("Please inspect the task")); + if depth == "none" { + assert!(!prompt.contains("implementation is done")); + } else { + assert!(prompt.contains("implementation is done")); + } + let sid = result.agent.state.sessions[&scope].clone(); + result.agent.state.control_dispatch.take().unwrap().finish(); + let later = event( + &human, + channel, + Some(&root_id), + "Continue with the actual validation task", + Some(&ctx.agent_keys), + 3, + ); + let mut resumed = run_turn(result.agent, &ctx, batch(&scope, later)).await; + assert_eq!(resumed.state.sessions[&scope], sid); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/new") + .count(), + 1 + ); + resumed.acp.shutdown().await; + } +} + +#[tokio::test] +async fn failure_outputs_never_become_status_and_keep_restart_result_successful() { + for extra in [ + vec![("STATUS_STOP", "refusal")], + vec![("STATUS_STOP", "max_tokens")], + vec![("FAIL_PROMPT", "1")], + vec![("STATUS_TOOL", "1")], + vec![("STATUS_PERMISSION", "1")], + vec![("STATUS_WRONG_SESSION", "1")], + vec![("STATUS_THOUGHT", "1"), ("STATUS_TEXT", "")], + ] { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, relay) = setup(vec![], scope.channel_id()).await; + let job = admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, []); + let mut env = vec![("STATUS_TEXT", "partial output must not be posted")]; + env.extend(extra); + let mut result = dispatch(&fixture, Arc::new(ctx), &env).await; + let posts = relay.posts.lock().unwrap().clone(); + assert_eq!(posts.len(), 1); + assert!(posts[0].content.contains("couldn't confirm")); + assert!(!posts[0].content.contains("partial output")); + assert!(!posts[0] + .tags + .iter() + .any(|tag| tag.as_slice()[0].starts_with("buzz-session"))); + let record = fixture.record(&scope); + assert_eq!( + record + .control_receipts + .iter() + .find(|receipt| receipt.command_id == job.origin.command_event_id) + .unwrap() + .state, + crate::session_store::ControlReceiptState::Finished(Ok(())) + ); + assert_eq!( + record.restart_status.unwrap().phase, + RestartStatusPhase::Failed + ); + assert!(record.pending_restart.is_some()); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/prompt") + .count(), + 1 + ); + if env.iter().any(|(name, _)| *name == "STATUS_PERMISSION") { + timeout(Duration::from_secs(2), async { + while !fixture.methods().contains(&"permission-denied".into()) { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + } + result.agent.acp.shutdown().await; + } +} + +#[tokio::test] +async fn selected_history_keeps_only_a_language_hint_from_omitted_russian_conversation() { + for (command, coverage) in [ + ( + "!restart none", + "", + ), + ( + "!restart", + "", + ), + ( + "!restart all", + "", + ), + ] { + for language in ["ru", "en", "unknown"] { + let fixture = Fixture::new(); + let human = Keys::generate(); + let agent = Keys::generate(); + let channel = Uuid::new_v4(); + let root = event( + &human, + channel, + None, + if language == "unknown" { + "???" + } else { + "Please inspect the task and report progress" + }, + Some(&agent), + 1, + ); + let root_id = root.id.to_hex(); + let text = if language == "ru" { + "Проверка завершена, осталось собрать итоговый результат" + } else if language == "en" { + "The validation is done and the next step is clear" + } else { + "???" + }; + let progress = event(&human, channel, Some(&root_id), text, None, 2); + let code=event(&human,channel,Some(&root_id),"```text\nThe task is done and we are ready\n```\n> The task is done\n2026-01-01 INFO the task is done\n!restart all",None,3); + let sibling = event( + &human, + channel, + Some(&"f".repeat(64)), + "The task is done and we are ready", + None, + 4, + ); + let events = vec![root, progress, code, sibling]; + let (mut ctx, _relay) = setup(events, channel).await; + ctx.agent_keys = agent; + let scope = SessionScope::Thread { + channel_id: channel, + root_event_id: root_id, + }; + admitted(&fixture, &mut ctx, &human, &scope, command).await; + // Requester-only language policy works when no configured owner key + // is available to the prompt layer. + ctx.agent_owner_pubkey = None; + ctx.session_controls.plan_retirement(&scope, []); + let prompts = fixture.dir.path().join("language-prompts"); + let mut result = dispatch( + &fixture, + Arc::new(ctx), + &[ + ("STATUS_TEXT", "A useful status response"), + ("PROMPTS", prompts.to_str().unwrap()), + ], + ) + .await; + let wire = std::fs::read_to_string(prompts).unwrap(); + let params: serde_json::Value = + serde_json::from_str(wire.lines().next().unwrap()).unwrap(); + let prompt = params["prompt"][0]["text"].as_str().unwrap(); + assert!( + prompt.contains(&format!("conversation-language=\"{language}\"")), + "{prompt}" + ); + let request = prompt + .split_once("") + .unwrap() + .0; + assert!(prompt.contains(coverage), "{command}: {prompt}"); + for instruction in [ + "State any missing context explicitly.", + "Distinguish missing task details from missing messages; use the supplied messages and history coverage markers (included, total, truncated) for any coverage claim.", + "Never call a supplied message absent.", + "Omitted history does not prove that no work was done or that a prior request was unfulfilled; report unknown outcomes as unknown.", + ] { + assert!(request.contains(instruction), "{command}: {request}"); + } + assert!(request.contains("Use the same language as the human conversation")); + assert_eq!( + request.contains("Write this status in Russian."), + language == "ru", + "{command} language={language}: {request}" + ); + assert_eq!( + request.contains("Write this status in English."), + language == "en", + "{command} language={language}: {request}" + ); + if command == "!restart none" { + assert!(!prompt.contains("[2] ")); + } else { + assert!(prompt.contains("[2] ")); + assert!(prompt.contains(text)); + } + if command == "!restart none" && language == "ru" { + assert!(!prompt.contains(text)); + } + result.agent.acp.shutdown().await; + } + } +} + +#[tokio::test] +async fn lost_or_wrong_ack_retries_immutable_bytes_without_replaying_the_model() { + for mode in ["lost", "wrong", "reject"] { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, relay) = setup(vec![], scope.channel_id()).await; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, []); + *relay.mode.lock().unwrap() = mode.into(); + let controls = ctx.session_controls.clone(); + let mut result = dispatch( + &fixture, + Arc::new(ctx), + &[("STATUS_TEXT", "Actual generated answer")], + ) + .await; + let posts = relay.posts.lock().unwrap().clone(); + assert_eq!(posts.len(), 6); + assert!(posts[..3].iter().all(|event| event == &posts[0])); + assert!(posts[3..].iter().all(|event| event == &posts[3])); + assert_ne!(posts[0].id, posts[3].id); + let saved = fixture.record(&scope).restart_status.unwrap(); + assert_eq!(saved.phase, RestartStatusPhase::Unconfirmed); + assert_eq!(saved.prepared, Some(posts[0].clone())); + assert_eq!(saved.notice, Some(posts[3].clone())); + assert!(!saved.notice_confirmed); + assert!(!controls.status_pending(&scope)); + assert!(controls.pending_statuses().is_empty()); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/prompt") + .count(), + 1 + ); + result.agent.acp.shutdown().await; + } +} + +#[tokio::test] +async fn pending_status_never_merges_or_discards_new_human_work_and_sibling_is_available() { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, _relay) = setup(vec![], scope.channel_id()).await; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, []); + let ctx = Arc::new(ctx); + let agent = fixture.agent(&[]).await; + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + let mut queue = crate::EventQueue::new(crate::DedupMode::Queue); + let later = event( + &human, + scope.channel_id(), + scope.root_event_id(), + "Preserve this actual task instruction", + Some(&ctx.agent_keys), + nostr::Timestamp::now().as_secs(), + ); + queue.push(crate::QueuedEvent { + scope: scope.clone(), + channel_id: scope.channel_id(), + event: later.clone(), + received_at: std::time::Instant::now(), + prompt_tag: "test".into(), + edit: None, + }); + let mut activity = tokio::time::Instant::now(); + assert!(crate::dispatch_pending(&mut pool, &mut queue, &ctx, &mut activity, None).is_empty()); + assert!(queue.has_undispatched_work()); + let sibling = SessionScope::Thread { + channel_id: scope.channel_id(), + root_event_id: "c".repeat(64), + }; + let sibling_event = event( + &human, + sibling.channel_id(), + sibling.root_event_id(), + "Execute this sibling task", + Some(&ctx.agent_keys), + nostr::Timestamp::now().as_secs(), + ); + queue.push(crate::QueuedEvent { + scope: sibling.clone(), + channel_id: sibling.channel_id(), + event: sibling_event, + received_at: std::time::Instant::now(), + prompt_tag: "test".into(), + edit: None, + }); + let dispatched = crate::dispatch_pending(&mut pool, &mut queue, &ctx, &mut activity, None); + assert_eq!(dispatched.len(), 1); + assert_eq!(dispatched[0].0, sibling); + let mut completed = timeout(Duration::from_secs(5), pool.rx_and_join_set().0.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!(completed.outcome, PromptOutcome::Ok(_))); + assert!(ctx.session_controls.status_pending(&scope)); + assert!(ctx.session_controls.cancel_status(&scope)); + let batch = queue.flush_next().unwrap(); + assert_eq!(batch.events.len(), 1); + assert_eq!(batch.events[0].event.id, later.id); + assert!(batch.cancelled_events.is_empty()); + assert_eq!( + fixture.record(&scope).restart_status.unwrap().phase, + RestartStatusPhase::Cancelled + ); + completed.agent.acp.shutdown().await; + pool.join_set.join_next().await; +} + +#[tokio::test] +async fn failed_retirement_store_failure_and_startup_failure_never_fabricate_status_success() { + for failure in ["unverified", "store", "startup"] { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, relay) = setup(vec![], scope.channel_id()).await; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, [7]); + if failure == "unverified" { + ctx.session_controls + .retirement_report(7, crate::acp::TerminationReport::Unverified); + assert!(ctx.session_controls.pending_statuses().is_empty()); + assert!(relay.posts.lock().unwrap().is_empty()); + continue; + } + ctx.session_controls + .retirement_report(7, crate::acp::TerminationReport::Clean); + if failure == "store" { + fixture.store.fail_writes(true); + } + let env = if failure == "startup" { + vec![("FAIL_NEW", "1")] + } else { + vec![] + }; + let mut result = dispatch(&fixture, Arc::new(ctx), &env).await; + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/prompt") + .count(), + 0 + ); + let saved = fixture.record(&scope).restart_status.unwrap(); + assert_ne!(saved.phase, RestartStatusPhase::Completed); + assert!(relay + .posts + .lock() + .unwrap() + .iter() + .all(|post| post.content.contains("couldn't confirm"))); + fixture.store.fail_writes(false); + result.agent.acp.shutdown().await; + } +} + +#[tokio::test] +async fn late_model_results_are_fenced_by_new_restart_cancel_removal_shutdown_and_authority() { + for action in ["restart", "cancel", "remove", "shutdown", "authority"] { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, relay) = setup(vec![], scope.channel_id()).await; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, []); + let controls = ctx.session_controls.clone(); + let old_token = controls.restart_intent(&scope).unwrap().token; + let ctx = Arc::new(ctx); + let log = fixture.log(); + let env = [ + ("PROMPT_DELAY", "0.2"), + ("STATUS_TEXT", "This obsolete status must be suppressed"), + ]; + let work = dispatch(&fixture, ctx.clone(), &env); + let interrupt = async { + timeout(Duration::from_secs(5), async { + while !std::fs::read_to_string(&log) + .unwrap_or_default() + .contains("session/prompt") + { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + match action { + "restart" => { + let later = super::control_tests::authorized_control( + &ctx, + &human, + &scope, + "!restart all", + ) + .await; + assert!(controls.restart(later).unwrap()); + } + "cancel" => { + assert!(controls.cancel_status(&scope)); + } + "remove" => { + controls.revoke_channel(scope.channel_id()).unwrap(); + } + "shutdown" => { + assert!(controls.shutdown_statuses().contains(&scope)); + } + "authority" => controls.configure_status_authority( + crate::RespondTo::Nobody, + HashSet::new(), + Some(human.public_key().to_hex()), + ), + _ => unreachable!(), + } + }; + let (mut result, ()) = tokio::join!(work, interrupt); + assert!(relay.posts.lock().unwrap().is_empty()); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/prompt") + .count(), + 1 + ); + if action == "restart" { + let current = controls.restart_intent(&scope).unwrap(); + assert_ne!(current.token, old_token); + assert_eq!(current.depth, buzz_core::session_control::RestartDepth::All); + assert!(!controls.cancel_status_for_token(&scope, old_token)); + assert_eq!( + controls.restart_intent(&scope).unwrap().token, + current.token + ); + } + result.agent.acp.shutdown().await; + } +} + +#[tokio::test] +async fn pending_observer_cancel_and_expiry_do_not_require_a_worker_or_drop_human_input() { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, relay) = setup(vec![], scope.channel_id()).await; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, []); + let mut pool = AgentPool::from_slots(vec![]); + let mut queue = crate::EventQueue::new(crate::DedupMode::Queue); + crate::handle_cancel_turn_control_with_status( + &serde_json::json!({"channelId":scope.channel_id().to_string()}), + &mut pool, + &mut queue, + None, + Some(&ctx.session_controls), + ); + assert!(!ctx.session_controls.status_pending(&scope)); + assert_eq!( + fixture.record(&scope).restart_status.unwrap().phase, + RestartStatusPhase::Cancelled + ); + let next = super::control_tests::authorized_control(&ctx, &human, &scope, "!restart all").await; + assert!(ctx.session_controls.restart(next).unwrap()); + ctx.session_controls.plan_retirement(&scope, []); + // Use the real pending-job deadline and scheduler with virtual monotonic + // time. No provider slot exists, so this can only publish failure feedback. + let ctx = Arc::new(ctx); + let (tx, _rx) = mpsc::unbounded_channel(); + tokio::time::pause(); + tokio::time::advance(Duration::from_secs(121)).await; + crate::status_tasks::dispatch(&mut pool, &ctx, &tx); + assert!(!ctx.session_controls.status_pending(&scope)); + tokio::time::resume(); + timeout(Duration::from_secs(8), pool.join_set.join_next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(relay.posts.lock().unwrap().len(), 1); + assert!(relay.posts.lock().unwrap()[0] + .content + .contains("couldn't confirm")); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/prompt") + .count(), + 0 + ); +} + +#[tokio::test] +async fn reconstruction_claims_only_dead_owners_and_never_replays_started_model_attempts() { + for phase in [ + "waiting", + "pending", + "started", + "prepared", + "completed", + "cancel_uncertain", + ] { + let dir = tempfile::tempdir().unwrap(); + let keys = Keys::generate(); + let agent_hex = keys.public_key().to_hex(); + let store = Arc::new( + SessionStore::open(dir.path().join("store"), "wss://relay.example", &agent_hex) + .unwrap(), + ); + let fixture = Fixture { dir, store }; + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, relay) = setup(vec![], scope.channel_id()).await; + ctx.agent_keys = keys; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + if phase != "waiting" { + ctx.session_controls.plan_retirement(&scope, []); + } + let mut prepared = None; + if matches!(phase, "started" | "prepared" | "completed") { + let mut job = ctx.session_controls.pending_statuses().pop().unwrap(); + ctx.session_controls + .transition_status(&mut job, RestartStatusPhase::Started, None, false, false) + .unwrap(); + if phase != "started" { + let tags = crate::queue::ThreadTags { + root_event_id: scope.root_event_id().map(str::to_owned), + parent_event_id: scope.root_event_id().map(str::to_owned), + ..Default::default() + }; + let signed = crate::pool::build_failure_notice_event( + &ctx.agent_keys, + scope.channel_id(), + &tags, + "Persisted actual model-authored reply", + ) + .unwrap(); + prepared = Some(signed.clone()); + ctx.session_controls + .transition_status( + &mut job, + RestartStatusPhase::Prepared, + Some(signed.clone()), + false, + false, + ) + .unwrap(); + // The store seam rejects attempts to change prepared bytes. + let changed = crate::pool::build_failure_notice_event( + &ctx.agent_keys, + scope.channel_id(), + &tags, + "A duplicate replacement answer", + ) + .unwrap(); + assert!(ctx + .session_controls + .transition_status( + &mut job, + RestartStatusPhase::Prepared, + Some(changed), + false, + false + ) + .is_err()); + if phase == "completed" { + ctx.session_controls + .transition_status( + &mut job, + RestartStatusPhase::Completed, + Some(signed), + false, + true, + ) + .unwrap(); + } + } + } + if phase == "cancel_uncertain" { + fixture.store.fail_writes(true); + assert_eq!( + ctx.session_controls.cancel_status_outcome(&scope), + crate::session_controls::status::StatusCancellation::Unconfirmed + ); + fixture.store.fail_writes(false); + assert!(!ctx.session_controls.status_pending(&scope)); + } + let live_probe = SessionStore::open( + fixture.dir.path().join("store"), + "wss://relay.example", + &agent_hex, + ) + .unwrap(); + assert!( + live_probe + .sweep_orphan_controls(128) + .unwrap() + .reconciled_statuses + == 0 + ); + drop(live_probe); + ctx.session_controls = Default::default(); + let Fixture { dir, store } = fixture; + drop(store); + let reopened = Arc::new( + SessionStore::open(dir.path().join("store"), "wss://relay.example", &agent_hex) + .unwrap(), + ); + let fixture = Fixture { + dir, + store: reopened.clone(), + }; + ctx.session_controls = crate::session_controls::SessionControlRuntime::new(Some(reopened)); + ctx.session_controls.configure_status_authority( + crate::RespondTo::OwnerOnly, + HashSet::new(), + Some(human.public_key().to_hex()), + ); + ctx.session_controls.sweep_orphans(128).unwrap(); + assert!(ctx.session_controls.pending_statuses().is_empty()); + assert!(relay.posts.lock().unwrap().is_empty()); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/prompt") + .count(), + 0 + ); + let saved = fixture.record(&scope); + assert_eq!( + saved.restart_status.as_ref().unwrap().phase, + if phase == "completed" { + RestartStatusPhase::Completed + } else { + RestartStatusPhase::Unconfirmed + } + ); + assert_eq!(saved.restart_status.unwrap().prepared, prepared); + assert_eq!(saved.pending_restart.is_some(), phase != "completed"); + } +} + +#[path = "restart_status_edge_tests.rs"] +mod edge_tests; + +#[path = "restart_status_review_tests.rs"] +mod review_tests; + +#[path = "restart_status_unavailable_tests.rs"] +mod unavailable_tests; + +#[path = "restart_status_metadata_tests.rs"] +mod metadata_tests; diff --git a/crates/buzz-acp/src/pool/restart_status_unavailable_tests.rs b/crates/buzz-acp/src/pool/restart_status_unavailable_tests.rs new file mode 100644 index 00000000000..2d454755ec8 --- /dev/null +++ b/crates/buzz-acp/src/pool/restart_status_unavailable_tests.rs @@ -0,0 +1,443 @@ +//! Cancellation must revoke local ownership even when the exact record is absent. +use super::*; +use std::path::PathBuf; + +struct UnavailableRecord { + record: PathBuf, + moved: PathBuf, + backup: PathBuf, + bytes: Vec, + directory: bool, +} + +impl UnavailableRecord { + fn hide(fixture: &Fixture, scope: &SessionScope, fault: &str) -> Self { + let dir = fixture.store.channel_dir(scope.channel_id()); + let record = std::fs::read_dir(&dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| path.extension().is_some_and(|ext| ext == "json")) + .unwrap(); + let bytes = std::fs::read(&record).unwrap(); + let directory = fault == "unreadable"; + let moved = if directory { dir } else { record.clone() }; + let backup = moved.with_extension("held"); + std::fs::rename(&moved, &backup).unwrap(); + if directory { + // ENOTDIR makes both read and quarantine-lock creation fail even + // for privileged test users; no permission-based false positive. + std::fs::write(&moved, b"temporarily unavailable directory").unwrap(); + assert!(matches!(fixture.store.load(scope), LoadOutcome::Unreadable)); + } else { + assert!(matches!(fixture.store.load(scope), LoadOutcome::Missing)); + } + Self { + record, + moved, + backup, + bytes, + directory, + } + } + + fn restore(self) { + if self.directory { + std::fs::remove_file(&self.moved).unwrap(); + } + std::fs::rename(&self.backup, &self.moved).unwrap(); + assert_eq!(std::fs::read(self.record).unwrap(), self.bytes); + } +} + +fn queue_human( + ctx: &PromptContext, + human: &Keys, + scope: &SessionScope, +) -> (crate::EventQueue, nostr::Event) { + let event = event( + human, + scope.channel_id(), + scope.root_event_id(), + "Preserve this actual human task for the next ordinary turn", + Some(&ctx.agent_keys), + nostr::Timestamp::now().as_secs(), + ); + let mut queue = crate::EventQueue::new(crate::DedupMode::Queue); + queue.push(crate::QueuedEvent { + scope: scope.clone(), + channel_id: scope.channel_id(), + event: event.clone(), + received_at: std::time::Instant::now(), + prompt_tag: "test".into(), + edit: None, + }); + (queue, event) +} + +fn cancel_unconfirmed( + ctx: &PromptContext, + pool: &mut AgentPool, + queue: &mut crate::EventQueue, + scope: &SessionScope, + source: &str, +) { + let observer = crate::observer::ObserverHandle::in_process(); + if source == "owner" { + assert_eq!( + crate::handle_owner_cancel_for_scope( + pool, + queue, + &ctx.session_controls, + scope, + Some(&observer) + ), + crate::session_controls::status::StatusCancellation::Unconfirmed + ); + } else { + crate::handle_cancel_turn_control_with_status( + &serde_json::json!({"channelId":scope.channel_id().to_string(), "requestId":"unavailable-record"}), + pool, + queue, + Some(&observer), + Some(&ctx.session_controls), + ); + } + let events = observer.snapshot(); + let result = events.last().unwrap(); + assert_eq!(result.kind, "control_result"); + assert_eq!(result.payload["type"], "cancel_turn"); + assert_eq!(result.payload["status"], "unconfirmed"); +} + +#[allow(clippy::too_many_arguments)] +async fn assert_no_status_and_keep_human( + fixture: &Fixture, + ctx: &Arc, + relay: &Relay, + pool: &mut AgentPool, + mut queue: crate::EventQueue, + human: &nostr::Event, + scope: &SessionScope, + held_worker: OwnedAgent, +) { + assert!(!ctx.session_controls.status_pending(scope)); + assert!(ctx.session_controls.pending_statuses().is_empty()); + pool.return_agent(held_worker); + let (tx, mut rx) = mpsc::unbounded_channel(); + crate::status_tasks::dispatch(pool, ctx, &tx); + assert!(pool.join_set.is_empty()); + assert!(rx.try_recv().is_err()); + let mut worker = pool.try_claim(Some(scope)).unwrap(); + worker.acp.shutdown().await; + assert!(!fixture + .methods() + .iter() + .any(|method| method == "session/new" || method == "session/prompt")); + assert!(relay.posts.lock().unwrap().is_empty()); + let batch = queue.flush_next().unwrap(); + assert_eq!(batch.scope, *scope); + assert_eq!(batch.events.len(), 1); + assert_eq!(&batch.events[0].event, human); + assert!(batch.cancelled_events.is_empty()); + assert!(!queue.has_undispatched_work()); +} + +#[tokio::test] +async fn unavailable_pending_record_cancel_retires_local_fence_before_a_worker_is_free() { + for fault in ["missing", "unreadable"] { + for source in ["owner", "observer"] { + timeout(Duration::from_secs(15), async { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, relay) = setup(vec![], scope.channel_id()).await; + let restart = admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, [7]); + assert!(ctx.session_controls.pending_statuses().is_empty()); + ctx.session_controls + .retirement_report(7, crate::acp::TerminationReport::Clean); + let job = ctx.session_controls.pending_statuses().pop().unwrap(); + assert_eq!(job.saved.phase, RestartStatusPhase::Pending); + let captured = ctx.session_controls.capture(&scope); + let ctx = Arc::new(ctx); + let worker = fixture + .agent(&[("STATUS_TEXT", "Must never dispatch")]) + .await; + let mut pool = AgentPool::from_slots(vec![None]); + let (mut queue, queued) = queue_human(&ctx, &human, &scope); + let (tx, _rx) = mpsc::unbounded_channel(); + crate::status_tasks::dispatch(&mut pool, &ctx, &tx); + assert!(pool.join_set.is_empty()); + let unavailable = UnavailableRecord::hide(&fixture, &scope, fault); + cancel_unconfirmed(&ctx, &mut pool, &mut queue, &scope, source); + assert!(!captured.current()); + assert!(ctx.session_controls.pending_statuses().is_empty()); + unavailable.restore(); + assert!(tokio::time::Instant::now() < job.deadline); + let record = fixture.record(&scope); + assert_eq!( + record.restart_status.unwrap().phase, + RestartStatusPhase::Pending + ); + assert!(record + .control_receipts + .iter() + .any( + |receipt| receipt.command_id == restart.origin.command_event_id + && receipt.state + == crate::session_store::ControlReceiptState::Finished(Ok(())) + )); + assert_eq!(record.pending_restart.unwrap().token, job.saved.token); + assert_no_status_and_keep_human( + &fixture, &ctx, &relay, &mut pool, queue, &queued, &scope, worker, + ) + .await; + }) + .await + .unwrap(); + } + } +} + +#[tokio::test] +async fn unavailable_cancel_during_retirement_cannot_recreate_the_followup_after_clean_exit() { + for fault in ["missing", "unreadable", "write"] { + for source in ["owner", "observer"] { + timeout(Duration::from_secs(15), async { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, relay) = setup(vec![], scope.channel_id()).await; + let restart = admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, [7]); + let captured = ctx.session_controls.capture(&scope); + let old_token = captured.intent.as_ref().unwrap().token; + assert_eq!( + fixture.record(&scope).restart_status.unwrap().phase, + RestartStatusPhase::WaitingRetirement + ); + let ctx = Arc::new(ctx); + let worker = fixture + .agent(&[("STATUS_TEXT", "Must never dispatch")]) + .await; + let mut pool = AgentPool::from_slots(vec![None]); + let (mut queue, queued) = queue_human(&ctx, &human, &scope); + let unavailable = if fault == "write" { + fixture.store.fail_writes(true); + None + } else { + Some(UnavailableRecord::hide(&fixture, &scope, fault)) + }; + cancel_unconfirmed(&ctx, &mut pool, &mut queue, &scope, source); + assert!(!captured.current()); + if let Some(unavailable) = unavailable { + unavailable.restore(); + } + fixture.store.fail_writes(false); + ctx.session_controls + .retirement_report(7, crate::acp::TerminationReport::Clean); + assert!(!ctx.session_controls.retirement_pending(&scope)); + let record = fixture.record(&scope); + assert_eq!(record.pending_restart.unwrap().token, old_token); + assert!(record + .control_receipts + .iter() + .any( + |receipt| receipt.command_id == restart.origin.command_event_id + && receipt.state + == crate::session_store::ControlReceiptState::Finished(Ok(())) + )); + let status = record.restart_status.unwrap(); + assert_eq!(status.phase, RestartStatusPhase::Pending); + assert!(!status.notice_confirmed); + assert!(status.prepared.is_none() && status.notice.is_none()); + assert!(ctx + .session_controls + .take_publications() + .iter() + .any( + |publication| publication.command_id == restart.origin.command_event_id + && publication.result + == buzz_core::session_control::ControlResult::Terminal(Ok(())) + )); + assert_no_status_and_keep_human( + &fixture, &ctx, &relay, &mut pool, queue, &queued, &scope, worker, + ) + .await; + }) + .await + .unwrap(); + } + } +} + +#[tokio::test] +async fn unavailable_record_cancel_fences_already_scheduled_workerless_expiry_feedback() { + for fault in ["missing", "unreadable"] { + for source in ["owner", "observer"] { + // Budget includes the explicit 121-second virtual-time advance. + timeout(Duration::from_secs(150), async { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, relay) = setup(vec![], scope.channel_id()).await; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, [7]); + ctx.session_controls + .retirement_report(7, crate::acp::TerminationReport::Clean); + let captured = ctx.session_controls.capture(&scope); + let ctx = Arc::new(ctx); + let worker = fixture.agent(&[]).await; + let mut pool = AgentPool::from_slots(vec![None]); + let (mut queue, queued) = queue_human(&ctx, &human, &scope); + let (tx, _rx) = mpsc::unbounded_channel(); + tokio::time::pause(); + tokio::time::advance(Duration::from_secs(121)).await; + crate::status_tasks::dispatch(&mut pool, &ctx, &tx); + assert_eq!(pool.join_set.len(), 1); + let unavailable = UnavailableRecord::hide(&fixture, &scope, fault); + // No await since spawn: the feedback future is still held. + cancel_unconfirmed(&ctx, &mut pool, &mut queue, &scope, source); + assert!(!captured.current()); + unavailable.restore(); + tokio::time::resume(); + timeout(Duration::from_secs(8), pool.join_set.join_next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(fixture + .record(&scope) + .restart_status + .unwrap() + .notice + .is_none()); + assert_no_status_and_keep_human( + &fixture, &ctx, &relay, &mut pool, queue, &queued, &scope, worker, + ) + .await; + }) + .await + .unwrap(); + } + } +} + +#[tokio::test] +async fn stale_token_cleanup_cannot_fence_newer_pending_or_retiring_work_when_reads_fail() { + for phase in ["waiting", "pending"] { + for fault in ["missing", "unreadable", "readable"] { + timeout(Duration::from_secs(15), async { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, _relay) = setup(vec![], scope.channel_id()).await; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, []); + let old_token = ctx.session_controls.restart_intent(&scope).unwrap().token; + let later = super::super::control_tests::authorized_control( + &ctx, + &human, + &scope, + "!restart all", + ) + .await; + assert!(ctx.session_controls.restart(later).unwrap()); + ctx.session_controls.plan_retirement(&scope, [7]); + if phase == "pending" { + ctx.session_controls + .retirement_report(7, crate::acp::TerminationReport::Clean); + } + let captured = ctx.session_controls.capture(&scope); + let new_token = captured.intent.as_ref().unwrap().token; + assert_ne!(old_token, new_token); + let unavailable = + (fault != "readable").then(|| UnavailableRecord::hide(&fixture, &scope, fault)); + assert!(!ctx + .session_controls + .cancel_status_for_token(&scope, old_token)); + assert!(captured.current()); + assert_eq!( + ctx.session_controls.cancellable_status_scopes(), + vec![scope.clone()] + ); + if let Some(unavailable) = unavailable { + unavailable.restore(); + } + assert_eq!( + ctx.session_controls.restart_intent(&scope).unwrap().token, + new_token + ); + if phase == "waiting" { + ctx.session_controls + .retirement_report(7, crate::acp::TerminationReport::Clean); + } + assert_eq!( + ctx.session_controls + .pending_statuses() + .pop() + .unwrap() + .saved + .token, + new_token + ); + assert!(captured.current()); + }) + .await + .unwrap(); + } + } +} + +#[tokio::test] +async fn cancellation_preserves_a_confirmed_terminal_status_even_when_the_busy_cache_lags() { + timeout(Duration::from_secs(15), async { + let fixture = Fixture::new(); + let human = Keys::generate(); + let scope = scope(); + let (mut ctx, _relay) = setup(vec![], scope.channel_id()).await; + admitted(&fixture, &mut ctx, &human, &scope, "!restart none").await; + ctx.session_controls.plan_retirement(&scope, []); + let mut job = ctx.session_controls.pending_statuses().pop().unwrap(); + assert!(ctx.session_controls.start_status(&job)); + let captured = ctx.session_controls.capture(&scope); + ctx.session_controls + .transition_status(&mut job, RestartStatusPhase::Started, None, false, false) + .unwrap(); + let reply = event( + &ctx.agent_keys, + scope.channel_id(), + scope.root_event_id(), + "Actual confirmed status", + None, + nostr::Timestamp::now().as_secs(), + ); + ctx.session_controls + .transition_status( + &mut job, + RestartStatusPhase::Prepared, + Some(reply.clone()), + false, + false, + ) + .unwrap(); + let mut completed = job.saved.clone(); + completed.phase = RestartStatusPhase::Completed; + ctx.session_controls + .persist_status(&mut job, completed, true) + .unwrap(); + assert_eq!( + ctx.session_controls.cancel_status_outcome(&scope), + crate::session_controls::status::StatusCancellation::None + ); + assert!(!captured.current()); + assert!(ctx.session_controls.cancellable_status_scopes().is_empty()); + let saved = fixture.record(&scope); + assert!(saved.pending_restart.is_none()); + let status = saved.restart_status.unwrap(); + assert_eq!(status.phase, RestartStatusPhase::Completed); + assert_eq!(status.prepared, Some(reply)); + }) + .await + .unwrap(); +} diff --git a/crates/buzz-acp/src/pool/session_continuity.rs b/crates/buzz-acp/src/pool/session_continuity.rs new file mode 100644 index 00000000000..2bdc8c8de34 --- /dev/null +++ b/crates/buzz-acp/src/pool/session_continuity.rs @@ -0,0 +1,586 @@ +//! Session continuity: provider sessions that outlive their worker process. +//! +//! On a session miss the next turn in a scope first consults the durable +//! [`SessionStore`] and resumes the provider session with ACP `session/resume` +//! when that is safe. Every acquisition runs in rounds of +//! *decision → claim → RPC → commit with the same lease*: the claim happens +//! before the provider call, and a commit that finds the record taken over in +//! the meantime (a rotate, a channel removal, another owner) drops the session +//! it just obtained and starts a new round. +//! +//! [`SessionState`] keeps the lease of every scope it owns, so its invalidation +//! primitives can tombstone, mark departed, or update exactly the records it +//! still owns and never a record another owner has claimed since. + +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::Arc; + +use uuid::Uuid; + +use super::{ + create_or_resume_session_and_apply_model, AcpError, AgentPool, ChannelDeliveryState, + NewSessionChannelContext, OwnedAgent, PromptContext, SessionState, +}; +use crate::acp::resume_failure_code; +use crate::scope::SessionScope; +use crate::session_store::{ + append_delivered, Departure, Expect, LiveSession, LoadOutcome, SessionReason, SessionStore, + StoreError, +}; + +/// Acquisition rounds before a turn gives up on owning the scope's record. +const MAX_ACQUIRE_ROUNDS: usize = 3; + +/// How the provider session for a turn came to be. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SessionStart { + New { reason: SessionReason }, + Resumed { reason: SessionReason }, +} + +impl SessionStart { + pub(crate) fn is_resumed(&self) -> bool { + matches!(self, Self::Resumed { .. }) + } +} + +/// What the deciding worker knows about itself. +pub(crate) struct ResumeContext { + pub adapter: String, + pub cwd: String, + pub model: Option, + pub instance: Uuid, + pub worker_index: usize, + pub resume_supported: bool, + pub poisoned: bool, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum ResumeDecision { + Resume { + live: LiveSession, + reason: SessionReason, + }, + New { + reason: SessionReason, + }, +} + +/// Decide whether a scope's stored session may be resumed by this worker. +pub(crate) fn resume_decision( + record: &LoadOutcome, + me: &ResumeContext, + instance_alive: impl Fn(Uuid) -> bool, +) -> ResumeDecision { + let new = |reason| ResumeDecision::New { reason }; + if me.poisoned { + return new(SessionReason::ResumeFailed("not_persisted".into())); + } + let live = match record { + LoadOutcome::Missing => return new(SessionReason::FirstInThread), + LoadOutcome::Unreadable => { + return new(SessionReason::ResumeFailed("store_unreadable".into())) + } + LoadOutcome::Found(record) => match &record.live { + None => return new(SessionReason::Discarded), + Some(live) => live, + }, + }; + // A claim whose provider call never completed has no session to resume. + if live.session_id.is_empty() { + return new(SessionReason::FirstInThread); + } + if live.adapter != me.adapter || live.cwd != me.cwd || live.model != me.model { + return new(SessionReason::ConfigChanged); + } + if !me.resume_supported { + return new(SessionReason::ResumeUnsupported); + } + let resume = |reason| ResumeDecision::Resume { + live: live.clone(), + reason, + }; + if live.instance != me.instance { + return if instance_alive(live.instance) { + new(SessionReason::LiveElsewhere) + } else { + resume(SessionReason::HarnessRestart) + }; + } + if let Some(departure) = live.departed { + return resume(departure.into()); + } + if live.worker_index != me.worker_index { + return new(SessionReason::LiveElsewhere); + } + // This slot was respawned without marking its sessions departed. + resume(SessionReason::WorkerCrash) +} + +/// Open the agent's session store, or `None` when resume is disabled or the +/// directory cannot be used. +pub(crate) fn open_session_store( + configured_dir: Option, + resume_enabled: bool, + agent_pubkey_hex: &str, + relay_url: &str, +) -> Option> { + if !resume_enabled { + return None; + } + let dir = match configured_dir { + Some(dir) => dir, + None => std::env::current_dir().ok()?.join(".buzz-sessions"), + }; + match SessionStore::open(dir.clone(), relay_url, agent_pubkey_hex) { + Ok(store) => Some(Arc::new(store)), + Err(err) => { + tracing::warn!( + target: "session_store", + dir = %dir.display(), + "session store unavailable, sessions will not resume: {err}" + ); + None + } + } +} + +impl SessionState { + /// The result handler replaces workers after transport failures. Preserve + /// their provider sessions just as on an observed process exit. + pub(super) fn invalidate_after_error( + &mut self, + source: &super::PromptSource, + error: &AcpError, + ) { + if matches!( + error, + AcpError::Io(_) + | AcpError::WriteTimeout(_) + | AcpError::Timeout(_) + | AcpError::Protocol(_) + ) { + self.invalidate_all(); + } else { + self.invalidate(source); + } + } + + pub(crate) fn with_store(store: Option>) -> Self { + Self { + store, + ..Self::default() + } + } + + /// Tombstone the record this state owns for `scope`, if any, and retire + /// its status generation. + pub(super) fn discard_durable(&mut self, scope: &SessionScope) { + self.retire_status(scope); + if let (Some(store), Some(lease)) = (&self.store, self.leases.remove(scope)) { + let _ = store.discard(scope, lease); + } + } + + /// Mark every owned record departed and forget the leases: the worker's + /// memory is gone, the provider sessions are not. + pub(super) fn depart_durable_all(&mut self, departure: Departure) { + self.retire_all_status(); + let leases = std::mem::take(&mut self.leases); + if let Some(store) = &self.store { + for (scope, lease) in leases { + let _ = store.mark_departed(&scope, lease, departure); + } + } + } + + /// Mark every owned record departed while keeping memory and leases + /// (the pool is about to sleep). + pub(crate) fn depart_all(&mut self, departure: Departure) { + self.retire_all_status(); + if let Some(store) = &self.store { + for (scope, lease) in &self.leases { + let _ = store.mark_departed(scope, *lease, departure); + } + } + } + + /// Record a successful delivery in the owned record. + pub(super) fn persist_delivery( + &mut self, + scope: &SessionScope, + standing_context_sent: bool, + event_ids: &[String], + hydrated_thread_roots: &[String], + ) { + self.commit_owned(scope, |live| { + live.standing_context_sent |= standing_context_sent; + append_delivered(&mut live.delivered_event_ids, event_ids.iter().cloned()); + for root in hydrated_thread_roots { + if !live.hydrated_thread_roots.contains(root) { + live.hydrated_thread_roots.push(root.clone()); + } + } + let excess = live + .hydrated_thread_roots + .len() + .saturating_sub(super::MAX_HYDRATED_THREAD_ROOTS_PER_SCOPE); + live.hydrated_thread_roots.drain(..excess); + }); + } + + /// Record the scope's turn counter after it was incremented. + pub(crate) fn persist_turns(&mut self, scope: &SessionScope) { + let turns = self.turn_counts.get(scope).copied().unwrap_or(0); + self.commit_owned(scope, |live| live.turns = turns); + } + + fn commit_owned(&mut self, scope: &SessionScope, change: impl FnOnce(&mut LiveSession)) { + let (Some(store), Some(lease)) = (&self.store, self.leases.get(scope).copied()) else { + return; + }; + if let Err(StoreError::Conflict) = store.commit(scope, lease, change) { + // Another owner took the record; this state no longer speaks for it. + self.leases.remove(scope); + } + } + + pub(super) fn restore_delivery(&mut self, scope: &SessionScope, live: LiveSession) { + self.deliveries.insert( + scope.clone(), + ChannelDeliveryState { + standing_context_sent: live.standing_context_sent, + delivered_event_ids: live.delivered_event_ids.into_iter().collect(), + hydrated_thread_roots: VecDeque::from(live.hydrated_thread_roots), + }, + ); + if live.turns > 0 { + self.turn_counts.insert(scope.clone(), live.turns); + } + } +} + +impl AgentPool { + /// Attach the session store to the pool and to every worker in it. + pub(crate) fn set_session_store(&mut self, store: Option>) { + for agent in self.agents.iter_mut().flatten() { + agent.state.store = store.clone(); + } + self.store = store; + } + + /// Before the pool sleeps: mark every idle worker's owned sessions + /// departed so the next turn resumes them. + pub(crate) fn depart_idle_sessions(&mut self, departure: Departure) { + for agent in self.agents.iter_mut().flatten() { + agent.state.depart_all(departure); + } + } + + /// Owner rotate: tombstone the scope's record whoever owns it, including + /// a session that lives only on disk after a sleep or respawn. Leases in + /// memory stay, so a late write of the previous owner is a conflict. + #[cfg(all(test, unix))] + pub(crate) fn discard_durable_scope(&mut self, scope: &SessionScope) { + if let Some(store) = &self.store { + let _ = store.discard_scope(scope); + } + } + + /// Channel removal: tombstone every record of the channel. + pub(crate) fn discard_durable_channel(&mut self, channel_id: Uuid) { + if let Some(store) = &self.store { + let _ = store.discard_channel(channel_id); + } + } +} + +/// Provide a provider session for `scope` on a session miss: resume the +/// stored one when safe, otherwise create a new one. On success the session's +/// delivery state is restored for a resumed session; the caller records the +/// session id and, for a new session, a fresh delivery state. +pub(super) async fn acquire_session( + agent: &mut OwnedAgent, + ctx: &PromptContext, + agent_core: Option<&str>, + channel: NewSessionChannelContext<'_>, + scope: &SessionScope, +) -> Result<(String, SessionStart), AcpError> { + ensure_dispatch_current(agent)?; + let Some(store) = agent.state.store.clone() else { + let sid = + create_or_resume_session_and_apply_model(agent, ctx, agent_core, channel, None).await?; + return Ok(( + sid, + SessionStart::New { + reason: agent + .state + .control_dispatch + .as_ref() + .and_then(|dispatch| dispatch.intent.as_ref()) + .map_or_else( + || { + if ctx.session_controls.recycled_reason(scope) { + SessionReason::WorkerRecycled + } else { + SessionReason::FirstInThread + } + }, + |intent| SessionReason::UserRestart(intent.depth), + ), + }, + )); + }; + for _ in 0..MAX_ACQUIRE_ROUNDS { + ensure_dispatch_current(agent)?; + let outcome = store.load(scope); + let me = ResumeContext { + adapter: agent.agent_name.clone(), + cwd: ctx.cwd.clone(), + model: agent.desired_model.clone(), + instance: store.instance(), + worker_index: agent.index, + resume_supported: agent.acp.resume_supported(), + poisoned: store.is_poisoned(scope), + }; + let mut decision = + resume_decision(&outcome, &me, |instance| store.instance_alive(instance)); + if let Some(intent) = agent + .state + .control_dispatch + .as_ref() + .and_then(|dispatch| dispatch.intent.as_ref()) + { + decision = ResumeDecision::New { + reason: SessionReason::UserRestart(intent.depth), + }; + } + if let ResumeDecision::New { + reason: SessionReason::ResumeFailed(code), + } = &decision + { + if code == "not_persisted" { + return new_unpersisted( + agent, + ctx, + agent_core, + channel, + decision_reason(&decision), + ) + .await; + } + } + let claim = match &decision { + ResumeDecision::Resume { live, .. } => LiveSession { + instance: me.instance, + worker_index: me.worker_index, + departed: None, + ..live.clone() + }, + ResumeDecision::New { .. } => LiveSession { + lease: Uuid::nil(), + adapter: me.adapter.clone(), + session_id: String::new(), + cwd: me.cwd.clone(), + model: me.model.clone(), + instance: me.instance, + worker_index: me.worker_index, + departed: None, + turns: 0, + standing_context_sent: false, + delivered_event_ids: Vec::new(), + hydrated_thread_roots: Vec::new(), + }, + }; + let lease = match store.claim(scope, &Expect::from_outcome(&outcome), claim) { + Ok(lease) => lease, + Err(StoreError::Conflict) => continue, + Err(err) => { + // Without a claim no resume may run; the scope is poisoned. + let reason = match (&decision, &err) { + (ResumeDecision::Resume { .. }, StoreError::Busy) => { + SessionReason::ResumeFailed("store_busy".into()) + } + (ResumeDecision::Resume { .. }, _) => { + SessionReason::ResumeFailed("not_persisted".into()) + } + (ResumeDecision::New { reason }, _) => reason.clone(), + }; + return new_unpersisted(agent, ctx, agent_core, channel, reason).await; + } + }; + // Owned from here: a crash before commit marks this claim departed. + agent.state.leases.insert(scope.clone(), lease); + + let (sid, start, restored) = match decision { + ResumeDecision::Resume { live, reason } => { + match create_or_resume_session_and_apply_model( + agent, + ctx, + agent_core, + channel, + Some(&live.session_id), + ) + .await + { + Ok(sid) => (sid, SessionStart::Resumed { reason }, Some(live)), + Err(AcpError::AgentExited) => return Err(AcpError::AgentExited), + Err(err) => { + // A restart during resume also forbids the fallback RPC. + if ensure_dispatch_current(agent).is_err() { + // Preserve the original error's health classification; + // the retired result fence suppresses application output. + return Err(err); + } + let code = resume_failure_code(&err); + tracing::warn!( + target: "pool::session", + "resume of session {} failed ({code}): {err}", + live.session_id + ); + let sid = create_or_resume_session_and_apply_model( + agent, ctx, agent_core, channel, None, + ) + .await?; + ( + sid, + SessionStart::New { + reason: SessionReason::ResumeFailed(code), + }, + None, + ) + } + } + } + ResumeDecision::New { reason } => { + let sid = + create_or_resume_session_and_apply_model(agent, ctx, agent_core, channel, None) + .await?; + (sid, SessionStart::New { reason }, None) + } + }; + + ensure_dispatch_current(agent)?; + let committed = store.commit(scope, lease, |live| { + live.session_id = sid.clone(); + if restored.is_none() { + live.turns = 0; + live.standing_context_sent = false; + live.delivered_event_ids.clear(); + live.hydrated_thread_roots.clear(); + } + }); + match committed { + Ok(()) + | Err( + StoreError::Busy + | StoreError::Io(_) + | StoreError::Unreadable + | StoreError::InvalidControl + | StoreError::StaleRequest + | StoreError::AlreadyRecorded, + ) => { + if let Some(live) = restored { + agent.state.restore_delivery(scope, live); + } + return Ok((sid, start)); + } + Err(StoreError::Conflict) => { + // The record was taken over during the call: the session just + // obtained must not be used. + agent.state.leases.remove(scope); + tracing::info!( + target: "pool::session", + "dropping session {sid}: scope {} changed owner during acquisition", + scope.telemetry_label() + ); + } + } + } + new_unpersisted( + agent, + ctx, + agent_core, + channel, + SessionReason::LiveElsewhere, + ) + .await +} + +/// The agent-facing marker for the first prompt of a new or resumed session: +/// ``, plus +/// `first_mention="not_found"` when a cold start could not find the agent's +/// first mention in the thread. +pub(crate) fn format_session_marker( + start: &SessionStart, + first_mention: Option<&super::first_mention::FirstMention>, +) -> String { + let (state, reason) = match start { + SessionStart::New { reason } => ("new", reason), + SessionStart::Resumed { reason } => ("resumed", reason), + }; + let not_found = matches!( + first_mention, + Some(super::first_mention::FirstMention::NotFound) + ); + format!( + r#""#, + reason.as_wire(), + if not_found { + r#" first_mention="not_found""# + } else { + "" + } + ) +} + +/// Put the marker right before the conversation context section, or first +/// when the prompt has none. +pub(crate) fn insert_session_marker(sections: &mut Vec, marker: String) { + let at = sections + .iter() + .position(|section| { + section.starts_with(" SessionReason { + match decision { + ResumeDecision::Resume { reason, .. } | ResumeDecision::New { reason } => reason.clone(), + } +} + +/// A new session that this worker keeps only in memory. +async fn new_unpersisted( + agent: &mut OwnedAgent, + ctx: &PromptContext, + agent_core: Option<&str>, + channel: NewSessionChannelContext<'_>, + reason: SessionReason, +) -> Result<(String, SessionStart), AcpError> { + ensure_dispatch_current(agent)?; + let sid = + create_or_resume_session_and_apply_model(agent, ctx, agent_core, channel, None).await?; + ensure_dispatch_current(agent)?; + Ok((sid, SessionStart::New { reason })) +} + +#[cfg(all(test, unix))] +#[path = "session_continuity_tests.rs"] +mod tests; + +fn ensure_dispatch_current(agent: &OwnedAgent) -> Result<(), AcpError> { + if agent + .state + .control_dispatch + .as_ref() + .is_some_and(|dispatch| !dispatch.current()) + { + Err(AcpError::DispatchRetired) + } else { + Ok(()) + } +} diff --git a/crates/buzz-acp/src/pool/session_continuity_tests.rs b/crates/buzz-acp/src/pool/session_continuity_tests.rs new file mode 100644 index 00000000000..90fbbb9cf98 --- /dev/null +++ b/crates/buzz-acp/src/pool/session_continuity_tests.rs @@ -0,0 +1,1394 @@ +use super::super::{run_prompt_task, ChannelInfoResolver, FlushBatch, PromptOutcome, RestClient}; +use super::*; +use crate::acp::{AcpClient, StopReason}; +use crate::session_store::SessionRecord; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use std::collections::HashMap; +use std::time::Duration; + +/// Fake ACP adapter. Logs every method to `$LOG`; `RESUME_CAP=1` advertises +/// resume, `RESUME_ERROR=1` answers resume with -32002, `DELAY=` delays +/// every session/new and session/resume answer. +const ADAPTER: &str = r#" +import json, os, sys, time +log = open(os.environ['LOG'], 'a', buffering=1) +delay = float(os.environ.get('DELAY', '0')) +created = 0 +prompt_count = 0 +for line in sys.stdin: + req = json.loads(line) + method = req.get('method') + log.write(f"{method}\n") + if method is None and req.get('id') == 'permission-one': + assert req['result']['outcome']['outcome']=='cancelled' + log.write('permission-denied\n') + if 'id' not in req or method is None: + continue + reply = {'jsonrpc': '2.0', 'id': req['id']} + if method == 'initialize': + caps = {'sessionCapabilities': {'resume': {}}} if os.environ.get('RESUME_CAP') == '1' else {} + reply['result'] = {'protocolVersion': 1, 'agentCapabilities': caps} + elif method == 'session/new': + if os.environ.get('CHECK_STATUS_STARTED_ROOT'): + import pathlib + records=[json.loads(p.read_text()) for p in pathlib.Path(os.environ['CHECK_STATUS_STARTED_ROOT']).rglob('*.json')] + assert any(record.get('restart_status',{}).get('phase')=='started' for record in records) + if os.environ.get('FAIL_NEW'): + reply['error'] = {'code':-32000,'message':'deliberate startup failure'} + print(json.dumps(reply), flush=True) + continue + time.sleep(delay) + created += 1 + if os.environ.get('NEW_ADVERTISE') == '1': + print(json.dumps({'jsonrpc':'2.0','method':'session/update','params':{'sessionId':f'new-{created}','update':{'sessionUpdate':'available_commands_update','availableCommands':[{'name':'compact','description':'Compact'}]}}}),flush=True) + reply['result'] = {'sessionId': f'new-{created}'} + elif method == 'session/resume': + if os.environ.get('COMPACT_STATUS'): + print(json.dumps({'jsonrpc':'2.0','method':'session/update','params':{'sessionId':req['params']['sessionId'],'update':{'sessionUpdate':'available_commands_update','availableCommands':[{'name':'compact','description':'Compact'}]}}}),flush=True) + time.sleep(delay) + if os.environ.get('RESUME_PROTOCOL') == '1': + print('x' * 10000001, flush=True) + continue + if os.environ.get('RESUME_ERROR') == '1': + reply['error'] = {'code': -32002, 'message': 'Resource not found'} + else: + reply['result'] = {} + elif method == 'session/prompt': + time.sleep(float(os.environ.get('PROMPT_DELAY', '0'))) + if os.environ.get('CHECK_STARTED_ROOT'): + import pathlib + records=[json.loads(p.read_text()) for p in pathlib.Path(os.environ['CHECK_STARTED_ROOT']).rglob('*.json')] + assert any(any(r['command_id']==os.environ['COMMAND_ID'] and r['state']=='started' for r in record.get('control_receipts',[])) for record in records) + if os.environ.get('COMPACT_STATUS') not in (None,'missing') and (os.environ.get('COMPACT_ONLY_SLASH') != '1' or req['params']['prompt'] == [{'type':'text','text':'/compact'}]): + print(json.dumps({'jsonrpc':'2.0','method':'session/update','params':{'sessionId':req['params']['sessionId'],'update':{'sessionUpdate':'compaction_update','compactionId':'compact-one','status':os.environ['COMPACT_STATUS']}}}),flush=True) + if os.environ.get('HANG_PROMPT') == '1': + continue + if os.environ.get('DIE_PROMPT'): + while not os.path.exists(os.environ['DIE_PROMPT']): + time.sleep(.01) + sys.exit(0) + if os.environ.get('BAD_PROMPT') == '1': + reply['result'] = {} + print(json.dumps(reply), flush=True) + continue + prompt_count += 1 + session = req['params']['sessionId'] + if os.environ.get('STATUS_WRONG_SESSION'): + session = 'another-session' + if os.environ.get('STATUS_USAGE'): + used, size = json.loads(os.environ['STATUS_USAGE']) + print(json.dumps({'jsonrpc':'2.0','method':'session/update','params':{'sessionId':session,'update':{'sessionUpdate':'usage_update','used':used,'size':size}}}), flush=True) + if os.environ.get('STATUS_TEXT'): + print(json.dumps({'jsonrpc':'2.0','method':'session/update','params':{'sessionId':session,'update':{'sessionUpdate':'agent_message_chunk','content':{'type':'text','text':os.environ['STATUS_TEXT']}}}}), flush=True) + if os.environ.get('STATUS_THOUGHT'): + print(json.dumps({'jsonrpc':'2.0','method':'session/update','params':{'sessionId':session,'update':{'sessionUpdate':'agent_thought_chunk','content':{'type':'text','text':'private reasoning'}}}}), flush=True) + if os.environ.get('STATUS_TOOL'): + print(json.dumps({'jsonrpc':'2.0','method':'session/update','params':{'sessionId':session,'update':{'sessionUpdate':'tool_call','toolCallId':'one','title':'forbidden tool','kind':'execute'}}}), flush=True) + if os.environ.get('STATUS_PERMISSION'): + print(json.dumps({'jsonrpc':'2.0','id':'permission-one','method':'session/request_permission','params':{'sessionId':session,'options':[{'kind':'allow_once','optionId':'allow'}]}}), flush=True) + if os.environ.get('PROMPTS'): + with open(os.environ['PROMPTS'], 'a') as out: + out.write(json.dumps(req['params']) + '\n') + if str(prompt_count) == os.environ.get('FAIL_PROMPT'): + reply['error'] = {'code': int(os.environ.get('FAIL_PROMPT_CODE','-32000')), 'message': 'deliberate prompt failure'} + else: + reply['result'] = {'stopReason': os.environ.get('STATUS_STOP','end_turn')} + else: + reply['result'] = {} + print(json.dumps(reply), flush=True) +"#; + +struct Fixture { + dir: tempfile::TempDir, + store: Arc, +} + +impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let store = Arc::new( + SessionStore::open( + dir.path().join("store"), + "wss://relay.example", + &Keys::generate().public_key().to_hex(), + ) + .unwrap(), + ); + Self { dir, store } + } + + fn log(&self) -> std::path::PathBuf { + self.dir.path().join("methods.log") + } + + fn methods(&self) -> Vec { + std::fs::read_to_string(self.log()) + .unwrap_or_default() + .lines() + .map(str::to_owned) + .collect() + } + + async fn agent(&self, env: &[(&str, &str)]) -> OwnedAgent { + let mut vars = vec![("LOG".to_owned(), self.log().display().to_string())]; + vars.extend(env.iter().map(|(k, v)| (k.to_string(), v.to_string()))); + let mut acp = AcpClient::spawn("python3", &["-c".into(), ADAPTER.into()], &vars, false) + .await + .unwrap(); + acp.initialize().await.unwrap(); + OwnedAgent { + index: 0, + acp, + state: SessionState::with_store(Some(self.store.clone())), + model_capabilities: None, + desired_model: None, + model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, + agent_name: "fake-acp".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + background_context: None, + } + } + + /// A resumable record for `scope`, as left by a worker that went to sleep. + fn departed_record(&self, scope: &SessionScope, ctx: &PromptContext, delivered: &[&str]) { + let lease = self + .store + .claim( + scope, + &Expect::Missing, + live_session(&self.store, ctx, "old-1"), + ) + .unwrap(); + self.store + .commit(scope, lease, |live| { + live.departed = Some(Departure::IdleSleep); + live.turns = 1; + live.delivered_event_ids = delivered.iter().map(|id| id.to_string()).collect(); + }) + .unwrap(); + } + + fn record(&self, scope: &SessionScope) -> SessionRecord { + match self.store.load(scope) { + LoadOutcome::Found(record) => *record, + other => panic!("expected record, got {other:?}"), + } + } +} + +fn scope() -> SessionScope { + SessionScope::Thread { + channel_id: Uuid::from_u128(42), + root_event_id: "ab".repeat(32), + } +} + +fn ctx() -> PromptContext { + let mut ctx = super::super::tests::make_prompt_context_no_owner(); + ctx.cwd = "/tmp".into(); + ctx +} + +fn live_session(store: &SessionStore, ctx: &PromptContext, sid: &str) -> LiveSession { + LiveSession { + lease: Uuid::nil(), + adapter: "fake-acp".into(), + session_id: sid.into(), + cwd: ctx.cwd.clone(), + model: None, + instance: store.instance(), + worker_index: 0, + departed: None, + turns: 0, + standing_context_sent: false, + delivered_event_ids: Vec::new(), + hydrated_thread_roots: Vec::new(), + } +} + +fn channel_context(scope: &SessionScope) -> NewSessionChannelContext<'_> { + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + scope: Some(scope), + channel_type: None, + } +} + +// ---- decision table ---- + +fn me(store_instance: Uuid) -> ResumeContext { + ResumeContext { + adapter: "fake-acp".into(), + cwd: "/tmp".into(), + model: None, + instance: store_instance, + worker_index: 0, + resume_supported: true, + poisoned: false, + } +} + +fn stored(live: impl FnOnce(&mut LiveSession)) -> LoadOutcome { + let mut session = LiveSession { + lease: Uuid::new_v4(), + adapter: "fake-acp".into(), + session_id: "s-1".into(), + cwd: "/tmp".into(), + model: None, + instance: Uuid::from_u128(1), + worker_index: 0, + departed: None, + turns: 0, + standing_context_sent: false, + delivered_event_ids: Vec::new(), + hydrated_thread_roots: Vec::new(), + }; + live(&mut session); + LoadOutcome::Found(Box::new(SessionRecord { + version: 2, + namespace: String::new(), + revision: None, + pending_restart: None, + control_receipts: Vec::new(), + restart_status: None, + live: Some(session), + reason: None, + })) +} + +fn decide(record: LoadOutcome, me: ResumeContext, alive: bool) -> ResumeDecision { + resume_decision(&record, &me, |_| alive) +} + +fn new_reason(decision: ResumeDecision) -> SessionReason { + match decision { + ResumeDecision::New { reason } => reason, + other => panic!("expected a new session, got {other:?}"), + } +} + +fn resume_reason(decision: ResumeDecision) -> SessionReason { + match decision { + ResumeDecision::Resume { reason, .. } => reason, + other => panic!("expected a resume, got {other:?}"), + } +} + +const ME: Uuid = Uuid::from_u128(1); + +#[test] +fn decision_poisoned_is_not_persisted() { + let mut ctx = me(ME); + ctx.poisoned = true; + assert_eq!( + new_reason(decide(stored(|_| {}), ctx, false)), + SessionReason::ResumeFailed("not_persisted".into()) + ); +} + +#[test] +fn decision_missing_is_first_in_thread() { + assert_eq!( + new_reason(decide(LoadOutcome::Missing, me(ME), false)), + SessionReason::FirstInThread + ); +} + +#[test] +fn decision_unreadable_is_resume_failed() { + assert_eq!( + new_reason(decide(LoadOutcome::Unreadable, me(ME), false)), + SessionReason::ResumeFailed("store_unreadable".into()) + ); +} + +#[test] +fn decision_tombstone_is_discarded() { + let tombstone = LoadOutcome::Found(Box::new(SessionRecord { + version: 2, + namespace: String::new(), + revision: None, + pending_restart: None, + control_receipts: Vec::new(), + restart_status: None, + live: None, + reason: Some(SessionReason::Discarded), + })); + assert_eq!( + new_reason(decide(tombstone, me(ME), false)), + SessionReason::Discarded + ); +} + +#[test] +fn decision_unfinished_claim_is_first_in_thread() { + assert_eq!( + new_reason(decide(stored(|l| l.session_id.clear()), me(ME), false)), + SessionReason::FirstInThread + ); +} + +#[test] +fn decision_model_change_is_config_changed() { + assert_eq!( + new_reason(decide( + stored(|l| l.model = Some("other".into())), + me(ME), + false + )), + SessionReason::ConfigChanged + ); +} + +#[test] +fn decision_without_capability_is_unsupported() { + let mut ctx = me(ME); + ctx.resume_supported = false; + assert_eq!( + new_reason(decide( + stored(|l| l.departed = Some(Departure::IdleSleep)), + ctx, + false + )), + SessionReason::ResumeUnsupported + ); +} + +#[test] +fn decision_other_live_instance_is_live_elsewhere() { + assert_eq!( + new_reason(decide( + stored(|l| l.instance = Uuid::from_u128(2)), + me(ME), + true + )), + SessionReason::LiveElsewhere + ); +} + +#[test] +fn decision_dead_instance_resumes_as_harness_restart() { + assert_eq!( + resume_reason(decide( + stored(|l| l.instance = Uuid::from_u128(2)), + me(ME), + false + )), + SessionReason::HarnessRestart + ); +} + +#[test] +fn decision_live_on_other_worker_is_live_elsewhere() { + assert_eq!( + new_reason(decide(stored(|l| l.worker_index = 3), me(ME), false)), + SessionReason::LiveElsewhere + ); +} + +#[test] +fn decision_departed_resumes_with_departure_reason() { + assert_eq!( + resume_reason(decide( + stored(|l| { + l.worker_index = 3; + l.departed = Some(Departure::IdleSleep); + }), + me(ME), + false + )), + SessionReason::IdleSleep + ); +} + +#[test] +fn decision_same_slot_unmarked_resumes_as_worker_crash() { + assert_eq!( + resume_reason(decide(stored(|_| {}), me(ME), false)), + SessionReason::WorkerCrash + ); +} + +// ---- SessionState with a real store ---- + +fn owned_state(fixture: &Fixture, scope: &SessionScope, sid: &str) -> (SessionState, Uuid) { + let ctx = ctx(); + let lease = fixture + .store + .claim( + scope, + &Expect::Missing, + live_session(&fixture.store, &ctx, sid), + ) + .unwrap(); + let mut state = SessionState::with_store(Some(fixture.store.clone())); + state.sessions.insert(scope.clone(), sid.into()); + state.leases.insert(scope.clone(), lease); + (state, lease) +} + +#[test] +fn delivery_success_persists_ids() { + let fixture = Fixture::new(); + let scope = scope(); + let (mut state, _) = owned_state(&fixture, &scope, "s-1"); + state.mark_scope_delivery_success( + scope.clone(), + true, + ["e1".to_owned(), "e2".to_owned()], + ["root".to_owned()], + ); + let live = fixture.record(&scope).live.unwrap(); + assert_eq!(live.delivered_event_ids, ["e1", "e2"]); + assert!(live.standing_context_sent); + assert_eq!(live.hydrated_thread_roots, ["root"]); +} + +#[test] +fn invalidate_scope_writes_tombstone() { + let fixture = Fixture::new(); + let scope = scope(); + let (mut state, _) = owned_state(&fixture, &scope, "s-1"); + assert!(state.invalidate_scope(&scope)); + let record = fixture.record(&scope); + assert_eq!(record.live, None); + assert_eq!(record.reason, Some(SessionReason::Discarded)); +} + +#[test] +fn stale_owner_invalidation_keeps_newer_record() { + let fixture = Fixture::new(); + let scope = scope(); + let (mut stale, _) = owned_state(&fixture, &scope, "s-a"); + let expect = Expect::from_outcome(&fixture.store.load(&scope)); + let newer = fixture + .store + .claim(&scope, &expect, live_session(&fixture.store, &ctx(), "s-b")) + .unwrap(); + stale.invalidate_scope(&scope); + let live = fixture.record(&scope).live.unwrap(); + assert_eq!(live.session_id, "s-b"); + assert_eq!(live.lease, newer); +} + +#[test] +fn invalidate_all_marks_worker_crash_without_tombstone() { + let fixture = Fixture::new(); + let scope = scope(); + let (mut state, _) = owned_state(&fixture, &scope, "s-1"); + state.invalidate_all(); + assert!(state.sessions.is_empty()); + let live = fixture.record(&scope).live.unwrap(); + assert_eq!(live.departed, Some(Departure::WorkerCrash)); +} + +#[test] +fn depart_all_marks_idle_sleep_and_keeps_memory() { + let fixture = Fixture::new(); + let scope = scope(); + let (mut state, _) = owned_state(&fixture, &scope, "s-1"); + state.depart_all(Departure::IdleSleep); + assert!(state.sessions.contains_key(&scope)); + assert_eq!( + fixture.record(&scope).live.unwrap().departed, + Some(Departure::IdleSleep) + ); +} + +// ---- acquire_session against a fake adapter ---- + +#[tokio::test] +async fn resume_advertised_uses_session_resume_not_new() { + let fixture = Fixture::new(); + let scope = scope(); + let ctx = ctx(); + fixture.departed_record(&scope, &ctx, &["e-old"]); + let mut agent = fixture.agent(&[("RESUME_CAP", "1")]).await; + let (sid, start) = acquire_session(&mut agent, &ctx, None, channel_context(&scope), &scope) + .await + .unwrap(); + agent.acp.shutdown().await; + assert_eq!(sid, "old-1"); + assert_eq!( + start, + SessionStart::Resumed { + reason: SessionReason::IdleSleep + } + ); + let methods = fixture.methods(); + assert!(methods.contains(&"session/resume".to_owned())); + assert!(!methods.contains(&"session/new".to_owned())); + assert!(agent.state.deliveries[&scope] + .delivered_event_ids + .contains("e-old")); + assert_eq!(agent.state.turn_counts[&scope], 1); + let live = fixture.record(&scope).live.unwrap(); + assert_eq!(live.departed, None); + assert_eq!(Some(&live.lease), agent.state.leases.get(&scope)); +} + +#[tokio::test] +async fn resume_error_falls_back_to_new_session() { + let fixture = Fixture::new(); + let scope = scope(); + let ctx = ctx(); + fixture.departed_record(&scope, &ctx, &["e-old"]); + let mut agent = fixture + .agent(&[("RESUME_CAP", "1"), ("RESUME_ERROR", "1")]) + .await; + let (sid, start) = acquire_session(&mut agent, &ctx, None, channel_context(&scope), &scope) + .await + .unwrap(); + agent.acp.shutdown().await; + assert_eq!(sid, "new-1"); + assert_eq!( + start, + SessionStart::New { + reason: SessionReason::ResumeFailed("not_found".into()) + } + ); + let methods = fixture.methods(); + let resume_at = methods.iter().position(|m| m == "session/resume").unwrap(); + let new_at = methods.iter().position(|m| m == "session/new").unwrap(); + assert!(resume_at < new_at); + let live = fixture.record(&scope).live.unwrap(); + assert_eq!(live.session_id, "new-1"); + assert!(live.delivered_event_ids.is_empty()); +} + +#[tokio::test] +async fn resume_not_advertised_creates_new_without_attempt() { + let fixture = Fixture::new(); + let scope = scope(); + let ctx = ctx(); + fixture.departed_record(&scope, &ctx, &[]); + let mut agent = fixture.agent(&[]).await; + let (_, start) = acquire_session(&mut agent, &ctx, None, channel_context(&scope), &scope) + .await + .unwrap(); + agent.acp.shutdown().await; + assert_eq!( + start, + SessionStart::New { + reason: SessionReason::ResumeUnsupported + } + ); + assert!(!fixture.methods().contains(&"session/resume".to_owned())); +} + +#[tokio::test] +async fn failed_claim_never_calls_resume() { + use fs2::FileExt; + let fixture = Fixture::new(); + let scope = scope(); + let ctx = ctx(); + fixture.departed_record(&scope, &ctx, &[]); + let channel_dir = fixture.store.channel_dir(scope.channel_id()); + let record = std::fs::read_dir(&channel_dir) + .unwrap() + .flatten() + .map(|entry| entry.path()) + .find(|path| path.extension().is_some_and(|ext| ext == "json")) + .unwrap(); + let mut lock_path = record.into_os_string(); + lock_path.push(".lock"); + let holder = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&lock_path) + .unwrap(); + holder.lock_exclusive().unwrap(); + let mut agent = fixture.agent(&[("RESUME_CAP", "1")]).await; + let (_, start) = acquire_session(&mut agent, &ctx, None, channel_context(&scope), &scope) + .await + .unwrap(); + agent.acp.shutdown().await; + FileExt::unlock(&holder).unwrap(); + assert_eq!( + start, + SessionStart::New { + reason: SessionReason::ResumeFailed("store_busy".into()) + } + ); + let methods = fixture.methods(); + assert!(!methods.contains(&"session/resume".to_owned())); + assert!(methods.contains(&"session/new".to_owned())); + assert!(!agent.state.leases.contains_key(&scope)); +} + +/// Tombstone `scope` from another thread while each of the first `times` +/// calls of `method` is in flight (the fake adapter logs a call when it +/// receives it and answers after its delay). +fn rotate_during_calls( + fixture: &Fixture, + scope: &SessionScope, + method: &'static str, + times: usize, +) -> std::thread::JoinHandle<()> { + let (store, scope, log) = (fixture.store.clone(), scope.clone(), fixture.log()); + std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(30); + let mut rotated = 0; + while rotated < times && std::time::Instant::now() < deadline { + let calls = std::fs::read_to_string(&log) + .unwrap_or_default() + .lines() + .filter(|line| *line == method) + .count(); + if calls > rotated { + store.discard_scope(&scope).unwrap(); + rotated += 1; + } + std::thread::sleep(Duration::from_millis(10)); + } + }) +} + +#[tokio::test] +async fn resume_result_after_rotate_is_dropped() { + let fixture = Fixture::new(); + let scope = scope(); + let ctx = ctx(); + fixture.departed_record(&scope, &ctx, &[]); + let mut agent = fixture + .agent(&[("RESUME_CAP", "1"), ("DELAY", "0.6")]) + .await; + let rotate = rotate_during_calls(&fixture, &scope, "session/resume", 1); + let (sid, start) = acquire_session(&mut agent, &ctx, None, channel_context(&scope), &scope) + .await + .unwrap(); + rotate.join().unwrap(); + agent.acp.shutdown().await; + assert_eq!(sid, "new-1"); + assert_eq!( + start, + SessionStart::New { + reason: SessionReason::Discarded + } + ); + assert_eq!(fixture.record(&scope).live.unwrap().session_id, "new-1"); +} + +#[tokio::test] +async fn new_result_after_rotate_is_dropped() { + let fixture = Fixture::new(); + let scope = scope(); + let ctx = ctx(); + let mut agent = fixture.agent(&[("DELAY", "0.6")]).await; + let rotate = rotate_during_calls(&fixture, &scope, "session/new", 1); + let (sid, start) = acquire_session(&mut agent, &ctx, None, channel_context(&scope), &scope) + .await + .unwrap(); + rotate.join().unwrap(); + agent.acp.shutdown().await; + assert_eq!(sid, "new-2", "the session from the lost round is not used"); + assert_eq!( + start, + SessionStart::New { + reason: SessionReason::Discarded + } + ); + assert_eq!(fixture.record(&scope).live.unwrap().session_id, "new-2"); +} + +#[tokio::test] +async fn ownership_lost_three_times_runs_unpersisted() { + let fixture = Fixture::new(); + let scope = scope(); + let ctx = ctx(); + let mut agent = fixture.agent(&[("DELAY", "0.3")]).await; + let rotate = rotate_during_calls(&fixture, &scope, "session/new", MAX_ACQUIRE_ROUNDS); + let (sid, start) = acquire_session(&mut agent, &ctx, None, channel_context(&scope), &scope) + .await + .unwrap(); + rotate.join().unwrap(); + agent.acp.shutdown().await; + assert_eq!( + start, + SessionStart::New { + reason: SessionReason::LiveElsewhere + } + ); + assert_eq!(sid, "new-4"); + assert!(!agent.state.leases.contains_key(&scope)); +} + +// ---- run_prompt_task: the production seams ---- + +fn mention(keys: &Keys, channel_id: Uuid, root: Option<&str>, text: &str) -> nostr::Event { + let mut tags = vec![Tag::parse(["h", channel_id.to_string().as_str()]).unwrap()]; + if let Some(root) = root { + tags.push(Tag::parse(["e", root, "", "reply"]).unwrap()); + } + EventBuilder::new(Kind::Custom(9), text) + .tags(tags) + .sign_with_keys(keys) + .unwrap() +} + +fn batch(scope: &SessionScope, event: nostr::Event) -> FlushBatch { + FlushBatch { + continuation: None, + channel_id: scope.channel_id(), + scope: scope.clone(), + events: vec![crate::queue::BatchEvent { + edit: None, + event, + prompt_tag: "@mention".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + } +} + +async fn run_turn(agent: OwnedAgent, ctx: &Arc, batch: FlushBatch) -> OwnedAgent { + let (result_tx, mut result_rx) = tokio::sync::mpsc::unbounded_channel(); + run_prompt_task( + agent, + Some(batch), + None, + ctx.clone(), + result_tx, + None, + "turn".into(), + ) + .await; + let result = result_rx.recv().await.expect("prompt result"); + assert!( + matches!(result.outcome, PromptOutcome::Ok(StopReason::EndTurn)), + "turn did not end normally" + ); + result.agent +} + +#[tokio::test] +async fn max_turns_survives_resume() { + let fixture = Fixture::new(); + let human = Keys::generate(); + let channel_id = Uuid::from_u128(42); + let root = mention(&human, channel_id, None, "first"); + let scope = SessionScope::Thread { + channel_id, + root_event_id: root.id.to_hex(), + }; + let mut ctx = ctx(); + ctx.context_message_limit = 0; + ctx.max_turns_per_session = 2; + let ctx = Arc::new(ctx); + + let agent = fixture.agent(&[("RESUME_CAP", "1")]).await; + let mut agent = run_turn(agent, &ctx, batch(&scope, root.clone())).await; + assert_eq!(fixture.record(&scope).live.unwrap().turns, 1); + // The pool sleeps: sessions are marked departed and the process goes away. + agent.state.depart_all(Departure::IdleSleep); + agent.acp.shutdown().await; + + let woken = fixture.agent(&[("RESUME_CAP", "1")]).await; + let follow_up = mention(&human, channel_id, Some(&root.id.to_hex()), "second"); + let mut agent = run_turn(woken, &ctx, batch(&scope, follow_up)).await; + agent.acp.shutdown().await; + assert!(fixture.methods().contains(&"session/resume".to_owned())); + assert!( + !agent.state.sessions.contains_key(&scope), + "the second successful turn reaches max_turns and rotates" + ); + assert_eq!(fixture.record(&scope).live, None); +} + +#[tokio::test] +async fn idle_worker_transport_death_keeps_session_resumable() { + let fixture = Fixture::new(); + let human = Keys::generate(); + let channel_id = Uuid::from_u128(44); + let root = mention(&human, channel_id, None, "first"); + let scope = SessionScope::Thread { + channel_id, + root_event_id: root.id.to_hex(), + }; + let mut ctx = ctx(); + ctx.context_message_limit = 0; + let ctx = Arc::new(ctx); + let agent = fixture.agent(&[("RESUME_CAP", "1")]).await; + let mut agent = run_turn(agent, &ctx, batch(&scope, root.clone())).await; + let sid = agent.state.sessions[&scope].clone(); + agent.acp.shutdown().await; + let follow = mention(&human, channel_id, Some(&root.id.to_hex()), "after death"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + run_prompt_task( + agent, + Some(batch(&scope, follow.clone())), + None, + ctx.clone(), + tx, + None, + "dead-worker".into(), + ) + .await; + let result = rx.recv().await.unwrap(); + assert!(matches!( + result.outcome, + PromptOutcome::Error(AcpError::Io(_)) | PromptOutcome::AgentExited + )); + let live = fixture + .record(&scope) + .live + .expect("transport death must not tombstone the provider session"); + assert_eq!(live.session_id, sid); + assert_eq!(live.departed, Some(Departure::WorkerCrash)); + drop(result.agent); + let replacement = fixture.agent(&[("RESUME_CAP", "1")]).await; + let mut replacement = run_turn(replacement, &ctx, batch(&scope, follow)).await; + assert_eq!(replacement.state.sessions[&scope], sid); + assert!(fixture.methods().contains(&"session/resume".to_owned())); + replacement.acp.shutdown().await; +} + +#[tokio::test] +async fn resumed_session_gets_only_delta() { + let fixture = Fixture::new(); + let human = Keys::generate(); + let channel_id = Uuid::from_u128(42); + let root = mention(&human, channel_id, None, "original question"); + let root_id = root.id.to_hex(); + let old_reply = mention( + &human, + channel_id, + Some(&root_id), + "OLD-CONTEXT-ALREADY-SEEN", + ); + let new_reply = mention( + &human, + channel_id, + Some(&root_id), + "NEW-CONTEXT-AFTER-SLEEP", + ); + let trigger = mention(&human, channel_id, Some(&root_id), "follow-up mention"); + let scope = SessionScope::Thread { + channel_id, + root_event_id: root_id.clone(), + }; + + let (ctx, server) = relay_context( + channel_id, + vec![ + root.clone(), + old_reply.clone(), + new_reply.clone(), + trigger.clone(), + ], + ) + .await; + let ctx = Arc::new(ctx); + fixture.departed_record(&scope, &ctx, &[&root_id, &old_reply.id.to_hex()]); + + let prompts = fixture.dir.path().join("prompts.ndjson"); + let agent = fixture + .agent(&[("RESUME_CAP", "1"), ("PROMPTS", prompts.to_str().unwrap())]) + .await; + let mut agent = run_turn(agent, &ctx, batch(&scope, trigger)).await; + agent.acp.shutdown().await; + server.abort(); + + assert!(fixture.methods().contains(&"session/resume".to_owned())); + let prompt = std::fs::read_to_string(&prompts).unwrap(); + assert!(prompt.contains("NEW-CONTEXT-AFTER-SLEEP")); + assert!(!prompt.contains("OLD-CONTEXT-ALREADY-SEEN")); +} + +// ---- pool control paths (sleep, !rotate, channel removal) ---- + +async fn idle_worker( + fixture: &Fixture, + index: usize, + scope: &SessionScope, + sid: &str, +) -> OwnedAgent { + let acp = AcpClient::spawn("cat", &[], &[], false).await.unwrap(); + let (mut state, _) = owned_state(fixture, scope, sid); + state.store = Some(fixture.store.clone()); + OwnedAgent { + index, + acp, + state, + model_capabilities: None, + desired_model: None, + model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, + agent_name: "fake-acp".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + background_context: None, + } +} + +fn thread_scope(channel: u128, root: &str) -> SessionScope { + SessionScope::Thread { + channel_id: Uuid::from_u128(channel), + root_event_id: root.repeat(64 / root.len()), + } +} + +fn decision_now(fixture: &Fixture, scope: &SessionScope) -> ResumeDecision { + let me = ResumeContext { + adapter: "fake-acp".into(), + cwd: "/tmp".into(), + model: None, + instance: fixture.store.instance(), + worker_index: 0, + resume_supported: true, + poisoned: fixture.store.is_poisoned(scope), + }; + resume_decision(&fixture.store.load(scope), &me, |_| false) +} + +#[tokio::test] +async fn pool_sleep_marks_every_idle_session() { + let fixture = Fixture::new(); + let (a, b) = (thread_scope(42, "a"), thread_scope(42, "b")); + let mut pool = AgentPool::from_slots(vec![ + Some(idle_worker(&fixture, 0, &a, "s-a").await), + Some(idle_worker(&fixture, 1, &b, "s-b").await), + ]); + pool.set_session_store(Some(fixture.store.clone())); + pool.depart_idle_sessions(Departure::IdleSleep); + for scope in [&a, &b] { + assert_eq!( + fixture.record(scope).live.unwrap().departed, + Some(Departure::IdleSleep) + ); + } +} + +#[test] +fn rotate_before_first_resume_discards_disk_session() { + let fixture = Fixture::new(); + let scope = thread_scope(42, "a"); + fixture.departed_record(&scope, &ctx(), &[]); + // After sleep the pool has only empty slots. + let mut pool = AgentPool::from_slots(vec![None, None]); + pool.set_session_store(Some(fixture.store.clone())); + pool.discard_durable_scope(&scope); + assert_eq!( + new_reason(decision_now(&fixture, &scope)), + SessionReason::Discarded + ); +} + +#[test] +fn rotate_leaves_sibling_thread_resumable() { + let fixture = Fixture::new(); + let (rotated, sibling) = (thread_scope(42, "a"), thread_scope(42, "b")); + fixture.departed_record(&rotated, &ctx(), &[]); + fixture.departed_record(&sibling, &ctx(), &[]); + let mut pool = AgentPool::from_slots(vec![None]); + pool.set_session_store(Some(fixture.store.clone())); + pool.discard_durable_scope(&rotated); + assert_eq!( + resume_reason(decision_now(&fixture, &sibling)), + SessionReason::IdleSleep + ); +} + +#[test] +fn channel_removal_then_readd_starts_new_session() { + let fixture = Fixture::new(); + let (a, b, other) = ( + thread_scope(42, "a"), + thread_scope(42, "b"), + thread_scope(43, "a"), + ); + for scope in [&a, &b, &other] { + fixture.departed_record(scope, &ctx(), &[]); + } + let mut pool = AgentPool::from_slots(vec![None]); + pool.set_session_store(Some(fixture.store.clone())); + pool.discard_durable_channel(Uuid::from_u128(42)); + for scope in [&a, &b] { + assert_eq!( + new_reason(decision_now(&fixture, scope)), + SessionReason::Discarded + ); + } + assert_eq!( + resume_reason(decision_now(&fixture, &other)), + SessionReason::IdleSleep + ); +} + +#[test] +fn late_commit_after_rotate_is_ignored() { + let fixture = Fixture::new(); + let scope = thread_scope(42, "a"); + let (mut state, _) = owned_state(&fixture, &scope, "s-1"); + let mut pool = AgentPool::from_slots(vec![None]); + pool.set_session_store(Some(fixture.store.clone())); + pool.discard_durable_scope(&scope); + state.turn_counts.insert(scope.clone(), 5); + state.persist_turns(&scope); + state.mark_scope_delivery_success(scope.clone(), true, ["late".to_owned()], []); + assert_eq!(fixture.record(&scope).live, None); + assert!(!state.leases.contains_key(&scope)); +} + +/// A prompt context whose relay answers every `/query` with `events`. +async fn relay_context( + channel_id: Uuid, + events: Vec, +) -> (PromptContext, tokio::task::JoinHandle<()>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let body = serde_json::to_string(&events).unwrap(); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut request = vec![0; 16 * 1024]; + let _ = socket.read(&mut request).await; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + let mut ctx = ctx(); + ctx.context_message_limit = 10; + ctx.rest_client.base_url = base_url.clone(); + ctx.channel_info = ChannelInfoResolver::new( + HashMap::from([( + channel_id, + crate::relay::ChannelInfo { + name: "thread".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + RestClient { + http: reqwest::Client::new(), + base_url, + keys: ctx.agent_keys.clone(), + auth_tag_json: None, + }, + ); + (ctx, server) +} + +#[tokio::test] +async fn cold_start_prompt_includes_first_mention() { + let fixture = Fixture::new(); + let human = Keys::generate(); + let channel_id = Uuid::from_u128(42); + let root = mention(&human, channel_id, None, "thread opener"); + let root_id = root.id.to_hex(); + let agent_keys = Keys::generate(); + let agent_hex = agent_keys.public_key().to_hex(); + let mut events = vec![root.clone()]; + for n in 0..30 { + let mut tags = vec![ + Tag::parse(["h", channel_id.to_string().as_str()]).unwrap(), + Tag::parse(["e", root_id.as_str(), "", "reply"]).unwrap(), + ]; + let text = if n == 2 { + tags.push(Tag::parse(["p", agent_hex.as_str()]).unwrap()); + "FIRST-MENTION-TASK".to_owned() + } else { + format!("chatter {n}") + }; + events.push( + EventBuilder::new(Kind::Custom(9), text) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(2_000 + n)) + .sign_with_keys(&human) + .unwrap(), + ); + } + let trigger = events.last().unwrap().clone(); + let (mut ctx, server) = relay_context(channel_id, events).await; + ctx.agent_keys = agent_keys; + ctx.context_message_limit = 12; + let ctx = Arc::new(ctx); + let scope = SessionScope::Thread { + channel_id, + root_event_id: root_id, + }; + let prompts = fixture.dir.path().join("prompts.ndjson"); + let agent = fixture + .agent(&[("PROMPTS", prompts.to_str().unwrap())]) + .await; + let mut agent = run_turn(agent, &ctx, batch(&scope, trigger)).await; + agent.acp.shutdown().await; + server.abort(); + let prompt = std::fs::read_to_string(&prompts).unwrap(); + assert!(prompt.contains("FIRST-MENTION-TASK")); + assert!(!prompt.contains("chatter 5"), "window stays bounded"); +} + +// ---- session marker ---- + +#[tokio::test] +async fn one_reply_window_keeps_first_mention_over_agent_reply() { + let fixture = Fixture::new(); + let human = Keys::generate(); + let agent_keys = Keys::generate(); + let channel_id = Uuid::from_u128(43); + let root = mention(&human, channel_id, None, "thread opener"); + let root_id = root.id.to_hex(); + let reply = |keys: &Keys, at, text, addressed| { + let mut tags = vec![ + Tag::parse(["h", channel_id.to_string().as_str()]).unwrap(), + Tag::parse(["e", root_id.as_str(), "", "reply"]).unwrap(), + ]; + if addressed { + tags.push(Tag::parse(["p", agent_keys.public_key().to_hex().as_str()]).unwrap()); + } + EventBuilder::new(Kind::Custom(9), text) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(at)) + .sign_with_keys(keys) + .unwrap() + }; + let assignment = reply(&human, 2_000, "ORIGINAL-ASSIGNMENT", true); + let old_answer = reply(&agent_keys, 2_001, "PREVIOUS-AGENT-ANSWER", false); + let trigger = reply(&human, 2_002, "continue", true); + let (mut ctx, server) = relay_context( + channel_id, + vec![root, assignment, old_answer, trigger.clone()], + ) + .await; + ctx.agent_keys = agent_keys; + ctx.context_message_limit = 1; + let scope = SessionScope::Thread { + channel_id, + root_event_id: root_id, + }; + let prompts = fixture.dir.path().join("prompts.ndjson"); + let agent = fixture + .agent(&[("PROMPTS", prompts.to_str().unwrap())]) + .await; + let mut agent = run_turn(agent, &Arc::new(ctx), batch(&scope, trigger)).await; + agent.acp.shutdown().await; + server.abort(); + let prompt = std::fs::read_to_string(prompts).unwrap(); + assert!(prompt.contains("ORIGINAL-ASSIGNMENT")); + assert!( + !prompt.contains("PREVIOUS-AGENT-ANSWER"), + "the reply budget remains one" + ); +} + +#[test] +fn marker_for_resumed_session() { + let start = SessionStart::Resumed { + reason: SessionReason::IdleSleep, + }; + assert_eq!( + format_session_marker(&start, None), + r#""# + ); +} + +#[test] +fn marker_for_failed_resume() { + let start = SessionStart::New { + reason: SessionReason::ResumeFailed("not_found".into()), + }; + assert_eq!( + format_session_marker(&start, None), + r#""# + ); +} + +#[test] +fn marker_reports_first_mention_not_found() { + let start = SessionStart::New { + reason: SessionReason::FirstInThread, + }; + assert_eq!( + format_session_marker( + &start, + Some(&super::super::first_mention::FirstMention::NotFound) + ), + r#""# + ); + assert_eq!( + format_session_marker( + &start, + Some(&super::super::first_mention::FirstMention::InRoot) + ), + r#""# + ); +} + +#[test] +fn marker_precedes_thread_context() { + let mut sections = vec![ + "".to_owned(), + "x".to_owned(), + "".to_owned(), + ]; + insert_session_marker(&mut sections, "".to_owned()); + assert_eq!(sections[1], ""); + let mut bare = vec!["".to_owned()]; + insert_session_marker(&mut bare, "".to_owned()); + assert_eq!(bare[0], ""); +} + +#[tokio::test] +async fn marker_present_once_after_start() { + let fixture = Fixture::new(); + let human = Keys::generate(); + let channel_id = Uuid::from_u128(42); + let root = mention(&human, channel_id, None, "first"); + let scope = SessionScope::Thread { + channel_id, + root_event_id: root.id.to_hex(), + }; + let mut ctx = ctx(); + ctx.context_message_limit = 0; + let ctx = Arc::new(ctx); + let prompts = fixture.dir.path().join("prompts.ndjson"); + let agent = fixture + .agent(&[("PROMPTS", prompts.to_str().unwrap())]) + .await; + let agent = run_turn(agent, &ctx, batch(&scope, root.clone())).await; + let follow_up = mention(&human, channel_id, Some(&root.id.to_hex()), "second"); + let mut agent = run_turn(agent, &ctx, batch(&scope, follow_up)).await; + agent.acp.shutdown().await; + let prompts = std::fs::read_to_string(&prompts).unwrap(); + let lines: Vec<&str> = prompts.lines().collect(); + assert_eq!(lines.len(), 2); + assert!(lines[0].contains(r#""#)); + assert!(!lines[1].contains(" {next_url}" + ); + assert_eq!(sid, "new-1"); + } +} + +#[tokio::test] +async fn acquisition_resumes_canonical_same_community_after_restart() { + let mut first = ctx(); + first.relay_url = "WSS://Relay.Example:443/".into(); + let mut next = ctx(); + next.agent_keys = first.agent_keys.clone(); + next.relay_url = "wss://relay.example".into(); + let (sid, methods) = acquire_after_context_change(first, next).await; + assert!(methods.contains(&"session/resume".into())); + assert_eq!(sid, "old-1"); +} + +#[tokio::test] +async fn failed_channel_removal_during_sleep_never_resumes_after_readd() { + use std::os::unix::fs::PermissionsExt; + let fixture = Fixture::new(); + let scope = thread_scope(42, "a"); + let neighbor = thread_scope(43, "a"); + let ctx = ctx(); + for scope in [&scope, &neighbor] { + fixture.departed_record(scope, &ctx, &[]); + } + let mut pool = AgentPool::from_slots(vec![None]); + pool.set_session_store(Some(fixture.store.clone())); + let dir = fixture.store.channel_dir(scope.channel_id()); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o000)).unwrap(); + pool.discard_durable_channel(scope.channel_id()); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + // A subsequent re-add uses the same shared store, with empty worker memory. + let mut agent = fixture.agent(&[("RESUME_CAP", "1")]).await; + let (sid, start) = acquire_session(&mut agent, &ctx, None, channel_context(&scope), &scope) + .await + .unwrap(); + agent.acp.shutdown().await; + assert_eq!(sid, "new-1"); + assert_eq!( + start, + SessionStart::New { + reason: SessionReason::ResumeFailed("not_persisted".into()) + } + ); + assert!(!fixture.methods().contains(&"session/resume".into())); + assert_eq!( + resume_reason(decision_now(&fixture, &neighbor)), + SessionReason::IdleSleep + ); + assert_eq!(fixture.record(&scope).live.unwrap().session_id, "old-1"); +} + +#[path = "session_control_runtime_tests.rs"] +mod control_tests; + +#[path = "session_retirement_tests.rs"] +mod retirement_tests; + +#[path = "compaction_tests.rs"] +mod compaction_tests; + +#[path = "restart_status_runtime_tests.rs"] +mod restart_status_tests; diff --git a/crates/buzz-acp/src/pool/session_control_history_tests.rs b/crates/buzz-acp/src/pool/session_control_history_tests.rs new file mode 100644 index 00000000000..a4a102da087 --- /dev/null +++ b/crates/buzz-acp/src/pool/session_control_history_tests.rs @@ -0,0 +1,30 @@ +use super::*; +#[test] +fn human_target_reserved_syntax_is_filtered_but_not_executed() { + for content in [ + "!restart", + "!rotate", + "!restart none", + "!restart all", + "!compact", + ] { + let event = serde_json::json!({"kind":9,"content":content,"tags":[["p","a".repeat(64)]],"id":"b".repeat(64)}); + assert!( + json_to_context_message(&event).is_none(), + "reserved control leaked: {content}" + ); + } + for content in [ + "!restart foo", + "!restart recent", + "@Name !restart", + "> !restart", + ] { + let event = serde_json::json!({"kind":9,"content":content,"tags":[["p","a".repeat(64)]]}); + assert!(json_to_context_message(&event).is_some()); + } + assert!( + json_to_context_message(&serde_json::json!({"kind":9,"content":"!restart","tags":[]})) + .is_some() + ); +} diff --git a/crates/buzz-acp/src/pool/session_control_runtime_tests.rs b/crates/buzz-acp/src/pool/session_control_runtime_tests.rs new file mode 100644 index 00000000000..eedd41bc0a9 --- /dev/null +++ b/crates/buzz-acp/src/pool/session_control_runtime_tests.rs @@ -0,0 +1,1378 @@ +use super::*; + +// Session-control regressions construct every job through the existing author +// capability; no raw event can call the controller's effect API. +pub(super) async fn authorized_control( + ctx: &PromptContext, + human: &Keys, + scope: &SessionScope, + content: &str, +) -> crate::session_controls::ControlJob { + authorized_control_at(ctx, human, scope, content, nostr::Timestamp::now()).await +} + +async fn authorized_control_at( + ctx: &PromptContext, + human: &Keys, + scope: &SessionScope, + content: &str, + created_at: nostr::Timestamp, +) -> crate::session_controls::ControlJob { + let event = EventBuilder::new(Kind::Custom(9), content) + .custom_created_at(created_at) + .tags([ + Tag::parse(["h", scope.channel_id().to_string().as_str()]).unwrap(), + Tag::parse([ + "e", + scope.root_event_id().unwrap_or(&"a".repeat(64)), + "", + "reply", + ]) + .unwrap(), + Tag::parse(["p", ctx.agent_keys.public_key().to_hex().as_str()]).unwrap(), + ]) + .sign_with_keys(human) + .unwrap(); + let owner = crate::OwnerCache::new(Some(human.public_key().to_hex())); + let mut gate = crate::InboundAuthorGate::connect( + &ctx.rest_client, + &ctx.agent_keys.public_key().to_hex(), + "control test", + ) + .await; + let authorized = crate::authorize_normal_listener_event( + &mut gate, + crate::relay::BuzzEvent { + channel_id: scope.channel_id(), + event, + connection_generation: 0, + }, + &crate::RespondTo::OwnerOnly, + &Default::default(), + &owner, + &ctx.channel_info, + &ctx.rest_client, + ) + .await + .unwrap(); + match crate::session_controls::admit_authorized( + authorized, + ctx, + nostr::Timestamp::now().as_secs(), + ) + .await + { + crate::session_controls::Admission::Control(job) => job, + _ => panic!("authorized control must bypass subscription matching"), + } +} + +#[tokio::test] +async fn restart_during_delayed_acquisition_fences_old_dispatch() { + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let job = authorized_control(&ctx, &human, &scope, "!restart all").await; + let controls = ctx.session_controls.clone(); + let mut agent = fixture.agent(&[("DELAY", "0.3")]).await; + agent.state.control_dispatch = Some(controls.capture(&scope)); + let log = fixture.log(); + let restart = async { + tokio::time::timeout(Duration::from_secs(5), async { + while !std::fs::read_to_string(&log) + .unwrap_or_default() + .contains("session/new") + { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + assert!(controls.restart(job).unwrap()); + }; + let acquisition = acquire_session(&mut agent, &ctx, None, channel_context(&scope), &scope); + let (result, ()) = tokio::join!(acquisition, restart); + assert!( + result.is_err(), + "old acquisition must not retry and claim the new tombstone" + ); + assert_eq!( + fixture + .methods() + .iter() + .filter(|method| *method == "session/new") + .count(), + 1 + ); + let record = fixture.record(&scope); + assert!(record.live.is_none()); + assert_eq!( + record.pending_restart.unwrap().depth, + buzz_core::session_control::RestartDepth::All + ); + agent.acp.shutdown().await; + server.abort(); +} + +#[tokio::test] +async fn second_restart_survives_old_result() { + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let first = authorized_control(&ctx, &human, &scope, "!restart none").await; + assert!(ctx.session_controls.restart(first.clone()).unwrap()); + let old = ctx.session_controls.capture(&scope); + let late_ack = old.clone(); + old.clone().finish(); + assert!( + late_ack.current(), + "ordinary task completion must preserve a still-owned late ACK epoch" + ); + let second = authorized_control(&ctx, &human, &scope, "!restart all").await; + assert!(ctx.session_controls.restart(second).unwrap()); + let latest = ctx.session_controls.restart_intent(&scope).unwrap(); + old.foreground_success(); + assert!(!old.current()); + assert!( + !ctx.session_controls.restart(first).unwrap(), + "duplicate must not reset policy" + ); + assert_eq!( + ctx.session_controls.restart_intent(&scope), + Some(latest.clone()) + ); + let mut agent = fixture.agent(&[]).await; + agent.state.control_dispatch = Some(old); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + crate::pool::send_prompt_result( + &tx, + "old", + agent, + crate::pool::PromptSource::Channel(scope.clone()), + PromptOutcome::Ok(StopReason::EndTurn), + Some(batch( + &scope, + mention( + &human, + scope.channel_id(), + scope.root_event_id(), + "old trigger", + ), + )), + ); + let mut result = rx.recv().await.unwrap(); + assert!(matches!(result.outcome, PromptOutcome::Cancelled)); + assert!( + result.batch.is_none(), + "retired triggering batch cannot requeue into a newer restart" + ); + assert_eq!(ctx.session_controls.restart_intent(&scope), Some(latest)); + result.agent.acp.shutdown().await; + server.abort(); +} + +#[tokio::test] +async fn selected_restart_depths_reach_actual_prompt_and_supplied_ids() { + use buzz_core::session_control::RestartDepth; + for (command, depth, limit, is_dm) in [ + ("!restart none", RestartDepth::None, 0, false), + ("!restart all", RestartDepth::All, 1, false), + ("!restart", RestartDepth::Recent, 2, false), + ("!restart all", RestartDepth::All, 0, true), + ] { + let fixture = Fixture::new(); + let human = Keys::generate(); + let channel = Uuid::from_u128(42); + let root = if is_dm { + EventBuilder::new(Kind::Custom(9), "ROOT-CHATTER") + .tags([Tag::parse(["t", "dm"]).unwrap()]) + .sign_with_keys(&human) + .unwrap() + } else { + mention(&human, channel, None, "ROOT-CHATTER") + }; + let scope = if is_dm { + SessionScope::Conversation { + channel_id: channel, + } + } else { + SessionScope::Thread { + channel_id: channel, + root_event_id: root.id.to_hex(), + } + }; + let (mut ctx, initial_server) = relay_context(channel, vec![]).await; + let target = ctx.agent_keys.public_key().to_hex(); + let assignment = EventBuilder::new(Kind::Custom(9), "REAL-ASSIGNMENT") + .tags([ + Tag::parse(["e", root.id.to_hex().as_str(), "", "reply"]).unwrap(), + Tag::parse(["p", target.as_str()]).unwrap(), + ]) + .custom_created_at(nostr::Timestamp::from(root.created_at.as_secs() + 1)) + .sign_with_keys(&human) + .unwrap(); + let control = EventBuilder::new(Kind::Custom(9), "!restart all") + .tags([ + Tag::parse(["e", root.id.to_hex().as_str(), "", "reply"]).unwrap(), + Tag::parse(["p", target.as_str()]).unwrap(), + ]) + .sign_with_keys(&human) + .unwrap(); + let chatter = mention(&human, channel, scope.root_event_id(), "LATER-CHATTER"); + let malformed = mention(&human, channel, scope.root_event_id(), "!restart foo"); + let huge = mention( + &human, + channel, + scope.root_event_id(), + &"LARGE-OMITTED".repeat(30_000), + ); + let events = vec![ + root.clone(), + assignment.clone(), + control.clone(), + chatter.clone(), + malformed, + huge.clone(), + ]; + let (history_ctx, server) = relay_context(channel, events).await; + initial_server.abort(); + ctx.rest_client = history_ctx.rest_client; + ctx.channel_info = if is_dm { + ChannelInfoResolver::new( + HashMap::from([( + channel, + crate::relay::ChannelInfo { + name: "dm".into(), + channel_type: "dm".into(), + description: None, + }, + )]), + ctx.rest_client.clone(), + ) + } else { + history_ctx.channel_info + }; + ctx.context_message_limit = limit; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let job = authorized_control(&ctx, &human, &scope, command).await; + let origin = job.origin.clone(); + let status = crate::session_status::SessionStatusRuntime::open( + fixture.dir.path(), + buzz_core::session_status::canonical_identity( + "wss://relay.example", + &ctx.agent_keys.public_key().to_hex(), + ) + .unwrap(), + ) + .unwrap(); + ctx.session_status = Some(status.clone()); + assert!(ctx.session_controls.restart(job).unwrap()); + ctx.session_controls.plan_retirement(&scope, []); + let prompts = fixture.dir.path().join("prompts.ndjson"); + let agent = fixture + .agent(&[("PROMPTS", prompts.to_str().unwrap())]) + .await; + let trigger = mention(&human, channel, scope.root_event_id(), "CURRENT-TRIGGER"); + let ctx = Arc::new(ctx); + let mut agent = run_turn(agent, &ctx, batch(&scope, trigger.clone())).await; + let wire = std::fs::read_to_string(&prompts).unwrap(); + let display_scope = crate::pool::session_visibility::status_scope(&scope).unwrap(); + let snapshot = std::fs::read( + status + .dir() + .join(buzz_core::session_status::scope_file_name(&display_scope).unwrap()), + ) + .unwrap(); + let snapshot = buzz_core::session_status::parse_snapshot(&snapshot).unwrap(); + assert_eq!( + snapshot.status.state, + buzz_core::session_status::SessionState::Restarted + ); + assert_eq!(snapshot.origin, Some(origin)); + + assert!(wire.contains("REAL-ASSIGNMENT")); + assert!(wire.contains("CURRENT-TRIGGER")); + assert!(!wire.contains("!restart all")); + let delivered = &agent.state.deliveries[&scope].delivered_event_ids; + assert!(delivered.contains(&assignment.id.to_hex())); + assert!(!delivered.contains(&control.id.to_hex())); + if depth != RestartDepth::Recent { + assert!(!wire.contains("LARGE-OMITTED")); + assert!(!delivered.contains(&huge.id.to_hex())); + } + if depth == RestartDepth::None { + assert!(!wire.contains("ROOT-CHATTER")); + assert!(!wire.contains("LATER-CHATTER")); + assert_eq!( + delivered, + &std::collections::HashSet::from([assignment.id.to_hex(), trigger.id.to_hex()]) + ); + } + if depth == RestartDepth::All { + assert!(wire.contains("!restart foo")); + } + assert!( + ctx.session_controls.restart_intent(&scope).is_none(), + "only actual foreground success consumes the captured token" + ); + agent.acp.shutdown().await; + server.abort(); + } +} + +#[tokio::test] +async fn controls_use_author_capability_before_subscription_and_reserve_wrong_targets() { + use crate::session_controls::{admit_authorized, Admission}; + use crate::RespondTo; + for (policy, is_dm, is_owner, sibling, allow, expected) in [ + (RespondTo::OwnerOnly, false, true, false, false, true), + (RespondTo::OwnerOnly, false, false, true, false, true), + (RespondTo::OwnerOnly, false, false, false, false, false), + (RespondTo::Allowlist, false, false, false, true, true), + (RespondTo::Anyone, false, false, false, false, true), + (RespondTo::Nobody, false, true, false, true, false), + (RespondTo::Anyone, true, false, false, true, false), + (RespondTo::Anyone, true, true, false, false, true), + (RespondTo::Nobody, true, true, false, false, false), + ] { + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let owner_key = if is_owner { + human.public_key().to_hex() + } else { + Keys::generate().public_key().to_hex() + }; + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + if is_dm { + ctx.channel_info = ChannelInfoResolver::new( + HashMap::from([( + scope.channel_id(), + crate::relay::ChannelInfo { + name: "dm".into(), + channel_type: "dm".into(), + description: None, + }, + )]), + ctx.rest_client.clone(), + ); + } + let owner = crate::OwnerCache::new(Some(owner_key.clone())); + owner.cache_sibling(human.public_key().to_hex(), sibling); + let target = ctx.agent_keys.public_key().to_hex(); + let mut gate = + crate::InboundAuthorGate::connect(&ctx.rest_client, &target, "admission test").await; + let event = EventBuilder::new(Kind::Custom(9), "!restart none") + .tags([ + Tag::parse(["p", target.as_str()]).unwrap(), + // Forged workflow author tags cannot bypass the raw-author policy. + Tag::parse(["buzz:workflow", "true"]).unwrap(), + Tag::parse(["buzz:workflow-owner", owner_key.as_str()]).unwrap(), + Tag::parse(["buzz:workflow-mention", target.as_str()]).unwrap(), + ]) + .sign_with_keys(&human) + .unwrap(); + let allowlist = if allow { + std::collections::HashSet::from([human.public_key().to_hex()]) + } else { + Default::default() + }; + let auth = crate::authorize_normal_listener_event( + &mut gate, + crate::relay::BuzzEvent { + event, + channel_id: scope.channel_id(), + connection_generation: 0, + }, + &policy, + &allowlist, + &owner, + &ctx.channel_info, + &ctx.rest_client, + ) + .await; + assert_eq!(auth.is_some(), expected, "policy {policy:?} dm={is_dm}"); + if let Some(auth) = auth { + assert!( + matches!( + admit_authorized(auth, &ctx, nostr::Timestamp::now().as_secs()).await, + Admission::Control(_) + ), + "no subscription rule is required for an authorized control" + ); + } + assert!( + matches!(fixture.store.load(&scope), LoadOutcome::Missing), + "authorization alone causes zero durable mutation" + ); + server.abort(); + } +} + +#[tokio::test] +async fn stale_replay_does_not_overwrite_completed_ack() { + use crate::session_controls::{admit_authorized, Admission}; + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let job = authorized_control(&ctx, &human, &scope, "!restart").await; + let event = job.event.clone(); + ctx.session_controls.restart(job.clone()).unwrap(); + fixture + .store + .finish_control_attempt(&scope, &event.id.to_hex(), &Ok(())) + .unwrap(); + let before = fixture.record(&scope); + let owner = crate::OwnerCache::new(Some(human.public_key().to_hex())); + let mut gate = crate::InboundAuthorGate::connect( + &ctx.rest_client, + &ctx.agent_keys.public_key().to_hex(), + "replay test", + ) + .await; + for now in [ + event.created_at.as_secs() + 901, + event.created_at.as_secs() - 31, + ] { + let auth = crate::authorize_normal_listener_event( + &mut gate, + crate::relay::BuzzEvent { + event: event.clone(), + channel_id: scope.channel_id(), + connection_generation: 0, + }, + &crate::RespondTo::OwnerOnly, + &Default::default(), + &owner, + &ctx.channel_info, + &ctx.rest_client, + ) + .await + .unwrap(); + assert!(matches!( + admit_authorized(auth, &ctx, now).await, + Admission::Ignored + )); + } + let after = fixture.record(&scope); + assert_eq!(before.pending_restart, after.pending_restart); + assert_eq!(before.control_receipts, after.control_receipts); + // Same syntax addressed to a human is reserved but cannot enter broad rules. + let wrong = EventBuilder::new(Kind::Custom(9), "!restart") + .tags([Tag::parse(["p", Keys::generate().public_key().to_hex().as_str()]).unwrap()]) + .sign_with_keys(&human) + .unwrap(); + let auth = crate::authorize_normal_listener_event( + &mut gate, + crate::relay::BuzzEvent { + event: wrong.clone(), + channel_id: scope.channel_id(), + connection_generation: 0, + }, + &crate::RespondTo::OwnerOnly, + &Default::default(), + &owner, + &ctx.channel_info, + &ctx.rest_client, + ) + .await + .unwrap(); + assert!(matches!( + admit_authorized(auth, &ctx, nostr::Timestamp::now().as_secs()).await, + Admission::Ignored + )); + let rules: Vec = serde_json::from_str( + r#"[{"name":"broad","channels":"all","kinds":[],"require_mention":false}]"#, + ) + .unwrap(); + assert!(crate::filter::match_event( + &wrong, + scope.channel_id(), + &rules, + &ctx.agent_keys.public_key().to_hex() + ) + .await + .is_none()); + server.abort(); +} + +#[tokio::test] +async fn busy_one_shot_already_consumed_keeps_restart_gate_and_newer_queue() { + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let old_dispatch = ctx.session_controls.capture(&scope); + let job = authorized_control(&ctx, &human, &scope, "!restart none").await; + ctx.session_controls.restart(job).unwrap(); + let idle = fixture.agent(&[]).await; + let mut pool = AgentPool::from_slots(vec![Some(idle), None]); + let handle = pool.join_set.spawn(std::future::pending::<()>()); + pool.task_map_mut().insert( + handle.id(), + crate::pool::TaskMeta { + agent_index: 1, + channel_id: Some(scope.channel_id()), + scope: Some(scope.clone()), + turn_id: "busy-old".into(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + control_dispatch: Some(old_dispatch), + successful_steer_deliveries: Default::default(), + }, + ); + assert!(!crate::signal_in_flight_task_for_scope( + &mut pool, + &scope, + crate::pool::ControlSignal::Rotate + )); + let newer = mention( + &human, + scope.channel_id(), + scope.root_event_id(), + "ordinary newer event", + ); + let mut queue = crate::EventQueue::new(crate::DedupMode::Queue); + queue.push(crate::QueuedEvent { + scope: scope.clone(), + channel_id: scope.channel_id(), + event: newer.clone(), + received_at: std::time::Instant::now(), + prompt_tag: "test".into(), + edit: None, + }); + let ctx = Arc::new(ctx); + let mut activity = tokio::time::Instant::now(); + assert!(crate::dispatch_pending(&mut pool, &mut queue, &ctx, &mut activity, None).is_empty()); + assert!(ctx.session_controls.retirement_pending(&scope)); + assert!(queue.has_undispatched_work()); + let mut idle = pool.try_claim(None).unwrap(); + idle.acp.shutdown().await; + pool.task_map_mut().clear(); + handle.abort(); + crate::dispatch_pending(&mut pool, &mut queue, &ctx, &mut activity, None); + assert!( + ctx.session_controls.retirement_pending(&scope), + "ordinary task absence cannot attest worker termination" + ); + ctx.session_controls.plan_retirement(&scope, [1]); + ctx.session_controls + .retirement_report(1, crate::acp::TerminationReport::Clean); + assert!(!ctx.session_controls.retirement_pending(&scope)); + let batch = queue.flush_next().unwrap(); + assert_eq!(batch.events[0].event.id, newer.id); + server.abort(); +} + +#[tokio::test] +async fn bootstrap_success_and_failed_foreground_preserve_restart_policy() { + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.initial_message = Some("configured bootstrap".into()); + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let job = authorized_control(&ctx, &human, &scope, "!restart none").await; + ctx.session_controls.restart(job).unwrap(); + let intent = ctx.session_controls.restart_intent(&scope).unwrap(); + let agent = fixture.agent(&[("FAIL_PROMPT", "2")]).await; + let ctx = Arc::new(ctx); + let trigger = mention( + &human, + scope.channel_id(), + scope.root_event_id(), + "foreground task", + ); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + run_prompt_task( + agent, + Some(batch(&scope, trigger.clone())), + None, + ctx.clone(), + tx, + None, + "failed-first".into(), + ) + .await; + let failed = rx.recv().await.unwrap(); + assert!(matches!(failed.outcome, PromptOutcome::Error(_))); + assert_eq!(ctx.session_controls.restart_intent(&scope), Some(intent)); + let mut agent = run_turn(failed.agent, &ctx, batch(&scope, trigger)).await; + assert!(ctx.session_controls.restart_intent(&scope).is_none()); + agent.acp.shutdown().await; + server.abort(); +} + +#[tokio::test] +async fn no_resume_retains_only_control_policy_and_channel_revoke_fences_it() { + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let job = authorized_control(&ctx, &human, &scope, "!restart none").await; + ctx.session_controls.restart(job).unwrap(); + let controller = ctx.session_controls.clone(); + // Pool replacement has no ownership of the controller or its control-only store. + drop(AgentPool::from_slots(vec![None])); + assert!(controller.restart_intent(&scope).is_some()); + let mut agent = fixture.agent(&[]).await; + agent.state.store = None; + let mut agent = run_turn( + agent, + &Arc::new(ctx), + batch( + &scope, + mention( + &human, + scope.channel_id(), + scope.root_event_id(), + "new foreground", + ), + ), + ) + .await; + let record = fixture.record(&scope); + assert!( + record.live.is_none(), + "no provider ID/delivery may enter the control-only store" + ); + assert!(record.pending_restart.is_none()); + assert_eq!(record.control_receipts.len(), 1); + let stale = controller.capture(&scope); + controller.revoke_channel(scope.channel_id()).unwrap(); + assert!(!stale.current()); + assert!(controller.restart_intent(&scope).is_none()); + agent.acp.shutdown().await; + server.abort(); +} + +#[tokio::test] +async fn late_cancelled_result_cannot_requeue_old_batch_after_restart() { + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let old_dispatch = ctx.session_controls.capture(&scope); + let mut queue = crate::EventQueue::new(crate::DedupMode::Queue); + let old = mention( + &human, + scope.channel_id(), + scope.root_event_id(), + "old triggering event", + ); + queue.push(crate::QueuedEvent { + scope: scope.clone(), + channel_id: scope.channel_id(), + event: old, + received_at: std::time::Instant::now(), + prompt_tag: "test".into(), + edit: None, + }); + let old_batch = queue.flush_next().unwrap(); + let job = authorized_control(&ctx, &human, &scope, "!restart none").await; + ctx.session_controls.restart(job).unwrap(); + let newer = mention( + &human, + scope.channel_id(), + scope.root_event_id(), + "newer queued event", + ); + queue.push(crate::QueuedEvent { + scope: scope.clone(), + channel_id: scope.channel_id(), + event: newer.clone(), + received_at: std::time::Instant::now(), + prompt_tag: "test".into(), + edit: None, + }); + let mut agent = fixture.agent(&[]).await; + agent.state.control_dispatch = Some(old_dispatch.clone()); + let mut pool = AgentPool::from_slots(vec![None]); + let id = pool.join_set.spawn(std::future::pending::<()>()).id(); + pool.task_map_mut().insert( + id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: Some(scope.channel_id()), + scope: Some(scope.clone()), + turn_id: "old".into(), + recoverable_batch: Some(old_batch.clone()), + control_tx: None, + steer_tx: None, + control_dispatch: Some(old_dispatch), + successful_steer_deliveries: Default::default(), + }, + ); + let result = crate::pool::PromptResult { + agent, + source: crate::pool::PromptSource::Channel(scope.clone()), + turn_id: "old".into(), + outcome: PromptOutcome::Cancelled, + batch: Some(old_batch), + }; + let (respawn_tx, _) = tokio::sync::mpsc::channel(8); + let mut crash_history = vec![crate::SlotCircuit { + crash_times: vec![], + open_until: None, + respawn_in_flight: false, + }]; + let mut respawns = tokio::task::JoinSet::new(); + let mut heartbeat = false; + let mut activity = tokio::time::Instant::now(); + crate::handle_prompt_result( + &mut pool, + &mut queue, + &crate::error_outcome_emission_tests::test_config(), + result, + &mut heartbeat, + &Default::default(), + &mut crash_history, + &respawn_tx, + &mut respawns, + None, + None, + &mut activity, + ); + let next = queue.flush_next().unwrap(); + assert!( + next.cancelled_events.is_empty(), + "old cancelled trigger must not merge into the new generation" + ); + assert_eq!(next.events.len(), 1); + assert_eq!(next.events[0].event.id, newer.id); + let mut returned = pool.try_claim(None).unwrap(); + returned.acp.shutdown().await; + server.abort(); +} + +#[tokio::test] +async fn unknown_thread_metadata_refuses_control_without_changing_normal_messages() { + use crate::session_controls::{admit_authorized, Admission}; + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.channel_info = ChannelInfoResolver::new(Default::default(), ctx.rest_client.clone()); + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let target = ctx.agent_keys.public_key().to_hex(); + let owner = crate::OwnerCache::new(Some(human.public_key().to_hex())); + let mut gate = + crate::InboundAuthorGate::connect(&ctx.rest_client, &target, "unknown scope test").await; + for content in [ + "!restart none", + "!restart foo", + "!cancel", + "!shutdown", + "ordinary stale message", + ] { + let event = EventBuilder::new(Kind::Custom(9), content) + .tags([Tag::parse(["p", target.as_str()]).unwrap()]) + .sign_with_keys(&human) + .unwrap(); + let auth = crate::authorize_normal_listener_event( + &mut gate, + crate::relay::BuzzEvent { + event, + channel_id: scope.channel_id(), + connection_generation: 0, + }, + &crate::RespondTo::OwnerOnly, + &Default::default(), + &owner, + &ctx.channel_info, + &ctx.rest_client, + ) + .await + .unwrap(); + let admission = admit_authorized( + auth, + &ctx, + nostr::Timestamp::now().as_secs() + + if content == "ordinary stale message" { + 9001 + } else { + 0 + }, + ) + .await; + if content == "!restart none" { + assert!(matches!( + admission, + Admission::Refused { + failure: buzz_core::session_control::ControlFailure::ScopeUnavailable, + .. + } + )); + } else { + assert!(matches!(admission, Admission::Normal(_))); + } + } + assert!(matches!(fixture.store.load(&scope), LoadOutcome::Missing)); + server.abort(); +} + +#[tokio::test] +async fn restart_during_failed_resume_never_calls_fallback_new() { + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let status = crate::session_status::SessionStatusRuntime::open( + fixture.dir.path(), + buzz_core::session_status::canonical_identity( + "wss://relay.example", + &ctx.agent_keys.public_key().to_hex(), + ) + .unwrap(), + ) + .unwrap(); + let display_scope = crate::pool::session_visibility::status_scope(&scope).unwrap(); + let snapshot = status + .dir() + .join(buzz_core::session_status::scope_file_name(&display_scope).unwrap()); + ctx.session_status = Some(status.clone()); + fixture.departed_record(&scope, &ctx, &[]); + let job = authorized_control(&ctx, &human, &scope, "!restart all").await; + let controls = ctx.session_controls.clone(); + let mut agent = fixture + .agent(&[("DELAY", "0.3"), ("RESUME_CAP", "1"), ("RESUME_ERROR", "1")]) + .await; + let old_binding = status + .begin( + display_scope, + "old-1", + buzz_core::session_status::SessionState::Resumed, + buzz_core::session_status::StatusReason::IdleSleep, + ) + .unwrap(); + agent.state.status.insert(scope.clone(), old_binding); + agent.state.control_dispatch = Some(controls.capture(&scope)); + let log = fixture.log(); + let ctx = Arc::new(ctx); + let restart = async { + tokio::time::timeout(Duration::from_secs(5), async { + while !std::fs::read_to_string(&log) + .unwrap_or_default() + .contains("session/resume") + { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + controls.restart(job).unwrap(); + // Same retirement seam used immediately after admission by the listener. + crate::pool::session_visibility::retire_scope(ctx.session_status.as_ref(), &scope); + controls.restart_intent(&scope).unwrap() + }; + let trigger = mention( + &human, + scope.channel_id(), + scope.root_event_id(), + "old task", + ); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let (_, accepted) = tokio::join!( + run_prompt_task( + agent, + Some(batch(&scope, trigger)), + None, + ctx.clone(), + tx, + None, + "failed-resume".into() + ), + restart + ); + let mut result = rx.recv().await.unwrap(); + result.agent.acp.shutdown().await; + server.abort(); + assert!(matches!(result.outcome, PromptOutcome::Cancelled)); + assert!( + !snapshot.exists(), + "late acquisition cannot publish a replacement for the retired status" + ); + let record = fixture.record(&scope); + assert!(record.live.is_none()); + assert_eq!(record.pending_restart, Some(accepted)); + assert!( + !fixture + .methods() + .iter() + .any(|method| method == "session/new"), + "an old resume error cannot initiate a new provider RPC after restart" + ); +} + +// Answer history and profile queries separately so the production profile lookup +// can move an otherwise fitting assignment across the rendered history budget. +async fn profiled_history_context( + channel: Uuid, + events: Vec, + profile: nostr::Event, +) -> (PromptContext, tokio::task::JoinHandle<()>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut request = Vec::new(); + let header_end = loop { + let mut chunk = [0; 8192]; + let n = socket.read(&mut chunk).await.unwrap(); + if n == 0 { + return; + } + request.extend_from_slice(&chunk[..n]); + if let Some(at) = request.windows(4).position(|w| w == b"\r\n\r\n") { + break at + 4; + } + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length: ") + .and_then(|n| n.parse::().ok()) + }) + .unwrap_or(0); + while request.len() < header_end + length { + let mut chunk = [0; 8192]; + let n = socket.read(&mut chunk).await.unwrap(); + if n == 0 { + break; + } + request.extend_from_slice(&chunk[..n]); + } + let filters = serde_json::from_slice::>(&request[header_end..]) + .unwrap_or_default(); + let body = if filters.iter().any(|f| { + f["kinds"] + .as_array() + .is_some_and(|k| k.contains(&serde_json::json!(0))) + }) { + serde_json::to_vec(&vec![profile.clone()]).unwrap() + } else if filters.iter().any(|f| { + f["kinds"] + .as_array() + .is_some_and(|k| k.contains(&serde_json::json!(9))) + }) { + serde_json::to_vec(&events).unwrap() + } else { + b"[]".to_vec() + }; + let header = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); + if socket.write_all(header.as_bytes()).await.is_ok() { + let _ = socket.write_all(&body).await; + } + } + }); + let mut ctx = ctx(); + ctx.rest_client.base_url = base_url; + ctx.channel_info = ChannelInfoResolver::new( + HashMap::from([( + channel, + crate::relay::ChannelInfo { + name: "thread".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + ctx.rest_client.clone(), + ); + (ctx, server) +} + +#[tokio::test] +async fn none_assignment_omitted_after_profile_render_is_explicitly_not_found() { + let fixture = Fixture::new(); + let human = Keys::generate(); + let channel = Uuid::from_u128(42); + let agent_keys = Keys::generate(); + let root = mention(&human, channel, None, "ROOT-CHATTER"); + let root_id = root.id.to_hex(); + let scope = SessionScope::Thread { + channel_id: channel, + root_event_id: root_id.clone(), + }; + let assignment = |content: &str| { + EventBuilder::new(Kind::Custom(9), content) + .tags([ + Tag::parse(["e", root_id.as_str(), "", "reply"]).unwrap(), + Tag::parse(["p", agent_keys.public_key().to_hex().as_str()]).unwrap(), + ]) + .sign_with_keys(&human) + .unwrap() + }; + let stub = assignment("x"); + let context = crate::queue::ConversationContext::Thread { + messages: vec![ + crate::pool::json_to_context_message(&serde_json::to_value(stub).unwrap()).unwrap(), + ], + total: 2, + root_present: false, + truncated: true, + }; + let overhead = crate::queue::format_conversation_context(&context, None).len() - 1; + let body = format!( + "BOUNDARY-ASSIGNMENT{}", + "x".repeat( + crate::pool::session_history::HISTORY_BYTES + - overhead + - 40 + - "BOUNDARY-ASSIGNMENT".len() + ) + ); + let assignment = assignment(&body); + let profile = EventBuilder::new( + Kind::Metadata, + serde_json::json!({"display_name":"n".repeat(64)}).to_string(), + ) + .sign_with_keys(&human) + .unwrap(); + let (mut ctx, server) = + profiled_history_context(channel, vec![root, assignment.clone()], profile).await; + ctx.agent_keys = agent_keys; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.context_message_limit = 0; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let job = authorized_control(&ctx, &human, &scope, "!restart none").await; + assert!(ctx.session_controls.restart(job).unwrap()); + ctx.session_controls.plan_retirement(&scope, []); + let prompts = fixture.dir.path().join("boundary-prompts.ndjson"); + let agent = fixture + .agent(&[("PROMPTS", prompts.to_str().unwrap())]) + .await; + let trigger = mention(&human, channel, Some(&root_id), "CURRENT-TRIGGER"); + let ctx = Arc::new(ctx); + let mut agent = run_turn(agent, &ctx, batch(&scope, trigger.clone())).await; + let wire = std::fs::read_to_string(prompts).unwrap(); + assert!( + !wire.contains("BOUNDARY-ASSIGNMENT"), + "the resolved actor label pushes this assignment over the rendered limit" + ); + assert!(wire.contains("CURRENT-TRIGGER")); + assert!( + wire.contains("first_mention=\\\"not_found\\\""), + "an omitted assignment must be explicit in the actual prompt: {}", + &wire[..wire.len().min(500)] + ); + let supplied = &agent + .state + .deliveries + .get(&scope) + .unwrap() + .delivered_event_ids; + assert!(!supplied.contains(&assignment.id.to_hex())); + assert!(supplied.contains(&trigger.id.to_hex())); + agent.acp.shutdown().await; + server.abort(); +} + +async fn retired_worker_health_is_preserved(mode: &str) { + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.context_message_limit = 0; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let status = crate::session_status::SessionStatusRuntime::open( + fixture.dir.path(), + buzz_core::session_status::canonical_identity( + "wss://relay.example", + &ctx.agent_keys.public_key().to_hex(), + ) + .unwrap(), + ) + .unwrap(); + let display_scope = crate::pool::session_visibility::status_scope(&scope).unwrap(); + let snapshot = status + .dir() + .join(buzz_core::session_status::scope_file_name(&display_scope).unwrap()); + ctx.session_status = Some(status.clone()); + let job = authorized_control(&ctx, &human, &scope, "!restart none").await; + let release = fixture.dir.path().join("exit-now"); + let env = match mode { + "cancel_drain" => vec![("HANG_PROMPT", "1")], + "adapter_death" => vec![("DIE_PROMPT", release.to_str().unwrap())], + "transport_after_return" => vec![("BAD_PROMPT", "1")], + "healthy_acquisition" => vec![("DELAY", "0.3")], + "resume_transport" => vec![ + ("DELAY", "0.3"), + ("RESUME_CAP", "1"), + ("RESUME_PROTOCOL", "1"), + ], + _ => panic!("unknown health scenario"), + }; + if mode == "resume_transport" { + fixture.departed_record(&scope, &ctx, &[]); + } + let mut agent = fixture.agent(&env).await; + let old_pid = agent.acp.process_id().unwrap(); + let dispatch = ctx.session_controls.capture(&scope); + agent.state.control_dispatch = Some(dispatch.clone()); + let trigger = mention( + &human, + scope.channel_id(), + scope.root_event_id(), + "OLD-TRIGGER", + ); + let old_batch = batch(&scope, trigger); + let (control_tx, control_rx) = tokio::sync::oneshot::channel(); + let (result_tx, mut result_rx) = tokio::sync::mpsc::unbounded_channel(); + let ctx = Arc::new(ctx); + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool + .join_set + .spawn(run_prompt_task( + agent, + Some(old_batch.clone()), + None, + ctx.clone(), + result_tx, + Some(control_rx), + "retired-health".into(), + )) + .id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: Some(scope.channel_id()), + scope: Some(scope.clone()), + turn_id: "retired-health".into(), + recoverable_batch: Some(old_batch), + control_tx: Some(control_tx), + steer_tx: None, + control_dispatch: Some(dispatch), + successful_steer_deliveries: Default::default(), + }, + ); + tokio::time::timeout(Duration::from_secs(5), async { + while !fixture.methods().iter().any(|method| { + method + == if mode == "healthy_acquisition" { + "session/new" + } else if mode == "resume_transport" { + "session/resume" + } else { + "session/prompt" + } + }) { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + let mut result = if mode == "transport_after_return" { + Some( + tokio::time::timeout(Duration::from_secs(5), result_rx.recv()) + .await + .unwrap() + .unwrap(), + ) + } else { + None + }; + if !matches!( + mode, + "transport_after_return" | "healthy_acquisition" | "resume_transport" + ) { + assert!( + snapshot.exists(), + "the active old turn must have published status" + ); + } + assert!(ctx.session_controls.restart(job).unwrap()); + crate::pool::session_visibility::retire_scope(Some(&status), &scope); + let intent = ctx.session_controls.restart_intent(&scope).unwrap(); + pool.invalidate_scope_session(&scope); + if mode == "cancel_drain" { + assert!(crate::signal_in_flight_task_for_scope( + &mut pool, + &scope, + crate::pool::ControlSignal::Rotate + )); + } else if mode == "adapter_death" { + std::fs::write(&release, "exit").unwrap(); + } + let result = match result.take() { + Some(result) => result, + None => tokio::time::timeout(Duration::from_secs(8), result_rx.recv()) + .await + .unwrap() + .unwrap(), + }; + assert!( + !snapshot.exists(), + "{mode}: stale completion must not republish retired status" + ); + if mode == "resume_transport" { + assert!( + !fixture + .methods() + .iter() + .any(|method| method == "session/new"), + "retired resume transport failure cannot trigger fallback session/new" + ); + } + let mut queue = crate::queue::EventQueue::new(crate::config::DedupMode::Queue); + let newer = mention( + &human, + scope.channel_id(), + scope.root_event_id(), + "NEWER-TRIGGER", + ); + queue.push(crate::QueuedEvent { + scope: scope.clone(), + channel_id: scope.channel_id(), + event: newer.clone(), + received_at: std::time::Instant::now(), + prompt_tag: "test".into(), + edit: None, + }); + let mut config = crate::error_outcome_emission_tests::test_config(); + config.agent_command = "python3".into(); + config.agent_args = vec!["-c".into(), ADAPTER.into()]; + config.persona_env_vars = vec![("LOG".into(), fixture.log().display().to_string())]; + let (respawn_tx, mut respawn_rx) = tokio::sync::mpsc::channel(8); + let mut crashes = vec![crate::SlotCircuit { + crash_times: vec![], + open_until: None, + respawn_in_flight: false, + }]; + let mut respawns = tokio::task::JoinSet::new(); + crate::handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut false, + &Default::default(), + &mut crashes, + &respawn_tx, + &mut respawns, + None, + None, + &mut tokio::time::Instant::now(), + ); + let returned = pool.try_claim(None); + if mode == "healthy_acquisition" { + assert!( + !crashes[0].respawn_in_flight, + "a benign retired acquisition must not masquerade as transport failure" + ); + let mut returned = returned + .expect("healthy retired acquisition remains usable under the interim Rotate path"); + assert_eq!(returned.acp.process_id(), Some(old_pid)); + returned.acp.shutdown().await; + } else { + assert!( + returned.is_none(), + "{mode}: a retired broken worker must never return to the idle slot" + ); + assert!( + crashes[0].respawn_in_flight, + "{mode}: process-health outcome must schedule replacement" + ); + } + let next = queue.flush_next().unwrap(); + assert!( + next.cancelled_events.is_empty(), + "retired trigger cannot be requeued" + ); + assert_eq!(next.events.len(), 1); + assert_eq!(next.events[0].event.id, newer.id); + assert_eq!(ctx.session_controls.restart_intent(&scope), Some(intent)); + assert!(fixture.record(&scope).live.is_none()); + assert!( + !snapshot.exists(), + "{mode}: main-loop recovery must not resurrect old status" + ); + if mode != "healthy_acquisition" { + let replacement = tokio::time::timeout(Duration::from_secs(8), respawn_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(replacement.index, 0); + let (mut client, _, _) = replacement.result.unwrap(); + assert_ne!( + client.process_id(), + Some(old_pid), + "a new adapter process must actually initialize" + ); + client.shutdown().await; + } + respawns.shutdown().await; + pool.join_set.shutdown().await; + server.abort(); +} + +#[tokio::test] +async fn retired_cancel_drain_expiry_replaces_the_worker_through_result_handling() { + retired_worker_health_is_preserved("cancel_drain").await; +} +#[tokio::test] +async fn retired_adapter_death_replaces_the_worker_through_result_handling() { + retired_worker_health_is_preserved("adapter_death").await; +} +#[tokio::test] +async fn retired_transport_error_replaces_the_worker_through_result_handling() { + retired_worker_health_is_preserved("transport_after_return").await; +} + +#[tokio::test] +async fn retired_successful_acquisition_keeps_worker_health_and_fences_old_state() { + retired_worker_health_is_preserved("healthy_acquisition").await; +} + +#[tokio::test] +async fn retired_resume_transport_preserves_health_without_fallback_new() { + retired_worker_health_is_preserved("resume_transport").await; +} + +#[path = "session_retirement_runtime_tests.rs"] +mod retirement_runtime_tests; + +#[path = "session_retirement_review_tests.rs"] +mod retirement_review_tests; + +#[path = "compaction_runtime_tests.rs"] +mod compaction_runtime_tests; diff --git a/crates/buzz-acp/src/pool/session_history.rs b/crates/buzz-acp/src/pool/session_history.rs new file mode 100644 index 00000000000..3c4901d257a --- /dev/null +++ b/crates/buzz-acp/src/pool/session_history.rs @@ -0,0 +1,496 @@ +//! Selected restart history. Request/decode/retained-render budgets are separate. +use super::{first_mention::FirstMention, json_to_context_message, ContextTarget, PromptContext}; +use crate::queue::{ContextMessage, ConversationContext, PromptProfileLookup}; +use buzz_core::session_control::RestartDepth; +use serde_json::{json, Value}; +use std::collections::{HashSet, VecDeque}; +use tokio::time::{timeout_at, Duration, Instant}; +use uuid::Uuid; + +pub(crate) const HISTORY_BYTES: usize = 256 * 1024; +const RESPONSE_BYTES: usize = 4 * 1024 * 1024; +const PAGE: usize = 500; +const MAX_PAGES: usize = 20; +const MAX_CANDIDATES: usize = 10_000; + +struct Reader<'a> { + ctx: &'a PromptContext, + deadline: Instant, + requests: usize, + candidates: usize, +} +impl Reader<'_> { + async fn query(&mut self, filters: &[Value]) -> Option> { + if self.requests >= MAX_PAGES || self.candidates >= MAX_CANDIDATES { + return None; + } + let requested = filters.iter().fold(0usize, |total, filter| { + total.saturating_add( + filter["limit"] + .as_u64() + .and_then(|limit| usize::try_from(limit).ok()) + .unwrap_or(MAX_CANDIDATES), + ) + }); + if requested > MAX_CANDIDATES.saturating_sub(self.candidates) { + return None; + } + self.requests += 1; + let response = timeout_at( + self.deadline, + self.ctx + .rest_client + .query_raw_bounded(filters, RESPONSE_BYTES), + ) + .await + .ok()? + .ok()?; + let Value::Array(mut events) = response else { + return None; + }; + // A relay ignoring its requested limit cannot bypass the raw-candidate bound. + let room = MAX_CANDIDATES - self.candidates; + if events.len() > PAGE + 1 || events.len() > room { + return None; + } + self.candidates += events.len(); + events.shrink_to_fit(); + Some(events) + } +} + +fn key(event: &Value) -> Option<(u64, &str)> { + Some((event["created_at"].as_u64()?, event["id"].as_str()?)) +} +fn mentions(event: &Value, agent: &str) -> bool { + event["tags"] + .as_array() + .is_some_and(|tags| tags.iter().any(|tag| tag[0] == "p" && tag[1] == agent)) +} +fn root_message_id_missing(seen: &HashSet, root: Option<&str>, present: bool) -> bool { + present && root.is_some_and(|id| !seen.contains(id)) +} +fn cost(message: &ContextMessage) -> usize { + // Charge the message body once, rather than repeating the section wrapper + // per message. Five bytes cover a separator and index growth through 10000. + let singleton = ConversationContext::Dm { + messages: vec![message.clone()], + total: MAX_CANDIDATES, + truncated: true, + }; + let empty = ConversationContext::Dm { + messages: Vec::new(), + total: MAX_CANDIDATES, + truncated: true, + }; + let rendered = crate::queue::format_conversation_context(&singleton, None); + let empty = crate::queue::format_conversation_context(&empty, None); + // The included attribute grows from 0 to 1, so both wrappers have equal size. + rendered.len().saturating_sub(empty.len()) + 5 +} +// Working retention can overestimate each message by at most five bytes. Keep +// that bounded slack so every history fitting the actual renderer survives the +// scan; final rendering still enforces the strict 256 KiB limit. +const RETAINED_HEAD_BYTES: usize = HISTORY_BYTES + MAX_CANDIDATES * 5; +fn retain_tail(tail: &mut VecDeque, message: ContextMessage, bytes: &mut usize) { + let size = cost(&message); + if size > HISTORY_BYTES / 2 { + return; + } + while *bytes + size > HISTORY_BYTES / 2 { + if let Some(old) = tail.pop_front() { + *bytes -= cost(&old); + } else { + break; + } + } + *bytes += size; + tail.push_back(message); +} + +/// Selected depths follow the current target thread even for a top-level +/// trigger under channel policy; DMs always use the whole conversation. +pub(super) fn target(batch: &super::FlushBatch, is_dm: bool) -> ContextTarget { + if is_dm { + return ContextTarget::Dm; + } + match super::resolve_context_target(batch, false) { + ContextTarget::None => batch.events.last().map_or(ContextTarget::None, |event| { + ContextTarget::Thread(event.reply_thread()) + }), + target => target, + } +} + +/// None still searches for the original assignment when context_message_limit=0. +/// All walks only the target thread, or the DM conversation, never the channel. +pub(super) async fn selected( + target: &ContextTarget, + channel: Uuid, + depth: RestartDepth, + ctx: &PromptContext, +) -> (Option, Option) { + let (context, mention, _) = + selected_with_language(target, channel, depth, ctx, &HashSet::new()).await; + (context, mention) +} + +pub(super) async fn selected_with_language( + target: &ContextTarget, + channel: Uuid, + depth: RestartDepth, + ctx: &PromptContext, + humans: &HashSet, +) -> ( + Option, + Option, + &'static str, +) { + let mut language = super::status_language::Evidence::default(); + if *target == ContextTarget::None { + return (None, Some(FirstMention::NotFound), "unknown"); + } + let mut reader = Reader { + ctx, + deadline: Instant::now() + Duration::from_secs(10), + requests: 0, + candidates: 0, + }; + let agent = ctx.agent_keys.public_key().to_hex(); + let root = match target { + ContextTarget::Thread(root) => Some(root.as_str()), + _ => None, + }; + let mut base = json!({"kinds":[9,40002],"#h":[channel.to_string()],"limit":PAGE}); + if let Some(root) = root { + base["#e"] = json!([root]); + } + let mut head = Vec::new(); + let mut tail = VecDeque::new(); + let mut head_bytes = 0; + let mut tail_bytes = 0; + let mut root_message = None; + let mut assignment: Option<(Value, ContextMessage)> = None; + let mut seen = HashSet::new(); + let mut incomplete = false; + let mut complete = false; + // Reserve the newest tail before the forward scan can exhaust the deadline. + // Root lookup shares this request and all global budgets. + let mut initial = vec![base.clone()]; + if let Some(root) = root { + initial.push(json!({"kinds":[9,40002],"#h":[channel.to_string()],"ids":[root],"limit":1})); + } + let initial = reader.query(&initial).await; + if let Some(events) = &initial { + let mut events = events.iter().collect::>(); + events.sort_by(|a, b| key(a).cmp(&key(b))); + for event in events { + language.observe(event, humans, channel, target); + let Some(message) = json_to_context_message(event) else { + continue; + }; + if Some(message.event_id.as_str()) == root { + if mentions(event, &agent) { + assignment = Some((event.clone(), message.clone())); + } + root_message = Some(message.clone()); + } + if depth == RestartDepth::All { + retain_tail(&mut tail, message, &mut tail_bytes); + } + } + } else { + incomplete = true; + } + // Threads have a forward composite cursor. DMs use the ordinary descending + // composite cursor; only exhaustion proves the earliest assignment there. + let mut cursor: Option<(u64, String)> = None; + for page in 0..MAX_PAGES { + let mut filter = base.clone(); + if root.is_some() { + filter["depth_limit"] = json!(1_000_000); + if let Some((at, id)) = &cursor { + filter["thread_cursor"] = json!(at); + filter["thread_cursor_id"] = json!(id); + } + } else if let Some((at, id)) = &cursor { + filter["until"] = json!(at); + filter["before_id"] = json!(id); + } + let events = if page == 0 && root.is_none() { + initial.clone() + } else { + reader.query(&[filter]).await + }; + let Some(mut events) = events else { + incomplete = true; + break; + }; + if root.is_none() { + // Ordinary relay pages use time DESC, id ASC. Reverse only time: + // equal-second continuation requires id > before_id. + events.sort_by(|a, b| { + b["created_at"] + .as_u64() + .cmp(&a["created_at"].as_u64()) + .then_with(|| a["id"].as_str().cmp(&b["id"].as_str())) + }); + } else { + events.sort_by(|a, b| key(a).cmp(&key(b))); + } + let next = events + .last() + .and_then(key) + .map(|(at, id)| (at, id.to_owned())); + let short = events.len() < PAGE; + for event in events { + language.observe(&event, humans, channel, target); + let Some(message) = json_to_context_message(&event) else { + continue; + }; + if message.event_id.is_empty() || !seen.insert(message.event_id.clone()) { + continue; + } + // DM pagination order differs from chronological assignment order. + // Keep the minimum time/id independently, including ties on a page. + let replaces_assignment = assignment + .as_ref() + .is_none_or(|(prior, _)| root.is_none() && key(&event) < key(prior)); + if mentions(&event, &agent) && replaces_assignment { + assignment = Some((event.clone(), message.clone())); + } + if depth == RestartDepth::All { + let size = cost(&message); + if root.is_some() { + if head_bytes + size <= RETAINED_HEAD_BYTES { + head_bytes += size; + head.push(message); + } else { + incomplete = true; + } + } else { + // Descending DM pages: keep the newest prefix and roll the + // other half toward the oldest messages without accumulation. + if head_bytes + size <= RETAINED_HEAD_BYTES { + head_bytes += size; + head.push(message.clone()); + } + retain_tail(&mut tail, message, &mut tail_bytes); + } + } + } + if depth == RestartDepth::None && root.is_some() && assignment.is_some() { + complete = true; + break; + } + if short { + complete = true; + break; + } + if next.is_none() || next == cursor { + incomplete = true; + break; + } + cursor = next; + } + if !complete { + incomplete = true; + } + if root.is_none() && !complete { + assignment = None; + } + if assignment + .as_ref() + .is_some_and(|(_, message)| cost(message) >= HISTORY_BYTES) + { + assignment = None; + incomplete = true; + complete = false; + } + let mention = assignment + .as_ref() + .map(|(event, message)| { + if Some(message.event_id.as_str()) == root { + FirstMention::InRoot + } else { + FirstMention::Found(event.clone()) + } + }) + .unwrap_or(if complete && depth != RestartDepth::None { + FirstMention::NotMentioned + } else { + FirstMention::NotFound + }); + let root_observed = root_message.is_some(); + let mut messages = Vec::new(); + // Assignment wins over history when an individual body is too large. + if let Some((_, assignment)) = assignment { + if cost(&assignment) < HISTORY_BYTES { + messages.push(assignment); + } else { + incomplete = true; + } + } + if depth == RestartDepth::All { + if let Some(root) = root_message { + if cost(&root) < HISTORY_BYTES { + messages.push(root); + } else { + incomplete = true; + } + } + messages.extend(head); + messages.extend(tail); + } + let mut retained = HashSet::new(); + messages.retain(|message| retained.insert(message.event_id.clone())); + // Timestamp RFC3339 strings sort chronologically for relay integer dates. + messages.sort_by(|a, b| (&a.timestamp, &a.event_id).cmp(&(&b.timestamp, &b.event_id))); + if let Some(position) = + root.and_then(|root| messages.iter().position(|message| message.event_id == root)) + { + let root = messages.remove(position); + messages.insert(0, root); + } + let root_present = + root.is_some_and(|root| messages.iter().any(|message| message.event_id == root)); + let total = (seen.len() + usize::from(root_message_id_missing(&seen, root, root_observed))) + .max(messages.len()); + let truncated = incomplete || total > messages.len(); + let context = if root.is_some() { + ConversationContext::Thread { + messages, + total, + root_present, + truncated, + } + } else { + ConversationContext::Dm { + messages, + total, + truncated, + } + }; + let mut pins = HashSet::new(); + match &mention { + FirstMention::Found(event) => { + if let Some(id) = event["id"].as_str() { + pins.insert(id.to_owned()); + } + } + FirstMention::InRoot => { + if let Some(root) = root { + pins.insert(root.to_owned()); + } + } + _ => {} + } + if let Some(root) = root { + pins.insert(root.to_owned()); + } + let mut context = Some(context); + enforce_render_budget(&mut context, None, &pins); + (context, Some(mention), language.language()) +} + +/// Reconcile assignment discovery with the bodies actually supplied after +/// profile expansion and delta filtering. An assignment in the trigger batch +/// remains available even when removed from the history section. +pub(super) fn supplied_mention( + mention: Option, + target: &ContextTarget, + context: Option<&ConversationContext>, + batch_ids: &HashSet, +) -> Option { + let id = match mention.as_ref() { + Some(FirstMention::Found(event)) => event["id"].as_str(), + Some(FirstMention::InRoot) => match target { + ContextTarget::Thread(root) => Some(root.as_str()), + _ => None, + }, + _ => return mention, + }; + let supplied = id.is_some_and(|id| { + batch_ids.contains(id) + || context.is_some_and(|context| { + let messages = match context { + ConversationContext::Thread { messages, .. } + | ConversationContext::Dm { messages, .. } => messages, + }; + messages.iter().any(|message| message.event_id == id) + }) + }); + if supplied { + mention + } else { + Some(FirstMention::NotFound) + } +} + +/// Apply the final rendered UTF-8 budget after actor profiles and delta filtering. +pub(super) fn enforce_render_budget( + context: &mut Option, + profiles: Option<&PromptProfileLookup>, + pins: &HashSet, +) { + let Some(context) = context else { + return; + }; + let root_id = match context { + ConversationContext::Thread { + root_present: true, + messages, + .. + } => messages.first().map(|message| message.event_id.clone()), + _ => None, + }; + while crate::queue::format_conversation_context(context, profiles).len() > HISTORY_BYTES { + let messages = match context { + ConversationContext::Thread { + messages, + truncated, + .. + } + | ConversationContext::Dm { + messages, + truncated, + .. + } => { + *truncated = true; + messages + } + }; + if messages.is_empty() { + break; + } + // Preserve bounded head+tail; never truncate a body and count it supplied. + let victim = (0..messages.len()).min_by_key(|index| { + ( + if root_id.as_deref() == Some(messages[*index].event_id.as_str()) { + 1 + } else if pins.contains(&messages[*index].event_id) { + 2 + } else { + 0 + }, + index.abs_diff(messages.len() / 2), + ) + }); + if let Some(victim) = victim { + messages.remove(victim); + } + } + if let ConversationContext::Thread { + root_present, + messages, + .. + } = context + { + *root_present = + root_id.is_some_and(|id| messages.iter().any(|message| message.event_id == id)); + } +} + +#[cfg(test)] +#[path = "session_history_tests.rs"] +mod tests; diff --git a/crates/buzz-acp/src/pool/session_history_tests.rs b/crates/buzz-acp/src/pool/session_history_tests.rs new file mode 100644 index 00000000000..c9f57067d86 --- /dev/null +++ b/crates/buzz-acp/src/pool/session_history_tests.rs @@ -0,0 +1,464 @@ +use super::*; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +async fn scripted( + events: Vec, +) -> ( + PromptContext, + tokio::task::JoinHandle<()>, + std::sync::Arc>>, +) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut ctx = super::super::tests::make_prompt_context_no_owner(); + ctx.rest_client.base_url = format!("http://{}", listener.local_addr().unwrap()); + let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let log = requests.clone(); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut request = Vec::new(); + let header_end = loop { + let mut chunk = [0; 8192]; + let n = socket.read(&mut chunk).await.unwrap(); + if n == 0 { + return; + } + request.extend_from_slice(&chunk[..n]); + if let Some(at) = request.windows(4).position(|w| w == b"\r\n\r\n") { + break at + 4; + } + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length: ") + .and_then(|n| n.parse::().ok()) + }) + .unwrap_or(0); + while request.len() < header_end + length { + let mut chunk = [0; 8192]; + let n = socket.read(&mut chunk).await.unwrap(); + if n == 0 { + break; + } + request.extend_from_slice(&chunk[..n]); + } + let filters: Vec = serde_json::from_slice(&request[header_end..]).unwrap(); + let mut response = Vec::new(); + for filter in filters { + log.lock().unwrap().push(filter.clone()); + let mut page = events + .iter() + .filter(|event| { + if let Some(ids) = filter["ids"].as_array() { + return ids.contains(&event["id"]); + } + if filter["#e"].is_array() && event["id"] == filter["#e"][0] { + return false; + } + if let Some(id) = filter["thread_cursor_id"].as_str() { + return event["id"].as_str().unwrap() > id; + } + if let Some(until) = filter["until"].as_u64() { + let at = event["created_at"].as_u64().unwrap(); + return at < until + || (at == until + && filter["before_id"] + .as_str() + .is_none_or(|id| event["id"].as_str().unwrap() > id)); + } + true + }) + .cloned() + .collect::>(); + page.sort_by(|a, b| key(a).cmp(&key(b))); + if filter["#e"].is_null() { + // Ordinary relay contract: time DESC, id ASC, including ties. + page.sort_by(|a, b| { + b["created_at"] + .as_u64() + .cmp(&a["created_at"].as_u64()) + .then_with(|| a["id"].as_str().cmp(&b["id"].as_str())) + }); + } else if filter["depth_limit"].is_null() && filter["ids"].is_null() { + page.reverse(); + } + page.truncate(filter["limit"].as_u64().unwrap() as usize); + response.extend(page); + } + let body = serde_json::to_vec(&response).unwrap(); + // No Content-Length. EOF framing exercises the bounded body reader. + if socket + .write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n") + .await + .is_ok() + { + let _ = socket.write_all(&body).await; + } + } + }); + (ctx, server, requests) +} + +#[tokio::test] +async fn all_history_same_second_capped_head_tail_and_huge_body_are_truthful() { + let agent = nostr::Keys::generate(); + let root = format!("{:064x}", 1); + let mut events = Vec::new(); + for n in 1..=1200 { + events.push(json!({"kind":9,"id":format!("{n:064x}"),"pubkey":"a".repeat(64),"created_at":1000,"content":format!("event-{n}-{}","x".repeat(if n == 500 {300_000} else {400})),"tags":if n == 2 {json!([["p",agent.public_key().to_hex()]])} else {json!([])}})); + } + let (mut ctx, server, requests) = scripted(events).await; + ctx.agent_keys = agent; + let (context, mention) = selected( + &ContextTarget::Thread(root.clone()), + Uuid::from_u128(42), + RestartDepth::All, + &ctx, + ) + .await; + assert!(matches!(mention, Some(FirstMention::Found(_)))); + let context = context.unwrap(); + let rendered = crate::queue::format_conversation_context(&context, None); + assert!(rendered.len() <= HISTORY_BYTES); + assert!(rendered.contains("event-1-")); + assert!(rendered.contains("event-2-")); + assert!(rendered.contains("event-1200-")); + assert!(!rendered.contains("event-500-")); + match context { + ConversationContext::Thread { + messages, + total, + truncated, + .. + } => { + assert!(truncated); + assert_eq!(total, 1200); + assert!(messages.len() < total); + assert_eq!( + messages.len(), + messages + .iter() + .map(|m| &m.event_id) + .collect::>() + .len() + ); + } + _ => panic!("thread"), + } + let requests = requests.lock().unwrap(); + assert!(requests + .iter() + .any(|f| f["thread_cursor_id"].as_str().is_some())); + assert!(!requests.iter().any(|f| f["thread_cursor_id"] == "")); + server.abort(); +} + +#[tokio::test] +async fn bounded_history_reader_rejects_chunked_actual_bytes_and_times_out() { + for chunked in [true, false] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut ctx = super::super::tests::make_prompt_context_no_owner(); + ctx.rest_client.base_url = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0; 8192]; + assert!(socket.read(&mut request).await.unwrap() > 0); + if chunked { + socket.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n").await.unwrap(); + let chunk = "x".repeat(1024 * 1024); + for _ in 0..5 { + if socket + .write_all(format!("{:x}\r\n{}\r\n", chunk.len(), chunk).as_bytes()) + .await + .is_err() + { + break; + } + } + let _ = socket.write_all(b"0\r\n\r\n").await; + } else { + tokio::time::sleep(Duration::from_secs(5)).await; + } + }); + let start = Instant::now(); + let error = ctx + .rest_client + .query_raw_bounded(&[json!({"kinds":[9]})], RESPONSE_BYTES) + .await + .unwrap_err(); + if chunked { + assert!(error.to_string().contains("byte budget")); + } else { + assert!(error.to_string().contains("deadline")); + } + assert!(start.elapsed() < Duration::from_secs(4)); + server.abort(); + } +} + +#[tokio::test] +async fn all_fits_and_none_missing_assignment_never_fall_back_to_recent() { + let root = format!("{:064x}", 0xffff); + let events = (1..=8).map(|n| json!({"kind":9,"id":if n == 1 {root.clone()} else {format!("{n:064x}")},"pubkey":"a".repeat(64),"created_at":1000,"content":format!("message {n}"),"tags":[]})).collect(); + let (ctx, server, _) = scripted(events).await; + let (all, _) = selected( + &ContextTarget::Thread(root.clone()), + Uuid::from_u128(42), + RestartDepth::All, + &ctx, + ) + .await; + match all.unwrap() { + ConversationContext::Thread { + messages, + total, + truncated, + root_present, + } => { + assert_eq!( + messages[0].event_id, root, + "canonical root must precede same-second lower IDs" + ); + assert_eq!(messages.len(), 8); + assert_eq!(total, 8); + assert!(!truncated); + assert!(root_present); + } + _ => panic!("thread"), + } + let (none, mention) = selected( + &ContextTarget::Thread(root), + Uuid::from_u128(42), + RestartDepth::None, + &ctx, + ) + .await; + assert!(matches!(mention, Some(FirstMention::NotFound))); + match none.unwrap() { + ConversationContext::Thread { messages, .. } => assert!(messages.is_empty()), + _ => panic!("thread"), + } + server.abort(); +} + +#[test] +fn rendered_budget_preserves_assignment_when_root_and_assignment_cannot_both_fit() { + let root = "a".repeat(64); + let assignment = "b".repeat(64); + let mut context = Some(ConversationContext::Thread { + messages: vec![ + ContextMessage { + event_id: root.clone(), + pubkey: "human".into(), + timestamp: "now".into(), + content: "root".repeat(50_000), + }, + ContextMessage { + event_id: assignment.clone(), + pubkey: "human".into(), + timestamp: "now".into(), + content: "assignment".repeat(10_000), + }, + ], + total: 2, + root_present: true, + truncated: false, + }); + enforce_render_budget( + &mut context, + None, + &HashSet::from([root, assignment.clone()]), + ); + match context.unwrap() { + ConversationContext::Thread { + messages, + root_present, + truncated, + .. + } => { + assert!(truncated); + assert!(!root_present); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].event_id, assignment); + } + _ => panic!("thread"), + } +} + +#[tokio::test] +async fn all_history_request_budget_reserves_tail_without_overfetching_candidates() { + let root = format!("{:064x}", 1); + let events = (1..=11_000).map(|n| json!({"kind":9,"id":format!("{n:064x}"),"pubkey":"a".repeat(64),"created_at":1000,"content":format!("bounded message {n}"),"tags":[]})).collect(); + let (ctx, server, requests) = scripted(events).await; + let (context, mention) = selected( + &ContextTarget::Thread(root), + Uuid::from_u128(42), + RestartDepth::All, + &ctx, + ) + .await; + assert!(matches!(mention, Some(FirstMention::NotFound))); + let context = context.unwrap(); + assert!(crate::queue::format_conversation_context(&context, None).len() <= HISTORY_BYTES); + match context { + ConversationContext::Thread { + messages, + total, + truncated, + .. + } => { + assert!(truncated); + assert!(total < 11_000); + assert!( + messages + .iter() + .any(|m| m.event_id == format!("{:064x}", 11_000)), + "latest tail is reserved before the scan reaches its cap" + ); + } + _ => panic!("thread"), + } + let filters = requests.lock().unwrap(); + assert!( + filters.len() <= MAX_PAGES + 1, + "root filter shares a request" + ); + let requested = filters + .iter() + .map(|f| f["limit"].as_u64().unwrap() as usize) + .sum::(); + assert!( + requested <= MAX_CANDIDATES, + "even requested raw candidates must stay inside the shared budget: {requested}" + ); + server.abort(); +} + +#[tokio::test] +async fn all_history_many_small_messages_fit_the_actual_rendered_budget() { + let root = format!("{:064x}", 1); + let events = (1..=2000).map(|n| json!({"kind":9,"id":format!("{n:064x}"),"pubkey":"a".repeat(64),"created_at":1000,"content":"small","tags":[]})).collect::>(); + let expected = ConversationContext::Thread { + messages: events + .iter() + .map(|event| json_to_context_message(event).unwrap()) + .collect(), + total: events.len(), + root_present: true, + truncated: false, + }; + assert!(crate::queue::format_conversation_context(&expected, None).len() < HISTORY_BYTES); + let (ctx, server, _) = scripted(events).await; + let (actual, _) = selected( + &ContextTarget::Thread(root), + Uuid::from_u128(42), + RestartDepth::All, + &ctx, + ) + .await; + assert_eq!( + crate::queue::format_conversation_context(&actual.unwrap(), None), + crate::queue::format_conversation_context(&expected, None), + "a fitting rendered history must retain every message" + ); + server.abort(); +} + +#[test] +fn supplied_assignment_in_trigger_survives_history_delta_filtering() { + let root = "a".repeat(64); + let target = ContextTarget::Thread(root.clone()); + let context = ConversationContext::Thread { + messages: vec![], + total: 1, + root_present: false, + truncated: false, + }; + assert!(crate::queue::format_conversation_context(&context, None).len() < HISTORY_BYTES); + let batch = HashSet::from([root.clone()]); + for mention in [ + FirstMention::InRoot, + FirstMention::Found(json!({"id":root})), + ] { + assert!( + !matches!( + supplied_mention(Some(mention), &target, Some(&context), &batch), + Some(FirstMention::NotFound) + ), + "the trigger still supplies its assignment body" + ); + } + assert!(matches!( + supplied_mention( + Some(FirstMention::InRoot), + &target, + Some(&context), + &HashSet::new() + ), + Some(FirstMention::NotFound) + )); +} + +async fn dm_same_second_depth(depth: RestartDepth) { + let agent = nostr::Keys::generate(); + let mut events = (1..=800).map(|n| json!({"kind":9,"id":format!("{n:064x}"),"pubkey":"a".repeat(64),"created_at":2000,"content":format!("same-second-{n}"),"tags":[]})).collect::>(); + for n in [900, 901] { + events.push(json!({"kind":9,"id":format!("{n:064x}"),"pubkey":"a".repeat(64),"created_at":1000,"content":format!("older-assignment-{n}"),"tags":[["p",agent.public_key().to_hex()]]})); + } + let mut chronological = events.clone(); + chronological.sort_by(|a, b| key(a).cmp(&key(b))); + let expected = ConversationContext::Dm { + messages: chronological + .iter() + .map(|event| json_to_context_message(event).unwrap()) + .collect(), + total: 802, + truncated: false, + }; + assert!(crate::queue::format_conversation_context(&expected, None).len() < HISTORY_BYTES); + let (mut ctx, server, requests) = scripted(events).await; + ctx.agent_keys = agent; + ctx.context_message_limit = 0; + let (actual, mention) = selected(&ContextTarget::Dm, Uuid::from_u128(42), depth, &ctx).await; + assert!( + matches!(mention, Some(FirstMention::Found(ref event)) if event["id"] == format!("{:064x}",900)), + "the chronologically earliest assignment must be selected independently of page tie order" + ); + let actual = actual.unwrap(); + if depth == RestartDepth::All { + assert_eq!( + crate::queue::format_conversation_context(&actual, None), + crate::queue::format_conversation_context(&expected, None) + ); + } else { + match actual { + ConversationContext::Dm { + messages, total, .. + } => { + assert_eq!(total, 802); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].event_id, format!("{:064x}", 900)); + } + _ => panic!("DM"), + } + } + let requests = requests.lock().unwrap(); + assert_eq!( + requests.len(), + 2, + "a fitting complete DM requires exactly two ordinary pages" + ); + assert_eq!(requests[1]["before_id"], format!("{:064x}", 500)); + assert_eq!(requests[1]["until"], 2000); + server.abort(); +} +#[tokio::test] +async fn dm_same_second_all_fits_with_older_assignment_under_real_relay_cursor() { + dm_same_second_depth(RestartDepth::All).await; +} +#[tokio::test] +async fn dm_same_second_none_finds_earliest_assignment_under_real_relay_cursor() { + dm_same_second_depth(RestartDepth::None).await; +} diff --git a/crates/buzz-acp/src/pool/session_retirement.rs b/crates/buzz-acp/src/pool/session_retirement.rs new file mode 100644 index 00000000000..b9c16c53b08 --- /dev/null +++ b/crates/buzz-acp/src/pool/session_retirement.rs @@ -0,0 +1,127 @@ +//! All-copy targeting and the single deliberate-retirement return boundary. +use super::*; + +impl AgentPool { + /// Every worker that may hold this scope, including checked-out and held work. + pub(crate) fn retirement_targets(&self, scope: &SessionScope) -> Vec { + let mut targets = self.scope_workers.get(scope).cloned().unwrap_or_default(); + targets.extend( + self.runtime_holds + .iter() + .filter(|(_, hold)| &hold.scope == scope) + .map(|(index, _)| *index), + ); + targets.extend( + self.task_map + .values() + .filter(|task| task.scope.as_ref() == Some(scope)) + .map(|task| task.agent_index), + ); + targets.extend( + self.agents + .iter() + .flatten() + .filter(|agent| agent.state.sessions.contains_key(scope)) + .map(|agent| agent.index), + ); + let mut targets: Vec<_> = targets.into_iter().collect(); + targets.sort_unstable(); + targets + } + + pub(crate) fn retirement_waits_for_foreign(&self, index: usize, scope: &SessionScope) -> bool { + self.runtime_holds + .get(&index) + .is_some_and(|hold| &hold.scope != scope) + || self + .task_map + .values() + .any(|task| task.agent_index == index && task.scope.as_ref() != Some(scope)) + } + + /// Reserve a slot immediately; foreign foreground work still owns its turn. + pub(crate) fn mark_retire_on_return(&mut self, index: usize) { + self.retire_on_return.insert(index); + } + + /// This boundary precedes outcome-specific return/crash handling. + pub(crate) fn return_for_retirement(&mut self, mut agent: OwnedAgent) { + self.refresh_runtime_hold(&mut agent); + let index = agent.index; + self.agents[index] = Some(agent); + } + + pub(crate) fn needs_retirement(&self, agent: &OwnedAgent) -> bool { + self.retire_on_return.contains(&agent.index) + } + + /// Foreign background holds precede the idle-slot branch. A deadline may + /// expire a background hold, but can never take a checked-out foreground turn. + pub(crate) fn take_retirement_target( + &mut self, + index: usize, + scopes: &HashSet, + background_expired: bool, + ) -> Option { + if self.task_map.values().any(|task| task.agent_index == index) { + return None; + } + if self + .runtime_holds + .get(&index) + .is_some_and(|hold| !scopes.contains(&hold.scope)) + && !background_expired + { + return None; + } + let agent = self.agents.get_mut(index)?.take()?; + self.clear_runtime_hold(index); + Some(agent) + } + + /// Forget only the retired process's ownership; durable provider state stays. + pub(crate) fn finish_worker_retirement(&mut self, index: usize) { + self.retire_on_return.remove(&index); + self.runtime_holds.remove(&index); + self.session_owners + .retain(|_, owner| owner.agent_index != index); + self.scope_workers.retain(|_, workers| { + workers.remove(&index); + !workers.is_empty() + }); + } +} + +impl OwnedAgent { + /// Publish neighbor departure only after the old process was verified stopped. + /// A newer owner is deliberately left untouched by the lease CAS. + pub(crate) fn prepare_retired_neighbors( + &mut self, + scopes: &HashSet, + controls: &crate::session_controls::SessionControlRuntime, + ) -> Result<(), crate::session_store::StoreError> { + for scope in self + .state + .sessions + .keys() + .filter(|scope| !scopes.contains(*scope)) + { + if let Some(store) = &self.state.store { + if let Some(lease) = self.state.leases.get(scope) { + match store.mark_departed( + scope, + *lease, + crate::session_store::Departure::WorkerRecycled, + ) { + Ok(()) | Err(crate::session_store::StoreError::Conflict) => {} + Err(error) => return Err(error), + } + } + } else { + controls.recycle_neighbor(scope)?; + } + } + self.state.retire_all_status(); + Ok(()) + } +} diff --git a/crates/buzz-acp/src/pool/session_retirement_review_tests.rs b/crates/buzz-acp/src/pool/session_retirement_review_tests.rs new file mode 100644 index 00000000000..aa74e3ed8e3 --- /dev/null +++ b/crates/buzz-acp/src/pool/session_retirement_review_tests.rs @@ -0,0 +1,313 @@ +use super::*; + +#[tokio::test] +async fn retirement_executor_preserves_concurrent_no_resume_neighbor_restart() { + let fixture = Fixture::new(); + let a = scope(); + let c = SessionScope::Thread { + channel_id: a.channel_id(), + root_event_id: "cd".repeat(32), + }; + let human = Keys::generate(); + let (mut ctx, server) = relay_context(a.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let eof = fixture.dir.path().join("retirement-eof"); + let mut agent = fixture.agent(&[]).await; + agent.acp.shutdown().await; + let adapter = format!( + "{ADAPTER}\nopen({:?}, 'w').close()\ntime.sleep(.3)\n", + eof.display().to_string() + ); + agent.acp = AcpClient::spawn( + "/usr/bin/python3", + &["-u".into(), "-c".into(), adapter], + &[("LOG".into(), fixture.log().display().to_string())], + false, + ) + .await + .unwrap(); + agent.acp.initialize().await.unwrap(); + agent.state = SessionState::default(); + agent.state.sessions.insert(a.clone(), "old-a".into()); + agent.state.sessions.insert(c.clone(), "old-c".into()); + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + pool.record_scope_owner(a.clone(), 0); + pool.record_scope_owner(c.clone(), 0); + let job = authorized_control(&ctx, &human, &a, "!restart none").await; + ctx.session_controls.restart(job).unwrap(); + ctx.session_controls.plan_retirement(&a, [0]); + let mut config = crate::error_outcome_emission_tests::test_config(); + config.agent_command = "/usr/bin/python3".into(); + config.agent_args = vec!["-u".into(), "-c".into(), ADAPTER.into()]; + config.persona_env_vars = vec![("LOG".into(), fixture.log().display().to_string())]; + let mut circuits = vec![crate::SlotCircuit { + crash_times: vec![], + open_until: None, + respawn_in_flight: false, + }]; + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + let mut tasks = tokio::task::JoinSet::new(); + let mut parked = HashMap::new(); + crate::worker_retirement::drive( + &mut pool, + &ctx.session_controls, + &mut parked, + &config, + &mut circuits, + &tx, + &mut tasks, + None, + ); + tokio::time::timeout(Duration::from_secs(5), async { + while !eof.exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + let job = authorized_control(&ctx, &human, &c, "!restart all").await; + let origin = job.origin.clone(); + ctx.session_controls.restart(job).unwrap(); + let intent = ctx.session_controls.restart_intent(&c).unwrap(); + ctx.session_controls.plan_retirement(&c, [0]); + let mut result = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(!crate::worker_retirement::finish( + &mut pool, + &ctx.session_controls, + &mut parked, + &mut result + )); + let (mut acp, _, _) = result.result.unwrap(); + acp.shutdown().await; + assert!(!ctx.session_controls.retirement_pending(&c)); + assert_eq!( + ctx.session_controls.restart_intent(&c), + Some(intent.clone()), + "neighbor cleanup must preserve the concurrently admitted restart token/depth/origin" + ); + assert_eq!(intent.origin, origin); + assert_eq!(intent.depth, buzz_core::session_control::RestartDepth::All); + assert!(fixture.record(&c).live.is_none()); + tasks.shutdown().await; + server.abort(); +} + +struct EscapedProbe(u32); +impl Drop for EscapedProbe { + fn drop(&mut self) { + let _ = nix::sys::signal::killpg( + nix::unistd::Pid::from_raw(self.0 as i32), + nix::sys::signal::Signal::SIGKILL, + ); + } +} + +fn replacement_agent(acp: AcpClient) -> OwnedAgent { + OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, + agent_name: "probe".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + background_context: None, + } +} + +#[tokio::test] +async fn retirement_foreground_panic_cannot_attest_replacement_or_forget_original_on_retry() { + use crate::acp::TerminationReport; + use buzz_core::session_control::{ControlFailure, ControlResult}; + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let child_path = fixture.dir.path().join("escaped-child"); + let adapter = format!("import subprocess\nchild = subprocess.Popen(['/bin/sleep', '120'], start_new_session=True)\nopen({:?}, 'w').write(str(child.pid))\n{ADAPTER}", child_path.display().to_string()); + let mut acp = AcpClient::spawn( + "/usr/bin/python3", + &["-u".into(), "-c".into(), adapter], + &[("LOG".into(), fixture.log().display().to_string())], + false, + ) + .await + .unwrap(); + acp.initialize().await.unwrap(); + let escaped = EscapedProbe( + std::fs::read_to_string(child_path) + .unwrap() + .parse() + .unwrap(), + ); + let agent = replacement_agent(acp); + let mut pool = AgentPool::from_slots(vec![None]); + pool.record_scope_owner(scope.clone(), 0); + let trigger = Arc::new(tokio::sync::Notify::new()); + let signal = trigger.clone(); + let abort = pool.join_set.spawn(async move { + let _owned_foreground_client = agent; + signal.notified().await; + panic!("foreground fixture panic"); + }); + pool.task_map_mut().insert( + abort.id(), + crate::pool::TaskMeta { + agent_index: 0, + channel_id: Some(scope.channel_id()), + scope: Some(scope.clone()), + turn_id: "reserved-panic".into(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + control_dispatch: Some(ctx.session_controls.capture(&scope)), + successful_steer_deliveries: Default::default(), + }, + ); + let first = authorized_control(&ctx, &human, &scope, "!restart none").await; + let first_id = first.origin.command_event_id.clone(); + ctx.session_controls.restart(first).unwrap(); + pool.mark_retire_on_return(0); + ctx.session_controls.plan_retirement(&scope, [0]); + trigger.notify_one(); + let error = pool.join_set.join_next().await.unwrap().unwrap_err(); + let mut config = crate::error_outcome_emission_tests::test_config(); + config.agent_command = "/usr/bin/python3".into(); + config.agent_args = vec!["-u".into(), "-c".into(), ADAPTER.into()]; + config.persona_env_vars = vec![("LOG".into(), fixture.log().display().to_string())]; + let mut circuits = vec![crate::SlotCircuit { + crash_times: vec![], + open_until: None, + respawn_in_flight: false, + }]; + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + let mut tasks = tokio::task::JoinSet::new(); + let mut parked = HashMap::new(); + crate::recover_panicked_agent( + &mut pool, + &mut crate::EventQueue::new(crate::config::DedupMode::Queue), + &config, + &ctx.session_controls, + error, + &mut false, + &Default::default(), + &mut Default::default(), + &mut circuits, + &tx, + &mut tasks, + None, + None, + ); + // Before the fix this is the actual ordinary replacement path that lets + // the next checked drive attest a fresh process instead of the lost one. + if !tasks.is_empty() { + let mut rr = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .unwrap() + .unwrap(); + if !crate::worker_retirement::finish(&mut pool, &ctx.session_controls, &mut parked, &mut rr) + { + let (acp, _, _) = rr.result.unwrap(); + pool.finish_worker_retirement(0); + pool.return_agent(replacement_agent(acp)); + circuits[0].respawn_in_flight = false; + crate::worker_retirement::drive( + &mut pool, + &ctx.session_controls, + &mut parked, + &config, + &mut circuits, + &tx, + &mut tasks, + None, + ); + let mut rr = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .unwrap() + .unwrap(); + crate::worker_retirement::finish( + &mut pool, + &ctx.session_controls, + &mut parked, + &mut rr, + ); + if let Ok((mut acp, _, _)) = rr.result { + acp.shutdown().await; + } + } + } + assert!( + nix::sys::signal::killpg(nix::unistd::Pid::from_raw(escaped.0 as i32), None).is_ok(), + "the original separate-group child outlives ordinary panic Drop" + ); + assert!( + ctx.session_controls.retirement_pending(&scope), + "a replacement Clean must not release the original worker's retirement gate" + ); + assert_eq!( + ctx.session_controls.prior_result(&scope, &first_id), + Some(ControlResult::Terminal(Err(ControlFailure::Unconfirmed))) + ); + assert!( + circuits[0].crash_times.is_empty(), + "retirement loss must not enter ordinary crash replacement" + ); + assert!(tasks.is_empty()); + let retry = authorized_control(&ctx, &human, &scope, "!restart").await; + let retry_id = retry.origin.command_event_id.clone(); + assert!(ctx.session_controls.restart(retry).unwrap()); + let intent = ctx.session_controls.restart_intent(&scope).unwrap(); + ctx.session_controls.plan_retirement(&scope, [0]); + let replacement = fixture.agent(&[]).await; + let mut late = crate::RespawnResult { + index: 0, + result: Ok((replacement.acp, 1, "late replacement".into())), + retirement: None, + }; + assert!( + crate::worker_retirement::finish(&mut pool, &ctx.session_controls, &mut parked, &mut late), + "a late ordinary replacement is not evidence about the lost original" + ); + ctx.session_controls + .retirement_report(0, TerminationReport::Clean); + assert!(ctx.session_controls.retirement_pending(&scope)); + assert!(ctx.session_controls.retiring_workers().is_empty()); + assert_eq!(ctx.session_controls.restart_intent(&scope), Some(intent)); + // A late Clean must retain the original evidence loss for future commands, + // not merely leave the already terminal current gate closed. + let later = authorized_control(&ctx, &human, &scope, "!restart all").await; + let later_id = later.origin.command_event_id.clone(); + assert!(ctx.session_controls.restart(later).unwrap()); + ctx.session_controls.plan_retirement(&scope, [0]); + ctx.session_controls + .retirement_report(0, TerminationReport::Clean); + assert!(ctx.session_controls.retirement_pending(&scope)); + assert_eq!( + ctx.session_controls.prior_result(&scope, &later_id), + Some(ControlResult::Terminal(Err(ControlFailure::Unconfirmed))) + ); + assert_eq!( + ctx.session_controls.prior_result(&scope, &retry_id), + Some(ControlResult::Terminal(Err(ControlFailure::Unconfirmed))) + ); + assert!(ctx + .session_controls + .take_publications() + .iter() + .all(|p| p.result != ControlResult::Terminal(Ok(())))); + tasks.shutdown().await; + server.abort(); +} diff --git a/crates/buzz-acp/src/pool/session_retirement_runtime_tests.rs b/crates/buzz-acp/src/pool/session_retirement_runtime_tests.rs new file mode 100644 index 00000000000..f652d5d3ea7 --- /dev/null +++ b/crates/buzz-acp/src/pool/session_retirement_runtime_tests.rs @@ -0,0 +1,416 @@ +use super::*; + +#[tokio::test] +async fn checked_retirement_requires_every_clean_report_and_retries_after_uncertainty() { + use crate::acp::TerminationReport; + use buzz_core::session_control::{ControlFailure, ControlResult}; + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let job = authorized_control(&ctx, &human, &scope, "!restart none").await; + let first_id = job.origin.command_event_id.clone(); + ctx.session_controls.restart(job).unwrap(); + ctx.session_controls.plan_retirement(&scope, [0, 1]); + ctx.session_controls + .retirement_report(0, TerminationReport::Clean); + assert!(ctx.session_controls.retirement_pending(&scope)); + ctx.session_controls + .retirement_report(1, TerminationReport::Unverified); + assert!(ctx.session_controls.retirement_pending(&scope)); + assert!( + ctx.session_controls.retiring_workers().is_empty(), + "failed termination waits for an explicit retry" + ); + assert_eq!( + ctx.session_controls.prior_result(&scope, &first_id), + Some(ControlResult::Terminal(Err(ControlFailure::Unconfirmed))) + ); + let retry = authorized_control(&ctx, &human, &scope, "!restart all").await; + assert!(ctx.session_controls.restart(retry).unwrap()); + ctx.session_controls.plan_retirement(&scope, []); + assert_eq!(ctx.session_controls.retiring_workers().len(), 1); + ctx.session_controls + .retirement_report(1, TerminationReport::Clean); + assert!(!ctx.session_controls.retirement_pending(&scope)); + assert_eq!( + ctx.session_controls.restart_intent(&scope).unwrap().depth, + buzz_core::session_control::RestartDepth::All + ); + server.abort(); +} + +#[tokio::test] +async fn checked_retirement_second_restart_supersedes_first_without_rechecking_freshness() { + use buzz_core::session_control::{ControlFailure, ControlResult}; + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let first = authorized_control(&ctx, &human, &scope, "!restart none").await; + let first_id = first.origin.command_event_id.clone(); + ctx.session_controls.restart(first).unwrap(); + ctx.session_controls.plan_retirement(&scope, [0]); + let second = authorized_control(&ctx, &human, &scope, "!restart all").await; + ctx.session_controls.restart(second).unwrap(); + ctx.session_controls.plan_retirement(&scope, [0]); + assert_eq!( + ctx.session_controls.prior_result(&scope, &first_id), + Some(ControlResult::Terminal(Err(ControlFailure::Superseded))) + ); + ctx.session_controls + .retirement_report(0, crate::acp::TerminationReport::Clean); + assert!(!ctx.session_controls.retirement_pending(&scope)); + server.abort(); +} + +#[tokio::test] +async fn checked_retirement_neighbors_resume_or_start_new_with_recycled_reason() { + for resume in [true, false] { + let fixture = Fixture::new(); + let scope = scope(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let mut agent = fixture.agent(&[("RESUME_CAP", "1")]).await; + if resume { + agent.state = owned_state(&fixture, &scope, "old-1").0; + } else { + agent.state = SessionState::default(); + agent + .state + .sessions + .insert(scope.clone(), "no-resume-old".into()); + } + agent + .prepare_retired_neighbors(&Default::default(), &ctx.session_controls) + .unwrap(); + let (_, start) = acquire_session(&mut agent, &ctx, None, channel_context(&scope), &scope) + .await + .unwrap(); + if resume { + assert_eq!( + start, + SessionStart::Resumed { + reason: SessionReason::WorkerRecycled + } + ); + assert!(fixture.methods().contains(&"session/resume".into())); + } else { + assert_eq!( + start, + SessionStart::New { + reason: SessionReason::WorkerRecycled + } + ); + let record = fixture.record(&scope); + assert!( + record.live.is_none(), + "no-resume controller must never persist provider identity" + ); + } + agent.acp.shutdown().await; + server.abort(); + } +} + +#[tokio::test] +async fn checked_retirement_admitted_job_can_complete_twenty_minutes_later() { + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let job = authorized_control(&ctx, &human, &scope, "!restart all").await; + let id = job.origin.command_event_id.clone(); + ctx.session_controls.restart(job).unwrap(); + ctx.session_controls.plan_retirement(&scope, [0]); + tokio::time::pause(); + tokio::time::advance(Duration::from_secs(1200)).await; + let workers = ctx.session_controls.retiring_workers(); + assert_eq!(workers.len(), 1); + assert!( + workers[0].2, + "foreign background wait expires at ten minutes" + ); + ctx.session_controls + .retirement_report(0, crate::acp::TerminationReport::Clean); + assert!(!ctx.session_controls.retirement_pending(&scope)); + assert_eq!( + ctx.session_controls.prior_result(&scope, &id), + Some(buzz_core::session_control::ControlResult::Terminal(Ok(()))) + ); + server.abort(); +} + +#[tokio::test] +async fn checked_retirement_executor_waits_for_verified_kill_and_excludes_refill() { + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let adapter = format!("import signal\nsignal.signal(signal.SIGTERM, signal.SIG_IGN)\n{ADAPTER}\nwhile True: time.sleep(1)\n"); + let mut agent = fixture.agent(&[]).await; + agent.acp.shutdown().await; + agent.acp = AcpClient::spawn( + "/usr/bin/python3", + &["-u".into(), "-c".into(), adapter.clone()], + &[("LOG".into(), fixture.log().display().to_string())], + false, + ) + .await + .unwrap(); + agent.acp.initialize().await.unwrap(); + let pid = agent.acp.process_id().unwrap(); + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + pool.record_scope_owner(scope.clone(), 0); + let job = authorized_control(&ctx, &human, &scope, "!restart none").await; + ctx.session_controls.restart(job).unwrap(); + ctx.session_controls + .plan_retirement(&scope, pool.retirement_targets(&scope)); + let mut config = crate::error_outcome_emission_tests::test_config(); + config.agent_command = "/usr/bin/python3".into(); + config.agent_args = vec!["-u".into(), "-c".into(), adapter]; + config.persona_env_vars = vec![("LOG".into(), fixture.log().display().to_string())]; + let mut circuits = vec![crate::SlotCircuit { + crash_times: vec![], + open_until: None, + respawn_in_flight: false, + }]; + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + let mut tasks = tokio::task::JoinSet::new(); + let mut parked = HashMap::new(); + let started = tokio::time::Instant::now(); + crate::worker_retirement::drive( + &mut pool, + &ctx.session_controls, + &mut parked, + &config, + &mut circuits, + &tx, + &mut tasks, + None, + ); + crate::worker_retirement::drive( + &mut pool, + &ctx.session_controls, + &mut parked, + &config, + &mut circuits, + &tx, + &mut tasks, + None, + ); + assert!( + circuits[0].respawn_in_flight, + "ordinary refill is excluded while checked retirement owns the slot" + ); + assert_eq!(tasks.len(), 1); + assert!(ctx.session_controls.retirement_pending(&scope)); + let mut result = tokio::time::timeout(Duration::from_secs(30), rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(started.elapsed() >= Duration::from_secs(15)); + assert_eq!( + result.retirement.as_ref().unwrap().report, + crate::acp::TerminationReport::Clean + ); + assert!(!crate::worker_retirement::finish( + &mut pool, + &ctx.session_controls, + &mut parked, + &mut result + )); + assert!(!ctx.session_controls.retirement_pending(&scope)); + assert!(circuits[0].crash_times.is_empty()); + assert!(parked.is_empty()); + assert_eq!( + nix::sys::signal::killpg(nix::unistd::Pid::from_raw(pid as i32), None), + Err(nix::errno::Errno::ESRCH) + ); + let (mut acp, _, _) = result.result.unwrap(); + acp.shutdown().await; + tasks.shutdown().await; + server.abort(); +} + +#[tokio::test] +async fn checked_retirement_terminal_store_failure_retains_gate_and_reports_uncertainty() { + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let job = authorized_control(&ctx, &human, &scope, "!restart none").await; + ctx.session_controls.restart(job).unwrap(); + ctx.session_controls.plan_retirement(&scope, [0]); + fixture.store.fail_writes(true); + ctx.session_controls + .retirement_report(0, crate::acp::TerminationReport::Clean); + assert!(ctx.session_controls.retirement_pending(&scope)); + let publications = ctx.session_controls.take_publications(); + assert_eq!(publications.len(), 1, "failed terminal persist must leave an explicit outcome rather than silent permanent pending"); + assert_eq!( + publications[0].result, + buzz_core::session_control::ControlResult::Terminal(Err( + buzz_core::session_control::ControlFailure::Unconfirmed + )) + ); + fixture.store.fail_writes(false); + server.abort(); +} + +#[tokio::test] +async fn checked_retirement_supersedes_started_compact_without_a_compact_rpc() { + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let compact = "c".repeat(64); + fixture + .store + .begin_control_attempt(&scope, &compact, nostr::Timestamp::now().as_secs()) + .unwrap(); + let dispatch = ctx.session_controls.capture(&scope); + let restart = authorized_control(&ctx, &human, &scope, "!restart none").await; + ctx.session_controls.restart(restart).unwrap(); + assert!(!dispatch.current()); + assert_eq!( + ctx.session_controls.prior_result(&scope, &compact), + Some(buzz_core::session_control::ControlResult::Terminal(Err( + buzz_core::session_control::ControlFailure::Superseded + ))) + ); + assert!(ctx + .session_controls + .take_publications() + .iter() + .any(|result| result.command_id == compact)); + assert!( + fixture.methods().is_empty(), + "Task2b adds supersession bookkeeping, not a compact RPC" + ); + server.abort(); +} + +#[tokio::test] +async fn checked_retirement_channel_revocation_cannot_abandon_checked_cleanup() { + let fixture = Fixture::new(); + let scope = scope(); + let human = Keys::generate(); + let (mut ctx, server) = relay_context(scope.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let job = authorized_control(&ctx, &human, &scope, "!restart none").await; + ctx.session_controls.restart(job).unwrap(); + ctx.session_controls.plan_retirement(&scope, [0]); + let stale = ctx.session_controls.capture(&scope); + ctx.session_controls + .revoke_channel(scope.channel_id()) + .unwrap(); + assert!(!stale.current()); + assert!( + ctx.session_controls.retirement_pending(&scope), + "membership loss must not discard an owned checked-retirement job" + ); + assert!(ctx.session_controls.restart_intent(&scope).is_none()); + ctx.session_controls + .retirement_report(0, crate::acp::TerminationReport::Clean); + assert!(!ctx.session_controls.retirement_pending(&scope)); + server.abort(); +} + +#[tokio::test] +async fn checked_retirement_cannot_depart_neighbor_owned_by_a_newer_worker() { + let fixture = Fixture::new(); + let a = scope(); + let c = SessionScope::Thread { + channel_id: a.channel_id(), + root_event_id: "d".repeat(64), + }; + let human = Keys::generate(); + let (mut ctx, server) = relay_context(a.channel_id(), vec![]).await; + ctx.session_policy = crate::scope::SessionPolicy::Thread; + ctx.session_controls = + crate::session_controls::SessionControlRuntime::new(Some(fixture.store.clone())); + let (old_state, _) = owned_state(&fixture, &c, "old-c"); + let mut newer = fixture.record(&c).live.unwrap(); + newer.worker_index = 1; + newer.session_id = "newer-c".into(); + newer.departed = None; + let lease = fixture + .store + .claim( + &c, + &crate::session_store::Expect::from_outcome(&fixture.store.load(&c)), + newer, + ) + .unwrap(); + let mut agent = fixture.agent(&[]).await; + agent.state = old_state; + let mut pool = AgentPool::from_slots(vec![Some(agent), None]); + pool.record_scope_owner(a.clone(), 0); + let restart = authorized_control(&ctx, &human, &a, "!restart none").await; + ctx.session_controls.restart(restart).unwrap(); + ctx.session_controls + .plan_retirement(&a, pool.retirement_targets(&a)); + let config = crate::error_outcome_emission_tests::test_config(); + let mut circuits = vec![ + crate::SlotCircuit { + crash_times: vec![], + open_until: None, + respawn_in_flight: false, + }, + crate::SlotCircuit { + crash_times: vec![], + open_until: None, + respawn_in_flight: false, + }, + ]; + let (tx, mut rx) = tokio::sync::mpsc::channel(2); + let mut tasks = tokio::task::JoinSet::new(); + let mut parked = HashMap::new(); + crate::worker_retirement::drive( + &mut pool, + &ctx.session_controls, + &mut parked, + &config, + &mut circuits, + &tx, + &mut tasks, + None, + ); + let result = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .unwrap() + .unwrap(); + let live = fixture.record(&c).live.unwrap(); + assert_eq!(live.lease, lease); + assert_eq!( + live.departed, None, + "retiring worker must not mark a newer active provider session departed" + ); + tasks.shutdown().await; + if let Some(mut retained) = result.retirement.unwrap().retained { + retained.acp.shutdown().await; + } + server.abort(); +} diff --git a/crates/buzz-acp/src/pool/session_retirement_tests.rs b/crates/buzz-acp/src/pool/session_retirement_tests.rs new file mode 100644 index 00000000000..024ede842c5 --- /dev/null +++ b/crates/buzz-acp/src/pool/session_retirement_tests.rs @@ -0,0 +1,209 @@ +use super::*; +use crate::pool::background_work::RuntimeHold; +use crate::pool::{PromptResult, PromptSource, TaskMeta}; +use std::collections::HashSet; + +#[tokio::test] +async fn retirement_targets_include_checked_out_copies_and_block_idle_claims() { + let fixture = Fixture::new(); + let a = scope(); + let agent = fixture.agent(&[]).await; + let mut pool = AgentPool::from_slots(vec![Some(agent), None]); + let generation = pool.record_scope_owner(a.clone(), 0); + let cached = pool.agents[0].as_mut().unwrap(); + cached.state.sessions.insert(a.clone(), "cached-a".into()); + cached + .state + .scope_owner_generations + .insert(a.clone(), generation); + pool.mark_retire_on_return(0); + assert!( + pool.try_claim(Some(&a)).is_none(), + "affinity cannot reclaim a reserved worker" + ); + assert!(!pool.any_idle()); + pool.record_scope_owner(a.clone(), 1); + assert_eq!(pool.retirement_targets(&a), vec![0, 1]); + assert!( + pool.try_claim(None).is_none(), + "admitted idle target cannot be claimed" + ); + let mut agent = pool + .take_retirement_target(0, &[a].into_iter().collect(), true) + .unwrap(); + agent.acp.shutdown().await; +} + +#[tokio::test] +async fn retirement_foreign_background_hold_waits_but_own_hold_does_not() { + let fixture = Fixture::new(); + let a = scope(); + let b = SessionScope::Thread { + channel_id: a.channel_id(), + root_event_id: "b".repeat(64), + }; + let agent = fixture.agent(&[]).await; + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + pool.record_scope_owner(a.clone(), 0); + pool.set_runtime_hold( + 0, + RuntimeHold { + scope: b.clone(), + blocking_pids: HashSet::from([123]), + continuation: None, + collection_pending: false, + }, + ); + pool.mark_retire_on_return(0); + assert!(pool + .take_retirement_target(0, &HashSet::from([a]), false) + .is_none()); + let mut agent = pool + .take_retirement_target(0, &HashSet::from([b]), false) + .unwrap(); + agent.acp.shutdown().await; +} + +#[tokio::test] +async fn retirement_background_deadline_cannot_take_foreground_worker() { + let fixture = Fixture::new(); + let a = scope(); + let agent = fixture.agent(&[]).await; + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + pool.mark_retire_on_return(0); + let handle = pool.join_set.spawn(std::future::pending::<()>()); + pool.task_map.insert( + handle.id(), + TaskMeta { + agent_index: 0, + channel_id: Some(a.channel_id()), + scope: Some(a.clone()), + turn_id: "foreign-foreground".into(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + control_dispatch: None, + successful_steer_deliveries: Default::default(), + }, + ); + assert!(pool + .take_retirement_target(0, &HashSet::from([a.clone()]), true) + .is_none()); + pool.task_map.clear(); + handle.abort(); + let mut agent = pool + .take_retirement_target(0, &HashSet::from([a]), true) + .unwrap(); + agent.acp.shutdown().await; +} + +#[tokio::test] +async fn retirement_background_release_avoids_unclaimable_collection() { + let fixture = Fixture::new(); + let a = scope(); + let agent = fixture.agent(&[]).await; + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + let generation = pool.record_scope_owner(a.clone(), 0); + let cached = pool.agents[0].as_mut().unwrap(); + cached + .state + .sessions + .insert(a.clone(), "background-a".into()); + cached + .state + .scope_owner_generations + .insert(a.clone(), generation); + pool.set_runtime_hold( + 0, + RuntimeHold { + scope: a.clone(), + blocking_pids: HashSet::from([123]), + continuation: Some(crate::queue::RuntimeContinuation { + delivery_key: Uuid::new_v4(), + ordinary_preempted: false, + worker_index: 0, + session_id: "background-a".into(), + generation, + thread_tags: crate::queue::ThreadTags::default(), + received_at: std::time::Instant::now(), + }), + collection_pending: false, + }, + ); + pool.mark_retire_on_return(0); + let mut queue = crate::EventQueue::new(crate::DedupMode::Queue); + pool.queue_finished_background_work(&mut queue); + assert!(!pool.has_runtime_hold()); + assert!(!queue.has_undispatched_work()); + let mut agent = pool + .take_retirement_target(0, &HashSet::new(), false) + .unwrap(); + agent.acp.shutdown().await; +} + +#[tokio::test] +async fn retirement_return_boundary_bypasses_crash_budget_for_fatal_old_outcome() { + let fixture = Fixture::new(); + let a = scope(); + let agent = fixture.agent(&[]).await; + let mut pool = AgentPool::from_slots(vec![None]); + pool.mark_retire_on_return(0); + let handle = pool.join_set.spawn(std::future::pending::<()>()); + pool.task_map.insert( + handle.id(), + TaskMeta { + agent_index: 0, + channel_id: Some(a.channel_id()), + scope: Some(a.clone()), + turn_id: "old".into(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + control_dispatch: None, + successful_steer_deliveries: Default::default(), + }, + ); + let result = PromptResult { + agent, + source: PromptSource::Channel(a.clone()), + turn_id: "old".into(), + outcome: PromptOutcome::AgentExited, + batch: None, + }; + let config = crate::error_outcome_emission_tests::test_config(); + let mut queue = crate::EventQueue::new(crate::DedupMode::Queue); + let mut heartbeat = false; + let mut circuits = vec![crate::SlotCircuit { + crash_times: vec![], + open_until: None, + respawn_in_flight: false, + }]; + let (tx, _rx) = tokio::sync::mpsc::channel(1); + let mut tasks = tokio::task::JoinSet::new(); + let mut activity = tokio::time::Instant::now(); + assert!(matches!( + crate::handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat, + &HashSet::new(), + &mut circuits, + &tx, + &mut tasks, + None, + None, + &mut activity + ), + crate::LoopAction::Continue + )); + assert!(circuits[0].crash_times.is_empty()); + assert!(tasks.is_empty()); + assert!(pool.try_claim(None).is_none()); + let mut agent = pool + .take_retirement_target(0, &HashSet::from([a]), false) + .unwrap(); + agent.acp.shutdown().await; + handle.abort(); +} diff --git a/crates/buzz-acp/src/pool/session_visibility.rs b/crates/buzz-acp/src/pool/session_visibility.rs new file mode 100644 index 00000000000..6b9c1b54a24 --- /dev/null +++ b/crates/buzz-acp/src/pool/session_visibility.rs @@ -0,0 +1,326 @@ +//! Session visibility: adapt the canonical session scope, the acquisition +//! result and the final cold-start prompt to the status runtime. +//! +//! Acquisitions publish `new`, `resumed`, or an explicitly `restarted` state. +//! History counts describe +//! the unique message events actually supplied in a new session's first +//! prompt; they come from data the prompt already fetched, never from an +//! extra history walk. + +use std::collections::HashSet; + +use buzz_core::session_status::{ + HistoryCoverage, SessionState as StatusState, StatusReason, StatusScope, +}; +use uuid::Uuid; + +use super::{ConversationContext, OwnedAgent, PromptChannelInfo, SessionStart, SessionState}; +use crate::scope::{SessionPolicy, SessionScope}; +use crate::session_status::SessionStatusRuntime; +use crate::session_store::SessionReason; + +/// The status scope of a session scope; `None` for a malformed thread root. +pub(crate) fn status_scope(scope: &SessionScope) -> Option { + let status = StatusScope { + channel_id: scope.channel_id(), + thread_root: scope.root_event_id().map(str::to_owned), + }; + status.validate().ok()?; + Some(status) +} + +/// Failure codes the harness produces today; anything else may carry raw +/// adapter or store text and is reported as `unknown`. +const KNOWN_FAILURE_CODES: &[&str] = &[ + "not_persisted", + "store_unreadable", + "store_busy", + "not_found", + "timeout", + "protocol", +]; +/// Decimal digits of the widest JSON-RPC error code (`i64`). +const MAX_RPC_CODE_DIGITS: usize = 19; + +/// Map a harness reason onto the bounded status vocabulary. A failure code +/// outside the allowlist becomes `resume_failed:unknown`, never raw text. +pub(crate) fn status_reason(reason: &SessionReason) -> StatusReason { + match reason { + SessionReason::IdleSleep => StatusReason::IdleSleep, + SessionReason::WorkerCrash => StatusReason::WorkerCrash, + SessionReason::WorkerRecycled => StatusReason::WorkerRecycled, + SessionReason::UserRestart(depth) => StatusReason::UserRestart(*depth), + SessionReason::UserCompact => StatusReason::UserCompact, + SessionReason::AutoCompact => StatusReason::AutoCompact, + SessionReason::HarnessRestart => StatusReason::HarnessRestart, + SessionReason::FirstInThread => StatusReason::FirstInThread, + SessionReason::Discarded => StatusReason::Discarded, + SessionReason::ConfigChanged => StatusReason::ConfigChanged, + SessionReason::LiveElsewhere => StatusReason::LiveElsewhere, + SessionReason::ResumeUnsupported => StatusReason::ResumeUnsupported, + SessionReason::ResumeFailed(code) => StatusReason::ResumeFailed( + if KNOWN_FAILURE_CODES.contains(&code.as_str()) || is_rpc_code(code) { + code.clone() + } else { + "unknown".into() + }, + ), + } +} + +/// `rpc_` as produced by `resume_failure_code`. +fn is_rpc_code(code: &str) -> bool { + let Some(number) = code.strip_prefix("rpc_") else { + return false; + }; + let digits = number.strip_prefix('-').unwrap_or(number); + (1..=MAX_RPC_CODE_DIGITS).contains(&digits.len()) && digits.bytes().all(|b| b.is_ascii_digit()) +} + +/// Retire `scope`'s status whichever worker owns it (owner rotate). +pub(crate) fn retire_scope(runtime: Option<&SessionStatusRuntime>, scope: &SessionScope) { + if let (Some(runtime), Some(scope)) = (runtime, status_scope(scope)) { + runtime.retire(&scope); + } +} + +/// Retire every status of `channel_id` (the agent left the channel). +pub(crate) fn retire_channel(runtime: Option<&SessionStatusRuntime>, channel_id: Uuid) { + if let Some(runtime) = runtime { + runtime.retire_channel(channel_id); + } +} + +impl SessionState { + /// Retire this worker's status generation for `scope`, if still current. + pub(super) fn retire_status(&mut self, scope: &SessionScope) { + if let Some(binding) = self.status.remove(scope) { + binding.retire(); + } + } + + /// Retire every status generation this worker owns. + pub(super) fn retire_all_status(&mut self) { + for (_, binding) in self.status.drain() { + binding.retire(); + } + } +} + +impl OwnedAgent { + /// Validate display provenance independently of the admitted execution scope. + /// The author gate may conservatively admit an unresolved channel as a DM; + /// only verified DM metadata justifies Conversation display under Thread policy. + pub(super) fn reconcile_session_status_scope( + &mut self, + runtime: Option<&SessionStatusRuntime>, + scope: &SessionScope, + policy: SessionPolicy, + channel_info: Option<&PromptChannelInfo>, + ) -> bool { + if scope.is_thread() + || policy == SessionPolicy::Channel + || channel_info.is_some_and(|info| info.channel_type == "dm") + { + return true; + } + // Revoke even when ACP already has a routing entry or the worker lost + // its local binding. The runtime releases every clone's generation guard. + self.state.retire_status(scope); + retire_scope(runtime, scope); + if let Some(sid) = self.state.sessions.get(scope) { + self.acp.clear_session_status(sid); + } + false + } + + /// Publish the state of a session this turn just created or resumed, + /// before any provider prompt. Status failures never fail the turn. + pub(super) fn publish_session_start( + &mut self, + runtime: Option<&SessionStatusRuntime>, + scope: &SessionScope, + session_id: &str, + start: &SessionStart, + ) { + self.state.retire_status(scope); + let (Some(runtime), Some(status)) = (runtime, status_scope(scope)) else { + return; + }; + let (state, reason) = match start { + SessionStart::New { reason } => (StatusState::New, reason), + SessionStart::Resumed { reason } => (StatusState::Resumed, reason), + }; + let intent = self + .state + .control_dispatch + .as_ref() + .filter(|dispatch| dispatch.current()) + .and_then(|dispatch| dispatch.intent.as_ref()); + let state = if intent.is_some() && !start.is_resumed() { + StatusState::Restarted + } else { + state + }; + let origin = intent.map(|intent| intent.origin.clone()); + let binding = match origin { + Some(origin) => runtime.begin_with_origin( + status, + session_id, + state, + status_reason(reason), + Some(origin), + ), + None => runtime.begin(status, session_id, state, status_reason(reason)), + }; + if let Some(binding) = binding { + self.acp.bind_session_status(session_id, binding.clone()); + self.state.status.insert(scope.clone(), binding); + } + } + + /// An ordinary turn keeps its transition: restore a dropped routing entry + /// while the scope's current generation still belongs to `session_id`. + pub(super) fn rebind_session_status( + &mut self, + runtime: Option<&SessionStatusRuntime>, + scope: &SessionScope, + session_id: &str, + ) { + if self.acp.has_session_status(session_id) { + return; + } + let binding = runtime + .zip(status_scope(scope)) + .and_then(|(runtime, status)| runtime.bind_existing(&status, session_id)); + match binding { + Some(binding) => self.acp.bind_session_status(session_id, binding), + None => self.acp.clear_session_status(session_id), + } + } +} + +/// What the cold-start history fetch returned, after first-mention pinning +/// and before the session delta. +pub(super) enum FetchedHistory { + /// No history was requested (disabled, or a plain channel message). + NotRequested, + /// History was requested but nothing came back. + Missing, + Fetched { + thread: bool, + total: u64, + complete: bool, + ids: Vec, + }, +} + +impl FetchedHistory { + pub(super) fn capture(requested: bool, context: Option<&ConversationContext>) -> Self { + let Some(context) = context else { + return if requested { + Self::Missing + } else { + Self::NotRequested + }; + }; + let (thread, messages, total, complete) = match context { + ConversationContext::Thread { + messages, + total, + root_present, + truncated, + } => (true, messages, *total, *root_present && !*truncated), + ConversationContext::Dm { + messages, + total, + truncated, + } => (false, messages, *total, !*truncated), + }; + Self::Fetched { + thread, + total: total as u64, + complete, + ids: messages + .iter() + .filter(|message| !message.event_id.is_empty()) + .map(|message| message.event_id.clone()) + .collect(), + } + } +} + +/// Coverage of a new session's first prompt. +/// +/// `read` is the number of unique identified events supplied (the final +/// context plus the rendered batch); any unidentified supplied message omits +/// it. The total is used only when the fetched history describes the session +/// scope itself and never falls below `read`. Exactness needs a complete +/// fetch, nothing omitted from the prompt, a successful first-mention scan, +/// and `read == total`. +pub(super) fn history_coverage( + fetched: &FetchedHistory, + supplied: Option<&ConversationContext>, + trigger_ids: &HashSet, + first_mention_incomplete: bool, + scope: &SessionScope, + is_dm: bool, +) -> HistoryCoverage { + let mut ids: HashSet<&str> = trigger_ids.iter().map(String::as_str).collect(); + let mut unidentified = false; + let messages = match supplied { + Some(ConversationContext::Thread { messages, .. }) + | Some(ConversationContext::Dm { messages, .. }) => messages.as_slice(), + None => &[], + }; + for message in messages { + if message.event_id.is_empty() { + unidentified = true; + } else { + ids.insert(&message.event_id); + } + } + let read = (!unidentified).then_some(ids.len() as u64); + + let (mut total, complete) = match fetched { + FetchedHistory::NotRequested => (None, !first_mention_incomplete), + FetchedHistory::Missing => (None, false), + FetchedHistory::Fetched { + thread, + total, + complete, + ids: fetched_ids, + } => { + let omitted = fetched_ids.iter().any(|id| !ids.contains(id.as_str())); + let describes_scope = if *thread { + scope.is_thread() + } else { + is_dm && !scope.is_thread() + }; + ( + describes_scope.then_some(*total), + *complete && !omitted && !first_mention_incomplete, + ) + } + }; + if let (Some(read), Some(known)) = (read, total) { + if read > known { + total = None; + } + } + let incomplete = !complete; + let exact = complete + && !matches!(fetched, FetchedHistory::NotRequested) + && read.is_some() + && read == total; + HistoryCoverage { + read, + total, + exact, + incomplete, + } +} + +#[cfg(all(test, unix))] +#[path = "session_visibility_tests.rs"] +mod tests; diff --git a/crates/buzz-acp/src/pool/session_visibility_policy_tests.rs b/crates/buzz-acp/src/pool/session_visibility_policy_tests.rs new file mode 100644 index 00000000000..f084affedf8 --- /dev/null +++ b/crates/buzz-acp/src/pool/session_visibility_policy_tests.rs @@ -0,0 +1,230 @@ +//! Scope-provenance regressions through the real prompt/status path. +use super::*; +use crate::scope::SessionPolicy; + +fn metadata(ctx: &PromptContext, channel_id: Uuid, kind: Option<&str>) { + let mut cache = ctx.channel_info.cache.write().unwrap(); + cache.remove(&channel_id); + if let Some(kind) = kind { + cache.insert( + channel_id, + super::super::super::PromptChannelInfo { + name: "test".into(), + channel_type: kind.into(), + description: None, + project: None, + }, + ); + } +} + +async fn fixture(h: &Harness, policy: SessionPolicy) -> (PromptContext, Relay) { + let mut ctx = h.ctx(); + ctx.session_policy = policy; + let server = relay( + RelayScript { + events: vec![], + fail_scan: false, + fail_history: false, + }, + &mut ctx, + Uuid::from_u128(42), + ) + .await; + (ctx, server) +} + +#[tokio::test] +async fn conversation_status_requires_channel_policy_or_verified_dm() { + for (policy, kind, eligible) in [ + (SessionPolicy::Thread, None, false), + (SessionPolicy::Thread, Some("unknown"), false), + (SessionPolicy::Thread, Some("stream"), false), + (SessionPolicy::Thread, Some("dm"), true), + (SessionPolicy::Channel, None, true), + ] { + let h = Harness::new(); + let (ctx, server) = fixture(&h, policy).await; + let scope = SessionScope::Conversation { + channel_id: Uuid::from_u128(42), + }; + metadata(&ctx, scope.channel_id(), kind); + let ctx = Arc::new(ctx); + h.control(json!({"snapshot": h.snapshot_path(&scope), "usage": [["SELF", 64, 100]]})); + // A reply tag cannot tell us which policy admitted this Conversation. + let event = mention( + &Keys::generate(), + scope.channel_id(), + Some(&"a".repeat(64)), + "reply", + ); + let agent = h.agent(0, &[]).await; + let mut agent = run_turn(agent, &ctx, batch(&scope, event)).await; + assert_eq!(h.seen()[0].is_some(), eligible, "{policy:?}, {kind:?}"); + assert_eq!( + h.snapshot(&scope).is_some(), + eligible, + "{policy:?}, {kind:?}" + ); + assert!( + agent.state.sessions.contains_key(&scope), + "execution scope is unchanged" + ); + agent.acp.shutdown().await; + server.server.abort(); + } +} + +#[tokio::test] +async fn uncertain_conversation_cannot_outlive_metadata_recovery_and_failed_thread_status() { + let h = Harness::new(); + let (ctx, server) = fixture(&h, SessionPolicy::Thread).await; + let scope = SessionScope::Conversation { + channel_id: Uuid::from_u128(42), + }; + metadata(&ctx, scope.channel_id(), None); + let ctx = Arc::new(ctx); + h.control(json!({"snapshot": h.snapshot_path(&scope), "usage": [["SELF", 99, 100]]})); + let agent = h.agent(0, &[]).await; + let event = mention( + &Keys::generate(), + scope.channel_id(), + None, + "uncertain admission", + ); + let agent = run_turn(agent, &ctx, batch(&scope, event)).await; + // Recover metadata; the previously admitted execution scope stays retained. + metadata(&ctx, scope.channel_id(), Some("stream")); + let event = mention( + &Keys::generate(), + scope.channel_id(), + None, + "retained scope", + ); + let agent = run_turn(agent, &ctx, batch(&scope, event)).await; + assert!(agent.state.sessions.contains_key(&scope)); + assert!( + h.snapshot(&scope).is_none(), + "uncertain Conversation must never be a fallback" + ); + + // A different root still receives its own Thread status under Thread policy. + let (agent, good_thread, _) = first_turn(&h, &ctx, agent, "verified thread").await; + assert_eq!(percent(&h.snapshot(&good_thread).unwrap()), Some(64)); + let root = mention( + &Keys::generate(), + scope.channel_id(), + None, + "failed status thread", + ); + let thread = thread_of(scope.channel_id(), &root); + // Force this optional scope's write to fail while provider execution succeeds. + std::fs::create_dir(h.snapshot_path(&thread)).unwrap(); + h.control(json!({"usage": [["new-1", 98, 100], ["SELF", 77, 100]]})); + let mut agent = run_turn(agent, &ctx, batch(&thread, root)).await; + assert!(agent.state.sessions.contains_key(&thread)); + assert!(!agent.state.status.contains_key(&thread)); + assert!( + h.snapshot(&scope).is_none(), + "no Conversation candidate for the CLI to borrow" + ); + assert_eq!(percent(&h.snapshot(&good_thread).unwrap()), Some(64)); + agent.acp.shutdown().await; + server.server.abort(); +} + +#[tokio::test] +async fn retained_conversation_revokes_generation_before_rebind_or_late_updates() { + for (kind, clear_acp, clear_worker) in [ + (None, false, false), + (Some("stream"), true, false), + (Some("stream"), false, true), + ] { + let h = Harness::new(); + let (ctx, server) = fixture(&h, SessionPolicy::Channel).await; + let scope = SessionScope::Conversation { + channel_id: Uuid::from_u128(42), + }; + metadata(&ctx, scope.channel_id(), kind); + let mut ctx = Arc::new(ctx); + h.control(json!({"snapshot": h.snapshot_path(&scope), "usage": [["SELF", 64, 100]]})); + let event = mention(&Keys::generate(), scope.channel_id(), None, "first"); + let agent = h.agent(0, &[]).await; + let mut agent = run_turn(agent, &ctx, batch(&scope, event)).await; + let original = h.snapshot(&scope).unwrap(); + let old_bytes = std::fs::read(h.snapshot_path(&scope)).unwrap(); + let old_binding = agent.state.status[&scope].clone(); + let sid = agent.state.sessions[&scope].clone(); + if clear_acp { + agent.acp.clear_session_status(&sid); + } + if clear_worker { + agent.state.status.remove(&scope); + } + // Simulate an already retained Conversation encountering Thread policy. + // The next prompt must revoke it even if ACP still has a routing entry. + Arc::get_mut(&mut ctx).unwrap().session_policy = SessionPolicy::Thread; + h.control(json!({"snapshot": h.snapshot_path(&scope), "usage": [["SELF", 99, 100]]})); + let event = mention(&Keys::generate(), scope.channel_id(), None, "retained"); + let agent = run_turn(agent, &ctx, batch(&scope, event)).await; + assert_eq!( + agent.state.sessions[&scope], sid, + "provider session must stay retained" + ); + assert!( + h.seen().last().unwrap().is_none(), + "revoke before provider prompt" + ); + assert!(!agent.state.status.contains_key(&scope)); + assert!(!agent.acp.has_session_status(&sid)); + assert!( + !lock_is_held(&h.generation_lock(&original)), + "release the generation guard" + ); + assert!(h + .status + .bind_existing(&session_visibility_scope(&scope), &sid) + .is_none()); + // A surviving file is untrusted after revocation; late callbacks cannot rewrite it. + std::fs::write(h.snapshot_path(&scope), &old_bytes).unwrap(); + old_binding.record_occupancy(100, 100); + assert_eq!(std::fs::read(h.snapshot_path(&scope)).unwrap(), old_bytes); + assert!(!old_binding.is_live()); + // Even verified-DM recovery cannot resurrect a retired transition. + metadata(&ctx, scope.channel_id(), Some("dm")); + h.control(json!({"usage": [["SELF", 88, 100]]})); + let event = mention(&Keys::generate(), scope.channel_id(), None, "recovered"); + let mut agent = run_turn(agent, &ctx, batch(&scope, event)).await; + assert_eq!(agent.state.sessions[&scope], sid); + assert!(!agent.acp.has_session_status(&sid)); + assert!(!lock_is_held(&h.generation_lock(&original))); + assert_eq!(std::fs::read(h.snapshot_path(&scope)).unwrap(), old_bytes); + agent.acp.shutdown().await; + server.server.abort(); + } +} + +#[test] +fn configured_policy_reaches_prompt_status_context_without_base_prompt() { + for policy in [SessionPolicy::Channel, SessionPolicy::Thread] { + let mut config = crate::build_mcp_servers_tests::test_config(); + config.session_policy = policy; + config.no_base_prompt = true; + let rest = RestClient { + http: reqwest::Client::new(), + base_url: "http://127.0.0.1:0".into(), + keys: config.keys.clone(), + auth_tag_json: None, + }; + let runtime = crate::runtime::AgentRuntime::prepare(config).unwrap(); + let ctx = runtime + .prompt_context( + rest, + HashMap::new(), + crate::runtime::SessionMode::Conversation, + ) + .unwrap(); + assert_eq!(ctx.session_policy, policy); + assert!(ctx.base_prompt.is_none()); + } +} diff --git a/crates/buzz-acp/src/pool/session_visibility_tests.rs b/crates/buzz-acp/src/pool/session_visibility_tests.rs new file mode 100644 index 00000000000..ac59e36ae22 --- /dev/null +++ b/crates/buzz-acp/src/pool/session_visibility_tests.rs @@ -0,0 +1,1306 @@ +//! Production-path tests: status published by `run_prompt_task`, routed by +//! the real ACP dispatcher, and retired by the existing lifecycle paths. + +use super::super::{ + run_prompt_task, AgentPool, ChannelInfoResolver, FlushBatch, PromptContext, PromptOutcome, + RestClient, +}; +use super::*; +use crate::acp::{AcpClient, StopReason}; +use crate::queue::ContextMessage; +use crate::session_store::{Departure, Expect, LiveSession, LoadOutcome, SessionStore}; +use buzz_core::session_status::{ + canonical_identity, generation_lock_path, parse_snapshot, scope_file_name, + SessionState as StatusState, StatusEnvelope, StatusReason, StatusScope, +}; +use fs2::FileExt; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use serde_json::json; +use std::collections::HashMap; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +/// Fake ACP adapter. Logs methods to `$LOG` and session/new|resume params to +/// `$PARAMS`. Before answering each `session/prompt` it reads the JSON file +/// `$CONTROL` (`snapshot`, `probe`, `usage`, `delay`), records the snapshot it +/// sees and the state of the `probe` lock to `$SEEN`, then emits the listed +/// `usage_update`s (`SELF` names the prompted session). +const ADAPTER: &str = r#" +import fcntl, json, os, sys, time +log = open(os.environ['LOG'], 'a', buffering=1) +created = 0 +def note(path, value): + with open(path, 'a') as out: + out.write(json.dumps(value) + '\n') +def probe(path): + try: + fd = os.open(path, os.O_RDONLY) + except FileNotFoundError: + return 'missing' + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + fcntl.flock(fd, fcntl.LOCK_UN) + return 'free' + except OSError: + return 'held' + finally: + os.close(fd) +def control(): + try: + with open(os.environ['CONTROL']) as f: + return json.load(f) + except FileNotFoundError: + return {} +for line in sys.stdin: + req = json.loads(line) + method = req.get('method') + log.write(f"{method}\n") + if 'id' not in req or method is None: + continue + reply = {'jsonrpc': '2.0', 'id': req['id']} + if method == 'initialize': + caps = {'sessionCapabilities': {'resume': {}}} if os.environ.get('RESUME_CAP') == '1' else {} + reply['result'] = {'protocolVersion': 1, 'agentCapabilities': caps} + elif method in ('session/new', 'session/resume'): + note(os.environ['PARAMS'], {'method': method, 'params': req['params']}) + if method == 'session/new': + created += 1 + reply['result'] = {'sessionId': os.environ.get('SID_PREFIX', 'new') + f'-{created}'} + elif os.environ.get('RESUME_ERROR') == '1': + reply['error'] = {'code': -32002, 'message': 'Resource not found'} + else: + reply['result'] = {} + elif method == 'session/prompt': + sid = req['params']['sessionId'] + ctl = control() + seen = {'session': sid} + if ctl.get('snapshot'): + try: + with open(ctl['snapshot']) as f: + seen['snapshot'] = f.read() + except FileNotFoundError: + seen['snapshot'] = None + if ctl.get('probe'): + seen['probe'] = probe(ctl['probe']) + note(os.environ['SEEN'], seen) + time.sleep(float(ctl.get('delay', 0))) + for target, used, size in ctl.get('usage', []): + update = {'sessionUpdate': 'usage_update', 'used': used, 'size': size} + params = {'sessionId': sid if target == 'SELF' else target, 'update': update} + print(json.dumps({'jsonrpc': '2.0', 'method': 'session/update', 'params': params}), flush=True) + reply['result'] = {'stopReason': 'end_turn'} + else: + reply['result'] = {} + print(json.dumps(reply), flush=True) +"#; + +struct Harness { + dir: tempfile::TempDir, + store: Arc, + status: SessionStatusRuntime, + agent_keys: Keys, +} + +impl Harness { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let agent_keys = Keys::generate(); + let relay = "ws://127.0.0.1:3000"; + let agent = agent_keys.public_key().to_hex(); + let store = Arc::new(SessionStore::open(dir.path().join("store"), relay, &agent).unwrap()); + let status = SessionStatusRuntime::open( + &dir.path().join("sessions"), + canonical_identity(relay, &agent).unwrap(), + ) + .unwrap(); + Self { + dir, + store, + status, + agent_keys, + } + } + + fn file(&self, name: &str) -> PathBuf { + self.dir.path().join(name) + } + + fn ctx(&self) -> PromptContext { + let mut ctx = super::super::tests::make_prompt_context_no_owner(); + ctx.cwd = "/tmp".into(); + ctx.context_message_limit = 0; + ctx.agent_keys = self.agent_keys.clone(); + ctx.session_status = Some(self.status.clone()); + ctx + } + + fn control(&self, value: serde_json::Value) { + std::fs::write(self.file("control.json"), value.to_string()).unwrap(); + } + + fn lines(&self, name: &str) -> Vec { + std::fs::read_to_string(self.file(name)) + .unwrap_or_default() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } + + /// Snapshot each `session/prompt` saw, in order. + fn seen(&self) -> Vec> { + self.lines("seen.ndjson") + .iter() + .map(|seen| { + seen["snapshot"] + .as_str() + .map(|text| parse_snapshot(text.as_bytes()).unwrap()) + }) + .collect() + } + + fn methods(&self) -> Vec { + std::fs::read_to_string(self.file("methods.log")) + .unwrap_or_default() + .lines() + .map(str::to_owned) + .collect() + } + + async fn agent(&self, index: usize, env: &[(&str, &str)]) -> OwnedAgent { + let mut vars = vec![ + ("LOG", self.file("methods.log")), + ("PARAMS", self.file("params.ndjson")), + ("SEEN", self.file("seen.ndjson")), + ("CONTROL", self.file("control.json")), + ] + .into_iter() + .map(|(k, v)| (k.to_owned(), v.display().to_string())) + .collect::>(); + vars.extend(env.iter().map(|(k, v)| (k.to_string(), v.to_string()))); + let mut acp = AcpClient::spawn("python3", &["-c".into(), ADAPTER.into()], &vars, false) + .await + .unwrap(); + acp.initialize().await.unwrap(); + OwnedAgent { + index, + acp, + state: SessionState::with_store(Some(self.store.clone())), + model_capabilities: None, + desired_model: None, + model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, + agent_name: "fake-acp".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + background_context: None, + } + } + + fn snapshot_path(&self, scope: &SessionScope) -> PathBuf { + let scope = session_visibility_scope(scope); + self.status.dir().join(scope_file_name(&scope).unwrap()) + } + + fn snapshot(&self, scope: &SessionScope) -> Option { + let bytes = std::fs::read(self.snapshot_path(scope)).ok()?; + Some(parse_snapshot(&bytes).unwrap()) + } + + fn generation_lock(&self, envelope: &StatusEnvelope) -> PathBuf { + self.status + .dir() + .join(generation_lock_path(envelope.generation)) + } + + /// A resumable record for `scope`, as left by a worker that went to sleep. + fn departed_record(&self, scope: &SessionScope, ctx: &PromptContext) { + let live = LiveSession { + lease: Uuid::nil(), + adapter: "fake-acp".into(), + session_id: "old-1".into(), + cwd: ctx.cwd.clone(), + model: None, + instance: self.store.instance(), + worker_index: 0, + departed: None, + turns: 0, + standing_context_sent: false, + delivered_event_ids: Vec::new(), + hydrated_thread_roots: Vec::new(), + }; + let lease = self.store.claim(scope, &Expect::Missing, live).unwrap(); + self.store + .commit(scope, lease, |live| { + live.departed = Some(Departure::IdleSleep); + live.turns = 1; + }) + .unwrap(); + } +} + +fn session_visibility_scope(scope: &SessionScope) -> StatusScope { + status_scope(scope).expect("valid status scope") +} + +fn lock_is_held(path: &Path) -> bool { + let Ok(file) = std::fs::File::open(path) else { + return false; + }; + match file.try_lock_exclusive() { + Ok(()) => { + FileExt::unlock(&file).unwrap(); + false + } + Err(_) => true, + } +} + +fn percent(envelope: &StatusEnvelope) -> Option { + envelope.status.occupancy.and_then(|o| o.percent()) +} + +fn mention(keys: &Keys, channel_id: Uuid, root: Option<&str>, text: &str) -> nostr::Event { + let mut tags = vec![Tag::parse(["h", channel_id.to_string().as_str()]).unwrap()]; + if let Some(root) = root { + tags.push(Tag::parse(["e", root, "", "reply"]).unwrap()); + } + EventBuilder::new(Kind::Custom(9), text) + .tags(tags) + .sign_with_keys(keys) + .unwrap() +} + +fn thread_of(channel_id: Uuid, root: &nostr::Event) -> SessionScope { + SessionScope::Thread { + channel_id, + root_event_id: root.id.to_hex(), + } +} + +fn batch(scope: &SessionScope, event: nostr::Event) -> FlushBatch { + FlushBatch { + continuation: None, + channel_id: scope.channel_id(), + scope: scope.clone(), + events: vec![crate::queue::BatchEvent { + edit: None, + event, + prompt_tag: "@mention".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + } +} + +async fn run_turn(agent: OwnedAgent, ctx: &Arc, batch: FlushBatch) -> OwnedAgent { + let (result_tx, mut result_rx) = tokio::sync::mpsc::unbounded_channel(); + run_prompt_task( + agent, + Some(batch), + None, + ctx.clone(), + result_tx, + None, + "turn".into(), + ) + .await; + let result = result_rx.recv().await.expect("prompt result"); + assert!( + matches!(result.outcome, PromptOutcome::Ok(StopReason::EndTurn)), + "turn did not end normally" + ); + result.agent +} + +/// A new thread session in channel 42 whose first turn ran on `agent`. +async fn first_turn( + h: &Harness, + ctx: &Arc, + agent: OwnedAgent, + root_text: &str, +) -> (OwnedAgent, SessionScope, nostr::Event) { + let human = Keys::generate(); + let channel_id = Uuid::from_u128(42); + let root = mention(&human, channel_id, None, root_text); + let scope = thread_of(channel_id, &root); + h.control(json!({"snapshot": h.snapshot_path(&scope), "usage": [["SELF", 64, 100]]})); + let agent = run_turn(agent, ctx, batch(&scope, root.clone())).await; + (agent, scope, root) +} + +fn follow_up(scope: &SessionScope, text: &str) -> FlushBatch { + let root = scope.root_event_id().unwrap(); + batch( + scope, + mention(&Keys::generate(), scope.channel_id(), Some(root), text), + ) +} + +// ---- state, reason and transition through run_prompt_task ---- + +#[tokio::test] +async fn new_session_status_precedes_the_prompt_and_spans_ordinary_turns() { + let h = Harness::new(); + let ctx = Arc::new(h.ctx()); + let agent = h.agent(0, &[]).await; + let (agent, scope, _) = first_turn(&h, &ctx, agent, "first").await; + + let seen = h.seen(); + let first = seen[0] + .clone() + .expect("snapshot exists before the provider prompt"); + assert_eq!(first.status.state, StatusState::New); + assert_eq!(first.status.reason, StatusReason::FirstInThread); + assert_eq!(first.status.occupancy, None); + let history = first + .status + .history + .expect("history is set before the prompt"); + assert_eq!((history.read, history.total), (Some(1), None)); + assert_eq!(percent(&h.snapshot(&scope).unwrap()), Some(64)); + + // An ordinary turn keeps the transition. + h.control(json!({"snapshot": h.snapshot_path(&scope), "usage": [["SELF", 66, 100]]})); + let mut agent = run_turn(agent, &ctx, follow_up(&scope, "second")).await; + let second = h.seen()[1].clone().unwrap(); + assert_eq!(second.status.transition_id, first.status.transition_id); + assert_eq!(second.generation, first.generation); + + // A dropped routing entry is restored for the same provider session. + let sid = agent.state.sessions[&scope].clone(); + agent.acp.clear_session_status(&sid); + h.control(json!({"usage": [["SELF", 33, 100]]})); + let mut agent = run_turn(agent, &ctx, follow_up(&scope, "third")).await; + let current = h.snapshot(&scope).unwrap(); + assert_eq!(percent(¤t), Some(33)); + assert_eq!(current.status.transition_id, first.status.transition_id); + + // The next acquisition changes the transition and never borrows 33%. + agent.state.invalidate_scope(&scope); + assert!( + h.snapshot(&scope).is_none(), + "invalidation retires the snapshot" + ); + assert!(!lock_is_held(&h.generation_lock(&first))); + h.control(json!({"snapshot": h.snapshot_path(&scope)})); + let mut agent = run_turn(agent, &ctx, follow_up(&scope, "fourth")).await; + agent.acp.shutdown().await; + let next = h.seen().last().cloned().flatten().unwrap(); + assert_ne!(next.status.transition_id, first.status.transition_id); + assert_eq!(next.status.state, StatusState::New); + assert_eq!(next.status.reason, StatusReason::Discarded); + assert_eq!(next.status.occupancy, None); +} + +#[tokio::test] +async fn resumed_session_reports_idle_sleep() { + let h = Harness::new(); + let ctx = Arc::new(h.ctx()); + let human = Keys::generate(); + let channel_id = Uuid::from_u128(42); + let root = mention(&human, channel_id, None, "first"); + let scope = thread_of(channel_id, &root); + h.departed_record(&scope, &ctx); + h.control(json!({"snapshot": h.snapshot_path(&scope)})); + let agent = h.agent(0, &[("RESUME_CAP", "1")]).await; + let mut agent = run_turn(agent, &ctx, follow_up(&scope, "after sleep")).await; + agent.acp.shutdown().await; + let seen = h.seen()[0].clone().unwrap(); + assert_eq!(seen.status.state, StatusState::Resumed); + assert_eq!(seen.status.reason, StatusReason::IdleSleep); + assert_eq!( + seen.status.history, None, + "read counts describe new sessions only" + ); +} + +#[tokio::test] +async fn failed_resume_reports_the_actual_fallback_reason() { + let h = Harness::new(); + let ctx = Arc::new(h.ctx()); + let channel_id = Uuid::from_u128(42); + let root = mention(&Keys::generate(), channel_id, None, "first"); + let scope = thread_of(channel_id, &root); + h.departed_record(&scope, &ctx); + h.control(json!({"snapshot": h.snapshot_path(&scope)})); + let agent = h + .agent(0, &[("RESUME_CAP", "1"), ("RESUME_ERROR", "1")]) + .await; + let mut agent = run_turn(agent, &ctx, follow_up(&scope, "after sleep")).await; + agent.acp.shutdown().await; + let seen = h.seen()[0].clone().unwrap(); + assert_eq!(seen.status.state, StatusState::New); + assert_eq!( + seen.status.reason, + StatusReason::ResumeFailed("not_found".into()) + ); +} + +#[test] +fn fallback_codes_outside_the_allowlist_become_unknown() { + use crate::session_store::SessionReason; + let wire = |code: &str| status_reason(&SessionReason::ResumeFailed(code.into())).as_wire(); + for known in [ + "not_persisted", + "store_unreadable", + "store_busy", + "not_found", + "timeout", + "protocol", + "rpc_-32603", + "rpc_7", + "rpc_-9223372036854775808", + ] { + assert_eq!(wire(known), format!("resume_failed:{known}")); + } + for leaked in [ + "Transcript /home/u/x.jsonl: EACCES", + "transcript_eacces", + "transcript__home_u_x_jsonl__eacces", + "rpc_", + "rpc_-", + "rpc_12a", + "rpc_--1", + "rpc_12345678901234567890", + "NOT_FOUND", + "not_found ", + "", + ] { + assert_eq!(wire(leaked), "resume_failed:unknown", "{leaked:?}"); + } + assert_eq!( + status_reason(&SessionReason::from(format!( + "resume_failed:{}", + "x".repeat(50) + ))) + .as_wire(), + "resume_failed:unknown", + "an unrecognised stored wire string never reaches the tag" + ); + assert_eq!( + status_reason(&SessionReason::IdleSleep), + StatusReason::IdleSleep + ); +} + +// ---- usage routing on one worker ---- + +#[tokio::test] +async fn interleaved_sessions_on_one_worker_keep_their_own_occupancy() { + let h = Harness::new(); + let ctx = Arc::new(h.ctx()); + let channel_id = Uuid::from_u128(42); + let human = Keys::generate(); + let root_a = mention(&human, channel_id, None, "thread A"); + let root_b = mention(&human, channel_id, None, "thread B"); + let (a, b) = ( + thread_of(channel_id, &root_a), + thread_of(channel_id, &root_b), + ); + let agent = h.agent(0, &[]).await; + + h.control(json!({"usage": [["SELF", 64, 100]]})); + let agent = run_turn(agent, &ctx, batch(&a, root_a)).await; + h.control(json!({"usage": [["SELF", 81, 100]]})); + let agent = run_turn(agent, &ctx, batch(&b, root_b)).await; + assert_eq!(percent(&h.snapshot(&a).unwrap()), Some(64)); + assert_eq!(percent(&h.snapshot(&b).unwrap()), Some(81)); + + // A late update of A during A's own turn never touches B. + h.control(json!({"usage": [["SELF", 70, 100], ["unknown-session", 99, 100]]})); + let agent = run_turn(agent, &ctx, follow_up(&a, "A again")).await; + assert_eq!(percent(&h.snapshot(&a).unwrap()), Some(70)); + assert_eq!(percent(&h.snapshot(&b).unwrap()), Some(81)); + + // A report for A's session during B's turn belongs to A, not to B. + let sid_a = agent.state.sessions[&a].clone(); + h.control(json!({"usage": [[sid_a, 12, 100]]})); + let mut agent = run_turn(agent, &ctx, follow_up(&b, "B again")).await; + agent.acp.shutdown().await; + assert_eq!(percent(&h.snapshot(&a).unwrap()), Some(12)); + assert_eq!(percent(&h.snapshot(&b).unwrap()), Some(81)); +} + +#[tokio::test] +async fn heartbeat_turn_never_touches_scope_status() { + let h = Harness::new(); + let ctx = Arc::new(h.ctx()); + let agent = h.agent(0, &[]).await; + let (agent, scope, _) = first_turn(&h, &ctx, agent, "first").await; + let before = std::fs::read(h.snapshot_path(&scope)).unwrap(); + let files_before = std::fs::read_dir(h.status.dir()).unwrap().count(); + + h.control(json!({"usage": [["SELF", 99, 100]]})); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + run_prompt_task( + agent, + None, + Some("heartbeat".into()), + ctx.clone(), + tx, + None, + "heartbeat".into(), + ) + .await; + let result = rx.recv().await.unwrap(); + assert!(matches!(result.outcome, PromptOutcome::Ok(_))); + let mut agent = result.agent; + agent.acp.shutdown().await; + assert_eq!(std::fs::read(h.snapshot_path(&scope)).unwrap(), before); + assert_eq!( + std::fs::read_dir(h.status.dir()).unwrap().count(), + files_before + ); +} + +// ---- lifecycle ---- + +#[tokio::test] +async fn rotate_during_a_delayed_turn_retires_before_late_usage() { + let h = Harness::new(); + let ctx = Arc::new(h.ctx()); + let channel_id = Uuid::from_u128(42); + let root = mention(&Keys::generate(), channel_id, None, "first"); + let scope = thread_of(channel_id, &root); + h.control(json!({"delay": 0.8, "usage": [["SELF", 64, 100]]})); + let agent = h.agent(0, &[]).await; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let turn = tokio::spawn(run_prompt_task( + agent, + Some(batch(&scope, root)), + None, + ctx.clone(), + tx, + None, + "turn".into(), + )); + let deadline = tokio::time::Instant::now() + Duration::from_secs(60); + while !h.methods().contains(&"session/prompt".to_owned()) { + assert!( + tokio::time::Instant::now() < deadline, + "prompt never started: {:?}", + h.methods() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + let current = h.snapshot(&scope).expect("published before the prompt"); + retire_scope(ctx.session_status.as_ref(), &scope); + turn.await.unwrap(); + let mut agent = rx.recv().await.unwrap().agent; + agent.acp.shutdown().await; + assert!( + h.snapshot(&scope).is_none(), + "late usage never recreates it" + ); + assert!(!lock_is_held(&h.generation_lock(¤t))); +} + +#[tokio::test] +async fn channel_removal_retires_every_scope_of_that_channel() { + let h = Harness::new(); + let ctx = Arc::new(h.ctx()); + let human = Keys::generate(); + let removed = Uuid::from_u128(42); + let kept = Uuid::from_u128(43); + let roots: Vec<_> = [(removed, "a"), (removed, "b"), (kept, "c")] + .into_iter() + .map(|(channel_id, text)| { + let root = mention(&human, channel_id, None, text); + (thread_of(channel_id, &root), root) + }) + .collect(); + let scopes: Vec<_> = roots.iter().map(|(scope, _)| scope.clone()).collect(); + let mut agent = h.agent(0, &[]).await; + for (scope, root) in roots { + agent = run_turn(agent, &ctx, batch(&scope, root)).await; + } + agent.acp.shutdown().await; + retire_channel(ctx.session_status.as_ref(), removed); + assert!(h.snapshot(&scopes[0]).is_none()); + assert!(h.snapshot(&scopes[1]).is_none()); + assert!(h.snapshot(&scopes[2]).is_some()); +} + +#[tokio::test] +async fn pool_sleep_retires_status_and_wake_starts_a_new_transition() { + let h = Harness::new(); + let ctx = Arc::new(h.ctx()); + let agent = h.agent(0, &[("RESUME_CAP", "1")]).await; + let (agent, scope, _) = first_turn(&h, &ctx, agent, "first").await; + let before = h.snapshot(&scope).unwrap(); + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + pool.depart_idle_sessions(Departure::IdleSleep); + assert!(h.snapshot(&scope).is_none()); + assert!(!lock_is_held(&h.generation_lock(&before))); + let mut slept = pool.agents_mut()[0].take().unwrap(); + slept.acp.shutdown().await; + + h.control(json!({"snapshot": h.snapshot_path(&scope)})); + let woken = h.agent(0, &[("RESUME_CAP", "1")]).await; + let mut woken = run_turn(woken, &ctx, follow_up(&scope, "after sleep")).await; + woken.acp.shutdown().await; + let seen = h.seen().last().cloned().flatten().unwrap(); + assert_eq!(seen.status.state, StatusState::Resumed); + assert_eq!(seen.status.reason, StatusReason::IdleSleep); + assert_ne!(seen.status.transition_id, before.status.transition_id); + assert_eq!(seen.status.occupancy, None); +} + +#[tokio::test] +async fn worker_crash_retires_status_before_the_replacement_prompt() { + let h = Harness::new(); + let ctx = Arc::new(h.ctx()); + let agent = h.agent(0, &[("RESUME_CAP", "1")]).await; + let (mut agent, scope, _) = first_turn(&h, &ctx, agent, "first").await; + let before = h.snapshot(&scope).unwrap(); + agent.state.invalidate_all(); + agent.acp.shutdown().await; + assert!(h.snapshot(&scope).is_none()); + assert!(!lock_is_held(&h.generation_lock(&before))); + + h.control(json!({"snapshot": h.snapshot_path(&scope)})); + let replacement = h.agent(0, &[("RESUME_CAP", "1")]).await; + let mut replacement = run_turn(replacement, &ctx, follow_up(&scope, "again")).await; + replacement.acp.shutdown().await; + let seen = h.seen().last().cloned().flatten().unwrap(); + assert_eq!(seen.status.state, StatusState::Resumed); + assert_eq!(seen.status.reason, StatusReason::WorkerCrash); + assert_ne!(seen.status.transition_id, before.status.transition_id); +} + +#[tokio::test] +async fn successor_acquisition_revokes_the_old_generation_before_its_prompt() { + let h = Harness::new(); + let ctx = Arc::new(h.ctx()); + let first = h.agent(0, &[]).await; + let (mut first, scope, _) = first_turn(&h, &ctx, first, "first").await; + let old = h.snapshot(&scope).unwrap(); + + h.control(json!({ + "snapshot": h.snapshot_path(&scope), + "probe": h.generation_lock(&old), + "usage": [["SELF", 20, 100]], + })); + let successor = h.agent(1, &[("SID_PREFIX", "other")]).await; + let mut successor = run_turn(successor, &ctx, follow_up(&scope, "forked")).await; + successor.acp.shutdown().await; + let seen = h.lines("seen.ndjson"); + assert_ne!( + seen.last().unwrap()["probe"], + "held", + "the predecessor generation is revoked before the successor prompt" + ); + let new = h.snapshot(&scope).unwrap(); + assert_ne!(new.status.transition_id, old.status.transition_id); + let successor_bytes = std::fs::read(h.snapshot_path(&scope)).unwrap(); + + // Late callbacks and the stale owner's invalidation leave the successor alone. + first.state.status[&scope].record_occupancy(99, 100); + first.state.invalidate_scope(&scope); + first.acp.shutdown().await; + assert_eq!( + std::fs::read(h.snapshot_path(&scope)).unwrap(), + successor_bytes + ); + assert!(lock_is_held(&h.generation_lock(&new))); +} + +#[tokio::test] +async fn status_write_failure_never_fails_the_turn() { + let h = Harness::new(); + let ctx = Arc::new(h.ctx()); + let channel_id = Uuid::from_u128(42); + let root = mention(&Keys::generate(), channel_id, None, "first"); + let scope = thread_of(channel_id, &root); + let dir = h.status.dir().to_path_buf(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap(); + let agent = h.agent(0, &[]).await; + let mut agent = run_turn(agent, &ctx, batch(&scope, root)).await; + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + agent.acp.shutdown().await; + assert!(h.snapshot(&scope).is_none()); + let LoadOutcome::Found(record) = h.store.load(&scope) else { + panic!("durable record missing"); + }; + assert_eq!(record.live.unwrap().session_id, "new-1"); +} + +// ---- final-context history coverage ---- + +#[derive(Clone, Default)] +struct RelayScript { + events: Vec, + fail_history: bool, + fail_scan: bool, +} + +struct Relay { + history_requests: Arc, + server: tokio::task::JoinHandle<()>, +} + +async fn read_request(socket: &mut tokio::net::TcpStream) -> Option<(String, String)> { + use tokio::io::AsyncReadExt; + let mut buf = Vec::new(); + let mut chunk = [0u8; 8192]; + loop { + let n = socket.read(&mut chunk).await.ok()?; + if n == 0 { + return None; + } + buf.extend_from_slice(&chunk[..n]); + let Some(end) = buf.windows(4).position(|w| w == b"\r\n\r\n") else { + continue; + }; + let head = String::from_utf8_lossy(&buf[..end]).to_string(); + let length = head + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok())? + }) + .unwrap_or(0); + while buf.len() < end + 4 + length { + let n = socket.read(&mut chunk).await.ok()?; + if n == 0 { + return None; + } + buf.extend_from_slice(&chunk[..n]); + } + let path = head.split_whitespace().nth(1)?.to_owned(); + let body = String::from_utf8_lossy(&buf[end + 4..end + 4 + length]).to_string(); + return Some((path, body)); + } +} + +async fn relay(script: RelayScript, ctx: &mut PromptContext, channel_id: Uuid) -> Relay { + use tokio::io::AsyncWriteExt; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let history_requests = Arc::new(AtomicUsize::new(0)); + let counter = history_requests.clone(); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let Some((path, body)) = read_request(&mut socket).await else { + continue; + }; + // Working/reaction cleanup is fire-and-forget and may arrive after + // the turn returns. Count history reads, not those cosmetic calls + // or unrelated channel/profile metadata lookups. + let filters: Vec = serde_json::from_str(&body).unwrap_or_default(); + let reads_history = path == "/query" + && filters.iter().any(|filter| { + filter.get("ids").is_some() + || filter["kinds"].as_array().is_some_and(|kinds| { + kinds + .iter() + .any(|kind| matches!(kind.as_u64(), Some(9 | 40002))) + }) + }); + if reads_history || path == "/count" { + counter.fetch_add(1, Ordering::SeqCst); + } + let scan = body.contains("depth_limit"); + let history = path == "/query" && body.contains("\"#e\"") && !scan; + let (status, body) = if (scan && script.fail_scan) || (history && script.fail_history) { + ("400 Bad Request", "{}".to_owned()) + } else if path == "/count" { + let replies = script.events.len().saturating_sub(1); + ("200 OK", json!({ "count": replies }).to_string()) + } else { + ("200 OK", serde_json::to_string(&script.events).unwrap()) + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + ctx.rest_client.base_url = base_url.clone(); + ctx.channel_info = ChannelInfoResolver::new( + HashMap::from([( + channel_id, + crate::relay::ChannelInfo { + name: "thread".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + RestClient { + http: reqwest::Client::new(), + base_url, + keys: ctx.agent_keys.clone(), + auth_tag_json: None, + }, + ); + Relay { + history_requests, + server, + } +} + +fn reply( + keys: &Keys, + channel_id: Uuid, + root_id: &str, + at: u64, + text: &str, + mention: Option<&Keys>, +) -> nostr::Event { + let mut tags = vec![ + Tag::parse(["h", channel_id.to_string().as_str()]).unwrap(), + Tag::parse(["e", root_id, "", "reply"]).unwrap(), + ]; + if let Some(agent) = mention { + tags.push(Tag::parse(["p", agent.public_key().to_hex().as_str()]).unwrap()); + } + EventBuilder::new(Kind::Custom(9), text) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(at)) + .sign_with_keys(keys) + .unwrap() +} + +fn root_event(keys: &Keys, channel_id: Uuid, mention: Option<&Keys>) -> nostr::Event { + let mut tags = vec![Tag::parse(["h", channel_id.to_string().as_str()]).unwrap()]; + if let Some(agent) = mention { + tags.push(Tag::parse(["p", agent.public_key().to_hex().as_str()]).unwrap()); + } + EventBuilder::new(Kind::Custom(9), "thread opener") + .tags(tags) + .custom_created_at(nostr::Timestamp::from(1_000)) + .sign_with_keys(keys) + .unwrap() +} + +/// Run one cold-start turn of `trigger` against a scripted relay and return +/// the history coverage the provider prompt saw plus the history request count. +async fn cold_start( + status: bool, + limit: u32, + script: impl FnOnce(&Keys) -> (RelayScript, nostr::Event, nostr::Event), +) -> (Option, usize, String) { + let h = Harness::new(); + let (script, root, trigger) = script(&h.agent_keys); + let channel_id = Uuid::from_u128(42); + let scope = thread_of(channel_id, &root); + let mut ctx = h.ctx(); + ctx.context_message_limit = limit; + if !status { + ctx.session_status = None; + } + let relay = relay(script, &mut ctx, channel_id).await; + h.control(json!({"snapshot": h.snapshot_path(&scope)})); + let agent = h.agent(0, &[]).await; + let mut agent = run_turn(agent, &Arc::new(ctx), batch(&scope, trigger)).await; + agent.acp.shutdown().await; + relay.server.abort(); + let seen = h.lines("seen.ndjson"); + let prompt_snapshot = seen[0]["snapshot"].as_str().map(str::to_owned); + let history = prompt_snapshot + .map(|text| parse_snapshot(text.as_bytes()).unwrap()) + .and_then(|envelope| envelope.status.history); + let methods = h.methods().join(","); + ( + history, + relay.history_requests.load(Ordering::SeqCst), + methods, + ) +} + +fn coverage( + read: Option, + total: Option, + exact: bool, + incomplete: bool, +) -> HistoryCoverage { + HistoryCoverage { + read, + total, + exact, + incomplete, + } +} + +/// Root, assignment, the agent's previous answer and a new trigger, with a +/// one-message window: the assignment is pinned and the answer evicted. +fn pinned_assignment(agent: &Keys) -> (RelayScript, nostr::Event, nostr::Event) { + let human = Keys::generate(); + let channel_id = Uuid::from_u128(42); + let root = root_event(&human, channel_id, None); + let root_id = root.id.to_hex(); + let assignment = reply( + &human, + channel_id, + &root_id, + 2_000, + "ASSIGNMENT", + Some(agent), + ); + let answer = reply(agent, channel_id, &root_id, 2_001, "ANSWER", None); + let trigger = reply(&human, channel_id, &root_id, 2_002, "continue", Some(agent)); + let script = RelayScript { + events: vec![root.clone(), assignment, answer, trigger.clone()], + ..Default::default() + }; + (script, root, trigger) +} + +#[tokio::test] +async fn pinned_assignment_outside_the_window_counts_supplied_events_once() { + let (history, _, _) = cold_start(true, 1, pinned_assignment).await; + assert_eq!(history, Some(coverage(Some(3), Some(4), false, true))); +} + +#[tokio::test] +async fn counts_need_no_extra_history_walk() { + let (_, with_status, _) = cold_start(true, 1, pinned_assignment).await; + let (history, without_status, _) = cold_start(false, 1, pinned_assignment).await; + assert_eq!(history, None); + // One batched context query, one count and one assignment scan. + assert_eq!(with_status, 3); + assert_eq!(with_status, without_status); +} + +#[tokio::test] +async fn root_equal_to_trigger_is_an_exact_single_message() { + let (history, _, _) = cold_start(true, 12, |agent| { + let root = root_event(&Keys::generate(), Uuid::from_u128(42), Some(agent)); + let script = RelayScript { + events: vec![root.clone()], + ..Default::default() + }; + (script, root.clone(), root) + }) + .await; + assert_eq!(history, Some(coverage(Some(1), Some(1), true, false))); +} + +fn root_assignment(agent: &Keys, fail_scan: bool) -> (RelayScript, nostr::Event, nostr::Event) { + let human = Keys::generate(); + let channel_id = Uuid::from_u128(42); + let root = root_event(&human, channel_id, (!fail_scan).then_some(agent)); + let root_id = root.id.to_hex(); + let chatter = reply(&human, channel_id, &root_id, 2_000, "chatter", None); + let trigger = reply(&human, channel_id, &root_id, 2_001, "continue", Some(agent)); + let script = RelayScript { + events: vec![root.clone(), chatter, trigger.clone()], + fail_scan, + ..Default::default() + }; + (script, root, trigger) +} + +#[tokio::test] +async fn root_that_is_the_assignment_is_counted_once() { + let (history, _, _) = cold_start(true, 12, |agent| root_assignment(agent, false)).await; + assert_eq!(history, Some(coverage(Some(3), Some(3), true, false))); +} + +#[tokio::test] +async fn failed_first_mention_scan_is_never_exact() { + let (history, _, _) = cold_start(true, 12, |agent| root_assignment(agent, true)).await; + assert_eq!(history, Some(coverage(Some(3), Some(3), false, true))); +} + +#[tokio::test] +async fn missing_history_response_reports_possible_omission() { + let (history, _, _) = cold_start(true, 12, |agent| { + let (mut script, root, trigger) = root_assignment(agent, false); + script.fail_history = true; + (script, root, trigger) + }) + .await; + assert_eq!(history, Some(coverage(Some(1), None, false, true))); +} + +fn message(id: &str) -> ContextMessage { + ContextMessage { + event_id: id.to_owned(), + pubkey: "p".into(), + timestamp: "t".into(), + content: "c".into(), + } +} + +fn thread_context( + ids: &[&str], + total: usize, + root_present: bool, + truncated: bool, +) -> ConversationContext { + ConversationContext::Thread { + messages: ids.iter().copied().map(message).collect(), + total, + root_present, + truncated, + } +} + +fn triggers(ids: &[&str]) -> std::collections::HashSet { + ids.iter().map(|id| id.to_string()).collect() +} + +fn thread_scope() -> SessionScope { + SessionScope::Thread { + channel_id: Uuid::from_u128(1), + root_event_id: "a".repeat(64), + } +} + +fn conversation_scope() -> SessionScope { + SessionScope::Conversation { + channel_id: Uuid::from_u128(1), + } +} + +#[test] +fn legacy_messages_without_ids_omit_the_read_count() { + let fetched = thread_context(&["r", ""], 2, true, false); + let captured = FetchedHistory::capture(true, Some(&fetched)); + let history = history_coverage( + &captured, + Some(&fetched), + &triggers(&["t"]), + false, + &thread_scope(), + false, + ); + assert_eq!(history.read, None, "never invent identities"); + assert!(!history.exact); +} + +#[test] +fn read_counts_unique_supplied_ids_not_the_fetch_window() { + // A full window from a complete-looking fetch is still only a lower bound. + let fetched = thread_context(&["r", "a", "b", "t"], 9, true, true); + let supplied = thread_context(&["r", "a", "b"], 9, true, true); + let history = history_coverage( + &FetchedHistory::capture(true, Some(&fetched)), + Some(&supplied), + &triggers(&["t", "a"]), + false, + &thread_scope(), + false, + ); + assert_eq!(history, coverage(Some(4), Some(9), false, true)); +} + +#[test] +fn delivered_context_omitted_from_the_prompt_is_not_read() { + let fetched = thread_context(&["r", "old", "t"], 3, true, false); + let supplied = thread_context(&["r"], 3, true, true); + let history = history_coverage( + &FetchedHistory::capture(true, Some(&fetched)), + Some(&supplied), + &triggers(&["t"]), + false, + &thread_scope(), + false, + ); + assert_eq!(history, coverage(Some(2), Some(3), false, true)); +} + +#[test] +fn dm_history_is_exact_only_when_untruncated() { + let dm = |truncated| ConversationContext::Dm { + messages: vec![message("a"), message("t")], + total: if truncated { 3 } else { 2 }, + truncated, + }; + let supplied = ConversationContext::Dm { + messages: vec![message("a")], + total: 2, + truncated: false, + }; + let exact = history_coverage( + &FetchedHistory::capture(true, Some(&dm(false))), + Some(&supplied), + &triggers(&["t"]), + false, + &conversation_scope(), + true, + ); + assert_eq!(exact, coverage(Some(2), Some(2), true, false)); + let bound = history_coverage( + &FetchedHistory::capture(true, Some(&dm(true))), + Some(&supplied), + &triggers(&["t"]), + false, + &conversation_scope(), + true, + ); + assert_eq!(bound, coverage(Some(2), Some(3), false, true)); +} + +#[test] +fn a_thread_total_never_describes_a_channel_conversation() { + let fetched = thread_context(&["r", "t"], 2, true, false); + let history = history_coverage( + &FetchedHistory::capture(true, Some(&fetched)), + Some(&thread_context(&["r"], 2, true, false)), + &triggers(&["t"]), + false, + &conversation_scope(), + false, + ); + assert_eq!(history, coverage(Some(2), None, false, false)); +} + +#[test] +fn triggers_outside_the_fetched_context_drop_the_total() { + let fetched = thread_context(&["r"], 1, true, false); + let history = history_coverage( + &FetchedHistory::capture(true, Some(&fetched)), + Some(&fetched), + &triggers(&["t1", "t2"]), + false, + &thread_scope(), + false, + ); + assert_eq!(history.total, None, "read may never exceed a total"); + assert!(!history.exact); +} + +#[test] +fn unrequested_history_makes_no_completeness_claim() { + let history = history_coverage( + &FetchedHistory::capture(false, None), + None, + &triggers(&["t"]), + false, + &thread_scope(), + false, + ); + assert_eq!(history, coverage(Some(1), None, false, false)); +} + +// ---- environment wiring ---- + +/// A minimal ACP adapter that records the status directory it was given. +fn probe_adapter(dir: &Path) -> (PathBuf, PathBuf) { + let log = dir.join("probe.log"); + let script = dir.join("probe-acp"); + std::fs::write( + &script, + format!( + "#!/usr/bin/env python3\n\ + import json, os, sys\n\ + open({log:?}, 'a').write(os.environ.get('{env}', '') + '\\n')\n\ + for line in sys.stdin:\n\ + \x20 req = json.loads(line)\n\ + \x20 if 'id' in req:\n\ + \x20 print(json.dumps({{'jsonrpc': '2.0', 'id': req['id'], 'result': {{'protocolVersion': 1}}}}), flush=True)\n", + log = log.display().to_string(), + env = crate::session_status::SESSION_STATUS_DIR_ENV, + ), + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + (script, log) +} + +#[tokio::test] +async fn every_spawn_path_and_managed_mcp_get_the_current_status_dir() { + use crate::session_status::SESSION_STATUS_DIR_ENV; + let root = tempfile::tempdir().unwrap(); + let (script, log) = probe_adapter(root.path()); + let mut config = crate::build_mcp_servers_tests::test_config(); + config.session_store_dir = Some(root.path().join("sessions")); + config.agent_command = script.display().to_string(); + config.agent_args = vec![]; + let mut runtime = crate::runtime::AgentRuntime::prepare(config).unwrap(); + let status = runtime.enable_session_status().expect("status runtime"); + let dir = status.dir().display().to_string(); + + // Lazy start and idle rebuild initialize the pool from the startup. + let mut pool = crate::initialize_agent_pool(&runtime.startup(None), None) + .await + .unwrap(); + for slot in pool.agents_mut() { + if let Some(mut agent) = slot.take() { + agent.acp.shutdown().await; + } + } + // Refill and crash respawn spawn from the configured environment. + let config = runtime.config(); + let (mut respawned, _, _) = crate::spawn_and_init( + &config.agent_command, + &config.agent_args, + &config.persona_env_vars, + config.has_generated_codex_config, + 1, + None, + ) + .await + .unwrap(); + respawned.shutdown().await; + let seen: Vec = std::fs::read_to_string(&log) + .unwrap() + .lines() + .map(str::to_owned) + .collect(); + assert_eq!(seen, [dir.clone(), dir.clone()]); + + // The managed MCP server receives it explicitly, on new and resumed sessions. + let rest = RestClient { + http: reqwest::Client::new(), + base_url: "http://127.0.0.1:0".into(), + keys: config.keys.clone(), + auth_tag_json: None, + }; + let prompt_ctx = runtime + .prompt_context( + rest, + HashMap::new(), + crate::runtime::SessionMode::Conversation, + ) + .unwrap(); + assert_eq!( + prompt_ctx + .session_status + .as_ref() + .map(|s| s.dir().to_path_buf()), + Some(status.dir().to_path_buf()) + ); + let h = Harness::new(); + let mut ctx = h.ctx(); + ctx.mcp_servers = prompt_ctx.mcp_servers.clone(); + let ctx = Arc::new(ctx); + let channel_id = Uuid::from_u128(42); + let root_event = mention(&Keys::generate(), channel_id, None, "first"); + let scope = thread_of(channel_id, &root_event); + let agent = h.agent(0, &[("RESUME_CAP", "1")]).await; + let mut agent = run_turn(agent, &ctx, batch(&scope, root_event)).await; + agent.state.depart_all(Departure::IdleSleep); + agent.acp.shutdown().await; + let woken = h.agent(0, &[("RESUME_CAP", "1")]).await; + let mut woken = run_turn(woken, &ctx, follow_up(&scope, "again")).await; + woken.acp.shutdown().await; + let calls = h.lines("params.ndjson"); + let methods: Vec<_> = calls + .iter() + .map(|c| c["method"].as_str().unwrap()) + .collect(); + assert_eq!(methods, ["session/new", "session/resume"]); + for call in &calls { + let env = call["params"]["mcpServers"][0]["env"].as_array().unwrap(); + let forwarded: Vec<_> = env + .iter() + .filter(|entry| entry["name"] == SESSION_STATUS_DIR_ENV) + .map(|entry| entry["value"].as_str().unwrap()) + .collect(); + assert_eq!(forwarded, [dir.as_str()], "{}", call["method"]); + } +} + +#[path = "session_visibility_policy_tests.rs"] +mod policy; diff --git a/crates/buzz-acp/src/pool/snapshot_helper_tests.rs b/crates/buzz-acp/src/pool/snapshot_helper_tests.rs new file mode 100644 index 00000000000..a84776b38ca --- /dev/null +++ b/crates/buzz-acp/src/pool/snapshot_helper_tests.rs @@ -0,0 +1,381 @@ +//! Exercise actual late snapshot shells through prompt completion, return and poll. +use super::*; + +const SNAPSHOT: &str = include_str!("../process_tree/codex_bash_snapshot.txt"); +const SERVER: &str = r#" +import json, os, pathlib, signal, subprocess, sys, threading, time +root = pathlib.Path.cwd() +child = None +stderr_tail = bytearray() +stderr_lock = threading.Lock() +stderr_eof = threading.Event() +stderr_done = threading.Event() +stderr_read_error = None +STDERR_LIMIT = 16 * 1024 + +def capture_stderr(stream): + global stderr_read_error + try: + while True: + chunk = stream.buffer.read1(4096) + if not chunk: + stderr_eof.set() + return + with stderr_lock: + stderr_tail.extend(chunk) + del stderr_tail[:-STDERR_LIMIT] + except Exception as error: + with stderr_lock: + stderr_read_error = f'{type(error).__name__}: {error}'[:256] + finally: + try: + stream.close() + except Exception as error: + with stderr_lock: + if stderr_read_error is None: + stderr_read_error = f'{type(error).__name__}: {error}'[:256] + stderr_done.set() + +def marker(name): + try: + with (root / name).open('rb') as source: + value = source.read(64) + if name == 'ready' and not value.endswith(b'\n'): + return '' + return value.decode('ascii', errors='replace').strip() + except FileNotFoundError: + return '' + +def process_details(pid): + # Linux-only detail is optional; the fixture also runs on macOS. + details = {'pid': pid} + for name, limit in [('stat', 1024), ('cmdline', 1024), (f'task/{pid}/children', 1024)]: + try: + with pathlib.Path(f'/proc/{pid}/{name}').open('rb') as source: + details[name] = source.read(limit).replace(b'\0', b' ').decode(errors='replace') + except OSError: + pass + return details + +def diagnostics(action): + with stderr_lock: + tail = bytes(stderr_tail).decode(errors='replace') + read_error = stderr_read_error + details = {'action': action, 'server_pid': os.getpid(), 'ready_pid': marker('ready'), + 'work_pid': marker('child'), 'changed': marker('changed'), 'stderr_eof': stderr_eof.is_set(), + 'stderr_done': stderr_done.is_set(), 'stderr_read_error': read_error} + if child is not None: + details['child'] = process_details(child.pid) + details['child_exit'] = child.poll() + if details['work_pid'].isdigit(): + details['work'] = process_details(int(details['work_pid'])) + # Test runners retain this captured output for a later Rust assertion failure, + # including the child-exit collection assertion after the last RPC returned. + print('snapshot-fixture ' + json.dumps(details), file=sys.stderr, flush=True) + print('snapshot-fixture stderr-tail:\n' + tail, file=sys.stderr, flush=True) + +for line in sys.stdin: + action = line.strip() + try: + if action == 'start': + script = (root / 'snapshot').read_text() + env = dict(os.environ, BASH_ENV=str(root / 'startup'), BUZZ_TEST_SNAPSHOT_COMMAND=script) + env.pop('BUZZ_TEST_SNAPSHOT_GATE_PID', None) + (root / 'ready').unlink(missing_ok=True) + child = subprocess.Popen(['/bin/bash', '-lc', script], env=env, + stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) + deadline = time.monotonic() + 5 + threading.Thread(target=capture_stderr, args=(child.stderr,), daemon=True).start() + while True: + ready = marker('ready') + if ready: + assert ready == str(child.pid), f'wrong startup PID: {ready}; expected {child.pid}' + break + assert time.monotonic() < deadline, 'shell startup did not reach barrier' + assert child.poll() is None, 'shell exited before barrier' + time.sleep(.01) + elif action == 'stop': + child.stdin.write('\n') + child.stdin.flush() + child.wait(timeout=5) + elif action == 'advance': + child.stdin.write('\n') + child.stdin.flush() + deadline = time.monotonic() + 5 + while not (root / 'changed').exists(): + assert time.monotonic() < deadline, 'snapshot did not exec ordinary work' + time.sleep(.01) + elif action == 'stop-child': + os.kill(int((root / 'child').read_text()), signal.SIGTERM) + except Exception: + diagnostics(action) + raise + diagnostics(action) + print('ready', flush=True) +"#; + +const STARTUP_GATE: &str = r#" +BUZZ_TEST_SNAPSHOT_PID=${BASHPID:-$$} +printf 'snapshot-startup phase=entry pid=%s ppid=%s subshell=%s\n' "$BUZZ_TEST_SNAPSHOT_PID" "$PPID" "${BASH_SUBSHELL:-0}" >&2 +if [[ ${BASH_EXECUTION_STRING-} != "${BUZZ_TEST_SNAPSHOT_COMMAND-}" ]]; then + printf 'snapshot-startup phase=skip-command pid=%s\n' "$BUZZ_TEST_SNAPSHOT_PID" >&2 + return +fi +if [[ ${BASH_SUBSHELL:-0} != 0 ]]; then + printf 'snapshot-startup phase=skip-subshell pid=%s\n' "$BUZZ_TEST_SNAPSHOT_PID" >&2 + return +fi +if [[ ${BUZZ_TEST_SNAPSHOT_GATE_PID-} == "$BUZZ_TEST_SNAPSHOT_PID" ]]; then + printf 'snapshot-startup phase=skip-reentry pid=%s\n' "$BUZZ_TEST_SNAPSHOT_PID" >&2 + return +fi +BUZZ_TEST_SNAPSHOT_GATE_PID=$BUZZ_TEST_SNAPSHOT_PID +"#; + +const ADAPTER: &str = r#" +import json, pathlib, subprocess, sys +server = subprocess.Popen([sys.argv[1], 'app-server'], cwd=sys.argv[2], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) +def action(value): + server.stdin.write(value + '\n') + server.stdin.flush() + assert server.stdout.readline().strip() == 'ready' +for line in sys.stdin: + req = json.loads(line) + method = req['method'] + if method == 'initialize': + result = {'protocolVersion': 1, 'agentCapabilities': {}} + elif method == 'session/new': + if sys.argv[3] == 'setup': action('start') + result = {'sessionId': 'snapshot-session'} + elif method == 'session/prompt': + action(req['params']['prompt'][0]['text']) + result = {'stopReason': 'end_turn'} + else: + continue + print(json.dumps({'jsonrpc': '2.0', 'id': req['id'], 'result': result}), flush=True) +"#; + +async fn snapshot_worker( + dir: &tempfile::TempDir, + with_child: bool, + during_setup: bool, +) -> OwnedAgent { + configured_worker(dir, with_child, during_setup, SNAPSHOT, "codex").await +} + +async fn configured_worker( + dir: &tempfile::TempDir, + with_child: bool, + during_setup: bool, + script: &str, + parent: &str, +) -> OwnedAgent { + use std::os::unix::fs::PermissionsExt; + let python = std::process::Command::new("python3") + .args(["-c", "import sys; print(sys.executable)"]) + .output() + .unwrap(); + assert!(python.status.success()); + let python = String::from_utf8(python.stdout).unwrap(); + let executable = dir.path().join(if parent == "symlink" || parent == "exec" { + "codex" + } else { + parent + }); + if parent == "symlink" { + std::os::unix::fs::symlink(python.trim(), &executable).unwrap(); + } else { + std::fs::copy(python.trim(), &executable).unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + std::fs::write(dir.path().join("app-server"), SERVER).unwrap(); + std::fs::write(dir.path().join("snapshot"), script).unwrap(); + let startup = if parent == "exec" { + "trap 'unset BASH_ENV; exec /bin/bash -c \"printf changed > changed; read -r gate\"' EXIT\n" + } else if with_child { + "sleep 60 &\nprintf '%s' $! > child\n" + } else { + "" + }; + let startup = format!( + "{STARTUP_GATE}{startup}{}", + r#"printf 'snapshot-startup phase=read-entry pid=%s ppid=%s\n' "$BUZZ_TEST_SNAPSHOT_PID" "$PPID" >&2 +printf '%s\n' "$BUZZ_TEST_SNAPSHOT_PID" > ready +read -r gate +printf 'snapshot-startup phase=read-exit pid=%s status=%s\n' "$BUZZ_TEST_SNAPSHOT_PID" "$?" >&2 +"# + ); + std::fs::write(dir.path().join("startup"), startup).unwrap(); + let mut agent = worker(0).await; + agent.acp = AcpClient::spawn( + "python3", + &[ + "-u".into(), + "-c".into(), + ADAPTER.into(), + executable.to_string_lossy().into_owned(), + dir.path().to_string_lossy().into_owned(), + if during_setup { "setup" } else { "late" }.into(), + ], + &[], + false, + ) + .await + .unwrap(); + agent.acp.initialize().await.unwrap(); + agent + .acp + .session_new("/tmp", vec![], None, None) + .await + .unwrap(); + agent +} + +async fn action(agent: &mut OwnedAgent, value: &str) { + agent + .acp + .session_prompt_with_idle_timeout( + "snapshot-session", + value, + Duration::from_secs(10), + Duration::from_secs(15), + ) + .await + .unwrap(); +} + +fn remember(pool: &mut AgentPool, original: &SessionScope) -> OwnedAgent { + bind(pool, original, 0); + let mut agent = pool.try_claim(Some(original)).unwrap(); + agent + .state + .sessions + .insert(original.clone(), "snapshot-session".into()); + agent.remember_background_context( + &PromptSource::Channel(original.clone()), + None, + "snapshot-session", + ); + agent +} + +#[tokio::test] +async fn late_codex_snapshot_exit_does_not_enqueue_background_collection() { + let dir = tempfile::tempdir().unwrap(); + let agent = snapshot_worker(&dir, false, false).await; + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + let original = scope(); + let mut agent = remember(&mut pool, &original); + action(&mut agent, "start").await; + let _ = agent.background_work_running(&PromptSource::Channel(original.clone())); + pool.return_agent(agent); + action(pool.agents[0].as_mut().unwrap(), "stop").await; + let mut queue = EventQueue::new(DedupMode::Queue); + pool.queue_finished_background_work(&mut queue); + let invented = queue.has_pending_continuation(&original); + let retained = pool.has_runtime_hold(); + pool.agents[0].as_mut().unwrap().acp.shutdown().await; + assert!( + !invented, + "provider snapshot exit is not a user work completion" + ); + assert!(!retained, "snapshot bookkeeping must release the worker"); +} + +#[tokio::test] +async fn codex_snapshot_child_work_survives_late_and_session_capture() { + for during_setup in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let agent = snapshot_worker(&dir, true, during_setup).await; + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + let original = scope(); + let mut agent = remember(&mut pool, &original); + if !during_setup { + action(&mut agent, "start").await; + } + // Startup capture must also avoid adopting ordinary descendants of a snapshot. + agent.acp.capture_startup_children(); + assert!(agent.background_work_running(&PromptSource::Channel(original.clone()))); + pool.return_agent(agent); + let mut queue = EventQueue::new(DedupMode::Queue); + pool.queue_finished_background_work(&mut queue); + assert!(!queue.has_pending_continuation(&original)); + assert!(pool.try_claim(Some(&scope())).is_none()); + action(pool.agents[0].as_mut().unwrap(), "stop-child").await; + let deadline = tokio::time::Instant::now() + Duration::from_secs(3); + while !queue.has_pending_continuation(&original) { + pool.queue_finished_background_work(&mut queue); + assert!( + tokio::time::Instant::now() < deadline, + "child exit must collect while snapshot remains alive" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + let batch = queue.flush_next().unwrap(); + assert_eq!(batch.scope, original); + assert_eq!(batch.continuation.unwrap().session_id, "snapshot-session"); + assert!(queue.flush_next().is_none()); + action(pool.agents[0].as_mut().unwrap(), "stop").await; + pool.agents[0].as_mut().unwrap().acp.shutdown().await; + } +} + +#[tokio::test] +async fn ordinary_snapshot_lookalikes_still_collect_actual_work() { + let suffix = format!("{SNAPSHOT}\n: ordinary_work\n"); + let changed = SNAPSHOT.replace("declare -f", "declare -f; : ordinary_work"); + for (script, parent) in [ + ("printf '%s' '# Snapshot file'\n", "codex"), + (suffix.as_str(), "codex"), + (changed.as_str(), "codex"), + (SNAPSHOT, "ordinary-worker"), + (SNAPSHOT, "symlink"), + ] { + let dir = tempfile::tempdir().unwrap(); + let agent = configured_worker(&dir, false, false, script, parent).await; + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + let original = scope(); + let mut agent = remember(&mut pool, &original); + action(&mut agent, "start").await; + let running = agent.background_work_running(&PromptSource::Channel(original.clone())); + pool.return_agent(agent); + let retained = pool.has_runtime_hold(); + action(pool.agents[0].as_mut().unwrap(), "stop").await; + let mut queue = EventQueue::new(DedupMode::Queue); + pool.queue_finished_background_work(&mut queue); + pool.queue_finished_background_work(&mut queue); + let batch = queue.flush_next(); + let duplicate = queue.flush_next(); + pool.agents[0].as_mut().unwrap().acp.shutdown().await; + assert!( + running && retained, + "ordinary shell must retain its original worker: {parent}" + ); + let batch = batch.expect("ordinary shell exit must collect"); + assert_eq!(batch.scope, original); + assert_eq!(batch.continuation.unwrap().session_id, "snapshot-session"); + assert!(duplicate.is_none()); + } +} + +#[tokio::test] +async fn snapshot_pid_exec_into_ordinary_work_is_not_a_cached_resident() { + let dir = tempfile::tempdir().unwrap(); + let mut agent = configured_worker(&dir, false, false, SNAPSHOT, "exec").await; + action(&mut agent, "start").await; + let before = agent.acp.background_child_pids().unwrap(); + agent.acp.capture_startup_children(); + action(&mut agent, "advance").await; + let after = agent.acp.background_child_pids().unwrap(); + action(&mut agent, "stop").await; + agent.acp.shutdown().await; + assert!(before.is_empty(), "snapshot starts as bookkeeping"); + assert_eq!( + after.len(), + 1, + "same PID with ordinary argv must become work" + ); +} + +#[path = "snapshot_uncertainty_tests.rs"] +mod uncertainty; diff --git a/crates/buzz-acp/src/pool/snapshot_uncertainty_tests.rs b/crates/buzz-acp/src/pool/snapshot_uncertainty_tests.rs new file mode 100644 index 00000000000..d130e81b39f --- /dev/null +++ b/crates/buzz-acp/src/pool/snapshot_uncertainty_tests.rs @@ -0,0 +1,204 @@ +//! Inject only OS census/probe results; exercise production ACP adoption and hold/poll. +use super::*; +use crate::process_tree::ProcessTree; + +const SHELL: u32 = 990001; +const WORK: u32 = 990002; + +fn census(worker: u32, parent: u32, identities: Option<&str>, child: bool) -> ProcessTree { + let script = SNAPSHOT.split_whitespace().collect::>().join(" "); + let mut rows = format!("{parent} {worker} S /fixture/codex app-server\n{SHELL} {parent} S /bin/bash -lc {script}\n"); + if child { + rows.push_str(&format!( + "{WORK} {SHELL} S /usr/bin/python3 ordinary-task.py\n" + )); + } + ProcessTree::from_snapshot_with_identities(&rows, identities) +} + +fn empty(worker: u32, parent: u32) -> ProcessTree { + ProcessTree::from_snapshot_with_identities( + &format!("{parent} {worker} S /fixture/codex app-server\n"), + None, + ) +} + +fn parent(agent: &OwnedAgent) -> (u32, u32) { + let worker = agent.acp.process_id().unwrap(); + let tree = crate::process_tree::snapshot().unwrap(); + let server = tree + .children(worker) + .iter() + .find(|child| child.command.ends_with(" app-server")) + .unwrap() + .pid; + (worker, server) +} + +#[tokio::test] +async fn partial_snapshot_identity_does_not_invent_collection_but_preserves_observed_work() { + for prior_work in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let agent = snapshot_worker(&dir, false, false).await; + let (worker, server) = parent(&agent); + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + let original = scope(); + let mut agent = remember(&mut pool, &original); + if prior_work { + agent.acp.process_tree_snapshots.push_back(Some(ProcessTree::from_snapshot_with_identities( + &format!("{server} {worker} S /fixture/codex app-server\n{WORK} {server} S /usr/bin/python3 task.py\n"), None))); + assert!(agent.background_work_running(&PromptSource::Channel(original.clone()))); + } + // Exact observed race: parent is identified; Bash disappeared between census/probe. + agent.acp.process_tree_snapshots.extend([ + Some(census( + worker, + server, + Some(&format!("{server} /fixture/codex\n")), + false, + )), + None, + Some(empty(worker, server)), + Some(empty(worker, server)), + ]); + let _ = agent.background_work_running(&PromptSource::Channel(original.clone())); + pool.return_agent(agent); + let mut queue = EventQueue::new(DedupMode::Queue); + pool.queue_finished_background_work(&mut queue); + pool.queue_finished_background_work(&mut queue); + let batch = queue.flush_next(); + let duplicate = queue.flush_next(); + let held = pool.has_runtime_hold(); + pool.agents[0].as_mut().unwrap().acp.shutdown().await; + assert_eq!( + batch.is_some(), + prior_work, + "uncertainty alone cannot create completion evidence" + ); + if let Some(batch) = batch { + assert_eq!(batch.scope, original); + assert_eq!(batch.continuation.unwrap().session_id, "snapshot-session"); + } + assert!(duplicate.is_none()); + assert_eq!(held, prior_work); + } +} + +#[tokio::test] +async fn uncertain_snapshot_capture_never_adopts_shell_or_real_descendants() { + for session_capture in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let mut agent = snapshot_worker(&dir, false, false).await; + let (worker, server) = parent(&agent); + if session_capture { + agent + .acp + .process_tree_snapshots + .push_back(Some(empty(worker, server))); + } + agent + .acp + .process_tree_snapshots + .push_back(Some(census(worker, server, None, true))); + if session_capture { + agent + .acp + .session_new("/tmp", vec![], None, None) + .await + .unwrap(); + } else { + agent.acp.capture_startup_children(); + } + agent.acp.process_tree_snapshots.push_back(Some(census( + worker, + server, + Some(&format!("{server} /fixture/codex\n{SHELL} /bin/bash\n")), + true, + ))); + let blocking = agent.acp.background_child_pids().unwrap(); + agent.acp.shutdown().await; + assert_eq!( + blocking, + HashSet::from([WORK]), + "later confirmation must reveal actual child work" + ); + } +} + +#[tokio::test] +async fn snapshot_uncertainty_is_owned_and_known_wrong_identity_stays_work() { + let dir = tempfile::tempdir().unwrap(); + let mut agent = snapshot_worker(&dir, false, false).await; + let (worker, server) = parent(&agent); + agent.acp.process_tree_snapshots.push_back(Some(census( + worker, + server, + Some(&format!( + "{server} /fixture/codex\n{SHELL} /usr/bin/python3\n" + )), + false, + ))); + let wrong = agent.acp.background_child_pids().unwrap(); + // A foreign worker's partial probe cannot make this idle worker uncertain. + let script = SNAPSHOT.split_whitespace().collect::>().join(" "); + let foreign = format!("{server} {worker} S /fixture/codex app-server\n880001 1 S /other/codex app-server\n880002 880001 S /bin/bash -lc {script}\n"); + agent + .acp + .process_tree_snapshots + .push_back(Some(ProcessTree::from_snapshot_with_identities( + &foreign, None, + ))); + let unrelated = agent.acp.background_child_pids(); + agent.acp.shutdown().await; + assert_eq!(wrong, HashSet::from([SHELL])); + assert_eq!(unrelated, Some(HashSet::new())); +} + +#[tokio::test] +async fn new_real_work_alongside_uncertain_snapshot_collects_after_its_exit() { + let dir = tempfile::tempdir().unwrap(); + let agent = snapshot_worker(&dir, false, false).await; + let (worker, server) = parent(&agent); + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + let original = scope(); + let mut agent = remember(&mut pool, &original); + agent.acp.process_tree_snapshots.extend([ + Some(census( + worker, + server, + Some(&format!("{server} /fixture/codex\n")), + true, + )), + Some(census( + worker, + server, + Some(&format!("{server} /fixture/codex\n")), + false, + )), + Some(empty(worker, server)), + Some(empty(worker, server)), + ]); + assert!(agent.background_work_running(&PromptSource::Channel(original.clone()))); + let observed = agent + .background_context + .as_ref() + .unwrap() + .blocking_pids + .clone(); + pool.return_agent(agent); + let mut queue = EventQueue::new(DedupMode::Queue); + pool.queue_finished_background_work(&mut queue); + pool.queue_finished_background_work(&mut queue); + let batch = queue.flush_next(); + let duplicate = queue.flush_next(); + pool.agents[0].as_mut().unwrap().acp.shutdown().await; + assert_eq!( + observed, + HashSet::from([WORK]), + "only genuine child work may become completion evidence" + ); + let batch = batch.expect("real child first observed alongside uncertainty must collect"); + assert_eq!(batch.scope, original); + assert_eq!(batch.continuation.unwrap().session_id, "snapshot-session"); + assert!(duplicate.is_none()); +} diff --git a/crates/buzz-acp/src/pool/status_language.rs b/crates/buzz-acp/src/pool/status_language.rs new file mode 100644 index 00000000000..ff992328a86 --- /dev/null +++ b/crates/buzz-acp/src/pool/status_language.rs @@ -0,0 +1,234 @@ +//! Deliberately small language inference: Russian, English, or unknown. +//! Only already bounded, signed conversation evidence from a trusted human is +//! used. Code, quotes, operational controls and log-shaped lines carry no hint. +use super::{ContextTarget, PromptContext}; +use serde_json::Value; +use std::collections::HashSet; +use uuid::Uuid; + +#[derive(Default)] +pub(super) struct Evidence { + newest: Option<((u64, String), &'static str)>, +} +impl Evidence { + pub(super) fn observe( + &mut self, + event: &Value, + humans: &HashSet, + channel: Uuid, + target: &ContextTarget, + ) { + if !event["pubkey"] + .as_str() + .is_some_and(|author| humans.contains(author)) + { + return; + } + let Some(content) = event["content"].as_str() else { + return; + }; + let Some(language) = infer(content) else { + return; + }; + let key = ( + event["created_at"].as_u64().unwrap_or_default(), + event["id"].as_str().unwrap_or_default().to_owned(), + ); + if self.newest.as_ref().is_some_and(|(prior, _)| key <= *prior) { + return; + } + let Ok(signed) = serde_json::from_value::(event.clone()) else { + return; + }; + if signed.verify().is_err() { + return; + } + if !signed.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.first().is_some_and(|part| part == "h") + && parts + .get(1) + .is_some_and(|part| part == &channel.to_string()) + }) { + return; + } + if let ContextTarget::Thread(root) = target { + if signed.id.to_hex() != *root + && crate::queue::parse_thread_tags(&signed) + .root_event_id + .as_ref() + != Some(root) + { + return; + } + } + if !matches!(signed.kind.as_u16(), 9 | 40002) + || crate::session_controls::reserved_raw(event) + { + return; + } + if self.newest.as_ref().is_none_or(|(prior, _)| key > *prior) { + self.newest = Some((key, language)); + } + } + pub(super) fn language(&self) -> &'static str { + self.newest + .as_ref() + .map_or("unknown", |(_, language)| *language) + } +} + +// None means no prose evidence; substantive unsupported prose explicitly +// replaces older certainty with unknown. +pub(super) fn infer(text: &str) -> Option<&'static str> { + let mut fenced = false; + let mut ru = 0; + let mut en = 0; + let mut letters = 0; + for line in text.lines().take(128) { + let line = line.trim(); + if line.starts_with("```") || line.starts_with("~~~") { + fenced = !fenced; + continue; + } + if fenced + || log_shaped(line) + || line.starts_with(['>', '!', '$', '{']) + || ["[\"", "[{", "http://", "https://"] + .iter() + .any(|prefix| line.starts_with(*prefix)) + { + continue; + } + let mut inline = false; + let filtered: String = line + .chars() + .take(2048) + .filter(|character| { + if *character == '`' { + inline = !inline; + return false; + } + !inline + }) + .collect(); + letters += filtered.chars().filter(|c| c.is_alphabetic()).count(); + ru += filtered + .chars() + .filter(|c| matches!(c, 'А'..='я' | 'Ё' | 'ё')) + .count(); + let lower = filtered.to_ascii_lowercase(); + en += lower + .split(|c: char| !c.is_ascii_alphabetic()) + .filter(|word| { + matches!( + *word, + "the" + | "this" + | "that" + | "please" + | "is" + | "are" + | "we" + | "you" + | "and" + | "have" + | "has" + | "need" + | "done" + | "with" + | "what" + | "task" + | "can" + | "continue" + | "status" + ) + }) + .count(); + } + if ru >= 4 { + Some("ru") + } else if en >= 2 { + Some("en") + } else if letters >= 4 { + Some("unknown") + } else { + None + } +} + +// Match timestamp/severity prefixes, not numbered or date-led progress prose. +fn log_shaped(line: &str) -> bool { + fn severity(token: &str) -> bool { + matches!( + token.trim_matches(['[', ']']), + "ERROR" | "WARN" | "INFO" | "DEBUG" | "TRACE" | "FATAL" + ) + } + fn clock(token: &str) -> bool { + let bytes = token.trim_matches(['[', ']']).as_bytes(); + bytes.len() >= 8 + && bytes[2] == b':' + && bytes[5] == b':' + && bytes[..8] + .iter() + .enumerate() + .all(|(i, b)| matches!(i, 2 | 5) || b.is_ascii_digit()) + } + let mut words = line.split_whitespace(); + let first = words.next().unwrap_or_default().trim_matches(['[', ']']); + if severity(first) || clock(first) { + return true; + } + let bytes = first.as_bytes(); + let date = bytes.len() >= 10 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes[..10] + .iter() + .enumerate() + .all(|(i, b)| matches!(i, 4 | 7) || b.is_ascii_digit()); + date && ((bytes.get(10) == Some(&b'T') && first.get(11..).is_some_and(clock)) + || words + .next() + .is_some_and(|next| severity(next) || clock(next))) +} + +/// Owner and authenticated requester are a conversation-author trust policy, +/// not a proof that a key belongs to a human. Verified agent attestations remove +/// those authors; absent profiles never become a claimed human credential. +pub(super) async fn trusted_authors(requester: &str, ctx: &PromptContext) -> HashSet { + let mut authors = HashSet::from([requester.to_owned()]); + if let Some(owner) = &ctx.agent_owner_pubkey { + authors.insert(owner.to_hex()); + } + authors.remove(&ctx.agent_keys.public_key().to_hex()); + if authors.is_empty() { + return authors; + } + let filter = + serde_json::json!({"kinds":[0],"authors":authors.iter().collect::>(),"limit":2}); + if let Ok(Ok(Value::Array(events))) = tokio::time::timeout( + std::time::Duration::from_secs(2), + ctx.rest_client.query_raw_bounded(&[filter], 128 * 1024), + ) + .await + { + for value in events.into_iter().take(2) { + let Ok(event) = serde_json::from_value::(value) else { + continue; + }; + if event.kind.as_u16() != 0 || event.verify().is_err() { + continue; + } + if event.tags.iter().any(|tag| { + serde_json::to_string(tag).ok().is_some_and(|json| { + buzz_sdk::nip_oa::verify_auth_tag(&json, &event.pubkey).is_ok() + }) + }) { + authors.remove(&event.pubkey.to_hex()); + } + } + } + authors +} diff --git a/crates/buzz-acp/src/process_tree.rs b/crates/buzz-acp/src/process_tree.rs index 2ff4e020968..107dbd9dd14 100644 --- a/crates/buzz-acp/src/process_tree.rs +++ b/crates/buzz-acp/src/process_tree.rs @@ -3,7 +3,9 @@ //! Pool sleep kills the worker's process group. Children that are still //! doing the turn's work (a subagent, a background shell) must keep the //! worker alive. Long-lived helpers such as MCP servers must not, or a -//! lazy pool would never sleep. +//! lazy pool would never sleep. Agent CLIs often start MCP servers through +//! launcher chains (`npm exec`, `sh -c`, a node launcher script); a chain +//! whose every leaf is a helper is a helper too. use std::collections::{HashMap, HashSet}; @@ -20,15 +22,6 @@ pub struct ChildProcess { /// Arguments of ordinary work must not turn it into a resident just because a /// task mentions MCP, or a working directory happens to contain those letters. pub fn is_resident_helper(command: &str) -> bool { - fn helper_name(path: &str) -> bool { - let name = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase(); - name == "mcp" - || name.starts_with("mcp-") - || name.starts_with("mcp_") - || name.contains("-mcp") - || name.contains("_mcp") - || path.split('/').any(|part| part == "@modelcontextprotocol") - } let mut parts = command.split_whitespace(); let Some(executable) = parts.next() else { return false; @@ -41,12 +34,43 @@ pub fn is_resident_helper(command: &str) -> bool { // The first non-option word is the script. Inline programs (-c/-e) // and interpreter flags with their own values are deliberately unknown. if let Some(script) = parts.next().filter(|part| !part.starts_with('-')) { - return helper_name(script); + return helper_name(script) || in_helper_package(script); } } false } +fn helper_name(path: &str) -> bool { + let name = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase(); + name == "mcp" + || name.starts_with("mcp-") + || name.starts_with("mcp_") + || name.contains("-mcp") + || name.contains("_mcp") + || path.split('/').any(|part| part == "@modelcontextprotocol") +} + +/// Whether a script path lies inside an installed package with a helper +/// name: the component right after `node_modules/` (or after its `@scope/`). +/// Ordinary directories are not packages, so a work directory that merely +/// contains the letters never qualifies. +fn in_helper_package(script: &str) -> bool { + let parts: Vec<&str> = script.split('/').collect(); + parts.iter().enumerate().any(|(index, part)| { + if *part != "node_modules" { + return false; + } + let package = match parts.get(index + 1) { + Some(scope) if scope.starts_with('@') => parts.get(index + 2), + other => other, + }; + package.is_some_and(|package| helper_name(package)) + }) +} + +/// Launcher chains deeper than this are treated as work. +const MAX_LAUNCHER_DEPTH: usize = 8; + /// Parse one `ps -axo pid=,ppid=,command=` line. /// /// Returns `(pid, ppid, command)`. @@ -68,21 +92,78 @@ pub fn parse_ps_line(line: &str) -> Option<(u32, u32, String)> { pub struct ProcessTree { children: HashMap>, resident_helpers: HashSet, + snapshot_helpers: HashSet, + snapshot_candidates: HashSet, + uncertain_snapshots: HashSet, } impl ProcessTree { - /// Direct children that are work, excluding startup residents and helpers. + #[cfg(all(test, any(target_os = "macos", target_os = "linux")))] + pub(crate) fn from_snapshot_with_identities(output: &str, identities: Option<&str>) -> Self { + let mut tree = parse_snapshot(output).unwrap(); + let snapshots = codex_snapshots::candidates(&tree); + codex_helpers::apply_snapshot_identities(&mut tree, &snapshots, identities); + tree + } + + /// Snapshot shells are bookkeeping only while their current identity matches. + pub(crate) fn is_snapshot_helper(&self, pid: u32) -> bool { + self.snapshot_helpers.contains(&pid) + } + + /// A structural snapshot branch must never authorize permanent residency. + pub(crate) fn is_snapshot_candidate(&self, pid: u32) -> bool { + self.snapshot_candidates.contains(&pid) + } + + /// Missing executable proof is uncertainty rather than observed task work. + pub(crate) fn is_uncertain_snapshot(&self, pid: u32) -> bool { + self.uncertain_snapshots.contains(&pid) + } + + /// Startup residents exclude snapshot branches, whose children may be real work. + pub(crate) fn startup_resident_pids(&self, parent: u32) -> HashSet { + let mut residents = HashSet::new(); + let mut pending = vec![parent]; + while let Some(parent) = pending.pop() { + for child in self.children(parent) { + if !self.is_snapshot_candidate(child.pid) && residents.insert(child.pid) { + pending.push(child.pid); + } + } + } + residents + } + + /// Definite work children, excluding residents, helpers and uncertain snapshots. pub fn blocking_pids(&self, parent: u32, resident: &HashSet) -> HashSet { self.children(parent) .iter() - .filter(|child| !resident.contains(&child.pid) && !self.is_resident_helper(child)) + .filter(|child| { + !resident.contains(&child.pid) + && !self.is_resident_helper(child) + && !self.is_uncertain_snapshot(child.pid) + }) .map(|child| child.pid) .collect() } - /// Helpers confirmed by executable identity, or the existing MCP classifier. + /// Helpers confirmed by executable identity, the MCP classifier, or a + /// launcher whose children are all helpers. A childless non-helper is work. pub fn is_resident_helper(&self, child: &ChildProcess) -> bool { - self.resident_helpers.contains(&child.pid) || is_resident_helper(&child.command) + self.is_snapshot_helper(child.pid) || self.is_helper_within(child, MAX_LAUNCHER_DEPTH) + } + + fn is_helper_within(&self, child: &ChildProcess, depth: usize) -> bool { + if self.resident_helpers.contains(&child.pid) || is_resident_helper(&child.command) { + return true; + } + let children = self.children(child.pid); + depth > 0 + && !children.is_empty() + && children + .iter() + .all(|grandchild| self.is_helper_within(grandchild, depth - 1)) } /// Direct children in this snapshot. @@ -127,6 +208,9 @@ pub fn snapshot() -> Option { #[cfg(any(target_os = "macos", target_os = "linux"))] mod codex_helpers; +#[cfg(any(target_os = "macos", target_os = "linux"))] +mod codex_snapshots; + #[cfg(unix)] fn parse_snapshot(output: &str) -> Option { let mut tree = ProcessTree::default(); @@ -145,7 +229,7 @@ fn parse_snapshot(output: &str) -> Option { } #[cfg(unix)] -fn bounded_output( +pub(crate) fn bounded_output( mut command: tokio::process::Command, timeout: std::time::Duration, limit: usize, @@ -307,3 +391,56 @@ mod classification_regression_tests { #[cfg(all(test, unix))] mod snapshot_tests; + +#[cfg(all(test, unix))] +mod launcher_chain_tests { + use super::*; + + #[test] + fn npm_exec_mcp_chain_is_resident() { + let tree = parse_snapshot(concat!( + "11 10 S npm exec @zereight/mcp-gitlab\n", + "12 11 S sh -c 'mcp-gitlab'\n", + "13 12 S node /home/u/.npm/_npx/d6b1/node_modules/.bin/mcp-gitlab\n", + )) + .expect("valid process snapshot"); + assert!(tree.is_resident_helper(&tree.children(10)[0])); + assert!(tree.blocking_pids(10, &HashSet::new()).is_empty()); + } + + #[test] + fn launcher_into_scoped_mcp_package_is_resident() { + let tree = parse_snapshot(concat!( + "21 10 S /usr/bin/node /home/u/.cache/b24-mcp/launcher.cjs\n", + "22 21 S /usr/bin/node /home/u/.cache/b24-mcp/versions/1.1.2/node_modules/@bitrix/b24-mcp/dist/cli.js\n", + )) + .expect("valid process snapshot"); + assert!(tree.blocking_pids(10, &HashSet::new()).is_empty()); + } + + #[test] + fn shell_with_non_helper_leaf_still_blocks() { + let tree = parse_snapshot(concat!( + "31 10 S /bin/bash -c sleep 600\n", + "32 31 S sleep 600\n", + "41 10 S npm exec @upstash/context7-mcp\n", + )) + .expect("valid process snapshot"); + // 41 has no children yet: not proven resident on this poll. + assert_eq!( + tree.blocking_pids(10, &HashSet::new()), + HashSet::from([31, 41]) + ); + } + + #[test] + fn work_dir_named_like_mcp_is_not_a_package() { + assert!(!is_resident_helper("python3 /home/u/my-mcp-project/run.py")); + } +} + +#[cfg(all(test, unix))] +mod retirement_tests; + +#[cfg(unix)] +pub(crate) mod retirement; diff --git a/crates/buzz-acp/src/process_tree/codex_bash_snapshot.txt b/crates/buzz-acp/src/process_tree/codex_bash_snapshot.txt new file mode 100644 index 00000000000..24da8344859 --- /dev/null +++ b/crates/buzz-acp/src/process_tree/codex_bash_snapshot.txt @@ -0,0 +1,44 @@ +if [ -z "${BASH_ENV-}" ] && [ -n "${HOME-}" ] && [ -r "$HOME/.bashrc" ]; then + . "$HOME/.bashrc" +fi +echo '# Snapshot file' +echo '# Unset all aliases to avoid conflicts with functions' +echo 'unalias -a 2>/dev/null || true' +shopt -p || true +echo '# Functions' +declare -f +echo '' + +__codex_snapshot_command() { + if command -v "$1" >/dev/null 2>&1; then + "$@" + else + command -p "$@" + fi +} +bash_opts=$(set -o | __codex_snapshot_command awk '$2=="on"{print $1}') +bash_opt_count=$(printf '%s\n' "$bash_opts" | __codex_snapshot_command sed '/^$/d' | __codex_snapshot_command wc -l | __codex_snapshot_command tr -d ' ') +echo "# setopts $bash_opt_count" +if [ -n "$bash_opts" ]; then + printf 'set -o %s\n' $bash_opts +fi +echo '' +printf '\0' +alias_count=$(\alias -p | __codex_snapshot_command wc -l | __codex_snapshot_command tr -d ' ') +echo "# aliases $alias_count" +\alias -p +echo '' + +printf '\0' +( + while IFS= read -r __codex_snapshot_export_name; do + case "$__codex_snapshot_export_name" in + ""|[0-9]*|*[!A-Za-z0-9_]*|PWD|OLDPWD) continue ;; + esac + printf '%s\0' "$__codex_snapshot_export_name" + declare -xp "$__codex_snapshot_export_name" 2>/dev/null || true + printf '\0' + done < <(compgen -e) +) + +printf '\0' diff --git a/crates/buzz-acp/src/process_tree/codex_helpers.rs b/crates/buzz-acp/src/process_tree/codex_helpers.rs index 3c2e95416b8..99f6a9dc6fd 100644 --- a/crates/buzz-acp/src/process_tree/codex_helpers.rs +++ b/crates/buzz-acp/src/process_tree/codex_helpers.rs @@ -19,11 +19,17 @@ pub(super) fn classify(mut tree: ProcessTree) -> ProcessTree { }) .map(|child| child.pid) .collect(); + let snapshots = super::codex_snapshots::candidates(&tree); + let mut probe_pids = candidates.clone(); + for (child, parent) in &snapshots { + probe_pids.extend([child, parent]); + } + apply_snapshot_identities(&mut tree, &snapshots, None); // Bound argv as well as time/output. Uncertain enumeration keeps work held. - if candidates.is_empty() || candidates.len() > 128 { + if probe_pids.is_empty() || probe_pids.len() > 128 { return tree; } - let pids = candidates + let pids = probe_pids .iter() .map(u32::to_string) .collect::>() @@ -35,9 +41,20 @@ pub(super) fn classify(mut tree: ProcessTree) -> ProcessTree { command.args(["-ww", "-p", &pids, "-o", "pid=,exe="]); let output = bounded_output(command, std::time::Duration::from_millis(250), 256 * 1024); tree.resident_helpers = confirmed_hosts(output.as_deref(), &candidates); + apply_snapshot_identities(&mut tree, &snapshots, output.as_deref()); tree } +pub(super) fn apply_snapshot_identities( + tree: &mut ProcessTree, + snapshots: &HashMap, + output: Option<&str>, +) { + tree.snapshot_candidates = snapshots.keys().copied().collect(); + (tree.snapshot_helpers, tree.uncertain_snapshots) = + super::codex_snapshots::classify_identities(snapshots, output); +} + fn confirmed_hosts(output: Option<&str>, candidates: &HashSet) -> HashSet { let Some(output) = output else { return HashSet::new(); diff --git a/crates/buzz-acp/src/process_tree/codex_snapshots.rs b/crates/buzz-acp/src/process_tree/codex_snapshots.rs new file mode 100644 index 00000000000..59a9225ac2d --- /dev/null +++ b/crates/buzz-acp/src/process_tree/codex_snapshots.rs @@ -0,0 +1,188 @@ +//! Recognize only the default Bash capture emitted by Codex shell snapshots. +use super::*; + +// Codex d27764b82f7118f674371e6d6e76271d9d606edb, shell-command/src/ +// startup.rs + shell_snapshot_capture.rs + shell_snapshot_exports.rs: +// interactive startup, Eval replay, declarations enabled, environment disabled. +const BASH_CAPTURE: &str = include_str!("codex_bash_snapshot.txt"); + +pub(super) fn candidates(tree: &ProcessTree) -> HashMap { + let mut candidates = HashMap::new(); + let servers: HashSet<_> = tree + .children + .values() + .flatten() + .filter(|process| is_app_server(&process.command)) + .map(|process| process.pid) + .collect(); + for (parent, children) in &tree.children { + if !servers.contains(parent) { + continue; + } + for child in children { + if is_bash_capture(&child.command) { + candidates.insert(child.pid, *parent); + } + } + } + candidates +} + +fn is_bash_capture(command: &str) -> bool { + let mut args = command.split_whitespace(); + args.next().is_some_and(|path| basename(path) == "bash") + && args.next() == Some("-lc") + && args.eq(BASH_CAPTURE.split_whitespace()) +} + +fn is_app_server(command: &str) -> bool { + let mut args = command.split_whitespace(); + if args.next().is_none_or(|path| basename(path) != "codex") { + return false; + } + loop { + match args.next() { + Some("-c" | "--config") if args.next().is_some() => {} + Some("app-server") => return args.next().is_none(), + _ => return false, + } + } +} + +fn basename(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} + +pub(super) fn classify_identities( + candidates: &HashMap, + output: Option<&str>, +) -> (HashSet, HashSet) { + let identities: HashMap<_, _> = output + .into_iter() + .flat_map(str::lines) + .filter_map(|line| { + let (pid, executable) = line.trim().split_once(char::is_whitespace)?; + let executable = executable.trim(); + #[cfg(target_os = "linux")] + let executable = executable.strip_suffix(" (deleted)").unwrap_or(executable); + let name = basename(executable); + if name.is_empty() || name == "-" || name == "?" { + return None; + } + Some((pid.parse::().ok()?, name)) + }) + .collect(); + let mut confirmed = HashSet::new(); + let mut uncertain = HashSet::new(); + for (child, parent) in candidates { + match ( + identities.get(child).copied(), + identities.get(parent).copied(), + ) { + (Some("bash"), Some("codex")) => { + confirmed.insert(*child); + } + (Some(name), _) if name != "bash" => {} + (_, Some(name)) if name != "codex" => {} + _ => { + uncertain.insert(*child); + } + } + } + (confirmed, uncertain) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tree(shell: &str, parent: &str) -> ProcessTree { + parse_snapshot(&format!("20 10 S {parent}\n30 20 S {shell}\n")).unwrap() + } + + #[test] + fn snapshot_requires_complete_script_exact_flags_and_direct_app_server() { + let shell = format!("/bin/bash -lc {BASH_CAPTURE}"); + // ps collapses whitespace; normalize fixture rows exactly as production does. + let shell = shell.split_whitespace().collect::>().join(" "); + for parent in [ + "/opt/codex app-server", + "/opt/codex -c mcp_servers.test.enabled=false app-server", + ] { + assert_eq!(candidates(&tree(&shell, parent)), HashMap::from([(30, 20)])); + } + for command in [ + format!("{shell} ; sleep 60"), + format!("/bin/bash -lc sleep 60 # {BASH_CAPTURE}"), + shell.replacen("-lc", "-c", 1), + shell.replacen("declare -f", "sleep 60", 1), + format!("/bin/bash -c read marker {shell}"), + ] { + let command = command.split_whitespace().collect::>().join(" "); + assert!(candidates(&tree(&command, "/opt/codex app-server")).is_empty()); + } + for parent in [ + "/bin/bash app-server", + "/opt/codex exec app-server", + "/opt/codex -c app-server", + ] { + assert!(candidates(&tree(&shell, parent)).is_empty()); + } + } + + #[test] + fn snapshot_requires_both_kernel_identities_and_known_probe() { + let candidates = HashMap::from([(30, 20)]); + assert_eq!( + classify_identities(&candidates, Some("20 /opt/codex\n30 /bin/bash\n")).0, + HashSet::from([30]) + ); + for output in [ + None, + Some(""), + Some("20 /bin/python3\n30 /bin/bash\n"), + Some("20 /opt/codex\n30 /bin/python3\n"), + Some("20 /opt/codex\n30 -\n"), + Some("30 /bin/bash\n"), + ] { + assert!(classify_identities(&candidates, output).0.is_empty()); + } + } + #[test] + fn snapshot_identity_does_not_promote_ordinary_ancestors() { + let shell = format!("/bin/bash -lc {BASH_CAPTURE}") + .split_whitespace() + .collect::>() + .join(" "); + let mut tree = parse_snapshot(&format!( + "15 10 S /bin/bash user-task.sh\n20 15 S /opt/codex app-server\n30 20 S {shell}\n" + )) + .unwrap(); + tree.snapshot_helpers.insert(30); + assert!(tree.is_resident_helper(&tree.children(20)[0])); + assert!(!tree.is_resident_helper(&tree.children(15)[0])); + assert!(!tree.is_resident_helper(&tree.children(10)[0])); + assert_eq!(tree.blocking_pids(10, &HashSet::new()), HashSet::from([15])); + } + #[test] + fn missing_identity_is_uncertain_but_positive_mismatch_is_work() { + let candidates = HashMap::from([(30, 20)]); + for output in [ + None, + Some("20 /opt/codex\n"), + Some("30 /bin/bash\n"), + Some("20 /opt/codex\n30 -\n"), + ] { + assert_eq!( + classify_identities(&candidates, output), + (HashSet::new(), HashSet::from([30])) + ); + } + for output in [Some("20 /usr/bin/python3\n"), Some("30 /usr/bin/python3\n")] { + assert_eq!( + classify_identities(&candidates, output), + (HashSet::new(), HashSet::new()) + ); + } + } +} diff --git a/crates/buzz-acp/src/process_tree/retirement.rs b/crates/buzz-acp/src/process_tree/retirement.rs new file mode 100644 index 00000000000..cdad67989d5 --- /dev/null +++ b/crates/buzz-acp/src/process_tree/retirement.rs @@ -0,0 +1,111 @@ +//! Identity-preserving process-group snapshots for explicit worker retirement. +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +#[derive(Clone, Debug)] +struct Process { + parent: u32, + group: u32, + command: String, + zombie: bool, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct RetirementSnapshot { + processes: HashMap, +} + +impl RetirementSnapshot { + pub(crate) fn parse(output: &str) -> Option { + let mut processes = HashMap::new(); + for line in output.lines().filter(|line| !line.trim().is_empty()) { + let mut fields = line.split_whitespace(); + let pid = fields.next()?.parse().ok()?; + let parent = fields.next()?.parse().ok()?; + let group = fields.next()?.parse().ok()?; + let state = fields.next()?; + let command = fields.collect::>().join(" "); + if command.is_empty() || processes.contains_key(&pid) { + return None; + } + processes.insert( + pid, + Process { + parent, + group, + command, + zombie: state.starts_with('Z'), + }, + ); + } + (!processes.is_empty()).then_some(Self { processes }) + } + + /// A complete initial snapshot must still identify the owned live adapter. + pub(crate) fn worker_present(&self, pid: u32) -> bool { + self.processes + .get(&pid) + .is_some_and(|process| process.group == pid && !process.zombie) + } + + fn descendants(&self, root: u32) -> HashSet { + let mut found = HashSet::from([root]); + let mut pending = vec![root]; + while let Some(parent) = pending.pop() { + for (&pid, process) in &self.processes { + if process.parent == parent && found.insert(pid) { + pending.push(pid); + } + } + } + found + } + + pub(crate) fn descendant_groups(&self, root: u32, own_group: u32) -> HashSet { + self.descendants(root) + .iter() + .filter_map(|pid| self.processes.get(pid)) + .map(|process| process.group) + .filter(|group| *group > 1 && *group != own_group) + .collect() + } + + /// Revalidate PID and command before signalling an escaped group. Descendants + /// of a surviving anchor are included even after that anchor was orphaned. + pub(crate) fn verified_groups(&self, last: &Self, root: u32, own_group: u32) -> HashSet { + let mut verified = HashSet::new(); + for pid in self.descendants(root) { + if self + .processes + .get(&pid) + .zip(last.processes.get(&pid)) + .is_some_and(|(before, after)| before.command == after.command) + { + verified.extend(last.descendant_groups(pid, own_group)); + } + } + // A sibling worker (and all its groups) is never a retirement target. + let harness = self.processes.get(&root).map(|process| process.parent); + if let Some(harness) = harness { + for (&pid, process) in &last.processes { + if process.parent == harness && pid != root { + for group in last.descendant_groups(pid, own_group) { + verified.remove(&group); + } + } + } + } + verified + } +} + +/// Called on a blocking thread: one attempt is bounded by two seconds and 4 MiB. +pub(crate) fn snapshot() -> Option { + let mut command = tokio::process::Command::new("/bin/ps"); + command.args(["-ww", "-axo", "pid=,ppid=,pgid=,stat=,command="]); + RetirementSnapshot::parse(&super::bounded_output( + command, + Duration::from_secs(2), + 4 * 1024 * 1024, + )?) +} diff --git a/crates/buzz-acp/src/process_tree/retirement_tests.rs b/crates/buzz-acp/src/process_tree/retirement_tests.rs new file mode 100644 index 00000000000..e518f03d9c7 --- /dev/null +++ b/crates/buzz-acp/src/process_tree/retirement_tests.rs @@ -0,0 +1,33 @@ +use super::retirement::*; +use std::collections::HashSet; + +#[test] +fn retirement_snapshot_parses_groups_and_excludes_harness_and_reserved_groups() { + let snapshot = RetirementSnapshot::parse("10 1 10 S harness\n20 10 20 S adapter\n21 20 21 S shell\n22 21 21 S sleep 60\n23 20 10 S unsafe-own\n24 20 1 S init-group\n25 20 0 S zero\n").unwrap(); + assert_eq!(snapshot.descendant_groups(20, 10), HashSet::from([20, 21])); +} + +#[test] +fn retirement_groups_require_matching_process_identity_and_protect_other_workers() { + let first = + RetirementSnapshot::parse("20 10 20 S adapter\n21 20 21 S shell\n22 21 21 S sleep 60\n") + .unwrap(); + let last = RetirementSnapshot::parse( + "21 1 21 S shell\n22 21 21 S sleep 60\n30 10 30 S other-adapter\n31 30 31 S other-work\n", + ) + .unwrap(); + assert_eq!(first.verified_groups(&last, 20, 10), HashSet::from([21])); + let reused = RetirementSnapshot::parse("21 1 21 S unrelated\n").unwrap(); + assert!(first.verified_groups(&reused, 20, 10).is_empty()); +} + +#[test] +fn retirement_initial_snapshot_requires_live_worker_in_its_own_group() { + let live = RetirementSnapshot::parse("20 10 20 S adapter\n").unwrap(); + assert!(live.worker_present(20)); + assert!(!live.worker_present(21)); + let zombie = RetirementSnapshot::parse("20 10 20 Z adapter\n").unwrap(); + assert!(!zombie.worker_present(20)); + let wrong = RetirementSnapshot::parse("20 10 99 S adapter\n").unwrap(); + assert!(!wrong.worker_present(20)); +} diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index b02b8cf586c..fc6da430567 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -2101,7 +2101,7 @@ fn conversation_context_status( } /// Format a conversation context section (thread or DM). -fn format_conversation_context( +pub(crate) fn format_conversation_context( ctx: &ConversationContext, profile_lookup: Option<&PromptProfileLookup>, ) -> String { @@ -2539,6 +2539,7 @@ mod tests { queue.retry_after.insert(conv(channel), deadline); let (tx, mut rx) = tokio::sync::mpsc::channel(1); tx.send(crate::RespawnResult { + retirement: None, index: 0, result: Err(anyhow::anyhow!("wake token")), }) diff --git a/crates/buzz-acp/src/recovery_wake.rs b/crates/buzz-acp/src/recovery_wake.rs index a4bd8057256..6a4f9fa27a7 100644 --- a/crates/buzz-acp/src/recovery_wake.rs +++ b/crates/buzz-acp/src/recovery_wake.rs @@ -42,6 +42,7 @@ mod tests { async fn respawn_wakes_without_optional_timers() { let (tx, mut rx) = mpsc::channel(1); tx.send(RespawnResult { + retirement: None, index: 0, result: Err(anyhow::anyhow!("fixture spawn failure")), }) @@ -92,6 +93,7 @@ mod tests { _ = wait(&mut rx, None, None) => panic!("unexpected wake"), } tx.send(RespawnResult { + retirement: None, index: 0, result: Err(anyhow::anyhow!("fixture")), }) diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index a019e758341..7a1bdca67f4 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -505,6 +505,45 @@ impl RestClient { .map_err(|e| RelayError::Http(e.to_string())) } + /// Decode one raw history response with a byte ceiling on actual received + /// chunks, including when Content-Length is absent or misleading. The + /// deadline covers request, body reading and decoding independently of the + /// caller's total history budget. + pub async fn query_raw_bounded( + &self, + filters: &[Value], + max_bytes: usize, + ) -> Result { + tokio::time::timeout(std::time::Duration::from_secs(3), async { + let body = serde_json::to_vec(filters).map_err(|e| RelayError::Http(e.to_string()))?; + let mut response = self.bridge_post("/query", &body).await?; + if response + .content_length() + .is_some_and(|size| size > max_bytes as u64) + { + return Err(RelayError::Http( + "history response exceeds byte budget".into(), + )); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| RelayError::Http(e.to_string()))? + { + if chunk.len() > max_bytes.saturating_sub(bytes.len()) { + return Err(RelayError::Http( + "history response exceeds byte budget".into(), + )); + } + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes).map_err(|e| RelayError::Http(e.to_string())) + }) + .await + .map_err(|_| RelayError::Http("history request deadline exceeded".into()))? + } + /// Query every historical event matching one raw filter across bounded pages. /// /// Uses the bridge's composite `(until, before_id)` cursor so a full page @@ -563,6 +602,74 @@ impl RestClient { .map_err(|e| RelayError::Http(e.to_string())) } + /// Submit once when the caller owns the complete retry budget. The + /// acknowledgment is bounded independently from ordinary relay requests. + pub(crate) async fn submit_event_once(&self, event: &Event) -> Result<(), RelayError> { + self.submit_event_once_checked(event, false).await + } + + /// Require the relay to acknowledge this exact immutable signed event. + pub(crate) async fn submit_event_once_exact(&self, event: &Event) -> Result<(), RelayError> { + self.submit_event_once_checked(event, true).await + } + + async fn submit_event_once_checked( + &self, + event: &Event, + exact: bool, + ) -> Result<(), RelayError> { + let body = serde_json::to_vec(event) + .map_err(|error| RelayError::Http(format!("event serialize error: {error}")))?; + let url = format!("{}/events", self.base_url); + let auth = self.nip98_header("POST", &url, Some(&body))?; + let mut request = self + .http + .post(&url) + .header("Authorization", auth) + .header("Content-Type", "application/json") + .body(body); + if let Some(tag) = &self.auth_tag_json { + request = request.header("x-auth-tag", tag); + } + let mut response = request + .send() + .await + .map_err(|error| RelayError::Http(error.to_string()))?; + if !response.status().is_success() { + return Err(RelayError::Http(format!( + "POST /events returned HTTP {}", + response.status() + ))); + } + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| RelayError::Http(error.to_string()))? + { + if body.len().saturating_add(chunk.len()) > 64 * 1024 { + return Err(RelayError::Http( + "event acknowledgment exceeds byte limit".into(), + )); + } + body.extend_from_slice(&chunk); + } + let acknowledgment: Value = + serde_json::from_slice(&body).map_err(|error| RelayError::Http(error.to_string()))?; + if acknowledgment.get("accepted").and_then(Value::as_bool) != Some(true) { + return Err(RelayError::Http("relay did not accept event".into())); + } + if exact + && acknowledgment.get("event_id").and_then(Value::as_str) + != Some(event.id.to_hex().as_str()) + { + return Err(RelayError::Http( + "relay acknowledgment did not match the prepared event".into(), + )); + } + Ok(()) + } + /// Submit a signed event via the HTTP bridge: `POST /events` with NIP-98 auth. /// /// The event must already be signed. Returns the relay response JSON. diff --git a/crates/buzz-acp/src/runtime.rs b/crates/buzz-acp/src/runtime.rs index e6745b63313..0acdb30f593 100644 --- a/crates/buzz-acp/src/runtime.rs +++ b/crates/buzz-acp/src/runtime.rs @@ -1,5 +1,6 @@ //! Shared agent capabilities. Intake and scheduling belong to each entry point; //! environment, credentials, adapter settings and prompt equipment belong here. +use crate::session_status::{self, SessionStatusRuntime}; use crate::{ build_mcp_servers, config::Config, current_working_directory, git, observer, pool, relay, resolve_agent_owner, PromptContext, @@ -17,6 +18,7 @@ pub(crate) enum SessionMode { /// Add new runtime capabilities here, not separately to the CLI entry points. pub(crate) struct AgentRuntime { config: Config, + session_status: Option, _git_environment: git::GitEnvironment, } @@ -25,10 +27,23 @@ impl AgentRuntime { let git_environment = git::GitEnvironment::for_config(&mut config)?; Ok(Self { config, + session_status: None, _git_environment: git_environment, }) } + /// Open this instance's session status directory and route every adapter + /// spawn and managed MCP server to it. Call before any worker starts. + pub(crate) fn enable_session_status(&mut self) -> Option { + let runtime = session_status::open_for_config(&self.config)?; + let dir = runtime.dir().to_str()?.to_owned(); + let env = &mut self.config.persona_env_vars; + env.retain(|(name, _)| name != session_status::SESSION_STATUS_DIR_ENV); + env.push((session_status::SESSION_STATUS_DIR_ENV.to_owned(), dir)); + self.session_status = Some(runtime.clone()); + Some(runtime) + } + pub(crate) fn config(&self) -> &Config { &self.config } @@ -43,7 +58,9 @@ impl AgentRuntime { channels: HashMap, mode: SessionMode, ) -> Result { - make_prompt_context(&self.config, rest, channels, mode) + let mut ctx = make_prompt_context(&self.config, rest, channels, mode)?; + ctx.session_status = self.session_status.clone(); + Ok(ctx) } } @@ -121,5 +138,8 @@ fn make_prompt_context( memory_enabled: config.memory_enabled, harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), relay_url: config.relay_url.clone(), + session_policy: config.session_policy, + session_status: None, + session_controls: Default::default(), }) } diff --git a/crates/buzz-acp/src/scope.rs b/crates/buzz-acp/src/scope.rs index a0dd2176fb0..ab5803f2abb 100644 --- a/crates/buzz-acp/src/scope.rs +++ b/crates/buzz-acp/src/scope.rs @@ -67,7 +67,7 @@ impl std::fmt::Display for SessionPolicy { /// This is the canonical key for provider sessions, queue partitions, in-flight /// tracking, and context gathering. The channel remains the authorization and /// collaboration boundary; the scope is the default *execution* boundary. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum SessionScope { /// The whole channel is one session. Used for DMs always, and for every /// channel event under [`SessionPolicy::Channel`]. @@ -199,7 +199,19 @@ mod tests { let prompt = policy.append_session_model(base); assert!(prompt.starts_with(base.trim_end())); assert_eq!(prompt.matches("## Session Model").count(), 1); - assert!(prompt.ends_with("assume the owning session has it handled.")); + assert!(prompt.contains("assume the owning session has it handled.")); + assert!(prompt.ends_with("Later ordinary requests resume normal work.")); + assert_eq!( + prompt + .matches( + "A harness-owned `` request is a single status-only turn." + ) + .count(), + 1 + ); + assert!(prompt.contains( + "Do not execute the task, inspect files or logs, run tools or subagents, or send the reply yourself." + )); assert!(prompt.contains("DMs stay one conversation")); assert!(prompt.contains( "core memory, your workspace on disk, relay access, and channel authorization" diff --git a/crates/buzz-acp/src/session_controls.rs b/crates/buzz-acp/src/session_controls.rs new file mode 100644 index 00000000000..5b681d1bdda --- /dev/null +++ b/crates/buzz-acp/src/session_controls.rs @@ -0,0 +1,462 @@ +//! Authorized control admission and generation fences, independent of pool lifetime. +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use buzz_core::session_control::{ + control_is_fresh, is_canonical_id, ControlFailure, ControlOrigin, SessionCommand, +}; +use uuid::Uuid; + +use crate::scope::{SessionPolicy, SessionScope}; +use crate::session_store::{LoadOutcome, RestartIntent, SessionStore, StoreError}; +use crate::{AuthorizedListenerEvent, PromptContext}; + +/// Reserved syntax is removed before raw kind/tags are lost. A human target +/// reserves the syntax too; this predicate does not authorize execution. +pub(crate) fn reserved_raw(event: &serde_json::Value) -> bool { + let Some(kind) = event["kind"].as_u64().and_then(|n| u16::try_from(n).ok()) else { + return false; + }; + SessionCommand::parse(kind, event["content"].as_str().unwrap_or_default()).is_some() + && event["tags"].as_array().is_some_and(|tags| { + tags.iter().any(|tag| { + tag[0].as_str() == Some("p") && tag[1].as_str().is_some_and(is_canonical_id) + }) + }) +} + +/// Only the existing listener authorization capability can construct a job. +#[derive(Clone)] +pub(crate) struct ControlJob { + pub command: SessionCommand, + pub origin: ControlOrigin, + pub scope: SessionScope, + pub dispatch_epoch: Uuid, + pub event: nostr::Event, + pub admitted_at: u64, +} + +pub(crate) enum Admission { + Normal(AuthorizedListenerEvent), + Ignored, + Refused { + event: nostr::Event, + origin: ControlOrigin, + failure: ControlFailure, + }, + Control(ControlJob), +} + +/// Authorization precedes this call; subscription matching follows only Normal. +pub(crate) async fn admit_authorized( + authorized: AuthorizedListenerEvent, + ctx: &PromptContext, + now: u64, +) -> Admission { + let raw = authorized.event(); + let Ok(value) = serde_json::to_value(&raw.event) else { + return Admission::Ignored; + }; + if !reserved_raw(&value) { + return Admission::Normal(authorized); + } + let agent = ctx.agent_keys.public_key().to_hex(); + let targets_agent = value["tags"] + .as_array() + .is_some_and(|tags| tags.iter().any(|tag| tag[0] == "p" && tag[1] == agent)); + // Stale replays never overwrite a completed acknowledgement. + if !targets_agent || !control_is_fresh(raw.event.created_at.as_secs(), now) { + return Admission::Ignored; + } + let metadata = ctx + .channel_info + .resolve_channel_metadata(raw.channel_id) + .await; + if ctx.session_policy == SessionPolicy::Thread + && !metadata.as_ref().is_some_and(|info| { + matches!( + info.channel_type.as_str(), + "dm" | "stream" | "private" | "forum" + ) + }) + { + let (event, actor) = authorized.into_parts(); + let origin = ControlOrigin { + command_event_id: event.event.id.to_hex(), + actor_pubkey: actor, + }; + return Admission::Refused { + event: event.event, + origin, + failure: ControlFailure::ScopeUnavailable, + }; + } + let (raw, effective_author) = authorized.into_parts(); + let scope = SessionScope::derive( + ctx.session_policy, + raw.channel_id, + metadata + .as_ref() + .is_some_and(|info| info.channel_type == "dm"), + &raw.event, + ); + let Some(command) = SessionCommand::parse(raw.event.kind.as_u16(), &raw.event.content) else { + return Admission::Ignored; + }; + Admission::Control(ControlJob { + command, + origin: ControlOrigin { + command_event_id: raw.event.id.to_hex(), + actor_pubkey: effective_author, + }, + dispatch_epoch: ctx.session_controls.epoch(&scope), + scope, + event: raw.event, + admitted_at: now, + }) +} + +#[derive(Default)] +struct ScopeControl { + lifetime: Arc<()>, + epoch: Uuid, + retiring: Option, + // Exact locally accepted Restart ownership survives unavailable store reads. + restart_status_token: Option, + retirement: retirement::Retirement, +} + +#[derive(Default)] +struct State { + scopes: HashMap, + workers: HashMap, + publications: std::collections::VecDeque, + compactions: HashMap, + receipt_writes: HashMap, + statuses: HashMap, + status_authority: Option, + outcomes: HashMap< + String, + ( + tokio::time::Instant, + buzz_core::session_control::ControlOutcome, + ), + >, +} + +impl State { + fn publish(&mut self, publication: retirement::Publication) { + if matches!( + publication.result, + buzz_core::session_control::ControlResult::Pending + ) && self.publications.iter().any(|prior| { + prior.command_id == publication.command_id + && matches!( + prior.result, + buzz_core::session_control::ControlResult::Terminal(_) + ) + }) { + return; + } + // Receipts retain outcomes beyond this bounded delivery cache. + self.publications + .retain(|prior| prior.command_id != publication.command_id); + if self.publications.len() >= 128 { + self.publications.pop_front(); + tracing::warn!("session result delivery cache full; durable outcome retained"); + } + self.publications.push_back(publication); + } + fn prune_quiescent(&mut self) { + self.scopes.retain(|scope, entry| { + entry.retiring.is_some() + || self.statuses.contains_key(scope) + || Arc::strong_count(&entry.lifetime) > 1 + }); + } +} + +/// Shared controller survives sleep, wake and replacement of the disposable pool. +#[derive(Clone, Default)] +pub(crate) struct SessionControlRuntime { + state: Arc>, + store: Option>, +} + +impl SessionControlRuntime { + pub(crate) fn new(store: Option>) -> Self { + Self { + store, + ..Self::default() + } + } + + fn epoch(&self, scope: &SessionScope) -> Uuid { + self.state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .scopes + .get(scope) + .map_or(Uuid::nil(), |entry| entry.epoch) + } + + /// Capture at dispatch, before any asynchronous metadata or provider lookup. + pub(crate) fn capture(&self, scope: &SessionScope) -> ControlDispatch { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + state.prune_quiescent(); + let entry = state + .scopes + .entry(scope.clone()) + .or_insert_with(|| ScopeControl { + lifetime: Arc::new(()), + epoch: Uuid::new_v4(), + retiring: None, + restart_status_token: None, + retirement: retirement::Retirement::default(), + }); + ControlDispatch { + runtime: self.clone(), + scope: scope.clone(), + epoch: entry.epoch, + _lifetime: entry.lifetime.clone(), + intent: self.restart_intent(scope), + } + } + + /// Atomically tombstone the provider and persist intent before fencing work. + /// A duplicate ID never replaces a newer intent or reopens retirement. + pub(crate) fn restart(&self, mut job: ControlJob) -> Result { + let SessionCommand::Restart(depth) = job.command else { + return Err(StoreError::InvalidControl); + }; + if !control_is_fresh( + job.event.created_at.as_secs(), + nostr::Timestamp::now().as_secs(), + ) { + return Err(StoreError::StaleRequest); + } + let store = self.store.as_ref().ok_or(StoreError::Unreadable)?; + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + if let LoadOutcome::Found(record) = store.load(&job.scope) { + if record + .control_receipts + .iter() + .any(|receipt| receipt.command_id == job.origin.command_event_id) + { + return Ok(false); + } + } + let pending_scope_count = state + .scopes + .iter() + .filter(|(_, entry)| entry.retiring.is_some()) + .map(|(scope, _)| scope) + .chain(state.compactions.keys()) + .chain(state.statuses.keys()) + .collect::>() + .len(); + if pending_scope_count + state.receipt_writes.len() >= 128 + && !state.compactions.contains_key(&job.scope) + && !state + .receipt_writes + .values() + .any(|pending| pending.scope == job.scope) + && state + .scopes + .get(&job.scope) + .is_none_or(|entry| entry.retiring.is_none()) + { + return Err(StoreError::Busy); + } + let intent = RestartIntent { + token: Uuid::new_v4(), + depth, + origin: job.origin.clone(), + }; + let status = crate::session_store::RestartStatus { + token: intent.token, + origin: intent.origin.clone(), + scope: job.scope.clone(), + command: job.event.clone(), + owner: store.instance(), + deadline: 0, + phase: crate::session_store::RestartStatusPhase::WaitingRetirement, + prepared: None, + failure_notice: false, + notice: None, + notice_confirmed: false, + }; + let superseded = store.request_restart_status(&job.scope, &intent, status)?; + state.statuses.remove(&job.scope); + if matches!(store.load(&job.scope), LoadOutcome::Found(record) if record.pending_restart.as_ref().map(|saved| saved.token) != Some(intent.token)) + { + return Ok(false); + } + for command_id in superseded { + state.publish(retirement::Publication { + command_id, + result: buzz_core::session_control::ControlResult::Terminal(Err( + ControlFailure::Superseded, + )), + }); + } + if let Some(pending) = state.compactions.remove(&job.scope) { + let id = pending.admitted.job.origin.command_event_id; + state.publish(retirement::Publication { + command_id: id, + result: buzz_core::session_control::ControlResult::Terminal(Err( + ControlFailure::Superseded, + )), + }); + } + let retirement = state + .scopes + .remove(&job.scope) + .map(|entry| entry.retirement) + .unwrap_or_default(); + job.dispatch_epoch = Uuid::new_v4(); + state.scopes.insert( + job.scope.clone(), + ScopeControl { + lifetime: Arc::new(()), + epoch: job.dispatch_epoch, + retiring: Some(job), + restart_status_token: Some(intent.token), + retirement: retirement.reset(), + }, + ); + Ok(true) + } + + pub(crate) fn restart_intent(&self, scope: &SessionScope) -> Option { + match self.store.as_ref()?.load(scope) { + LoadOutcome::Found(record) => record.pending_restart, + _ => None, + } + } + + pub(crate) fn complete_restart( + &self, + scope: &SessionScope, + token: Uuid, + ) -> Result<(), StoreError> { + self.store + .as_ref() + .ok_or(StoreError::Unreadable)? + .complete_restart(scope, token) + } + + pub(crate) fn retirement_pending(&self, scope: &SessionScope) -> bool { + self.state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .scopes + .get(scope) + .is_some_and(|entry| entry.retiring.is_some()) + } + + pub(crate) fn pending_scopes(&self) -> Vec { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + state.prune_quiescent(); + let mut scopes: Vec<_> = state + .scopes + .iter() + .filter(|(_, entry)| entry.retiring.is_some()) + .map(|(scope, _)| scope.clone()) + .collect(); + scopes.extend(state.compactions.keys().cloned()); + scopes.extend(state.statuses.keys().cloned()); + scopes + } + + /// Explicit channel revocation clears control-only records even without resume. + pub(crate) fn revoke_channel(&self, channel: Uuid) -> Result<(), StoreError> { + let scopes: Vec<_> = self + .state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .statuses + .keys() + .filter(|scope| scope.channel_id() == channel) + .cloned() + .collect(); + for scope in scopes { + self.cancel_status(&scope); + } + let state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + let removed: Vec<_> = state + .compactions + .iter() + .filter(|(scope, _)| scope.channel_id() == channel) + .map(|(_, pending)| pending.admitted.clone()) + .collect(); + drop(state); + for job in removed { + self.finish_compaction_job(&job, Err(ControlFailure::Cancelled)); + } + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + state.scopes.retain(|scope, entry| { + if scope.channel_id() != channel { + return true; + } + entry.epoch = Uuid::new_v4(); + // Revocation fences dispatch and clears durable intent, but cannot + // discard a checked retirement that still owns live processes. + entry.retiring.is_some() + }); + drop(state); + if let Some(store) = &self.store { + store.discard_channel(channel)?; + } + Ok(()) + } +} + +/// Per-task snapshot. It never reads a later intent on an old dispatch's behalf. +#[derive(Clone)] +pub(crate) struct ControlDispatch { + runtime: SessionControlRuntime, + scope: SessionScope, + epoch: Uuid, + _lifetime: Arc<()>, + pub(crate) intent: Option, +} + +impl ControlDispatch { + pub(crate) fn current(&self) -> bool { + self.runtime.epoch(&self.scope) == self.epoch + } + + /// Release completed dispatch metadata without reopening a retirement gate. + pub(crate) fn finish(self) { + let runtime = self.runtime.clone(); + drop(self); + runtime + .state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .prune_quiescent(); + } + + pub(crate) fn foreground_success(&self) { + if self.current() { + if let Some(intent) = &self.intent { + if let Err(error) = self.runtime.complete_restart(&self.scope, intent.token) { + tracing::warn!(%error, "restart policy retained after completion write failed"); + } + } + } + } +} + +pub(crate) mod compaction; +mod receipt_writes; +mod retirement; +pub(crate) mod status; +pub(crate) use compaction::{AdmittedSessionControl, CompactionTarget}; +pub(crate) use retirement::Publication; + +pub(crate) mod publish; + +mod orphans; +pub(crate) use orphans::OrphanReconciliation; diff --git a/crates/buzz-acp/src/session_controls/compaction.rs b/crates/buzz-acp/src/session_controls/compaction.rs new file mode 100644 index 00000000000..d5a098808b5 --- /dev/null +++ b/crates/buzz-acp/src/session_controls/compaction.rs @@ -0,0 +1,456 @@ +//! Bounded manual control jobs; provider execution belongs to the exact owner. +use super::*; +use crate::session_store::{ControlAttempt, ControlReceipt, ControlReceiptState}; +use buzz_core::session_control::{ControlOutcome, ControlResult}; +use std::time::Duration; + +// Waiting for a foreground turn must not consume the provider execution budget. +pub(crate) const COMPACT_QUEUE_WAIT: Duration = + Duration::from_secs(buzz_core::session_control::CONTROL_MAX_AGE_SECS); +pub(crate) const COMPACT_EXECUTION_WAIT: Duration = Duration::from_secs(120); + +#[derive(Clone)] +pub(crate) enum CompactionTarget { + Memory { + worker: usize, + generation: u64, + session_id: String, + }, + Disk { + lease: Uuid, + session_id: String, + }, +} + +#[derive(Clone)] +pub(crate) struct AdmittedSessionControl { + pub(crate) job: ControlJob, + pub(crate) dispatch: ControlDispatch, + pub(crate) target: CompactionTarget, + pub(crate) deadline: tokio::time::Instant, +} + +pub(super) struct PendingCompaction { + pub(super) admitted: AdmittedSessionControl, + pub(super) worker: Option, + waiting_reported: bool, + started: Option, +} + +impl SessionControlRuntime { + pub(crate) fn control_store(&self) -> Option> { + self.store.clone() + } + + pub(crate) fn queue_compaction( + &self, + job: ControlJob, + target: Result, + ) -> Result<(), ControlFailure> { + let cached = { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + state.prune_outcomes(); + state.outcomes.get(&job.origin.command_event_id).copied() + }; + if let Some((_, outcome)) = cached { + self.queue_result( + job.origin.command_event_id, + ControlResult::Terminal(outcome), + ); + return Ok(()); + } + // Durable redelivery precedes in-memory dedup and cannot rerun an effect. + if let Some(store) = &self.store { + match store.load(&job.scope) { + LoadOutcome::Found(record) => { + if let Some(receipt) = record + .control_receipts + .iter() + .find(|receipt| receipt.command_id == job.origin.command_event_id) + { + if let ControlReceiptState::Finished(result) = receipt.state { + self.queue_result( + job.origin.command_event_id, + ControlResult::Terminal(result), + ); + } + return Ok(()); + } + } + LoadOutcome::Unreadable => return Err(ControlFailure::StoreUnavailable), + LoadOutcome::Missing => {} + } + } + let target = match target { + Ok(target) => target, + Err(failure) => return self.refuse_compaction(&job, failure), + }; + let dispatch = self.capture(&job.scope); + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + state.prune_outcomes(); + if let Some(pending) = state.compactions.get(&job.scope) { + if pending.admitted.job.origin.command_event_id == job.origin.command_event_id { + if pending.waiting_reported { + state.publish(Publication { + command_id: job.origin.command_event_id.clone(), + result: ControlResult::Pending, + }); + } + return Ok(()); + } + drop(state); + return self.refuse_compaction(&job, ControlFailure::Busy); + } + let scopes: std::collections::HashSet<_> = state + .scopes + .iter() + .filter(|(_, entry)| entry.retiring.is_some()) + .map(|(scope, _)| scope) + .chain(state.compactions.keys()) + .collect(); + if scopes.len() + state.receipt_writes.len() >= 128 + || state + .scopes + .get(&job.scope) + .is_some_and(|entry| entry.retiring.is_some()) + { + drop(state); + return self.refuse_compaction(&job, ControlFailure::Busy); + } + let scope = job.scope.clone(); + state.compactions.insert( + scope, + PendingCompaction { + admitted: AdmittedSessionControl { + job, + dispatch, + target, + deadline: tokio::time::Instant::now() + COMPACT_QUEUE_WAIT, + }, + worker: None, + waiting_reported: false, + started: None, + }, + ); + Ok(()) + } + + fn refuse_compaction( + &self, + job: &ControlJob, + mut failure: ControlFailure, + ) -> Result<(), ControlFailure> { + let operation_outcome = Err(failure); + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + let pending_scopes = state + .scopes + .iter() + .filter(|(_, entry)| entry.retiring.is_some()) + .map(|(scope, _)| scope) + .chain(state.compactions.keys()) + .collect::>() + .len(); + if pending_scopes + state.receipt_writes.len() >= 128 { + state.remember( + job.origin.command_event_id.clone(), + Err(ControlFailure::Busy), + ); + return Err(ControlFailure::Busy); + } + if let Some(store) = &self.store { + match store.begin_admitted_control_with_receipt( + &job.scope, + &job.origin.command_event_id, + job.event.created_at.as_secs(), + job.admitted_at, + ) { + Ok((ControlAttempt::New, receipt)) => { + if let Err(error) = store.finish_control_attempt( + &job.scope, + &job.origin.command_event_id, + &Err(failure), + ) { + failure = store_failure(error); + state.retain_receipt_write( + store, + &job.scope, + receipt, + Err(failure), + operation_outcome, + ); + } + } + Ok((ControlAttempt::PriorFinished(outcome), _)) => { + state.publish(Publication { + command_id: job.origin.command_event_id.clone(), + result: ControlResult::Terminal(outcome), + }); + return Ok(()); + } + Ok((ControlAttempt::PriorStarted, _)) => return Ok(()), + Err(error) => failure = store_failure(error), + } + } + state.remember(job.origin.command_event_id.clone(), Err(failure)); + Err(failure) + } + + pub(crate) fn compact_pending(&self, scope: &SessionScope) -> bool { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .compactions + .contains_key(scope) + } + + pub(crate) fn pending_compactions(&self) -> Vec { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .compactions + .values() + .filter(|pending| pending.worker.is_none()) + .map(|pending| pending.admitted.clone()) + .collect() + } + + pub(crate) fn report_compaction_wait(&self, job: &AdmittedSessionControl) { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + let Some(pending) = state.compactions.get_mut(&job.job.scope) else { + return; + }; + if pending.admitted.job.origin != job.job.origin + || pending.worker.is_some() + || pending.waiting_reported + { + return; + } + pending.waiting_reported = true; + state.publish(Publication { + command_id: job.job.origin.command_event_id.clone(), + result: ControlResult::Pending, + }); + } + + pub(crate) fn start_compaction(&self, job: &AdmittedSessionControl, worker: usize) -> bool { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + let Some(pending) = state.compactions.get_mut(&job.job.scope) else { + return false; + }; + if pending.worker.is_some() || pending.admitted.job.origin != job.job.origin { + return false; + } + pending.admitted.deadline = job.deadline; + pending.worker = Some(worker); + true + } + + pub(crate) fn begin_compaction_effect( + &self, + job: &AdmittedSessionControl, + ) -> Result { + if !job.dispatch.current() { + return Err(ControlFailure::Superseded); + } + // Keep the owning controller entry locked across durable Started and + // capture: timeout/cancel/panic must not depend on this future returning. + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + let pending = state + .compactions + .get_mut(&job.job.scope) + .filter(|pending| pending.admitted.job.origin == job.job.origin) + .ok_or(ControlFailure::Superseded)?; + let store = self + .store + .as_ref() + .ok_or(ControlFailure::StoreUnavailable)?; + let (attempt, receipt) = store + .begin_admitted_control_with_receipt( + &job.job.scope, + &job.job.origin.command_event_id, + job.job.event.created_at.as_secs(), + job.job.admitted_at, + ) + .map_err(store_failure)?; + if receipt.owner_instance == store.instance() + && receipt.state == ControlReceiptState::Started + { + pending.started = Some(receipt); + } + Ok(attempt) + } + + pub(crate) fn finish_compaction_job( + &self, + job: &AdmittedSessionControl, + mut outcome: ControlOutcome, + ) { + if !job.dispatch.current() { + outcome = Err(ControlFailure::Superseded); + } + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + // A restart/removal already terminalized this job; its first result wins. + if !state + .compactions + .get(&job.job.scope) + .is_some_and(|pending| pending.admitted.job.origin == job.job.origin) + { + return; + } + let operation_outcome = outcome; + let mut expected = state + .compactions + .get(&job.job.scope) + .and_then(|pending| pending.started.clone()); + if let Some(store) = &self.store { + if let Err(error) = store.finish_control_attempt( + &job.job.scope, + &job.job.origin.command_event_id, + &outcome, + ) { + let unresolved = match store.begin_admitted_control_with_receipt( + &job.job.scope, + &job.job.origin.command_event_id, + job.job.event.created_at.as_secs(), + job.job.admitted_at, + ) { + Ok((ControlAttempt::PriorFinished(prior), _)) => { + outcome = prior; + false + } + Ok((ControlAttempt::New | ControlAttempt::PriorStarted, receipt)) => { + if expected.is_none() + && receipt.owner_instance == store.instance() + && receipt.state == ControlReceiptState::Started + { + expected = Some(receipt); + } + store + .finish_control_attempt( + &job.job.scope, + &job.job.origin.command_event_id, + &outcome, + ) + .is_err() + } + Err(_) => true, + }; + if unresolved { + tracing::warn!(%error, "compaction terminal receipt unavailable; preserving known local Started"); + outcome = Err(ControlFailure::StoreUnavailable); + if let Some(receipt) = expected { + state.retain_receipt_write( + store, + &job.job.scope, + receipt, + outcome, + operation_outcome, + ); + } + } + } + } + state.compactions.remove(&job.job.scope); + state.remember(job.job.origin.command_event_id.clone(), outcome); + state.publish(Publication { + command_id: job.job.origin.command_event_id.clone(), + result: ControlResult::Terminal(outcome), + }); + } + + pub(crate) fn shutdown_compactions(&self) -> std::collections::HashSet { + let jobs: Vec<_> = self + .state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .compactions + .values() + .map(|pending| (pending.admitted.clone(), pending.worker)) + .collect(); + let workers = jobs.iter().filter_map(|(_, worker)| *worker).collect(); + for (job, worker) in jobs { + self.finish_compaction_job( + &job, + Err(if worker.is_some() { + ControlFailure::Unconfirmed + } else { + ControlFailure::Cancelled + }), + ); + } + workers + } + + pub(crate) fn compaction_panicked(&self, worker: usize) { + let jobs: Vec<_> = self + .state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .compactions + .values() + .filter(|pending| pending.worker == Some(worker)) + .map(|pending| pending.admitted.clone()) + .collect(); + for job in jobs { + self.finish_compaction_job(&job, Err(ControlFailure::Unconfirmed)); + } + } + + pub(crate) fn compaction_deadline(&self) -> Option { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .compactions + .values() + .filter(|pending| pending.worker.is_none()) + .map(|pending| pending.admitted.deadline) + .min() + } +} + +impl State { + fn prune_outcomes(&mut self) { + self.outcomes + .retain(|_, (at, _)| at.elapsed() < Duration::from_secs(86400)); + } + pub(super) fn remember(&mut self, id: String, result: ControlOutcome) { + self.prune_outcomes(); + if self.outcomes.len() >= 128 { + if let Some(oldest) = self + .outcomes + .iter() + .min_by_key(|(_, (at, _))| *at) + .map(|(id, _)| id.clone()) + { + self.outcomes.remove(&oldest); + } + } + self.outcomes + .insert(id, (tokio::time::Instant::now(), result)); + } +} + +pub(crate) fn store_failure(error: StoreError) -> ControlFailure { + match error { + StoreError::Busy => ControlFailure::Busy, + StoreError::Conflict => ControlFailure::StaleTarget, + StoreError::StaleRequest => ControlFailure::StaleRequest, + _ => ControlFailure::StoreUnavailable, + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test(start_paused = true)] + async fn control_outcome_retention_is_bounded() { + let mut state = State::default(); + for n in 0..1024 { + state.remember(format!("{n:064x}"), Ok(())); + } + assert_eq!(state.outcomes.len(), 128); + tokio::time::advance(Duration::from_secs(86400)).await; + state.prune_outcomes(); + assert!(state.outcomes.is_empty()); + } +} diff --git a/crates/buzz-acp/src/session_controls/orphan_owner_tests.rs b/crates/buzz-acp/src/session_controls/orphan_owner_tests.rs new file mode 100644 index 00000000000..ff9e2189ca7 --- /dev/null +++ b/crates/buzz-acp/src/session_controls/orphan_owner_tests.rs @@ -0,0 +1,129 @@ +//! Autonomous owner reconciliation through the production driver and real locks. +use super::*; +use crate::scope::SessionScope; +use crate::session_store::{ControlReceiptState, LoadOutcome, SessionStore, STORE_LOCK_WAIT}; +use fs2::FileExt; + +fn fixture() -> ( + tempfile::TempDir, + SessionStore, + Arc, + SessionScope, + String, +) { + let dir = tempfile::tempdir().unwrap(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let owner = SessionStore::open(dir.path().into(), "wss://relay.example", &agent).unwrap(); + let scope = SessionScope::Conversation { + channel_id: Uuid::new_v4(), + }; + let command = "a".repeat(64); + owner + .begin_control_attempt(&scope, &command, nostr::Timestamp::now().as_secs()) + .unwrap(); + let next = + Arc::new(SessionStore::open(dir.path().into(), "wss://relay.example", &agent).unwrap()); + (dir, owner, next, scope, command) +} + +async fn assert_eventually_finished( + reconciliation: &mut OrphanReconciliation, + controls: &SessionControlRuntime, + store: &SessionStore, + scope: &SessionScope, + command: &str, +) { + tokio::time::timeout(Duration::from_secs(8), async { + loop { + reconciliation.wake().await; + reconciliation.drive(controls, 128); + let publications = controls.take_publications(); + if !publications.is_empty() { + assert_eq!(publications.len(), 1); + assert_eq!(publications[0].command_id, command); + assert_eq!( + publications[0].result, + ControlResult::Terminal(Err(ControlFailure::Unconfirmed)) + ); + break; + } + } + }) + .await + .expect("foreign Started must be revisited without another event or failure"); + assert!(matches!(store.load(scope), LoadOutcome::Found(record) + if record.control_receipts[0].state == ControlReceiptState::Finished(Err(ControlFailure::Unconfirmed)))); + reconciliation.drive(controls, 128); + assert!(controls.take_publications().is_empty()); + assert!(reconciliation.next.is_none()); +} + +#[tokio::test] +async fn orphan_registry_unknown_revisits_after_lock_release_without_external_wake() { + let (_dir, owner, store, scope, command) = fixture(); + drop(owner); + let registry = store + .channel_dir(scope.channel_id()) + .parent() + .unwrap() + .join("instances.lock"); + let lock = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(registry) + .unwrap(); + lock.lock_exclusive().unwrap(); + let controls = SessionControlRuntime::new(Some(store.clone())); + let mut reconciliation = OrphanReconciliation::default(); + let started = std::time::Instant::now(); + reconciliation.drive(&controls, 128); + reconciliation.wake().await; + reconciliation.drive(&controls, 128); + assert!(started.elapsed() >= STORE_LOCK_WAIT); + assert!(controls.take_publications().is_empty()); + assert!(matches!(store.load(&scope), LoadOutcome::Found(record) + if record.control_receipts[0].state == ControlReceiptState::Started)); + drop(lock); + assert_eventually_finished(&mut reconciliation, &controls, &store, &scope, &command).await; +} + +#[tokio::test] +async fn orphan_alive_foreign_owner_is_revisited_after_it_dies() { + let (_dir, owner, store, scope, command) = fixture(); + let controls = SessionControlRuntime::new(Some(store.clone())); + let mut reconciliation = OrphanReconciliation::default(); + reconciliation.drive(&controls, 128); + reconciliation.wake().await; + reconciliation.drive(&controls, 128); + assert!(controls.take_publications().is_empty()); + assert!(matches!(store.load(&scope), LoadOutcome::Found(record) + if record.control_receipts[0].state == ControlReceiptState::Started)); + drop(owner); + assert_eventually_finished(&mut reconciliation, &controls, &store, &scope, &command).await; +} + +#[tokio::test(start_paused = true)] +async fn orphan_live_owner_revisit_uses_capped_backoff_and_never_spins_without_capacity() { + use futures_util::FutureExt; + let (_dir, _owner, store, scope, _) = fixture(); + let controls = SessionControlRuntime::new(Some(store.clone())); + let mut reconciliation = OrphanReconciliation::default(); + reconciliation.drive(&controls, 128); + for seconds in [1, 2, 4, 8, 16, 32, 60, 60] { + reconciliation.wake().await; + reconciliation.drive(&controls, 128); + assert_eq!( + reconciliation.next.unwrap() - Instant::now(), + Duration::from_secs(seconds) + ); + assert!(controls.take_publications().is_empty()); + assert!(matches!(store.load(&scope), LoadOutcome::Found(record) + if record.control_receipts[0].state == ControlReceiptState::Started)); + tokio::time::advance(Duration::from_secs(seconds)).await; + reconciliation.drive(&controls, 0); + assert!(reconciliation.task.is_none()); + assert!(reconciliation.wake().now_or_never().is_none()); + reconciliation.drive(&controls, 128); + } + reconciliation.wake().await; +} diff --git a/crates/buzz-acp/src/session_controls/orphans.rs b/crates/buzz-acp/src/session_controls/orphans.rs new file mode 100644 index 00000000000..f2ff8dae5ef --- /dev/null +++ b/crates/buzz-acp/src/session_controls/orphans.rs @@ -0,0 +1,233 @@ +//! Bounded-backoff startup reconciliation, with store I/O outside the relay loop. +use super::*; +use buzz_core::session_control::ControlResult; +use std::time::Duration; +use tokio::time::Instant; + +#[derive(Debug, Default)] +pub(crate) struct SweepProgress { + more: bool, + failures: usize, + revisit: bool, +} + +impl SessionControlRuntime { + pub(crate) fn sweep_orphans(&self, capacity: usize) -> Result { + let Some(store) = &self.store else { + return Ok(SweepProgress::default()); + }; + let local = self.retry_receipt_writes(capacity); + let progress = store.sweep_orphan_controls(capacity.saturating_sub(local.completed))?; + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + for command_id in progress.finished { + state.publish(Publication { + command_id, + result: ControlResult::Terminal(Err(ControlFailure::Unconfirmed)), + }); + } + Ok(SweepProgress { + more: progress.more || local.more, + failures: progress.failures + local.failures, + revisit: progress.revisit, + }) + } +} + +type SweepResult = Result, tokio::task::JoinError>; + +pub(crate) struct OrphanReconciliation { + task: Option>>, + completed: Option, + next: Option, + backoff: Duration, + waiting_capacity: bool, +} + +impl Default for OrphanReconciliation { + fn default() -> Self { + Self { + task: None, + completed: None, + next: Some(Instant::now()), + backoff: Duration::from_secs(1), + waiting_capacity: false, + } + } +} + +impl OrphanReconciliation { + /// Each sweep publishes its successful prefix before returning retry status. + pub(crate) fn drive(&mut self, controls: &SessionControlRuntime, capacity: usize) { + if let Some(result) = self.completed.take() { + match result { + Ok(Ok(progress)) if progress.failures == 0 => { + self.next = if progress.more { + Some(Instant::now()) + } else if progress.revisit { + let next = Instant::now() + self.backoff; + self.backoff = (self.backoff * 2).min(Duration::from_secs(60)); + Some(next) + } else { + self.backoff = Duration::from_secs(1); + None + }; + } + failure => { + tracing::warn!( + ?failure, + retry_secs = self.backoff.as_secs(), + "orphan reconciliation incomplete; retry scheduled" + ); + self.next = Some(Instant::now() + self.backoff); + self.backoff = (self.backoff * 2).min(Duration::from_secs(60)); + } + } + } + if self.next.is_none() && self.task.is_none() && controls.has_receipt_writes() { + self.next = Some(Instant::now()); + } + self.waiting_capacity = capacity == 0; + if self.task.is_none() + && capacity > 0 + && self.next.is_some_and(|next| next <= Instant::now()) + { + let controls = controls.clone(); + self.next = None; + self.task = Some(tokio::task::spawn_blocking(move || { + controls.sweep_orphans(capacity.min(128)) + })); + } + } + + /// Task completion and retry deadlines wake even a completely idle harness. + pub(crate) async fn wake(&mut self) { + if let Some(task) = &mut self.task { + self.completed = Some(task.await); + self.task = None; + } else if let Some(next) = self.next.filter(|_| !self.waiting_capacity) { + tokio::time::sleep_until(next).await; + } else { + std::future::pending::<()>().await; + } + } +} + +#[cfg(test)] +#[path = "orphan_owner_tests.rs"] +mod owner_tests; + +#[cfg(test)] +mod tests { + use super::*; + use crate::scope::SessionScope; + use crate::session_store::{ControlReceiptState, LoadOutcome}; + use futures_util::FutureExt; + + fn dead_started() -> (tempfile::TempDir, Arc, SessionScope, String) { + let dir = tempfile::tempdir().unwrap(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let first = SessionStore::open(dir.path().into(), "wss://relay.example", &agent).unwrap(); + let scope = SessionScope::Conversation { + channel_id: Uuid::new_v4(), + }; + let command = "a".repeat(64); + first + .begin_control_attempt(&scope, &command, nostr::Timestamp::now().as_secs()) + .unwrap(); + drop(first); + let store = + Arc::new(SessionStore::open(dir.path().into(), "wss://relay.example", &agent).unwrap()); + (dir, store, scope, command) + } + + #[tokio::test(start_paused = true)] + async fn orphan_reconciliation_retries_failed_writes_with_capped_backoff_without_capacity_spin() + { + let (_dir, store, scope, command) = dead_started(); + store.fail_writes(true); + let controls = SessionControlRuntime::new(Some(store.clone())); + let mut reconciliation = OrphanReconciliation::default(); + reconciliation.drive(&controls, 128); + for seconds in [1, 2, 4, 8, 16, 32, 60, 60] { + reconciliation.wake().await; + reconciliation.drive(&controls, 128); + assert!(reconciliation.task.is_none()); + assert_eq!( + reconciliation.next.unwrap() - Instant::now(), + Duration::from_secs(seconds) + ); + assert!(controls.take_publications().is_empty()); + assert!( + matches!(store.load(&scope), LoadOutcome::Found(record) if record.control_receipts[0].state == ControlReceiptState::Started) + ); + tokio::time::advance(Duration::from_secs(seconds)).await; + reconciliation.drive(&controls, 0); + assert!(reconciliation.task.is_none()); + assert!( + reconciliation.wake().now_or_never().is_none(), + "a past retry deadline with no capacity must not spin" + ); + reconciliation.drive(&controls, 128); + assert!(reconciliation.task.is_some()); + } + store.fail_writes(false); + // One failed attempt might already be running; consume it, then repair + // the scheduled retry using the real store rather than a synthetic result. + reconciliation.wake().await; + reconciliation.drive(&controls, 128); + if let Some(next) = reconciliation.next { + tokio::time::sleep_until(next).await; + reconciliation.drive(&controls, 128); + reconciliation.wake().await; + reconciliation.drive(&controls, 128); + } + assert_eq!( + controls + .take_publications() + .into_iter() + .map(|p| p.command_id) + .collect::>(), + vec![command] + ); + assert!( + matches!(store.load(&scope), LoadOutcome::Found(record) if matches!(record.control_receipts[0].state, ControlReceiptState::Finished(_))) + ); + assert!(reconciliation.next.is_none()); + assert!(reconciliation.wake().now_or_never().is_none()); + } + + #[tokio::test(start_paused = true)] + async fn orphan_reconciliation_global_read_failure_retries_and_publishes_after_repair() { + let (_dir, store, scope, command) = dead_started(); + let channel = store.channel_dir(scope.channel_id()); + let namespace = channel.parent().unwrap().to_path_buf(); + let saved = namespace.with_extension("held"); + std::fs::rename(&namespace, &saved).unwrap(); + std::fs::write(&namespace, b"temporary blocker").unwrap(); + let controls = SessionControlRuntime::new(Some(store)); + let mut reconciliation = OrphanReconciliation::default(); + reconciliation.drive(&controls, 128); + reconciliation.wake().await; + reconciliation.drive(&controls, 128); + assert_eq!( + reconciliation.next.unwrap() - Instant::now(), + Duration::from_secs(1) + ); + assert!(controls.take_publications().is_empty()); + std::fs::remove_file(&namespace).unwrap(); + std::fs::rename(saved, namespace).unwrap(); + tokio::time::advance(Duration::from_secs(1)).await; + reconciliation.drive(&controls, 128); + reconciliation.wake().await; + reconciliation.drive(&controls, 128); + assert_eq!( + controls + .take_publications() + .into_iter() + .map(|p| p.command_id) + .collect::>(), + vec![command] + ); + assert!(reconciliation.next.is_none()); + } +} diff --git a/crates/buzz-acp/src/session_controls/publish.rs b/crates/buzz-acp/src/session_controls/publish.rs new file mode 100644 index 00000000000..adfcb7e8adf --- /dev/null +++ b/crates/buzz-acp/src/session_controls/publish.rs @@ -0,0 +1,166 @@ +//! Signed bounded result delivery, reusable by manual compaction. +use super::Publication; +use buzz_core::session_control::SessionResult; + +pub(crate) fn build_result( + keys: &nostr::Keys, + publication: &Publication, +) -> anyhow::Result { + let result = SessionResult { + command_id: publication.command_id.clone(), + result: publication.result, + }; + let tag = result + .event_tag() + .ok_or_else(|| anyhow::anyhow!("invalid command identity"))?; + let target = nostr::EventId::from_hex(&publication.command_id)?; + Ok(buzz_sdk::build_reaction(target, result.result.reaction())? + .tag(nostr::Tag::parse(tag)?) + .sign_with_keys(keys)?) +} + +/// Publish the same signed event at most three times; publication cannot rerun +/// the operation. The caller retains the durable outcome on delivery failure. +pub(crate) async fn publish_result( + rest: &crate::relay::RestClient, + publication: &Publication, +) -> anyhow::Result<()> { + let event = build_result(&rest.keys, publication)?; + let mut last = None; + for delay in [0, 1, 2] { + if delay != 0 { + tokio::time::sleep(std::time::Duration::from_secs(delay)).await; + } + match tokio::time::timeout( + std::time::Duration::from_secs(3), + rest.submit_event_once(&event), + ) + .await + { + Ok(Ok(_)) => { + if matches!( + publication.result, + buzz_core::session_control::ControlResult::Terminal(_) + ) { + crate::pool::reaction_remove(rest, &publication.command_id, "⏳").await; + } + return Ok(()); + } + Ok(Err(error)) => last = Some(error.to_string()), + Err(_) => last = Some("result publication timed out".into()), + } + } + Err(anyhow::anyhow!("{}", last.unwrap_or_default())) +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::session_control::{ControlFailure, ControlResult}; + #[test] + fn control_results_are_signed_and_content_matches_canonical_result_tag() { + let keys = nostr::Keys::generate(); + for result in [ + ControlResult::Pending, + ControlResult::Terminal(Ok(())), + ControlResult::Terminal(Err(ControlFailure::Unconfirmed)), + ] { + let publication = Publication { + command_id: "a".repeat(64), + result, + }; + let event = build_result(&keys, &publication).unwrap(); + event.verify().unwrap(); + assert_eq!(event.kind.as_u16(), 7); + let tags = event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect::>(); + assert_eq!( + SessionResult::from_tags(&event.content, &tags) + .unwrap() + .result, + result + ); + assert!(tags + .iter() + .any(|tag| tag.first().map(String::as_str) == Some("e") + && tag.get(1) == Some(&publication.command_id))); + } + } +} + +#[cfg(test)] +mod queue_tests { + use buzz_core::session_control::ControlResult; + #[test] + fn publication_queue_is_bounded_and_terminal_replaces_pending() { + let runtime = crate::session_controls::SessionControlRuntime::default(); + for id in 0..1000 { + runtime.queue_result(format!("{id:064x}"), ControlResult::Pending); + } + runtime.queue_result(format!("{:064x}", 0), ControlResult::Terminal(Ok(()))); + let publications = runtime.take_publications(); + assert!(publications.len() <= 128); + assert!(publications.iter().any(|publication| publication.command_id + == format!("{:064x}", 0) + && publication.result == ControlResult::Terminal(Ok(())))); + assert!( + !publications.iter().any( + |publication| publication.command_id == format!("{:064x}", 0) + && publication.result == ControlResult::Pending + ) + ); + } +} + +#[cfg(test)] +mod retry_tests { + use super::*; + use std::sync::{Arc, Mutex}; + #[tokio::test] + async fn control_result_publication_has_three_total_signed_attempts() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let attempts = Arc::new(Mutex::new(Vec::new())); + let received = attempts.clone(); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut request = vec![0; 8192]; + let count = socket.read(&mut request).await.unwrap(); + received.lock().unwrap().push(request[..count].to_vec()); + socket.write_all(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}").await.unwrap(); + } + }); + let rest = crate::relay::RestClient { + http: reqwest::Client::new(), + base_url: url, + keys: nostr::Keys::generate(), + auth_tag_json: None, + }; + let publication = Publication { + command_id: "a".repeat(64), + result: buzz_core::session_control::ControlResult::Terminal(Ok(())), + }; + assert!(publish_result(&rest, &publication).await.is_err()); + let requests = attempts.lock().unwrap(); + assert_eq!( + requests.len(), + 3, + "result helper owns the entire retry budget" + ); + let mut ids = Vec::new(); + for request in requests.iter() { + let text = String::from_utf8_lossy(request); + assert!(text.to_ascii_lowercase().contains("authorization: nostr ")); + let (_, body) = text.split_once("\r\n\r\n").unwrap(); + let event: nostr::Event = serde_json::from_str(body).unwrap(); + event.verify().unwrap(); + ids.push(event.id); + } + assert!(ids.windows(2).all(|pair| pair[0] == pair[1])); + server.abort(); + } +} diff --git a/crates/buzz-acp/src/session_controls/receipt_writes.rs b/crates/buzz-acp/src/session_controls/receipt_writes.rs new file mode 100644 index 00000000000..8e82938dc0a --- /dev/null +++ b/crates/buzz-acp/src/session_controls/receipt_writes.rs @@ -0,0 +1,117 @@ +//! Known-finalized local receipt writes share the existing reconciliation backoff. +use super::*; +use crate::session_store::{ControlReceipt, ControlReceiptState}; +use buzz_core::session_control::{ControlOutcome, ControlResult}; + +#[derive(Clone)] +pub(super) struct ReceiptPersistence { + pub(super) scope: SessionScope, + receipt: ControlReceipt, + outcome: ControlOutcome, + operation_outcome: ControlOutcome, +} + +#[derive(Default)] +pub(super) struct ReceiptWriteProgress { + pub(super) completed: usize, + pub(super) failures: usize, + pub(super) more: bool, +} + +impl State { + pub(super) fn retain_receipt_write( + &mut self, + store: &SessionStore, + scope: &SessionScope, + receipt: ControlReceipt, + outcome: ControlOutcome, + operation_outcome: ControlOutcome, + ) { + if receipt.owner_instance != store.instance() + || receipt.state != ControlReceiptState::Started + { + return; + } + let id = receipt.command_id.clone(); + if self.receipt_writes.len() >= 128 && !self.receipt_writes.contains_key(&id) { + // Admission reserves this bounded capacity before any effect. + tracing::error!( + command = id, + "receipt persistence capacity invariant violated; durable Started retained" + ); + return; + } + self.receipt_writes.insert( + id, + ReceiptPersistence { + scope: scope.clone(), + receipt, + outcome, + operation_outcome, + }, + ); + } +} + +impl SessionControlRuntime { + pub(super) fn has_receipt_writes(&self) -> bool { + !self + .state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .receipt_writes + .is_empty() + } + + pub(super) fn retry_receipt_writes(&self, capacity: usize) -> ReceiptWriteProgress { + let Some(store) = &self.store else { + return ReceiptWriteProgress::default(); + }; + let pending: Vec<_> = self + .state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .receipt_writes + .iter() + .take(capacity.min(128)) + .map(|(id, entry)| (id.clone(), entry.clone())) + .collect(); + let mut progress = ReceiptWriteProgress::default(); + for (id, entry) in pending { + match store.retry_control_outcome(&entry.scope, &entry.receipt, &entry.outcome) { + Ok(resolved) => { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state.receipt_writes.get(&id).is_some_and(|current| { + current.receipt.owner_instance == entry.receipt.owner_instance + && current.receipt.owner_generation == entry.receipt.owner_generation + }) { + state.receipt_writes.remove(&id); + if let Some(outcome) = resolved { + state.remember(id.clone(), outcome); + state.publish(Publication { + command_id: id, + result: ControlResult::Terminal(outcome), + }); + } + progress.completed += 1; + } + } + Err(error) => { + progress.failures += 1; + tracing::warn!(%error, command = id, operation = ?entry.operation_outcome, "terminal receipt persistence deferred; provider operation will not repeat"); + } + } + } + progress.more = self.has_receipt_writes(); + progress + } + + #[cfg(all(test, unix))] + pub(crate) fn receipt_write_count(&self) -> usize { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .receipt_writes + .len() + } +} diff --git a/crates/buzz-acp/src/session_controls/retirement.rs b/crates/buzz-acp/src/session_controls/retirement.rs new file mode 100644 index 00000000000..05636440656 --- /dev/null +++ b/crates/buzz-acp/src/session_controls/retirement.rs @@ -0,0 +1,315 @@ +//! Controller-owned retirement jobs survive the disposable worker pool. +use super::*; +use crate::acp::TerminationReport; +use buzz_core::session_control::{ControlOutcome, ControlResult}; +use std::collections::{HashSet, VecDeque}; + +pub(crate) const FOREIGN_BACKGROUND_LIMIT: std::time::Duration = + std::time::Duration::from_secs(600); + +#[derive(Default)] +pub(super) struct Retirement { + targets: HashSet, + registered: bool, + terminal: bool, +} + +impl Retirement { + pub(super) fn reset(mut self) -> Self { + self.registered = false; + self.terminal = false; + self + } +} + +pub(super) struct Worker { + since: tokio::time::Instant, + running: bool, + failed: bool, + evidence_lost: bool, +} + +#[derive(Clone)] +pub(crate) struct Publication { + pub command_id: String, + pub result: ControlResult, +} + +impl SessionControlRuntime { + /// Reserve all copies before invalidation can erase the pool's directory. + pub(crate) fn plan_retirement( + &self, + scope: &SessionScope, + targets: impl IntoIterator, + ) { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + let targets: HashSet<_> = targets.into_iter().collect(); + let Some(entry) = state.scopes.get_mut(scope) else { + return; + }; + entry.retirement.targets.extend(targets); + entry.retirement.registered = true; + let targets = entry.retirement.targets.clone(); + for index in targets { + let worker = state.workers.entry(index).or_insert_with(|| Worker { + since: tokio::time::Instant::now(), + running: false, + failed: false, + evidence_lost: false, + }); + worker.failed = worker.evidence_lost; + } + if state.scopes.get(scope).is_some_and(|entry| { + entry.retirement.targets.iter().any(|index| { + state + .workers + .get(index) + .is_some_and(|worker| worker.evidence_lost) + }) + }) { + Self::terminal( + &mut state, + self.store.as_deref(), + scope, + Err(ControlFailure::Unconfirmed), + false, + ); + } + Self::finish_ready(&mut state, self.store.as_deref()); + } + + pub(crate) fn owns_retirement(&self, index: usize) -> bool { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .workers + .contains_key(&index) + } + + pub(crate) fn retirement_evidence_lost(&self, index: usize) -> bool { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .workers + .get(&index) + .is_some_and(|worker| worker.evidence_lost) + } + + /// A foreground unwind already dropped the original client without a checked + /// snapshot. No replacement or later retry can attest those escaped groups. + pub(crate) fn lose_retirement_evidence(&self, index: usize) { + { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if let Some(worker) = state.workers.get_mut(&index) { + worker.evidence_lost = true; + } + } + self.retirement_report(index, TerminationReport::Unverified); + } + + pub(crate) fn retiring_workers(&self) -> Vec<(usize, HashSet, bool)> { + let state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + state + .workers + .iter() + .filter(|(_, worker)| !worker.running && !worker.failed && !worker.evidence_lost) + .map(|(index, worker)| { + let scopes = state + .scopes + .iter() + .filter(|(_, entry)| { + entry.retiring.is_some() && entry.retirement.targets.contains(index) + }) + .map(|(scope, _)| scope.clone()) + .collect(); + ( + *index, + scopes, + worker.since.elapsed() >= FOREIGN_BACKGROUND_LIMIT, + ) + }) + .collect() + } + + pub(crate) fn retirement_started(&self, index: usize) { + if let Some(worker) = self + .state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .workers + .get_mut(&index) + { + worker.running = true; + } + } + + pub(crate) fn retirement_report(&self, index: usize, report: TerminationReport) { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + let report = if state + .workers + .get(&index) + .is_some_and(|worker| worker.evidence_lost) + { + TerminationReport::Unverified + } else { + report + }; + if report == TerminationReport::Clean { + state.workers.remove(&index); + for entry in state.scopes.values_mut() { + entry.retirement.targets.remove(&index); + } + Self::finish_ready(&mut state, self.store.as_deref()); + } else { + if let Some(worker) = state.workers.get_mut(&index) { + worker.running = false; + worker.failed = true; + } + let scopes: Vec<_> = state + .scopes + .iter() + .filter(|(_, entry)| { + entry.retirement.targets.contains(&index) && !entry.retirement.terminal + }) + .map(|(scope, _)| scope.clone()) + .collect(); + for scope in scopes { + Self::terminal( + &mut state, + self.store.as_deref(), + &scope, + Err(ControlFailure::Unconfirmed), + false, + ); + } + } + } + + fn finish_ready(state: &mut State, store: Option<&SessionStore>) { + let scopes: Vec<_> = state + .scopes + .iter() + .filter(|(_, entry)| { + entry.retiring.is_some() + && entry.retirement.registered + && entry.retirement.targets.is_empty() + && !entry.retirement.terminal + }) + .map(|(scope, _)| scope.clone()) + .collect(); + for scope in scopes { + Self::terminal(state, store, &scope, Ok(()), true); + } + } + + fn terminal( + state: &mut State, + store: Option<&SessionStore>, + scope: &SessionScope, + outcome: ControlOutcome, + release: bool, + ) { + let Some(entry) = state.scopes.get_mut(scope) else { + return; + }; + let Some(job) = &entry.retiring else { + return; + }; + // A failed terminal persist never silently releases the gate. + if let Some(store) = store { + if let Err(error) = + store.finish_control_attempt(scope, &job.origin.command_event_id, &outcome) + { + tracing::warn!(%error, "session control result could not be persisted; restart remains gated"); + let command_id = job.origin.command_event_id.clone(); + entry.retirement.terminal = true; + state.publish(Publication { + command_id, + result: ControlResult::Terminal(Err(ControlFailure::Unconfirmed)), + }); + return; + } + } + let command_id = job.origin.command_event_id.clone(); + // Reset completion stays independent; a cancelled follow-up must not + // acquire a fresh post-cancellation epoch when retirement finishes. + let status_current = entry.epoch == job.dispatch_epoch; + entry.retirement.terminal = true; + if release { + entry.retiring = None; + } + if release && outcome.is_ok() && status_current { + Self::status_ready(state, store, scope); + } + state.publish(Publication { + command_id, + result: ControlResult::Terminal(outcome), + }); + } + + pub(crate) fn publish_pending(&self, scope: &SessionScope) { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if let Some(command_id) = state + .scopes + .get(scope) + .and_then(|entry| entry.retiring.as_ref()) + .map(|job| job.origin.command_event_id.clone()) + { + state.publish(Publication { + command_id, + result: ControlResult::Pending, + }); + } + } + + #[cfg(test)] + pub(crate) fn take_publications(&self) -> VecDeque { + self.take_publications_up_to(128) + } + + pub(crate) fn take_publications_up_to(&self, capacity: usize) -> VecDeque { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + let count = state.publications.len().min(capacity); + state.publications.drain(..count).collect() + } + + pub(crate) fn recycle_neighbor(&self, scope: &SessionScope) -> Result<(), StoreError> { + if let Some(store) = &self.store { + store.record_worker_recycled(scope)?; + } + Ok(()) + } + + pub(crate) fn recycled_reason(&self, scope: &SessionScope) -> bool { + matches!(self.store.as_ref().map(|store| store.load(scope)), Some(LoadOutcome::Found(record)) if record.reason == Some(crate::session_store::SessionReason::WorkerRecycled)) + } +} + +impl SessionControlRuntime { + pub(crate) fn prior_result( + &self, + scope: &SessionScope, + command: &str, + ) -> Option { + match self.store.as_ref()?.load(scope) { + LoadOutcome::Found(record) => record + .control_receipts + .iter() + .find(|receipt| receipt.command_id == command) + .map(|receipt| match receipt.state { + crate::session_store::ControlReceiptState::Started => ControlResult::Pending, + crate::session_store::ControlReceiptState::Finished(outcome) => { + ControlResult::Terminal(outcome) + } + }), + _ => None, + } + } + + pub(crate) fn queue_result(&self, command_id: String, result: ControlResult) { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .publish(Publication { command_id, result }); + } +} diff --git a/crates/buzz-acp/src/session_controls/status.rs b/crates/buzz-acp/src/session_controls/status.rs new file mode 100644 index 00000000000..b69f84f4ca2 --- /dev/null +++ b/crates/buzz-acp/src/session_controls/status.rs @@ -0,0 +1,381 @@ +//! A finite, independently fenced first turn after checked Restart retirement. +use super::*; +use crate::session_store::{RestartStatus, RestartStatusPhase}; +use std::collections::HashSet; +use tokio::time::{Duration, Instant}; + +#[derive(Clone)] +pub(crate) struct StatusJob { + pub saved: RestartStatus, + pub deadline: Instant, + pub busy: bool, +} + +#[derive(Clone)] +pub(super) struct Authority { + respond_to: crate::RespondTo, + allowlist: HashSet, + owner: Option, +} + +impl SessionControlRuntime { + pub(crate) fn configure_status_authority( + &self, + respond_to: crate::RespondTo, + allowlist: HashSet, + owner: Option, + ) { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + if state.status_authority.is_some() { + let scopes: Vec<_> = state.statuses.keys().cloned().collect(); + for scope in scopes { + if let Some(entry) = state.scopes.get_mut(&scope) { + entry.epoch = Uuid::new_v4(); + } + } + } + state.status_authority = Some(Authority { + respond_to, + allowlist, + owner, + }); + } + + pub(crate) async fn status_authorized( + &self, + status: &RestartStatus, + ctx: &PromptContext, + ) -> bool { + let agent = ctx.agent_keys.public_key().to_hex(); + let metadata = ctx + .channel_info + .resolve_channel_metadata(status.scope.channel_id()) + .await; + let is_dm = metadata + .as_ref() + .is_some_and(|info| info.channel_type == "dm"); + if SessionScope::derive( + ctx.session_policy, + status.scope.channel_id(), + is_dm, + &status.command, + ) != status.scope + || !status.command.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.first().is_some_and(|part| part == "p") + && parts.get(1).is_some_and(|part| part == &agent) + }) + { + return false; + } + let authority = self + .state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .status_authority + .clone(); + let Some(authority) = authority else { + return false; + }; + let mut gate = crate::InboundAuthorGate::connect( + &ctx.rest_client, + &ctx.agent_keys.public_key().to_hex(), + "restart status", + ) + .await; + let owner = crate::OwnerCache::new(authority.owner); + let event = crate::relay::BuzzEvent { + channel_id: status.scope.channel_id(), + event: status.command.clone(), + connection_generation: 0, + }; + let Some(admitted) = crate::authorize_normal_listener_event( + &mut gate, + event, + &authority.respond_to, + &authority.allowlist, + &owner, + &ctx.channel_info, + &ctx.rest_client, + ) + .await + else { + return false; + }; + let (_, actor) = admitted.into_parts(); + actor == status.origin.actor_pubkey + } + + pub(super) fn status_ready( + state: &mut State, + store: Option<&SessionStore>, + scope: &SessionScope, + ) { + let Some(store) = store else { + return; + }; + if let LoadOutcome::Found(record) = store.load(scope) { + if let Some(saved) = record.restart_status.filter(|status| status.active()) { + let remaining = saved + .deadline + .saturating_sub(nostr::Timestamp::now().as_secs()) + .min(120); + state.statuses.insert( + scope.clone(), + StatusJob { + saved, + deadline: Instant::now() + Duration::from_secs(remaining), + busy: false, + }, + ); + } + } + } + + pub(crate) fn pending_statuses(&self) -> Vec { + self.state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .statuses + .values() + .filter(|job| !job.busy) + .cloned() + .collect() + } + // Cancellation targets include waiting retirement and scheduled workerless + // feedback, which are deliberately excluded from the model scheduling list. + pub(crate) fn cancellable_status_scopes(&self) -> Vec { + let state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + state + .statuses + .keys() + .cloned() + .chain( + state + .scopes + .iter() + .filter(|(_, entry)| { + entry.restart_status_token.is_some() + && entry + .retiring + .as_ref() + .is_some_and(|job| job.dispatch_epoch == entry.epoch) + }) + .map(|(scope, _)| scope.clone()), + ) + .collect::>() + .into_iter() + .collect() + } + pub(crate) fn status_pending(&self, scope: &SessionScope) -> bool { + self.state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .statuses + .get(scope) + .is_some_and(|job| Instant::now() < job.deadline) + } + pub(crate) fn status_deadline(&self) -> Option { + self.state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .statuses + .values() + .filter(|job| !job.busy) + .map(|job| job.deadline) + .min() + } + pub(crate) fn start_status(&self, job: &StatusJob) -> bool { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + let Some(pending) = state + .statuses + .get_mut(&job.saved.scope) + .filter(|pending| pending.saved == job.saved && !pending.busy) + else { + return false; + }; + pending.busy = true; + true + } + pub(crate) fn status_current(&self, job: &StatusJob, dispatch: &ControlDispatch) -> bool { + if !dispatch.current() || Instant::now() >= job.deadline { + return false; + } + let Some(store) = self.store.as_ref() else { + return false; + }; + matches!(store.load(&job.saved.scope), LoadOutcome::Found(record) if record.pending_restart.as_ref().map(|intent| intent.token) == Some(job.saved.token) && record.restart_status.as_ref() == Some(&job.saved) && job.saved.active()) + } + pub(crate) fn persist_status( + &self, + job: &mut StatusJob, + next: RestartStatus, + consume: bool, + ) -> Result<(), StoreError> { + self.store + .as_ref() + .ok_or(StoreError::Unreadable)? + .status_transition(&job.saved, &next, consume)?; + job.saved = next; + Ok(()) + } + pub(crate) fn transition_status( + &self, + job: &mut StatusJob, + phase: RestartStatusPhase, + prepared: Option, + failure_notice: bool, + consume: bool, + ) -> Result<(), StoreError> { + let store = self.store.as_ref().ok_or(StoreError::Unreadable)?; + let mut next = job.saved.clone(); + next.phase = phase; + next.prepared = prepared; + next.failure_notice = failure_notice; + next.owner = store.instance(); + store.status_transition(&job.saved, &next, consume)?; + job.saved = next; + Ok(()) + } + pub(crate) fn finish_status(&self, job: &StatusJob) { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + if state + .statuses + .get(&job.saved.scope) + .is_some_and(|pending| pending.saved.token == job.saved.token) + { + state.statuses.remove(&job.saved.scope); + } + } + pub(crate) fn cancel_status(&self, scope: &SessionScope) -> bool { + self.cancel_status_outcome(scope) != StatusCancellation::None + } + pub(crate) fn cancel_status_outcome(&self, scope: &SessionScope) -> StatusCancellation { + self.cancel_status_expected(scope, None) + } + pub(crate) fn cancel_status_for_token(&self, scope: &SessionScope, token: Uuid) -> bool { + self.cancel_status_expected(scope, Some(token)) != StatusCancellation::None + } + fn cancel_status_expected( + &self, + scope: &SessionScope, + token: Option, + ) -> StatusCancellation { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + let local_token = state + .statuses + .get(scope) + .map(|job| job.saved.token) + .or_else(|| { + state + .scopes + .get(scope) + .filter(|entry| entry.retiring.is_some()) + .and_then(|entry| entry.restart_status_token) + }); + if token + .zip(local_token) + .is_some_and(|(expected, current)| expected != current) + { + return StatusCancellation::None; + } + // Local cancellation authority comes from the accepted job, not a + // successful store read. Revoke it before any potentially failing I/O. + if local_token.is_some() { + state.statuses.remove(scope); + if let Some(entry) = state.scopes.get_mut(scope) { + entry.epoch = Uuid::new_v4(); + } + } + let saved = match self.store.as_ref().map(|store| store.load(scope)) { + Some(LoadOutcome::Found(record)) => record.restart_status, + _ if local_token.is_some() => { + tracing::warn!("restart status cancellation record unavailable; local effects fenced and persistence unconfirmed"); + return StatusCancellation::Unconfirmed; + } + _ => return StatusCancellation::None, + }; + let Some(saved) = saved.filter(|saved| { + (saved.active() || saved.phase == RestartStatusPhase::WaitingRetirement) + && token.is_none_or(|token| saved.token == token) + && local_token.is_none_or(|local| saved.token == local) + }) else { + // Confirmed terminal status is preserved even if the busy cache lags. + return StatusCancellation::None; + }; + if local_token.is_none() { + if let Some(entry) = state.scopes.get_mut(scope) { + entry.epoch = Uuid::new_v4(); + } + } + let mut job = StatusJob { + saved, + deadline: Instant::now(), + busy: false, + }; + let prepared = job.saved.prepared.clone(); + let failure = job.saved.failure_notice; + let persistence = self.transition_status( + &mut job, + RestartStatusPhase::Cancelled, + prepared, + failure, + false, + ); + if let Err(error) = &persistence { + tracing::warn!(%error, "restart status cancellation could not be persisted; attempt remains uncertain"); + } + if persistence.is_ok() { + StatusCancellation::Cancelled + } else { + StatusCancellation::Unconfirmed + } + } + /// A known-local panic authorizes failure feedback, never a model replay. + pub(crate) fn status_panicked(&self, scope: &SessionScope, token: Uuid) -> Option { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + state + .statuses + .get(scope) + .filter(|job| job.saved.token == token)?; + let store = self.store.as_ref()?; + let LoadOutcome::Found(record) = store.load(scope) else { + return None; + }; + let saved = record.restart_status.filter(|saved| { + saved.token == token && saved.owner == store.instance() && saved.active() + })?; + let job = StatusJob { + saved, + deadline: Instant::now(), + busy: true, + }; + state.statuses.insert(scope.clone(), job.clone()); + if let Some(entry) = state.scopes.get_mut(scope) { + entry.epoch = Uuid::new_v4(); + } + Some(job) + } + pub(crate) fn shutdown_statuses(&self) -> HashSet { + let scopes: Vec<_> = self + .state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .statuses + .keys() + .cloned() + .collect(); + for scope in &scopes { + self.cancel_status(scope); + } + scopes.into_iter().collect() + } +} + +/// Cancellation signals are immediate; durable confirmation is independent. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum StatusCancellation { + None, + Cancelled, + Unconfirmed, +} diff --git a/crates/buzz-acp/src/session_model_channel.md b/crates/buzz-acp/src/session_model_channel.md index 58f652aa3c2..9c59160b61e 100644 --- a/crates/buzz-acp/src/session_model_channel.md +++ b/crates/buzz-acp/src/session_model_channel.md @@ -3,3 +3,6 @@ You are one per-channel session of your agent identity — not the only copy. Each channel gets its own independent conversation context, and multiple sessions of the same agent may be active in different channels at the same time. Threads within a channel share that channel's session. DMs stay one conversation. Sessions share your core memory, your workspace on disk, relay access, and channel authorization. They do NOT share conversation context, in-progress reasoning, or in-context task state. When a human references work "you" are doing in another channel, that work belongs to a different session of you. Unless the human asks you to take it over or coordinate it from this channel, leave execution with the owning session — answer from what you can verify (core memory, workspace files, relay messages) and assume the owning session has it handled. + + +A harness-owned `` request is a single status-only turn. Use only the supplied context, state missing history honestly, and reply in the conversation language (using its language hint when present). Do not execute the task, inspect files or logs, run tools or subagents, or send the reply yourself. Return one short status text; the harness signs and delivers it. Later ordinary requests resume normal work. diff --git a/crates/buzz-acp/src/session_model_thread.md b/crates/buzz-acp/src/session_model_thread.md index 5665520b8d9..3898b85552e 100644 --- a/crates/buzz-acp/src/session_model_thread.md +++ b/crates/buzz-acp/src/session_model_thread.md @@ -3,3 +3,6 @@ You are one session of your agent identity — not the only copy. In channels, each thread gets its own independent conversation context, including a new thread rooted at a top-level mention. DMs stay one conversation, not separate sessions per thread. Multiple sessions of the same agent may be active in different channels or different threads in the same channel at the same time. Sessions share your core memory, your workspace on disk, relay access, and channel authorization. They do NOT share conversation context, in-progress reasoning, or in-context task state. When a human references work "you" are doing in another channel or a sibling channel thread, that work belongs to a different session of you. Unless the human asks you to take it over or coordinate it from this session, leave execution with the owning session — answer from what you can verify (core memory, workspace files, relay messages) and assume the owning session has it handled. + + +A harness-owned `` request is a single status-only turn. Use only the supplied context, state missing history honestly, and reply in the conversation language (using its language hint when present). Do not execute the task, inspect files or logs, run tools or subagents, or send the reply yourself. Return one short status text; the harness signs and delivers it. Later ordinary requests resume normal work. diff --git a/crates/buzz-acp/src/session_status.rs b/crates/buzz-acp/src/session_status.rs new file mode 100644 index 00000000000..d0bba427251 --- /dev/null +++ b/crates/buzz-acp/src/session_status.rs @@ -0,0 +1,629 @@ +//! Live per-scope session status snapshots. +//! +//! The harness writes one small snapshot per conversation scope so the CLI can +//! tag the agent's outgoing messages with the session's state, the reason for +//! its last transition and the reported context occupancy. Status is display +//! metadata only: it never decides whether a turn runs or a session resumes. +//! +//! # Layout +//! +//! ```text +//! /status/v1/// (0700) +//! instance.lock held while this harness runs +//! generations/.lock held while that generation is current +//! snapshot (0600), see `scope_file_name` +//! ``` +//! +//! `BUZZ_SESSION_STATUS_DIR` names the instance directory. The instance +//! directory only appears under its final name once its lock is held, and a +//! snapshot is only published while its generation lock is held. A reader +//! trusts a snapshot only while both locks are held by someone else. +//! +//! Each scope has at most one current generation. A successor generation, a +//! retirement or a failed write revokes the shared guard of the old one: its +//! lock is released (even when deleting its snapshot fails) and no clone of +//! its binding can write again. A scope whose write failed stops producing +//! status for the rest of this runtime. + +use std::collections::{HashMap, HashSet}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak}; + +use buzz_core::session_status::{ + generation_lock_path, scope_file_name, ContextOccupancy, HistoryCoverage, SessionState, + SessionStatus, StatusEnvelope, StatusIdentity, StatusReason, StatusScope, GENERATIONS_DIR, + INSTANCE_LOCK_FILE, +}; +use fs2::FileExt; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +/// Environment variable naming the current harness instance's status directory. +pub(crate) const SESSION_STATUS_DIR_ENV: &str = "BUZZ_SESSION_STATUS_DIR"; + +const STATUS_DIR: &str = "status"; +const FORMAT_DIR: &str = "v1"; +const STAGING_PREFIX: &str = ".staging-"; +/// Sibling instance directories examined per startup sweep. +const MAX_SWEPT_INSTANCES: usize = 64; + +/// The status writer of one harness instance. Cheap to clone. +#[derive(Clone)] +pub(crate) struct SessionStatusRuntime { + shared: Arc, +} + +struct Shared { + dir: PathBuf, + identity: StatusIdentity, + instance_id: Uuid, + /// Held for the life of the runtime; see the module docs. + instance_lock: Option, + scopes: Mutex, + #[cfg(test)] + before_write: Mutex>>, +} + +#[derive(Default)] +struct Scopes { + current: HashMap>, + disabled: HashSet, +} + +struct Generation { + runtime: Weak, + id: Uuid, + scope: StatusScope, + session_id: String, + snapshot: PathBuf, + lock_path: PathBuf, + state: Mutex, +} + +struct GenerationState { + /// The held generation lock; `None` once revoked. + guard: Option, + status: SessionStatus, + origin: Option, +} + +/// A handle on one scope generation. Clones share one revocable guard; none +/// of them keeps the generation alive after it was revoked. +#[derive(Clone)] +pub(crate) struct StatusBinding { + generation: Arc, +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +impl SessionStatusRuntime { + /// Create this instance's directory under `root` and hold its lock before + /// the directory can be found under its final name. + pub(crate) fn open(root: &Path, identity: StatusIdentity) -> io::Result { + identity + .validate() + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + let parent = root + .join(STATUS_DIR) + .join(FORMAT_DIR) + .join(identity_dir(&identity)?); + create_private_dir_all(&parent)?; + sweep_dead_instances(&parent); + + let instance_id = Uuid::new_v4(); + let staging = parent.join(format!("{STAGING_PREFIX}{instance_id}")); + let dir = parent.join(instance_id.to_string()); + let instance_lock = (|| { + create_private_dir(&staging)?; + let lock = create_lock(&staging.join(INSTANCE_LOCK_FILE))?; + create_private_dir(&staging.join(GENERATIONS_DIR))?; + fs::rename(&staging, &dir)?; + Ok::<_, io::Error>(lock) + })() + .inspect_err(|_| { + let _ = fs::remove_dir_all(&staging); + })?; + Ok(Self { + shared: Arc::new(Shared { + dir, + identity, + instance_id, + instance_lock: Some(instance_lock), + scopes: Mutex::new(Scopes::default()), + #[cfg(test)] + before_write: Mutex::new(None), + }), + }) + } + + /// The instance directory exported as [`SESSION_STATUS_DIR_ENV`]. + pub(crate) fn dir(&self) -> &Path { + &self.shared.dir + } + + /// Start a new generation for `scope`: revoke the current one, then + /// publish the new state with unknown occupancy under a held lock. + /// + /// Returns `None` when the scope or reason is invalid, the scope is + /// disabled, or publication failed (which disables the scope). + pub(crate) fn begin( + &self, + scope: StatusScope, + session_id: &str, + state: SessionState, + reason: StatusReason, + ) -> Option { + self.begin_with_origin(scope, session_id, state, reason, None) + } + + /// Start a generation with an authorized manual transition's provenance. + pub(crate) fn begin_with_origin( + &self, + scope: StatusScope, + session_id: &str, + state: SessionState, + reason: StatusReason, + origin: Option, + ) -> Option { + if origin + .as_ref() + .is_some_and(|origin| origin.validate().is_err()) + { + return None; + } + let file_name = scope_file_name(&scope).ok()?; + reason.validate().ok()?; + let mut scopes = lock(&self.shared.scopes); + if scopes.disabled.contains(&scope) { + return None; + } + if let Some(previous) = scopes.current.remove(&scope) { + previous.revoke(); + } + let id = Uuid::new_v4(); + let lock_path = self.shared.dir.join(generation_lock_path(id)); + let guard = match create_lock(&lock_path) { + Ok(guard) => guard, + Err(err) => { + disable(&mut scopes, &scope, &err); + return None; + } + }; + let generation = Arc::new(Generation { + runtime: Arc::downgrade(&self.shared), + id, + scope: scope.clone(), + session_id: session_id.to_owned(), + snapshot: self.shared.dir.join(file_name), + lock_path, + state: Mutex::new(GenerationState { + guard: Some(guard), + origin, + status: SessionStatus { + state, + reason, + transition_id: Uuid::new_v4(), + occupancy: None, + history: None, + }, + }), + }); + { + let mut generation_state = lock(&generation.state); + let status = generation_state.status.clone(); + if let Err(err) = + generation.write(&self.shared, &status, generation_state.origin.as_ref()) + { + generation.revoke_locked(&mut generation_state); + disable(&mut scopes, &scope, &err); + return None; + } + } + scopes.current.insert(scope, generation.clone()); + Some(StatusBinding { generation }) + } + + /// The current generation of `scope`, if it belongs to `session_id`. + pub(crate) fn bind_existing( + &self, + scope: &StatusScope, + session_id: &str, + ) -> Option { + let scopes = lock(&self.shared.scopes); + let generation = scopes.current.get(scope)?; + (generation.session_id == session_id && generation.is_live()).then(|| StatusBinding { + generation: generation.clone(), + }) + } + + /// Retire the current generation of `scope`, whoever owns it. + pub(crate) fn retire(&self, scope: &StatusScope) { + let mut scopes = lock(&self.shared.scopes); + if let Some(generation) = scopes.current.remove(scope) { + generation.revoke(); + } + } + + /// Retire every scope of `channel_id`. + pub(crate) fn retire_channel(&self, channel_id: Uuid) { + let mut scopes = lock(&self.shared.scopes); + let retired: Vec = scopes + .current + .keys() + .filter(|scope| scope.channel_id == channel_id) + .cloned() + .collect(); + for scope in retired { + if let Some(generation) = scopes.current.remove(&scope) { + generation.revoke(); + } + } + } + + /// Run `hook` inside the next snapshot write, before it is renamed into + /// place, so a test can hold a writer mid-flight. + #[cfg(all(test, unix))] + pub(crate) fn delay_next_write(&self, hook: Box) { + *lock(&self.shared.before_write) = Some(hook); + } +} + +impl StatusBinding { + pub(crate) fn generation_id(&self) -> Uuid { + self.generation.id + } + + /// Serialize display tags only while this generation is still current. + pub(crate) fn event_tags(&self) -> Option>> { + let generation = &self.generation; + let shared = generation.runtime.upgrade()?; + let state = lock(&generation.state); + state.guard.as_ref()?; + let mut envelope = StatusEnvelope::new( + shared.identity.clone(), + shared.instance_id, + generation.scope.clone(), + generation.id, + state.status.clone(), + ); + envelope.origin = state.origin.clone(); + envelope.event_tags().ok() + } + + /// Change the transition within this generation, retaining history/occupancy. + pub(crate) fn compacted(&self, origin: Option) { + let generation = &self.generation; + let Some(shared) = generation.runtime.upgrade() else { + return; + }; + let mut state = lock(&generation.state); + if state.guard.is_none() { + return; + } + let mut next = state.status.clone(); + next.state = SessionState::Compacted; + next.reason = if origin.is_some() { + StatusReason::UserCompact + } else { + StatusReason::AutoCompact + }; + next.transition_id = Uuid::new_v4(); + match generation.write(&shared, &next, origin.as_ref()) { + Ok(()) => { + state.status = next; + state.origin = origin; + } + Err(error) => { + generation.revoke_locked(&mut state); + drop(state); + let mut scopes = lock(&shared.scopes); + if scopes + .current + .get(&generation.scope) + .is_some_and(|current| current.id == generation.id) + { + scopes.current.remove(&generation.scope); + } + disable(&mut scopes, &generation.scope, &error); + } + } + } + + /// Record the cold-start history coverage of this generation. + pub(crate) fn set_history(&self, history: HistoryCoverage) { + if history.validate().is_err() { + return; + } + self.generation.update(|status| { + let changed = status.history != Some(history); + status.history = Some(history); + changed + }); + } + + /// Record a reported context occupancy; a zero size is unavailable. + pub(crate) fn record_occupancy(&self, used: u64, size: u64) { + let Some(occupancy) = ContextOccupancy::new(used, size) else { + return; + }; + self.generation.update(|status| { + let changed = status.occupancy != Some(occupancy); + status.occupancy = Some(occupancy); + changed + }); + } + + /// Clear occupancy when the provider has not established its capacity. + pub(crate) fn clear_occupancy(&self) { + self.generation + .update(|status| status.occupancy.take().is_some()); + } + + /// Retire this generation. A generation that is no longer current was + /// already revoked, so this never touches a successor. + pub(crate) fn retire(&self) { + let generation = &self.generation; + match generation.runtime.upgrade() { + Some(shared) => { + let mut scopes = lock(&shared.scopes); + if scopes + .current + .get(&generation.scope) + .is_some_and(|current| Arc::ptr_eq(current, generation)) + { + scopes.current.remove(&generation.scope); + } + generation.revoke(); + } + None => generation.revoke(), + } + } + + /// Whether this generation can still publish. + pub(crate) fn is_live(&self) -> bool { + self.generation.is_live() + } + + /// Provider session this generation describes. + #[cfg(all(test, unix))] + pub(crate) fn session_id(&self) -> &str { + &self.generation.session_id + } +} + +impl Generation { + fn is_live(&self) -> bool { + lock(&self.state).guard.is_some() + } + + /// Apply `change` and republish; a revoked generation ignores it. + fn update(&self, change: impl FnOnce(&mut SessionStatus) -> bool) { + let Some(shared) = self.runtime.upgrade() else { + return; + }; + let mut state = lock(&self.state); + if state.guard.is_none() { + return; + } + let mut next = state.status.clone(); + if !change(&mut next) { + return; + } + match self.write(&shared, &next, state.origin.as_ref()) { + Ok(()) => state.status = next, + Err(err) => { + self.revoke_locked(&mut state); + drop(state); + let mut scopes = lock(&shared.scopes); + if scopes + .current + .get(&self.scope) + .is_some_and(|current| current.id == self.id) + { + scopes.current.remove(&self.scope); + } + disable(&mut scopes, &self.scope, &err); + } + } + } + + /// Atomically replace the snapshot: a bounded temp file in the same + /// directory, renamed into place. + fn write( + &self, + shared: &Shared, + status: &SessionStatus, + origin: Option<&buzz_core::session_control::ControlOrigin>, + ) -> io::Result<()> { + let mut envelope = StatusEnvelope::new( + shared.identity.clone(), + shared.instance_id, + self.scope.clone(), + self.id, + status.clone(), + ); + envelope.origin = origin.cloned(); + let bytes = envelope + .to_snapshot_bytes() + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let mut file = tempfile::Builder::new() + .prefix(".snapshot-") + .tempfile_in(&shared.dir)?; + file.write_all(&bytes)?; + #[cfg(test)] + if let Some(hook) = lock(&shared.before_write).take() { + hook(); + } + file.persist(&self.snapshot).map_err(|err| err.error)?; + Ok(()) + } + + fn revoke(&self) { + let mut state = lock(&self.state); + self.revoke_locked(&mut state); + } + + /// Release the generation lock even when the snapshot cannot be removed: + /// a reader then rejects whatever file remains. + fn revoke_locked(&self, state: &mut GenerationState) { + let Some(guard) = state.guard.take() else { + return; + }; + let _ = fs::remove_file(&self.snapshot); + let _ = FileExt::unlock(&guard); + drop(guard); + let _ = fs::remove_file(&self.lock_path); + } +} + +impl Drop for Shared { + fn drop(&mut self) { + let scopes = std::mem::take(&mut *lock(&self.scopes)); + for generation in scopes.current.values() { + generation.revoke(); + } + let _ = fs::remove_dir_all(&self.dir); + drop(self.instance_lock.take()); + } +} + +fn disable(scopes: &mut Scopes, scope: &StatusScope, err: &io::Error) { + if scopes.disabled.insert(scope.clone()) { + tracing::warn!( + target: "session_status", + scope = scope.kind(), + error = %err.kind(), + "session status disabled for this scope" + ); + } +} + +/// Route a standard `usage_update` to the binding of its own +/// `params.sessionId`. Missing or malformed values are ignored. +pub(crate) fn record_usage_update( + bindings: &HashMap, + msg: &serde_json::Value, +) { + let Some(binding) = msg + .pointer("/params/sessionId") + .and_then(serde_json::Value::as_str) + .and_then(|session_id| bindings.get(session_id)) + else { + return; + }; + let update = &msg["params"]["update"]; + if let Some(occupancy) = ContextOccupancy::from_json(update.get("used"), update.get("size")) { + binding.record_occupancy(occupancy.used, occupancy.size); + } +} + +/// The harness owns [`SESSION_STATUS_DIR_ENV`]: set it from `extra_env` or +/// remove an inherited value naming another harness instance. +pub(crate) fn apply_status_dir_env( + cmd: &mut tokio::process::Command, + extra_env: &[(String, String)], +) { + cmd.env_remove(SESSION_STATUS_DIR_ENV); + if let Some((name, dir)) = extra_env + .iter() + .rev() + .find(|(name, _)| name == SESSION_STATUS_DIR_ENV) + { + cmd.env(name, dir); + } +} + +/// Open the status runtime of `config`'s session root, or `None` (with a +/// diagnostic) when status cannot be published. +pub(crate) fn open_for_config(config: &crate::config::Config) -> Option { + let root = match &config.session_store_dir { + Some(dir) => dir.clone(), + None => std::env::current_dir().ok()?.join(".buzz-sessions"), + }; + let identity = buzz_core::session_status::canonical_identity( + &config.relay_url, + &config.keys.public_key().to_hex(), + ) + .ok()?; + match SessionStatusRuntime::open(&root, identity) { + Ok(runtime) => Some(runtime), + Err(err) => { + tracing::warn!( + target: "session_status", + error = %err.kind(), + "session status unavailable" + ); + None + } + } +} + +fn identity_dir(identity: &StatusIdentity) -> io::Result { + let encoded = serde_json::to_vec(&("buzz-session-status-v1", &identity.relay, &identity.agent)) + .map_err(io::Error::other)?; + Ok(hex::encode(Sha256::digest(encoded))) +} + +fn create_private_dir_all(path: &Path) -> io::Result<()> { + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + builder.create(path) +} + +fn create_private_dir(path: &Path) -> io::Result<()> { + #[cfg(unix)] + let mut builder = fs::DirBuilder::new(); + #[cfg(not(unix))] + let builder = fs::DirBuilder::new(); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + builder.create(path) +} + +/// Create a fresh lock file (0600) and hold an exclusive lock on it. +fn create_lock(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.read(true).write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let file = options.open(path)?; + file.try_lock_exclusive()?; + Ok(file) +} + +/// Remove sibling instance directories whose instance lock nobody holds. +/// Bounded; directories still being created (staging names) are skipped. +fn sweep_dead_instances(parent: &Path) { + let Ok(entries) = fs::read_dir(parent) else { + return; + }; + for entry in entries.flatten().take(MAX_SWEPT_INSTANCES) { + let path = entry.path(); + let is_instance = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| Uuid::parse_str(name).is_ok()); + if !is_instance { + continue; + } + let Ok(file) = OpenOptions::new() + .read(true) + .open(path.join(INSTANCE_LOCK_FILE)) + else { + continue; + }; + if file.try_lock_exclusive().is_ok() { + let _ = fs::remove_dir_all(&path); + } + } +} + +#[cfg(all(test, unix))] +#[path = "session_status_tests.rs"] +mod tests; diff --git a/crates/buzz-acp/src/session_status_tests.rs b/crates/buzz-acp/src/session_status_tests.rs new file mode 100644 index 00000000000..b5a94e9776d --- /dev/null +++ b/crates/buzz-acp/src/session_status_tests.rs @@ -0,0 +1,868 @@ +use super::*; +use crate::acp::AcpClient; +use buzz_core::session_status::{ + canonical_identity, generation_lock_path, parse_snapshot, scope_file_name, StatusEnvelope, + GENERATIONS_DIR, INSTANCE_LOCK_FILE, +}; +use fs2::FileExt; +use std::fs::{self, File, OpenOptions}; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::time::Duration; + +fn identity() -> StatusIdentity { + canonical_identity( + "wss://relay.example", + &nostr::Keys::generate().public_key().to_hex(), + ) + .unwrap() +} + +fn thread(channel: u128, root: char) -> StatusScope { + StatusScope { + channel_id: Uuid::from_u128(channel), + thread_root: Some(root.to_string().repeat(64)), + } +} + +fn conversation(channel: u128) -> StatusScope { + StatusScope { + channel_id: Uuid::from_u128(channel), + thread_root: None, + } +} + +fn open_runtime() -> (tempfile::TempDir, SessionStatusRuntime) { + let root = tempfile::tempdir().unwrap(); + let runtime = SessionStatusRuntime::open(root.path(), identity()).unwrap(); + (root, runtime) +} + +fn begin(runtime: &SessionStatusRuntime, scope: &StatusScope, sid: &str) -> StatusBinding { + runtime + .begin( + scope.clone(), + sid, + SessionState::New, + StatusReason::FirstInThread, + ) + .expect("status binding") +} + +fn snapshot_path(runtime: &SessionStatusRuntime, scope: &StatusScope) -> PathBuf { + runtime.dir().join(scope_file_name(scope).unwrap()) +} + +fn snapshot(runtime: &SessionStatusRuntime, scope: &StatusScope) -> Option { + let bytes = fs::read(snapshot_path(runtime, scope)).ok()?; + Some(parse_snapshot(&bytes).expect("valid snapshot")) +} + +fn generation_lock(runtime: &SessionStatusRuntime, envelope: &StatusEnvelope) -> PathBuf { + runtime + .dir() + .join(generation_lock_path(envelope.generation)) +} + +/// Whether another open file description cannot take the lock right now. +fn lock_is_held(path: &Path) -> bool { + let Ok(file) = OpenOptions::new().read(true).open(path) else { + return false; + }; + match file.try_lock_exclusive() { + Ok(()) => { + FileExt::unlock(&file).unwrap(); + false + } + Err(_) => true, + } +} + +fn mode(path: &Path) -> u32 { + fs::metadata(path).unwrap().permissions().mode() & 0o777 +} + +fn set_mode(path: &Path, mode: u32) { + fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap(); +} + +// ---- layout and locks ---- + +#[test] +fn display_tags_follow_current_generation_and_reject_revoked_clones() { + let (_root, runtime) = open_runtime(); + let scope = thread(1, 'a'); + let binding = begin(&runtime, &scope, "old"); + let clone = binding.clone(); + binding.record_occupancy(95, 100); + assert_eq!( + binding.event_tags().unwrap(), + snapshot(&runtime, &scope).unwrap().event_tags().unwrap() + ); + let next = begin(&runtime, &scope, "next"); + assert!(binding.event_tags().is_none()); + assert!(clone.event_tags().is_none()); + assert_eq!(next.event_tags().unwrap()[0][3], ""); + runtime.retire(&scope); + assert!(next.event_tags().is_none()); +} + +#[test] +fn open_holds_instance_lock_in_private_layout() { + let root = tempfile::tempdir().unwrap(); + let runtime = SessionStatusRuntime::open(root.path(), identity()).unwrap(); + let dir = runtime.dir().to_path_buf(); + let instance: Uuid = dir.file_name().unwrap().to_str().unwrap().parse().unwrap(); + let identity_dir = dir.parent().unwrap(); + let name = identity_dir.file_name().unwrap().to_str().unwrap(); + assert_eq!(name.len(), 64); + assert!(name.bytes().all(|b| b.is_ascii_hexdigit())); + assert_eq!( + identity_dir.parent().unwrap(), + root.path().join("status").join("v1") + ); + assert_eq!(mode(&dir), 0o700); + assert_eq!(mode(&dir.join(GENERATIONS_DIR)), 0o700); + assert_eq!(mode(identity_dir), 0o700); + assert!(lock_is_held(&dir.join(INSTANCE_LOCK_FILE))); + let binding = begin(&runtime, &thread(1, 'a'), "s1"); + assert_eq!( + snapshot(&runtime, &thread(1, 'a')).unwrap().instance_id, + instance + ); + drop(binding); + drop(runtime); + assert!( + !dir.exists(), + "a stopped runtime removes its instance directory" + ); +} + +#[test] +fn begin_publishes_private_snapshot_under_held_generation_lock() { + let (_root, runtime) = open_runtime(); + let scope = thread(1, 'a'); + let binding = runtime + .begin( + scope.clone(), + "s1", + SessionState::Resumed, + StatusReason::IdleSleep, + ) + .unwrap(); + let envelope = snapshot(&runtime, &scope).unwrap(); + assert_eq!(envelope.scope, scope); + assert_eq!(envelope.status.state, SessionState::Resumed); + assert_eq!(envelope.status.reason, StatusReason::IdleSleep); + assert_eq!(envelope.status.occupancy, None, "never borrowed, never 0%"); + assert_eq!(envelope.status.history, None); + assert_eq!(mode(&snapshot_path(&runtime, &scope)), 0o600); + assert!(lock_is_held(&generation_lock(&runtime, &envelope))); + assert!(binding.is_live()); + assert_eq!(binding.session_id(), "s1"); +} + +#[test] +fn invalid_scope_or_reason_publishes_nothing() { + let (_root, runtime) = open_runtime(); + let upper = StatusScope { + channel_id: Uuid::from_u128(1), + thread_root: Some("AB".repeat(32)), + }; + assert!(runtime + .begin(upper, "s1", SessionState::New, StatusReason::FirstInThread) + .is_none()); + assert!(runtime + .begin( + thread(1, 'a'), + "s1", + SessionState::New, + StatusReason::ResumeFailed("Raw adapter error: /tmp".into()), + ) + .is_none()); + let files: Vec<_> = fs::read_dir(runtime.dir()) + .unwrap() + .flatten() + .map(|entry| entry.file_name()) + .collect(); + assert_eq!(files.len(), 2, "only the lock and generations: {files:?}"); +} + +#[test] +fn occupancy_and_history_update_the_snapshot() { + let (_root, runtime) = open_runtime(); + let scope = conversation(2); + let binding = begin(&runtime, &scope, "s1"); + let transition = snapshot(&runtime, &scope).unwrap().status.transition_id; + binding.record_occupancy(64, 100); + let history = HistoryCoverage { + read: Some(3), + total: Some(9), + exact: false, + incomplete: true, + }; + binding.set_history(history); + let status = snapshot(&runtime, &scope).unwrap().status; + assert_eq!(status.occupancy.and_then(|o| o.percent()), Some(64)); + assert_eq!(status.history, Some(history)); + assert_eq!(status.transition_id, transition); + binding.record_occupancy(5, 0); + assert_eq!( + snapshot(&runtime, &scope) + .unwrap() + .status + .occupancy + .and_then(|o| o.percent()), + Some(64), + "a zero context size is unavailable, not a new value" + ); +} + +#[test] +fn successor_generation_revokes_predecessor() { + let (_root, runtime) = open_runtime(); + let scope = thread(1, 'a'); + let first = begin(&runtime, &scope, "s1"); + first.record_occupancy(64, 100); + let old = snapshot(&runtime, &scope).unwrap(); + let second = runtime + .begin( + scope.clone(), + "s2", + SessionState::Resumed, + StatusReason::WorkerCrash, + ) + .unwrap(); + let new = snapshot(&runtime, &scope).unwrap(); + assert_ne!(old.generation, new.generation); + assert_ne!(old.status.transition_id, new.status.transition_id); + assert_eq!(new.status.occupancy, None, "the successor starts unknown"); + assert!(!lock_is_held(&generation_lock(&runtime, &old))); + assert!(lock_is_held(&generation_lock(&runtime, &new))); + assert!(!first.is_live()); + first.record_occupancy(90, 100); + first.retire(); + assert_eq!(snapshot(&runtime, &scope).unwrap(), new); + assert!(second.is_live()); +} + +#[test] +fn retire_releases_lock_and_late_callbacks_never_recreate() { + let (_root, runtime) = open_runtime(); + let scope = thread(1, 'a'); + let binding = begin(&runtime, &scope, "s1"); + let clone = binding.clone(); + let envelope = snapshot(&runtime, &scope).unwrap(); + runtime.retire(&scope); + assert!(snapshot(&runtime, &scope).is_none()); + assert!(!lock_is_held(&generation_lock(&runtime, &envelope))); + clone.record_occupancy(10, 100); + clone.set_history(HistoryCoverage { + read: Some(1), + total: None, + exact: false, + incomplete: false, + }); + assert!(snapshot(&runtime, &scope).is_none()); + assert!(!binding.is_live()); + assert!(runtime.bind_existing(&scope, "s1").is_none()); +} + +#[test] +fn failed_snapshot_removal_still_revokes_generation() { + let (_root, runtime) = open_runtime(); + let scope = thread(1, 'a'); + let neighbor = thread(1, 'b'); + let binding = begin(&runtime, &scope, "s1"); + let late = binding.clone(); + let other = begin(&runtime, &neighbor, "s2"); + let envelope = snapshot(&runtime, &scope).unwrap(); + let before = fs::read(snapshot_path(&runtime, &scope)).unwrap(); + let neighbor_before = fs::read(snapshot_path(&runtime, &neighbor)).unwrap(); + let probe = File::open(generation_lock(&runtime, &envelope)).unwrap(); + assert!(probe.try_lock_exclusive().is_err(), "held while current"); + + set_mode(runtime.dir(), 0o500); + binding.retire(); + set_mode(runtime.dir(), 0o700); + + probe + .try_lock_exclusive() + .expect("the generation lock is released although removal failed"); + FileExt::unlock(&probe).unwrap(); + assert_eq!( + fs::read(snapshot_path(&runtime, &scope)).unwrap(), + before, + "the remaining snapshot names a released generation" + ); + late.record_occupancy(99, 100); + late.set_history(HistoryCoverage { + read: Some(1), + total: Some(1), + exact: true, + incomplete: false, + }); + assert_eq!(fs::read(snapshot_path(&runtime, &scope)).unwrap(), before); + assert!(runtime.bind_existing(&scope, "s1").is_none()); + assert_eq!( + fs::read(snapshot_path(&runtime, &neighbor)).unwrap(), + neighbor_before + ); + let neighbor_lock = generation_lock(&runtime, &snapshot(&runtime, &neighbor).unwrap()); + assert!(lock_is_held(&neighbor_lock), "successors are untouched"); + other.record_occupancy(10, 100); + assert_eq!( + snapshot(&runtime, &neighbor) + .unwrap() + .status + .occupancy + .and_then(|o| o.percent()), + Some(10) + ); +} + +#[test] +fn write_failure_disables_only_that_scope() { + let (_root, runtime) = open_runtime(); + let failing = thread(1, 'a'); + let healthy = thread(1, 'b'); + let binding = begin(&runtime, &failing, "s1"); + let other = begin(&runtime, &healthy, "s2"); + let envelope = snapshot(&runtime, &failing).unwrap(); + + set_mode(runtime.dir(), 0o500); + binding.record_occupancy(64, 100); + set_mode(runtime.dir(), 0o700); + + assert!(!binding.is_live()); + assert!(!lock_is_held(&generation_lock(&runtime, &envelope))); + assert!( + runtime + .begin( + failing.clone(), + "s3", + SessionState::New, + StatusReason::Discarded + ) + .is_none(), + "a failed scope stops producing status for this runtime" + ); + binding.record_occupancy(70, 100); + if let Some(left) = snapshot(&runtime, &failing) { + assert_eq!(left.generation, envelope.generation, "never rewritten"); + assert_eq!(left.status.occupancy, None); + } + other.record_occupancy(81, 100); + assert_eq!( + snapshot(&runtime, &healthy) + .unwrap() + .status + .occupancy + .and_then(|o| o.percent()), + Some(81) + ); +} + +#[test] +fn retire_waits_for_a_delayed_writer_then_removes_snapshot() { + let (_root, runtime) = open_runtime(); + let scope = thread(1, 'a'); + let binding = begin(&runtime, &scope, "s1"); + let envelope = snapshot(&runtime, &scope).unwrap(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); + runtime.delay_next_write(Box::new(move || { + entered_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + })); + let writer = { + let binding = binding.clone(); + std::thread::spawn(move || binding.record_occupancy(50, 100)) + }; + entered_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + let retire = { + let runtime = runtime.clone(); + let scope = scope.clone(); + std::thread::spawn(move || runtime.retire(&scope)) + }; + std::thread::sleep(Duration::from_millis(100)); + assert!( + !retire.is_finished(), + "retirement waits for the in-flight write" + ); + release_tx.send(()).unwrap(); + writer.join().unwrap(); + retire.join().unwrap(); + assert!(snapshot(&runtime, &scope).is_none()); + assert!(!lock_is_held(&generation_lock(&runtime, &envelope))); + binding.record_occupancy(60, 100); + assert!(snapshot(&runtime, &scope).is_none()); +} + +#[test] +fn bind_existing_keeps_transition_for_the_same_session_only() { + let (_root, runtime) = open_runtime(); + let scope = thread(1, 'a'); + let binding = begin(&runtime, &scope, "s1"); + let again = runtime.bind_existing(&scope, "s1").expect("same session"); + again.record_occupancy(12, 100); + let status = snapshot(&runtime, &scope).unwrap().status; + assert_eq!(status.occupancy.and_then(|o| o.percent()), Some(12)); + assert!(runtime.bind_existing(&scope, "other").is_none()); + assert!(runtime.bind_existing(&thread(1, 'b'), "s1").is_none()); + binding.retire(); + assert!(!again.is_live()); +} + +#[test] +fn retire_channel_leaves_other_channels() { + let (_root, runtime) = open_runtime(); + let removed = [thread(1, 'a'), conversation(1)]; + let kept = thread(2, 'a'); + let _bindings: Vec<_> = removed + .iter() + .chain([&kept]) + .enumerate() + .map(|(n, scope)| begin(&runtime, scope, &format!("s{n}"))) + .collect(); + runtime.retire_channel(Uuid::from_u128(1)); + for scope in &removed { + assert!(snapshot(&runtime, scope).is_none()); + } + assert!(snapshot(&runtime, &kept).is_some()); +} + +#[test] +fn open_sweeps_dead_instances_only() { + let root = tempfile::tempdir().unwrap(); + let identity = identity(); + let live = SessionStatusRuntime::open(root.path(), identity.clone()).unwrap(); + let parent = live.dir().parent().unwrap().to_path_buf(); + let dead = parent.join(Uuid::new_v4().to_string()); + fs::create_dir_all(dead.join(GENERATIONS_DIR)).unwrap(); + File::create(dead.join(INSTANCE_LOCK_FILE)).unwrap(); + let next = SessionStatusRuntime::open(root.path(), identity).unwrap(); + assert!(!dead.exists(), "a dead instance directory is swept"); + assert!(live.dir().exists(), "a live instance directory is kept"); + assert_ne!(live.dir(), next.dir()); +} + +#[test] +fn status_dir_env_is_owned_by_the_harness() { + let env_of = |extra: &[(String, String)]| { + let mut cmd = tokio::process::Command::new("true"); + apply_status_dir_env(&mut cmd, extra); + cmd.as_std() + .get_envs() + .find(|(name, _)| *name == SESSION_STATUS_DIR_ENV) + .map(|(_, value)| value.map(|v| v.to_str().unwrap().to_owned())) + }; + assert_eq!( + env_of(&[(SESSION_STATUS_DIR_ENV.into(), "/status/current".into())]), + Some(Some("/status/current".into())) + ); + assert_eq!( + env_of(&[]), + Some(None), + "an inherited directory of another harness is removed" + ); +} + +// ---- usage notifications through the real AcpClient dispatcher ---- + +/// Spawn a fake adapter named `name` (which selects the adapter kind) that +/// answers one `session/prompt` after emitting `notifications`. +async fn adapter( + name: &str, + notifications: &[serde_json::Value], +) -> (AcpClient, tempfile::TempDir) { + adapter_with_stop(name, notifications, "end_turn").await +} + +async fn adapter_with_stop( + name: &str, + notifications: &[serde_json::Value], + stop: &str, +) -> (AcpClient, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(name); + let mut script = String::from("#!/usr/bin/env bash\nread -r REQ\n"); + script.push_str(r#"ID=$(printf '%s' "$REQ" | sed -E 's/.*"id":([0-9]+).*/\1/')"#); + script.push('\n'); + for notification in notifications { + script.push_str(&format!("echo '{notification}'\n")); + } + script.push_str(&format!( + r#"echo '{{"jsonrpc":"2.0","id":'"$ID"',"result":{{"stopReason":"{stop}"}}}}'"# + )); + script.push_str("\nsleep 1\n"); + fs::write(&path, script).unwrap(); + set_mode(&path, 0o755); + let client = AcpClient::spawn(path.to_str().unwrap(), &[], &[], false) + .await + .unwrap(); + (client, dir) +} + +fn usage( + session: Option<&str>, + used: serde_json::Value, + size: serde_json::Value, +) -> serde_json::Value { + let mut params = serde_json::json!({ + "update": { + "sessionUpdate": "usage_update", + "used": used, + "size": size, + } + }); + if let Some(session) = session { + params["sessionId"] = serde_json::json!(session); + } + serde_json::json!({"jsonrpc": "2.0", "method": "session/update", "params": params}) +} + +fn with_cost(mut update: serde_json::Value, amount: f64) -> serde_json::Value { + update["params"]["update"]["cost"] = serde_json::json!({"amount": amount, "currency": "USD"}); + update +} + +fn percent(runtime: &SessionStatusRuntime, scope: &StatusScope) -> Option { + snapshot(runtime, scope)?.status.occupancy?.percent() +} + +async fn interleaved_updates( + name: &str, +) -> ( + SessionStatusRuntime, + Option, + tempfile::TempDir, +) { + use serde_json::json; + let (root, runtime) = open_runtime(); + let (a, b, retired) = (thread(1, 'a'), thread(1, 'b'), thread(1, 'c')); + let notifications = [ + with_cost(usage(Some("sess-a"), json!(64), json!(100)), 0.5), + usage(Some("sess-b"), json!(81), json!(100)), + usage(Some("sess-a"), json!(70), json!(100)), + usage(None, json!(99), json!(100)), + usage(Some("sess-unknown"), json!(99), json!(100)), + usage(Some("sess-b"), json!(-1), json!(100)), + usage(Some("sess-b"), json!(5), json!(0)), + usage(Some("sess-b"), json!("7"), json!(100)), + usage(Some("sess-b"), json!(1.5), json!(100)), + usage(Some("sess-c"), json!(50), json!(100)), + ]; + let (mut client, dir) = adapter(name, ¬ifications).await; + client.bind_session_status("sess-a", begin(&runtime, &a, "sess-a")); + client.bind_session_status("sess-b", begin(&runtime, &b, "sess-b")); + let late = begin(&runtime, &retired, "sess-c"); + client.bind_session_status("sess-c", late.clone()); + runtime.retire(&retired); + client.notify_session_spawned("sess-a"); + client + .session_prompt_with_idle_timeout( + "sess-a", + "hello", + Duration::from_secs(5), + Duration::from_secs(10), + ) + .await + .unwrap(); + let usage = client.take_turn_usage(); + client.shutdown().await; + drop(dir); + assert_eq!( + percent(&runtime, &a), + (name != "claude-code").then_some(70), + "{name}: Claude requires an authoritative SDK result" + ); + assert_eq!( + percent(&runtime, &b), + (name != "claude-code").then_some(81), + "{name}: late A never modifies B" + ); + assert!( + snapshot(&runtime, &retired).is_none(), + "{name}: a retired generation is not recreated" + ); + let files = fs::read_dir(runtime.dir()).unwrap().count(); + assert_eq!(files, 4, "{name}: lock, generations and two snapshots"); + (runtime, usage, root) +} + +#[tokio::test] +async fn claude_usage_updates_route_by_their_own_session_id() { + let (_runtime, usage, _root) = interleaved_updates("claude-code").await; + let usage = usage.expect("claude cost is still accounted"); + assert_eq!(usage.turn_cost_usd, Some(0.5)); + assert_eq!(usage.cumulative_cost_usd, Some(0.5)); + assert_eq!( + usage.turn_input_tokens, None, + "occupancy is not token usage" + ); +} + +#[tokio::test] +async fn codex_usage_updates_route_by_their_own_session_id() { + let (_runtime, usage, _root) = interleaved_updates("codex-acp").await; + assert_eq!( + usage.and_then(|usage| usage.cumulative_cost_usd), + None, + "codex cost stays ignored" + ); +} + +fn claude_result(session: &str, windows: &[u64]) -> serde_json::Value { + use serde_json::json; + let models: serde_json::Map = windows + .iter() + .enumerate() + .map(|(i, size)| (format!("alias-{i}"), json!({"contextWindow":size}))) + .collect(); + json!({"jsonrpc":"2.0", "method":"_claude/sdkMessage", "params":{ + "sessionId":session, "message":{"type":"result", "subtype":"success", + "is_error":false, "num_turns":1, "modelUsage":models, + "result":"PRIVATE_RAW_RESULT_MARKER"}}}) +} + +async fn claude_context_case(notifications: &[serde_json::Value]) -> (Option, Option) { + let (root, runtime) = open_runtime(); + let (a, b) = (thread(2, 'a'), thread(2, 'b')); + let (mut client, dir) = adapter("claude-code", notifications).await; + let observer = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(observer.clone()), 0); + client.bind_session_status("sess-a", begin(&runtime, &a, "sess-a")); + client.bind_session_status("sess-b", begin(&runtime, &b, "sess-b")); + client + .session_prompt_with_idle_timeout( + "sess-a", + "hello", + Duration::from_secs(5), + Duration::from_secs(10), + ) + .await + .unwrap(); + let actual = (percent(&runtime, &a), percent(&runtime, &b)); + let events = serde_json::to_string(&observer.snapshot()).unwrap(); + assert!( + !events.contains("PRIVATE_RAW_RESULT_MARKER"), + "SDK result text stays out of observer frames" + ); + assert!( + !events.contains("_claude/sdkMessage"), + "private SDK envelope stays out of observer frames" + ); + client.shutdown().await; + drop((dir, root)); + actual +} + +#[tokio::test] +async fn claude_context_uses_provider_window_and_the_final_context_numerator() { + use serde_json::json; + for (used, capacity, expected) in [ + (92_519, 1_000_000, 9), + (259_397, 1_000_000, 25), + (260_000, 1_000_000, 26), + (92_519, 200_000, 46), + ] { + let notifications = [ + usage(Some("sess-a"), json!(used - 1), json!(200_000)), + claude_result("sess-a", &[capacity]), + usage(Some("sess-a"), json!(used), json!(200_000)), + ]; + assert_eq!( + claude_context_case(¬ifications).await, + (Some(expected), None) + ); + } +} + +#[tokio::test] +async fn claude_context_stays_unknown_without_a_matching_unambiguous_result() { + use serde_json::json; + let stream = usage(Some("sess-a"), json!(259_397), json!(200_000)); + for result in [ + claude_result("sess-a", &[200_000, 1_000_000]), + claude_result("sess-b", &[1_000_000]), + claude_result("sess-retired", &[1_000_000]), + ] { + let notifications = [stream.clone(), result, stream.clone()]; + assert_eq!(claude_context_case(¬ifications).await, (None, None)); + } + assert_eq!(claude_context_case(&[stream]).await, (None, None)); +} + +#[tokio::test] +async fn claude_context_error_zero_turn_and_malformed_results_cannot_publish_capacity() { + use serde_json::json; + for (key, value) in [ + ("is_error", json!(true)), + ("num_turns", json!(0)), + ("modelUsage", json!({})), + ("modelUsage", json!({"model":{"contextWindow":0}})), + ("modelUsage", json!({"model":{"contextWindow":"1000000"}})), + ] { + let mut result = claude_result("sess-a", &[1_000_000]); + result["params"]["message"][key] = value; + let stream = usage(Some("sess-a"), json!(259_397), json!(200_000)); + assert_eq!( + claude_context_case(&[stream.clone(), result, stream]).await, + (None, None) + ); + } +} + +#[tokio::test] +async fn claude_cancelled_prompt_clears_result_capacity() { + use serde_json::json; + let (root, runtime) = open_runtime(); + let scope = thread(3, 'a'); + let notifications = [ + usage(Some("sess-a"), json!(90_000), json!(200_000)), + claude_result("sess-a", &[1_000_000]), + usage(Some("sess-a"), json!(92_519), json!(200_000)), + ]; + let (mut client, dir) = adapter_with_stop("claude-code", ¬ifications, "cancelled").await; + client.bind_session_status("sess-a", begin(&runtime, &scope, "sess-a")); + let stop = client + .session_prompt_with_idle_timeout( + "sess-a", + "hello", + Duration::from_secs(5), + Duration::from_secs(10), + ) + .await + .unwrap(); + assert_eq!(stop, crate::acp::StopReason::Cancelled); + assert_eq!(percent(&runtime, &scope), None); + client.shutdown().await; + drop((dir, root)); +} + +/// Pause the provider before the second result. The snapshot read at this +/// boundary is the input a tool-published kind-9 answer would freeze in tags. +async fn claude_second_turn_before_result(updates: &[serde_json::Value]) -> Option { + use serde_json::json; + let (root, runtime) = open_runtime(); + let scope = thread(4, 'a'); + let dir = tempfile::tempdir().unwrap(); + let gate = dir.path().join("continue"); + let path = dir.path().join("claude-code"); + let mut script = String::from("#!/usr/bin/env bash\n"); + let read_request = r#"read -r REQ +ID=$(printf '%s' "$REQ" | sed -E 's/.*"id":([0-9]+).*/\1/') +"#; + let respond = r#"echo '{"jsonrpc":"2.0","id":'"$ID"',"result":{"stopReason":"end_turn"}}' +"#; + script.push_str(read_request); + for frame in [ + usage(Some("sess-a"), json!(90_000), json!(200_000)), + claude_result("sess-a", &[1_000_000]), + usage(Some("sess-a"), json!(92_519), json!(200_000)), + ] { + script.push_str(&format!("echo '{frame}'\n")); + } + script.push_str(respond); + script.push_str(read_request); + for frame in updates { + script.push_str(&format!("echo '{frame}'\n")); + } + let ready = json!({"jsonrpc":"2.0", "method":"session/update", "params":{ + "sessionId":"sess-a", "update":{"sessionUpdate":"tool_call", + "toolCallId":"second-publish", "title":"Publishing", "kind":"other"}}}); + script.push_str(&format!("echo '{ready}'\n")); + script.push_str("for ((i=0; i<300; i++)); do\n [[ -f \"$CLAUDE_CONTEXT_TEST_GATE\" ]] && break\n sleep 0.01\ndone\n"); + script.push_str(respond); + script.push_str("sleep 1\n"); + fs::write(&path, script).unwrap(); + set_mode(&path, 0o755); + let mut client = AcpClient::spawn( + path.to_str().unwrap(), + &[], + &[( + "CLAUDE_CONTEXT_TEST_GATE".into(), + gate.to_str().unwrap().into(), + )], + false, + ) + .await + .unwrap(); + let observer = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(observer.clone()), 0); + client.bind_session_status("sess-a", begin(&runtime, &scope, "sess-a")); + client + .session_prompt_with_idle_timeout( + "sess-a", + "first", + Duration::from_secs(5), + Duration::from_secs(10), + ) + .await + .unwrap(); + assert_eq!(percent(&runtime, &scope), Some(9)); + let check = async { + let mut at_publication = None; + for _ in 0..200 { + if observer.snapshot().iter().any(|event| { + event + .payload + .pointer("/params/update/toolCallId") + .and_then(serde_json::Value::as_str) + == Some("second-publish") + }) { + at_publication = Some(percent(&runtime, &scope)); + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + fs::write(&gate, b"continue").unwrap(); + at_publication + }; + let (turn, at_publication) = tokio::join!( + client.session_prompt_with_idle_timeout( + "sess-a", + "second", + Duration::from_secs(5), + Duration::from_secs(10) + ), + check + ); + turn.unwrap(); + client.shutdown().await; + drop((dir, root)); + at_publication.expect("the second tool-publication boundary was observed") +} + +#[tokio::test] +async fn claude_confirmed_capacity_survives_an_ordinary_prompt_for_tool_published_answers() { + use serde_json::json; + assert_eq!( + claude_second_turn_before_result(&[usage(Some("sess-a"), json!(259_397), json!(200_000))]) + .await, + Some(25) + ); +} + +#[tokio::test] +async fn claude_model_change_and_ambiguous_result_clear_prior_confirmed_capacity() { + use serde_json::json; + let changed = json!({"jsonrpc":"2.0", "method":"session/update", "params":{ + "sessionId":"sess-a", "update":{"sessionUpdate":"current_model_update", + "currentModelId":"different-alias"}}}); + let stream = usage(Some("sess-a"), json!(259_397), json!(200_000)); + assert_eq!( + claude_second_turn_before_result(&[changed, stream.clone()]).await, + None + ); + assert_eq!( + claude_second_turn_before_result(&[ + stream.clone(), + claude_result("sess-a", &[1_000_000, 200_000]), + stream + ]) + .await, + None + ); +} diff --git a/crates/buzz-acp/src/session_store.rs b/crates/buzz-acp/src/session_store.rs new file mode 100644 index 00000000000..8869f577922 --- /dev/null +++ b/crates/buzz-acp/src/session_store.rs @@ -0,0 +1,846 @@ +//! Durable per-scope records of ACP provider sessions. +//! +//! A provider session outlives the worker process that created it: after an +//! idle pool sleep, a worker crash, or a harness restart, the next turn in the +//! same scope can resume it with ACP `session/resume`. This module stores one +//! small JSON record per scope so that decision survives the process. +//! +//! Every change to a record happens inside a critical section guarded by an +//! exclusive lock on `.lock`, with a bounded wait. Ownership is fenced +//! by a lease: a claim writes a fresh lease, and later writes only succeed +//! while the record still carries it, so a late writer that lost the record +//! (a stale owner, a rotate in between) never overwrites the new owner. +//! +//! Store failures never fail a turn. A failed write poisons the scope for the +//! rest of the process, and, if the failure happened inside the critical +//! section on a record we own, moves that record aside to `.stale` so a later +//! process does not resume a session this one could not update. + +use std::collections::HashSet; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use fs2::FileExt; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::scope::SessionScope; +use buzz_core::session_control::RestartDepth; + +#[path = "session_store_controls.rs"] +#[allow(dead_code)] +// Control controller consumes these durable APIs independently of provider resume. +mod controls; +#[path = "session_store_status.rs"] +mod restart_status; +#[allow(unused_imports)] // Durable controller contract, independent of provider acquisition. +pub use controls::{ControlAttempt, ControlReceipt, ControlReceiptState, RestartIntent}; +pub(crate) use restart_status::{RestartStatus, RestartStatusPhase, STATUS_TEXT_BYTES}; + +/// Delivered event ids kept per record, oldest dropped first. +pub const MAX_STORED_DELIVERED_IDS: usize = 2048; +/// Records larger than this are treated as unreadable. +pub const MAX_RECORD_BYTES: u64 = 256 * 1024; +/// Longest wait for a record lock before the operation gives up as busy. +pub const STORE_LOCK_WAIT: Duration = Duration::from_secs(2); + +const RECORD_VERSION: u32 = 2; +const LOCK_RETRY: Duration = Duration::from_millis(20); +const INSTANCES_DIR: &str = "instances"; + +/// Why a live session left worker memory while staying resumable. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Departure { + IdleSleep, + WorkerCrash, + /// Worker retired for a sibling scope restart. + WorkerRecycled, +} + +/// Why a scope's session is new or resumed, as reported to the agent. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(into = "String", from = "String")] +pub enum SessionReason { + IdleSleep, + WorkerCrash, + WorkerRecycled, + UserRestart(RestartDepth), + UserCompact, + AutoCompact, + HarnessRestart, + FirstInThread, + Discarded, + ConfigChanged, + LiveElsewhere, + ResumeUnsupported, + /// `resume_failed:`. + ResumeFailed(String), +} + +impl SessionReason { + /// Stable wire string. + pub fn as_wire(&self) -> String { + match self { + Self::IdleSleep => "idle_sleep".into(), + Self::WorkerCrash => "worker_crash".into(), + Self::WorkerRecycled => "worker_recycled".into(), + Self::UserRestart(depth) => format!("user_restart:{}", depth.as_wire()), + Self::UserCompact => "user_compact".into(), + Self::AutoCompact => "auto_compact".into(), + Self::HarnessRestart => "harness_restart".into(), + Self::FirstInThread => "first_in_thread".into(), + Self::Discarded => "discarded".into(), + Self::ConfigChanged => "config_changed".into(), + Self::LiveElsewhere => "live_elsewhere".into(), + Self::ResumeUnsupported => "resume_unsupported".into(), + Self::ResumeFailed(code) => format!("resume_failed:{code}"), + } + } +} + +impl From for SessionReason { + fn from(departure: Departure) -> Self { + match departure { + Departure::IdleSleep => Self::IdleSleep, + Departure::WorkerCrash => Self::WorkerCrash, + Departure::WorkerRecycled => Self::WorkerRecycled, + } + } +} + +impl From for String { + fn from(reason: SessionReason) -> Self { + reason.as_wire() + } +} + +impl From for SessionReason { + fn from(wire: String) -> Self { + match wire.as_str() { + "idle_sleep" => Self::IdleSleep, + "worker_crash" => Self::WorkerCrash, + "worker_recycled" => Self::WorkerRecycled, + "user_restart:none" => Self::UserRestart(RestartDepth::None), + "user_restart:recent" => Self::UserRestart(RestartDepth::Recent), + "user_restart:all" => Self::UserRestart(RestartDepth::All), + "user_compact" => Self::UserCompact, + "auto_compact" => Self::AutoCompact, + "harness_restart" => Self::HarnessRestart, + "first_in_thread" => Self::FirstInThread, + "discarded" => Self::Discarded, + "config_changed" => Self::ConfigChanged, + "live_elsewhere" => Self::LiveElsewhere, + "resume_unsupported" => Self::ResumeUnsupported, + other => Self::ResumeFailed( + other + .strip_prefix("resume_failed:") + .unwrap_or(other) + .to_owned(), + ), + } + } +} + +/// A provider session that a later turn may resume. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LiveSession { + /// Ownership token, replaced by every claim. + pub lease: Uuid, + pub adapter: String, + pub session_id: String, + pub cwd: String, + pub model: Option, + /// Harness process that owns the session. + pub instance: Uuid, + pub worker_index: usize, + pub departed: Option, + pub turns: u32, + pub standing_context_sent: bool, + /// Oldest first, capped at [`MAX_STORED_DELIVERED_IDS`]. + pub delivered_event_ids: Vec, + pub hydrated_thread_roots: Vec, +} + +/// One record per scope. `live: None` is a tombstone: the session was +/// discarded on purpose and must not be resumed. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionRecord { + pub version: u32, + /// Hash of the canonical relay identity and agent public key. + pub namespace: String, + pub live: Option, + pub reason: Option, + /// Fresh CAS revision; absent on legacy v2 records. + #[serde(default)] + pub revision: Option, + /// Policy for the next successful foreground delivery. + #[serde(default)] + pub pending_restart: Option, + /// Bounded durable control deduplication, empty on legacy v2 records. + #[serde(default)] + pub control_receipts: Vec, + /// Optional on legacy records; independent from the terminal reset result. + #[serde(default)] + pub(crate) restart_status: Option, +} + +/// Result of reading a scope's record. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LoadOutcome { + Missing, + Found(Box), + /// The record could not be read; it was moved aside to `.corrupt`. + Unreadable, +} + +/// What a claim expects to find on disk, taken from the record its decision +/// was based on. A mismatch means another owner changed the record since. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Expect { + Missing, + Tombstone { revision: Option }, + Live { session_id: String, lease: Uuid }, +} + +impl Expect { + /// The expectation matching what `load` returned. + pub fn from_outcome(outcome: &LoadOutcome) -> Self { + match outcome { + // An unreadable record was moved aside, so the path is empty now. + LoadOutcome::Missing | LoadOutcome::Unreadable => Self::Missing, + LoadOutcome::Found(record) => match &record.live { + None => Self::Tombstone { + revision: record.revision, + }, + Some(live) => Self::Live { + session_id: live.session_id.clone(), + lease: live.lease, + }, + }, + } + } + + fn matches(&self, current: Option<&SessionRecord>) -> bool { + match (self, current) { + (Self::Missing, None) => true, + (Self::Tombstone { revision }, Some(record)) => { + record.live.is_none() && record.revision == *revision + } + (Self::Live { session_id, lease }, Some(record)) => record + .live + .as_ref() + .is_some_and(|live| live.session_id == *session_id && live.lease == *lease), + _ => false, + } + } +} + +#[derive(Debug)] +pub enum StoreError { + /// The record lock stayed taken for longer than [`STORE_LOCK_WAIT`]. + Busy, + /// The record no longer matches what the caller expected. + Conflict, + Io(io::Error), + Unreadable, + /// Input is not a canonical control identity. + InvalidControl, + /// New signed request is outside the freshness window. + StaleRequest, + /// Internal no-write signal for an already persisted control. + AlreadyRecorded, +} + +impl std::fmt::Display for StoreError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Busy => f.write_str("record lock busy"), + Self::Conflict => f.write_str("record changed by another owner"), + Self::Io(err) => write!(f, "io error: {err}"), + Self::Unreadable => f.write_str("record unreadable"), + Self::InvalidControl => f.write_str("invalid control metadata"), + Self::StaleRequest => f.write_str("stale control request"), + Self::AlreadyRecorded => f.write_str("control already recorded"), + } + } +} + +/// Append newly delivered ids, skipping duplicates and dropping the oldest +/// beyond [`MAX_STORED_DELIVERED_IDS`]. +pub fn append_delivered(ids: &mut Vec, new: impl IntoIterator) { + let mut seen: HashSet = ids.iter().cloned().collect(); + for id in new { + if seen.insert(id.clone()) { + ids.push(id); + } + } + if ids.len() > MAX_STORED_DELIVERED_IDS { + let excess = ids.len() - MAX_STORED_DELIVERED_IDS; + ids.drain(..excess); + } +} + +/// Registry proof distinguishes a live owner from temporarily unavailable evidence. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum InstanceLiveness { + Alive, + Dead, + Unknown, +} + +/// Per-agent directory of session records. +pub struct SessionStore { + dir: PathBuf, + namespace: String, + instance: Uuid, + /// Registered on the first write and held for the life of the process; + /// the OS releases it on exit, which is how other processes tell a dead + /// instance from a live one. Every record naming this instance is written + /// after the lock exists. + instance_lock: Mutex>, + poisoned: Mutex>, + poisoned_channels: Mutex>, + #[cfg(test)] + fail_writes: std::sync::atomic::AtomicBool, + #[cfg(test)] + before_instance_lock: Mutex>>, +} + +impl SessionStore { + /// Open an isolated namespace under either the default or configured root. + /// Legacy unscoped records are deliberately not migrated. Nothing touches + /// the disk until the first write. + pub fn open(root: PathBuf, relay: &str, agent: &str) -> io::Result { + let namespace = store_namespace(relay, agent)?; + Ok(Self { + dir: root.join("v2").join(&namespace), + namespace, + instance: Uuid::new_v4(), + instance_lock: Mutex::new(None), + poisoned: Mutex::new(HashSet::new()), + poisoned_channels: Mutex::new(HashSet::new()), + #[cfg(test)] + fail_writes: std::sync::atomic::AtomicBool::new(false), + #[cfg(test)] + before_instance_lock: Mutex::new(None), + }) + } + + pub fn instance(&self) -> Uuid { + self.instance + } + + /// Create and hold this process's instance lock, sweeping lock files of + /// instances that are no longer running. + fn register_instance(&self) -> io::Result<()> { + let mut held = self + .instance_lock + .lock() + .map_err(|_| io::Error::other("instance lock poisoned"))?; + if held.is_some() { + return Ok(()); + } + // Publication and removal share a registry lock. An unlocked UUID + // pathname can be a registration in progress, not a dead process. + let _registry = self + .lock_record(&self.dir.join(INSTANCES_DIR)) + .map_err(|error| io::Error::other(error.to_string()))?; + let instances = self.dir.join(INSTANCES_DIR); + fs::create_dir_all(&instances)?; + for entry in fs::read_dir(&instances)?.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("lock") { + continue; + } + if let Ok(file) = OpenOptions::new().read(true).write(true).open(&path) { + if file.try_lock_exclusive().is_ok() { + let _ = fs::remove_file(&path); + } + } + } + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(self.instance_lock_path(self.instance))?; + #[cfg(test)] + if let Some(hook) = self.before_instance_lock.lock().unwrap().take() { + hook(); + } + lock.try_lock_exclusive()?; + *held = Some(lock); + Ok(()) + } + + fn instance_lock_path(&self, instance: Uuid) -> PathBuf { + self.dir + .join(INSTANCES_DIR) + .join(format!("{instance}.lock")) + } + + /// Whether the harness process `instance` may still be running. + /// Uncertain ownership must block resume admission just like a live owner. + pub fn instance_alive(&self, instance: Uuid) -> bool { + self.instance_liveness(instance) != InstanceLiveness::Dead + } + + pub(crate) fn instance_liveness(&self, instance: Uuid) -> InstanceLiveness { + if instance == self.instance { + return InstanceLiveness::Alive; + } + let Ok(_registry) = self.lock_record(&self.dir.join(INSTANCES_DIR)) else { + return InstanceLiveness::Unknown; + }; + let path = self.instance_lock_path(instance); + let file = match OpenOptions::new().read(true).write(true).open(&path) { + Ok(file) => file, + Err(err) if err.kind() == io::ErrorKind::NotFound => return InstanceLiveness::Dead, + Err(_) => return InstanceLiveness::Unknown, + }; + match file.try_lock_exclusive() { + Ok(()) => { + let _ = FileExt::unlock(&file); + let _ = fs::remove_file(&path); + InstanceLiveness::Dead + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => InstanceLiveness::Alive, + Err(_) => InstanceLiveness::Unknown, + } + } + + /// Read a scope's record. A record that cannot be parsed is moved aside. + pub fn load(&self, scope: &SessionScope) -> LoadOutcome { + let path = self.record_path(scope); + match read_record(&path, &self.namespace) { + Ok(None) => LoadOutcome::Missing, + Ok(Some(record)) => LoadOutcome::Found(Box::new(record)), + Err(()) => { + if let Ok(_guard) = self.lock_record(&path) { + // Re-check under the lock: a writer may have replaced it. + if read_record(&path, &self.namespace).is_err() { + let _ = fs::rename(&path, with_suffix(&path, "corrupt")); + // A newer canonical record supersedes quarantine metadata. + // Never resurrect that older intent after corruption. + let stale = with_suffix(&path, "stale"); + let _ = fs::rename(&stale, with_suffix(&stale, "corrupt")); + } + } + tracing::warn!(target: "session_store", path = %path.display(), "unreadable session record"); + LoadOutcome::Unreadable + } + } + } + + /// Take ownership of a scope's record before calling the provider. + /// Returns the new lease. + pub fn claim( + &self, + scope: &SessionScope, + expect: &Expect, + mut live: LiveSession, + ) -> Result { + let lease = Uuid::new_v4(); + live.lease = lease; + self.update(&self.record_path(scope), |current| { + if !expect.matches(current) { + return Err(StoreError::Conflict); + } + Ok(SessionRecord { + version: RECORD_VERSION, + namespace: self.namespace.clone(), + live: Some(live), + reason: None, + revision: Some(Uuid::new_v4()), + pending_restart: current.and_then(|r| r.pending_restart.clone()), + restart_status: current.and_then(|r| r.restart_status.clone()), + control_receipts: current + .map(|r| r.control_receipts.clone()) + .unwrap_or_default(), + }) + })?; + Ok(lease) + } + + /// Change the live session while it still carries `lease`. + pub fn commit( + &self, + scope: &SessionScope, + lease: Uuid, + change: impl FnOnce(&mut LiveSession), + ) -> Result<(), StoreError> { + self.update(&self.record_path(scope), |current| { + let mut record = owned_record(current, lease)?; + if let Some(live) = record.live.as_mut() { + change(live); + } + Ok(record) + }) + } + + pub fn mark_departed( + &self, + scope: &SessionScope, + lease: Uuid, + departure: Departure, + ) -> Result<(), StoreError> { + self.commit(scope, lease, |live| live.departed = Some(departure)) + } + + /// Invalidate the owned provider after an attempt, retaining pending restart + /// policy until foreground delivery completes it. Explicit scope/channel + /// revocation uses the separate unconditional discard methods below. + pub fn discard(&self, scope: &SessionScope, lease: Uuid) -> Result<(), StoreError> { + self.update(&self.record_path(scope), |current| { + let owned = owned_record(current, lease)?; + let mut record = self.tombstone(Some(&owned)); + record.pending_restart = owned.pending_restart; + record.restart_status = owned.restart_status; + Ok(record) + }) + } + + /// Tombstone the record whoever owns it (owner commands, channel removal). + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "Explicit policy-revoking API retained; production restart uses one atomic request_restart instead" + ) + )] + pub fn discard_scope(&self, scope: &SessionScope) -> Result<(), StoreError> { + self.update(&self.record_path(scope), |current| { + Ok(self.tombstone(current)) + }) + } + + /// Tombstone every record of a channel. Returns how many were changed. + pub fn discard_channel(&self, channel_id: uuid::Uuid) -> Result { + let result = self.discard_channel_records(channel_id); + if let Err(err) = &result { + // Enumeration can fail before any record is known. A per-record + // poison cannot fence these disk-only scopes after pool sleep. + if let Ok(mut channels) = self.poisoned_channels.lock() { + channels.insert(channel_id); + } + tracing::warn!(target: "session_store", %channel_id, + "channel invalidation failed, channel will not resume: {err}"); + } + result + } + + fn discard_channel_records(&self, channel_id: Uuid) -> Result { + let dir = self.channel_dir(channel_id); + let entries = match fs::read_dir(&dir) { + Ok(entries) => entries, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(0), + Err(err) => return Err(StoreError::Io(err)), + }; + let mut changed = 0; + let mut first_error = None; + let mut visited = HashSet::new(); + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + first_error.get_or_insert(StoreError::Io(err)); + continue; + } + }; + let mut path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) == Some("stale") { + path = path.with_extension(""); + } + if path.extension().and_then(|ext| ext.to_str()) != Some("json") + || !visited.insert(path.clone()) + { + continue; + } + match self.update(&path, |current| Ok(self.tombstone(current))) { + Ok(()) => changed += 1, + Err(err) => { + first_error.get_or_insert(err); + } + } + } + match first_error { + Some(err) => Err(err), + None => Ok(changed), + } + } + + pub fn is_poisoned(&self, scope: &SessionScope) -> bool { + self.poisoned_channels + .lock() + .map(|channels| channels.contains(&scope.channel_id())) + .unwrap_or(true) + || self + .poisoned + .lock() + .map(|set| set.contains(&self.record_path(scope))) + .unwrap_or(true) + } + + #[cfg(test)] + pub fn fail_writes(&self, on: bool) { + self.fail_writes + .store(on, std::sync::atomic::Ordering::SeqCst); + } + + fn poison_path(&self, path: &Path) { + if let Ok(mut set) = self.poisoned.lock() { + set.insert(path.to_path_buf()); + } + } + + fn record_path(&self, scope: &SessionScope) -> PathBuf { + let key = match scope { + SessionScope::Conversation { channel_id } => format!("conversation:{channel_id}"), + SessionScope::Thread { + channel_id, + root_event_id, + } => format!("thread:{channel_id}:{root_event_id}"), + }; + let digest = hex::encode(Sha256::digest(key.as_bytes())); + self.channel_dir(scope.channel_id()) + .join(format!("{digest}.json")) + } + + /// Directory containing this namespace's records for one channel. + pub(crate) fn channel_dir(&self, channel_id: Uuid) -> PathBuf { + self.dir.join(channel_id.to_string()) + } + + fn tombstone(&self, current: Option<&SessionRecord>) -> SessionRecord { + SessionRecord { + version: RECORD_VERSION, + namespace: self.namespace.clone(), + live: None, + reason: Some(SessionReason::Discarded), + revision: Some(Uuid::new_v4()), + pending_restart: None, + restart_status: current + .and_then(|r| r.restart_status.clone()) + .map(|mut status| { + status.phase = RestartStatusPhase::Cancelled; + status + }), + control_receipts: current + .map(|r| r.control_receipts.clone()) + .unwrap_or_default(), + } + } + + fn lock_record(&self, path: &Path) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(StoreError::Io)?; + } + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(with_suffix(path, "lock")) + .map_err(StoreError::Io)?; + let deadline = Instant::now() + STORE_LOCK_WAIT; + loop { + match lock.try_lock_exclusive() { + Ok(()) => return Ok(lock), + Err(_) if Instant::now() < deadline => std::thread::sleep(LOCK_RETRY), + Err(_) => return Err(StoreError::Busy), + } + } + } + + /// The critical section: lock, read, decide, write. `decide` sees the + /// record currently on disk and returns the record to write. + fn update( + &self, + path: &Path, + decide: impl FnOnce(Option<&SessionRecord>) -> Result, + ) -> Result<(), StoreError> { + self.update_with_quarantine(path, decide, true) + } + + fn update_with_quarantine( + &self, + path: &Path, + decide: impl FnOnce(Option<&SessionRecord>) -> Result, + quarantine: bool, + ) -> Result<(), StoreError> { + let result = self.update_locked(path, decide, quarantine); + match &result { + Ok(()) + | Err( + StoreError::Conflict + | StoreError::AlreadyRecorded + | StoreError::StaleRequest + | StoreError::InvalidControl, + ) => {} + Err(StoreError::Busy) if !quarantine => {} + Err(err) => { + tracing::warn!( + target: "session_store", + path = %path.display(), + "session record write failed, scope will not resume: {err}" + ); + self.poison_path(path); + } + } + result + } + + fn update_locked( + &self, + path: &Path, + decide: impl FnOnce(Option<&SessionRecord>) -> Result, + quarantine: bool, + ) -> Result<(), StoreError> { + self.register_instance().map_err(StoreError::Io)?; + let lock = self.lock_record(path)?; + let current = read_record(path, &self.namespace).map_err(|()| StoreError::Unreadable)?; + let mut next = decide(current.as_ref())?; + next.revision = Some(Uuid::new_v4()); + if let Err(err) = self.write_record(path, &next) { + // Still inside the critical section, on a record `decide` + // accepted: move it aside so no later process resumes it. + if quarantine && current.is_some() { + let _ = fs::rename(path, with_suffix(path, "stale")); + } + drop(lock); + return Err(StoreError::Io(err)); + } + drop(lock); + Ok(()) + } + + fn write_record(&self, path: &Path, record: &SessionRecord) -> io::Result<()> { + #[cfg(test)] + if self.fail_writes.load(std::sync::atomic::Ordering::SeqCst) { + return Err(io::Error::other("injected write failure")); + } + let dir = path + .parent() + .ok_or_else(|| io::Error::other("record path has no parent"))?; + let json = serde_json::to_vec(record).map_err(io::Error::other)?; + if json.len() as u64 > MAX_RECORD_BYTES || !controls::valid_controls(record) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "record exceeds limits", + )); + } + let mut file = tempfile::NamedTempFile::new_in(dir)?; + file.write_all(&json)?; + file.as_file().sync_all()?; + file.persist(path).map_err(|err| err.error)?; + #[cfg(unix)] + File::open(dir)?.sync_all()?; + Ok(()) + } +} + +impl Drop for SessionStore { + fn drop(&mut self) { + let held = self.instance_lock.get_mut().ok().and_then(Option::take); + if held.is_some() { + let _ = fs::remove_file(self.instance_lock_path(self.instance)); + } + } +} + +fn store_namespace(relay: &str, agent: &str) -> io::Result { + let invalid = |message| io::Error::new(io::ErrorKind::InvalidInput, message); + let relay = url::Url::parse(relay.trim()).map_err(|_| invalid("invalid relay URL"))?; + if !matches!(relay.scheme(), "ws" | "wss") + || relay.host().is_none() + || !relay.username().is_empty() + || relay.password().is_some() + || relay.fragment().is_some() + { + return Err(invalid("invalid relay identity")); + } + // URL serialization normalizes case, default ports and the root slash. + // Keep distinct hosts (including loopback hosts), paths and queries: + // the relay host selects the community, unlike runtime process aliases. + let agent = nostr::PublicKey::from_hex(agent) + .map_err(|_| invalid("invalid agent public key"))? + .to_hex(); + let identity = serde_json::to_vec(&("buzz-session-store-v2", relay.as_str(), agent)) + .map_err(io::Error::other)?; + Ok(hex::encode(Sha256::digest(identity))) +} + +/// The current record, if its live session carries `lease`. +fn owned_record(current: Option<&SessionRecord>, lease: Uuid) -> Result { + match current { + Some(record) if record.live.as_ref().is_some_and(|live| live.lease == lease) => { + Ok(record.clone()) + } + _ => Err(StoreError::Conflict), + } +} + +/// `Ok(None)` when absent, `Err(())` when present but unusable. +fn read_record(path: &Path, namespace: &str) -> Result, ()> { + if let Some(record) = read_record_file(path, namespace)? { + return Ok(Some(record)); + } + // A failed provider write quarantines stale provider state. Its accepted + // restart policy and receipts must survive that quarantine as a tombstone. + Ok(read_record_file(&with_suffix(path, "stale"), namespace)? + .filter(|r| !r.control_receipts.is_empty() || r.pending_restart.is_some()) + .map(|mut record| { + record.live = None; + record.reason = Some(SessionReason::ResumeFailed("not_persisted".into())); + record + })) +} + +fn read_record_file(path: &Path, namespace: &str) -> Result, ()> { + use std::io::Read; + let file = match File::open(path) { + Ok(file) => file, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(()), + }; + let mut bytes = Vec::new(); + file.take(MAX_RECORD_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| ())?; + if bytes.len() as u64 > MAX_RECORD_BYTES { + return Err(()); + } + match serde_json::from_slice::(&bytes) { + Ok(record) + if record.version == RECORD_VERSION + && record.namespace == namespace + && controls::valid_controls(&record) => + { + Ok(Some(record)) + } + _ => Err(()), + } +} + +fn with_suffix(path: &Path, suffix: &str) -> PathBuf { + let mut name = path.as_os_str().to_owned(); + name.push("."); + name.push(suffix); + PathBuf::from(name) +} + +#[cfg(test)] +#[path = "session_store_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "session_store_control_tests.rs"] +mod control_tests; + +#[path = "session_store_orphans.rs"] +mod orphan_controls; diff --git a/crates/buzz-acp/src/session_store_control_tests.rs b/crates/buzz-acp/src/session_store_control_tests.rs new file mode 100644 index 00000000000..d3c48534261 --- /dev/null +++ b/crates/buzz-acp/src/session_store_control_tests.rs @@ -0,0 +1,873 @@ +use super::*; +use buzz_core::session_control::{ControlFailure, ControlOrigin, RestartDepth}; + +const AGENT: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +fn fixture() -> (tempfile::TempDir, SessionStore, SessionScope) { + let dir = tempfile::tempdir().unwrap(); + let store = SessionStore::open(dir.path().into(), "wss://relay.example", AGENT).unwrap(); + ( + dir, + store, + SessionScope::Conversation { + channel_id: Uuid::from_u128(9), + }, + ) +} +fn id(n: usize) -> String { + format!("{n:064x}") +} + +#[test] +fn admitted_compact_receipt_uses_original_freshness_without_weakening_unadmitted_api() { + let (_dir, store, scope) = fixture(); + let now = nostr::Timestamp::now().as_secs(); + let signed = now - 901; + assert!(matches!( + store.begin_control_attempt(&scope, &id(1), signed), + Err(StoreError::StaleRequest) + )); + assert!(matches!( + store.begin_admitted_control_with_receipt(&scope, &id(1), signed, now), + Err(StoreError::StaleRequest) + )); + assert!(matches!( + store.begin_admitted_control_with_receipt(&scope, &id(1), now + 60, now + 60), + Err(StoreError::StaleRequest) + )); + assert_eq!( + store + .begin_admitted_control_with_receipt(&scope, &id(1), signed, now - 20) + .unwrap() + .0, + ControlAttempt::New + ); + assert_eq!( + record(&store, &scope).control_receipts[0].created_at, + signed + ); + assert_eq!( + store + .begin_admitted_control_with_receipt(&scope, &id(1), signed, now - 20) + .unwrap() + .0, + ControlAttempt::PriorStarted + ); +} + +#[test] +fn compact_terminal_receipt_retry_checks_exact_owner_generation_and_preserves_supersession() { + let (dir, store, scope) = fixture(); + let now = nostr::Timestamp::now().as_secs(); + store.begin_control_attempt(&scope, &id(1), now).unwrap(); + let expected = record(&store, &scope).control_receipts[0].clone(); + let mut wrong = expected.clone(); + wrong.owner_generation = Uuid::new_v4(); + assert!(matches!( + store.retry_control_outcome(&scope, &wrong, &Ok(())), + Err(StoreError::Conflict) + )); + assert_eq!( + record(&store, &scope).control_receipts[0].state, + ControlReceiptState::Started + ); + let foreign = SessionStore::open(dir.path().into(), "wss://relay.example", AGENT).unwrap(); + assert!(matches!( + foreign.retry_control_outcome(&scope, &expected, &Ok(())), + Err(StoreError::Conflict) + )); + store + .request_restart_superseding(&scope, &intent(2)) + .unwrap(); + assert_eq!( + store + .retry_control_outcome(&scope, &expected, &Ok(())) + .unwrap(), + Some(Err(ControlFailure::Superseded)) + ); + assert_eq!( + record(&store, &scope).control_receipts[0].state, + ControlReceiptState::Finished(Err(ControlFailure::Superseded)) + ); +} +fn intent(n: usize) -> RestartIntent { + RestartIntent { + token: Uuid::new_v4(), + depth: RestartDepth::All, + origin: ControlOrigin { + command_event_id: id(n), + actor_pubkey: id(999), + }, + } +} +fn record(store: &SessionStore, scope: &SessionScope) -> SessionRecord { + match store.load(scope) { + LoadOutcome::Found(r) => *r, + other => panic!("{other:?}"), + } +} +fn live(store: &SessionStore) -> LiveSession { + LiveSession { + lease: Uuid::nil(), + adapter: "fake".into(), + session_id: "existing".into(), + cwd: "/tmp".into(), + model: None, + instance: store.instance(), + worker_index: 0, + departed: None, + turns: 0, + standing_context_sent: false, + delivered_event_ids: vec![], + hydrated_thread_roots: vec![], + } +} +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() +} + +#[test] +fn restart_intent_is_atomic() { + let (_dir, store, scope) = fixture(); + let lease = store.claim(&scope, &Expect::Missing, live(&store)).unwrap(); + let restart = intent(1); + store.request_restart(&scope, &restart).unwrap(); + let stored = record(&store, &scope); + assert!(stored.live.is_none()); + assert_eq!(stored.pending_restart, Some(restart)); + assert!(stored.revision.is_some()); + assert!(matches!( + store.commit(&scope, lease, |_| {}), + Err(StoreError::Conflict) + )); +} + +#[test] +fn legacy_v2_record_keeps_live_session() { + let (_dir, store, scope) = fixture(); + store.claim(&scope, &Expect::Missing, live(&store)).unwrap(); + let mut json = serde_json::to_value(record(&store, &scope)).unwrap(); + for field in ["revision", "pending_restart", "control_receipts"] { + json.as_object_mut().unwrap().remove(field); + } + fs::write( + store.record_path(&scope), + serde_json::to_vec(&json).unwrap(), + ) + .unwrap(); + let legacy = record(&store, &scope); + assert!(legacy.live.is_some()); + assert_eq!(legacy.revision, None); + assert!(legacy.control_receipts.is_empty()); + store + .claim( + &scope, + &Expect::from_outcome(&store.load(&scope)), + live(&store), + ) + .unwrap(); + assert!(record(&store, &scope).revision.is_some()); +} + +#[test] +fn tombstone_revision_rejects_aba() { + let (_dir, store, scope) = fixture(); + store.discard_scope(&scope).unwrap(); + let old = Expect::from_outcome(&store.load(&scope)); + store.request_restart(&scope, &intent(1)).unwrap(); + assert!(matches!( + store.claim(&scope, &old, live(&store)), + Err(StoreError::Conflict) + )); +} + +#[test] +fn claim_preserves_pending_restart_and_commit_does_not_clear_it() { + let (_dir, store, scope) = fixture(); + let restart = intent(1); + store.request_restart(&scope, &restart).unwrap(); + let lease = store + .claim( + &scope, + &Expect::from_outcome(&store.load(&scope)), + live(&store), + ) + .unwrap(); + store.commit(&scope, lease, |live| live.turns += 1).unwrap(); + assert_eq!( + record(&store, &scope).pending_restart, + Some(restart.clone()) + ); + store.complete_restart(&scope, restart.token).unwrap(); + assert_eq!(record(&store, &scope).pending_restart, None); +} + +#[test] +fn failed_attempt_discard_preserves_restart_until_matching_completion() { + for depth in [RestartDepth::None, RestartDepth::Recent, RestartDepth::All] { + let (_dir, store, scope) = fixture(); + let mut restart = intent(1); + restart.depth = depth; + store.request_restart(&scope, &restart).unwrap(); + let receipts = record(&store, &scope).control_receipts; + let first_lease = store + .claim( + &scope, + &Expect::from_outcome(&store.load(&scope)), + live(&store), + ) + .unwrap(); + // Existing initial-message error/cancel cleanup discards this lease + // before the foreground assignment has been delivered successfully. + store.discard(&scope, first_lease).unwrap(); + let discarded = record(&store, &scope); + assert!(discarded.live.is_none()); + assert_eq!(discarded.pending_restart, Some(restart.clone())); + assert_eq!(discarded.control_receipts, receipts); + assert!(matches!( + store.commit(&scope, first_lease, |_| {}), + Err(StoreError::Conflict) + )); + store + .claim( + &scope, + &Expect::from_outcome(&store.load(&scope)), + live(&store), + ) + .unwrap(); + assert_eq!( + record(&store, &scope).pending_restart, + Some(restart.clone()) + ); + assert!(matches!( + store.complete_restart(&scope, Uuid::new_v4()), + Err(StoreError::Conflict) + )); + assert_eq!( + record(&store, &scope).pending_restart, + Some(restart.clone()) + ); + store.complete_restart(&scope, restart.token).unwrap(); + assert_eq!(record(&store, &scope).pending_restart, None); + assert_eq!(record(&store, &scope).control_receipts, receipts); + } +} + +#[test] +fn explicit_scope_and_channel_revocation_clear_intent_but_keep_receipts() { + for channel_wide in [false, true] { + let (_dir, store, scope) = fixture(); + let restart = intent(1); + store.request_restart(&scope, &restart).unwrap(); + let receipts = record(&store, &scope).control_receipts; + let lease = store + .claim( + &scope, + &Expect::from_outcome(&store.load(&scope)), + live(&store), + ) + .unwrap(); + if channel_wide { + store.discard_channel(scope.channel_id()).unwrap(); + } else { + store.discard_scope(&scope).unwrap(); + } + // A late failed attempt cannot restore a deliberately revoked policy. + assert!(matches!( + store.discard(&scope, lease), + Err(StoreError::Conflict) + )); + let revoked = record(&store, &scope); + assert!(revoked.live.is_none()); + assert_eq!(revoked.pending_restart, None); + assert_eq!(revoked.control_receipts, receipts); + } +} + +#[test] +fn old_token_cannot_clear_newer_intent() { + let (_dir, store, scope) = fixture(); + let old = intent(1); + let new = intent(2); + store.request_restart(&scope, &old).unwrap(); + store.request_restart(&scope, &new).unwrap(); + assert!(matches!( + store.complete_restart(&scope, old.token), + Err(StoreError::Conflict) + )); + assert_eq!(record(&store, &scope).pending_restart, Some(new)); + // Redelivery of the older command must not fence the new intent again. + store.request_restart(&scope, &old).unwrap(); + assert_eq!( + record(&store, &scope) + .pending_restart + .as_ref() + .unwrap() + .origin + .command_event_id, + id(2) + ); +} + +#[test] +fn started_compact_redelivery_never_reexecutes() { + let (_dir, store, scope) = fixture(); + assert_eq!( + store.begin_control_attempt(&scope, &id(1), now()).unwrap(), + ControlAttempt::New + ); + assert_eq!( + store.begin_control_attempt(&scope, &id(1), now()).unwrap(), + ControlAttempt::PriorStarted + ); + store + .finish_control_attempt(&scope, &id(1), &Err(ControlFailure::Unconfirmed)) + .unwrap(); + assert_eq!( + store.begin_control_attempt(&scope, &id(1), now()).unwrap(), + ControlAttempt::PriorFinished(Err(ControlFailure::Unconfirmed)) + ); + assert!(matches!( + store.finish_control_attempt(&scope, &id(1), &Ok(())), + Err(StoreError::Conflict) + )); +} + +#[test] +fn restart_started_receipt_is_written_with_tombstone_intent() { + let (_dir, store, scope) = fixture(); + store.claim(&scope, &Expect::Missing, live(&store)).unwrap(); + let before = fs::read(store.record_path(&scope)).unwrap(); + let restart = intent(1); + store.fail_writes(true); + assert!(store.request_restart(&scope, &restart).is_err()); + assert_eq!(fs::read(store.record_path(&scope)).unwrap(), before); + store.fail_writes(false); + store.request_restart(&scope, &restart).unwrap(); + let stored = record(&store, &scope); + assert!(stored.live.is_none()); + assert_eq!(stored.pending_restart, Some(restart)); + assert_eq!(stored.control_receipts.len(), 1); + assert_eq!( + stored.control_receipts[0].state, + ControlReceiptState::Started + ); + assert_eq!(stored.control_receipts[0].owner_instance, store.instance()); +} + +#[test] +fn compact_receipt_survives_restart_rotate_channel_tombstone() { + let (_dir, store, scope) = fixture(); + store.begin_control_attempt(&scope, &id(1), now()).unwrap(); + store + .finish_control_attempt(&scope, &id(1), &Ok(())) + .unwrap(); + let expected = ControlAttempt::PriorFinished(Ok(())); + store.request_restart(&scope, &intent(2)).unwrap(); + assert_eq!( + store.begin_control_attempt(&scope, &id(1), now()).unwrap(), + expected + ); + let lease = store + .claim( + &scope, + &Expect::from_outcome(&store.load(&scope)), + live(&store), + ) + .unwrap(); + store.discard(&scope, lease).unwrap(); + store.discard_scope(&scope).unwrap(); + store.discard_channel(scope.channel_id()).unwrap(); + assert_eq!( + store.begin_control_attempt(&scope, &id(1), now()).unwrap(), + expected + ); +} + +#[test] +fn orphan_started_becomes_terminal_without_reexecution() { + let (dir, store, scope) = fixture(); + store.begin_control_attempt(&scope, &id(1), now()).unwrap(); + let receipt = record(&store, &scope).control_receipts[0].clone(); + let observer = SessionStore::open(dir.path().into(), "wss://relay.example", AGENT).unwrap(); + assert!(matches!( + observer.reconcile_control_attempt( + &scope, + &id(1), + receipt.owner_instance, + receipt.owner_generation + ), + Err(StoreError::Conflict) + )); + assert!(matches!( + store.reconcile_control_attempt(&scope, &id(1), receipt.owner_instance, Uuid::new_v4()), + Err(StoreError::Conflict) + )); + drop(store); + observer + .reconcile_control_attempt( + &scope, + &id(1), + receipt.owner_instance, + receipt.owner_generation, + ) + .unwrap(); + assert_eq!( + observer + .begin_control_attempt(&scope, &id(1), now()) + .unwrap(), + ControlAttempt::PriorFinished(Err(ControlFailure::Unconfirmed)) + ); +} + +#[test] +fn receipts_are_bounded_fresh_and_pending_never_evicted() { + let (_dir, store, scope) = fixture(); + for n in 0..128 { + store.begin_control_attempt(&scope, &id(n), now()).unwrap(); + store + .finish_control_attempt(&scope, &id(n), &Ok(())) + .unwrap(); + } + assert!(matches!( + store.begin_control_attempt(&scope, &id(128), now()), + Err(StoreError::Busy) + )); + assert!(matches!( + store.begin_control_attempt(&scope, &id(129), now() - 901), + Err(StoreError::StaleRequest) + )); + assert!(matches!( + store.begin_control_attempt(&scope, &id(129), now() + 31), + Err(StoreError::StaleRequest) + )); + let path = store.record_path(&scope); + let mut stored = record(&store, &scope); + stored.control_receipts[0].created_at = now() - 931; + stored.control_receipts[1].created_at = now() - 931; + stored.control_receipts[1].state = ControlReceiptState::Started; + fs::write(&path, serde_json::to_vec(&stored).unwrap()).unwrap(); + assert_eq!( + store + .begin_control_attempt(&scope, &id(128), now()) + .unwrap(), + ControlAttempt::New + ); + let stored = record(&store, &scope); + assert_eq!(stored.control_receipts.len(), 128); + assert!(stored + .control_receipts + .iter() + .any(|r| r.command_id == id(1))); + assert!(!stored + .control_receipts + .iter() + .any(|r| r.command_id == id(0))); +} + +#[test] +fn receipt_write_and_lock_failures_do_not_allow_execution() { + let (_dir, store, scope) = fixture(); + store.fail_writes(true); + assert!(store.begin_control_attempt(&scope, &id(1), now()).is_err()); + store.fail_writes(false); + let lock = store.lock_record(&store.record_path(&scope)).unwrap(); + assert!(matches!( + store.begin_control_attempt(&scope, &id(1), now()), + Err(StoreError::Busy) + )); + drop(lock); + assert_eq!( + store.begin_control_attempt(&scope, &id(1), now()).unwrap(), + ControlAttempt::New + ); +} + +#[test] +fn foreign_community_and_agent_cannot_adopt_control_metadata() { + let (dir, store, scope) = fixture(); + store.request_restart(&scope, &intent(1)).unwrap(); + for (relay, agent) in [ + ("wss://other.example", AGENT.to_owned()), + ( + "wss://relay.example", + nostr::Keys::generate().public_key().to_hex(), + ), + ] { + let foreign = SessionStore::open(dir.path().into(), relay, &agent).unwrap(); + let path = foreign.record_path(&scope); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::copy(store.record_path(&scope), &path).unwrap(); + assert!(matches!( + foreign.begin_control_attempt(&scope, &id(1), now()), + Err(StoreError::Unreadable) + )); + } +} + +#[test] +fn quarantine_retains_control_metadata_without_recovering_provider_state() { + let (dir, store, scope) = fixture(); + let restart = intent(1); + store.request_restart(&scope, &restart).unwrap(); + store + .finish_control_attempt(&scope, &id(1), &Ok(())) + .unwrap(); + let lease = store + .claim( + &scope, + &Expect::from_outcome(&store.load(&scope)), + live(&store), + ) + .unwrap(); + store.fail_writes(true); + assert!(store.commit(&scope, lease, |live| live.turns += 1).is_err()); + drop(store); + let store = SessionStore::open(dir.path().into(), "wss://relay.example", AGENT).unwrap(); + let recovered = record(&store, &scope); + assert_eq!(recovered.live, None); + assert_eq!(recovered.pending_restart, Some(restart.clone())); + assert_eq!( + store.begin_control_attempt(&scope, &id(1), now()).unwrap(), + ControlAttempt::PriorFinished(Ok(())) + ); + // A canonical successful clear always wins over older quarantine metadata. + store.complete_restart(&scope, restart.token).unwrap(); + assert_eq!(record(&store, &scope).pending_restart, None); + fs::write(store.record_path(&scope), b"corrupt newer record").unwrap(); + assert_eq!(store.load(&scope), LoadOutcome::Unreadable); + assert_eq!(store.load(&scope), LoadOutcome::Missing); +} + +#[test] +fn channel_discard_includes_quarantined_controls_and_keeps_receipts() { + let (_dir, store, scope) = fixture(); + let restart = intent(1); + store.request_restart(&scope, &restart).unwrap(); + let lease = store + .claim( + &scope, + &Expect::from_outcome(&store.load(&scope)), + live(&store), + ) + .unwrap(); + store.fail_writes(true); + assert!(store.commit(&scope, lease, |_| {}).is_err()); + store.fail_writes(false); + assert_eq!(store.discard_channel(scope.channel_id()).unwrap(), 1); + let stored = record(&store, &scope); + assert!(stored.pending_restart.is_none()); + assert_eq!(stored.control_receipts.len(), 1); + assert!(stored.live.is_none()); +} + +#[test] +fn same_instance_reconciliation_requires_exact_command_and_generation() { + let (_dir, store, scope) = fixture(); + store.begin_control_attempt(&scope, &id(1), now()).unwrap(); + let receipt = record(&store, &scope).control_receipts[0].clone(); + assert!(matches!( + store.reconcile_control_attempt( + &scope, + &id(2), + receipt.owner_instance, + receipt.owner_generation + ), + Err(StoreError::Conflict) + )); + store + .reconcile_control_attempt( + &scope, + &id(1), + receipt.owner_instance, + receipt.owner_generation, + ) + .unwrap(); + assert_eq!( + store.begin_control_attempt(&scope, &id(1), now()).unwrap(), + ControlAttempt::PriorFinished(Err(ControlFailure::Unconfirmed)) + ); +} + +#[test] +fn failed_finish_preserves_started_and_failed_begin_preserves_previous_receipts() { + let (_dir, store, scope) = fixture(); + store.begin_control_attempt(&scope, &id(1), now()).unwrap(); + store.fail_writes(true); + assert!(store + .finish_control_attempt(&scope, &id(1), &Ok(())) + .is_err()); + assert!(store.begin_control_attempt(&scope, &id(2), now()).is_err()); + store.fail_writes(false); + assert_eq!( + store.begin_control_attempt(&scope, &id(1), now()).unwrap(), + ControlAttempt::PriorStarted + ); + assert_eq!(record(&store, &scope).control_receipts.len(), 1); +} + +#[test] +fn metadata_survives_store_reopen_and_foreign_quarantine_is_rejected() { + let (dir, store, scope) = fixture(); + let restart = intent(1); + store.request_restart(&scope, &restart).unwrap(); + drop(store); + let store = SessionStore::open(dir.path().into(), "wss://relay.example", AGENT).unwrap(); + assert_eq!(record(&store, &scope).pending_restart, Some(restart)); + let foreign = SessionStore::open(dir.path().into(), "wss://foreign.example", AGENT).unwrap(); + let path = foreign.record_path(&scope); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::copy(store.record_path(&scope), with_suffix(&path, "stale")).unwrap(); + assert_eq!(foreign.load(&scope), LoadOutcome::Unreadable); + assert_eq!(foreign.load(&scope), LoadOutcome::Missing); +} + +#[test] +fn concurrent_duplicate_starts_have_exactly_one_new_admission() { + let (_dir, store, scope) = fixture(); + let admissions = std::thread::scope(|threads| { + let jobs: Vec<_> = (0..8) + .map(|_| threads.spawn(|| store.begin_control_attempt(&scope, &id(1), now()).unwrap())) + .collect(); + jobs.into_iter() + .map(|j| j.join().unwrap()) + .collect::>() + }); + assert_eq!( + admissions + .iter() + .filter(|a| **a == ControlAttempt::New) + .count(), + 1 + ); + assert_eq!( + admissions + .iter() + .filter(|a| **a == ControlAttempt::PriorStarted) + .count(), + 7 + ); +} + +#[test] +fn malformed_or_oversized_receipt_records_are_rejected() { + let (_dir, store, scope) = fixture(); + store.begin_control_attempt(&scope, &id(1), now()).unwrap(); + let original = record(&store, &scope); + for invalid in 0..3 { + let mut stored = original.clone(); + match invalid { + 0 => stored.control_receipts[0].command_id = "invalid".into(), + 1 => stored + .control_receipts + .push(stored.control_receipts[0].clone()), + _ => { + stored.control_receipts = (0..129) + .map(|n| { + let mut r = original.control_receipts[0].clone(); + r.command_id = id(n); + r + }) + .collect(); + } + } + // The writer must reject invalid metadata before replacing a record; + // reader validation alone cannot hide a successful invalid write. + let path = store.record_path(&scope); + let before = fs::read(&path).ok(); + assert!(store.write_record(&path, &stored).is_err()); + assert_eq!(fs::read(&path).ok(), before); + fs::write( + store.record_path(&scope), + serde_json::to_vec(&stored).unwrap(), + ) + .unwrap(); + assert_eq!(store.load(&scope), LoadOutcome::Unreadable); + } + assert!(matches!( + store.begin_control_attempt(&scope, "invalid", now()), + Err(StoreError::InvalidControl) + )); + let mut oversized = live(&store); + oversized.cwd = "x".repeat(MAX_RECORD_BYTES as usize); + assert!(matches!( + store.claim(&scope, &Expect::Missing, oversized), + Err(StoreError::Io(err)) if err.kind() == io::ErrorKind::InvalidData + )); + assert!(!store.record_path(&scope).exists()); + let mut bad_restart = intent(3); + bad_restart.origin.actor_pubkey = "invalid".into(); + assert!(matches!( + store.request_restart(&scope, &bad_restart), + Err(StoreError::InvalidControl) + )); +} + +#[test] +fn startup_sweep_terminalizes_dead_started_controls_but_preserves_live_owner() { + let (dir, first, scope) = fixture(); + first.request_restart(&scope, &intent(1)).unwrap(); + let second = SessionStore::open(dir.path().into(), "wss://relay.example", AGENT).unwrap(); + assert!(second + .sweep_orphan_controls(128) + .unwrap() + .finished + .is_empty()); + drop(first); + assert_eq!( + second.sweep_orphan_controls(128).unwrap().finished, + vec![id(1)] + ); + assert_eq!( + record(&second, &scope).control_receipts[0].state, + ControlReceiptState::Finished(Err(ControlFailure::Unconfirmed)) + ); + assert!( + record(&second, &scope).pending_restart.is_some(), + "restart depth survives orphan reconciliation" + ); + assert!(second + .sweep_orphan_controls(128) + .unwrap() + .finished + .is_empty()); +} + +#[test] +fn startup_sweep_chunks_orphan_outcomes_without_losing_remaining_started_receipts() { + let (dir, first, scope) = fixture(); + let scopes = [ + scope, + SessionScope::Conversation { + channel_id: Uuid::from_u128(10), + }, + SessionScope::Conversation { + channel_id: Uuid::from_u128(11), + }, + ]; + for (index, scope) in scopes.iter().enumerate() { + first.request_restart(scope, &intent(index + 1)).unwrap(); + } + drop(first); + let second = SessionStore::open(dir.path().into(), "wss://relay.example", AGENT).unwrap(); + let a = second.sweep_orphan_controls(2).unwrap().finished; + let b = second.sweep_orphan_controls(2).unwrap().finished; + assert_eq!((a.len(), b.len()), (2, 1)); + assert_eq!(a.into_iter().chain(b).collect::>().len(), 3); + assert!(second.sweep_orphan_controls(2).unwrap().finished.is_empty()); + for scope in scopes { + assert!(record(&second, &scope) + .control_receipts + .iter() + .all(|receipt| receipt.state + == ControlReceiptState::Finished(Err(ControlFailure::Unconfirmed)))); + } +} + +#[test] +fn startup_sweep_keeps_healthy_and_committed_prefix_outcomes_across_bad_records() { + for busy in [false, true] { + let (dir, first, scope) = fixture(); + let mut scopes: Vec<_> = (0..3) + .map(|index| SessionScope::Thread { + channel_id: scope.channel_id(), + root_event_id: format!("{index:064x}"), + }) + .collect(); + scopes.sort_by_key(|scope| first.record_path(scope)); + for (index, scope) in scopes.iter().enumerate() { + first.request_restart(scope, &intent(index + 1)).unwrap(); + } + let observed_order: Vec<_> = fs::read_dir(first.channel_dir(scope.channel_id())) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|e| e == "json")) + .collect(); + let bad_path = observed_order[1].clone(); + let prefix_scope = scopes + .iter() + .find(|scope| first.record_path(scope) == observed_order[0]) + .unwrap() + .clone(); + let bad_scope = scopes + .iter() + .find(|scope| first.record_path(scope) == bad_path) + .unwrap() + .clone(); + let bad_id = record(&first, &bad_scope).control_receipts[0] + .command_id + .clone(); + let expected: HashSet<_> = scopes + .iter() + .filter(|scope| **scope != bad_scope) + .map(|scope| record(&first, scope).control_receipts[0].command_id.clone()) + .collect(); + let good_bytes = fs::read(&bad_path).unwrap(); + drop(first); + let second = std::sync::Arc::new( + SessionStore::open(dir.path().into(), "wss://relay.example", AGENT).unwrap(), + ); + let lock = if busy { + Some(second.lock_record(&bad_path).unwrap()) + } else { + fs::write(&bad_path, b"corrupt").unwrap(); + None + }; + let controls = crate::session_controls::SessionControlRuntime::new(Some(second.clone())); + let result = controls.sweep_orphans(128); + assert!(result.is_ok(), "one bad record must not discard the committed prefix or abandon healthy scopes: {result:?}"); + let results: HashSet<_> = controls + .take_publications() + .into_iter() + .map(|item| item.command_id) + .collect(); + assert_eq!(results, expected); + assert!(record(&second, &prefix_scope) + .control_receipts + .iter() + .all(|r| matches!(r.state, ControlReceiptState::Finished(_)))); + if busy { + drop(lock); + } else { + fs::write(&bad_path, good_bytes).unwrap(); + } + controls.sweep_orphans(128).unwrap(); + assert_eq!( + controls + .take_publications() + .into_iter() + .map(|p| p.command_id) + .collect::>(), + vec![bad_id] + ); + } +} + +#[test] +fn startup_sweep_recovers_stale_only_started_receipt_and_deduplicates_record_key() { + let (dir, first, scope) = fixture(); + first.request_restart(&scope, &intent(1)).unwrap(); + let path = first.record_path(&scope); + fs::rename(&path, with_suffix(&path, "stale")).unwrap(); + drop(first); + let second = SessionStore::open(dir.path().into(), "wss://relay.example", AGENT).unwrap(); + assert_eq!( + second.sweep_orphan_controls(128).unwrap().finished, + vec![id(1)] + ); + assert!(record(&second, &scope).pending_restart.is_some()); + assert!( + path.exists(), + "existing reader and locked writer recover the canonical key" + ); + assert!( + second + .sweep_orphan_controls(128) + .unwrap() + .finished + .is_empty(), + "canonical plus stale must not publish twice" + ); +} diff --git a/crates/buzz-acp/src/session_store_controls.rs b/crates/buzz-acp/src/session_store_controls.rs new file mode 100644 index 00000000000..cf9923f7d67 --- /dev/null +++ b/crates/buzz-acp/src/session_store_controls.rs @@ -0,0 +1,483 @@ +//! Durable control receipts share the provider record's lock and atomic write. +use super::*; +use buzz_core::session_control::{ + control_is_fresh, is_canonical_id, ControlFailure, ControlOrigin, ControlOutcome, RestartDepth, + CONTROL_FUTURE_SKEW_SECS, CONTROL_MAX_AGE_SECS, +}; + +/// Maximum protected command receipts per scope. Active receipts are never evicted. +pub const MAX_CONTROL_RECEIPTS: usize = 128; + +/// Persisted restart policy; cleared only after successful foreground delivery. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RestartIntent { + /// CAS token; an older delivery cannot clear a newer restart. + pub token: Uuid, + /// Signed command identity and authorized effective author. + pub origin: ControlOrigin, + /// Requested next cold-start history depth. + pub depth: RestartDepth, +} + +/// Durable execution marker; Started never authorizes implicit reexecution. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ControlReceiptState { + /// Persisted before an external effect may begin. + Started, + /// Terminal outcome, reusable on redelivery. + Finished(ControlOutcome), +} + +/// One bounded deduplication receipt in a scope record. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ControlReceipt { + /// Canonical signed event ID. + pub command_id: String, + /// Signed time for compact; admission time for atomic restart. Retention + /// includes the maximum future skew so a fresh restart cannot lose its receipt. + pub created_at: u64, + /// Instance which admitted the effect. + pub owner_instance: Uuid, + /// Captured live lease, or record revision when no live provider exists. + pub owner_generation: Uuid, + /// Current durable state. + pub state: ControlReceiptState, +} + +/// Admission result. Only New permits an initial effect; coalescing an active +/// duplicate and reconciling orphaned Started receipts belong to the controller. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ControlAttempt { + /// A new durable Started marker was written. + New, + /// Prior marker exists; do not execute again. + PriorStarted, + /// Prior terminal outcome; do not execute again. + PriorFinished(ControlOutcome), +} + +impl SessionStore { + pub(crate) fn record_worker_recycled(&self, scope: &SessionScope) -> Result<(), StoreError> { + self.update_control(&self.record_path(scope), |current| { + // A restart admitted while another scope's worker is stopping owns + // this record. Preserve its policy and receipts, never provider identity. + if let Some(current) = current.filter(|record| record.pending_restart.is_some()) { + let mut record = current.clone(); + record.live = None; + return Ok(record); + } + let mut record = self.tombstone(current); + record.reason = Some(SessionReason::WorkerRecycled); + Ok(record) + }) + } + + /// Atomically fence the old provider and persist restart intent plus Started + /// receipt. The caller must freshness-check signed created_at immediately + /// before admission. Receipts here use admission time because RestartIntent + /// carries no signed timestamp. Duplicate command IDs never reapply a reset. + pub fn request_restart( + &self, + scope: &SessionScope, + intent: &RestartIntent, + ) -> Result<(), StoreError> { + self.request_restart_inner(scope, intent, false, None) + .map(|_| ()) + } + + /// Supersede this instance's active controls in the same atomic reset. + pub(crate) fn request_restart_superseding( + &self, + scope: &SessionScope, + intent: &RestartIntent, + ) -> Result, StoreError> { + self.request_restart_inner(scope, intent, true, None) + } + + pub(super) fn controls_request_restart_status( + &self, + scope: &SessionScope, + intent: &RestartIntent, + status: RestartStatus, + ) -> Result, StoreError> { + self.request_restart_inner(scope, intent, true, Some(status)) + } + + fn request_restart_inner( + &self, + scope: &SessionScope, + intent: &RestartIntent, + supersede: bool, + status: Option, + ) -> Result, StoreError> { + let mut superseded = Vec::new(); + intent + .origin + .validate() + .map_err(|_| StoreError::InvalidControl)?; + let now = unix_now()?; + self.update_control(&self.record_path(scope), |current| { + if current + .and_then(|r| receipt(r, &intent.origin.command_event_id)) + .is_some() + { + return Err(StoreError::AlreadyRecorded); + } + let mut record = self.tombstone(current); + if supersede { + for receipt in &mut record.control_receipts { + if receipt.owner_instance == self.instance + && receipt.state == ControlReceiptState::Started + { + receipt.state = + ControlReceiptState::Finished(Err(ControlFailure::Superseded)); + superseded.push(receipt.command_id.clone()); + } + } + } + make_room(&mut record.control_receipts, now)?; + add_started( + &mut record, + &intent.origin.command_event_id, + now, + self.instance, + ); + record.reason = Some(SessionReason::UserRestart(intent.depth)); + record.pending_restart = Some(intent.clone()); + record.restart_status = status; + Ok(record) + }) + .or_else(|err| match err { + StoreError::AlreadyRecorded => Ok(()), + other => Err(other), + })?; + Ok(superseded) + } + + /// Clear only this exact restart after successful foreground prompt delivery. + pub fn complete_restart(&self, scope: &SessionScope, token: Uuid) -> Result<(), StoreError> { + self.update_control(&self.record_path(scope), |current| { + let mut record = current.cloned().ok_or(StoreError::Conflict)?; + if record.pending_restart.as_ref().map(|i| i.token) != Some(token) { + return Err(StoreError::Conflict); + } + record.pending_restart = None; + if let Some(status) = record + .restart_status + .as_mut() + .filter(|status| status.token == token && status.active()) + { + status.phase = RestartStatusPhase::Cancelled; + } + Ok(record) + }) + } + + /// Write Started before attempting compact. Return prior state on duplicate + /// delivery, including after a crash. This API performs no provider operation. + pub fn begin_control_attempt( + &self, + scope: &SessionScope, + command_id: &str, + created_at: u64, + ) -> Result { + self.begin_control_attempt_at(scope, command_id, created_at, unix_now()?) + .map(|(attempt, _)| attempt) + } + + /// Persist an already admitted control using its original freshness check. + /// Execution still enforces the captured logical deadline; receipt completion + /// may follow bounded cleanup after that deadline. + pub(crate) fn begin_admitted_control_with_receipt( + &self, + scope: &SessionScope, + command_id: &str, + created_at: u64, + admitted_at: u64, + ) -> Result<(ControlAttempt, ControlReceipt), StoreError> { + self.begin_control_attempt_at(scope, command_id, created_at, admitted_at) + } + + fn begin_control_attempt_at( + &self, + scope: &SessionScope, + command_id: &str, + created_at: u64, + admitted_at: u64, + ) -> Result<(ControlAttempt, ControlReceipt), StoreError> { + if !is_canonical_id(command_id) { + return Err(StoreError::InvalidControl); + } + let now = unix_now()?; + if admitted_at > now.saturating_add(CONTROL_FUTURE_SKEW_SECS) { + return Err(StoreError::StaleRequest); + } + let mut attempt = ControlAttempt::New; + let mut observed = None; + let result = self.update_control(&self.record_path(scope), |current| { + if let Some(prior) = current.and_then(|r| receipt(r, command_id)) { + observed = Some(prior.clone()); + attempt = match prior.state { + ControlReceiptState::Started => ControlAttempt::PriorStarted, + ControlReceiptState::Finished(outcome) => { + ControlAttempt::PriorFinished(outcome) + } + }; + return Err(StoreError::AlreadyRecorded); + } + if !control_is_fresh(created_at, admitted_at) { + return Err(StoreError::StaleRequest); + } + let mut record = current.cloned().unwrap_or_else(|| self.tombstone(None)); + make_room(&mut record.control_receipts, now)?; + observed = Some(add_started( + &mut record, + command_id, + created_at, + self.instance, + )); + Ok(record) + }); + match result { + Ok(()) | Err(StoreError::AlreadyRecorded) => observed + .map(|receipt| (attempt, receipt)) + .ok_or(StoreError::InvalidControl), + Err(err) => Err(err), + } + } + + /// Persist the owning instance's terminal result. Conflicting terminal writes + /// are refused, preserving the first result rather than claiming success. + pub fn finish_control_attempt( + &self, + scope: &SessionScope, + command_id: &str, + result: &ControlOutcome, + ) -> Result<(), StoreError> { + self.update_control(&self.record_path(scope), |current| { + let mut record = current.cloned().ok_or(StoreError::Conflict)?; + let prior = record + .control_receipts + .iter_mut() + .find(|r| r.command_id == command_id) + .ok_or(StoreError::Conflict)?; + if prior.owner_instance != self.instance { + return Err(StoreError::Conflict); + } + match prior.state { + ControlReceiptState::Started => { + prior.state = ControlReceiptState::Finished(*result); + if let Some(status) = record + .restart_status + .as_mut() + .filter(|status| status.origin.command_event_id == command_id) + { + if *result == Ok(()) + && status.phase == RestartStatusPhase::WaitingRetirement + { + status.phase = RestartStatusPhase::Pending; + status.deadline = unix_now()?.saturating_add(120); + } else if result.is_err() { + status.phase = RestartStatusPhase::Cancelled; + } + } + } + ControlReceiptState::Finished(previous) if previous == *result => { + return Err(StoreError::AlreadyRecorded) + } + _ => return Err(StoreError::Conflict), + } + Ok(record) + }) + .or_else(|err| match err { + StoreError::AlreadyRecorded => Ok(()), + other => Err(other), + }) + } + + /// Retry only a known-finalized local receipt's terminal write. The exact + /// owner and generation fence the write; a newer terminal result wins. + /// `None` means absence was proven under the record lock. + pub(crate) fn retry_control_outcome( + &self, + scope: &SessionScope, + expected: &ControlReceipt, + outcome: &ControlOutcome, + ) -> Result, StoreError> { + if expected.owner_instance != self.instance { + return Err(StoreError::Conflict); + } + let mut resolved = None; + let result = self.update_control(&self.record_path(scope), |current| { + let Some(mut record) = current.cloned() else { + return Err(StoreError::AlreadyRecorded); + }; + let Some(receipt) = record + .control_receipts + .iter_mut() + .find(|receipt| receipt.command_id == expected.command_id) + else { + return Err(StoreError::AlreadyRecorded); + }; + if receipt.owner_instance != expected.owner_instance + || receipt.owner_generation != expected.owner_generation + { + return Err(StoreError::Conflict); + } + if let ControlReceiptState::Finished(prior) = receipt.state { + resolved = Some(prior); + return Err(StoreError::AlreadyRecorded); + } + receipt.state = ControlReceiptState::Finished(*outcome); + resolved = Some(*outcome); + Ok(record) + }); + match result { + Ok(()) | Err(StoreError::AlreadyRecorded) => Ok(resolved), + Err(error) => Err(error), + } + } + + /// Terminalize an orphan without reexecuting it. For this instance the caller + /// must prove that the exact command+generation job has retired (including + /// panic handling). A foreign owner additionally must be proven dead by its + /// OS instance lock; empty local scheduler state is never that proof. + pub(crate) fn reconcile_control_attempt( + &self, + scope: &SessionScope, + command_id: &str, + instance: Uuid, + generation: Uuid, + ) -> Result<(), StoreError> { + if instance != self.instance && self.instance_alive(instance) { + return Err(StoreError::Conflict); + } + self.update_control(&self.record_path(scope), |current| { + let mut record = current.cloned().ok_or(StoreError::Conflict)?; + let prior = record + .control_receipts + .iter_mut() + .find(|r| r.command_id == command_id) + .ok_or(StoreError::Conflict)?; + if prior.owner_instance != instance + || prior.owner_generation != generation + || prior.state != ControlReceiptState::Started + { + return Err(StoreError::Conflict); + } + prior.state = ControlReceiptState::Finished(Err(ControlFailure::Unconfirmed)); + Ok(record) + }) + } + + /// Control persistence failure leaves the previous atomic snapshot intact: + /// it must neither promise a reset nor erase receipts protecting an effect. + fn update_control( + &self, + path: &Path, + decide: impl FnOnce(Option<&SessionRecord>) -> Result, + ) -> Result<(), StoreError> { + self.update_with_quarantine(path, decide, false) + } +} + +fn receipt<'a>(record: &'a SessionRecord, command_id: &str) -> Option<&'a ControlReceipt> { + record + .control_receipts + .iter() + .find(|r| r.command_id == command_id) +} + +fn add_started( + record: &mut SessionRecord, + command_id: &str, + created_at: u64, + instance: Uuid, +) -> ControlReceipt { + let receipt = ControlReceipt { + command_id: command_id.to_owned(), + created_at, + owner_instance: instance, + owner_generation: record + .live + .as_ref() + .map(|l| l.lease) + .or(record.revision) + .unwrap_or_else(Uuid::new_v4), + state: ControlReceiptState::Started, + }; + record.control_receipts.push(receipt.clone()); + receipt +} + +fn make_room(receipts: &mut Vec, now: u64) -> Result<(), StoreError> { + receipts + .retain(|r| matches!(r.state, ControlReceiptState::Started) || receipt_protected(r, now)); + if receipts.len() >= MAX_CONTROL_RECEIPTS { + Err(StoreError::Busy) + } else { + Ok(()) + } +} + +fn receipt_protected(receipt: &ControlReceipt, now: u64) -> bool { + now <= receipt + .created_at + .saturating_add(CONTROL_MAX_AGE_SECS + CONTROL_FUTURE_SKEW_SECS) +} + +fn unix_now() -> Result { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .map_err(|_| StoreError::InvalidControl) +} + +/// Validate persisted metadata without dropping legacy provider records. +pub(super) fn valid_controls(record: &SessionRecord) -> bool { + let mut ids = HashSet::new(); + record + .restart_status + .as_ref() + .is_none_or(RestartStatus::valid) + && record.control_receipts.len() <= MAX_CONTROL_RECEIPTS + && record + .control_receipts + .iter() + .all(|r| is_canonical_id(&r.command_id) && ids.insert(&r.command_id)) + && record + .pending_restart + .as_ref() + .is_none_or(|i| i.origin.validate().is_ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn restart_receipt_covers_future_skew_until_signed_event_expires() { + let admission = 1_000; + let signed_time = admission + CONTROL_FUTURE_SKEW_SECS; + let mut receipts = vec![ControlReceipt { + command_id: "a".repeat(64), + created_at: admission, + owner_instance: Uuid::new_v4(), + owner_generation: Uuid::new_v4(), + state: ControlReceiptState::Finished(Ok(())), + }]; + let last_fresh = signed_time + CONTROL_MAX_AGE_SECS; + assert!(control_is_fresh(signed_time, last_fresh)); + make_room(&mut receipts, last_fresh).unwrap(); + assert_eq!( + receipts.len(), + 1, + "a still-fresh +30s event retains its restart receipt" + ); + make_room(&mut receipts, last_fresh + 1).unwrap(); + assert!(receipts.is_empty()); + } +} diff --git a/crates/buzz-acp/src/session_store_orphans.rs b/crates/buzz-acp/src/session_store_orphans.rs new file mode 100644 index 00000000000..3c38a6d34b5 --- /dev/null +++ b/crates/buzz-acp/src/session_store_orphans.rs @@ -0,0 +1,196 @@ +//! Startup reconciliation of durable Started markers whose OS owner is dead. +use super::*; + +#[derive(Debug, Default)] +pub(crate) struct OrphanSweep { + pub finished: Vec, + pub reconciled_statuses: usize, + pub failures: usize, + pub more: bool, + pub revisit: bool, +} + +impl OrphanSweep { + fn failed(&mut self, path: &Path, error: impl std::fmt::Display) { + self.failures = self.failures.saturating_add(1); + tracing::warn!(target: "session_store", path = %path.display(), %error, + "orphan reconciliation deferred for this record; other scopes continue"); + } +} + +impl SessionStore { + /// Reconcile through the same canonical reader/lock as normal store writes. + /// Partial progress survives failures of other records; failures require retry. + pub(crate) fn sweep_orphan_controls(&self, limit: usize) -> Result { + let mut progress = OrphanSweep::default(); + let limit = limit.min(128); + if limit == 0 { + return Ok(progress); + } + let channels = match fs::read_dir(&self.dir) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(progress), + Err(error) => return Err(StoreError::Io(error)), + }; + for channel in channels { + let channel = match channel { + Ok(channel) => channel, + Err(error) => { + progress.failed(&self.dir, error); + continue; + } + }; + if channel + .file_name() + .to_str() + .and_then(|name| Uuid::parse_str(name).ok()) + .is_none() + { + continue; + } + let entries = match fs::read_dir(channel.path()) { + Ok(entries) => entries, + Err(error) => { + progress.failed(&channel.path(), error); + continue; + } + }; + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + progress.failed(&channel.path(), error); + continue; + } + }; + let entry_path = entry.path(); + let Some(path) = canonical_key(&entry_path) else { + continue; + }; + // A canonical file and its stale alias are one key. Skip the + // alias when canonical exists; stale-only uses the normal reader. + // No unbounded directory-wide key set is needed. If a concurrent + // recovery creates canonical during iteration, the receipt CAS + // and Finished state still prevent duplicate reconciliation. + if entry_path != path { + match path.try_exists() { + Ok(true) => continue, + Ok(false) => {} + Err(error) => { + progress.failed(&path, error); + continue; + } + } + } + let record = match read_record(&path, &self.namespace) { + Ok(Some(record)) => record, + Ok(None) => continue, + Err(()) => { + progress.failed(&path, StoreError::Unreadable); + continue; + } + }; + if let Some(saved) = record.restart_status.as_ref().filter(|saved| { + saved.owner != self.instance + && record.pending_restart.as_ref().map(|intent| intent.token) + == Some(saved.token) + && self.record_path(&saved.scope) == path + }) { + if saved.active() + || matches!( + saved.phase, + RestartStatusPhase::WaitingRetirement | RestartStatusPhase::Unconfirmed + ) + { + if self.instance_liveness(saved.owner) == InstanceLiveness::Dead { + match self.recover_status(saved) { + Ok(()) => progress.reconciled_statuses += 1, + Err(error) => progress.failed(&path, error), + } + } else { + progress.revisit = true; + } + } + } + if progress.finished.len() + progress.reconciled_statuses >= limit { + progress.more = true; + return Ok(progress); + } + let orphans: Vec<_> = record + .control_receipts + .iter() + .filter(|receipt| { + if receipt.state != ControlReceiptState::Started + || receipt.owner_instance == self.instance + { + return false; + } + match self.instance_liveness(receipt.owner_instance) { + InstanceLiveness::Dead => true, + InstanceLiveness::Alive | InstanceLiveness::Unknown => { + // A live owner can die later; uncertainty can resolve. + // Neither is proof this reconciliation is complete. + progress.revisit = true; + false + } + } + }) + .take( + limit + .saturating_sub(progress.finished.len() + progress.reconciled_statuses), + ) + .cloned() + .collect(); + if orphans.is_empty() { + continue; + } + let result = self.update_with_quarantine( + &path, + |current| { + let mut record = current.cloned().ok_or(StoreError::Conflict)?; + for orphan in &orphans { + let Some(receipt) = + record.control_receipts.iter_mut().find(|receipt| { + receipt.command_id == orphan.command_id && *receipt == orphan + }) + else { + return Err(StoreError::Conflict); + }; + // Recheck the live foreign-owner proof in the locked decision. + if self.instance_liveness(receipt.owner_instance) + != InstanceLiveness::Dead + { + return Err(StoreError::Conflict); + } + receipt.state = ControlReceiptState::Finished(Err( + buzz_core::session_control::ControlFailure::Unconfirmed, + )); + } + Ok(record) + }, + false, + ); + match result { + Ok(()) => progress + .finished + .extend(orphans.into_iter().map(|receipt| receipt.command_id)), + Err(error) => progress.failed(&path, error), + } + if progress.finished.len() + progress.reconciled_statuses == limit { + progress.more = true; + return Ok(progress); + } + } + } + Ok(progress) + } +} + +fn canonical_key(path: &Path) -> Option { + let name = path.file_name()?.to_str()?; + let key = name + .strip_suffix(".json") + .or_else(|| name.strip_suffix(".json.stale"))?; + (key.len() == 64 && key.bytes().all(|byte| byte.is_ascii_hexdigit())) + .then(|| path.with_file_name(format!("{key}.json"))) +} diff --git a/crates/buzz-acp/src/session_store_status.rs b/crates/buzz-acp/src/session_store_status.rs new file mode 100644 index 00000000000..f8dfb296b1f --- /dev/null +++ b/crates/buzz-acp/src/session_store_status.rs @@ -0,0 +1,195 @@ +//! Restart follow-up ownership shares the reset record's atomic lock. +use super::*; +use buzz_core::session_control::{ControlOrigin, SessionCommand}; + +pub(crate) const STATUS_TEXT_BYTES: usize = 12 * 1024; +pub(crate) const STATUS_EVENT_BYTES: usize = 20 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum RestartStatusPhase { + WaitingRetirement, + Pending, + Started, + Prepared, + Completed, + Failed, + Unconfirmed, + Cancelled, +} + +/// One bounded follow-up per current restart; old command receipts retain dedup. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RestartStatus { + pub token: Uuid, + pub origin: ControlOrigin, + pub scope: SessionScope, + pub command: nostr::Event, + pub owner: Uuid, + pub deadline: u64, + pub phase: RestartStatusPhase, + /// Prepared signed bytes are immutable, including after lost acknowledgment. + pub prepared: Option, + pub failure_notice: bool, + pub notice: Option, + pub notice_confirmed: bool, +} + +impl RestartStatus { + pub(crate) fn active(&self) -> bool { + matches!( + self.phase, + RestartStatusPhase::Pending + | RestartStatusPhase::Started + | RestartStatusPhase::Prepared + ) + } + pub(crate) fn valid(&self) -> bool { + self.origin.validate().is_ok() + && matches!( + SessionCommand::parse(self.command.kind.as_u16(), &self.command.content), + Some(SessionCommand::Restart(_)) + ) + && self.command.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.first().is_some_and(|part| part == "h") + && parts + .get(1) + .is_some_and(|part| part == &self.scope.channel_id().to_string()) + }) + && self + .scope + .root_event_id() + .is_none_or(|root| crate::queue::reply_thread(&self.command, None) == root) + && self.command.id.to_hex() == self.origin.command_event_id + && self.command.verify().is_ok() + && self + .scope + .root_event_id() + .is_none_or(buzz_core::session_control::is_canonical_id) + && serde_json::to_vec(&self.command).is_ok_and(|v| v.len() <= STATUS_EVENT_BYTES) + && self.notice.as_ref().is_none_or(|event| { + event.kind.as_u16() == 9 + && event.verify().is_ok() + && event.content.len() <= STATUS_TEXT_BYTES + && serde_json::to_vec(event).is_ok_and(|v| v.len() <= STATUS_EVENT_BYTES) + }) + && self.prepared.as_ref().is_none_or(|event| { + event.kind.as_u16() == 9 + && event.verify().is_ok() + && event.content.len() <= STATUS_TEXT_BYTES + && serde_json::to_vec(event).is_ok_and(|v| v.len() <= STATUS_EVENT_BYTES) + }) + } +} + +impl SessionStore { + pub(crate) fn request_restart_status( + &self, + scope: &SessionScope, + intent: &RestartIntent, + status: RestartStatus, + ) -> Result, StoreError> { + if status.scope != *scope + || status.token != intent.token + || status.origin != intent.origin + || status.owner != self.instance + || status.phase != RestartStatusPhase::WaitingRetirement + || status.deadline != 0 + || status.prepared.is_some() + || status.notice.is_some() + || !status.valid() + { + return Err(StoreError::InvalidControl); + } + self.controls_request_restart_status(scope, intent, status) + } + + /// Reconcile a proven-dead owner without authorizing any further effect. + /// An empty scheduler never proves another live controller is abandoned. + pub(super) fn recover_status(&self, saved: &RestartStatus) -> Result<(), StoreError> { + if saved.owner == self.instance + || self.instance_liveness(saved.owner) != InstanceLiveness::Dead + { + return Ok(()); + } + if matches!( + saved.phase, + RestartStatusPhase::Completed + | RestartStatusPhase::Failed + | RestartStatusPhase::Cancelled + ) { + return Ok(()); + } + let mut next = saved.clone(); + next.owner = self.instance; + // A failed cancellation write and a crash are indistinguishable from + // an abandoned Pending/Prepared turn. Dead-owner recovery never grants + // another model attempt or send, even when signed bytes were prepared. + next.phase = RestartStatusPhase::Unconfirmed; + self.status_transition(saved, &next, false)?; + Ok(()) + } + + pub(crate) fn status_transition( + &self, + expected: &RestartStatus, + next: &RestartStatus, + consume: bool, + ) -> Result<(), StoreError> { + if !next.valid() + || expected.token != next.token + || expected.origin != next.origin + || expected.scope != next.scope + || next.owner != self.instance + { + return Err(StoreError::InvalidControl); + } + if expected + .prepared + .as_ref() + .is_some_and(|event| next.prepared.as_ref() != Some(event)) + || expected + .notice + .as_ref() + .is_some_and(|event| next.notice.as_ref() != Some(event)) + || matches!( + expected.phase, + RestartStatusPhase::Completed + | RestartStatusPhase::Failed + | RestartStatusPhase::Cancelled + ) + || (expected.phase != RestartStatusPhase::Pending + && next.phase == RestartStatusPhase::Pending) + || (consume + && (next.phase != RestartStatusPhase::Completed + || next.failure_notice + || next.prepared.is_none())) + || (next.phase == RestartStatusPhase::Completed && !consume) + || (next.notice_confirmed && next.notice.is_none()) + { + return Err(StoreError::Conflict); + } + self.update_with_quarantine( + &self.record_path(&expected.scope), + |current| { + let mut record = current.cloned().ok_or(StoreError::Conflict)?; + if record.restart_status.as_ref() != Some(expected) + || record.pending_restart.as_ref().map(|intent| intent.token) + != Some(expected.token) + || (expected.owner != self.instance + && self.instance_liveness(expected.owner) != InstanceLiveness::Dead) + { + return Err(StoreError::Conflict); + } + record.restart_status = Some(next.clone()); + if consume { + record.pending_restart = None; + } + Ok(record) + }, + false, + ) + } +} diff --git a/crates/buzz-acp/src/session_store_tests.rs b/crates/buzz-acp/src/session_store_tests.rs new file mode 100644 index 00000000000..f51d3412aa0 --- /dev/null +++ b/crates/buzz-acp/src/session_store_tests.rs @@ -0,0 +1,508 @@ +use super::*; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::sync::Arc; + +const TEST_AGENT: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +fn open_store(root: PathBuf) -> io::Result { + SessionStore::open(root, "wss://relay.example", TEST_AGENT) +} + +fn thread(root: &str) -> SessionScope { + SessionScope::Thread { + channel_id: Uuid::from_u128(7), + root_event_id: root.repeat(64 / root.len()), + } +} + +fn live(store: &SessionStore, session_id: &str) -> LiveSession { + LiveSession { + lease: Uuid::nil(), + adapter: "claude-agent-acp".into(), + session_id: session_id.into(), + cwd: "/work".into(), + model: Some("opus".into()), + instance: store.instance(), + worker_index: 0, + departed: None, + turns: 0, + standing_context_sent: false, + delivered_event_ids: Vec::new(), + hydrated_thread_roots: Vec::new(), + } +} + +fn found(outcome: LoadOutcome) -> SessionRecord { + match outcome { + LoadOutcome::Found(record) => *record, + other => panic!("expected a record, got {other:?}"), + } +} + +#[test] +fn roundtrip_live_record() { + let dir = tempfile::tempdir().unwrap(); + let store = open_store(dir.path().to_path_buf()).unwrap(); + let scope = thread("a"); + let lease = store + .claim(&scope, &Expect::Missing, live(&store, "s-1")) + .unwrap(); + let record = found(store.load(&scope)); + let live = record.live.unwrap(); + assert_eq!(live.session_id, "s-1"); + assert_eq!(live.lease, lease); + assert_eq!(record.reason, None); +} + +#[test] +fn corrupt_file_is_renamed_and_reported_unreadable() { + let dir = tempfile::tempdir().unwrap(); + let store = open_store(dir.path().to_path_buf()).unwrap(); + let scope = thread("b"); + let path = store.record_path(&scope); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, b"{").unwrap(); + assert_eq!(store.load(&scope), LoadOutcome::Unreadable); + assert_eq!(store.load(&scope), LoadOutcome::Missing); + assert!(with_suffix(&path, "corrupt").exists()); +} + +#[test] +fn oversized_file_is_unreadable() { + let dir = tempfile::tempdir().unwrap(); + let store = open_store(dir.path().to_path_buf()).unwrap(); + let scope = thread("c"); + let path = store.record_path(&scope); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, vec![b' '; MAX_RECORD_BYTES as usize + 1]).unwrap(); + assert_eq!(store.load(&scope), LoadOutcome::Unreadable); +} + +#[test] +fn claim_conflicts_when_record_changed() { + let dir = tempfile::tempdir().unwrap(); + let store = open_store(dir.path().to_path_buf()).unwrap(); + let scope = thread("d"); + let expect = Expect::from_outcome(&store.load(&scope)); + store + .claim(&scope, &Expect::Missing, live(&store, "s-other")) + .unwrap(); + assert!(matches!( + store.claim(&scope, &expect, live(&store, "s-mine")), + Err(StoreError::Conflict) + )); + assert_eq!( + found(store.load(&scope)).live.unwrap().session_id, + "s-other" + ); + assert!(!store.is_poisoned(&scope)); +} + +#[test] +fn late_write_from_previous_lease_is_ignored() { + let dir = tempfile::tempdir().unwrap(); + let store = open_store(dir.path().to_path_buf()).unwrap(); + let scope = thread("e"); + let lease_a = store + .claim(&scope, &Expect::Missing, live(&store, "s-a")) + .unwrap(); + let expect = Expect::from_outcome(&store.load(&scope)); + // A resume keeps the provider session id but takes a new lease. + let lease_b = store.claim(&scope, &expect, live(&store, "s-a")).unwrap(); + assert!(matches!( + store.commit(&scope, lease_a, |live| live.turns = 99), + Err(StoreError::Conflict) + )); + assert!(matches!( + store.discard(&scope, lease_a), + Err(StoreError::Conflict) + )); + let live = found(store.load(&scope)).live.unwrap(); + assert_eq!(live.lease, lease_b); + assert_eq!(live.turns, 0); +} + +#[test] +fn concurrent_claims_have_one_winner() { + let dir = tempfile::tempdir().unwrap(); + let store = Arc::new(open_store(dir.path().to_path_buf()).unwrap()); + let scope = thread("f"); + let barrier = Arc::new(std::sync::Barrier::new(2)); + let handles: Vec<_> = ["s-1", "s-2"] + .into_iter() + .map(|sid| { + let (store, scope, barrier) = (store.clone(), scope.clone(), barrier.clone()); + std::thread::spawn(move || { + let session = live(&store, sid); + barrier.wait(); + store.claim(&scope, &Expect::Missing, session).is_ok() + }) + }) + .collect(); + let wins = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .filter(|won| *won) + .count(); + assert_eq!(wins, 1); +} + +#[test] +fn lock_wait_is_bounded() { + let dir = tempfile::tempdir().unwrap(); + let store = open_store(dir.path().to_path_buf()).unwrap(); + let scope = thread("g"); + let holder = store.lock_record(&store.record_path(&scope)).unwrap(); + let started = Instant::now(); + let result = store.claim(&scope, &Expect::Missing, live(&store, "s-1")); + assert!(matches!(result, Err(StoreError::Busy))); + assert!(started.elapsed() < Duration::from_secs(3)); + assert!(store.is_poisoned(&scope)); + drop(holder); +} + +#[test] +fn busy_does_not_quarantine_owner_record() { + let dir = tempfile::tempdir().unwrap(); + let owner = open_store(dir.path().to_path_buf()).unwrap(); + let other = open_store(dir.path().to_path_buf()).unwrap(); + let scope = thread("h"); + let lease = owner + .claim(&scope, &Expect::Missing, live(&owner, "s-a")) + .unwrap(); + let path = owner.record_path(&scope); + let holder = owner.lock_record(&path).unwrap(); + other.fail_writes(true); + assert!(matches!(other.discard_scope(&scope), Err(StoreError::Busy))); + drop(holder); + assert!(path.exists()); + assert!(!with_suffix(&path, "stale").exists()); + assert!(other.is_poisoned(&scope)); + assert_eq!(found(owner.load(&scope)).live.unwrap().lease, lease); +} + +#[test] +fn quarantine_requires_matching_lease() { + let dir = tempfile::tempdir().unwrap(); + let store = open_store(dir.path().to_path_buf()).unwrap(); + let scope = thread("i"); + store + .claim(&scope, &Expect::Missing, live(&store, "s-a")) + .unwrap(); + store.fail_writes(true); + assert!(matches!( + store.commit(&scope, Uuid::new_v4(), |live| live.turns = 1), + Err(StoreError::Conflict) + )); + let path = store.record_path(&scope); + assert!(path.exists()); + assert!(!with_suffix(&path, "stale").exists()); +} + +#[test] +#[cfg(unix)] +fn failed_update_of_existing_record_poisons_scope() { + let dir = tempfile::tempdir().unwrap(); + let store = open_store(dir.path().to_path_buf()).unwrap(); + let scope = thread("j"); + let lease = store + .claim(&scope, &Expect::Missing, live(&store, "s-a")) + .unwrap(); + let channel_dir = store.record_path(&scope).parent().unwrap().to_path_buf(); + fs::set_permissions(&channel_dir, fs::Permissions::from_mode(0o500)).unwrap(); + let result = store.commit(&scope, lease, |live| live.turns = 1); + fs::set_permissions(&channel_dir, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(matches!(result, Err(StoreError::Io(_)))); + assert!(store.is_poisoned(&scope)); + // The boundary of the guarantee: neither the write nor the rename could + // happen, so the old record is still there. + assert_eq!(found(store.load(&scope)).live.unwrap().turns, 0); +} + +#[test] +fn failed_discard_moves_record_aside_across_restart() { + let dir = tempfile::tempdir().unwrap(); + let path; + { + let store = open_store(dir.path().to_path_buf()).unwrap(); + let scope = thread("k"); + let lease = store + .claim(&scope, &Expect::Missing, live(&store, "s-a")) + .unwrap(); + store.fail_writes(true); + assert!(matches!( + store.discard(&scope, lease), + Err(StoreError::Io(_)) + )); + path = store.record_path(&scope); + } + let restarted = open_store(dir.path().to_path_buf()).unwrap(); + assert_eq!(restarted.load(&thread("k")), LoadOutcome::Missing); + assert!(with_suffix(&path, "stale").exists()); +} + +#[test] +fn discard_leaves_tombstone_with_reason() { + let dir = tempfile::tempdir().unwrap(); + let store = open_store(dir.path().to_path_buf()).unwrap(); + let scope = thread("l"); + let lease = store + .claim(&scope, &Expect::Missing, live(&store, "s-a")) + .unwrap(); + store.discard(&scope, lease).unwrap(); + let record = found(store.load(&scope)); + assert_eq!(record.live, None); + assert_eq!(record.reason, Some(SessionReason::Discarded)); + let revision = record.revision; + assert_eq!( + Expect::from_outcome(&LoadOutcome::Found(Box::new(record))), + Expect::Tombstone { revision } + ); +} + +#[test] +fn discard_channel_tombstones_only_that_channel() { + let dir = tempfile::tempdir().unwrap(); + let store = open_store(dir.path().to_path_buf()).unwrap(); + let ours = [thread("m"), thread("n")]; + let theirs = SessionScope::Conversation { + channel_id: Uuid::from_u128(8), + }; + for scope in ours.iter().chain([&theirs]) { + store + .claim(scope, &Expect::Missing, live(&store, "s")) + .unwrap(); + } + assert_eq!(store.discard_channel(Uuid::from_u128(7)).unwrap(), 2); + for scope in &ours { + assert_eq!(found(store.load(scope)).live, None); + } + assert!(found(store.load(&theirs)).live.is_some()); +} + +#[test] +fn other_live_instance_is_alive_dead_one_is_not() { + let dir = tempfile::tempdir().unwrap(); + let store = open_store(dir.path().to_path_buf()).unwrap(); + let other = open_store(dir.path().to_path_buf()).unwrap(); + // An instance registers on its first write; only then can it own records. + other + .claim(&thread("r"), &Expect::Missing, live(&other, "s")) + .unwrap(); + let other_instance = other.instance(); + assert!(store.instance_alive(store.instance())); + assert!(store.instance_alive(other_instance)); + drop(other); + assert!(!store.instance_alive(other_instance)); + assert!(!store.instance_alive(Uuid::new_v4())); +} + +#[test] +fn append_delivered_caps_oldest_first() { + let mut ids = Vec::new(); + append_delivered(&mut ids, (0..2050).map(|n| format!("id-{n}"))); + append_delivered(&mut ids, ["id-2049".to_owned()]); + assert_eq!(ids.len(), MAX_STORED_DELIVERED_IDS); + assert_eq!(ids[0], "id-2"); + assert_eq!(ids.last().unwrap(), "id-2049"); +} + +#[test] +fn reason_wire_strings_roundtrip() { + for reason in [ + SessionReason::IdleSleep, + SessionReason::HarnessRestart, + SessionReason::LiveElsewhere, + SessionReason::ResumeFailed("not_found".into()), + ] { + assert_eq!(SessionReason::from(reason.as_wire()), reason); + } + assert_eq!( + SessionReason::ResumeFailed("rpc_-32603".into()).as_wire(), + "resume_failed:rpc_-32603" + ); +} + +#[test] +fn open_does_not_touch_disk_until_first_write() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("store"); + let store = open_store(root.clone()).unwrap(); + assert!(!root.exists()); + assert_eq!(store.load(&thread("o")), LoadOutcome::Missing); + assert!(!root.exists()); + store + .claim(&thread("o"), &Expect::Missing, live(&store, "s")) + .unwrap(); + assert!(store + .dir + .join(INSTANCES_DIR) + .join(format!("{}.lock", store.instance())) + .exists()); +} + +#[test] +fn dead_instance_locks_are_swept_and_own_lock_removed_on_drop() { + let dir = tempfile::tempdir().unwrap(); + let first = open_store(dir.path().to_path_buf()).unwrap(); + first + .claim(&thread("p"), &Expect::Missing, live(&first, "s")) + .unwrap(); + let first_lock = first + .dir + .join(INSTANCES_DIR) + .join(format!("{}.lock", first.instance())); + // A crashed process leaves its lock file behind but no longer holds it. + let crashed = first + .dir + .join(INSTANCES_DIR) + .join(format!("{}.lock", Uuid::new_v4())); + fs::write(&crashed, b"").unwrap(); + let second = open_store(dir.path().to_path_buf()).unwrap(); + second + .claim(&thread("q"), &Expect::Missing, live(&second, "s")) + .unwrap(); + assert!(!crashed.exists(), "dead instance lock is swept"); + assert!(first_lock.exists(), "live instance lock is kept"); + drop(first); + assert!(!first_lock.exists(), "own lock is removed on drop"); +} + +#[test] +fn instance_publication_cannot_be_reaped_before_locking() { + let dir = tempfile::tempdir().unwrap(); + let first = Arc::new(open_store(dir.path().to_path_buf()).unwrap()); + let second = open_store(dir.path().to_path_buf()).unwrap(); + let (opened_tx, opened_rx) = std::sync::mpsc::sync_channel(0); + let (continue_tx, continue_rx) = std::sync::mpsc::sync_channel(0); + *first.before_instance_lock.lock().unwrap() = Some(Box::new(move || { + opened_tx.send(()).unwrap(); + continue_rx.recv().unwrap(); + })); + let writer = first.clone(); + let claim = std::thread::spawn(move || { + writer.claim(&thread("a"), &Expect::Missing, live(&writer, "active")) + }); + opened_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + let alive_while_publishing = second.instance_alive(first.instance()); + continue_tx.send(()).unwrap(); + claim.join().unwrap().unwrap(); + assert!( + alive_while_publishing, + "publication must fail closed while registration owns the registry" + ); + assert!( + second.instance_alive(first.instance()), + "registered owner must remain discoverable" + ); +} + +#[test] +#[cfg(unix)] +fn channel_enumeration_failure_poisons_unknown_scopes_until_restart() { + let dir = tempfile::tempdir().unwrap(); + let store = open_store(dir.path().to_path_buf()).unwrap(); + let scope = thread("t"); + let neighbor = SessionScope::Conversation { + channel_id: Uuid::from_u128(8), + }; + for scope in [&scope, &neighbor] { + store + .claim(scope, &Expect::Missing, live(&store, "before-removal")) + .unwrap(); + } + let channel_dir = store.record_path(&scope).parent().unwrap().to_path_buf(); + fs::set_permissions(&channel_dir, fs::Permissions::from_mode(0o000)).unwrap(); + let result = store.discard_channel(scope.channel_id()); + fs::set_permissions(&channel_dir, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(matches!(result, Err(StoreError::Io(_)))); + assert!( + store.is_poisoned(&scope), + "failed removal must forbid resume in this process" + ); + assert!( + store.is_poisoned(&thread("u")), + "unknown scopes in the same channel are fenced too" + ); + assert!(!store.is_poisoned(&neighbor)); +} + +#[test] +fn namespace_normalizes_url_spelling_without_aliasing_communities() { + let root = tempfile::tempdir().unwrap(); + let a = SessionStore::open(root.path().into(), "WSS://Relay.Example:443/", TEST_AGENT).unwrap(); + let same = SessionStore::open(root.path().into(), "wss://relay.example", TEST_AGENT).unwrap(); + assert_eq!(a.dir, same.dir); + for relay in [ + "ws://relay.example", + "wss://relay.example:444", + "wss://other.example", + "wss://relay.example/community", + "wss://relay.example/?community=2", + ] { + let other = SessionStore::open(root.path().into(), relay, TEST_AGENT).unwrap(); + assert_ne!(a.dir, other.dir, "distinct relay: {relay}"); + } + let local = SessionStore::open(root.path().into(), "ws://localhost:3030", TEST_AGENT).unwrap(); + let ip = SessionStore::open(root.path().into(), "ws://127.0.0.1:3030", TEST_AGENT).unwrap(); + assert_ne!( + local.dir, ip.dir, + "host-derived communities must remain distinct" + ); + let agent = nostr::Keys::generate().public_key().to_hex(); + let other = SessionStore::open(root.path().into(), "wss://relay.example", &agent).unwrap(); + assert_ne!(a.dir, other.dir, "configured roots isolate agents too"); +} + +#[test] +fn copied_foreign_namespace_record_is_not_resumable() { + let root = tempfile::tempdir().unwrap(); + let a = SessionStore::open(root.path().into(), "wss://a.example", TEST_AGENT).unwrap(); + let b = SessionStore::open(root.path().into(), "wss://b.example", TEST_AGENT).unwrap(); + let scope = thread("v"); + a.claim(&scope, &Expect::Missing, live(&a, "community-a")) + .unwrap(); + let foreign_path = b.record_path(&scope); + fs::create_dir_all(foreign_path.parent().unwrap()).unwrap(); + fs::copy(a.record_path(&scope), &foreign_path).unwrap(); + assert_eq!(b.load(&scope), LoadOutcome::Unreadable); + assert!(with_suffix(&foreign_path, "corrupt").exists()); + assert_eq!( + found(a.load(&scope)).live.unwrap().session_id, + "community-a" + ); +} + +#[test] +fn legacy_unscoped_records_are_not_adopted() { + let root = tempfile::tempdir().unwrap(); + let store = open_store(root.path().into()).unwrap(); + let scope = thread("w"); + let name = store.record_path(&scope).file_name().unwrap().to_owned(); + let legacy = serde_json::json!({"version":1, "live":live(&store,"legacy"), "reason":null}); + for dir in [root.path().to_path_buf(), root.path().join(TEST_AGENT)] { + let path = dir.join(scope.channel_id().to_string()).join(&name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap(); + assert_eq!(store.load(&scope), LoadOutcome::Missing); + assert!(path.exists(), "legacy records are left intact"); + } + let path = store.record_path(&scope); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap(); + assert_eq!(store.load(&scope), LoadOutcome::Unreadable); +} + +#[test] +fn invalid_identity_disables_store_instead_of_sharing_a_namespace() { + let root = tempfile::tempdir().unwrap(); + for relay in [ + "not-a-url", + "https://relay.example", + "wss://user:pass@relay.example", + "wss://relay.example/#fragment", + ] { + assert!(SessionStore::open(root.path().into(), relay, TEST_AGENT).is_err()); + } + assert!(SessionStore::open(root.path().into(), "wss://relay.example", "invalid-key").is_err()); +} diff --git a/crates/buzz-acp/src/status_tasks.rs b/crates/buzz-acp/src/status_tasks.rs new file mode 100644 index 00000000000..57ac4719ab4 --- /dev/null +++ b/crates/buzz-acp/src/status_tasks.rs @@ -0,0 +1,121 @@ +//! Status turns get finite priority in their scope without owning human queue input. +use super::*; +use pool::restart_status::{run_status_task, StatusTaskResult}; + +pub(crate) fn dispatch( + pool: &mut AgentPool, + ctx: &Arc, + tx: &mpsc::UnboundedSender, +) { + for mut job in ctx.session_controls.pending_statuses() { + job.deadline = job + .deadline + .min(tokio::time::Instant::now() + ctx.max_turn_duration); + let scope = job.saved.scope.clone(); + if tokio::time::Instant::now() >= job.deadline { + if ctx.session_controls.start_status(&job) { + let dispatch = ctx.session_controls.capture(&scope); + let ctx = ctx.clone(); + pool.join_set + .spawn(pool::restart_status::report_expired(job, ctx, dispatch)); + } + continue; + } + if pool + .task_map() + .values() + .any(|meta| meta.scope.as_ref() == Some(&scope)) + { + continue; + } + let Some(mut agent) = pool.try_claim(Some(&scope)) else { + continue; + }; + if !ctx.session_controls.start_status(&job) { + pool.return_agent(agent); + continue; + } + let generation = pool.record_scope_owner(scope.clone(), agent.index); + agent + .state + .set_scope_owner_generation(scope.clone(), generation); + let dispatch = ctx.session_controls.capture(&scope); + agent.state.control_dispatch = Some(dispatch.clone()); + let index = agent.index; + let turn_id = job.saved.origin.command_event_id.clone(); + let (control_tx, control_rx) = tokio::sync::oneshot::channel(); + let ctx = ctx.clone(); + let tx = tx.clone(); + let task_dispatch = dispatch.clone(); + let handle = pool.join_set.spawn(async move { + if tx + .send(run_status_task(agent, job, ctx, task_dispatch, control_rx).await) + .is_err() + { + tracing::error!(agent = index, "restart status completion receiver closed"); + } + }); + pool.task_map_mut().insert( + handle.id(), + pool::TaskMeta { + agent_index: index, + channel_id: Some(scope.channel_id()), + scope: Some(scope), + turn_id, + recoverable_batch: None, + control_tx: Some(control_tx), + steer_tx: None, + control_dispatch: Some(dispatch), + successful_steer_deliveries: HashSet::new(), + }, + ); + } +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn finish( + pool: &mut AgentPool, + ctx: &PromptContext, + mut result: StatusTaskResult, + removed: &HashSet, + config: &Config, + circuits: &mut [SlotCircuit], + respawn_tx: &mpsc::Sender, + tasks: &mut tokio::task::JoinSet<()>, + observer: Option, + last_activity: &mut tokio::time::Instant, +) { + let index = result.agent.index; + pool.task_map_mut() + .retain(|_, meta| meta.agent_index != index); + ctx.session_controls.finish_status(&result.job); + if let Some(dispatch) = result.agent.state.control_dispatch.take() { + if !dispatch.current() { + result.agent.state.invalidate_scope(&result.job.saved.scope); + } + dispatch.finish(); + } + for channel in removed { + result.agent.state.invalidate_channel(channel); + } + *last_activity = tokio::time::Instant::now(); + if pool.needs_retirement(&result.agent) { + pool.return_for_retirement(result.agent); + } else if result.transport_failed { + result + .agent + .state + .depart_all(session_store::Departure::WorkerCrash); + pool.clear_runtime_hold(index); + spawn_respawn_task( + result.agent, + config, + &mut circuits[index], + respawn_tx, + tasks, + observer, + ); + } else { + pool.return_agent(result.agent); + } +} diff --git a/crates/buzz-acp/src/worker_retirement.rs b/crates/buzz-acp/src/worker_retirement.rs new file mode 100644 index 00000000000..3d4e9a7b852 --- /dev/null +++ b/crates/buzz-acp/src/worker_retirement.rs @@ -0,0 +1,262 @@ +//! Nonblocking replacement of reserved workers; uncertainty retains ownership. +use super::*; +use crate::acp::TerminationReport; + +pub(crate) struct RetirementReturn { + pub report: TerminationReport, + pub retained: Option, +} + +struct RetirementGuard { + index: usize, + tx: mpsc::Sender, + agent: Option, + sent: bool, +} + +impl RetirementGuard { + fn send(&mut self, result: Result<(AcpClient, u32, String)>, report: TerminationReport) { + let message = RespawnResult { + index: self.index, + result, + retirement: Some(RetirementReturn { + report, + retained: self.agent.take(), + }), + }; + match self.tx.try_send(message) { + Ok(()) => self.sent = true, + Err(error) => tracing::error!( + agent = self.index, + "retirement result channel unavailable: {error}" + ), + } + } +} +impl Drop for RetirementGuard { + fn drop(&mut self) { + if !self.sent { + self.send( + Err(anyhow::anyhow!("retirement task did not complete")), + TerminationReport::Unverified, + ); + } + } +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn drive( + pool: &mut AgentPool, + controls: &session_controls::SessionControlRuntime, + parked: &mut HashMap>, + config: &Config, + circuits: &mut [SlotCircuit], + tx: &mpsc::Sender, + tasks: &mut tokio::task::JoinSet<()>, + observer: Option, +) { + for (index, scopes, expired) in controls.retiring_workers() { + pool.mark_retire_on_return(index); + let agent = if let Some(agent) = parked.remove(&index) { + agent + } else { + let Some(agent) = pool.take_retirement_target(index, &scopes, expired) else { + continue; + }; + Some(agent) + }; + // This is a deliberate replacement, never a circuit-breaker crash. + circuits[index].respawn_in_flight = true; + controls.retirement_started(index); + let mut guard = RetirementGuard { + index, + tx: tx.clone(), + agent, + sent: false, + }; + let command = config.agent_command.clone(); + let args = config.agent_args.clone(); + let env = config.persona_env_vars.clone(); + let has_codex = config.has_generated_codex_config; + let observer = observer.clone(); + let controls = controls.clone(); + tasks.spawn(async move { + let report = match guard.agent.as_mut() { + Some(agent) => agent.acp.terminate().await, + None => TerminationReport::Clean, + }; + if report != TerminationReport::Clean { + guard.send( + Err(anyhow::anyhow!("old worker retirement unconfirmed")), + report, + ); + return; + } + if let Some(agent) = guard.agent.as_mut() { + if let Err(error) = agent.prepare_retired_neighbors(&scopes, &controls) { + guard.send( + Err(anyhow::anyhow!( + "neighbor departure could not be persisted: {error}" + )), + TerminationReport::Unverified, + ); + return; + } + } + guard.agent.take(); + let result = spawn_and_init(&command, &args, &env, has_codex, index, observer).await; + guard.send(result, report); + }); + } +} + +pub(crate) fn finish( + pool: &mut AgentPool, + controls: &session_controls::SessionControlRuntime, + parked: &mut HashMap>, + result: &mut RespawnResult, +) -> bool { + if controls.retirement_evidence_lost(result.index) + || (result.retirement.is_none() && controls.owns_retirement(result.index)) + { + pool.mark_retire_on_return(result.index); + controls.lose_retirement_evidence(result.index); + tracing::error!( + agent = result.index, + "replacement cannot verify the lost original worker; restart remains unconfirmed" + ); + return true; + } + let Some(retirement) = result.retirement.take() else { + return false; + }; + if retirement.report != TerminationReport::Clean || result.result.is_err() { + parked.insert(result.index, retirement.retained); + controls.retirement_report( + result.index, + if retirement.report == TerminationReport::Clean { + TerminationReport::Unverified + } else { + retirement.report + }, + ); + return true; + } + pool.finish_worker_retirement(result.index); + controls.retirement_report(result.index, TerminationReport::Clean); + false +} + +/// Drain every retirement owner after aborting and awaiting the task guards. +/// Unverified originals remain owned by the caller until final harness exit. +pub(crate) async fn shutdown( + parked: &mut HashMap>, + tasks: &mut tokio::task::JoinSet<()>, + rx: &mut mpsc::Receiver, +) -> Vec<(usize, OwnedAgent, TerminationReport)> { + // Await cancellation before draining: RetirementGuard returns its original + // client synchronously on drop, including cancellation during a snapshot. + tasks.shutdown().await; + let mut originals: Vec<_> = parked + .drain() + .filter_map(|(index, agent)| agent.map(|agent| (index, agent))) + .collect(); + while let Ok(result) = rx.try_recv() { + if let Some(agent) = result.retirement.and_then(|retirement| retirement.retained) { + originals.push((result.index, agent)); + } + if let Ok((mut acp, _, _)) = result.result { + acp.shutdown().await; + } + } + // The existing pool bounds the set. Each checked attempt has its existing + // snapshot/TERM/KILL/verification bounds; concurrent cleanup avoids adding + // those grace periods once per retained worker during orderly shutdown. + futures_util::future::join_all(originals.into_iter().map(|(index, mut agent)| async move { + let report = agent.acp.terminate().await; + if report == TerminationReport::Clean { + tracing::debug!(agent = index, "reaped retained original worker on shutdown"); + None + } else { + tracing::warn!( + agent = index, + ?report, + "retained original worker shutdown remains unconfirmed" + ); + Some((index, agent, report)) + } + })) + .await + .into_iter() + .flatten() + .collect() +} + +#[cfg(all(test, unix))] +#[path = "worker_retirement_shutdown_tests.rs"] +mod shutdown_tests; + +#[cfg(all(test, unix))] +mod tests { + use super::*; + #[tokio::test] + async fn abort_during_initial_snapshot_returns_live_worker_without_signalling() { + let acp = AcpClient::spawn( + "/usr/bin/python3", + &["-c".into(), "import sys; sys.stdin.read()".into()], + &[], + false, + ) + .await + .unwrap(); + let pid = acp.process_id().unwrap(); + let agent = OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, + agent_name: "probe".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + background_context: None, + }; + let (tx, mut rx) = mpsc::channel(1); + let entered = Arc::new(tokio::sync::Notify::new()); + let signal = entered.clone(); + let task = tokio::spawn(async move { + let mut guard = RetirementGuard { + index: 0, + tx, + agent: Some(agent), + sent: false, + }; + guard + .agent + .as_mut() + .unwrap() + .acp + .terminate_with_snapshot(|| { + let signal = signal.clone(); + async move { + signal.notify_one(); + std::future::pending().await + } + }) + .await; + }); + entered.notified().await; + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + let mut returned = rx.recv().await.unwrap().retirement.unwrap(); + assert_eq!(returned.report, TerminationReport::Unverified); + let mut agent = returned.retained.take().unwrap(); + assert_eq!(agent.acp.process_id(), Some(pid)); + assert!(nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid as i32), None).is_ok()); + agent.acp.shutdown().await; + } +} diff --git a/crates/buzz-acp/src/worker_retirement_shutdown_tests.rs b/crates/buzz-acp/src/worker_retirement_shutdown_tests.rs new file mode 100644 index 00000000000..a43833315a2 --- /dev/null +++ b/crates/buzz-acp/src/worker_retirement_shutdown_tests.rs @@ -0,0 +1,189 @@ +//! Bind every retained original ownership path to the actual shutdown helper. +use super::*; + +async fn original() -> OwnedAgent { + let acp = AcpClient::spawn( + "/usr/bin/python3", + &["-c".into(), "import time; time.sleep(60)".into()], + &[], + false, + ) + .await + .unwrap(); + OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + desired_model_request_id: None, + desired_model_pending_ack: false, + startup_effort: None, + agent_name: "probe".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + background_context: None, + } +} + +fn alive(pid: u32) -> bool { + nix::sys::signal::killpg(nix::unistd::Pid::from_raw(pid as i32), None).is_ok() +} + +fn cleanup(pid: u32) { + // Exact original group created by this test; cleanup only after measuring. + let _ = nix::sys::signal::killpg( + nix::unistd::Pid::from_raw(pid as i32), + nix::sys::signal::Signal::SIGKILL, + ); +} + +#[tokio::test] +async fn retirement_shutdown_retries_original_parked_after_snapshot_failure() { + let mut agent = original().await; + let pid = agent.acp.process_id().unwrap(); + assert_eq!( + agent.acp.terminate_with_snapshot(|| async { None }).await, + TerminationReport::Unverified + ); + assert!(alive(pid)); + let mut parked = HashMap::new(); + let controls = session_controls::SessionControlRuntime::new(None); + let mut pool = AgentPool::from_slots(vec![None]); + let mut result = RespawnResult { + index: 0, + result: Err(anyhow::anyhow!("initial checked retirement unconfirmed")), + retirement: Some(RetirementReturn { + report: TerminationReport::Unverified, + retained: Some(agent), + }), + }; + assert!(finish(&mut pool, &controls, &mut parked, &mut result)); + assert_eq!(parked[&0].as_ref().unwrap().acp.process_id(), Some(pid)); + let (_tx, mut rx) = mpsc::channel(1); + let mut tasks = tokio::task::JoinSet::new(); + let uncertain = shutdown(&mut parked, &mut tasks, &mut rx).await; + let original_survived = alive(pid); + if original_survived { + cleanup(pid); + } + assert!( + !original_survived, + "shutdown dropped the parked original without checked cleanup" + ); + assert!(parked.is_empty()); + assert!(uncertain.is_empty()); +} + +#[tokio::test] +async fn retirement_shutdown_drains_original_returned_by_aborted_guard() { + let agent = original().await; + let pid = agent.acp.process_id().unwrap(); + let (tx, mut rx) = mpsc::channel(1); + let entered = Arc::new(tokio::sync::Notify::new()); + let signal = entered.clone(); + let mut tasks = tokio::task::JoinSet::new(); + tasks.spawn(async move { + let mut guard = RetirementGuard { + index: 0, + tx, + agent: Some(agent), + sent: false, + }; + guard + .agent + .as_mut() + .unwrap() + .acp + .terminate_with_snapshot(|| { + let signal = signal.clone(); + async move { + signal.notify_one(); + std::future::pending().await + } + }) + .await; + }); + entered.notified().await; + let mut parked = HashMap::new(); + let uncertain = shutdown(&mut parked, &mut tasks, &mut rx).await; + let original_survived = alive(pid); + if original_survived { + cleanup(pid); + } + assert!( + !original_survived, + "shutdown lost the original returned by RetirementGuard after abort" + ); + assert!(uncertain.is_empty()); + assert!(tasks.is_empty()); + assert!(rx.try_recv().is_err()); +} + +#[tokio::test] +async fn retirement_shutdown_reuses_original_evidence_after_parent_reaped() { + let mut agent = original().await; + let pid = agent.acp.process_id().unwrap(); + let mut first = true; + let report = agent + .acp + .terminate_with_snapshot(|| { + let capture = first; + first = false; + async move { + if capture { + tokio::task::spawn_blocking(crate::process_tree::retirement::snapshot) + .await + .unwrap() + } else { + None + } + } + }) + .await; + assert_eq!(report, TerminationReport::Unverified); + assert_eq!( + agent.acp.process_id(), + None, + "the original parent was reaped before shutdown retry" + ); + let mut parked = HashMap::from([(0, Some(agent))]); + let (_tx, mut rx) = mpsc::channel(1); + let uncertain = shutdown(&mut parked, &mut tokio::task::JoinSet::new(), &mut rx).await; + let original_survived = alive(pid); + if original_survived { + cleanup(pid); + } + assert!(!original_survived); + assert!( + uncertain.is_empty(), + "retained original evidence must prove cleanup without a replacement parent" + ); +} + +#[tokio::test] +async fn retirement_shutdown_retains_uncertain_original_without_fabricating_clean() { + let mut agent = original().await; + assert_eq!( + agent.acp.terminate_with_snapshot(|| async { None }).await, + TerminationReport::Unverified + ); + // Independently stop/reap this fixture's exact child. No initial checked + // evidence was captured; final shutdown must not invent that missing proof. + agent.acp.shutdown().await; + assert_eq!(agent.acp.process_id(), None); + let mut parked = HashMap::from([(0, Some(agent))]); + let (_tx, mut rx) = mpsc::channel(1); + let uncertain = tokio::time::timeout( + Duration::from_secs(1), + shutdown(&mut parked, &mut tokio::task::JoinSet::new(), &mut rx), + ) + .await + .unwrap(); + assert!(parked.is_empty()); + assert_eq!(uncertain.len(), 1); + assert_eq!(uncertain[0].0, 0); + assert_eq!(uncertain[0].1.index, 0); + assert_eq!(uncertain[0].2, TerminationReport::Unverified); +} diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index 5ff2d52c468..dee6609da20 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -770,3 +770,69 @@ check requires a disposable persisted journal and a relay/proxy boundary that can stop the publisher after acceptance; preserve the journal and confirm readback by event ID before recovery. Do not infer real power-loss durability from process-kill tests alone. + +## Session-status enrichment + +An agent harness advertises its private status instance directory in +`BUZZ_SESSION_STATUS_DIR`. When it is set, `messages send` (kind 9, the +default) and the first preparation of `messages deliver` add two tags to the +new event before signing: + +```text +["buzz-session", "", "", ""] +["buzz-session-info", "1", "", "thread|conversation", "", "", "exact|lower_bound|unknown"] +``` + +The destination decides the snapshot. A reply matches the thread scope of its +canonical NIP-10 root (thread policy) or the channel's conversation scope +(channel policy and DMs); a top-level message matches only the conversation +scope. A message in another thread never borrows a thread's status. If both a +thread and a conversation snapshot are current, the scope is ambiguous and no +tags are added. Mixed execution scopes can exist after unresolved channel metadata +was conservatively admitted as a DM. Under thread policy, the harness withholds +or retires conversation display status unless metadata verifies a DM; this +includes ordinary turns of a retained session. Channel policy still permits +conversation status without channel metadata. A thread whose optional status +fails therefore cannot fall back to an uncertain conversation's telemetry. + +Absence and failure never block or alter a valid publication: the message is +sent exactly as before, without status tags and without a diagnostic, when + +- the variable is unset, empty, relative or not UTF-8, or its final component + is not the instance UUID; +- the instance lock is missing or not held (the harness is gone); +- the snapshot is missing, not a regular file (for example a symlink), larger + than 16 KiB, malformed, of another format version or otherwise invalid; +- the snapshot belongs to another relay or signing key (identity is the CLI's + own relay URL and key, with `http`/`ws` and `https`/`wss` folded), another + channel, thread root or instance; +- the snapshot's generation lock is missing or not held (a revoked generation + whose file could not be removed), or the scope is ambiguous. + +The lookup is read-only and bounded: it reads at most two snapshots, makes no +network calls, keeps no acknowledgment state and never creates, writes, +deletes or renames anything in the status directory. Locks are probed with +nonblocking attempts only; a lock the CLI can take is released at once and +treated as not held. A failed send therefore leaves the snapshot and its +transition untouched, and the next message still carries the explanation. + +Only kind-9 messages are enriched. Forum posts and comments (`--kind 45001`, +`--kind 45003`), `messages send-diff`, edits, deletions and other commands keep +their existing tags. `messages deliver` journals the signed event with the +tags of its first preparation: a same-key retry and late timestamp recovery +replay those original tags even after the live status has changed, while a new +delivery key captures the current status. + +Automated local checks (mock relay and a harness-shaped status directory with +real OS locks; no harness process): + +```sh +cargo test -p buzz-cli session_status -- --test-threads=1 +cargo test -p buzz-cli --test session_status_env +``` + +Human check against a local relay: run an agent under the harness, let it +reply in a thread, then read the reply back with +`buzz messages thread --channel "$CHANNEL_ID" --event "$REPLY_ID" | jq '.[].tags'` +and expect both tags. Stop the harness and send again with the same +environment: expect no status tags and a normal publication. diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 6467296ff07..6bd67f015fc 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -457,6 +457,8 @@ pub struct BuzzClient { auth_tag: Option, /// Raw JSON of the auth tag for the `x-auth-tag` HTTP header. auth_tag_json: Option, + /// Session-status instance directory advertised by an agent harness. + session_status_dir: Option, } impl BuzzClient { @@ -486,9 +488,22 @@ impl BuzzClient { keys, auth_tag, auth_tag_json, + session_status_dir: None, }) } + /// Read session status for new kind-9 messages from `dir`, an agent + /// harness instance directory. `None` disables status enrichment. + pub fn with_session_status_dir(mut self, dir: Option) -> Self { + self.session_status_dir = dir; + self + } + + /// The configured session-status instance directory, if any. + pub fn session_status_dir(&self) -> Option<&std::path::Path> { + self.session_status_dir.as_deref() + } + /// Get the keypair. pub fn keys(&self) -> &Keys { &self.keys diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index a15973b0f07..95496e684bd 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -751,6 +751,13 @@ pub(super) async fn prepare_message( &emoji_tags, ) .map_err(|e| CliError::Other(format!("build_message failed: {e}")))? + // Best-effort, read-only session status of the agent's own session + // for this destination. Kind 9 only; absence adds nothing. + .tags(super::session_status::message_status_tags( + client, + channel_uuid, + thread_ref.as_ref().map(|t| &t.root_event_id), + )) } Some(k) => { return Err(CliError::Usage(format!( diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 59e418e51a5..2fc0253db52 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -19,6 +19,7 @@ pub mod projects; pub mod reactions; mod repo_default_branch; pub mod repos; +pub(crate) mod session_status; pub mod social; pub mod upload; pub mod users; diff --git a/crates/buzz-cli/src/commands/session_status.rs b/crates/buzz-cli/src/commands/session_status.rs new file mode 100644 index 00000000000..bbd2bd52293 --- /dev/null +++ b/crates/buzz-cli/src/commands/session_status.rs @@ -0,0 +1,207 @@ +//! Optional session-status tags for new agent messages. +//! +//! An agent harness advertises its status instance directory through +//! [`SESSION_STATUS_DIR_ENV`]. Before a new kind-9 message is signed, the CLI +//! looks up the snapshot for the message's destination there and, when it is +//! current, adds the shared `buzz-session` / `buzz-session-info` tags. +//! +//! The lookup is best-effort and read-only: any missing, invalid, foreign, +//! ambiguous or stale snapshot yields no tags and never fails the send. It +//! makes no network calls, writes, deletes or renames nothing, never waits on +//! a lock and keeps no acknowledgment state. Liveness uses nonblocking lock +//! probes: a lock this process can take is not held by its producer, so the +//! probe is released at once and the snapshot is ignored. + +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use buzz_core::session_status::{ + canonical_identity, generation_lock_path, parse_snapshot, scope_file_name, StatusEnvelope, + StatusIdentity, StatusScope, INSTANCE_LOCK_FILE, MAX_SNAPSHOT_BYTES, +}; +use fs2::FileExt; +use nostr::Tag; +use uuid::Uuid; + +use crate::client::BuzzClient; + +/// Environment variable naming the agent harness's status instance directory. +pub(crate) const SESSION_STATUS_DIR_ENV: &str = "BUZZ_SESSION_STATUS_DIR"; + +/// The status directory advertised in this process's environment. +pub(crate) fn status_dir_from_env() -> Option { + status_dir_from_value(std::env::var_os(SESSION_STATUS_DIR_ENV)) +} + +/// Accept only a nonempty absolute UTF-8 path, as the harness writes it. +fn status_dir_from_value(value: Option) -> Option { + let path = PathBuf::from(value?.into_string().ok()?); + path.is_absolute().then_some(path) +} + +/// Status tags for a new kind-9 message from `client` in `channel`, replying +/// in the thread rooted at `root` (if any). +/// +/// The identity is the client's own relay URL and signing key. +pub(crate) fn message_status_tags( + client: &BuzzClient, + channel: Uuid, + root: Option<&nostr::EventId>, +) -> Vec { + let Some(directory) = client.session_status_dir() else { + return Vec::new(); + }; + let Ok(identity) = canonical_identity(client.relay_url(), &client.keys().public_key().to_hex()) + else { + return Vec::new(); + }; + let root = root.map(nostr::EventId::to_hex); + status_tags(Some(directory), &identity, channel, root.as_deref()) +} + +/// Status tags of the single live snapshot matching a publication. +/// +/// A thread reply may belong to the thread's own session (thread policy) or +/// to the channel conversation (channel policy, DMs). Both candidates are +/// checked; exactly one current snapshot must match, otherwise the scope is +/// ambiguous and no tags are returned. A top-level message only matches the +/// conversation scope, so it never borrows a thread's status. +pub(crate) fn status_tags( + directory: Option<&Path>, + identity: &StatusIdentity, + channel: Uuid, + root: Option<&str>, +) -> Vec { + lookup(directory, identity, channel, root) + .and_then(|envelope| envelope.event_tags().ok()) + .and_then(|tags| { + tags.into_iter() + .map(Tag::parse) + .collect::>() + .ok() + }) + .unwrap_or_default() +} + +fn lookup( + directory: Option<&Path>, + identity: &StatusIdentity, + channel: Uuid, + root: Option<&str>, +) -> Option { + let directory = directory.filter(|dir| dir.is_absolute())?; + let instance_id = instance_id(directory)?; + if !held_by_producer(&directory.join(INSTANCE_LOCK_FILE)) { + return None; + } + let thread = root.map(|root| StatusScope { + channel_id: channel, + thread_root: Some(root.to_owned()), + }); + let conversation = StatusScope { + channel_id: channel, + thread_root: None, + }; + let mut found = None; + for scope in thread.iter().chain(std::iter::once(&conversation)) { + if let Some(envelope) = current_snapshot(directory, instance_id, identity, scope) { + if found.replace(envelope).is_some() { + return None; + } + } + } + found +} + +/// The instance UUID named by the directory's final component. +fn instance_id(directory: &Path) -> Option { + let name = directory.file_name()?.to_str()?; + let id = Uuid::parse_str(name).ok()?; + (id.as_hyphenated().to_string() == name).then_some(id) +} + +/// The snapshot for `scope`, if it belongs to this identity, scope and +/// instance and its generation lock is still held by the producer. +fn current_snapshot( + directory: &Path, + instance_id: Uuid, + identity: &StatusIdentity, + scope: &StatusScope, +) -> Option { + let path = directory.join(scope_file_name(scope).ok()?); + let envelope = parse_snapshot(&read_bounded(&path)?).ok()?; + let matches = envelope.identity == *identity + && envelope.scope == *scope + && envelope.instance_id == instance_id; + (matches && held_by_producer(&directory.join(generation_lock_path(envelope.generation)))) + .then_some(envelope) +} + +/// Open a regular file (not a link, FIFO or device) read-only. +fn open_regular(path: &Path) -> Option { + if !fs::symlink_metadata(path).ok()?.file_type().is_file() { + return None; + } + let file = File::open(path).ok()?; + file.metadata().ok()?.is_file().then_some(file) +} + +/// Read at most one byte more than a snapshot may hold. +fn read_bounded(path: &Path) -> Option> { + let file = open_regular(path)?; + let mut bytes = Vec::new(); + file.take(MAX_SNAPSHOT_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .ok()?; + (bytes.len() <= MAX_SNAPSHOT_BYTES).then_some(bytes) +} + +/// Whether another holder keeps `path` exclusively locked. +/// +/// Only a nonblocking attempt is made. If it succeeds the producer no longer +/// holds the lock: release it immediately and report "not held". +fn held_by_producer(path: &Path) -> bool { + let Some(file) = open_regular(path) else { + return false; + }; + match file.try_lock_exclusive() { + Ok(()) => { + let _ = FileExt::unlock(&file); + false + } + Err(err) => { + err.kind() == std::io::ErrorKind::WouldBlock + || err.raw_os_error() == fs2::lock_contended_error().raw_os_error() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + + #[test] + fn status_dir_value_must_be_a_nonempty_absolute_path() { + let absolute = std::env::temp_dir().join("instance"); + let cases: Vec<(Option, Option)> = vec![ + (None, None), + (Some(OsString::new()), None), + (Some("relative/instance".into()), None), + (Some("instance".into()), None), + (Some(absolute.clone().into()), Some(absolute)), + ]; + for (value, expected) in cases { + assert_eq!(status_dir_from_value(value.clone()), expected, "{value:?}"); + } + } + + #[cfg(unix)] + #[test] + fn status_dir_value_must_be_utf8() { + use std::os::unix::ffi::OsStringExt; + let value = OsString::from_vec(b"/tmp/\xff".to_vec()); + assert_eq!(status_dir_from_value(Some(value)), None); + } +} diff --git a/crates/buzz-cli/src/delivery_tests.rs b/crates/buzz-cli/src/delivery_tests.rs index 6538167407d..bd24b5b5132 100644 --- a/crates/buzz-cli/src/delivery_tests.rs +++ b/crates/buzz-cli/src/delivery_tests.rs @@ -5,16 +5,18 @@ use nostr::{Event, EventBuilder, Kind, Tag}; use std::sync::{Arc, Mutex}; #[derive(Clone)] -struct Relay { - keys: Keys, - channel: String, - parent: Event, - member: Arc>, - ack: Arc>, - membership_channel: Arc>>, - extra_members: Arc>>, - events: Arc>>, - bodies: Arc>>>, +pub(super) struct Relay { + pub(super) keys: Keys, + pub(super) channel: String, + pub(super) parent: Event, + pub(super) member: Arc>, + pub(super) ack: Arc>, + pub(super) membership_channel: Arc>>, + pub(super) extra_members: Arc>>, + pub(super) events: Arc>>, + pub(super) bodies: Arc>>>, + /// Workspace emoji sets served for kind-30030 palette queries. + pub(super) palette: Arc>>, } async fn query( @@ -53,6 +55,9 @@ async fn query( .unwrap(); return Json(serde_json::json!([e])); } + if filter["kinds"][0] == 30030 { + return Json(serde_json::json!(*r.palette.lock().unwrap())); + } if filter["kinds"][0] == 0 { let e = EventBuilder::new(Kind::Metadata, r#"{"name":"Callback"}"#) .sign_with_keys(&r.keys) @@ -75,9 +80,16 @@ async fn publish(State(r): State, body: axum::body::Bytes) -> axum::respo let event: Event = serde_json::from_slice(&body).unwrap(); event.verify().unwrap(); r.bodies.lock().unwrap().push(body.to_vec()); + let ack = *r.ack.lock().unwrap(); + if ack == "http-error" { + return ( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error":"invalid: rejected by test relay"})), + ) + .into_response(); + } let mut events = r.events.lock().unwrap(); let duplicate = events.iter().any(|e| e.id == event.id); - let ack = *r.ack.lock().unwrap(); if !duplicate && ack != "rejected" { events.push(event.clone()); } @@ -97,11 +109,33 @@ async fn publish(State(r): State, body: axum::body::Bytes) -> axum::respo }.into_response() } -async fn fixture() -> (Relay, String, tempfile::TempDir) { +/// Blossom upload stub: describes the received blob without storing it. +async fn upload( + headers: axum::http::HeaderMap, + body: axum::body::Bytes, +) -> Json { + let header = |name: &str| { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_owned() + }; + let sha256 = header("x-sha-256"); + Json(serde_json::json!({ + "url": format!("https://media.example/{sha256}"), + "sha256": sha256, + "size": body.len(), + "type": header("content-type"), + "uploaded": 1, + })) +} + +pub(super) async fn fixture() -> (Relay, String, tempfile::TempDir) { fixture_with_keys(Keys::generate()).await } -async fn fixture_with_keys(keys: Keys) -> (Relay, String, tempfile::TempDir) { +pub(super) async fn fixture_with_keys(keys: Keys) -> (Relay, String, tempfile::TempDir) { let channel = "123e4567-e89b-12d3-a456-426614174000".to_string(); let parent = EventBuilder::new(Kind::Custom(9), "original task") .tag(Tag::parse(["h", &channel]).unwrap()) @@ -117,12 +151,14 @@ async fn fixture_with_keys(keys: Keys) -> (Relay, String, tempfile::TempDir) { extra_members: Arc::new(Mutex::new(vec![])), events: Arc::new(Mutex::new(vec![])), bodies: Arc::new(Mutex::new(vec![])), + palette: Arc::new(Mutex::new(vec![])), }; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let app = Router::new() .route("/query", post(query)) .route("/events", post(publish)) + .route("/upload", axum::routing::put(upload)) .with_state(relay.clone()); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); @@ -130,7 +166,25 @@ async fn fixture_with_keys(keys: Keys) -> (Relay, String, tempfile::TempDir) { (relay, url, tempfile::tempdir().unwrap()) } -async fn deliver(r: &Relay, url: &str, dir: &std::path::Path, key: &str, content: &str) -> i32 { +pub(super) async fn deliver( + r: &Relay, + url: &str, + dir: &std::path::Path, + key: &str, + content: &str, +) -> i32 { + deliver_with_status(r, url, dir, key, content, None).await +} + +/// [`deliver`] from a CLI configured with a session-status directory. +pub(super) async fn deliver_with_status( + r: &Relay, + url: &str, + dir: &std::path::Path, + key: &str, + content: &str, + status_dir: Option<&std::path::Path>, +) -> i32 { let cli = match parse_args([ "buzz", "messages", @@ -152,7 +206,9 @@ async fn deliver(r: &Relay, url: &str, dir: &std::path::Path, key: &str, content let Cmd::Messages(command) = cli.command else { panic!("expected messages command"); }; - let client = BuzzClient::new(url.into(), r.keys.clone(), None, None).unwrap(); + let client = BuzzClient::new(url.into(), r.keys.clone(), None, None) + .unwrap() + .with_session_status_dir(status_dir.map(std::path::Path::to_owned)); match commands::messages::dispatch(command, &client, &OutputFormat::Json).await { Ok(()) => 0, Err(e) => { diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 178867c9cd4..695f77c549a 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -2211,7 +2211,8 @@ async fn run(cli: Cli) -> Result<(), CliError> { _ => (None, None), }; - let client = BuzzClient::new(relay_url, keys, auth_tag, auth_tag_json)?; + let client = BuzzClient::new(relay_url, keys, auth_tag, auth_tag_json)? + .with_session_status_dir(commands::session_status::status_dir_from_env()); match cli.command { Cmd::Agents(sub) => commands::agents::dispatch(sub, &client).await, @@ -2815,3 +2816,6 @@ mod delivery_tests; #[cfg(test)] mod attachment_tests; + +#[cfg(test)] +mod session_status_tests; diff --git a/crates/buzz-cli/src/session_status_tests.rs b/crates/buzz-cli/src/session_status_tests.rs new file mode 100644 index 00000000000..a11fe12901b --- /dev/null +++ b/crates/buzz-cli/src/session_status_tests.rs @@ -0,0 +1,1123 @@ +//! Session-status enrichment through the production `messages send` and +//! `messages deliver` paths. +//! +//! The status directory is built exactly as the agent harness lays it out +//! (instance directory renamed into place with its lock already held, +//! `generations/.lock` held while a generation is current, snapshots +//! written to a temp file and renamed). Locks are held and released with fs2 +//! like the harness does, so the CLI reader sees real OS lock state. Events +//! are captured by the signature-checking local mock relay of +//! `delivery_tests`. +use super::delivery_tests::{deliver_with_status, fixture, Relay}; +use super::*; +use buzz_core::session_status::{ + canonical_identity, generation_lock_path, scope_file_name, ContextOccupancy, HistoryCoverage, + SessionState, SessionStatus, StatusEnvelope, StatusIdentity, StatusReason, StatusScope, + GENERATIONS_DIR, INSTANCE_LOCK_FILE, MAX_SNAPSHOT_BYTES, SESSION_INFO_TAG, SESSION_TAG, +}; +use fs2::FileExt; +use nostr::{Event, EventBuilder, Kind, Tag}; +use std::collections::{BTreeMap, HashMap}; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +// --------------------------------------------------------------------------- +// Harness-shaped status producer +// --------------------------------------------------------------------------- + +struct Generation { + id: Uuid, + lock: Option, + lock_path: PathBuf, + status: SessionStatus, +} + +/// One harness instance directory with the harness's lock protocol. +struct Producer { + _root: tempfile::TempDir, + dir: PathBuf, + identity: StatusIdentity, + instance_id: Uuid, + instance_lock: Option, + current: HashMap, +} + +/// Create a fresh 0600 lock file and hold an exclusive lock on it. +fn create_lock(path: &Path) -> File { + let mut options = OpenOptions::new(); + options.read(true).write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let file = options.open(path).unwrap(); + file.try_lock_exclusive().unwrap(); + file +} + +impl Producer { + fn start(relay: &str, keys: &Keys) -> Self { + Self::with_identity(canonical_identity(relay, &keys.public_key().to_hex()).unwrap()) + } + + fn with_identity(identity: StatusIdentity) -> Self { + let root = tempfile::tempdir().unwrap(); + let parent = root.path().join("status").join("v1").join("identity"); + fs::create_dir_all(&parent).unwrap(); + let instance_id = Uuid::new_v4(); + let staging = parent.join(format!(".staging-{instance_id}")); + fs::create_dir(&staging).unwrap(); + let instance_lock = create_lock(&staging.join(INSTANCE_LOCK_FILE)); + fs::create_dir(staging.join(GENERATIONS_DIR)).unwrap(); + let dir = parent.join(instance_id.to_string()); + fs::rename(&staging, &dir).unwrap(); + Self { + _root: root, + dir, + identity, + instance_id, + instance_lock: Some(instance_lock), + current: HashMap::new(), + } + } + + fn snapshot_path(&self, scope: &StatusScope) -> PathBuf { + self.dir.join(scope_file_name(scope).unwrap()) + } + + /// Start a new transition for `scope`, revoking its predecessor first. + fn begin(&mut self, scope: &StatusScope, state: SessionState, reason: StatusReason) -> Uuid { + self.revoke(scope); + let id = Uuid::new_v4(); + let lock_path = self.dir.join(generation_lock_path(id)); + let lock = create_lock(&lock_path); + let status = SessionStatus { + state, + reason, + transition_id: Uuid::new_v4(), + occupancy: None, + history: None, + }; + let transition = status.transition_id; + self.current.insert( + scope.clone(), + Generation { + id, + lock: Some(lock), + lock_path, + status, + }, + ); + self.write(scope); + transition + } + + /// Update the current generation's status in place (same transition). + fn update(&mut self, scope: &StatusScope, change: impl FnOnce(&mut SessionStatus)) { + change(&mut self.current.get_mut(scope).unwrap().status); + self.write(scope); + } + + fn envelope(&self, scope: &StatusScope) -> StatusEnvelope { + let generation = &self.current[scope]; + StatusEnvelope::new( + self.identity.clone(), + self.instance_id, + scope.clone(), + generation.id, + generation.status.clone(), + ) + } + + fn write(&self, scope: &StatusScope) { + assert!( + self.current[scope].lock.is_some(), + "write needs a held lock" + ); + let bytes = self.envelope(scope).to_snapshot_bytes().unwrap(); + let mut file = tempfile::Builder::new() + .prefix(".snapshot-") + .tempfile_in(&self.dir) + .unwrap(); + file.write_all(&bytes).unwrap(); + file.persist(self.snapshot_path(scope)).unwrap(); + } + + /// Ordinary revocation: remove the snapshot, release and unlink the lock. + fn revoke(&mut self, scope: &StatusScope) { + if let Some(mut generation) = self.current.remove(scope) { + let _ = fs::remove_file(self.snapshot_path(scope)); + if let Some(lock) = generation.lock.take() { + FileExt::unlock(&lock).unwrap(); + } + let _ = fs::remove_file(&generation.lock_path); + } + } + + /// Revocation whose snapshot removal failed: the generation lock is + /// released (and optionally unlinked) while the snapshot stays. + fn revoke_leaving_snapshot(&mut self, scope: &StatusScope, unlink_lock: bool) { + let generation = self.current.get_mut(scope).unwrap(); + let lock = generation.lock.take().unwrap(); + FileExt::unlock(&lock).unwrap(); + drop(lock); + if unlink_lock { + fs::remove_file(&generation.lock_path).unwrap(); + } + } + + /// The harness process is gone: its instance lock is released while + /// every file (and, here, even the generation lock) is left in place. + fn release_instance(&mut self) { + let lock = self.instance_lock.take().unwrap(); + FileExt::unlock(&lock).unwrap(); + } + + fn generation_lock_path(&self, scope: &StatusScope) -> PathBuf { + self.current[scope].lock_path.clone() + } + + /// Every entry under the instance directory with its bytes. + fn tree(&self) -> BTreeMap> { + let mut out = BTreeMap::new(); + let mut stack = vec![self.dir.clone()]; + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + let meta = fs::symlink_metadata(&path).unwrap(); + if meta.is_dir() { + stack.push(path.clone()); + out.insert(path, Vec::new()); + } else { + out.insert(path.clone(), fs::read(&path).unwrap_or_default()); + } + } + } + out + } +} + +/// Whether some other open file description holds `path`'s lock. +fn held_elsewhere(path: &Path) -> bool { + let file = File::open(path).unwrap(); + match file.try_lock_exclusive() { + Ok(()) => { + FileExt::unlock(&file).unwrap(); + false + } + Err(_) => true, + } +} + +fn thread_scope(channel: &str, root: &nostr::EventId) -> StatusScope { + StatusScope { + channel_id: Uuid::parse_str(channel).unwrap(), + thread_root: Some(root.to_hex()), + } +} + +fn conversation_scope(channel: &str) -> StatusScope { + StatusScope { + channel_id: Uuid::parse_str(channel).unwrap(), + thread_root: None, + } +} + +/// Transition A: a new session with known occupancy and exact history. +fn begin_a(producer: &mut Producer, scope: &StatusScope) -> Uuid { + let transition = producer.begin(scope, SessionState::New, StatusReason::FirstInThread); + producer.update(scope, |status| { + status.occupancy = ContextOccupancy::new(64, 100); + status.history = Some(HistoryCoverage { + read: Some(3), + total: Some(3), + exact: true, + incomplete: false, + }); + }); + transition +} + +/// Transition B: a resumed session whose occupancy is not known yet. +fn begin_b(producer: &mut Producer, scope: &StatusScope) -> Uuid { + producer.begin(scope, SessionState::Resumed, StatusReason::IdleSleep) +} + +fn expected_a(transition: Uuid, kind: &str) -> Vec> { + vec![ + vec![ + SESSION_TAG.into(), + "new".into(), + "first_in_thread".into(), + "64".into(), + ], + vec![ + SESSION_INFO_TAG.into(), + "1".into(), + transition.to_string(), + kind.into(), + "3".into(), + "3".into(), + "exact".into(), + ], + ] +} + +fn expected_b(transition: Uuid, kind: &str) -> Vec> { + vec![ + vec![ + SESSION_TAG.into(), + "resumed".into(), + "idle_sleep".into(), + String::new(), + ], + vec![ + SESSION_INFO_TAG.into(), + "1".into(), + transition.to_string(), + kind.into(), + String::new(), + String::new(), + "unknown".into(), + ], + ] +} + +// --------------------------------------------------------------------------- +// CLI helpers +// --------------------------------------------------------------------------- + +fn client(r: &Relay, url: &str, status_dir: Option<&Path>) -> BuzzClient { + BuzzClient::new(url.into(), r.keys.clone(), None, None) + .unwrap() + .with_session_status_dir(status_dir.map(Path::to_owned)) +} + +/// Run `buzz messages ` through the production dispatcher and return +/// the event the relay received (signature verified). +async fn run_messages(client: &BuzzClient, r: &Relay, args: &[&str]) -> Result { + let before = r.bodies.lock().unwrap().len(); + let mut argv = vec!["buzz", "messages"]; + argv.extend_from_slice(args); + let cli = parse_args(argv).expect("valid arguments"); + let Cmd::Messages(command) = cli.command else { + panic!("expected messages command"); + }; + commands::messages::dispatch(command, client, &OutputFormat::Json).await?; + let bodies = r.bodies.lock().unwrap(); + assert_eq!(bodies.len(), before + 1, "exactly one publication"); + let event: Event = serde_json::from_slice(bodies.last().unwrap()).unwrap(); + event.verify().expect("final event signature verifies"); + Ok(event) +} + +async fn send( + r: &Relay, + url: &str, + status_dir: Option<&Path>, + extra: &[&str], +) -> Result { + let client = client(r, url, status_dir); + let mut args = vec!["send", "--channel", &r.channel]; + args.extend_from_slice(extra); + run_messages(&client, r, &args).await +} + +fn tags(event: &Event) -> Vec> { + event.tags.iter().map(|t| t.as_slice().to_vec()).collect() +} + +fn status_tags_of(event: &Event) -> Vec> { + tags(event) + .into_iter() + .filter(|t| { + matches!( + t.first().map(String::as_str), + Some(SESSION_TAG | SESSION_INFO_TAG) + ) + }) + .collect() +} + +/// Store a kind-9 event in `channel` (optionally a reply) on the mock relay. +fn seed_event( + r: &Relay, + keys: &Keys, + channel: &str, + content: &str, + reply: Option<(&Event, &Event)>, +) -> Event { + let mut builder = + EventBuilder::new(Kind::Custom(9), content).tag(Tag::parse(["h", channel]).unwrap()); + if let Some((root, parent)) = reply { + builder = builder + .tag(Tag::parse(["e", &root.id.to_hex(), "", "root"]).unwrap()) + .tag(Tag::parse(["e", &parent.id.to_hex(), "", "reply"]).unwrap()); + } + let event = builder.sign_with_keys(keys).unwrap(); + r.events.lock().unwrap().push(event.clone()); + event +} + +/// `(root, parent)` of a reply under the shared NIP-10 rules. +fn thread_of(event: &Event) -> Option<(String, String)> { + buzz_core::nip10::parse_thread_markers(&event.tags).resolve() +} + +fn thread_root(event: &Event) -> Option { + thread_of(event).map(|(root, _)| root) +} + +// --------------------------------------------------------------------------- +// messages send +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn status_reply_carries_thread_status_and_keeps_existing_tags() { + let (r, url, files) = fixture().await; + let mut producer = Producer::start(&url, &r.keys); + let scope = thread_scope(&r.channel, &r.parent.id); + let transition = begin_a(&mut producer, &scope); + + // Everything the existing send path adds must survive enrichment. + let mention = Keys::generate().public_key().to_hex(); + r.extra_members.lock().unwrap().push(mention.clone()); + let palette = EventBuilder::new(Kind::Custom(30030), "") + .tags([ + Tag::parse(["d", "buzz:custom-emoji"]).unwrap(), + Tag::parse(["emoji", "wave", "https://cdn.example/wave.png"]).unwrap(), + ]) + .sign_with_keys(&r.keys) + .unwrap(); + r.palette.lock().unwrap().push(palette); + let image = files.path().join("pixel.png"); + fs::write(&image, b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01").unwrap(); + let owner = Keys::generate(); + let auth_json = buzz_sdk::nip_oa::compute_auth_tag(&owner, &r.keys.public_key(), "").unwrap(); + let auth = buzz_sdk::nip_oa::parse_auth_tag(&auth_json).unwrap(); + let client = BuzzClient::new( + url.clone(), + r.keys.clone(), + Some(auth.clone()), + Some(auth_json), + ) + .unwrap() + .with_session_status_dir(Some(producer.dir.clone())); + let parent = r.parent.id.to_hex(); + let image_arg = image.to_str().unwrap().to_owned(); + let event = run_messages( + &client, + &r, + &[ + "send", + "--channel", + &r.channel, + "--reply-to", + &parent, + "--content", + "done :wave:", + "--mention", + &mention, + "--broadcast", + "--file", + &image_arg, + ], + ) + .await + .unwrap(); + + assert_eq!(event.kind.as_u16(), 9); + assert_eq!(status_tags_of(&event), expected_a(transition, "thread")); + let all = tags(&event); + let has = |name: &str| { + all.iter() + .any(|t| t.first().map(String::as_str) == Some(name)) + }; + assert_eq!(thread_root(&event).as_deref(), Some(parent.as_str())); + assert!(all.contains(&vec!["h".into(), r.channel.clone()])); + assert!(all.contains(&vec!["p".into(), mention])); + assert!(all.contains(&vec!["broadcast".into(), "1".into()])); + assert!(has("imeta"), "media tag kept: {all:?}"); + assert!(all.contains(&vec![ + "emoji".into(), + "wave".into(), + "https://cdn.example/wave.png".into() + ])); + assert!(all.contains(&auth.as_slice().to_vec()), "auth tag kept"); + assert_eq!( + all.iter() + .filter(|t| t.first().map(String::as_str) == Some(SESSION_TAG)) + .count(), + 1 + ); +} + +#[tokio::test] +async fn status_nested_reply_uses_the_canonical_thread_root() { + let (r, url, _files) = fixture().await; + let mut producer = Producer::start(&url, &r.keys); + let scope = thread_scope(&r.channel, &r.parent.id); + let transition = begin_a(&mut producer, &scope); + let human = Keys::generate(); + let nested = seed_event( + &r, + &human, + &r.channel, + "follow-up", + Some((&r.parent, &r.parent)), + ); + + let event = send( + &r, + &url, + Some(&producer.dir), + &["--reply-to", &nested.id.to_hex(), "--content", "answer"], + ) + .await + .unwrap(); + + assert_eq!( + thread_of(&event), + Some((r.parent.id.to_hex(), nested.id.to_hex())) + ); + assert_eq!(status_tags_of(&event), expected_a(transition, "thread")); +} + +#[tokio::test] +async fn status_publication_into_another_thread_never_inherits_status() { + let (r, url, _files) = fixture().await; + let mut producer = Producer::start(&url, &r.keys); + let human = Keys::generate(); + let other_root = seed_event(&r, &human, &r.channel, "another topic", None); + // The worker's last active status belongs to thread A. + let a = thread_scope(&r.channel, &r.parent.id); + let transition_a = begin_a(&mut producer, &a); + + let into_b = send( + &r, + &url, + Some(&producer.dir), + &["--reply-to", &other_root.id.to_hex(), "--content", "x"], + ) + .await + .unwrap(); + assert!(status_tags_of(&into_b).is_empty(), "{:?}", tags(&into_b)); + let top_level = send(&r, &url, Some(&producer.dir), &["--content", "top"]) + .await + .unwrap(); + assert!(status_tags_of(&top_level).is_empty()); + + // Two concurrent thread sessions: each destination gets its own status. + let b = thread_scope(&r.channel, &other_root.id); + let transition_b = begin_b(&mut producer, &b); + let into_b = send( + &r, + &url, + Some(&producer.dir), + &["--reply-to", &other_root.id.to_hex(), "--content", "x"], + ) + .await + .unwrap(); + assert_eq!(status_tags_of(&into_b), expected_b(transition_b, "thread")); + let into_a = send( + &r, + &url, + Some(&producer.dir), + &["--reply-to", &r.parent.id.to_hex(), "--content", "y"], + ) + .await + .unwrap(); + assert_eq!(status_tags_of(&into_a), expected_a(transition_a, "thread")); +} + +#[tokio::test] +async fn status_channel_policy_conversation_covers_its_channel_only() { + let (r, url, _files) = fixture().await; + let mut producer = Producer::start(&url, &r.keys); + let transition = begin_a(&mut producer, &conversation_scope(&r.channel)); + + let reply = send( + &r, + &url, + Some(&producer.dir), + &[ + "--reply-to", + &r.parent.id.to_hex(), + "--content", + "in a thread", + ], + ) + .await + .unwrap(); + assert_eq!( + status_tags_of(&reply), + expected_a(transition, "conversation") + ); + let top_level = send(&r, &url, Some(&producer.dir), &["--content", "top"]) + .await + .unwrap(); + assert_eq!( + status_tags_of(&top_level), + expected_a(transition, "conversation") + ); + + // Another channel's conversation status never applies here. + let other_channel = Uuid::new_v4().to_string(); + let mut elsewhere = Producer::start(&url, &r.keys); + begin_a(&mut elsewhere, &conversation_scope(&other_channel)); + let plain = send(&r, &url, Some(&elsewhere.dir), &["--content", "top"]) + .await + .unwrap(); + assert!(status_tags_of(&plain).is_empty()); +} + +#[tokio::test] +async fn status_dm_reply_uses_the_conversation_scope() { + let (r, url, _files) = fixture().await; + let dm = Uuid::new_v4().to_string(); + let human = Keys::generate(); + let trigger = seed_event(&r, &human, &dm, "please check", None); + let mut producer = Producer::start(&url, &r.keys); + let transition = begin_b(&mut producer, &conversation_scope(&dm)); + + let client = client(&r, &url, Some(&producer.dir)); + let trigger_id = trigger.id.to_hex(); + let event = run_messages( + &client, + &r, + &[ + "send", + "--channel", + &dm, + "--reply-to", + &trigger_id, + "--content", + "checked", + ], + ) + .await + .unwrap(); + + assert_eq!(thread_root(&event).as_deref(), Some(trigger_id.as_str())); + assert_eq!( + status_tags_of(&event), + expected_b(transition, "conversation") + ); +} + +#[tokio::test] +async fn status_absent_or_invalid_snapshot_sends_without_status_tags() { + let (r, url, files) = fixture().await; + let parent = r.parent.id.to_hex(); + let scope = thread_scope(&r.channel, &r.parent.id); + let reply = ["--reply-to", parent.as_str(), "--content", "report"]; + + // Control: a valid snapshot is used, so every case below differs only in + // the condition it names. + let mut valid = Producer::start(&url, &r.keys); + let transition = begin_a(&mut valid, &scope); + let event = send(&r, &url, Some(&valid.dir), &reply).await.unwrap(); + assert_eq!(status_tags_of(&event), expected_a(transition, "thread")); + + let mut cases: Vec<(&str, Producer, Option)> = Vec::new(); + + let none = Producer::start(&url, &r.keys); + cases.push(("no status directory", none, None)); + + let missing = Producer::start(&url, &r.keys); + let dir = missing.dir.clone(); + cases.push(("missing snapshot", missing, Some(dir))); + + let corrupt = Producer::start(&url, &r.keys); + fs::write(corrupt.snapshot_path(&scope), b"{\"version\":1,").unwrap(); + let dir = corrupt.dir.clone(); + cases.push(("corrupt snapshot", corrupt, Some(dir))); + + let mut oversized = Producer::start(&url, &r.keys); + begin_a(&mut oversized, &scope); + let mut bytes = oversized.envelope(&scope).to_snapshot_bytes().unwrap(); + bytes.resize(MAX_SNAPSHOT_BYTES + 1, b' '); + fs::write(oversized.snapshot_path(&scope), bytes).unwrap(); + let dir = oversized.dir.clone(); + cases.push(("oversized snapshot", oversized, Some(dir))); + + let mut dead = Producer::start(&url, &r.keys); + begin_a(&mut dead, &scope); + dead.release_instance(); + let dir = dead.dir.clone(); + cases.push(("dead instance", dead, Some(dir))); + + let mut wrong_key = Producer::start(&url, &Keys::generate()); + begin_a(&mut wrong_key, &scope); + let dir = wrong_key.dir.clone(); + cases.push(("another agent key", wrong_key, Some(dir))); + + let mut wrong_relay = Producer::start("wss://other-community.example", &r.keys); + begin_a(&mut wrong_relay, &scope); + let dir = wrong_relay.dir.clone(); + cases.push(("another community relay", wrong_relay, Some(dir))); + + let other_root = seed_event(&r, &r.keys, &r.channel, "other root", None); + let mut wrong_root = Producer::start(&url, &r.keys); + begin_a(&mut wrong_root, &thread_scope(&r.channel, &other_root.id)); + let dir = wrong_root.dir.clone(); + cases.push(("another thread root", wrong_root, Some(dir))); + + let other_channel = Uuid::new_v4().to_string(); + let mut wrong_channel = Producer::start(&url, &r.keys); + begin_a( + &mut wrong_channel, + &thread_scope(&other_channel, &r.parent.id), + ); + let dir = wrong_channel.dir.clone(); + cases.push(("another channel", wrong_channel, Some(dir))); + + // A live snapshot of another scope placed under this scope's name. + let mut misplaced = Producer::start(&url, &r.keys); + let foreign = thread_scope(&other_channel, &r.parent.id); + begin_a(&mut misplaced, &foreign); + fs::copy( + misplaced.snapshot_path(&foreign), + misplaced.snapshot_path(&scope), + ) + .unwrap(); + let dir = misplaced.dir.clone(); + cases.push(("snapshot names another scope", misplaced, Some(dir))); + + // A live snapshot written by another instance placed in this one. + let copied = Producer::start(&url, &r.keys); + let mut source = Producer::start(&url, &r.keys); + begin_a(&mut source, &scope); + fs::copy(source.snapshot_path(&scope), copied.snapshot_path(&scope)).unwrap(); + let source_lock = source.generation_lock_path(&scope); + let copied_lock = copied + .dir + .join(source_lock.strip_prefix(&source.dir).unwrap()); + fs::copy(&source_lock, &copied_lock).unwrap(); + let held_copy = File::open(&copied_lock).unwrap(); + held_copy.try_lock_exclusive().unwrap(); + let dir = copied.dir.clone(); + cases.push(("snapshot from another instance", copied, Some(dir))); + + let mut ambiguous = Producer::start(&url, &r.keys); + begin_a(&mut ambiguous, &scope); + begin_b(&mut ambiguous, &conversation_scope(&r.channel)); + let dir = ambiguous.dir.clone(); + cases.push(("thread and conversation both live", ambiguous, Some(dir))); + + // The link target is this instance's own valid snapshot, so only the + // link itself is wrong. + #[cfg(unix)] + { + let mut linked = Producer::start(&url, &r.keys); + begin_a(&mut linked, &scope); + let outside = files.path().join("outside-snapshot.json"); + fs::rename(linked.snapshot_path(&scope), &outside).unwrap(); + std::os::unix::fs::symlink(&outside, linked.snapshot_path(&scope)).unwrap(); + let dir = linked.dir.clone(); + cases.push(("snapshot is a symlink", linked, Some(dir))); + } + + // A relative spelling that does resolve to a valid live instance. + #[cfg(unix)] + { + let mut relative = Producer::start(&url, &r.keys); + begin_a(&mut relative, &scope); + let cwd = std::env::current_dir().unwrap(); + let mut relative_dir = PathBuf::new(); + for _ in 1..cwd.components().count() { + relative_dir.push(".."); + } + relative_dir.push(relative.dir.strip_prefix("/").unwrap()); + assert!(relative_dir.is_relative() && relative_dir.join(INSTANCE_LOCK_FILE).exists()); + cases.push(("relative directory", relative, Some(relative_dir))); + } + + let mut renamed = Producer::start(&url, &r.keys); + begin_a(&mut renamed, &scope); + let not_uuid = files.path().join("not-an-instance"); + fs::rename(&renamed.dir, ¬_uuid).unwrap(); + renamed.dir = not_uuid.clone(); + cases.push(("directory is not an instance", renamed, Some(not_uuid))); + + for (name, _producer, dir) in &cases { + let event = send(&r, &url, dir.as_deref(), &reply) + .await + .unwrap_or_else(|e| panic!("{name}: send must succeed: {e}")); + assert!( + status_tags_of(&event).is_empty(), + "{name}: unexpected status tags {:?}", + tags(&event) + ); + assert_eq!( + thread_root(&event).as_deref(), + Some(parent.as_str()), + "{name}: thread kept" + ); + } + drop(held_copy); +} + +/// Ruling R3: after the producer releases a generation, a snapshot that its +/// failed removal left behind is never used. +#[tokio::test] +async fn status_released_generation_lock_rejects_remaining_snapshot() { + let (r, url, _files) = fixture().await; + let reply_args = |id: String| vec!["--reply-to".to_owned(), id, "--content".into(), "r".into()]; + for unlink_lock in [true, false] { + let mut producer = Producer::start(&url, &r.keys); + let scope = thread_scope(&r.channel, &r.parent.id); + let transition = begin_a(&mut producer, &scope); + let args = reply_args(r.parent.id.to_hex()); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let live = send(&r, &url, Some(&producer.dir), &args).await.unwrap(); + assert_eq!(status_tags_of(&live), expected_a(transition, "thread")); + + let lock_path = producer.generation_lock_path(&scope); + producer.revoke_leaving_snapshot(&scope, unlink_lock); + let snapshot = fs::read(producer.snapshot_path(&scope)).unwrap(); + let before = producer.tree(); + + let stale = send(&r, &url, Some(&producer.dir), &args).await.unwrap(); + assert!( + status_tags_of(&stale).is_empty(), + "unlink_lock={unlink_lock}: {:?}", + tags(&stale) + ); + assert_eq!(fs::read(producer.snapshot_path(&scope)).unwrap(), snapshot); + assert_eq!(producer.tree(), before, "reader changed the directory"); + if !unlink_lock { + assert!(!held_elsewhere(&lock_path), "reader released its probe"); + } + } +} + +/// Ruling R3: a released instance lock means the harness is gone, even when +/// its snapshot and generation lock still look current. +#[tokio::test] +async fn status_released_instance_lock_rejects_live_looking_snapshot() { + let (r, url, _files) = fixture().await; + let mut producer = Producer::start(&url, &r.keys); + let scope = conversation_scope(&r.channel); + let transition = begin_a(&mut producer, &scope); + let live = send(&r, &url, Some(&producer.dir), &["--content", "a"]) + .await + .unwrap(); + assert_eq!( + status_tags_of(&live), + expected_a(transition, "conversation") + ); + + producer.release_instance(); + let before = producer.tree(); + let dead = send(&r, &url, Some(&producer.dir), &["--content", "b"]) + .await + .unwrap(); + assert!(status_tags_of(&dead).is_empty(), "{:?}", tags(&dead)); + assert_eq!(producer.tree(), before); + assert!(!held_elsewhere(&producer.dir.join(INSTANCE_LOCK_FILE))); + assert!(held_elsewhere(&producer.generation_lock_path(&scope))); +} + +#[tokio::test] +async fn status_reader_never_mutates_or_takes_live_locks() { + let (r, url, _files) = fixture().await; + let mut producer = Producer::start(&url, &r.keys); + let thread = thread_scope(&r.channel, &r.parent.id); + let transition = begin_a(&mut producer, &thread); + let before = producer.tree(); + for _ in 0..3 { + let event = send( + &r, + &url, + Some(&producer.dir), + &["--reply-to", &r.parent.id.to_hex(), "--content", "z"], + ) + .await + .unwrap(); + assert_eq!(status_tags_of(&event), expected_a(transition, "thread")); + } + send(&r, &url, Some(&producer.dir), &["--content", "top"]) + .await + .unwrap(); + assert_eq!(producer.tree(), before); + assert!(held_elsewhere(&producer.dir.join(INSTANCE_LOCK_FILE))); + assert!(held_elsewhere(&producer.generation_lock_path(&thread))); +} + +#[tokio::test] +async fn status_failed_send_keeps_status_and_transition() { + let (r, url, _files) = fixture().await; + let mut producer = Producer::start(&url, &r.keys); + let scope = thread_scope(&r.channel, &r.parent.id); + let transition = begin_a(&mut producer, &scope); + let before = producer.tree(); + let parent = r.parent.id.to_hex(); + + *r.ack.lock().unwrap() = "http-error"; + let failed = send( + &r, + &url, + Some(&producer.dir), + &["--reply-to", &parent, "--content", "lost"], + ) + .await; + assert!(failed.is_err()); + let attempted: Event = + serde_json::from_slice(r.bodies.lock().unwrap().last().unwrap()).unwrap(); + assert_eq!(status_tags_of(&attempted), expected_a(transition, "thread")); + assert_eq!(producer.tree(), before); + + *r.ack.lock().unwrap() = "ok"; + let retried = send( + &r, + &url, + Some(&producer.dir), + &["--reply-to", &parent, "--content", "again"], + ) + .await + .unwrap(); + assert_eq!(status_tags_of(&retried), expected_a(transition, "thread")); +} + +#[tokio::test] +async fn status_only_kind_nine_messages_are_enriched() { + let (r, url, _files) = fixture().await; + let mut producer = Producer::start(&url, &r.keys); + let thread = thread_scope(&r.channel, &r.parent.id); + let transition = begin_a(&mut producer, &thread); + let parent = r.parent.id.to_hex(); + + let explicit = send( + &r, + &url, + Some(&producer.dir), + &["--kind", "9", "--reply-to", &parent, "--content", "k9"], + ) + .await + .unwrap(); + assert_eq!(status_tags_of(&explicit), expected_a(transition, "thread")); + + let comment = send( + &r, + &url, + Some(&producer.dir), + &[ + "--kind", + "45003", + "--reply-to", + &parent, + "--content", + "comment", + ], + ) + .await + .unwrap(); + assert_eq!(comment.kind.as_u16(), 45003); + assert!(status_tags_of(&comment).is_empty()); + + begin_a(&mut producer, &conversation_scope(&r.channel)); + let post = send( + &r, + &url, + Some(&producer.dir), + &["--kind", "45001", "--content", "post"], + ) + .await + .unwrap(); + assert_eq!(post.kind.as_u16(), 45001); + assert!(status_tags_of(&post).is_empty()); + + let client = client(&r, &url, Some(&producer.dir)); + let diff = run_messages( + &client, + &r, + &[ + "send-diff", + "--channel", + &r.channel, + "--diff=--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n", + "--repo", + "https://example.com/repo", + "--commit", + "abcdef1234567890abcdef1234567890abcdef12", + "--reply-to", + &parent, + ], + ) + .await + .unwrap(); + assert_ne!(diff.kind.as_u16(), 9); + assert!(status_tags_of(&diff).is_empty()); +} + +// --------------------------------------------------------------------------- +// messages deliver (managed journal) +// --------------------------------------------------------------------------- + +fn journal_record(dir: &Path) -> serde_json::Value { + let journal: serde_json::Value = + serde_json::from_slice(&fs::read(dir.join("journal.json")).unwrap()).unwrap(); + journal["entries"] + .as_object() + .unwrap() + .values() + .next() + .unwrap() + .clone() +} + +#[tokio::test] +async fn managed_status_retry_keeps_the_originally_prepared_tags() { + let (r, url, journal) = fixture().await; + let mut producer = Producer::start(&url, &r.keys); + let scope = thread_scope(&r.channel, &r.parent.id); + let transition_a = begin_a(&mut producer, &scope); + let key = Uuid::new_v4().to_string(); + + // The submit response is lost: the prepared event is journaled. + *r.ack.lock().unwrap() = "malformed"; + assert_eq!( + deliver_with_status(&r, &url, journal.path(), &key, "final", Some(&producer.dir)).await, + 2 + ); + let saved: Event = + serde_json::from_value(journal_record(journal.path())["event"].clone()).unwrap(); + assert_eq!(status_tags_of(&saved), expected_a(transition_a, "thread")); + + // Live status moves on to transition B before the retry. + let transition_b = begin_b(&mut producer, &scope); + *r.ack.lock().unwrap() = "ok"; + assert_eq!( + deliver_with_status( + &r, + &url, + journal.path(), + &key, + "changed", + Some(&producer.dir) + ) + .await, + 0 + ); + { + let bodies = r.bodies.lock().unwrap(); + assert_eq!(bodies.len(), 2); + assert_eq!( + bodies[0], bodies[1], + "retry resubmits the same signed event" + ); + let events = r.events.lock().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].id, saved.id); + assert_eq!( + status_tags_of(&events[0]), + expected_a(transition_a, "thread") + ); + } + + // A new delivery key prepares a new event with the current status. + assert_eq!( + deliver_with_status( + &r, + &url, + journal.path(), + &Uuid::new_v4().to_string(), + "next", + Some(&producer.dir) + ) + .await, + 0 + ); + let events = r.events.lock().unwrap(); + assert_eq!(events.len(), 2); + assert_eq!( + status_tags_of(&events[1]), + expected_b(transition_b, "thread") + ); +} + +#[tokio::test] +async fn managed_status_timestamp_recovery_preserves_original_tags() { + let (r, url, journal) = fixture().await; + let mut producer = Producer::start(&url, &r.keys); + let scope = thread_scope(&r.channel, &r.parent.id); + let transition_a = begin_a(&mut producer, &scope); + let key = Uuid::new_v4().to_string(); + *r.ack.lock().unwrap() = "malformed"; + assert_eq!( + deliver_with_status(&r, &url, journal.path(), &key, "final", Some(&producer.dir)).await, + 2 + ); + + // Age the journaled event past the recovery threshold, keeping its tags. + let path = journal.path().join("journal.json"); + let mut value: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + let record = value["entries"] + .as_object_mut() + .unwrap() + .values_mut() + .next() + .unwrap(); + let first: Event = serde_json::from_value(record["event"].clone()).unwrap(); + let old = EventBuilder::new(first.kind, &first.content) + .tags(first.tags.clone()) + .allow_self_tagging() + .custom_created_at(nostr::Timestamp::from( + nostr::Timestamp::now().as_secs() - 3600, + )) + .sign_with_keys(&r.keys) + .unwrap(); + record["event"] = serde_json::to_value(&old).unwrap(); + fs::write(&path, value.to_string()).unwrap(); + + let transition_b = begin_b(&mut producer, &scope); + *r.ack.lock().unwrap() = "ok"; + // The mock relay has no atomic recovery, so the delivery stays pending; + // what matters is the signed candidate it journaled and submitted. + assert_eq!( + deliver_with_status( + &r, + &url, + journal.path(), + &key, + "changed", + Some(&producer.dir) + ) + .await, + 2 + ); + let record = journal_record(journal.path()); + let original: Event = serde_json::from_value(record["event"].clone()).unwrap(); + let stamp: buzz_core::managed_delivery::ReportStamp = + serde_json::from_value(record["recovery_candidate"].clone()).unwrap(); + let candidate = stamp.restore(&original).unwrap(); + assert_eq!( + status_tags_of(&original), + expected_a(transition_a, "thread") + ); + assert_eq!( + status_tags_of(&candidate), + expected_a(transition_a, "thread") + ); + let wrapper: Event = serde_json::from_slice(r.bodies.lock().unwrap().last().unwrap()).unwrap(); + let request: buzz_core::managed_delivery::RecoveryRequest = + serde_json::from_str(&wrapper.content).unwrap(); + assert_eq!(request.original, original); + assert!(status_tags_of(&wrapper).is_empty()); + + // A fresh key still captures the current transition. + assert_eq!( + deliver_with_status( + &r, + &url, + journal.path(), + &Uuid::new_v4().to_string(), + "next", + Some(&producer.dir) + ) + .await, + 0 + ); + let latest: Event = serde_json::from_slice(r.bodies.lock().unwrap().last().unwrap()).unwrap(); + assert_eq!(status_tags_of(&latest), expected_b(transition_b, "thread")); +} diff --git a/crates/buzz-cli/tests/session_status_env.rs b/crates/buzz-cli/tests/session_status_env.rs new file mode 100644 index 00000000000..65b2e03fd91 --- /dev/null +++ b/crates/buzz-cli/tests/session_status_env.rs @@ -0,0 +1,177 @@ +//! The `buzz` binary takes its session-status directory from +//! `BUZZ_SESSION_STATUS_DIR`, the variable the agent harness sets for its +//! agents and managed MCP server. +//! +//! Runs the real binary against a local mock relay; the status directory is +//! laid out and locked the way the harness does it. + +use std::fs::{self, File, OpenOptions}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use axum::{extract::State, routing::post, Json, Router}; +use buzz_core::session_status::{ + canonical_identity, generation_lock_path, scope_file_name, SessionState, SessionStatus, + StatusEnvelope, StatusReason, StatusScope, GENERATIONS_DIR, INSTANCE_LOCK_FILE, +}; +use fs2::FileExt; +use nostr::{Event, Keys}; +use uuid::Uuid; + +type Captured = Arc>>; + +async fn publish( + State(captured): State, + body: axum::body::Bytes, +) -> Json { + let event: Event = serde_json::from_slice(&body).unwrap(); + event.verify().unwrap(); + let id = event.id.to_hex(); + captured.lock().unwrap().push(event); + Json(serde_json::json!({"accepted": true, "event_id": id, "message": "saved"})) +} + +async fn relay() -> (String, Captured) { + let captured = Captured::default(); + let app = Router::new() + .route("/query", post(|| async { Json(serde_json::json!([])) })) + .route("/events", post(publish)) + .with_state(captured.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (url, captured) +} + +fn held_lock(path: &Path) -> File { + let file = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(path) + .unwrap(); + file.try_lock_exclusive().unwrap(); + file +} + +async fn send( + url: &str, + captured: &Captured, + keys: &Keys, + channel: &str, + status_dir: Option<&Path>, +) -> Event { + let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_buzz")); + command + .env_clear() + .env("BUZZ_RELAY_URL", url) + .env("BUZZ_PRIVATE_KEY", keys.secret_key().to_secret_hex()) + .args([ + "messages", + "send", + "--channel", + channel, + "--content", + "hello", + ]); + if let Some(dir) = status_dir { + command.env("BUZZ_SESSION_STATUS_DIR", dir); + } + let output = tokio::task::spawn_blocking(move || command.output().unwrap()) + .await + .unwrap(); + assert!( + output.status.success(), + "buzz failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let id = stdout["event_id"].as_str().unwrap(); + captured + .lock() + .unwrap() + .iter() + .find(|e| e.id.to_hex() == id) + .cloned() + .expect("relay received the event") +} + +fn status_tags(event: &Event) -> Vec> { + event + .tags + .iter() + .map(|t| t.as_slice().to_vec()) + .filter(|t| { + t.first() + .is_some_and(|name| name.starts_with("buzz-session")) + }) + .collect() +} + +#[tokio::test] +async fn binary_reads_the_status_directory_from_the_environment() { + let (url, captured) = relay().await; + let keys = Keys::generate(); + let channel = Uuid::new_v4(); + + // Harness-shaped instance directory with live locks. + let root = tempfile::tempdir().unwrap(); + let instance_id = Uuid::new_v4(); + let staging = root.path().join(format!(".staging-{instance_id}")); + fs::create_dir(&staging).unwrap(); + let _instance = held_lock(&staging.join(INSTANCE_LOCK_FILE)); + fs::create_dir(staging.join(GENERATIONS_DIR)).unwrap(); + let dir = root.path().join(instance_id.to_string()); + fs::rename(&staging, &dir).unwrap(); + let generation = Uuid::new_v4(); + let _generation = held_lock(&dir.join(generation_lock_path(generation))); + let scope = StatusScope { + channel_id: channel, + thread_root: None, + }; + let transition = Uuid::new_v4(); + let envelope = StatusEnvelope::new( + canonical_identity(&url, &keys.public_key().to_hex()).unwrap(), + instance_id, + scope.clone(), + generation, + SessionStatus { + state: SessionState::New, + reason: StatusReason::FirstInThread, + transition_id: transition, + occupancy: None, + history: None, + }, + ); + fs::write( + dir.join(scope_file_name(&scope).unwrap()), + envelope.to_snapshot_bytes().unwrap(), + ) + .unwrap(); + + let channel = channel.to_string(); + let with_env = send(&url, &captured, &keys, &channel, Some(&dir)).await; + assert_eq!( + status_tags(&with_env), + vec![ + vec![ + "buzz-session".to_owned(), + "new".into(), + "first_in_thread".into(), + String::new() + ], + vec![ + "buzz-session-info".to_owned(), + "1".into(), + transition.to_string(), + "conversation".into(), + String::new(), + String::new(), + "unknown".into(), + ], + ] + ); + + let without_env = send(&url, &captured, &keys, &channel, None).await; + assert!(status_tags(&without_env).is_empty()); +} diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index b0f07eb3057..298797613c5 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -40,6 +40,10 @@ pub mod presence; pub mod private_managed_agent; /// Canonical relay runtime identities. pub mod relay; +/// Shared session-control command and result contract. +pub mod session_control; +/// Shared session-status contract: snapshot format, identity, scope names and wire tags. +pub mod session_status; /// Tenant identity — the server-resolved community key carried on scoped paths. pub mod tenant; /// NIP-CW thread-mode normalized newest-first window contract. diff --git a/crates/buzz-core/src/session_control.rs b/crates/buzz-core/src/session_control.rs new file mode 100644 index 00000000000..ef73f519461 --- /dev/null +++ b/crates/buzz-core/src/session_control.rs @@ -0,0 +1,382 @@ +//! Pure session-control grammar and bounded public reaction/action metadata. +//! +//! These values describe agent self-reports; they never grant permissions. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Name of the supporting kind-7 reaction tag. +pub const SESSION_RESULT_TAG: &str = "buzz-session-result"; +/// Name of the optional manual-transition actor tag. +pub const SESSION_ACTION_TAG: &str = "buzz-session-action"; +/// Maximum signed command age in seconds, including the boundary. +pub const CONTROL_MAX_AGE_SECS: u64 = 900; +/// Maximum accepted future clock skew in seconds. +pub const CONTROL_FUTURE_SKEW_SECS: u64 = 30; + +/// Cold-start history requested by an explicit restart. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RestartDepth { + /// Only the next trigger and its assignment. + None, + /// Root, assignment and the configured recent window. + Recent, + /// The current target thread (conversation for DMs), within history budgets. + All, +} + +impl RestartDepth { + /// Stable reason suffix. + pub fn as_wire(self) -> &'static str { + match self { + Self::None => "none", + Self::Recent => "recent", + Self::All => "all", + } + } +} + +/// Exact kind-9 session command, after trimming external whitespace. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionCommand { + /// Compact an existing session. + Compact, + /// Fence the current session and request a cold start. + Restart(RestartDepth), +} + +impl SessionCommand { + /// Parse only the five accepted spellings. Targeting and authorization are + /// separate gates and must be checked by the caller. + pub fn parse(kind: u16, content: &str) -> Option { + if kind != 9 { + return None; + } + match content.trim() { + "!compact" => Some(Self::Compact), + "!restart" | "!rotate" => Some(Self::Restart(RestartDepth::Recent)), + "!restart none" => Some(Self::Restart(RestartDepth::None)), + "!restart all" => Some(Self::Restart(RestartDepth::All)), + _ => None, + } + } + + /// Canonical command text; recent restarts use `!restart`. + pub fn as_content(self) -> &'static str { + match self { + Self::Compact => "!compact", + Self::Restart(RestartDepth::None) => "!restart none", + Self::Restart(RestartDepth::Recent) => "!restart", + Self::Restart(RestartDepth::All) => "!restart all", + } + } +} + +/// Signed command identity and effective author from the existing authorization gate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ControlOrigin { + /// Canonical signed event ID (64 lowercase hex characters). + pub command_event_id: String, + /// Effective author's public key (64 lowercase hex characters). + pub actor_pubkey: String, +} + +impl ControlOrigin { + /// Reject malformed or noncanonical identities. + pub fn validate(&self) -> Result<(), crate::session_status::StatusError> { + if is_canonical_id(&self.command_event_id) && is_canonical_id(&self.actor_pubkey) { + Ok(()) + } else { + Err(crate::session_status::StatusError::InvalidOrigin) + } + } +} + +/// Closed public failure vocabulary; adapter errors and paths never go on wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ControlFailure { + /// No existing resumable session. + NoSession, + /// Runtime has no supported compact operation. + Unsupported, + /// Conversation scope could not be verified. + ScopeUnavailable, + /// Queue, receipt capacity or target is occupied. + Busy, + /// Target generation changed. + StaleTarget, + /// Accepted request expired before execution. + StaleRequest, + /// Durable storage failed. + StoreUnavailable, + /// Existing session could not resume. + ResumeFailed, + /// Adapter reported compact failure. + CompactFailed, + /// Operation was cancelled. + Cancelled, + /// Deadline elapsed. + Timeout, + /// Completion could not be confirmed. + Unconfirmed, + /// A restart superseded the pending compact. + Superseded, + /// Internal failure without public diagnostics. + Internal, +} + +impl ControlFailure { + /// Parse exactly the safe public vocabulary. + pub fn from_wire(wire: &str) -> Option { + Some(match wire { + "no_session" => Self::NoSession, + "unsupported" => Self::Unsupported, + "scope_unavailable" => Self::ScopeUnavailable, + "busy" => Self::Busy, + "stale_target" => Self::StaleTarget, + "stale_request" => Self::StaleRequest, + "store_unavailable" => Self::StoreUnavailable, + "resume_failed" => Self::ResumeFailed, + "compact_failed" => Self::CompactFailed, + "cancelled" => Self::Cancelled, + "timeout" => Self::Timeout, + "unconfirmed" => Self::Unconfirmed, + "superseded" => Self::Superseded, + "internal" => Self::Internal, + _ => return None, + }) + } + + /// Safe code for public metadata. + pub fn as_wire(self) -> &'static str { + match self { + Self::NoSession => "no_session", + Self::Unsupported => "unsupported", + Self::ScopeUnavailable => "scope_unavailable", + Self::Busy => "busy", + Self::StaleTarget => "stale_target", + Self::StaleRequest => "stale_request", + Self::StoreUnavailable => "store_unavailable", + Self::ResumeFailed => "resume_failed", + Self::CompactFailed => "compact_failed", + Self::Cancelled => "cancelled", + Self::Timeout => "timeout", + Self::Unconfirmed => "unconfirmed", + Self::Superseded => "superseded", + Self::Internal => "internal", + } + } +} + +/// Terminal operation outcome; pending presentation is deliberately separate. +pub type ControlOutcome = Result<(), ControlFailure>; + +/// Intermediate or terminal presentation of a command result on wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ControlResult { + /// Accepted restart waiting for foreign work to stop. + Pending, + /// Confirmed terminal outcome. + Terminal(ControlOutcome), +} + +impl ControlResult { + /// Standard kind-7 reaction content matching this presentation. + pub fn reaction(self) -> &'static str { + match self { + Self::Pending => "⏳", + Self::Terminal(Ok(())) => "✅", + Self::Terminal(Err(_)) => "❌", + } + } + + /// Combine reports for the same command and reporting agent. Terminal + /// results override pending in either order; conflicting terminals are unconfirmed. + pub fn merge(self, other: Self) -> Self { + match (self, other) { + (Self::Pending, value) | (value, Self::Pending) => value, + (a, b) if a == b => a, + _ => Self::Terminal(Err(ControlFailure::Unconfirmed)), + } + } +} + +/// Optional structured result attached to a standard reaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionResult { + /// Canonical signed command event ID. + pub command_id: String, + /// Presentation consistent with the reaction content. + pub result: ControlResult, +} + +impl SessionResult { + /// Encode the exact five-field v1 supporting tag. + pub fn event_tag(&self) -> Option> { + if !is_canonical_id(&self.command_id) { + return None; + } + let (outcome, code) = match self.result { + ControlResult::Pending => ("pending", ""), + ControlResult::Terminal(Ok(())) => ("ok", ""), + ControlResult::Terminal(Err(code)) => ("error", code.as_wire()), + }; + Some(vec![ + SESSION_RESULT_TAG.into(), + "1".into(), + self.command_id.clone(), + outcome.into(), + code.into(), + ]) + } + + /// Strictly parse all matching tags. Identical duplicates are allowed; + /// malformed/conflicting duplicates or inconsistent reaction content reject enrichment. + pub fn from_tags(content: &str, tags: &[Vec]) -> Option { + let mut parsed = None; + for tag in tags + .iter() + .filter(|t| t.first().is_some_and(|s| s == SESSION_RESULT_TAG)) + { + if tag.len() != 5 || tag[1] != "1" || !is_canonical_id(&tag[2]) { + return None; + } + let result = match (tag[3].as_str(), tag[4].as_str()) { + ("pending", "") => ControlResult::Pending, + ("ok", "") => ControlResult::Terminal(Ok(())), + ("error", code) => ControlResult::Terminal(Err(ControlFailure::from_wire(code)?)), + _ => return None, + }; + if result.reaction() != content { + return None; + } + let next = Self { + command_id: tag[2].clone(), + result, + }; + if parsed.as_ref().is_some_and(|prior| *prior != next) { + return None; + } + parsed = Some(next); + } + parsed + } +} + +/// Actor enrichment for one validated manual status transition. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ControlAction { + /// Display transition ID matching the supporting info tag. + pub transition_id: Uuid, + /// Agent self-report; never permission evidence. + pub origin: ControlOrigin, +} + +impl ControlAction { + /// Encode the exact five-field v1 actor tag after validating both IDs. + pub fn event_tag(&self) -> Option> { + self.origin.validate().ok()?; + Some(vec![ + SESSION_ACTION_TAG.into(), + "1".into(), + self.transition_id.to_string(), + self.origin.command_event_id.clone(), + self.origin.actor_pubkey.clone(), + ]) + } + + /// Read actor metadata only beside valid matching primary/info tags for a + /// manual transition. Any malformed/conflicting duplicate removes enrichment. + pub fn from_tags(tags: &[Vec]) -> Option { + let primary = unique_tag(tags, crate::session_status::SESSION_TAG)?; + let info = unique_tag(tags, crate::session_status::SESSION_INFO_TAG)?; + if primary.len() != 4 + || !matches!( + primary[1].as_str(), + "new" | "resumed" | "compacted" | "restarted" + ) + { + return None; + } + let reason = crate::session_status::StatusReason::from_wire(&primary[2]).ok()?; + if !reason.is_manual() + || (!primary[3].is_empty() + && primary[3] + .parse::() + .ok() + .filter(|p| *p <= 100) + .map(|p| p.to_string()) + != Some(primary[3].clone())) + { + return None; + } + if info.len() != 7 + || info[1] != "1" + || !matches!(info[3].as_str(), "thread" | "conversation") + { + return None; + } + let transition_id = canonical_uuid(&info[2])?; + let count = |wire: &str| -> Option> { + if wire.is_empty() { + Some(None) + } else { + let n = wire.parse::().ok()?; + (n.to_string() == wire).then_some(Some(n)) + } + }; + let read = count(&info[4])?; + let total = count(&info[5])?; + if read.zip(total).is_some_and(|(r, t)| r > t) { + return None; + } + match info[6].as_str() { + "exact" if read.is_some() && total.is_some() => {} + "lower_bound" => {} + "unknown" if total.is_none() => {} + _ => return None, + } + let action = unique_tag(tags, SESSION_ACTION_TAG)?; + if action.len() != 5 || action[1] != "1" || canonical_uuid(&action[2])? != transition_id { + return None; + } + let origin = ControlOrigin { + command_event_id: action[3].clone(), + actor_pubkey: action[4].clone(), + }; + origin.validate().ok()?; + Some(Self { + transition_id, + origin, + }) + } +} + +fn unique_tag<'a>(tags: &'a [Vec], name: &str) -> Option<&'a Vec> { + let mut matching = tags.iter().filter(|t| t.first().is_some_and(|s| s == name)); + let first = matching.next()?; + matching.all(|t| t == first).then_some(first) +} + +fn canonical_uuid(wire: &str) -> Option { + let id = Uuid::parse_str(wire).ok()?; + (id.to_string() == wire).then_some(id) +} + +/// Whether an event ID or public-key encoding is canonical lowercase hex. +pub fn is_canonical_id(id: &str) -> bool { + id.len() == 64 + && id + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Freshness gate for signed controls; ordinary messages do not use this rule. +pub fn control_is_fresh(created_at: u64, now: u64) -> bool { + created_at <= now.saturating_add(CONTROL_FUTURE_SKEW_SECS) + && now <= created_at.saturating_add(CONTROL_MAX_AGE_SECS) +} diff --git a/crates/buzz-core/src/session_status.rs b/crates/buzz-core/src/session_status.rs new file mode 100644 index 00000000000..7cbfc8bfc2c --- /dev/null +++ b/crates/buzz-core/src/session_status.rs @@ -0,0 +1,691 @@ +//! Shared session-status contract. +//! +//! The agent harness writes small per-scope status snapshots, the CLI reads +//! them and attaches a pair of tags to outgoing kind-9 messages, and Desktop +//! renders those tags. This module holds the pure pieces all three share: +//! canonical status identity, scope-to-file naming, the snapshot JSON format +//! (version [`STATUS_FORMAT_VERSION`]), validation, and the wire tags. +//! +//! Nothing here touches the filesystem or the network. Status is display +//! metadata: it carries no transcript, prompt, key, provider session ID, lease +//! or local path, and reading it grants no authority. +//! +//! # Instance directory layout +//! +//! The harness owns one directory per running instance (see +//! [`INSTANCE_LOCK_FILE`]). Inside it: +//! +//! - `instance.lock` — held by the harness for as long as the instance lives; +//! - `generations/.lock` — see [`generation_lock_path`], held +//! while a snapshot generation is current; +//! - one snapshot file per scope — see [`scope_file_name`]. +//! +//! # Wire tags +//! +//! [`SessionStatus::event_tags`] produces two tags: +//! +//! - `["buzz-session", "", "", ""]`, where `` +//! is the empty string when context occupancy is unavailable (never `0`); +//! - `["buzz-session-info", "1", "", "thread|conversation", +//! "", "", "exact|lower_bound|unknown"]`. +//! +//! The info tag's mode carries the completeness of the cold-start history: +//! +//! - `exact`: the history was complete; `` and `` are both set; +//! - `lower_bound`: the history is known to be incomplete, or `` is +//! present but only a proven lower bound. `` may be empty (readers +//! then say earlier history may be omitted) and `` may be empty when +//! event identities are missing; +//! - `unknown`: no completeness claim: there is no total and the history is +//! not known to be incomplete. `` may still be set. + +use std::fmt; + +use crate::session_control::{ControlAction, ControlOrigin, RestartDepth}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use url::Url; +use uuid::Uuid; + +/// Snapshot format version written by this crate; readers also accept v1. +pub const STATUS_FORMAT_VERSION: u8 = 2; +/// Maximum accepted or produced snapshot size in bytes (16 KiB). +pub const MAX_SNAPSHOT_BYTES: usize = 16 * 1024; +/// Maximum length of the wire reason in ASCII bytes. +pub const MAX_REASON_BYTES: usize = 96; +/// Name of the file, inside an instance directory, held while it is alive. +pub const INSTANCE_LOCK_FILE: &str = "instance.lock"; +/// Name of the instance subdirectory that holds generation lock files. +pub const GENERATIONS_DIR: &str = "generations"; +/// Name of the primary status tag. +pub const SESSION_TAG: &str = "buzz-session"; +/// Name of the supporting status tag. +pub const SESSION_INFO_TAG: &str = "buzz-session-info"; +/// Version field of the supporting tag; independent of [`STATUS_FORMAT_VERSION`]. +pub const SESSION_INFO_VERSION: &str = "1"; + +const RESUME_FAILED_PREFIX: &str = "resume_failed:"; +const THREAD_ROOT_HEX_LEN: usize = 64; + +/// Errors from status validation, naming and parsing. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum StatusError { + /// The relay URL is not usable as a status identity. + #[error("invalid relay URL for status identity")] + InvalidRelay, + /// The agent key is not a valid public key. + #[error("invalid agent public key")] + InvalidAgent, + /// A thread root is not 64 lowercase hex characters. + #[error("thread root must be 64 lowercase hex characters")] + InvalidThreadRoot, + /// A reason is not in the known, bounded vocabulary. + #[error("unknown or unsafe status reason")] + InvalidReason, + /// Manual transition origin is malformed or forbidden by the envelope. + #[error("invalid manual transition origin")] + InvalidOrigin, + /// Occupancy has a zero size. + #[error("context size must be positive")] + InvalidOccupancy, + /// History counts are inconsistent. + #[error("inconsistent history coverage")] + InvalidHistory, + /// A snapshot is larger than [`MAX_SNAPSHOT_BYTES`]. + #[error("status snapshot exceeds the size limit")] + TooLarge, + /// A snapshot uses a format version this crate does not understand. + #[error("unsupported status snapshot version")] + UnsupportedVersion, + /// A snapshot is not valid JSON of the expected shape. + #[error("malformed status snapshot")] + Malformed, +} + +// --------------------------------------------------------------------------- +// Identity +// --------------------------------------------------------------------------- + +/// Canonical `(relay, agent)` identity used to match status to a sender. +/// +/// Built only by [`canonical_identity`]. This is a status-lookup identity and +/// does not replace the durable session store's own namespace helper. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StatusIdentity { + /// Canonical relay URL (`ws`/`wss` form). + pub relay: String, + /// Agent public key as lowercase hex. + pub agent: String, +} + +/// Canonicalize a relay URL and agent key into a [`StatusIdentity`]. +/// +/// URL serialization normalizes host case, default ports and the root slash. +/// `http`/`https` are folded onto `ws`/`wss` because a CLI may reach the same +/// relay over either transport. Everything else is preserved: each loopback +/// spelling stays distinct, as do nondefault ports, paths and queries. +/// Credentials, fragments, other schemes and invalid keys are rejected. +pub fn canonical_identity(relay: &str, agent: &str) -> Result { + let mut url = Url::parse(relay.trim()).map_err(|_| StatusError::InvalidRelay)?; + if url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(StatusError::InvalidRelay); + } + let websocket = match url.scheme() { + "ws" | "http" => "ws", + "wss" | "https" => "wss", + _ => return Err(StatusError::InvalidRelay), + }; + if url.scheme() != websocket { + url.set_scheme(websocket) + .map_err(|()| StatusError::InvalidRelay)?; + } + let mut relay = url.to_string(); + if url.path() == "/" && url.query().is_none() { + relay.pop(); + } + let agent = nostr::PublicKey::from_hex(agent) + .map_err(|_| StatusError::InvalidAgent)? + .to_hex(); + Ok(StatusIdentity { relay, agent }) +} + +impl StatusIdentity { + /// Whether this identity is already in canonical form. + pub fn validate(&self) -> Result<(), StatusError> { + if canonical_identity(&self.relay, &self.agent)? == *self { + Ok(()) + } else { + Err(StatusError::InvalidRelay) + } + } +} + +// --------------------------------------------------------------------------- +// Scope and file names +// --------------------------------------------------------------------------- + +/// The conversation a status belongs to. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StatusScope { + /// Channel (or DM) the session runs in. + pub channel_id: Uuid, + /// Thread root event ID (64 lowercase hex); `None` is conversation scope. + pub thread_root: Option, +} + +impl StatusScope { + /// Reject a thread root that is not 64 lowercase hex characters. + pub fn validate(&self) -> Result<(), StatusError> { + match &self.thread_root { + None => Ok(()), + Some(root) + if root.len() == THREAD_ROOT_HEX_LEN + && root + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) => + { + Ok(()) + } + Some(_) => Err(StatusError::InvalidThreadRoot), + } + } + + /// `thread` when a root is present, `conversation` otherwise. + pub fn kind(&self) -> &'static str { + if self.thread_root.is_some() { + "thread" + } else { + "conversation" + } + } +} + +/// File name of a scope's snapshot inside an instance directory. +/// +/// The name is built from a validated UUID and validated hex only, so it can +/// never contain a path separator or traversal component. +pub fn scope_file_name(scope: &StatusScope) -> Result { + scope.validate()?; + let channel = scope.channel_id.as_hyphenated(); + Ok(match &scope.thread_root { + Some(root) => format!("{channel}.thread-{root}.json"), + None => format!("{channel}.conversation.json"), + }) +} + +/// Relative path (with `/` separators) of a generation's lock file inside an +/// instance directory: `generations/.lock`. +pub fn generation_lock_path(generation: Uuid) -> String { + format!("{GENERATIONS_DIR}/{}.lock", generation.as_hyphenated()) +} + +// --------------------------------------------------------------------------- +// Occupancy and history +// --------------------------------------------------------------------------- + +/// Context window usage as reported by the adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ContextOccupancy { + /// Tokens currently in context. + pub used: u64, + /// Context window size; must be positive. + pub size: u64, +} + +impl ContextOccupancy { + /// Build an occupancy, or `None` when `size` is zero. + pub fn new(used: u64, size: u64) -> Option { + (size > 0).then_some(Self { used, size }) + } + + /// Build an occupancy from loosely typed adapter values. + /// + /// Missing, negative, nonintegral, non-numeric or oversized inputs yield + /// `None`, as does a zero size. + pub fn from_json( + used: Option<&serde_json::Value>, + size: Option<&serde_json::Value>, + ) -> Option { + Self::new(json_count(used?)?, json_count(size?)?) + } + + /// `min(100, floor(100 * used / size))`, or `None` when `size` is zero. + pub fn percent(&self) -> Option { + if self.size == 0 { + return None; + } + if self.used >= self.size { + return Some(100); + } + let percent = u128::from(self.used) * 100 / u128::from(self.size); + Some(u8::try_from(percent).unwrap_or(100)) + } +} + +/// Largest integer an `f64` represents exactly. +const MAX_EXACT_F64_INTEGER: f64 = 9_007_199_254_740_992.0; + +fn json_count(value: &serde_json::Value) -> Option { + let number = value.as_number()?; + if let Some(count) = number.as_u64() { + return Some(count); + } + let float = number.as_f64()?; + (float.is_finite() && float >= 0.0 && float.fract() == 0.0 && float <= MAX_EXACT_F64_INTEGER) + .then_some(float as u64) +} + +/// How much visible history a cold-started session was given. +/// +/// Counts concern messages supplied in the cold-start prompt only; they do not +/// claim the model understood them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HistoryCoverage { + /// Unique message events actually supplied; `None` when identities are + /// missing, in which case no ratio is shown. + pub read: Option, + /// Messages in the conversation: exact when `exact`, otherwise a proven + /// lower bound; `None` when unavailable. + pub total: Option, + /// Whether `total` is exact. Requires `read`, `total` and complete evidence. + pub exact: bool, + /// Whether the cold-start fetch was known to be incomplete. + pub incomplete: bool, +} + +impl HistoryCoverage { + /// Check count consistency. + pub fn validate(&self) -> Result<(), StatusError> { + if let (Some(read), Some(total)) = (self.read, self.total) { + if total < read { + return Err(StatusError::InvalidHistory); + } + } + if self.exact && (self.read.is_none() || self.total.is_none() || self.incomplete) { + return Err(StatusError::InvalidHistory); + } + Ok(()) + } + + /// Wire fields `(read, total, mode)` for the supporting tag. + /// + /// - `exact`: complete history; read and total are both present; + /// - `lower_bound`: known incomplete, or a total that is not exact (a + /// proven lower bound); total and read may be empty; + /// - `unknown`: no completeness claim (no total, not incomplete). + fn wire(&self) -> (String, String, &'static str) { + let count = |value: Option| value.map(|v| v.to_string()).unwrap_or_default(); + let mode = if self.exact { + "exact" + } else if self.incomplete || self.total.is_some() { + "lower_bound" + } else { + "unknown" + }; + (count(self.read), count(self.total), mode) + } +} + +// --------------------------------------------------------------------------- +// State, reason, status +// --------------------------------------------------------------------------- + +/// Session state vocabulary (closed). +/// +/// Only `New` and `Resumed` are produced today; `Compacted` and `Restarted` +/// are reserved and validated so readers and writers agree on the wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionState { + /// A fresh provider session. + New, + /// A previously stored provider session was resumed. + Resumed, + /// The context was compacted. + Compacted, + /// The session was restarted. + Restarted, +} + +impl SessionState { + /// Stable wire string. + pub fn as_wire(self) -> &'static str { + match self { + Self::New => "new", + Self::Resumed => "resumed", + Self::Compacted => "compacted", + Self::Restarted => "restarted", + } + } +} + +/// Why a session is in its state (bounded, closed vocabulary). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum StatusReason { + /// First session for this conversation. + FirstInThread, + /// Previous worker went idle and slept. + IdleSleep, + /// The harness restarted. + HarnessRestart, + /// The previous worker crashed. + WorkerCrash, + /// A sibling session lost its worker to a manual restart. + WorkerRecycled, + /// A manual restart with the requested cold-start history depth. + UserRestart(RestartDepth), + /// Manual compaction completed. + UserCompact, + /// Automatic compaction completed. + AutoCompact, + /// The stored session was discarded. + Discarded, + /// The session is live in another worker or process. + LiveElsewhere, + /// The adapter cannot resume sessions. + ResumeUnsupported, + /// Resuming failed; the payload is a short safe code (`[a-z0-9_-]`). + ResumeFailed(String), + /// Agent configuration changed. + ConfigChanged, + /// The context window overflowed. + ContextOverflow, + /// The turn limit was reached. + MaxTurns, +} + +impl StatusReason { + /// Whether an actor may be attached to this explicit user transition. + pub fn is_manual(&self) -> bool { + matches!(self, Self::UserRestart(_) | Self::UserCompact) + } + + /// Parse a wire reason; anything outside the vocabulary is rejected. + pub fn from_wire(wire: &str) -> Result { + let reason = match wire { + "first_in_thread" => Self::FirstInThread, + "idle_sleep" => Self::IdleSleep, + "harness_restart" => Self::HarnessRestart, + "worker_crash" => Self::WorkerCrash, + "worker_recycled" => Self::WorkerRecycled, + "user_restart:none" => Self::UserRestart(RestartDepth::None), + "user_restart:recent" => Self::UserRestart(RestartDepth::Recent), + "user_restart:all" => Self::UserRestart(RestartDepth::All), + "user_compact" => Self::UserCompact, + "auto_compact" => Self::AutoCompact, + "discarded" => Self::Discarded, + "live_elsewhere" => Self::LiveElsewhere, + "resume_unsupported" => Self::ResumeUnsupported, + "config_changed" => Self::ConfigChanged, + "context_overflow" => Self::ContextOverflow, + "max_turns" => Self::MaxTurns, + other => match other.strip_prefix(RESUME_FAILED_PREFIX) { + Some(code) => Self::ResumeFailed(code.to_owned()), + None => return Err(StatusError::InvalidReason), + }, + }; + reason.validate()?; + Ok(reason) + } + + /// Stable wire string. Call [`Self::validate`] first for untrusted codes. + pub fn as_wire(&self) -> String { + match self { + Self::FirstInThread => "first_in_thread".into(), + Self::IdleSleep => "idle_sleep".into(), + Self::HarnessRestart => "harness_restart".into(), + Self::WorkerCrash => "worker_crash".into(), + Self::WorkerRecycled => "worker_recycled".into(), + Self::UserRestart(depth) => format!("user_restart:{}", depth.as_wire()), + Self::UserCompact => "user_compact".into(), + Self::AutoCompact => "auto_compact".into(), + Self::Discarded => "discarded".into(), + Self::LiveElsewhere => "live_elsewhere".into(), + Self::ResumeUnsupported => "resume_unsupported".into(), + Self::ConfigChanged => "config_changed".into(), + Self::ContextOverflow => "context_overflow".into(), + Self::MaxTurns => "max_turns".into(), + Self::ResumeFailed(code) => format!("{RESUME_FAILED_PREFIX}{code}"), + } + } + + /// Check that a `resume_failed` code is a short safe token and that the + /// wire form fits [`MAX_REASON_BYTES`]. + pub fn validate(&self) -> Result<(), StatusError> { + if let Self::ResumeFailed(code) = self { + let safe = !code.is_empty() + && code.bytes().all(|b| { + b.is_ascii_digit() || b.is_ascii_lowercase() || b == b'_' || b == b'-' + }); + if !safe || self.as_wire().len() > MAX_REASON_BYTES { + return Err(StatusError::InvalidReason); + } + } + Ok(()) + } +} + +impl fmt::Display for StatusReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.as_wire()) + } +} + +impl TryFrom for StatusReason { + type Error = StatusError; + + fn try_from(wire: String) -> Result { + Self::from_wire(&wire) + } +} + +impl From for String { + fn from(reason: StatusReason) -> Self { + reason.as_wire() + } +} + +impl Serialize for StatusReason { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.as_wire()) + } +} + +impl<'de> Deserialize<'de> for StatusReason { + fn deserialize>(deserializer: D) -> Result { + let wire = String::deserialize(deserializer)?; + Self::from_wire(&wire).map_err(serde::de::Error::custom) + } +} + +/// The reportable state of one session transition. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SessionStatus { + /// Current state. + pub state: SessionState, + /// Why the session is in that state. + pub reason: StatusReason, + /// Random display ID; changes on acquisition/transition, stable across + /// ordinary turns. Not a provider session ID or capability. + pub transition_id: Uuid, + /// Last reported context occupancy; `None` until known. + pub occupancy: Option, + /// Cold-start history coverage, when known. + pub history: Option, +} + +impl SessionStatus { + /// Check reason, occupancy and history. + pub fn validate(&self) -> Result<(), StatusError> { + self.reason.validate()?; + if self.occupancy.is_some_and(|o| o.size == 0) { + return Err(StatusError::InvalidOccupancy); + } + if let Some(history) = &self.history { + history.validate()?; + } + Ok(()) + } + + /// Wire tags for an outgoing message in `scope`. + /// + /// Returns the primary [`SESSION_TAG`] followed by [`SESSION_INFO_TAG`]. + /// The tags contain only vocabulary strings, a percentage, a transition + /// UUID and counts: no path, key, provider ID or identity. + pub fn event_tags(&self, scope: &StatusScope) -> Result>, StatusError> { + self.validate()?; + let percent = self + .occupancy + .and_then(|o| o.percent()) + .map(|p| p.to_string()) + .unwrap_or_default(); + let (read, total, mode) = self + .history + .as_ref() + .map_or((String::new(), String::new(), "unknown"), |h| h.wire()); + Ok(vec![ + vec![ + SESSION_TAG.to_owned(), + self.state.as_wire().to_owned(), + self.reason.as_wire(), + percent, + ], + vec![ + SESSION_INFO_TAG.to_owned(), + SESSION_INFO_VERSION.to_owned(), + self.transition_id.as_hyphenated().to_string(), + scope.kind().to_owned(), + read, + total, + mode.to_owned(), + ], + ]) + } +} + +// --------------------------------------------------------------------------- +// Snapshot envelope +// --------------------------------------------------------------------------- + +/// The snapshot document written by the harness and read by the CLI. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StatusEnvelope { + /// Format version; v1 and [`STATUS_FORMAT_VERSION`] are accepted. + pub version: u8, + /// Who the status belongs to. + pub identity: StatusIdentity, + /// Harness instance that wrote the snapshot. + pub instance_id: Uuid, + /// Conversation the status belongs to. + pub scope: StatusScope, + /// Generation of the scope's status; names its lock file. + pub generation: Uuid, + /// The reportable state. + pub status: SessionStatus, + /// Optional manual-transition origin; forbidden in legacy schema v1. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin: Option, +} + +impl StatusEnvelope { + /// Build a current-version envelope. + pub fn new( + identity: StatusIdentity, + instance_id: Uuid, + scope: StatusScope, + generation: Uuid, + status: SessionStatus, + ) -> Self { + Self { + version: STATUS_FORMAT_VERSION, + identity, + instance_id, + scope, + generation, + status, + origin: None, + } + } + + /// Check version, canonical identity, scope and status. + pub fn validate(&self) -> Result<(), StatusError> { + if !matches!(self.version, 1 | STATUS_FORMAT_VERSION) { + return Err(StatusError::UnsupportedVersion); + } + if let Some(origin) = &self.origin { + if self.version == 1 || !self.status.reason.is_manual() { + return Err(StatusError::InvalidOrigin); + } + origin.validate()?; + } + self.identity.validate()?; + self.scope.validate()?; + self.status.validate() + } + + /// Wire tags for this envelope's own scope. + pub fn event_tags(&self) -> Result>, StatusError> { + self.validate()?; + let mut tags = self.status.event_tags(&self.scope)?; + if let Some(origin) = &self.origin { + tags.push( + ControlAction { + transition_id: self.status.transition_id, + origin: origin.clone(), + } + .event_tag() + .ok_or(StatusError::InvalidOrigin)?, + ); + } + Ok(tags) + } + + /// Serialize after validating, enforcing [`MAX_SNAPSHOT_BYTES`]. + pub fn to_snapshot_bytes(&self) -> Result, StatusError> { + self.validate()?; + let bytes = serde_json::to_vec(self).map_err(|_| StatusError::Malformed)?; + if bytes.len() > MAX_SNAPSHOT_BYTES { + return Err(StatusError::TooLarge); + } + Ok(bytes) + } +} + +/// Parse and validate a snapshot. +/// +/// Rejects oversized input, malformed JSON, unknown versions and any envelope +/// that fails [`StatusEnvelope::validate`]. The version is checked before the +/// remaining fields so a future format reports [`StatusError::UnsupportedVersion`]. +pub fn parse_snapshot(bytes: &[u8]) -> Result { + if bytes.len() > MAX_SNAPSHOT_BYTES { + return Err(StatusError::TooLarge); + } + let value: serde_json::Value = + serde_json::from_slice(bytes).map_err(|_| StatusError::Malformed)?; + match value.get("version").and_then(serde_json::Value::as_u64) { + Some(1) if value.get("origin").is_some() => return Err(StatusError::InvalidOrigin), + Some(1) => {} + Some(v) if v == u64::from(STATUS_FORMAT_VERSION) => {} + Some(_) => return Err(StatusError::UnsupportedVersion), + None => return Err(StatusError::Malformed), + } + let envelope: StatusEnvelope = + serde_json::from_value(value).map_err(|_| StatusError::Malformed)?; + envelope.validate()?; + Ok(envelope) +} + +#[cfg(test)] +#[path = "session_status_tests.rs"] +mod tests; diff --git a/crates/buzz-core/src/session_status_tests.rs b/crates/buzz-core/src/session_status_tests.rs new file mode 100644 index 00000000000..70cdb8f41cd --- /dev/null +++ b/crates/buzz-core/src/session_status_tests.rs @@ -0,0 +1,680 @@ +use super::*; +use serde_json::json; + +fn agent() -> String { + nostr::Keys::generate().public_key().to_hex() +} + +fn root() -> String { + "ab".repeat(32) +} + +fn status() -> SessionStatus { + SessionStatus { + state: SessionState::Resumed, + reason: StatusReason::IdleSleep, + transition_id: Uuid::new_v4(), + occupancy: ContextOccupancy::new(64, 100), + history: None, + } +} + +fn envelope() -> StatusEnvelope { + StatusEnvelope::new( + canonical_identity("ws://relay.example", &agent()).unwrap(), + Uuid::new_v4(), + StatusScope { + channel_id: Uuid::new_v4(), + thread_root: Some(root()), + }, + Uuid::new_v4(), + status(), + ) +} + +// -- occupancy --------------------------------------------------------- + +#[test] +fn percent_table() { + let cases: &[(u64, u64, Option)] = &[ + (64, 100, Some(64)), + (80, 100, Some(80)), + (0, 100, Some(0)), + (79, 100, Some(79)), + (1, 3, Some(33)), + (2, 3, Some(66)), + (5, 0, None), + (0, 0, None), + (101, 100, Some(100)), + (u64::MAX, 1, Some(100)), + (u64::MAX, u64::MAX, Some(100)), + (u64::MAX - 1, u64::MAX, Some(99)), + (u64::MAX / 2, u64::MAX, Some(49)), + ]; + for &(used, size, expected) in cases { + let occupancy = ContextOccupancy { used, size }; + assert_eq!(occupancy.percent(), expected, "used={used} size={size}"); + } +} + +#[test] +fn occupancy_constructor_rejects_zero_size() { + assert_eq!(ContextOccupancy::new(1, 0), None); + assert_eq!( + ContextOccupancy::new(1, 2), + Some(ContextOccupancy { used: 1, size: 2 }) + ); +} + +#[test] +fn occupancy_from_json_rejects_unusable_inputs() { + let ok = ContextOccupancy::from_json(Some(&json!(64)), Some(&json!(100))); + assert_eq!(ok, ContextOccupancy::new(64, 100)); + let integral_float = ContextOccupancy::from_json(Some(&json!(64.0)), Some(&json!(100.0))); + assert_eq!(integral_float, ContextOccupancy::new(64, 100)); + let bad: &[(Option, Option)] = &[ + (None, Some(json!(100))), + (Some(json!(1)), None), + (Some(json!(-1)), Some(json!(100))), + (Some(json!(1)), Some(json!(-100))), + (Some(json!(1.5)), Some(json!(100))), + (Some(json!(1)), Some(json!(100.25))), + (Some(json!("64")), Some(json!(100))), + (Some(json!(null)), Some(json!(100))), + (Some(json!(1)), Some(json!(0))), + (Some(json!(1e30)), Some(json!(100))), + ]; + for (used, size) in bad { + assert_eq!( + ContextOccupancy::from_json(used.as_ref(), size.as_ref()), + None, + "used={used:?} size={size:?}" + ); + } +} + +// -- tags -------------------------------------------------------------- + +#[test] +fn primary_tag_carries_percent_and_empty_when_unavailable() { + let scope = StatusScope { + channel_id: Uuid::new_v4(), + thread_root: None, + }; + let mut s = status(); + let tags = s.event_tags(&scope).unwrap(); + assert_eq!(tags[0], ["buzz-session", "resumed", "idle_sleep", "64"]); + + s.occupancy = ContextOccupancy::new(0, 100); + assert_eq!(s.event_tags(&scope).unwrap()[0][3], "0"); + + s.occupancy = None; + let tags = s.event_tags(&scope).unwrap(); + assert_eq!(tags[0], ["buzz-session", "resumed", "idle_sleep", ""]); + + // A directly constructed zero-size occupancy is rejected, never shown as 0%. + s.occupancy = Some(ContextOccupancy { used: 3, size: 0 }); + assert_eq!(s.event_tags(&scope), Err(StatusError::InvalidOccupancy)); +} + +#[test] +fn info_tag_encodes_each_history_mode() { + let thread = StatusScope { + channel_id: Uuid::new_v4(), + thread_root: Some(root()), + }; + let conversation = StatusScope { + channel_id: thread.channel_id, + thread_root: None, + }; + let mut s = status(); + let id = s.transition_id.to_string(); + let info = |s: &SessionStatus, scope: &StatusScope| s.event_tags(scope).unwrap()[1].clone(); + + s.history = Some(HistoryCoverage { + read: Some(12), + total: Some(12), + exact: true, + incomplete: false, + }); + assert_eq!( + info(&s, &thread), + ["buzz-session-info", "1", &id, "thread", "12", "12", "exact"] + ); + + s.history = Some(HistoryCoverage { + read: Some(12), + total: Some(40), + exact: false, + incomplete: false, + }); + assert_eq!( + info(&s, &conversation), + [ + "buzz-session-info", + "1", + &id, + "conversation", + "12", + "40", + "lower_bound" + ] + ); + + // Known incomplete, total unavailable: lower_bound with empty total. + s.history = Some(HistoryCoverage { + read: Some(12), + total: None, + exact: false, + incomplete: true, + }); + assert_eq!( + info(&s, &thread), + [ + "buzz-session-info", + "1", + &id, + "thread", + "12", + "", + "lower_bound" + ] + ); + + // Known incomplete and no event identities: still lower_bound. + s.history = Some(HistoryCoverage { + read: None, + total: None, + exact: false, + incomplete: true, + }); + assert_eq!( + info(&s, &thread), + [ + "buzz-session-info", + "1", + &id, + "thread", + "", + "", + "lower_bound" + ] + ); + + // Incomplete with a total: the total is a lower bound too. + s.history = Some(HistoryCoverage { + read: Some(12), + total: Some(30), + exact: false, + incomplete: true, + }); + assert_eq!( + info(&s, &thread), + [ + "buzz-session-info", + "1", + &id, + "thread", + "12", + "30", + "lower_bound" + ] + ); + + // Missing event identities with a proven lower-bound total. + s.history = Some(HistoryCoverage { + read: None, + total: Some(7), + exact: false, + incomplete: false, + }); + assert_eq!( + info(&s, &thread), + [ + "buzz-session-info", + "1", + &id, + "thread", + "", + "7", + "lower_bound" + ] + ); + + // No completeness claim: no total, not incomplete, not exact. + s.history = Some(HistoryCoverage { + read: Some(12), + total: None, + exact: false, + incomplete: false, + }); + assert_eq!( + info(&s, &thread), + ["buzz-session-info", "1", &id, "thread", "12", "", "unknown"] + ); + s.history = Some(HistoryCoverage { + read: None, + total: None, + exact: false, + incomplete: false, + }); + assert_eq!( + info(&s, &thread), + ["buzz-session-info", "1", &id, "thread", "", "", "unknown"] + ); + + s.history = None; + assert_eq!( + info(&s, &thread), + ["buzz-session-info", "1", &id, "thread", "", "", "unknown"] + ); + assert_eq!(SESSION_INFO_VERSION, "1"); +} + +#[test] +fn inconsistent_history_is_rejected() { + let bad = [ + HistoryCoverage { + read: Some(5), + total: Some(4), + exact: false, + incomplete: false, + }, + HistoryCoverage { + read: Some(5), + total: Some(5), + exact: true, + incomplete: true, + }, + HistoryCoverage { + read: Some(5), + total: None, + exact: true, + incomplete: false, + }, + HistoryCoverage { + read: None, + total: Some(5), + exact: true, + incomplete: false, + }, + ]; + let scope = StatusScope { + channel_id: Uuid::new_v4(), + thread_root: None, + }; + for history in bad { + let mut s = status(); + s.history = Some(history); + assert_eq!( + s.event_tags(&scope), + Err(StatusError::InvalidHistory), + "{history:?}" + ); + } +} + +#[test] +fn tags_leak_no_identity_path_or_provider_id() { + let private = "/home/someone/private-dir"; + let relay = format!("ws://relay.example/{}?token={private}", "base"); + let agent = agent(); + let env = StatusEnvelope::new( + canonical_identity(&relay, &agent).unwrap(), + Uuid::new_v4(), + StatusScope { + channel_id: Uuid::new_v4(), + thread_root: Some(root()), + }, + Uuid::new_v4(), + status(), + ); + let text = env.event_tags().unwrap().concat().join("|"); + for secret in [ + private, + "private-dir", + "relay.example", + &agent, + &env.scope.channel_id.to_string(), + &env.scope.thread_root.clone().unwrap(), + &env.instance_id.to_string(), + &env.generation.to_string(), + ] { + assert!(!text.contains(secret), "tags leaked {secret}"); + } + // A path smuggled through the reason code is rejected, not emitted. + let mut s = status(); + s.reason = StatusReason::ResumeFailed(format!("..{private}")); + assert_eq!(s.event_tags(&env.scope), Err(StatusError::InvalidReason)); +} + +// -- reasons ----------------------------------------------------------- + +#[test] +fn reason_vocabulary_round_trips() { + for wire in [ + "first_in_thread", + "idle_sleep", + "harness_restart", + "worker_crash", + "discarded", + "live_elsewhere", + "resume_unsupported", + "config_changed", + "context_overflow", + "max_turns", + "resume_failed:not_found", + "resume_failed:rpc_-32603", + "resume_failed:timeout", + "resume_failed:protocol", + ] { + let reason = StatusReason::from_wire(wire).expect(wire); + assert_eq!(reason.as_wire(), wire); + } +} + +#[test] +fn unknown_or_unsafe_reasons_are_rejected() { + let long = format!("resume_failed:{}", "a".repeat(83)); + for wire in [ + "", + "bogus", + "First_In_Thread", + "resume_failed:", + "resume_failed:Has Space", + "resume_failed:a/b", + "resume_failed:C0DE", + "resume_failed:caf\u{e9}", + "resume_failed:line\nbreak", + long.as_str(), + ] { + assert_eq!( + StatusReason::from_wire(wire), + Err(StatusError::InvalidReason), + "{wire:?}" + ); + } + let longest = format!("resume_failed:{}", "a".repeat(82)); + assert_eq!(longest.len(), MAX_REASON_BYTES); + assert!(StatusReason::from_wire(&longest).is_ok()); +} + +#[test] +fn state_vocabulary_is_closed_over_four_values() { + let states = [ + (SessionState::New, "new"), + (SessionState::Resumed, "resumed"), + (SessionState::Compacted, "compacted"), + (SessionState::Restarted, "restarted"), + ]; + for (state, wire) in states { + assert_eq!(state.as_wire(), wire); + assert_eq!(serde_json::to_value(state).unwrap(), json!(wire)); + assert_eq!( + serde_json::from_value::(json!(wire)).unwrap(), + state + ); + } + assert!(serde_json::from_value::(json!("paused")).is_err()); +} + +// -- identity ---------------------------------------------------------- + +#[test] +fn identity_matches_same_spelling_and_transport_counterparts() { + let a = agent(); + let base = canonical_identity("wss://relay.example", &a).unwrap(); + for same in [ + "wss://relay.example", + "wss://relay.example/", + "WSS://Relay.Example:443/", + "https://relay.example", + "https://relay.example/", + " https://RELAY.example:443 ", + ] { + assert_eq!(canonical_identity(same, &a).unwrap(), base, "{same}"); + } + let ws = canonical_identity("ws://relay.example", &a).unwrap(); + assert_eq!( + canonical_identity("http://relay.example:80/", &a).unwrap(), + ws + ); + assert_ne!(ws, base, "plain and secure transports stay distinct"); + assert_eq!(base.relay, "wss://relay.example"); + assert_eq!(base.agent, a); + // Idempotent. + assert_eq!(canonical_identity(&base.relay, &base.agent).unwrap(), base); + // Uppercase key hex normalizes. + assert_eq!( + canonical_identity("wss://relay.example", &a.to_uppercase()).unwrap(), + base + ); +} + +#[test] +fn identity_differs_on_host_agent_port_path_and_query() { + let a = agent(); + let id = |relay: &str, agent: &str| canonical_identity(relay, agent).unwrap(); + let base = id("ws://relay.example", &a); + for other in [ + "ws://other.example", + "ws://relay.example:8080", + "ws://relay.example/tenant", + "ws://relay.example/tenant/", + "ws://relay.example/?community=a", + "wss://relay.example", + ] { + assert_ne!(id(other, &a), base, "{other}"); + } + assert_ne!( + id("ws://relay.example/tenant", &a), + id("ws://relay.example/tenant/", &a) + ); + assert_ne!( + id("ws://relay.example/?community=a", &a), + id("ws://relay.example/?community=b", &a) + ); + assert_ne!( + id("ws://relay.example", &a), + id("ws://relay.example", &agent()) + ); + + // Each loopback spelling is its own host. + let spellings = ["ws://localhost", "ws://127.0.0.1", "ws://[::1]"]; + for (i, x) in spellings.iter().enumerate() { + for y in &spellings[i + 1..] { + assert_ne!(id(x, &a), id(y, &a), "{x} vs {y}"); + } + } + assert_eq!(id("ws://LOCALHOST:80", &a), id("ws://localhost", &a)); +} + +#[test] +fn invalid_identity_inputs_are_rejected() { + let a = agent(); + for relay in [ + "", + "not a url", + "ftp://relay.example", + "file:///tmp/x", + "ws://", + "wss://user@relay.example", + "wss://user:pw@relay.example", + "wss://relay.example/#frag", + "mailto:x@y.z", + ] { + assert_eq!( + canonical_identity(relay, &a), + Err(StatusError::InvalidRelay), + "{relay:?}" + ); + } + for key in ["", "xyz", "npub1abc", &"0".repeat(63), &"g".repeat(64)] { + assert_eq!( + canonical_identity("ws://relay.example", key), + Err(StatusError::InvalidAgent), + "{key:?}" + ); + } +} + +// -- scope and names --------------------------------------------------- + +#[test] +fn scope_file_names_are_distinct_and_path_safe() { + let channel = Uuid::new_v4(); + let conversation = StatusScope { + channel_id: channel, + thread_root: None, + }; + let thread = StatusScope { + channel_id: channel, + thread_root: Some(root()), + }; + let other_root = StatusScope { + channel_id: channel, + thread_root: Some("cd".repeat(32)), + }; + let names: Vec = [&conversation, &thread, &other_root] + .iter() + .map(|s| scope_file_name(s).unwrap()) + .collect(); + assert_eq!(names[0], format!("{channel}.conversation.json")); + assert_eq!(names[1], format!("{channel}.thread-{}.json", root())); + assert_ne!(names[1], names[2]); + for name in &names { + assert!(name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')); + assert!(!name.contains("..")); + } +} + +#[test] +fn invalid_thread_roots_never_become_file_names() { + for root in [ + "", + "ab", + "../../etc/passwd", + &"AB".repeat(32), + &"zz".repeat(32), + &format!("{}/", "a".repeat(63)), + &"a".repeat(65), + ] { + let scope = StatusScope { + channel_id: Uuid::new_v4(), + thread_root: Some(root.to_owned()), + }; + assert_eq!( + scope_file_name(&scope), + Err(StatusError::InvalidThreadRoot), + "{root:?}" + ); + } +} + +#[test] +fn instance_layout_names() { + let generation = Uuid::new_v4(); + assert_eq!(INSTANCE_LOCK_FILE, "instance.lock"); + assert_eq!( + generation_lock_path(generation), + format!("generations/{generation}.lock") + ); +} + +// -- snapshot ---------------------------------------------------------- + +#[test] +fn snapshot_round_trips() { + let mut env = envelope(); + env.status.history = Some(HistoryCoverage { + read: Some(3), + total: Some(9), + exact: false, + incomplete: false, + }); + let bytes = env.to_snapshot_bytes().unwrap(); + assert!(bytes.len() <= MAX_SNAPSHOT_BYTES); + assert_eq!(parse_snapshot(&bytes).unwrap(), env); + let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(value["version"], 2); + assert_eq!(value["status"]["reason"], "idle_sleep"); + assert_eq!(value["status"]["state"], "resumed"); +} + +#[test] +fn snapshot_rejects_unknown_versions_and_bad_shapes() { + let good: serde_json::Value = + serde_json::from_slice(&envelope().to_snapshot_bytes().unwrap()).unwrap(); + let parse = |v: &serde_json::Value| parse_snapshot(&serde_json::to_vec(v).unwrap()); + + for version in [json!(0), json!(3), json!(255), json!(1000)] { + let mut v = good.clone(); + v["version"] = version; + assert_eq!(parse(&v), Err(StatusError::UnsupportedVersion)); + } + let mut v = good.clone(); + v.as_object_mut().unwrap().remove("version"); + assert_eq!(parse(&v), Err(StatusError::Malformed)); + + let mut v = good.clone(); + v["extra"] = json!(1); + assert_eq!(parse(&v), Err(StatusError::Malformed)); + + let mut v = good.clone(); + v["status"]["state"] = json!("paused"); + assert_eq!(parse(&v), Err(StatusError::Malformed)); + + let mut v = good.clone(); + v["status"]["reason"] = json!("resume_failed:/etc/passwd"); + assert_eq!(parse(&v), Err(StatusError::Malformed)); + + let mut v = good.clone(); + v["scope"]["thread_root"] = json!("../x"); + assert_eq!(parse(&v), Err(StatusError::InvalidThreadRoot)); + + let mut v = good.clone(); + v["status"]["occupancy"] = json!({"used": 1, "size": 0}); + assert_eq!(parse(&v), Err(StatusError::InvalidOccupancy)); + + let mut v = good.clone(); + v["status"]["history"] = json!({"read": 9, "total": 3, "exact": false, "incomplete": false}); + assert_eq!(parse(&v), Err(StatusError::InvalidHistory)); + + // Non-canonical identity (uppercase scheme/host, http scheme). + let mut v = good.clone(); + v["identity"]["relay"] = json!("http://relay.example"); + assert_eq!(parse(&v), Err(StatusError::InvalidRelay)); + + assert_eq!(parse_snapshot(b"not json"), Err(StatusError::Malformed)); + assert_eq!(parse_snapshot(b""), Err(StatusError::Malformed)); + assert_eq!(parse_snapshot(b"[1]"), Err(StatusError::Malformed)); +} + +#[test] +fn snapshot_size_limit_is_enforced_on_both_sides() { + let env = envelope(); + let bytes = env.to_snapshot_bytes().unwrap(); + + // Pad inside valid JSON so only the size limit can reject it. + let mut padded = bytes.clone(); + padded.truncate(padded.len() - 1); + padded.extend_from_slice(b",\"pad\":\""); + padded.resize(MAX_SNAPSHOT_BYTES + 1, b'x'); + assert_eq!(parse_snapshot(&padded), Err(StatusError::TooLarge)); + assert_eq!( + parse_snapshot(&vec![b' '; MAX_SNAPSHOT_BYTES + 1]), + Err(StatusError::TooLarge) + ); + + // Exactly at the limit (trailing whitespace) is still accepted. + let mut at_limit = bytes; + at_limit.resize(MAX_SNAPSHOT_BYTES, b' '); + assert_eq!(parse_snapshot(&at_limit), Ok(env)); + + // The writer side refuses oversized documents too. + let mut huge = envelope(); + huge.identity.relay = format!("ws://relay.example/{}", "p".repeat(MAX_SNAPSHOT_BYTES)); + assert_eq!(huge.to_snapshot_bytes(), Err(StatusError::TooLarge)); +} diff --git a/crates/buzz-core/tests/fixtures/session_controls.json b/crates/buzz-core/tests/fixtures/session_controls.json new file mode 100644 index 00000000000..c23142f014b --- /dev/null +++ b/crates/buzz-core/tests/fixtures/session_controls.json @@ -0,0 +1,41 @@ +{ + "reasons": [ + {"wire":"user_restart:none","valid":true}, + {"wire":"user_restart:recent","valid":true}, + {"wire":"user_restart:all","valid":true}, + {"wire":"user_compact","valid":true}, + {"wire":"auto_compact","valid":true}, + {"wire":"worker_recycled","valid":true}, + {"wire":"user_restart:foo","valid":false}, + {"wire":"user_restart","valid":false}, + {"wire":"resume_failed:raw/path","valid":false}, + {"wire":"USER_COMPACT","valid":false} + ], + "origins": [ + {"origin":{"command_event_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","actor_pubkey":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"valid":true}, + {"origin":{"command_event_id":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","actor_pubkey":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"valid":false}, + {"origin":{"command_event_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","actor_pubkey":"bad"},"valid":false} + ], + "precedence": [ + {"first":"pending","second":{"terminal":{"Ok":null}},"expected":{"terminal":{"Ok":null}}}, + {"first":{"terminal":{"Ok":null}},"second":"pending","expected":{"terminal":{"Ok":null}}}, + {"first":{"terminal":{"Ok":null}},"second":{"terminal":{"Err":"busy"}},"expected":{"terminal":{"Err":"unconfirmed"}}} + ], + "commands": [ + {"content":"!compact","expected":"!compact"}, + {"content":"!restart","expected":"!restart"}, + {"content":"!rotate","expected":"!restart"}, + {"content":"!restart none","expected":"!restart none"}, + {"content":"!restart all","expected":"!restart all"}, + {"content":" \t!compact\n","expected":"!compact"}, + {"content":"\u0085!restart\u0085","expected":"!restart"}, + {"content":"\ufeff!restart\ufeff","expected":null}, + {"content":"!restart recent","expected":null}, + {"content":"!restart all","expected":null}, + {"content":"!Compact","expected":null}, + {"content":"!compact @Agent","expected":null}, + {"content":"> !compact","expected":null}, + {"content":"!restart\nnone","expected":null}, + {"content":"","expected":null} + ] +} diff --git a/crates/buzz-core/tests/session_control.rs b/crates/buzz-core/tests/session_control.rs new file mode 100644 index 00000000000..745a4d4f80a --- /dev/null +++ b/crates/buzz-core/tests/session_control.rs @@ -0,0 +1,270 @@ +use buzz_core::session_control::{ + ControlAction, ControlFailure, ControlOrigin, ControlResult, RestartDepth, SessionCommand, + SessionResult, +}; +use buzz_core::session_status::{ + canonical_identity, parse_snapshot, SessionState, SessionStatus, StatusEnvelope, StatusReason, + StatusScope, +}; +use serde_json::json; +use uuid::Uuid; + +const ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ACTOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const AGENT: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + +#[test] +fn command_grammar_is_exact() { + for (text, command) in [ + ("!compact", SessionCommand::Compact), + ("!restart", SessionCommand::Restart(RestartDepth::Recent)), + ("!restart none", SessionCommand::Restart(RestartDepth::None)), + ("!restart all", SessionCommand::Restart(RestartDepth::All)), + ("!rotate", SessionCommand::Restart(RestartDepth::Recent)), + ] { + assert_eq!(SessionCommand::parse(9, text), Some(command)); + assert_eq!( + SessionCommand::parse(9, &format!(" \n{text}\t")), + Some(command) + ); + assert_eq!(SessionCommand::parse(1, text), None); + assert_eq!(SessionCommand::parse(7, text), None); + assert_eq!( + SessionCommand::parse(9, command.as_content()), + Some(command) + ); + } + for text in [ + "!restart recent", + "!restart foo", + "@Name !restart", + "> !restart", + "say !compact", + "!restart all", + "!restart\nall", + "!COMPACT", + "!compact later", + ] { + assert_eq!(SessionCommand::parse(9, text), None, "{text}"); + } +} + +#[test] +fn result_tags_are_strict_and_pending_has_terminal_precedence() { + let pending = ControlResult::Pending; + let ok = ControlResult::Terminal(Ok(())); + let error = ControlResult::Terminal(Err(ControlFailure::Busy)); + for value in [pending, ok, error] { + let result = SessionResult { + command_id: ID.into(), + result: value, + }; + let tag = result.event_tag().unwrap(); + assert_eq!(tag.len(), 5); + assert_eq!( + SessionResult::from_tags(value.reaction(), &[tag.clone(), tag.clone()]), + Some(result) + ); + let mut malformed = tag.clone(); + malformed[2] = ID.to_uppercase(); + assert_eq!( + SessionResult::from_tags(value.reaction(), &[malformed]), + None + ); + let mut malformed = tag; + malformed[4] = "raw/path/error".into(); + assert_eq!( + SessionResult::from_tags(value.reaction(), &[malformed]), + None + ); + } + let good = SessionResult { + command_id: ID.into(), + result: ok, + } + .event_tag() + .unwrap(); + let bad = SessionResult { + command_id: ID.into(), + result: error, + } + .event_tag() + .unwrap(); + assert_eq!(SessionResult::from_tags("✅", &[good.clone(), bad]), None); + assert_eq!(SessionResult::from_tags("❌", &[good]), None); + assert_eq!(pending.merge(ok), ok); + assert_eq!(ok.merge(pending), ok); + assert_eq!(ok.merge(ok), ok); + assert_eq!( + ok.merge(error), + ControlResult::Terminal(Err(ControlFailure::Unconfirmed)) + ); + assert_eq!( + error.merge(ok), + ControlResult::Terminal(Err(ControlFailure::Unconfirmed)) + ); +} + +fn envelope() -> StatusEnvelope { + StatusEnvelope::new( + canonical_identity("wss://relay.example", AGENT).unwrap(), + Uuid::from_u128(1), + StatusScope { + channel_id: Uuid::from_u128(2), + thread_root: None, + }, + Uuid::from_u128(3), + SessionStatus { + state: SessionState::Restarted, + reason: StatusReason::UserRestart(RestartDepth::All), + transition_id: Uuid::from_u128(4), + occupancy: None, + history: None, + }, + ) +} + +#[test] +fn snapshot_legacy_v1_and_v2_actor_preserve_wire_dimensions() { + let mut snapshot = envelope(); + let mut legacy = serde_json::to_value(&snapshot).unwrap(); + legacy["version"] = json!(1); + legacy.as_object_mut().unwrap().remove("origin"); + assert!(parse_snapshot(&serde_json::to_vec(&legacy).unwrap()).is_ok()); + legacy["origin"] = json!(null); + assert!(parse_snapshot(&serde_json::to_vec(&legacy).unwrap()).is_err()); + snapshot.origin = Some(ControlOrigin { + command_event_id: ID.into(), + actor_pubkey: ACTOR.into(), + }); + let bytes = snapshot.to_snapshot_bytes().unwrap(); + assert_eq!(parse_snapshot(&bytes).unwrap(), snapshot); + let tags = snapshot.event_tags().unwrap(); + assert_eq!(tags[0].len(), 4); + assert_eq!(tags[1].len(), 7); + assert_eq!(tags[2].len(), 5); + let action = ControlAction::from_tags(&tags).unwrap(); + assert_eq!(action.origin, snapshot.origin.clone().unwrap()); + assert_eq!(action.transition_id, snapshot.status.transition_id); + let mut future = serde_json::to_value(snapshot).unwrap(); + future["version"] = json!(3); + assert!(parse_snapshot(&serde_json::to_vec(&future).unwrap()).is_err()); +} + +#[test] +fn action_tags_require_matching_valid_info_and_no_conflicts() { + let mut snapshot = envelope(); + snapshot.origin = Some(ControlOrigin { + command_event_id: ID.into(), + actor_pubkey: ACTOR.into(), + }); + let tags = snapshot.event_tags().unwrap(); + assert_eq!(ControlAction::from_tags(&tags[2..]), None); + for field in [2, 3, 4] { + let mut broken = tags.clone(); + broken[2][field] = "malformed".into(); + assert_eq!(ControlAction::from_tags(&broken), None); + } + let mut conflict = tags.clone(); + let mut other = tags[2].clone(); + other[4] = ID.into(); + conflict.push(other); + assert_eq!(ControlAction::from_tags(&conflict), None); + let mut duplicate = tags.clone(); + duplicate.push(tags[2].clone()); + assert!(ControlAction::from_tags(&duplicate).is_some()); + let mut auto = tags; + auto[0][2] = "auto_compact".into(); + assert_eq!(ControlAction::from_tags(&auto), None); +} + +#[test] +fn shared_reason_and_origin_fixture_corpus() { + let cases: serde_json::Value = + serde_json::from_str(include_str!("fixtures/session_controls.json")).unwrap(); + for case in cases["commands"].as_array().unwrap() { + let command = SessionCommand::parse(9, case["content"].as_str().unwrap()); + assert_eq!( + command.map(SessionCommand::as_content), + case["expected"].as_str() + ); + assert!(SessionCommand::parse(7, case["content"].as_str().unwrap()).is_none()); + } + for case in cases["reasons"].as_array().unwrap() { + assert_eq!( + StatusReason::from_wire(case["wire"].as_str().unwrap()).is_ok(), + case["valid"].as_bool().unwrap() + ); + } + for case in cases["origins"].as_array().unwrap() { + let origin: ControlOrigin = serde_json::from_value(case["origin"].clone()).unwrap(); + assert_eq!(origin.validate().is_ok(), case["valid"].as_bool().unwrap()); + } + for case in cases["precedence"].as_array().unwrap() { + let a: ControlResult = serde_json::from_value(case["first"].clone()).unwrap(); + let b: ControlResult = serde_json::from_value(case["second"].clone()).unwrap(); + assert_eq!(serde_json::to_value(a.merge(b)).unwrap(), case["expected"]); + } +} + +#[test] +fn freshness_boundaries_and_failure_vocabulary_are_closed() { + use buzz_core::session_control::{control_is_fresh, ControlOutcome}; + assert!(control_is_fresh(1000, 1900)); + assert!(!control_is_fresh(1000, 1901)); + assert!(control_is_fresh(1030, 1000)); + assert!(!control_is_fresh(1031, 1000)); + assert!(control_is_fresh(u64::MAX, u64::MAX)); + for code in [ + "no_session", + "unsupported", + "scope_unavailable", + "busy", + "stale_target", + "stale_request", + "store_unavailable", + "resume_failed", + "compact_failed", + "cancelled", + "timeout", + "unconfirmed", + "superseded", + "internal", + ] { + let failure = ControlFailure::from_wire(code).unwrap(); + let outcome: ControlOutcome = Err(failure); + assert_eq!(failure.as_wire(), code); + let result = SessionResult { + command_id: ID.into(), + result: ControlResult::Terminal(outcome), + }; + assert_eq!( + SessionResult::from_tags("❌", &[result.event_tag().unwrap()]), + Some(result) + ); + } + for code in [ + "", + "future_error", + "NO_SESSION", + "/private/path", + "raw error", + ] { + assert!(ControlFailure::from_wire(code).is_none()); + } +} + +#[test] +fn snapshot_rejects_invalid_actor_and_automatic_actor() { + let mut snapshot = envelope(); + snapshot.origin = Some(ControlOrigin { + command_event_id: ID.into(), + actor_pubkey: "invalid".into(), + }); + assert!(snapshot.to_snapshot_bytes().is_err()); + assert!(parse_snapshot(&serde_json::to_vec(&snapshot).unwrap()).is_err()); + snapshot.origin.as_mut().unwrap().actor_pubkey = ACTOR.into(); + snapshot.status.reason = StatusReason::AutoCompact; + assert!(snapshot.to_snapshot_bytes().is_err()); + assert!(parse_snapshot(&serde_json::to_vec(&snapshot).unwrap()).is_err()); +} diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 824ee3cfc92..b7ae5ccfc01 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -24,6 +24,8 @@ export default defineConfig({ "**/owned-agent-discovery.spec.ts", "**/thread-head-stale-edit.spec.ts", "**/thread-follow.spec.ts", + "**/session-status.spec.ts", + "**/session-controls.spec.ts", "**/sidebar-offcanvas-rail.spec.ts", "**/tooltip-semantics.spec.ts", "**/search-scope-screenshots.spec.ts", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index ac4682dbe28..4c9fc396d8f 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -6190,7 +6190,7 @@ dependencies = [ [[package]] name = "neteq" version = "0.8.3" -source = "git+https://github.com/reynholm/videocall-rs.git?rev=16729881b0a80022a7cbe0acc94c9becf2fad313#16729881b0a80022a7cbe0acc94c9becf2fad313" +source = "git+https://github.com/security-union/videocall-rs.git?rev=16729881b0a80022a7cbe0acc94c9becf2fad313#16729881b0a80022a7cbe0acc94c9becf2fad313" dependencies = [ "log", "serde", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 2c8f153d115..1a184d49f93 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -94,7 +94,7 @@ tokio-util = { version = "0.7", features = ["rt"] } bytes = "1" futures-util = "0.3" opus = "0.4" -neteq = { git = "https://github.com/reynholm/videocall-rs.git", rev = "16729881b0a80022a7cbe0acc94c9becf2fad313", default-features = false } +neteq = { git = "https://github.com/security-union/videocall-rs.git", rev = "16729881b0a80022a7cbe0acc94c9becf2fad313", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" yaml_serde = "0.10.7" diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 16534dd3367..6a1a47ff808 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -454,3 +454,47 @@ matches the code is worse than no rule; a new pattern that isn't written down here will be broken by the next agent that never learns it existed. Reviewers: treat a config-behavior diff without a matching AGENTS.md diff (or an explicit "no rules changed" note) as incomplete. + +## Scoped session controls + +`Manage context` is a thread-only composer button immediately before mention +management. An existing persisted root alone qualifies; drafts, placeholders and +optimistic heads do not. Always show the accessible agent list first, even for +one agent, then that agent's actions with Back to agents. Choosing an agent sends +nothing. Project original signers and explicit agent p-tags from the bounded +canonical root/reply data and verified directory, independently of automatic +mention pins; incomplete history cannot claim a complete roster. Bind the roster +to the active owning thread query's raw data and validation state. A +passive cache subscription must not replace that owner's expected-reply query +function or retry policy. Read-only project forum previews use an isolated +bounded fallback only while their ordinary owner is disabled; an active owner's +pending/error state always takes precedence. Other missing owners remain +unavailable. Keep the button available while the editor is disabled or sending. +Message rows retain quiet +status and signed command/result rows without a duplicate control trigger. + +Actions target the selected original agent signer and canonical thread root. +Fresh verified relay ownership, policy, +channel type and membership authorize publication; local inventory and status +tags never grant access. Preserve owner/sibling-only DMs and independently +operated cross-owner relay agents in marked builds. Managed runtime access +remains governed by rule 11. The action captures relay and signer before reads, +revalidates at click, and leaves the composer draft, selection, attachments and +mentions untouched on every outcome. Thread, relay and requester changes reset +the picker and retire selected actions before any awaited result can publish. + +Start over stops current work, including subagents, while keeping file changes. +Its submenu carries that warning once; no additional confirmation is required. +The original signed kind-9 request defines a command row even after edits. +Only raw target-signed kind-7 results with matching request metadata confirm it; +ordinary reaction pills, publication acknowledgments and delegated authors do +not. Conflicts are unconfirmed. Manual transition actors use validated matching +supporting tags and existing batched profile reads; absent metadata stays neutral. + +`messages/lib/sessionControl*.test.mjs`, +`messages/lib/threadContextAgents.test.mjs`, +`messages/ui/ThreadContextControls.jsdom-test.mjs`, +`messages/lib/useSessionControlAction.jsdom-test.mjs`, and the registered +`desktop/tests/e2e/session-controls.spec.ts` cover grammar, permissions, races, +command presentation, keyboard menus and draft preservation. Keep the existing +`session-status.spec.ts` quiet-paragraph assertions intact. diff --git a/desktop/src/features/agents/observerCompaction.test.mjs b/desktop/src/features/agents/observerCompaction.test.mjs new file mode 100644 index 00000000000..deec8f1a673 --- /dev/null +++ b/desktop/src/features/agents/observerCompaction.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + getAgentObserverSnapshot, + getAgentTranscript, + resetAgentObserverStore, + syncAgentObserverEvents, +} from "./observerRelayStore.ts"; + +const agent = "c".repeat(64); +const update = (seq, status, fields = {}) => ({ + seq, + timestamp: new Date( + Date.parse("2026-10-01T00:00:00Z") + seq * 1000, + ).toISOString(), + kind: "acp_read", + agentIndex: 0, + channelId: "channel", + sessionId: "stale-envelope", + turnId: "turn", + payload: { + method: "session/update", + params: { + sessionId: "provider", + update: { + sessionUpdate: "compaction_update", + compactionId: "id", + status, + ...fields, + }, + }, + }, +}); + +test("production observer store handles replay and out-of-order rebuild, retains raw frames and resets lifecycle state", () => { + resetAgentObserverStore(); + const completed = update(3, "completed", { + summary: "Adapter supplied detail", + }); + syncAgentObserverEvents(agent, [completed]); + assert.equal(getAgentTranscript(agent).length, 1); + syncAgentObserverEvents(agent, [update(1, "in_progress"), completed]); + const transcript = getAgentTranscript(agent); + assert.equal(transcript.length, 1); + assert.equal(transcript[0].compaction.status, "completed"); + assert.equal(transcript[0].sessionId, "provider"); + assert.equal(getAgentObserverSnapshot(agent).events.length, 2); + assert.deepEqual( + getAgentObserverSnapshot(agent).events[1].payload, + completed.payload, + ); + resetAgentObserverStore(); + syncAgentObserverEvents(agent, [update(4, "in_progress")]); + assert.equal(getAgentTranscript(agent)[0].compaction.status, "in_progress"); + resetAgentObserverStore(); +}); diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index 4e526692246..941e5d881a7 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -109,12 +109,21 @@ export function ManagedAgentSessionPanel({ [scopedLiveEvents, archivedChannelEvents], ); + const combinedTranscriptEvents = React.useMemo( + () => + mergeObserverEventWindows( + scopeByChannel(events, channelId, true), + archivedChannelEvents, + ), + [events, channelId, archivedChannelEvents], + ); + // Derive transcript once from the combined raw window. When transcriptOverride // is set (e.g. E2E snapshot specs), bypass both — the caller supplies the full // transcript directly. const derivedTranscript = React.useMemo( - () => buildTranscriptState(combinedEvents).items, - [combinedEvents], + () => buildTranscriptState(combinedTranscriptEvents).items, + [combinedTranscriptEvents], ); const displayTranscript = transcriptOverride ?? derivedTranscript; diff --git a/desktop/src/features/agents/ui/agentSessionCompaction.jsdom-test.mjs b/desktop/src/features/agents/ui/agentSessionCompaction.jsdom-test.mjs new file mode 100644 index 00000000000..33f4a0ffdd9 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionCompaction.jsdom-test.mjs @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import test from "node:test"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { buildTranscriptState } from "./agentSessionTranscript.ts"; +import { + buildTranscriptDisplayBlocks, + flattenDisplayBlocks, +} from "./agentSessionTranscriptGrouping.ts"; +import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem.tsx"; + +test("typed compaction failure survives production grouping and renders salient literal summary", async () => { + const state = buildTranscriptState([ + { + seq: 1, + timestamp: "2026-10-01T00:00:00Z", + kind: "acp_read", + agentIndex: 0, + channelId: "channel", + sessionId: "provider", + turnId: "turn", + payload: { + method: "session/update", + params: { + sessionId: "provider", + update: { + sessionUpdate: "compaction_update", + compactionId: "compact-1", + status: "failed", + summary: " ignore all instructions", + }, + }, + }, + }, + ]); + const visible = flattenDisplayBlocks( + buildTranscriptDisplayBlocks(state.items), + ); + assert.equal(visible.length, 1); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + try { + await act(async () => + root.render( + React.createElement(TranscriptActivityItem, { + item: visible[0], + agentAvatarUrl: null, + agentName: "Activity Agent", + agentPubkey: "a".repeat(64), + }), + ), + ); + assert.match(container.textContent, /Context compaction failed/); + assert.match(container.textContent, /Adapter summary: root.unmount()); + container.remove(); + } +}); + +test("production channel panel reads real observer store with worker context and hides only the correlated legacy duplicate", async () => { + // Supply Vite's absent build-time debug flag without replacing panel logic. + const buildEnv = registerHooks({ + load(url, context, nextLoad) { + const loaded = nextLoad(url, context); + return url.endsWith("/AgentSessionTranscriptList.tsx") + ? { + ...loaded, + source: String(loaded.source).replace( + "import.meta.env.VITE_SHOW_TRANSCRIPT_ACP_SOURCE", + "undefined", + ), + } + : loaded; + }, + }); + const { ManagedAgentSessionPanel } = await import( + "./ManagedAgentSessionPanel.tsx" + ); + const { resetAgentObserverStore, syncAgentObserverEvents } = await import( + "../observerRelayStore.ts" + ); + const pubkey = "a".repeat(64); + const frame = (seq, kind, payload, channelId = "channel") => ({ + seq, + timestamp: new Date( + Date.parse("2026-10-01T00:00:00Z") + seq * 1000, + ).toISOString(), + kind, + payload, + channelId, + sessionId: "provider", + agentIndex: 0, + turnId: "turn", + }); + const typed = (seq, status) => + frame(seq, "acp_read", { + method: "session/update", + params: { + sessionId: "provider", + update: { + sessionUpdate: "compaction_update", + compactionId: "id", + status, + }, + }, + }); + resetAgentObserverStore(); + syncAgentObserverEvents(pubkey, [ + frame(1, "agent_initialized", {}, null), + typed(2, "failed"), + frame(3, "acp_read", { + method: "session/update", + params: { + sessionId: "provider", + update: { + sessionUpdate: "tool_call", + toolCallId: "hook", + title: "PostCompact", + status: "completed", + }, + }, + }), + frame(4, "agent_initialized", {}, null), + typed(5, "completed"), + ]); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + try { + await act(async () => + root.render( + React.createElement(ManagedAgentSessionPanel, { + agent: { pubkey, name: "Activity Agent", status: "stopped" }, + channelId: "channel", + showRaw: false, + showHeader: false, + }), + ), + ); + assert.equal( + container.querySelectorAll('[data-testid="transcript-lifecycle-item"]') + .length, + 2, + ); + assert.equal( + container.querySelectorAll('[data-testid="transcript-suppressed-item"]') + .length, + 0, + ); + assert.match(container.textContent, /Context compaction failed/); + assert.match(container.textContent, /Context compacted/); + } finally { + await act(async () => root.unmount()); + container.remove(); + resetAgentObserverStore(); + buildEnv.deregister(); + } +}); diff --git a/desktop/src/features/agents/ui/agentSessionCompaction.test.mjs b/desktop/src/features/agents/ui/agentSessionCompaction.test.mjs new file mode 100644 index 00000000000..a76f857dc19 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionCompaction.test.mjs @@ -0,0 +1,443 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildTranscriptState, + createEmptyTranscriptState, + processTranscriptEvent, +} from "./agentSessionTranscript.ts"; +import { + buildTranscriptDisplayBlocks, + flattenDisplayBlocks, +} from "./agentSessionTranscriptGrouping.ts"; +import { isMeaningfulItem } from "./agentSessionTranscriptPresentation.ts"; + +const event = (seq, payload, extra = {}) => ({ + seq, + timestamp: new Date( + Date.parse("2026-10-01T00:00:00Z") + seq * 1000, + ).toISOString(), + kind: "acp_read", + agentIndex: 0, + channelId: "channel", + sessionId: "provider", + turnId: "turn", + payload, + ...extra, +}); +const update = (seq, status, fields = {}, extra = {}) => + event( + seq, + { + method: "session/update", + params: { + sessionId: "provider", + update: { + sessionUpdate: "compaction_update", + compactionId: "compact-1", + status, + ...fields, + }, + }, + }, + extra, + ); +const prompt = (seq, text, extra = {}) => + event( + seq, + { + method: "session/prompt", + params: { sessionId: "provider", prompt: [{ type: "text", text }] }, + }, + { kind: "acp_write", ...extra }, + ); +const rows = (state) => + state.items.filter((item) => item.acpSource === "compaction_update"); +const fold = (events) => + events.reduce(processTranscriptEvent, createEmptyTranscriptState()); + +for (const status of ["completed", "failed", "cancelled"]) { + test(`production transcript mutates one compaction row to ${status}`, () => { + let state = fold([update(1, "in_progress")]); + assert.equal(rows(state).length, 1); + const id = rows(state)[0].id; + state = processTranscriptEvent(state, update(2, status)); + assert.equal(rows(state).length, 1); + assert.equal(rows(state)[0].id, id); + assert.equal(rows(state)[0].compaction.status, status); + assert.equal( + rows(state)[0].renderClass, + status === "failed" ? "error" : "status", + ); + assert.equal(isMeaningfulItem(rows(state)[0]), true); + }); +} + +test("terminal-only, replay, late progress and enrichment preserve outcome and bounded summary", () => { + const completed = update(4, "completed"); + const state = fold([ + completed, + completed, + update(1, "in_progress"), + update(5, "completed", { summary: "x".repeat(5000) }), + ]); + assert.equal(rows(state).length, 1); + assert.equal(rows(state)[0].compaction.status, "completed"); + assert.equal(rows(state)[0].compaction.summary.length, 1000); + assert.equal(rows(state)[0].text.length < 1100, true); + const conflict = processTranscriptEvent(state, update(6, "failed")); + assert.equal(rows(conflict)[0].renderClass, "error"); + assert.match(rows(conflict)[0].title, /conflicting/i); +}); + +test("wire session wins over stale envelope; sessions and two same-turn IDs stay independent", () => { + const first = update(1, "completed", {}, { sessionId: "stale" }); + const second = update(2, "failed", { compactionId: "compact-2" }); + const other = update(3, "cancelled"); + other.payload.params.sessionId = "other"; + const state = buildTranscriptState([first, second, other]); + assert.equal(rows(state).length, 3); + assert.deepEqual( + rows(state).map((item) => item.sessionId), + ["provider", "provider", "other"], + ); + const visible = flattenDisplayBlocks( + buildTranscriptDisplayBlocks(state.items), + ); + assert.equal( + visible.filter((item) => item.renderClass === "error").length, + 1, + ); +}); + +test("real worker initialization and session resume separate generations; turns do not", () => { + const init = (seq) => + event( + seq, + { agentIndex: 0, initializeResult: {} }, + { + kind: "agent_initialized", + channelId: null, + sessionId: null, + turnId: null, + }, + ); + const resume = event( + 6, + { method: "session/resume", params: { sessionId: "provider" } }, + { kind: "acp_write" }, + ); + const state = fold([ + init(1), + update(2, "completed"), + update(3, "completed", {}, { turnId: "turn-2", startedAt: "different" }), + init(4), + update(5, "in_progress"), + resume, + update(7, "failed"), + ]); + assert.equal(rows(state).length, 3); + assert.equal(new Set(rows(state).map((item) => item.id)).size, 3); + assert.deepEqual( + rows(state).map((item) => item.compaction.status), + ["completed", "in_progress", "failed"], + ); + const unknown = rows(fold([update(1, "completed")]))[0]; + assert.equal(unknown.compaction.generation, "unknown"); +}); + +test("only exact compact prompt proves manual; ordinary prompt proves automatic; missing evidence stays unknown", () => { + for (const [text, expected] of [ + ["/compact", "manual"], + ["[Buzz event: Kind 9]\nContent: investigate", "automatic"], + [null, "unknown"], + ]) { + const state = fold([ + ...(text ? [prompt(1, text)] : []), + update(2, "completed"), + ]); + assert.equal(rows(state)[0].compaction.origin, expected); + } + const state = fold([update(2, "completed"), prompt(1, "/compact")]); + assert.equal(rows(state)[0].compaction.origin, "manual"); +}); + +test("malformed, unknown statuses, byte-overlong IDs and capability data never manufacture a lifecycle", () => { + const frames = [ + update(1, "success"), + update(2, "completed", { compactionId: "" }), + update(3, "failed", { compactionId: "é".repeat(65) }), + event(4, { result: { session: { compaction: {} } } }), + event(5, { + method: "session/update", + params: { + update: { + sessionUpdate: "compaction_update", + compactionId: "id", + status: "completed", + }, + }, + }), + ]; + assert.equal(rows(fold(frames)).length, 0); + assert.equal( + rows(fold([update(6, "completed", { compactionId: "é".repeat(64) })])) + .length, + 1, + ); +}); + +const hook = (seq) => + event(seq, { + method: "session/update", + params: { + sessionId: "provider", + update: { + sessionUpdate: "tool_call", + toolCallId: `hook-${seq}`, + title: "PostCompact", + status: "completed", + }, + }, + }); +test("legacy hook duplicates recede in both orders without hiding a typed failure or losing legacy-only fallback", () => { + assert.equal(buildTranscriptState([hook(1)]).items[0].renderClass, "status"); + for (const events of [ + [hook(1), update(2, "failed")], + [update(1, "failed"), hook(2)], + ]) { + const state = buildTranscriptState(events); + assert.equal(rows(state).length, 1); + assert.equal(state.items.filter(isMeaningfulItem).length, 1); + assert.equal(rows(state)[0].renderClass, "error"); + } +}); + +test("late progress cannot overwrite a terminal summary and older enrichment cannot replace newer text", () => { + const state = fold([ + update(4, "completed", { summary: "current" }), + update(1, "in_progress", { summary: "old progress" }), + update(3, "completed", { summary: "old terminal" }), + ]); + assert.equal(rows(state)[0].compaction.summary, "current"); +}); + +test("bounded observation indices and independent pool slots keep a retained lifecycle intact", () => { + let state = createEmptyTranscriptState(); + for (let seq = 1; seq <= 140; seq++) { + state = processTranscriptEvent( + state, + event(seq, {}, { kind: "agent_initialized" }), + ); + state = processTranscriptEvent( + state, + prompt(seq, "/compact", { turnId: `turn-${seq}` }), + ); + } + assert.equal(state.compactions.boundaries.length, 128); + assert.equal(state.compactions.prompts.length, 128); + state = processTranscriptEvent(state, update(141, "completed")); + state = processTranscriptEvent( + state, + update(142, "failed", {}, { agentIndex: 1 }), + ); + assert.equal(rows(state).length, 2); +}); + +test("legacy hook in another turn or provider session remains a meaningful fallback", () => { + const other = hook(2); + other.turnId = "other-turn"; + const state = fold([update(1, "completed"), other]); + assert.equal(state.items.filter(isMeaningfulItem).length, 2); +}); + +test("matched new-session ACK fences a reused SID and ID without adopting pre-ACK status or resetting cached neighbors", () => { + const request = event( + 3, + { + id: 42, + method: "session/new", + params: { cwd: "/workspace", mcpServers: [] }, + }, + { kind: "acp_write", sessionId: null }, + ); + const ack = event( + 5, + { id: 42, result: { sessionId: "provider" } }, + { sessionId: null }, + ); + const neighbor = update(2, "in_progress"); + neighbor.payload.params.sessionId = "cached-neighbor"; + const neighborEnd = update(7, "completed"); + neighborEnd.payload.params.sessionId = "cached-neighbor"; + const events = [ + update(1, "completed"), + neighbor, + request, + update(4, "in_progress"), + ack, + update(6, "in_progress"), + neighborEnd, + update(8, "failed"), + ]; + for (const state of [fold(events), buildTranscriptState(events)]) { + const own = rows(state).filter((item) => item.sessionId === "provider"); + assert.equal(own.length, 2); + assert.deepEqual( + own.map((item) => item.compaction.status), + ["completed", "failed"], + ); + assert.notEqual(own[0].compaction.generation, own[1].compaction.generation); + const cached = rows(state).filter( + (item) => item.sessionId === "cached-neighbor", + ); + assert.equal(cached.length, 1); + assert.equal(cached[0].compaction.status, "completed"); + assert.equal(state.compactions.pendingAttachments.length, 0); + } +}); + +test("new-session request IDs are bounded and exact; failed, malformed and unrelated ACKs never establish a generation", () => { + const request = (seq, id) => + event( + seq, + { id, method: "session/new", params: {} }, + { kind: "acp_write" }, + ); + for (const response of [ + { id: 4, result: { sessionId: "provider" } }, + { id: "3", result: { sessionId: "provider" } }, + { id: 3, result: {} }, + { id: 3, error: { code: -1, message: "failed" } }, + { + id: 3, + result: { sessionId: "provider" }, + error: { code: -1, message: "invalid mixed response" }, + }, + ]) { + const state = fold([ + update(1, "completed"), + request(2, 3), + event(3, response), + update(4, "in_progress"), + ]); + assert.equal(rows(state).length, 1); + assert.equal(rows(state)[0].compaction.status, "completed"); + } + let state = createEmptyTranscriptState(); + for (let i = 1; i <= 150; i++) + state = processTranscriptEvent(state, request(i, i)); + assert.equal(state.compactions.pendingAttachments.length, 128); +}); + +test("one exact compact request attributes only its first newly observed operation", () => { + const state = fold([ + update(1, "in_progress", { compactionId: "existing" }), + prompt(2, "/compact"), + update(3, "completed", { compactionId: "existing" }), + update(4, "failed", { compactionId: "first-new" }), + update(5, "completed", { compactionId: "second-new" }), + ]); + assert.deepEqual( + rows(state).map((item) => item.compaction.origin), + ["unknown", "manual", "unknown"], + ); + const late = fold([ + update(3, "failed", { compactionId: "first-new" }), + update(4, "completed", { compactionId: "second-new" }), + prompt(2, "/compact"), + ]); + assert.deepEqual( + rows(late).map((item) => item.compaction.origin), + ["manual", "unknown"], + ); +}); + +test("grouped production transcript removes correlated legacy hook instead of rendering a quiet duplicate", () => { + const state = fold([update(1, "in_progress"), hook(2), update(3, "failed")]); + const visible = flattenDisplayBlocks( + buildTranscriptDisplayBlocks(state.items), + ); + assert.equal(visible.length, 1); + assert.equal(visible[0].acpSource, "compaction_update"); +}); + +test("late pre-request history retracts manual attribution and leaves only the first genuinely new ID", () => { + const state = fold([ + prompt(2, "/compact"), + update(4, "completed", { compactionId: "older" }), + update(5, "failed", { compactionId: "newer" }), + update(1, "in_progress", { compactionId: "older" }), + ]); + assert.deepEqual( + rows(state).map((item) => item.compaction.origin), + ["unknown", "manual"], + ); +}); + +test("ordinary prompt completion and malformed typed summaries never manufacture compaction success", () => { + const malformed = update(2, "future_status"); + malformed.payload.title = "Context compacted"; + malformed.payload.text = "pretend success"; + const state = fold([ + update(1, "in_progress"), + malformed, + event(3, { id: 1, result: { stopReason: "end_turn", summary: "done" } }), + event(4, { outcome: "success" }, { kind: "turn_completed" }), + ]); + assert.equal(rows(state).length, 1); + assert.equal(rows(state)[0].compaction.status, "in_progress"); + assert.equal( + state.items.filter((item) => item.title === "Context compacted").length, + 0, + ); +}); + +test("failed resume clears observed attachment identity without resetting an unrelated cached session", () => { + const resume = event( + 1, + { id: 33, method: "session/resume", params: { sessionId: "provider" } }, + { kind: "acp_write" }, + ); + const neighbor = update(0, "in_progress"); + neighbor.payload.params.sessionId = "cached-neighbor"; + const neighborEnd = update(5, "completed"); + neighborEnd.payload.params.sessionId = "cached-neighbor"; + const state = fold([ + neighbor, + resume, + update(2, "in_progress"), + event(3, { id: 33, error: { code: -1, message: "resume failed" } }), + update(4, "completed"), + neighborEnd, + ]); + const own = rows(state).filter((item) => item.sessionId === "provider"); + const cached = rows(state).filter( + (item) => item.sessionId === "cached-neighbor", + ); + assert.equal(cached.length, 1); + assert.equal(cached[0].compaction.status, "completed"); + assert.equal(own.length, 2); + assert.equal(own[0].compaction.status, "in_progress"); + assert.match(own[1].compaction.generation, /^unbound:session\/resume:/); + assert.equal(own[1].compaction.origin, "unknown"); +}); + +test("direct attachment replay cannot evict a retained generation boundary or overwrite a newer pending request", () => { + const request = event( + 1, + { id: 1, method: "session/new", params: {} }, + { kind: "acp_write" }, + ); + const ack = event(2, { id: 1, result: { sessionId: "provider" } }); + let state = fold([request, ack]); + for (let i = 0; i < 140; i++) + state = processTranscriptEvent(processTranscriptEvent(state, request), ack); + assert.equal(state.compactions.boundaries.length, 1); + const newer = event( + 5, + { id: 1, method: "session/new", params: {} }, + { kind: "acp_write" }, + ); + state = processTranscriptEvent(processTranscriptEvent(state, newer), request); + assert.equal(state.compactions.pendingAttachments[0].seq, 5); +}); diff --git a/desktop/src/features/agents/ui/agentSessionCompaction.ts b/desktop/src/features/agents/ui/agentSessionCompaction.ts new file mode 100644 index 00000000000..90719165924 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionCompaction.ts @@ -0,0 +1,468 @@ +import type { ObserverEvent, TranscriptItem } from "./agentSessionTypes"; +import { asRecord } from "./agentSessionUtils"; + +export type CompactionUpdate = { + sessionId: string; + compactionId: string; + status: "in_progress" | "completed" | "failed" | "cancelled"; + summary: string | null; +}; +export type CompactionActivity = CompactionUpdate & { + /** Observed worker/attachment boundary, never the backend private tracker UUID. */ + generation: string; + origin: "manual" | "automatic" | "unknown"; + conflict: boolean; + scope: string; + summaryAt: Position | null; + firstSeen: Position; +}; +type Position = { timestamp: string; seq: number }; +type Boundary = Position & { + agent: number | null; + session: string | null; + id: string; +}; +type PromptEvidence = Position & { + scope: string; + origin: CompactionActivity["origin"]; +}; +export type CompactionActivityState = { + boundaries: Boundary[]; + prompts: PromptEvidence[]; + pendingAttachments: (Position & { + agent: number | null; + requestId: number; + session: string | null; + })[]; +}; +const MAX_TRACKED = 128; +const MAX_SUMMARY = 1000; +const utf8 = new TextEncoder(); +const nonempty = (value: unknown): value is string => + typeof value === "string" && value.length > 0; + +/** Decode the actual ACP notification; capabilities and unknown frames are not outcomes. */ +export function parseCompactionUpdate( + payload: unknown, +): CompactionUpdate | null { + const rpc = asRecord(payload); + const params = asRecord(rpc.params); + const update = asRecord(params.update); + const status = update.status; + if ( + rpc.method !== "session/update" || + update.sessionUpdate !== "compaction_update" || + !nonempty(params.sessionId) || + !nonempty(update.compactionId) || + update.compactionId.length > 128 || + utf8.encode(update.compactionId).length > 128 || + typeof status !== "string" || + !["in_progress", "completed", "failed", "cancelled"].includes(status) + ) + return null; + return { + sessionId: params.sessionId, + compactionId: update.compactionId, + status: status as CompactionUpdate["status"], + summary: + typeof update.summary === "string" + ? update.summary.slice(0, MAX_SUMMARY) + : null, + }; +} + +/** Small bounded observation index; the observer store owns the retained event window. */ +export function emptyCompactionActivityState(): CompactionActivityState { + return { boundaries: [], prompts: [], pendingAttachments: [] }; +} +function compare(a: Position, b: Position) { + return Date.parse(a.timestamp) - Date.parse(b.timestamp) || a.seq - b.seq; +} +function rememberBoundary(state: CompactionActivityState, boundary: Boundary) { + if ( + state.boundaries.some( + (entry) => entry.id === boundary.id && entry.agent === boundary.agent, + ) + ) + return state; + return { + ...state, + boundaries: [...state.boundaries, boundary] + .sort(compare) + .slice(-MAX_TRACKED), + }; +} +function generation( + state: CompactionActivityState, + event: ObserverEvent, + session: string, +) { + return ( + state.boundaries + .filter( + (boundary) => + boundary.agent === event.agentIndex && + (boundary.session === null || boundary.session === session) && + compare(boundary, event) <= 0, + ) + .at(-1)?.id ?? "unknown" + ); +} +function scope( + state: CompactionActivityState, + event: ObserverEvent, + session: string, +) { + return JSON.stringify([ + event.agentIndex, + generation(state, event, session), + session, + event.channelId, + event.turnId, + ]); +} +function lifecycle( + item: Extract, + activity: CompactionActivity, +) { + const title = activity.conflict + ? "Context compaction has conflicting outcomes" + : activity.status === "in_progress" + ? "Compacting context" + : activity.status === "completed" + ? "Context compacted" + : activity.status === "failed" + ? "Context compaction failed" + : "Context compaction cancelled"; + const provenance = + activity.origin === "manual" + ? "Requested compaction" + : activity.origin === "automatic" + ? "Automatic compaction" + : "Compaction origin unknown"; + return { + ...item, + title, + renderClass: + activity.status === "failed" || activity.conflict + ? ("error" as const) + : ("status" as const), + text: [ + provenance, + activity.summary ? `Adapter summary: ${activity.summary}` : null, + ] + .filter(Boolean) + .join(" · "), + compaction: activity, + }; +} + +/** Project typed lifecycle and observed prompt provenance without treating summaries as authority. */ +export function processCompactionActivity( + state: CompactionActivityState, + event: ObserverEvent, + items: readonly TranscriptItem[], +): { state: CompactionActivityState; upserts: TranscriptItem[] } { + const rpc = asRecord(event.payload); + const params = asRecord(rpc.params); + const session = nonempty(params.sessionId) + ? params.sessionId + : event.sessionId; + let next = state; + const upserts: TranscriptItem[] = []; + let affectedScope: string | null = null; + if (event.kind === "agent_initialized") { + next = { + ...next, + pendingAttachments: next.pendingAttachments.filter( + (pending) => + pending.agent !== event.agentIndex || compare(pending, event) > 0, + ), + }; + } + if ( + event.kind === "acp_write" && + (rpc.method === "session/new" || + (rpc.method === "session/resume" && nonempty(params.sessionId))) && + Number.isSafeInteger(rpc.id) + ) { + const requestId = rpc.id as number; + const newer = next.pendingAttachments.some( + (entry) => + entry.agent === event.agentIndex && + entry.requestId === requestId && + compare(entry, event) > 0, + ); + if (!newer) + next = { + ...next, + pendingAttachments: [ + ...next.pendingAttachments.filter( + (pending) => + pending.agent !== event.agentIndex || + pending.requestId !== requestId, + ), + { + agent: event.agentIndex, + requestId, + session: + rpc.method === "session/resume" + ? (params.sessionId as string) + : null, + timestamp: event.timestamp, + seq: event.seq, + }, + ] + .sort(compare) + .slice(-MAX_TRACKED), + }; + } + if ( + event.kind === "acp_read" && + rpc.method === undefined && + Number.isSafeInteger(rpc.id) + ) { + const pending = next.pendingAttachments.find( + (entry) => + entry.agent === event.agentIndex && + entry.requestId === rpc.id && + compare(entry, event) <= 0, + ); + if (pending) { + next = { + ...next, + pendingAttachments: next.pendingAttachments.filter( + (entry) => entry !== pending, + ), + }; + const returnedSession = asRecord(rpc.result).sessionId; + if (pending.session && rpc.error !== undefined) { + // Resume installs its route before I/O and clears it on failure. + next = rememberBoundary(next, { + agent: event.agentIndex, + session: pending.session, + timestamp: event.timestamp, + seq: event.seq, + id: `unbound:session/resume:${pending.timestamp}:${pending.seq}:${event.timestamp}:${event.seq}`, + }); + } + if ( + !pending.session && + rpc.error === undefined && + nonempty(returnedSession) + ) { + // The backend binds a new route only after this successful ACK. Typed + // pre-ACK updates still belong to an old route (or unknown history); + // only command advertisements are staged by the creation tracker. + next = rememberBoundary(next, { + agent: event.agentIndex, + session: returnedSession, + timestamp: event.timestamp, + seq: event.seq, + id: `session/new:${pending.timestamp}:${pending.seq}:${event.timestamp}:${event.seq}`, + }); + } + } + } + if ( + event.kind === "agent_initialized" || + (event.kind === "acp_write" && + rpc.method === "session/resume" && + nonempty(params.sessionId)) + ) { + const boundary: Boundary = { + timestamp: event.timestamp, + seq: event.seq, + agent: event.agentIndex, + session: event.kind === "agent_initialized" ? null : session, + id: `${event.kind}:${event.timestamp}:${event.seq}`, + }; + next = rememberBoundary(next, boundary); + } + if ( + event.kind === "acp_write" && + rpc.method === "session/prompt" && + session && + event.turnId + ) { + const blocks = Array.isArray(params.prompt) ? params.prompt : []; + const exactManual = + blocks.length === 1 && + asRecord(blocks[0]).type === "text" && + asRecord(blocks[0]).text === "/compact"; + const ordinary = blocks.some((block) => { + const text = asRecord(block).text; + return ( + asRecord(block).type === "text" && + typeof text === "string" && + text.startsWith("[Buzz event:") + ); + }); + const evidence: PromptEvidence = { + timestamp: event.timestamp, + seq: event.seq, + scope: scope(next, event, session), + origin: exactManual ? "manual" : ordinary ? "automatic" : "unknown", + }; + if (evidence.origin !== "unknown") { + next = { + ...next, + prompts: [ + ...next.prompts.filter((entry) => entry.scope !== evidence.scope), + evidence, + ] + .sort(compare) + .slice(-MAX_TRACKED), + }; + affectedScope = evidence.scope; + } + } + const update = + event.kind === "acp_read" ? parseCompactionUpdate(event.payload) : null; + if (update) { + const workerGeneration = generation(next, event, update.sessionId); + const operationScope = scope(next, event, update.sessionId); + const id = `compaction:${JSON.stringify([event.agentIndex, workerGeneration, update.sessionId, update.compactionId])}`; + const previous = items.find((item) => item.id === id); + const prior = + previous?.type === "lifecycle" ? previous.compaction : undefined; + const terminal = prior && prior.status !== "in_progress"; + const conflict = Boolean( + prior?.conflict || + (terminal && + update.status !== "in_progress" && + update.status !== prior.status), + ); + const acceptSummary = + update.summary !== null && + (!terminal || update.status === prior.status) && + (!prior?.summaryAt || compare(prior.summaryAt, event) <= 0); + const activity: CompactionActivity = { + ...update, + generation: workerGeneration, + scope: prior?.scope ?? operationScope, + status: terminal ? prior.status : update.status, + summary: acceptSummary ? update.summary : (prior?.summary ?? null), + summaryAt: acceptSummary + ? { timestamp: event.timestamp, seq: event.seq } + : (prior?.summaryAt ?? null), + origin: prior?.origin ?? "unknown", + firstSeen: + prior && compare(prior.firstSeen, event) <= 0 + ? prior.firstSeen + : { timestamp: event.timestamp, seq: event.seq }, + conflict, + }; + const item: Extract = + previous?.type === "lifecycle" + ? previous + : { + id, + type: "lifecycle", + renderClass: "status", + title: "", + text: "", + timestamp: event.timestamp, + channelId: event.channelId, + turnId: event.turnId, + sessionId: update.sessionId, + acpSource: "compaction_update", + }; + upserts.push(lifecycle(item, activity)); + affectedScope = activity.scope; + } + if (affectedScope) { + const evidence = next.prompts + .filter((prompt) => prompt.scope === affectedScope) + .at(-1); + if (evidence) { + const candidates = [ + ...new Map( + [...items, ...upserts].map((item) => [item.id, item]), + ).values(), + ] + .filter( + ( + item, + ): item is Extract & { + compaction: CompactionActivity; + } => + item.type === "lifecycle" && + item.compaction?.scope === affectedScope, + ) + .sort((a, b) => + compare(a.compaction.firstSeen, b.compaction.firstSeen), + ); + const firstNew = candidates.find( + (item) => compare(evidence, item.compaction.firstSeen) <= 0, + ); + for (const item of candidates) { + // The backend manual watch claims one newly observed ID, not every + // operation in the turn. Later IDs have no manual attribution evidence. + const attribution = + compare(evidence, item.compaction.firstSeen) <= 0 && + (evidence.origin !== "manual" || item.id === firstNew?.id) + ? evidence.origin + : "unknown"; + if (item.compaction.origin !== attribution) + upserts.push( + lifecycle(item, { ...item.compaction, origin: attribution }), + ); + } + } + } + const toolUpdate = asRecord(params.update); + const hookId = `tool:${event.channelId ?? "global"}:${toolUpdate.toolCallId}`; + const currentHook = + event.kind === "acp_read" && + rpc.method === "session/update" && + (toolUpdate.sessionUpdate === "tool_call" || + toolUpdate.sessionUpdate === "tool_call_update") + ? items.find( + (item) => + item.id === hookId && + item.type === "tool" && + item.descriptor.groupKey === "status:post-compact", + ) + : null; + if (!update && !currentHook) return { state: next, upserts }; + // Keep the legacy fallback unless this exact observed scope also has a typed row. + const typedScopes = new Set( + [...items, ...upserts].flatMap((item) => + item.type === "lifecycle" && item.compaction + ? [item.compaction.scope] + : [], + ), + ); + for (const item of update ? items : currentHook ? [currentHook] : []) { + if ( + item.type !== "tool" || + item.descriptor.groupKey !== "status:post-compact" + ) + continue; + const isCurrentHook = + item.timestamp === event.timestamp && + item.channelId === event.channelId && + item.turnId === event.turnId; + const hookScope = + item.compactionScope ?? + (session && isCurrentHook ? scope(next, event, session) : null); + if (!hookScope) continue; + if (typedScopes.has(hookScope)) { + if (item.renderClass !== "suppressed") + upserts.push({ + ...item, + compactionScope: hookScope, + renderClass: "suppressed", + descriptor: { + ...item.descriptor, + renderClass: "suppressed", + reason: "typed-compaction-duplicate", + }, + }); + } else if (!item.compactionScope) { + upserts.push({ ...item, compactionScope: hookScope }); + } + } + return { state: next, upserts }; +} diff --git a/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs b/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs index 35e1c5db18e..d319f9b7337 100644 --- a/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs @@ -118,3 +118,55 @@ test("observerEventScrollId returns distinct ids for same seq across a restart", const after = { seq: 1, timestamp: "2026-07-13T21:00:00.000Z" }; assert.notEqual(observerEventScrollId(before), observerEventScrollId(after)); }); + +test("channel transcript retains global worker boundaries without expanding the raw channel rail", async () => { + const { buildTranscriptState } = await import("./agentSessionTranscript.ts"); + const { deriveTranscriptBlockIds } = await import( + "./agentSessionTranscriptGrouping.ts" + ); + const frame = (seq, kind, payload, channelId = "channel-1") => ({ + seq, + timestamp: new Date( + Date.parse("2026-10-01T00:00:00Z") + seq * 1000, + ).toISOString(), + kind, + payload, + channelId, + sessionId: "provider", + agentIndex: 0, + turnId: "turn", + }); + const typed = (seq, status) => + frame(seq, "acp_read", { + method: "session/update", + params: { + sessionId: "provider", + update: { + sessionUpdate: "compaction_update", + compactionId: "id", + status, + }, + }, + }); + const events = [ + frame(1, "agent_initialized", {}, null), + typed(2, "completed"), + frame(3, "agent_initialized", {}, null), + typed(4, "in_progress"), + frame(5, "turn_started", {}, "other-channel"), + ]; + const raw = scopeByChannel(events, "channel-1"); + const transcriptEvents = scopeByChannel(events, "channel-1", true); + assert.equal(raw.length, 2); + const state = buildTranscriptState(transcriptEvents); + assert.equal(state.items.length, 2); + assert.notEqual(state.items[0].id, state.items[1].id); + assert.equal( + state.items.every((item) => item.channelId === "channel-1"), + true, + ); + assert.equal( + new Set(deriveTranscriptBlockIds(transcriptEvents)).size, + deriveTranscriptBlockIds(transcriptEvents).length, + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionPanelLayout.ts b/desktop/src/features/agents/ui/agentSessionPanelLayout.ts index 19d62428424..5a0ecb8555e 100644 --- a/desktop/src/features/agents/ui/agentSessionPanelLayout.ts +++ b/desktop/src/features/agents/ui/agentSessionPanelLayout.ts @@ -7,9 +7,18 @@ import type { ObserverEvent } from "./agentSessionTypes"; export function scopeByChannel( items: readonly T[], channelId: string | null | undefined, + includeWorkerBoundaries = false, ): T[] { if (!channelId) return items as T[]; - return items.filter((item) => item.channelId === channelId); + // Worker initialization has no channel, but fences typed operation identity. + // Only transcript derivation opts in; raw channel disclosure stays scoped. + return items.filter( + (item) => + item.channelId === channelId || + (includeWorkerBoundaries && + "kind" in item && + item.kind === "agent_initialized"), + ); } /** diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index ffc7f522037..4d6e8f69809 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -1,3 +1,8 @@ +import { + emptyCompactionActivityState, + processCompactionActivity, + type CompactionActivityState, +} from "./agentSessionCompaction"; import type { AgentActivityDescriptor, AgentActivityRenderClass, @@ -49,6 +54,7 @@ export type TranscriptState = { >; continuationSeq: number; latestSessionId: string | null; + compactions: CompactionActivityState; }; export function createEmptyTranscriptState(): TranscriptState { @@ -61,6 +67,7 @@ export function createEmptyTranscriptState(): TranscriptState { pendingPermissions: new Map(), continuationSeq: 0, latestSessionId: null, + compactions: emptyCompactionActivityState(), }; } @@ -81,6 +88,7 @@ type TranscriptDraft = { >; continuationSeq: number; latestSessionId: string | null; + compactions: CompactionActivityState; changed: boolean; }; @@ -94,6 +102,7 @@ function draftFrom(state: TranscriptState): TranscriptDraft { pendingPermissions: state.pendingPermissions, continuationSeq: state.continuationSeq, latestSessionId: state.latestSessionId, + compactions: state.compactions, changed: false, }; } @@ -1105,7 +1114,7 @@ export function processTranscriptEvent( updateType, ); } - } else { + } else if (updateType !== "compaction_update") { // Free-form observer status records are not part of the ACP session/update // union. Surface only explicit title/text payloads; leave all other // unknown frames out of the feed instead of guessing at semantics. @@ -1143,7 +1152,20 @@ export function processTranscriptEvent( } } - if (!d.changed && d.latestSessionId === state.latestSessionId) { + const compaction = processCompactionActivity(d.compactions, event, d.items); + d.compactions = compaction.state; + for (const item of compaction.upserts) { + if (d.itemsById.has(item.id)) replaceItem(d, item.id, item); + else { + sealOpenMessages(d); + pushItem(d, item); + } + } + if ( + !d.changed && + d.latestSessionId === state.latestSessionId && + d.compactions === state.compactions + ) { return state; } @@ -1156,6 +1178,7 @@ export function processTranscriptEvent( pendingPermissions: d.pendingPermissions, continuationSeq: d.continuationSeq, latestSessionId: d.latestSessionId, + compactions: d.compactions, }; } diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index 795f1fcd164..b4fbdaa2a7b 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -531,7 +531,13 @@ export function buildTranscriptDisplayBlocks( items: TranscriptItem[], latestLiveSessionId: string | null = null, ): TranscriptDisplayBlock[] { - const sessionRuns = splitIntoSessionRuns(items); + const sessionRuns = splitIntoSessionRuns( + items.filter( + (item) => + item.type !== "tool" || + item.descriptor?.reason !== "typed-compaction-duplicate", + ), + ); // Fast path: single session (or zero) — no boundary blocks needed. if (sessionRuns.length <= 1) { diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts index 578f98076cd..fc1372a6824 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -1,3 +1,4 @@ +import type { CompactionActivity } from "./agentSessionCompaction"; import type { LucideIcon } from "lucide-react"; export type ObserverEvent = { @@ -109,6 +110,7 @@ export type TranscriptItem = text: string; /** Resolved outcome for permission items (e.g. "Approved (allow_once)", "Denied (reject_once)", "Cancelled"). */ outcome?: string; + compaction?: CompactionActivity; timestamp: string; descriptor?: AgentActivityDescriptor; acpSource?: TranscriptAcpSource; @@ -131,6 +133,7 @@ export type TranscriptItem = toolName: string; buzzToolName: string | null; status: ToolStatus; + compactionScope?: string; args: Record; result: string; isError: boolean; diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index c3c5ce984aa..d2910e6e629 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -133,6 +133,14 @@ export function AgentSessionThreadPanel({ () => mergeObserverEventWindows(scopedEvents, archivedChannelEvents), [scopedEvents, archivedChannelEvents], ); + const combinedTranscriptEvents = React.useMemo( + () => + mergeObserverEventWindows( + scopeByChannel(events, sessionChannelId, true), + archivedChannelEvents, + ), + [events, sessionChannelId, archivedChannelEvents], + ); const latestActivityAt = React.useMemo( () => getLatestActivityTimestamp(combinedHeaderEvents), [combinedHeaderEvents], @@ -184,8 +192,9 @@ export function AgentSessionThreadPanel({ // never keys (agentSessionTranscriptGrouping.ts:557-574), so we avoid // subscribing to the observer store from the outer panel. const transcriptBlockIds = React.useMemo( - () => (showRawFeed ? [] : deriveTranscriptBlockIds(combinedHeaderEvents)), - [combinedHeaderEvents, showRawFeed], + () => + showRawFeed ? [] : deriveTranscriptBlockIds(combinedTranscriptEvents), + [combinedTranscriptEvents, showRawFeed], ); // Stabilize the id array by VALUE so the hook's restoration effect (keyed on diff --git a/desktop/src/features/channels/useMessageEventProfilePubkeys.ts b/desktop/src/features/channels/useMessageEventProfilePubkeys.ts index 949ac86eb49..3bbffd41e01 100644 --- a/desktop/src/features/channels/useMessageEventProfilePubkeys.ts +++ b/desktop/src/features/channels/useMessageEventProfilePubkeys.ts @@ -4,6 +4,7 @@ import { collectMessageAuthorPubkeys, collectMessageMentionPubkeys, collectReactionActorPubkeys, + collectSessionActorPubkeys, } from "@/features/messages/lib/formatTimelineMessages"; import type { RelayEvent } from "@/shared/api/types"; @@ -18,6 +19,7 @@ export function useMessageEventProfilePubkeys( ...new Set([ ...collectMessageAuthorPubkeys(events, relaySelfPubkey), ...collectMessageMentionPubkeys(events), + ...collectSessionActorPubkeys(events), ...collectReactionActorPubkeys(events, relaySelfPubkey), ]), ]; diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index a803bc14859..816c2724a6b 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -51,7 +51,10 @@ import { useChannelMessagesQuery, useToggleReactionMutation, } from "@/features/messages/hooks"; -import { collectMessageMentionPubkeys } from "@/features/messages/lib/formatTimelineMessages"; +import { + collectMessageMentionPubkeys, + collectSessionActorPubkeys, +} from "@/features/messages/lib/formatTimelineMessages"; import { formatTime } from "@/features/messages/lib/dateFormatters"; import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog"; import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown"; @@ -324,8 +327,10 @@ export function HomeView({ ...new Set([ ...feedItems.map((item) => item.pubkey), ...collectMessageMentionPubkeys(feedItems), + ...collectSessionActorPubkeys(feedItems), ...threadContext.events.map((event) => event.pubkey), ...collectMessageMentionPubkeys(threadContext.events), + ...collectSessionActorPubkeys(threadContext.events), ...(channelMessages ?? []) .filter((event) => event.kind === KIND_REACTION) .map((event) => event.pubkey), diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs b/desktop/src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs new file mode 100644 index 00000000000..af6c0076ecc --- /dev/null +++ b/desktop/src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs @@ -0,0 +1,469 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; + +import { formatTimelineMessages } from "./formatTimelineMessages.ts"; + +// Session status is self-reported display metadata on an agent's own kind-9 +// messages. These tests drive it through the production formatter: the +// explanation belongs to the earliest loaded message of one transition +// (signer, channel, scope, transition UUID) and every other message keeps +// only its reported percentage. + +const AGENT_A = + "1111111111111111111111111111111111111111111111111111111111111111"; +const AGENT_B = + "2222222222222222222222222222222222222222222222222222222222222222"; +const HUMAN = + "3333333333333333333333333333333333333333333333333333333333333333"; +const RELAY_SECRET = new Uint8Array(32).fill(3); +const RELAY_PUBKEY = getPublicKey(RELAY_SECRET); +const CHANNEL = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; +const OTHER_CHANNEL = "8d764100-fd8f-44cf-9c98-6d8fbd739b8c"; +const T1 = "6f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b"; +const T2 = "0f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b"; +const ROOT_1 = "a".repeat(64); +const ROOT_2 = "b".repeat(64); + +const PROFILES = { + [AGENT_A]: { displayName: "Agent A", isAgent: true, ownerPubkey: HUMAN }, + [AGENT_B]: { displayName: "Agent B", isAgent: true, ownerPubkey: HUMAN }, + [HUMAN]: { displayName: "Human" }, +}; + +let nextId = 1; +function hexId(n = nextId++) { + return n.toString(16).padStart(64, "0"); +} + +function status(state, reason, percent = "") { + return ["buzz-session", state, reason, percent]; +} + +function info(transition, scope, read = "", total = "", mode = "unknown") { + return ["buzz-session-info", "1", transition, scope, read, total, mode]; +} + +function message({ + id = hexId(), + pubkey = AGENT_A, + createdAt = 1_700_000_000, + channel = CHANNEL, + root = null, + kind = 9, + tags = [], +}) { + const threadTags = root + ? [ + ["e", root, "", "root"], + ["e", root, "", "reply"], + ] + : []; + return { + id, + pubkey, + kind, + created_at: createdAt, + content: `message ${id.slice(-4)}`, + tags: [["h", channel], ...threadTags, ...tags], + sig: "sig", + }; +} + +function project(events, relaySelfPubkey = undefined) { + const rows = formatTimelineMessages( + events, + null, + HUMAN, + null, + PROFILES, + undefined, + undefined, + undefined, + relaySelfPubkey, + ); + return new Map(rows.map((row) => [row.id, row.sessionStatus])); +} + +const NEW_EXPLANATION = { title: "New session" }; + +test("first message of a transition explains it; later ones keep only the percentage", () => { + const first = message({ + createdAt: 10, + tags: [ + status("new", "first_in_thread"), + info(T1, "conversation", "12", "37", "exact"), + ], + }); + const second = message({ + createdAt: 20, + tags: [status("new", "first_in_thread", "64"), info(T1, "conversation")], + }); + const third = message({ + createdAt: 30, + tags: [status("new", "first_in_thread"), info(T1, "conversation")], + }); + const out = project([first, second, third]); + assert.deepEqual(out.get(first.id), { + explanation: { + title: "New session", + history: ["read 12 of 37 messages"], + }, + }); + assert.deepEqual(out.get(second.id), { percent: 64 }); + // Unknown occupancy on a non-owner: no row at all, never 0%. + assert.equal(out.get(third.id), undefined); +}); + +test("the owner shows its own percentage beside the explanation", () => { + const first = message({ + tags: [status("resumed", "idle_sleep", "81"), info(T1, "conversation")], + }); + assert.deepEqual(project([first]).get(first.id), { + explanation: { title: "Resumed after sleep" }, + percent: 81, + }); +}); + +test("two agents with the same transition UUID each keep their own explanation", () => { + const a1 = message({ + pubkey: AGENT_A, + createdAt: 10, + tags: [status("new", "first_in_thread"), info(T1, "conversation")], + }); + const b1 = message({ + pubkey: AGENT_B, + createdAt: 11, + tags: [status("resumed", "harness_restart", "5"), info(T1, "conversation")], + }); + const a2 = message({ + pubkey: AGENT_A, + createdAt: 12, + tags: [status("new", "first_in_thread", "7"), info(T1, "conversation")], + }); + const b2 = message({ + pubkey: AGENT_B, + createdAt: 13, + tags: [status("resumed", "harness_restart", "9"), info(T1, "conversation")], + }); + const out = project([b2, a2, b1, a1]); + assert.deepEqual(out.get(a1.id), { explanation: NEW_EXPLANATION }); + assert.deepEqual(out.get(b1.id), { + explanation: { title: "Resumed after restart" }, + percent: 5, + }); + assert.deepEqual(out.get(a2.id), { percent: 7 }); + assert.deepEqual(out.get(b2.id), { percent: 9 }); +}); + +test("thread scopes, conversation scope and channels never share an explanation", () => { + const tags = (scope) => [status("new", "first_in_thread"), info(T1, scope)]; + const thread1 = message({ + createdAt: 10, + root: ROOT_1, + tags: tags("thread"), + }); + const thread2 = message({ + createdAt: 11, + root: ROOT_2, + tags: tags("thread"), + }); + const conversation = message({ + createdAt: 12, + tags: tags("conversation"), + }); + const otherChannel = message({ + createdAt: 13, + channel: OTHER_CHANNEL, + tags: tags("conversation"), + }); + const out = project([thread1, thread2, conversation, otherChannel]); + for (const event of [thread1, thread2, conversation, otherChannel]) { + assert.deepEqual(out.get(event.id), { explanation: NEW_EXPLANATION }); + } +}); + +test("conversation scope groups across top-level messages and thread replies", () => { + const top = message({ + createdAt: 10, + tags: [status("new", "first_in_thread"), info(T1, "conversation")], + }); + const inThread = message({ + createdAt: 11, + root: ROOT_1, + tags: [status("new", "first_in_thread", "3"), info(T1, "conversation")], + }); + const out = project([top, inThread]); + assert.deepEqual(out.get(top.id), { explanation: NEW_EXPLANATION }); + assert.deepEqual(out.get(inThread.id), { percent: 3 }); +}); + +test("interleaved human replies do not reset a thread transition", () => { + const agent1 = message({ + createdAt: 10, + root: ROOT_1, + tags: [status("resumed", "worker_crash", "30"), info(T2, "thread")], + }); + const human1 = message({ pubkey: HUMAN, createdAt: 11, root: ROOT_1 }); + const agent2 = message({ + createdAt: 12, + root: ROOT_1, + tags: [status("resumed", "worker_crash", "31"), info(T2, "thread")], + }); + const human2 = message({ pubkey: HUMAN, createdAt: 13, root: ROOT_1 }); + const agent3 = message({ + createdAt: 14, + root: ROOT_1, + tags: [status("resumed", "worker_crash", "32"), info(T2, "thread")], + }); + const out = project([agent1, human1, agent2, human2, agent3]); + assert.deepEqual(out.get(agent1.id), { + explanation: { title: "Resumed after worker failure" }, + percent: 30, + }); + assert.equal(out.get(human1.id), undefined); + assert.deepEqual(out.get(agent2.id), { percent: 31 }); + assert.deepEqual(out.get(agent3.id), { percent: 32 }); +}); + +test("same-second messages order by event id, independent of input order", () => { + const low = message({ + id: hexId(0x100), + createdAt: 50, + tags: [status("new", "first_in_thread", "1"), info(T1, "conversation")], + }); + const high = message({ + id: hexId(0x200), + createdAt: 50, + tags: [status("new", "first_in_thread", "2"), info(T1, "conversation")], + }); + for (const order of [ + [low, high], + [high, low], + ]) { + const out = project(order); + assert.deepEqual(out.get(low.id), { + explanation: NEW_EXPLANATION, + percent: 1, + }); + assert.deepEqual(out.get(high.id), { percent: 2 }); + } +}); + +test("deleting the owner moves the explanation to the next loaded message", () => { + const first = message({ + createdAt: 10, + tags: [status("new", "first_in_thread"), info(T1, "conversation")], + }); + const second = message({ + createdAt: 20, + tags: [status("new", "first_in_thread", "40"), info(T1, "conversation")], + }); + const deletion = { + id: hexId(), + pubkey: AGENT_A, + kind: 5, + created_at: 30, + content: "", + tags: [ + ["h", CHANNEL], + ["e", first.id], + ], + sig: "sig", + }; + const out = project([first, second, deletion]); + assert.equal(out.has(first.id), false); + assert.deepEqual(out.get(second.id), { + explanation: NEW_EXPLANATION, + percent: 40, + }); +}); + +test("an earlier page entering the window takes over the explanation", () => { + const older = message({ + createdAt: 10, + tags: [status("new", "first_in_thread"), info(T1, "conversation")], + }); + const newer = message({ + createdAt: 20, + tags: [status("new", "first_in_thread", "55"), info(T1, "conversation")], + }); + const before = project([newer]); + assert.deepEqual(before.get(newer.id), { + explanation: NEW_EXPLANATION, + percent: 55, + }); + const after = project([older, newer]); + assert.deepEqual(after.get(older.id), { explanation: NEW_EXPLANATION }); + assert.deepEqual(after.get(newer.id), { percent: 55 }); +}); + +test("an edit cannot add or change status; the original signed tags win", () => { + const original = message({ + createdAt: 10, + tags: [status("new", "first_in_thread", "20"), info(T1, "conversation")], + }); + const plain = message({ createdAt: 11 }); + const editOf = (target, createdAt) => ({ + id: hexId(), + pubkey: AGENT_A, + kind: 40003, + created_at: createdAt, + content: "edited", + tags: [ + ["h", CHANNEL], + ["e", target.id], + status("resumed", "harness_restart", "99"), + info(T2, "conversation", "1", "1", "exact"), + ], + sig: "sig", + }); + const rows = formatTimelineMessages( + [original, plain, editOf(original, 20), editOf(plain, 21)], + null, + HUMAN, + null, + PROFILES, + ); + const byId = new Map(rows.map((row) => [row.id, row])); + assert.equal(byId.get(original.id).body, "edited"); + assert.deepEqual(byId.get(original.id).sessionStatus, { + explanation: NEW_EXPLANATION, + percent: 20, + }); + assert.equal(byId.get(plain.id).edited, true); + assert.equal(byId.get(plain.id).sessionStatus, undefined); +}); + +test("a human copying status tags gets no status row", () => { + const copied = message({ + pubkey: HUMAN, + tags: [status("new", "first_in_thread", "50"), info(T1, "conversation")], + }); + assert.equal(project([copied]).get(copied.id), undefined); +}); + +test("a relay-signed event attributed to an agent cannot claim its session state", () => { + const event = finalizeEvent( + { + kind: 9, + created_at: 1_700_000_000, + content: "relayed", + tags: [ + ["h", CHANNEL], + ["actor", AGENT_A], + status("new", "first_in_thread", "50"), + info(T1, "conversation"), + ], + }, + RELAY_SECRET, + ); + const out = formatTimelineMessages( + [event], + null, + HUMAN, + null, + PROFILES, + undefined, + undefined, + undefined, + RELAY_PUBKEY, + ); + assert.equal(out[0].pubkey, AGENT_A); + assert.equal(out[0].isAgent, true); + assert.equal(out[0].sessionStatus, undefined); +}); + +test("a locally pending event cannot own an explanation", () => { + const pending = { + ...message({ + tags: [status("new", "first_in_thread", "50"), info(T1, "conversation")], + }), + pending: true, + }; + assert.equal(project([pending]).get(pending.id), undefined); +}); + +test("non-message kinds from an agent carry no status", () => { + const system = message({ + kind: 40099, + tags: [status("new", "first_in_thread", "50"), info(T1, "conversation")], + }); + assert.equal(project([system]).get(system.id), undefined); +}); + +test("valid primary-only status keeps its explanation per message without counts", () => { + const supporting = [ + [], + [["buzz-session-info", "1", "invalid", "conversation", "3", "4", "exact"]], + [["buzz-session-info", "2", T1, "conversation", "3", "4", "exact"]], + [info(T1, "conversation", "3", "4", "exact"), info(T2, "conversation")], + ]; + for (const tags of supporting) { + for (const percent of ["", "64"]) { + const first = message({ + createdAt: 10, + tags: [status("resumed", "idle_sleep", percent), ...tags], + }); + const second = message({ + createdAt: 11, + tags: [status("resumed", "idle_sleep", percent), ...tags], + }); + const out = project([second, first]); + const expected = { explanation: { title: "Resumed after sleep" } }; + if (percent) expected.percent = 64; + assert.deepEqual(out.get(first.id), expected); + assert.deepEqual( + out.get(second.id), + expected, + "no invented grouping key", + ); + } + } +}); + +test("thread scope without a thread root falls back to primary text without counts", () => { + const orphan = message({ + tags: [ + status("new", "first_in_thread", "8"), + info(T1, "thread", "3", "4", "exact"), + ], + }); + assert.deepEqual(project([orphan]).get(orphan.id), { + explanation: { title: "New session" }, + percent: 8, + }); +}); + +test("invalid primary tag yields no status even with valid supporting metadata", () => { + const bad = message({ + tags: [ + status("new", "resume_failed:/home/user/secret", "12"), + info(T1, "conversation", "1", "2", "exact"), + ], + }); + assert.equal(project([bad]).get(bad.id), undefined); +}); + +test("resume failure, unknown context and incomplete history read truthfully", () => { + const event = message({ + root: ROOT_1, + tags: [ + status("new", "resume_failed:not_found"), + info(T1, "thread", "12", "", "lower_bound"), + ], + }); + assert.deepEqual(project([event]).get(event.id), { + explanation: { + title: "New session", + detail: "resume failed: previous session not found", + history: ["read 12 messages", "Earlier history may be omitted"], + }, + }); +}); + +test("rows without status tags keep the old shape", () => { + const plain = message({ pubkey: HUMAN }); + const [row] = formatTimelineMessages([plain], null, HUMAN, null, PROFILES); + assert.equal("sessionStatus" in row, false); +}); diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index b35e8140481..c70c337774c 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -1,3 +1,4 @@ +import { attachSessionControls } from "@/features/messages/lib/sessionControl"; import type { Channel, ChannelMember, @@ -45,6 +46,10 @@ import { formatTime } from "@/features/messages/lib/dateFormatters"; // Pure overlay helper lives in a sibling .mjs so node:test (no TS loader) // can exercise the exact same source the renderer uses. import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs"; +import { + parseSessionStatusTags, + attachSessionStatuses, +} from "@/features/messages/lib/sessionStatus"; import { truncateNpub } from "@/shared/lib/pubkey"; const HEX_RE = /^[0-9a-f]+$/i; @@ -459,7 +464,7 @@ export function formatTimelineMessages( return depth; } - return visibleEvents.map((event) => { + const messages: TimelineMessage[] = visibleEvents.map((event) => { const author = getAuthorLabel(event); const authorPubkey = authorPubkeyByEventId.get(event.id) ?? @@ -542,6 +547,21 @@ export function formatTimelineMessages( })(), }; }); + // Self-reported agent session status, from each event's original tags. + attachSessionControls( + messages, + visibleEvents, + events.filter((event) => !deletedEventIds.has(event.id)), + profiles, + undefined, + roleByPubkey, + ); + return attachSessionStatuses( + messages, + visibleEvents, + channel?.id ?? null, + profiles, + ); } function extractSystemMessagePubkeys(event: RelayEvent): string[] { @@ -657,6 +677,24 @@ export function collectMessageProfilePubkeys(events: RelayEvent[]) { ...collectMessageAuthorPubkeys(events), ...collectMessageMentionPubkeys(events), ...collectReactionActorPubkeys(events), + ...collectSessionActorPubkeys(events), ]), ]; } + +/** Valid manual transition actors join the surface's existing batched profile read. */ +export function collectSessionActorPubkeys( + events: Array<{ kind?: number; tags?: string[][] }>, +): string[] { + return [ + ...new Set( + events.flatMap((event) => { + const actor = + event.kind === 9 + ? parseSessionStatusTags(event.tags)?.actorPubkey + : null; + return actor ? [actor] : []; + }), + ), + ]; +} diff --git a/desktop/src/features/messages/lib/messageGrouping.ts b/desktop/src/features/messages/lib/messageGrouping.ts index df8f125bb0e..0dbbc319ee4 100644 --- a/desktop/src/features/messages/lib/messageGrouping.ts +++ b/desktop/src/features/messages/lib/messageGrouping.ts @@ -2,6 +2,7 @@ import { getSentFromThreadRootId } from "@/features/messages/lib/sentFromThread" type MessageAuthorCandidate = { pubkey?: string | null; + sessionControl?: unknown; }; type MessageGroupingCandidate = { @@ -31,6 +32,7 @@ export function hasSameMessageAuthor( previous: MessageAuthorCandidate | null | undefined, current: MessageAuthorCandidate | null | undefined, ) { + if (previous?.sessionControl || current?.sessionControl) return false; const previousPubkey = previous?.pubkey?.trim().toLowerCase(); const currentPubkey = current?.pubkey?.trim().toLowerCase(); diff --git a/desktop/src/features/messages/lib/messageRowEquality.test.mjs b/desktop/src/features/messages/lib/messageRowEquality.test.mjs index df3275646aa..c390c59fed5 100644 --- a/desktop/src/features/messages/lib/messageRowEquality.test.mjs +++ b/desktop/src/features/messages/lib/messageRowEquality.test.mjs @@ -5,6 +5,7 @@ import { depthGuideActionsEqual, numberArrayEqual, reactionsEqual, + sessionStatusEqual, tagsEqual, } from "./messageRowEquality.ts"; @@ -112,3 +113,39 @@ test("depthGuideActionsEqual: same values (message by id) → equal", () => { false, ); }); + +test("sessionStatusEqual: value equality over explanation, history and percent", () => { + const status = () => ({ + explanation: { + title: "New session", + detail: "resume failed", + history: ["read 1 of 2 messages"], + }, + percent: 40, + }); + assert.equal(sessionStatusEqual(undefined, undefined), true); + assert.equal(sessionStatusEqual(status(), status()), true); + assert.equal(sessionStatusEqual(status(), undefined), false); + assert.equal(sessionStatusEqual(status(), { percent: 40 }), false); + assert.equal( + sessionStatusEqual(status(), { ...status(), percent: 41 }), + false, + ); + assert.equal( + sessionStatusEqual(status(), { + ...status(), + explanation: { ...status().explanation, detail: undefined }, + }), + false, + ); + assert.equal( + sessionStatusEqual(status(), { + ...status(), + explanation: { + ...status().explanation, + history: ["read 1 of at least 2 messages"], + }, + }), + false, + ); +}); diff --git a/desktop/src/features/messages/lib/messageRowEquality.ts b/desktop/src/features/messages/lib/messageRowEquality.ts index def5d62e064..f04a140f159 100644 --- a/desktop/src/features/messages/lib/messageRowEquality.ts +++ b/desktop/src/features/messages/lib/messageRowEquality.ts @@ -1,4 +1,5 @@ import type { + MessageSessionStatus, TimelineMessage, TimelineReaction, } from "@/features/messages/types"; @@ -69,6 +70,28 @@ export function reactionsEqual( return true; } +/** Derived session status: rebuilt per format pass, compared by value. The + * explanation can move between rows whose own tags did not change, so this + * must be compared in addition to `tagsEqual`. */ +export function sessionStatusEqual( + a: MessageSessionStatus | undefined, + b: MessageSessionStatus | undefined, +): boolean { + if (a === b) return true; + if (!a || !b || a.percent !== b.percent) return false; + const left = a.explanation; + const right = b.explanation; + if (left === right) return true; + if (!left || !right) return false; + if (left.title !== right.title || left.detail !== right.detail) return false; + const leftHistory = left.history ?? []; + const rightHistory = right.history ?? []; + return ( + leftHistory.length === rightHistory.length && + leftHistory.every((phrase, index) => phrase === rightHistory[index]) + ); +} + export function numberArrayEqual( a: readonly number[] | undefined, b: readonly number[] | undefined, @@ -117,3 +140,27 @@ export function depthGuideActionsEqual( } return true; } + +/** Results arrive independently of the request body and ordinary reaction pills. */ +export function sessionControlEqual( + a: TimelineMessage["sessionControl"], + b: TimelineMessage["sessionControl"], +): boolean { + if (a === b) return true; + return Boolean( + a && + b && + a.action === b.action && + a.targets.length === b.targets.length && + a.targets.every((target, i) => { + const other = b.targets[i]; + return ( + target.pubkey === other.pubkey && + target.name === other.name && + target.state === other.state && + target.detail === other.detail && + target.expiresAt === other.expiresAt + ); + }), + ); +} diff --git a/desktop/src/features/messages/lib/sessionControl.test.mjs b/desktop/src/features/messages/lib/sessionControl.test.mjs new file mode 100644 index 00000000000..988ee2bd545 --- /dev/null +++ b/desktop/src/features/messages/lib/sessionControl.test.mjs @@ -0,0 +1,290 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import fs from "node:fs"; +import { parseSessionCommand } from "./sessionControl.ts"; +import { formatTimelineMessages } from "./formatTimelineMessages.ts"; +import { hasSameMessageAuthor } from "./messageGrouping.ts"; +const A = "a".repeat(64), + B = "b".repeat(64), + H = "c".repeat(64), + ID = "d".repeat(64); +const profiles = { + [A]: { displayName: "Agent A", isAgent: true }, + [B]: { displayName: "Agent B", isAgent: true }, + [H]: { displayName: "Human" }, +}; +const request = (extra = {}) => ({ + id: ID, + pubkey: H, + kind: 9, + content: "!restart", + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["h", "channel"], + ["p", A], + ], + sig: "sig", + ...extra, +}); +const reaction = (outcome = "ok", code = "", extra = {}) => ({ + id: "e".repeat(64), + pubkey: A, + kind: 7, + content: outcome === "ok" ? "✅" : outcome === "pending" ? "⏳" : "❌", + created_at: 3, + tags: [ + ["h", "channel"], + ["e", ID], + ["buzz-session-result", "1", ID, outcome, code], + ], + sig: "sig", + ...extra, +}); +const rows = (events) => + formatTimelineMessages(events, null, H, null, profiles); +const control = (events) => + rows(events).find((row) => row.id === ID)?.sessionControl; +test("shared exact grammar matches the actual core parser whitespace corpus", () => { + const fixture = JSON.parse( + fs.readFileSync( + new URL( + "../../../../../crates/buzz-core/tests/fixtures/session_controls.json", + import.meta.url, + ), + ), + ); + for (const entry of fixture.commands) + assert.equal( + parseSessionCommand(entry.content)?.content ?? null, + entry.expected, + JSON.stringify(entry.content), + ); +}); +test("formatter reads original signed kind9 body, keeps identity, and requires known exact targets", () => { + const original = request(); + const edit = { + ...request(), + id: "f".repeat(64), + kind: 40003, + content: "ordinary edit", + created_at: original.created_at + 1, + tags: [ + ["h", "channel"], + ["e", ID], + ], + }; + assert.ok(control([original, edit])); + assert.equal( + control([ + request({ content: "ordinary" }), + { ...edit, content: "!compact" }, + ]), + undefined, + ); + for (const value of [ + request({ kind: 40002 }), + request({ tags: [["p", H]] }), + request({ content: "!restart recent" }), + request({ content: "!compact @Agent A" }), + ]) + assert.equal(control([value]), undefined); + const row = rows([original])[0]; + assert.equal(row.id, ID); + assert.equal(row.kind, 9); + assert.equal(row.depth, 0); + assert.equal(row.sessionControl.action, "Start over with recent history"); + assert.equal(hasSameMessageAuthor(row, { pubkey: H }), false); + assert.equal(hasSameMessageAuthor({ pubkey: H }, row), false); +}); +test("results require exact target raw signer, exact e reference and bounded tag; conflicts are unconfirmed", () => { + assert.equal(control([request(), reaction()]).targets[0].state, "success"); + assert.equal( + control([request(), reaction("pending")]).targets[0].detail, + "Waiting for current work to stop", + ); + assert.equal( + control([request({ content: "!compact" }), reaction("pending")]).targets[0] + .detail, + "Waiting for current work to finish · Compaction will start automatically", + ); + assert.equal( + control([request(), reaction("pending"), reaction()]).targets[0].state, + "success", + ); + assert.equal( + control([ + request(), + reaction(), + reaction("error", "busy", { id: "1".repeat(64) }), + ]).targets[0].state, + "unconfirmed", + ); + for (const bad of [ + reaction("ok", "", { pubkey: H }), + reaction("ok", "", { pubkey: B }), + reaction("ok", "", { + tags: [ + ["e", "0".repeat(64)], + ["buzz-session-result", "1", ID, "ok", ""], + ], + }), + reaction("ok", "", { tags: [["e", ID]] }), + reaction("ok", "", { + tags: [ + ["e", ID], + ["buzz-session-result", "1", ID, "ok", "raw/path"], + ], + }), + reaction("ok", "", { pending: true }), + ]) + assert.notEqual(control([request(), bad]).targets[0].state, "success"); + const multi = control([ + request({ + tags: [ + ["p", A], + ["p", B], + ], + }), + reaction(), + ]); + assert.equal(multi.targets[0].state, "success"); + assert.notEqual(multi.targets[1].state, "success"); + assert.equal( + control([request({ created_at: 1 })]).targets[0].state, + "expired", + ); + assert.equal( + control([request({ pending: true }), reaction()]).targets[0].state, + "publishing", + ); +}); +const T = "6f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b"; +test("compact wait expires without a terminal and fresh wait reports cover late admission", () => { + const now = Math.floor(Date.now() / 1000); + const command = request({ content: "!compact", created_at: now - 2000 }); + const oldWait = reaction("pending", "", { created_at: now - 1100 }); + assert.equal(control([command, oldWait]).targets[0].state, "expired"); + const freshWait = reaction("pending", "", { created_at: now }); + assert.equal(control([command, freshWait]).targets[0].state, "pending"); + assert.equal(control([command, freshWait]).targets[0].expiresAt, now + 1050); + assert.equal( + control([command, oldWait, reaction()]).targets[0].state, + "success", + ); +}); +test("manual status uses matching bounded actor metadata and neutral fallback without it", () => { + const tags = [ + ["h", "channel"], + ["buzz-session", "compacted", "user_compact", "18"], + ["buzz-session-info", "1", T, "conversation", "", "", "unknown"], + ["buzz-session-action", "1", T, ID, H], + ]; + const event = request({ pubkey: A, content: "Done", tags }); + assert.equal( + rows([event])[0].sessionStatus.explanation.title, + "Context compacted by Human", + ); + assert.equal( + rows([{ ...event, tags: tags.slice(0, -1) }])[0].sessionStatus.explanation + .title, + "Context compacted", + ); + assert.equal( + rows([ + { ...event, tags: [...tags, ["buzz-session-action", "1", T, ID, B]] }, + ])[0].sessionStatus.explanation.title, + "Context compacted", + ); + assert.equal( + rows([ + { + ...event, + tags: tags.map((tag) => + tag[0] === "buzz-session-action" + ? [...tag.slice(0, 2), "wrong", ID, H] + : tag, + ), + }, + ])[0].sessionStatus.explanation.title, + "Context compacted", + ); +}); + +test("known bot membership identifies targets even before optional profile metadata arrives", () => { + const [row] = formatTimelineMessages([request()], null, H, null, {}, [ + { pubkey: A, role: "bot" }, + ]); + assert.ok(row.sessionControl); +}); + +test("delegated relay reaction pills cannot confirm a target-signed session result", async () => { + const { finalizeEvent, getPublicKey } = await import("nostr-tools/pure"); + const secret = new Uint8Array(32).fill(3), + relay = getPublicKey(secret); + const delegated = finalizeEvent( + { + kind: 7, + created_at: 3, + content: "✅", + tags: [ + ["h", "channel"], + ["actor", A], + ["e", ID], + ["buzz-session-result", "1", ID, "ok", ""], + ], + }, + secret, + ); + const [row] = formatTimelineMessages( + [request(), delegated], + null, + H, + null, + profiles, + undefined, + undefined, + undefined, + relay, + ); + assert.equal(row.reactions[0].users[0].pubkey, A); + assert.equal(row.sessionControl.targets[0].state, "unconfirmed"); +}); +test("deleting an outcome removes confirmation and malformed conflicting enrichment stays unconfirmed", () => { + const result = reaction(); + const deleted = request({ + id: "1".repeat(64), + kind: 5, + content: "", + tags: [["e", result.id]], + }); + assert.equal( + control([request(), result, deleted]).targets[0].state, + "unconfirmed", + ); + const malformed = reaction("ok", "", { + id: "2".repeat(64), + tags: [ + ["e", ID], + ["buzz-session-result", "1", ID, "ok", "raw/path"], + ], + }); + assert.equal( + control([request(), result, malformed]).targets[0].state, + "unconfirmed", + ); +}); + +test("ordinary target reactions have no authority over a separate structured result", () => { + const ordinary = reaction("ok", "", { + id: "3".repeat(64), + content: "👍", + tags: [ + ["h", "channel"], + ["e", ID], + ], + }); + assert.equal( + control([request(), reaction(), ordinary]).targets[0].state, + "success", + ); +}); diff --git a/desktop/src/features/messages/lib/sessionControl.ts b/desktop/src/features/messages/lib/sessionControl.ts new file mode 100644 index 00000000000..ad90b65ee15 --- /dev/null +++ b/desktop/src/features/messages/lib/sessionControl.ts @@ -0,0 +1,199 @@ +import type { TimelineMessage } from "@/features/messages/types"; +import type { RelayEvent } from "@/shared/api/types"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; + +export type SessionCommand = { + type: "compact" | "restart"; + depth?: "none" | "recent" | "all"; + content: "!compact" | "!restart" | "!restart none" | "!restart all"; +}; +/** Rust str::trim's Unicode White_Space set; JS trim differs at NEL and BOM. */ +const EXTERNAL_WHITESPACE = /^\p{White_Space}+|\p{White_Space}+$/gu; +export const CANONICAL_CONTROL_ID = /^[0-9a-f]{64}$/; +/** Parse the five spellings consumed by the harness, without altering ordinary text. */ +export function parseSessionCommand(content: string): SessionCommand | null { + switch (content.replace(EXTERNAL_WHITESPACE, "")) { + case "!compact": + return { type: "compact", content: "!compact" }; + case "!restart": + case "!rotate": + return { type: "restart", depth: "recent", content: "!restart" }; + case "!restart none": + return { type: "restart", depth: "none", content: "!restart none" }; + case "!restart all": + return { type: "restart", depth: "all", content: "!restart all" }; + default: + return null; + } +} +export type SessionControlTarget = { + pubkey: string; + name: string; + state: + | "publishing" + | "pending" + | "expired" + | "unconfirmed" + | "success" + | "failure"; + detail: string; + expiresAt?: number; +}; +export type MessageSessionControl = { + action: string; + targets: SessionControlTarget[]; +}; +const FAILURE_COPY: Readonly> = { + no_session: "No existing session", + unsupported: "Compaction is unavailable", + scope_unavailable: "Session scope unavailable", + busy: "Session control is busy", + stale_target: "Session changed", + stale_request: "Request expired", + store_unavailable: "Session could not be saved", + resume_failed: "Session could not resume", + compact_failed: "Compaction failed", + cancelled: "Request cancelled", + timeout: "Request timed out", + unconfirmed: "Result not confirmed", + superseded: "Replaced by another request", + internal: "Session control failed", +}; +function parseResult(event: RelayEvent, commandId: string): string | null { + const tags = event.tags.filter((tag) => tag[0] === "buzz-session-result"); + if (!tags.length) return null; + const first = tags[0]; + if ( + tags.some( + (tag) => tag.length !== 5 || tag.some((value, i) => value !== first[i]), + ) || + first[1] !== "1" || + first[2] !== commandId + ) + return null; + const [, , , outcome, code] = first; + if (outcome === "ok" && code === "" && event.content === "✅") return "ok"; + if (outcome === "pending" && code === "" && event.content === "⏳") + return "pending"; + if ( + outcome === "error" && + Object.hasOwn(FAILURE_COPY, code) && + event.content === "❌" + ) + return code; + return null; +} +function targetResult( + command: RelayEvent, + target: string, + reactions: readonly RelayEvent[], + now: number, +): Pick { + if (command.pending) + return { state: "publishing", detail: "Sending request…" }; + const reports = reactions.filter( + (event) => + event.kind === 7 && + !event.pending && + event.pubkey === target && + event.tags.some((tag) => tag[0] === "buzz-session-result") && + event.tags.some((tag) => tag[0] === "e" && tag[1] === command.id), + ); + const outcomes = reports.map((event) => parseResult(event, command.id)); + const terminals = new Set( + outcomes.filter((outcome) => outcome !== "pending"), + ); + if (terminals.has(null) || terminals.size > 1) + return { state: "unconfirmed", detail: "Result not confirmed" }; + const terminal = [...terminals][0]; + if (terminal === "ok") return { state: "success", detail: "Completed" }; + if (terminal) + return { + state: terminal === "unconfirmed" ? "unconfirmed" : "failure", + detail: FAILURE_COPY[terminal], + }; + if (outcomes.includes("pending")) { + const compact = parseSessionCommand(command.content)?.type === "compact"; + // Queued compaction is in memory. A lost harness or terminal publication + // must not leave an unconditional promise to start automatically forever. + // Admission may happen near request expiry, so use the signed wait report. + const expiresAt = compact + ? reports.reduce( + (latest, event) => + parseResult(event, command.id) === "pending" + ? Math.max(latest, event.created_at) + : latest, + command.created_at, + ) + + 900 + + 120 + + 30 + : undefined; + if (expiresAt !== undefined && now >= expiresAt) + return { + state: "expired", + detail: "Request expired · Result not confirmed", + }; + return { + state: "pending", + detail: compact + ? "Waiting for current work to finish · Compaction will start automatically" + : "Waiting for current work to stop", + expiresAt, + }; + } + return now - command.created_at > 900 + ? { state: "expired", detail: "Request expired · Result not confirmed" } + : { state: "unconfirmed", detail: "Result not confirmed" }; +} +/** Derive command rows from original signed requests and raw signed reactions. */ +export function attachSessionControls( + messages: TimelineMessage[], + originals: readonly RelayEvent[], + reactions: readonly RelayEvent[], + profiles?: UserProfileLookup, + now = Date.now() / 1000, + knownAgentRoles: ReadonlyMap = new Map(), +): TimelineMessage[] { + messages.forEach((message, index) => { + const event = originals[index]; + if (event.kind !== 9) return; + const command = parseSessionCommand(event.content); + if (!command) return; + const targets = [ + ...new Set( + event.tags + .filter( + (tag) => tag[0] === "p" && CANONICAL_CONTROL_ID.test(tag[1] ?? ""), + ) + .map((tag) => tag[1]), + ), + ]; + // An unknown address cannot silently become an agent control. + if ( + !targets.length || + targets.some( + (key) => + profiles?.[key]?.isAgent !== true && + knownAgentRoles.get(key) !== "bot", + ) + ) + return; + message.sessionControl = { + action: + command.type === "compact" + ? "Compact context" + : command.depth === "none" + ? "Start over from scratch" + : command.depth === "all" + ? "Start over with the entire thread" + : "Start over with recent history", + targets: targets.map((pubkey) => ({ + pubkey, + name: profiles?.[pubkey]?.displayName || "Agent", + ...targetResult(event, pubkey, reactions, now), + })), + }; + }); + return messages; +} diff --git a/desktop/src/features/messages/lib/sessionControlPermissions.test.mjs b/desktop/src/features/messages/lib/sessionControlPermissions.test.mjs new file mode 100644 index 00000000000..01205e0e597 --- /dev/null +++ b/desktop/src/features/messages/lib/sessionControlPermissions.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { canManageSession } from "./sessionControlPermissions.ts"; +const target = "a".repeat(64), + owner = "b".repeat(64), + viewer = "c".repeat(64); +const evidence = (policy = "owner-only", type = "stream") => ({ + targetPubkey: target, + agent: { + pubkey: target, + ownerPubkey: owner, + respondTo: policy, + respondToAllowlist: [viewer], + channelIds: ["channel"], + }, + channel: { + id: "channel", + channelType: type, + isMember: true, + archivedAt: null, + }, +}); +test("verified scope and exact policy evidence admit owner, sibling, allowlist and anyone", () => { + for (const policy of ["owner-only", "allowlist", "anyone"]) { + for (const type of ["stream", "forum", "dm"]) { + const e = evidence(policy, type); + assert.equal(canManageSession(e, owner, "channel"), true); + assert.equal( + canManageSession( + { ...e, requester: { pubkey: viewer, ownerPubkey: owner } }, + viewer, + "channel", + ), + true, + ); + assert.equal( + canManageSession(e, viewer, "channel"), + type !== "dm" && policy !== "owner-only", + ); + } + } +}); +test("unknown, stale local inventory, missing membership and wrong coordinates never grant authority", () => { + const e = evidence("anyone"); + for (const patch of [ + { agent: null }, + { agent: { ...e.agent, respondTo: null } }, + { agent: { ...e.agent, respondTo: "nobody" } }, + { agent: { ...e.agent, pubkey: viewer } }, + { agent: { ...e.agent, channelIds: [] } }, + { channel: null }, + { channel: { ...e.channel, channelType: "unknown" } }, + { channel: { ...e.channel, isMember: false } }, + { channel: { ...e.channel, id: "other" } }, + { agent: null, managedAgent: { pubkey: target, respondTo: "anyone" } }, + ]) + assert.equal(canManageSession({ ...e, ...patch }, owner, "channel"), false); + assert.equal( + canManageSession( + { ...evidence(), requester: { pubkey: target, ownerPubkey: owner } }, + viewer, + "channel", + ), + false, + ); +}); +test("remote verified cross-owner targets remain allowed and telemetry is optional", () => { + assert.equal(canManageSession(evidence("anyone"), viewer, "channel"), true); + assert.equal( + canManageSession(evidence("allowlist"), viewer, "channel"), + true, + ); +}); diff --git a/desktop/src/features/messages/lib/sessionControlPermissions.ts b/desktop/src/features/messages/lib/sessionControlPermissions.ts new file mode 100644 index 00000000000..7369075e32f --- /dev/null +++ b/desktop/src/features/messages/lib/sessionControlPermissions.ts @@ -0,0 +1,54 @@ +import type { Channel, RelayAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** Evidence from the verified relay directory and channel metadata, never inventory. */ +export type SessionPermissionEvidence = { + targetPubkey: string; + agent: Pick< + RelayAgent, + "pubkey" | "ownerPubkey" | "respondTo" | "respondToAllowlist" | "channelIds" + > | null; + channel: Pick< + Channel, + "id" | "channelType" | "isMember" | "archivedAt" + > | null; + /** Optional verified requester coordinate for the harness's sibling rule. */ + requester?: Pick | null; +}; + +/** Mirror normal listener admission, including the owner-only DM policy. */ +export function canManageSession( + evidence: SessionPermissionEvidence, + viewerPubkey: string, + channelId: string, +): boolean { + const { agent, channel, requester } = evidence; + const viewer = normalizePubkey(viewerPubkey); + if ( + !viewer || + !agent || + !channel || + channel.id !== channelId || + !channel.isMember || + channel.archivedAt !== null || + !["stream", "forum", "dm"].includes(channel.channelType) || + normalizePubkey(agent.pubkey) !== normalizePubkey(evidence.targetPubkey) || + !agent.channelIds.includes(channelId) || + !["owner-only", "allowlist", "anyone"].includes(agent.respondTo ?? "") + ) + return false; + const owner = agent.ownerPubkey ? normalizePubkey(agent.ownerPubkey) : null; + const isOwner = + owner !== null && + (viewer === owner || + (requester?.ownerPubkey && + normalizePubkey(requester.pubkey) === viewer && + normalizePubkey(requester.ownerPubkey) === owner)); + if (isOwner) return true; + if (channel.channelType === "dm") return false; + return ( + agent.respondTo === "anyone" || + (agent.respondTo === "allowlist" && + agent.respondToAllowlist.some((key) => normalizePubkey(key) === viewer)) + ); +} diff --git a/desktop/src/features/messages/lib/sessionStatus.test.mjs b/desktop/src/features/messages/lib/sessionStatus.test.mjs new file mode 100644 index 00000000000..991a2cf33e3 --- /dev/null +++ b/desktop/src/features/messages/lib/sessionStatus.test.mjs @@ -0,0 +1,421 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CONTEXT_HIGHLIGHT_PERCENT, + describeHistory, + describeSessionTransition, + formatContextPercent, + isHighContext, + parseSessionInfoTag, + parseSessionPrimaryTag, + parseSessionStatusTags, +} from "./sessionStatus.ts"; + +const UUID_A = "6f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b"; + +function primary(state = "new", reason = "first_in_thread", percent = "") { + return ["buzz-session", state, reason, percent]; +} + +function info({ + version = "1", + transition = UUID_A, + scope = "conversation", + read = "", + total = "", + mode = "unknown", +} = {}) { + return ["buzz-session-info", version, transition, scope, read, total, mode]; +} + +// --------------------------------------------------------------------------- +// Primary tag +// --------------------------------------------------------------------------- + +test("primary tag: known states parse with absent percent", () => { + for (const state of ["new", "resumed", "compacted", "restarted"]) { + assert.deepEqual(parseSessionPrimaryTag(primary(state)), { + state, + reason: "first_in_thread", + percent: null, + }); + } +}); + +test("primary tag: empty percent is absent, never 0", () => { + const parsed = parseSessionPrimaryTag(primary("new", "first_in_thread", "")); + assert.equal(parsed?.percent, null); +}); + +test("primary tag: percent boundaries 0, 79, 80, 100 parse as integers", () => { + for (const value of [0, 79, 80, 100]) { + assert.equal( + parseSessionPrimaryTag(primary("resumed", "idle_sleep", String(value))) + ?.percent, + value, + ); + } +}); + +test("primary tag: malformed numbers make the tag invalid", () => { + for (const bad of [ + "-1", + "101", + "080", + "50.5", + " 50", + "50%", + "≈50", + "1e2", + "NaN", + "0x10", + "١٠", + ]) { + assert.equal( + parseSessionPrimaryTag(primary("new", "first_in_thread", bad)), + null, + `percent ${JSON.stringify(bad)} must be rejected`, + ); + } +}); + +test("primary tag: unknown state, wrong arity or non-string fields are invalid", () => { + assert.equal(parseSessionPrimaryTag(primary("sleeping")), null); + assert.equal(parseSessionPrimaryTag(primary("NEW")), null); + assert.equal( + parseSessionPrimaryTag(["buzz-session", "new", "first_in_thread"]), + null, + ); + assert.equal( + parseSessionPrimaryTag([...primary("new", "first_in_thread", "5"), "x"]), + null, + ); + assert.equal(parseSessionPrimaryTag(["buzz-session", "new", 5, "10"]), null); + assert.equal(parseSessionPrimaryTag(["other", "new", "x", ""]), null); + assert.equal(parseSessionPrimaryTag(undefined), null); +}); + +test("primary tag: hostile or oversized reasons make the tag invalid", () => { + for (const reason of [ + "", + "x".repeat(97), + "resume_failed:/home/user/.secret/session.json", + "resume_failed:Connection refused", + "", + "idle sleep", + "idle_sleep\n", + "IDLE_SLEEP", + "résumé", + ]) { + assert.equal( + parseSessionPrimaryTag(primary("new", reason)), + null, + `reason ${JSON.stringify(reason)} must be rejected`, + ); + } + // 96 bytes is the wire limit and stays valid (neutral copy, see below). + assert.equal( + parseSessionPrimaryTag(primary("new", "x".repeat(96)))?.reason, + "x".repeat(96), + ); +}); + +// --------------------------------------------------------------------------- +// Supporting tag +// --------------------------------------------------------------------------- + +test("info tag: exact counts parse", () => { + assert.deepEqual( + parseSessionInfoTag( + info({ scope: "thread", read: "12", total: "37", mode: "exact" }), + ), + { + transitionId: UUID_A, + scope: "thread", + read: 12, + total: 37, + mode: "exact", + }, + ); +}); + +test("info tag: lower bound may omit read or total", () => { + assert.deepEqual(parseSessionInfoTag(info({ mode: "lower_bound" })), { + transitionId: UUID_A, + scope: "conversation", + read: null, + total: null, + mode: "lower_bound", + }); + assert.equal( + parseSessionInfoTag(info({ total: "40", mode: "lower_bound" }))?.total, + 40, + ); +}); + +test("info tag: malformed version, UUID, scope, counts or mode are rejected", () => { + const bad = [ + info({ version: "2" }), + info({ version: "" }), + info({ transition: "not-a-uuid" }), + info({ transition: UUID_A.toUpperCase() }), + info({ transition: `{${UUID_A}}` }), + info({ transition: UUID_A.replaceAll("-", "") }), + info({ scope: "channel" }), + info({ read: "-1", mode: "unknown" }), + info({ read: "01", mode: "unknown" }), + info({ read: "1.5", mode: "unknown" }), + info({ read: "99999999999999999999", mode: "unknown" }), + info({ read: "5", total: "4", mode: "lower_bound" }), + info({ read: "5", total: "", mode: "exact" }), + info({ read: "", total: "5", mode: "exact" }), + info({ read: "5", total: "9", mode: "unknown" }), + info({ mode: "approximate" }), + ["buzz-session-info", "1", UUID_A, "thread", "", ""], + [...info(), "extra"], + ]; + for (const tag of bad) { + assert.equal( + parseSessionInfoTag(tag), + null, + `info ${JSON.stringify(tag)} must be rejected`, + ); + } +}); + +// --------------------------------------------------------------------------- +// Tag set +// --------------------------------------------------------------------------- + +test("tag set: no primary tag means no status", () => { + assert.equal(parseSessionStatusTags([["h", "c"], info()]), null); + assert.equal(parseSessionStatusTags(undefined), null); +}); + +test("tag set: valid primary with invalid supporting metadata keeps no info", () => { + const parsed = parseSessionStatusTags([ + primary("new", "first_in_thread", "12"), + info({ version: "9", read: "3", total: "4", mode: "exact" }), + ]); + assert.equal(parsed?.primary.percent, 12); + assert.equal(parsed?.info, null); +}); + +test("tag set: duplicate identical primary tags are accepted", () => { + const parsed = parseSessionStatusTags([ + primary("resumed", "idle_sleep", "40"), + primary("resumed", "idle_sleep", "40"), + info(), + ]); + assert.equal(parsed?.primary.state, "resumed"); + assert.equal(parsed?.info?.transitionId, UUID_A); +}); + +test("tag set: conflicting primary tags yield no status", () => { + assert.equal( + parseSessionStatusTags([ + primary("resumed", "idle_sleep", "40"), + primary("new", "first_in_thread", "40"), + ]), + null, + ); + assert.equal( + parseSessionStatusTags([ + primary("resumed", "idle_sleep", "40"), + primary("resumed", "idle_sleep", "41"), + ]), + null, + ); +}); + +test("tag set: one invalid primary among duplicates yields no status", () => { + assert.equal( + parseSessionStatusTags([ + primary("resumed", "idle_sleep", "40"), + primary("resumed", "idle_sleep", "4000"), + ]), + null, + ); +}); + +test("tag set: conflicting supporting tags drop the info, not the primary", () => { + const parsed = parseSessionStatusTags([ + primary("new", "first_in_thread", ""), + info(), + info({ transition: "0f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b" }), + ]); + assert.equal(parsed?.primary.state, "new"); + assert.equal(parsed?.info, null); +}); + +// --------------------------------------------------------------------------- +// Copy +// --------------------------------------------------------------------------- + +function copy(state, reason) { + return describeSessionTransition({ state, reason, percent: null }); +} + +test("copy: known new/resumed reasons", () => { + assert.deepEqual(copy("new", "first_in_thread"), { title: "New session" }); + assert.deepEqual(copy("resumed", "idle_sleep"), { + title: "Resumed after sleep", + }); + assert.deepEqual(copy("resumed", "harness_restart"), { + title: "Resumed after restart", + }); + assert.deepEqual(copy("resumed", "worker_crash"), { + title: "Resumed after worker failure", + }); + assert.deepEqual(copy("new", "discarded"), { + title: "New session", + detail: "previous session was discarded", + }); + assert.deepEqual(copy("new", "config_changed"), { + title: "New session", + detail: "agent configuration changed", + }); + assert.deepEqual(copy("new", "live_elsewhere"), { + title: "New session", + detail: "previous session is active elsewhere", + }); + assert.deepEqual(copy("new", "resume_unsupported"), { + title: "New session", + detail: "agent cannot resume sessions", + }); + assert.deepEqual(copy("new", "context_overflow"), { + title: "New session", + detail: "context window was full", + }); + assert.deepEqual(copy("new", "max_turns"), { + title: "New session", + detail: "turn limit reached", + }); +}); + +test("copy: resume failure codes get fixed wording, never the raw code", () => { + const expected = { + not_persisted: "resume failed: previous session was not saved", + store_unreadable: "resume failed: saved session could not be read", + store_busy: "resume failed: session store was busy", + not_found: "resume failed: previous session not found", + timeout: "resume failed: timed out", + protocol: "resume failed: protocol error", + "rpc_-32603": "resume failed: agent error", + rpc_42: "resume failed: agent error", + unknown: "resume failed", + some_future_code: "resume failed", + }; + for (const [code, detail] of Object.entries(expected)) { + assert.deepEqual(copy("new", `resume_failed:${code}`), { + title: "New session", + detail, + }); + } +}); + +test("copy: unknown but well-formed reasons get neutral copy without the raw string", () => { + const reason = "user_restart:recent"; + for (const [state, title] of [ + ["new", "New session"], + ["resumed", "Resumed session"], + ]) { + const result = copy(state, reason); + assert.deepEqual(result, { title }); + assert.ok(!JSON.stringify(result).includes("user_restart")); + } + // A known resume reason paired with an unexpected state stays neutral. + assert.deepEqual(copy("resumed", "first_in_thread"), { + title: "Resumed session", + }); + assert.deepEqual(copy("new", "idle_sleep"), { title: "New session" }); +}); + +test("copy: unmatched compacted/restarted reasons keep neutral copy", () => { + for (const state of ["compacted", "restarted"]) { + for (const reason of ["future_reason", "idle_sleep"]) { + assert.deepEqual(copy(state, reason), { title: "Session updated" }); + } + } +}); + +function history(fields) { + return describeHistory({ + transitionId: UUID_A, + scope: "thread", + read: null, + total: null, + mode: "unknown", + ...fields, + }); +} + +test("history: exact count", () => { + assert.deepEqual(history({ read: 12, total: 37, mode: "exact" }), [ + "read 12 of 37 messages", + ]); +}); + +test("history: proven lower bound", () => { + assert.deepEqual(history({ read: 12, total: 37, mode: "lower_bound" }), [ + "read 12 of at least 37 messages", + ]); +}); + +test("history: incomplete without a total says earlier history may be omitted", () => { + assert.deepEqual(history({ read: 12, total: null, mode: "lower_bound" }), [ + "read 12 messages", + "Earlier history may be omitted", + ]); + assert.deepEqual(history({ read: null, total: null, mode: "lower_bound" }), [ + "Earlier history may be omitted", + ]); + assert.deepEqual(history({ read: null, total: 37, mode: "lower_bound" }), [ + "Earlier history may be omitted", + ]); +}); + +test("history: unknown completeness makes no ratio or omission claim", () => { + assert.deepEqual(history({ read: 12, mode: "unknown" }), [ + "read 12 messages", + ]); + assert.deepEqual(history({ read: null, mode: "unknown" }), []); +}); + +test("history: one message reads naturally", () => { + assert.deepEqual(history({ read: 1, total: 1, mode: "exact" }), [ + "read 1 of 1 message", + ]); + assert.deepEqual(history({ read: 1, mode: "unknown" }), ["read 1 message"]); +}); + +test("percent: plain value without approximation sign, highlighted from 80%", () => { + assert.equal(CONTEXT_HIGHLIGHT_PERCENT, 80); + assert.equal(formatContextPercent(0), "0% context"); + assert.equal(formatContextPercent(79), "79% context"); + assert.equal(formatContextPercent(80), "80% context (high)"); + assert.equal(formatContextPercent(100), "100% context (high)"); + assert.equal(isHighContext(79), false); + assert.equal(isHighContext(80), true); + for (const value of [0, 79, 80, 100]) { + assert.ok(!formatContextPercent(value).includes("≈")); + } +}); + +test("copy: bounded compact and restart reasons describe the actual transition", () => { + assert.deepEqual(copy("compacted", "user_compact"), { + title: "Context compacted", + }); + assert.deepEqual(copy("compacted", "auto_compact"), { + title: "Context compacted automatically", + }); + assert.deepEqual(copy("resumed", "worker_recycled"), { + title: "Resumed after worker restart", + }); + for (const depth of ["none", "recent", "all"]) + assert.equal( + copy("restarted", `user_restart:${depth}`).title, + "Session started over", + ); +}); diff --git a/desktop/src/features/messages/lib/sessionStatus.ts b/desktop/src/features/messages/lib/sessionStatus.ts new file mode 100644 index 00000000000..f0a81eef07d --- /dev/null +++ b/desktop/src/features/messages/lib/sessionStatus.ts @@ -0,0 +1,421 @@ +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { CANONICAL_CONTROL_ID } from "./sessionControl"; +/** + * Agent session status: strict parsing of the signed `buzz-session` tags, + * English copy, and the per-transition projection used by the timeline. + * + * Wire format (produced by the agent CLI for the agent's own kind-9 messages): + * + * - `["buzz-session", state, reason, percent]`: `percent` is an integer + * 0..100 or the empty string when occupancy is unavailable (never 0); + * - `["buzz-session-info", "1", transitionUuid, "thread"|"conversation", + * read, total, "exact"|"lower_bound"|"unknown"]`: optional supporting + * metadata. `exact` means complete history (read and total exact); + * `lower_bound` means known-incomplete history (total, when present, is a + * proven lower bound); `unknown` makes no completeness claim. + * + * Status is self-reported by the signing agent. It is display metadata only: + * not verified provider telemetry and not evidence of any permission. Raw tag + * strings are never rendered; copy comes from the fixed tables below. + */ +import type { + MessageSessionStatus, + SessionStatusExplanation, + TimelineMessage, +} from "@/features/messages/types"; +import { + getChannelIdFromTags, + getThreadReference, +} from "@/features/messages/lib/threading"; +import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export const SESSION_TAG = "buzz-session"; +export const SESSION_INFO_TAG = "buzz-session-info"; +export const SESSION_INFO_VERSION = "1"; +/** Maximum wire reason length in ASCII bytes (mirrors the producer). */ +export const MAX_REASON_BYTES = 96; +/** Reported occupancy at or above this percentage is emphasized. */ +export const CONTEXT_HIGHLIGHT_PERCENT = 80; + +export type SessionState = "new" | "resumed" | "compacted" | "restarted"; +export type SessionScopeKind = "thread" | "conversation"; +export type SessionHistoryMode = "exact" | "lower_bound" | "unknown"; + +export type SessionPrimary = { + state: SessionState; + /** Well-formed wire reason; for matching only, never for display. */ + reason: string; + percent: number | null; +}; + +export type SessionInfo = { + transitionId: string; + scope: SessionScopeKind; + read: number | null; + total: number | null; + mode: SessionHistoryMode; +}; + +export type ParsedSessionTags = { + primary: SessionPrimary; + /** `null` when missing, malformed, of another version or conflicting. */ + info: SessionInfo | null; + actorPubkey?: string; +}; + +const STATES: ReadonlySet = new Set([ + "new", + "resumed", + "compacted", + "restarted", +]); +const MODES: ReadonlySet = new Set(["exact", "lower_bound", "unknown"]); +const REASON_RE = /^[a-z0-9_:-]+$/; +const PERCENT_RE = /^(?:100|[1-9]?[0-9])$/; +const COUNT_RE = /^(?:0|[1-9][0-9]*)$/; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const RESUME_FAILED_PREFIX = "resume_failed:"; + +function isStringTag(tag: unknown, name: string, length: number) { + return ( + Array.isArray(tag) && + tag.length === length && + tag[0] === name && + tag.every((field) => typeof field === "string") + ); +} + +function parseCount(field: string): number | null | undefined { + if (field === "") return null; + if (!COUNT_RE.test(field)) return undefined; + const value = Number(field); + return Number.isSafeInteger(value) ? value : undefined; +} + +/** Parse the primary tag; anything outside the contract yields `null`. */ +export function parseSessionPrimaryTag(tag: unknown): SessionPrimary | null { + if (!isStringTag(tag, SESSION_TAG, 4)) return null; + const [, state, reason, percent] = tag as string[]; + if (!STATES.has(state)) return null; + if (reason.length > MAX_REASON_BYTES || !REASON_RE.test(reason)) return null; + if (percent !== "" && !PERCENT_RE.test(percent)) return null; + return { + state: state as SessionState, + reason, + percent: percent === "" ? null : Number(percent), + }; +} + +/** Parse the supporting tag; malformed or inconsistent metadata is `null`. */ +export function parseSessionInfoTag(tag: unknown): SessionInfo | null { + if (!isStringTag(tag, SESSION_INFO_TAG, 7)) return null; + const [, version, transitionId, scope, readField, totalField, mode] = + tag as string[]; + if (version !== SESSION_INFO_VERSION || !UUID_RE.test(transitionId)) { + return null; + } + if ((scope !== "thread" && scope !== "conversation") || !MODES.has(mode)) { + return null; + } + const read = parseCount(readField); + const total = parseCount(totalField); + if (read === undefined || total === undefined) return null; + if (read !== null && total !== null && total < read) return null; + if (mode === "exact" && (read === null || total === null)) return null; + if (mode === "unknown" && total !== null) return null; + return { + transitionId, + scope, + read, + total, + mode: mode as SessionHistoryMode, + }; +} + +function sameFields(a: readonly string[], b: readonly string[]) { + return a.length === b.length && a.every((field, index) => field === b[index]); +} + +/** + * Collect every tag named `name`; all copies must be identical. Returns the + * single tag, `undefined` when absent, or `null` on conflict. + */ +function uniqueTag( + tags: readonly (readonly string[])[], + name: string, +): readonly string[] | null | undefined { + let found: readonly string[] | undefined; + for (const tag of tags) { + if (!Array.isArray(tag) || tag[0] !== name) continue; + if (found && !sameFields(found, tag)) return null; + found = tag; + } + return found; +} + +/** Parse a message's status tags. Conflicting primary tags yield `null`. */ +export function parseSessionStatusTags( + tags: readonly (readonly string[])[] | undefined, +): ParsedSessionTags | null { + if (!tags) return null; + const primaryTag = uniqueTag(tags, SESSION_TAG); + const primary = primaryTag ? parseSessionPrimaryTag(primaryTag) : null; + if (!primary) return null; + const infoTag = uniqueTag(tags, SESSION_INFO_TAG); + const info = infoTag ? parseSessionInfoTag(infoTag) : null; + const action = uniqueTag(tags, "buzz-session-action"); + const manual = + primary.reason === "user_compact" || + /^user_restart:(none|recent|all)$/.test(primary.reason); + const actorPubkey = + manual && + info && + action && + action.length === 5 && + action[1] === "1" && + action[2] === info.transitionId && + CANONICAL_CONTROL_ID.test(action[3]) && + CANONICAL_CONTROL_ID.test(action[4]) + ? action[4] + : null; + return { primary, info, ...(actorPubkey ? { actorPubkey } : {}) }; +} + +// --------------------------------------------------------------------------- +// Copy (English, fixed tables) +// --------------------------------------------------------------------------- + +const RESUMED_TITLES: Readonly> = { + idle_sleep: "Resumed after sleep", + harness_restart: "Resumed after restart", + worker_crash: "Resumed after worker failure", + worker_recycled: "Resumed after worker restart", +}; + +const NEW_DETAILS: Readonly> = { + discarded: "previous session was discarded", + config_changed: "agent configuration changed", + live_elsewhere: "previous session is active elsewhere", + resume_unsupported: "agent cannot resume sessions", + context_overflow: "context window was full", + max_turns: "turn limit reached", +}; + +const RESUME_FAILURE_DETAILS: Readonly> = { + not_persisted: "previous session was not saved", + store_unreadable: "saved session could not be read", + store_busy: "session store was busy", + not_found: "previous session not found", + timeout: "timed out", + protocol: "protocol error", +}; + +const RPC_CODE_RE = /^rpc_-?[0-9]{1,19}$/; + +function hasOwn(table: Readonly>, key: string) { + return Object.hasOwn(table, key); +} + +function resumeFailureDetail(code: string): string { + if (hasOwn(RESUME_FAILURE_DETAILS, code)) { + return `resume failed: ${RESUME_FAILURE_DETAILS[code]}`; + } + if (RPC_CODE_RE.test(code)) return "resume failed: agent error"; + return "resume failed"; +} + +/** + * Title and optional detail for a transition. Unknown but well-formed + * reasons get neutral copy. Manual transitions may receive separately validated actor metadata. + */ +export function describeSessionTransition( + primary: SessionPrimary, +): Omit { + const { state, reason } = primary; + if ( + state === "compacted" && + (reason === "user_compact" || reason === "auto_compact") + ) + return { + title: + reason === "auto_compact" + ? "Context compacted automatically" + : "Context compacted", + }; + if (state === "restarted" && /^user_restart:(none|recent|all)$/.test(reason)) + return { + title: "Session started over", + detail: + reason === "user_restart:none" + ? "from scratch" + : reason === "user_restart:all" + ? "with the entire thread" + : "with recent history", + }; + if (state === "resumed") { + return { + title: hasOwn(RESUMED_TITLES, reason) + ? RESUMED_TITLES[reason] + : "Resumed session", + }; + } + if (state !== "new") return { title: "Session updated" }; + if (reason.startsWith(RESUME_FAILED_PREFIX)) { + return { + title: "New session", + detail: resumeFailureDetail(reason.slice(RESUME_FAILED_PREFIX.length)), + }; + } + if (hasOwn(NEW_DETAILS, reason)) { + return { title: "New session", detail: NEW_DETAILS[reason] }; + } + return { title: "New session" }; +} + +function messages(count: number) { + return count === 1 ? "message" : "messages"; +} + +/** + * Truthful cold-start history phrases. Exact: "read N of M messages"; proven + * lower bound: "read N of at least M messages"; known incomplete without a + * ratio: "Earlier history may be omitted"; no completeness claim: at most + * the read count, without a ratio or omission note. + */ +export function describeHistory(info: SessionInfo): string[] { + const { read, total, mode } = info; + if (mode === "exact" && read !== null && total !== null) { + return [`read ${read} of ${total} ${messages(total)}`]; + } + if (mode === "lower_bound") { + if (read !== null && total !== null) { + return [`read ${read} of at least ${total} ${messages(total)}`]; + } + const omitted = "Earlier history may be omitted"; + return read !== null + ? [`read ${read} ${messages(read)}`, omitted] + : [omitted]; + } + return read !== null ? [`read ${read} ${messages(read)}`] : []; +} + +export function isHighContext(percent: number): boolean { + return percent >= CONTEXT_HIGHLIGHT_PERCENT; +} + +/** Reported occupancy without an approximation sign. */ +export function formatContextPercent(percent: number): string { + return isHighContext(percent) + ? `${percent}% context (high)` + : `${percent}% context`; +} + +// --------------------------------------------------------------------------- +// Projection +// --------------------------------------------------------------------------- + +type Candidate = { + message: TimelineMessage; + parsed: ParsedSessionTags; + key: string | null; +}; + +function isEarlier(a: TimelineMessage, b: TimelineMessage) { + return a.createdAt !== b.createdAt ? a.createdAt < b.createdAt : a.id < b.id; +} + +/** + * Attach `sessionStatus` to agent messages, in place. + * + * `messages[i]` must be the formatted row of `events[i]`. Only an agent's + * own, relay-accepted kind-9 message whose signer is the represented author + * is considered, and only its original signed tags are read: edits cannot + * add or change status. + * + * The explanation belongs to the earliest loaded message, by + * `(createdAt, id)`, of one transition: (signer, channel, scope kind and + * thread root, transition UUID). Every other message keeps only its + * percentage. Without valid supporting metadata there is no grouping key, + * so each such message keeps its primary explanation without history counts. + * The result depends only on the loaded events: no "already seen" state is + * kept anywhere. + */ +export function attachSessionStatuses( + messages: TimelineMessage[], + events: ReadonlyArray<{ + kind: number; + pending?: boolean; + tags: string[][]; + }>, + fallbackChannelId: string | null, + profiles?: UserProfileLookup, +): TimelineMessage[] { + const candidates: Candidate[] = []; + const owners = new Map(); + messages.forEach((message, index) => { + const event = events[index]; + if ( + !event || + event.kind !== KIND_STREAM_MESSAGE || + event.pending || + !message.isAgent || + !message.pubkey || + !message.signerPubkey || + normalizePubkey(message.pubkey) !== message.signerPubkey + ) { + return; + } + const parsed = parseSessionStatusTags(event.tags); + if (!parsed) return; + let key: string | null = null; + const { info } = parsed; + const channelId = getChannelIdFromTags(event.tags) ?? fallbackChannelId; + const rootId = getThreadReference(event.tags).rootId; + if (info && channelId && (info.scope === "conversation" || rootId)) { + key = [ + message.signerPubkey, + channelId, + info.scope, + info.scope === "thread" ? rootId : "", + info.transitionId, + parsed.primary.state, + parsed.primary.reason, + parsed.actorPubkey ?? "", + ].join("\u0000"); + const owner = owners.get(key); + if (!owner || isEarlier(message, owner)) owners.set(key, message); + } + candidates.push({ message, parsed, key }); + }); + + for (const { message, parsed, key } of candidates) { + const status: MessageSessionStatus = {}; + if (key === null || owners.get(key) === message) { + const explanation: SessionStatusExplanation = describeSessionTransition( + parsed.primary, + ); + const actorName = parsed.actorPubkey + ? profiles?.[parsed.actorPubkey]?.displayName + : null; + if ( + actorName && + (parsed.primary.state === "restarted" || + parsed.primary.state === "compacted") + ) + explanation.title += ` by ${actorName}`; + const history = + key !== null && parsed.info ? describeHistory(parsed.info) : []; + if (history.length > 0) explanation.history = history; + status.explanation = explanation; + } + if (parsed.primary.percent !== null) { + status.percent = parsed.primary.percent; + } + if (status.explanation || status.percent !== undefined) { + message.sessionStatus = status; + } + } + return messages; +} diff --git a/desktop/src/features/messages/lib/threadContextAgents.test.mjs b/desktop/src/features/messages/lib/threadContextAgents.test.mjs new file mode 100644 index 00000000000..bf725db9585 --- /dev/null +++ b/desktop/src/features/messages/lib/threadContextAgents.test.mjs @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + hasPersistedThread, + projectThreadContextAgents, +} from "./threadContextAgents.ts"; +import { loadThreadReplies } from "../useThreadReplies.ts"; +import { QueryClient } from "@tanstack/react-query"; +const root = "1".repeat(64), + nested = "2".repeat(64), + human = "3".repeat(64), + a = "a".repeat(64), + b = "b".repeat(64), + c = "c".repeat(64); +const channel = { + id: "channel", + channelType: "stream", + isMember: true, + archivedAt: null, +}; +const bot = (pubkey, extra = {}) => ({ + pubkey, + name: "Scout", + ownerPubkey: human, + respondTo: "owner-only", + respondToAllowlist: [], + channelIds: [channel.id], + ...extra, +}); +const event = (id, pubkey, tags = []) => ({ + id, + pubkey, + kind: 9, + tags: [["h", channel.id], ...tags], + created_at: 1, + content: "", + sig: "", +}); +const rootEvent = event(root, human, [["p", a]]); +function project(events, directory = [bot(a), bot(b), bot(c)], extra = {}) { + return projectThreadContextAgents({ + rootId: root, + channel, + signerPubkey: human, + directory, + events, + ...extra, + }); +} + +test("only persisted nonpending heads establish a scope, independent of a draft", () => { + for (const head of [ + null, + {}, + { id: root }, + { id: root, signerPubkey: human, pending: true }, + { id: "loading", signerPubkey: human }, + ]) + assert.equal(hasPersistedThread(head), false); + assert.equal(hasPersistedThread({ id: root, signerPubkey: human }), true); +}); +test("root-only addressed agents and raw nested signers deduplicate without sibling or display attribution leakage", () => { + assert.deepEqual( + project([rootEvent]).map((agent) => agent.pubkey), + [a], + ); + const events = [ + rootEvent, + event(nested, b, [ + ["e", root, "", "reply"], + ["p", a], + ["p", a], + ]), + event("4".repeat(64), c), + event("5".repeat(64), human, [ + ["e", root, "", "reply"], + ["author", c], + ]), + { + ...event("6".repeat(64), c, [["e", root, "", "reply"]]), + tags: [ + ["h", "other"], + ["e", root, "", "reply"], + ], + }, + ]; + const agents = project(events); + assert.deepEqual( + agents.map((agent) => agent.pubkey), + [a, b], + ); + assert.notEqual(agents[0].label, agents[1].label); + assert.equal( + project( + [rootEvent], + [bot(a, { respondTo: "allowlist", respondToAllowlist: [] })], + { signerPubkey: c }, + ).length, + 0, + ); + assert.deepEqual(project(events, [bot(a)], { signerPubkey: c }), []); +}); +test("permission changes and unavailable evidence never grant control; telemetry and mention pins are irrelevant", () => { + for (const extra of [ + { channel: { ...channel, isMember: false } }, + { channel: { ...channel, archivedAt: 1 } }, + { directory: [] }, + { signerPubkey: "" }, + ]) + assert.deepEqual(project([rootEvent], undefined, extra), []); + const stranger = "d".repeat(64); + assert.equal( + project([rootEvent], [bot(a, { respondTo: "anyone" })], { + signerPubkey: stranger, + }).length, + 1, + ); + assert.equal( + project([rootEvent], [bot(a, { respondTo: "anyone" })], { + signerPubkey: stranger, + channel: { ...channel, channelType: "dm" }, + }).length, + 0, + ); + assert.equal(project([rootEvent]).length, 1); +}); +test("bounded production thread loader includes later-page collapsed participants and fails rather than returning partial history", async () => { + const client = new QueryClient(); + let calls = 0; + const fetcher = async () => + ++calls === 1 + ? { + events: [event(nested, b, [["e", root, "", "reply"]])], + nextCursor: { id: nested, createdAt: 1 }, + } + : { + events: [event("7".repeat(64), c, [["e", root, "", "reply"]])], + nextCursor: null, + }; + const replies = await loadThreadReplies( + client, + channel.id, + root, + null, + undefined, + fetcher, + ); + assert.equal(calls, 2); + assert.deepEqual( + project([rootEvent, ...replies]).map((agent) => agent.pubkey), + [a, b, c], + ); + calls = 0; + await assert.rejects( + loadThreadReplies( + client, + channel.id, + root, + null, + undefined, + async (...args) => { + if (calls === 1) throw new Error("page unavailable"); + return fetcher(...args); + }, + ), + /page unavailable/, + ); + client.clear(); +}); + +test("a quoted root in a sibling never contributes an agent; forum roots remain eligible", () => { + assert.deepEqual( + project([ + rootEvent, + event("8".repeat(64), c, [ + ["e", root, "", "mention"], + ["e", "9".repeat(64), "", "reply"], + ]), + ]).map((agent) => agent.pubkey), + [a], + ); + assert.deepEqual( + project([{ ...rootEvent, kind: 40002 }]).map((agent) => agent.pubkey), + [a], + ); +}); diff --git a/desktop/src/features/messages/lib/threadContextAgents.ts b/desktop/src/features/messages/lib/threadContextAgents.ts new file mode 100644 index 00000000000..aa008641ac5 --- /dev/null +++ b/desktop/src/features/messages/lib/threadContextAgents.ts @@ -0,0 +1,79 @@ +import type { Channel, RelayAgent, RelayEvent } from "@/shared/api/types"; +import type { TimelineMessage } from "@/features/messages/types"; +import { getThreadReference, getChannelIdFromTags } from "./threading"; +import { canManageSession } from "./sessionControlPermissions"; +import { parseSessionStatusTags } from "./sessionStatus"; +import { formatMentionDisplayLabel } from "@/shared/lib/mentionDisplay"; +import { truncateNpub } from "@/shared/lib/pubkey"; + +/** A persisted head, including a root-only thread, establishes a real scope. */ +export function hasPersistedThread(head: TimelineMessage | null): boolean { + return Boolean( + head && + !head.pending && + /^[0-9a-f]{64}$/i.test(head.id) && + head.signerPubkey, + ); +} + +/** Project raw signers and explicit p-tags from the bounded canonical thread data. */ +export function projectThreadContextAgents({ + rootId, + channel, + events, + directory, + signerPubkey, +}: { + rootId: string; + channel: Channel; + events: readonly RelayEvent[]; + directory: readonly RelayAgent[]; + signerPubkey: string; +}) { + const candidates = new Set(); + const shared = new Set(); + for (const event of events) { + if ( + ![9, 40002].includes(event.kind) || + getChannelIdFromTags(event.tags) !== channel.id + ) + continue; + if (event.id !== rootId && getThreadReference(event.tags).rootId !== rootId) + continue; + candidates.add(event.pubkey.toLowerCase()); + for (const tag of event.tags) { + if (tag[0] === "p" && /^[0-9a-f]{64}$/i.test(tag[1] ?? "")) + candidates.add(tag[1].toLowerCase()); + } + if (parseSessionStatusTags(event.tags)?.info?.scope === "conversation") + shared.add(event.pubkey.toLowerCase()); + } + const requester = + directory.find((agent) => agent.pubkey === signerPubkey) ?? null; + const accessible = directory.filter( + (agent) => + candidates.has(agent.pubkey) && + canManageSession( + { targetPubkey: agent.pubkey, agent, requester, channel }, + signerPubkey, + channel.id, + ), + ); + return [ + ...new Map(accessible.map((agent) => [agent.pubkey, agent])).values(), + ].map((agent) => { + const name = agent.name.trim() || truncateNpub(agent.pubkey); + const sameName = accessible.some( + (other) => + other.pubkey !== agent.pubkey && + other.name.trim().toLowerCase() === agent.name.trim().toLowerCase(), + ); + return { + pubkey: agent.pubkey, + label: sameName + ? formatMentionDisplayLabel(`${name} (${agent.pubkey})`, agent.pubkey) + : name, + shared: shared.has(agent.pubkey), + }; + }); +} diff --git a/desktop/src/features/messages/lib/useSessionControlAction.jsdom-test.mjs b/desktop/src/features/messages/lib/useSessionControlAction.jsdom-test.mjs new file mode 100644 index 00000000000..1ff73f99ffc --- /dev/null +++ b/desktop/src/features/messages/lib/useSessionControlAction.jsdom-test.mjs @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import { test, afterEach } from "node:test"; +import { act, renderHook, cleanup } from "@testing-library/react"; +import { useSessionControlAction } from "./useSessionControlAction.ts"; +afterEach(cleanup); +const scope = { + targetPubkey: "a".repeat(64), + signerPubkey: "b".repeat(64), + channelId: "channel", + rootEventId: "c".repeat(64), + relayUrl: "wss://example.test", +}; +const command = { type: "restart", depth: "none", content: "!restart none" }; +const evidence = { + targetPubkey: scope.targetPubkey, + agent: { + pubkey: scope.targetPubkey, + ownerPubkey: scope.signerPubkey, + respondTo: "owner-only", + respondToAllowlist: [], + channelIds: ["channel"], + }, + channel: { + id: "channel", + channelType: "stream", + isMember: true, + archivedAt: null, + }, +}; +function setup() { + let resolve, reject; + const promise = new Promise((r, fail) => { + resolve = r; + reject = fail; + }); + const sends = []; + const dependencies = { + fetchEvidence: () => promise, + send: async (...args) => { + sends.push(args); + }, + }; + const hook = renderHook( + (props) => useSessionControlAction(props, dependencies), + { initialProps: scope }, + ); + return { hook, resolve, reject, sends, dependencies }; +} +test("captures exact target and canonical thread routing, publishes once", async () => { + const { hook, resolve, sends } = setup(); + let pending; + act(() => { + pending = hook.result.current.run(command); + }); + assert.equal(await hook.result.current.run(command), false); + await act(async () => { + resolve(evidence); + await pending; + }); + assert.equal(sends.length, 1); + assert.deepEqual(sends[0], [ + "channel", + "!restart none", + scope.rootEventId, + undefined, + [scope.targetPubkey], + 9, + undefined, + undefined, + undefined, + undefined, + scope.relayUrl, + scope.signerPubkey, + scope.rootEventId, + ]); + assert.equal(hook.result.current.pending, false); +}); +for (const boundary of [ + "revoke", + "query failure", + "community", + "signer", + "target", + "root", + "channel", + "unmount", + "publication failure", +]) { + test(`no unintended publication after ${boundary}`, async () => { + const { hook, resolve, reject, sends, dependencies } = setup(); + if (boundary === "publication failure") + dependencies.send = async () => { + throw new Error("offline"); + }; + let pending; + act(() => { + pending = hook.result.current.run(command); + }); + if (boundary === "unmount") hook.unmount(); + if (["community", "signer", "target", "root", "channel"].includes(boundary)) + hook.rerender({ + ...scope, + [{ + community: "relayUrl", + signer: "signerPubkey", + target: "targetPubkey", + root: "rootEventId", + channel: "channelId", + }[boundary]]: "changed", + }); + await act(async () => { + if (boundary === "query failure") + reject(new Error("directory unavailable")); + else + resolve( + boundary === "revoke" ? { ...evidence, agent: null } : evidence, + ); + assert.equal(await pending, false); + }); + assert.equal(sends.length, 0); + if (["revoke", "query failure", "publication failure"].includes(boundary)) + assert.match(hook.result.current.error, /Could not send/); + }); +} + +test("switching away and back cannot revive an old action", async () => { + const { hook, resolve, sends } = setup(); + let pending; + act(() => { + pending = hook.result.current.run(command); + }); + hook.rerender({ ...scope, rootEventId: "other" }); + hook.rerender(scope); + await act(async () => { + resolve(evidence); + assert.equal(await pending, false); + }); + assert.equal(sends.length, 0); +}); + +test("manual actor joins the actual channel surface batch without a per-row fetch", async () => { + const { useMessageEventProfilePubkeys } = await import( + "../../channels/useMessageEventProfilePubkeys.ts" + ); + const actor = "d".repeat(64), + transition = "6f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b"; + const event = { + id: "e".repeat(64), + pubkey: scope.targetPubkey, + kind: 9, + created_at: 1, + content: "Done", + sig: "sig", + tags: [ + ["buzz-session", "compacted", "user_compact", ""], + ["buzz-session-info", "1", transition, "thread", "", "", "unknown"], + ["buzz-session-action", "1", transition, "f".repeat(64), actor], + ], + }; + const hook = renderHook(() => + useMessageEventProfilePubkeys([event], [], null), + ); + assert.ok(hook.result.current.includes(actor)); +}); diff --git a/desktop/src/features/messages/lib/useSessionControlAction.ts b/desktop/src/features/messages/lib/useSessionControlAction.ts new file mode 100644 index 00000000000..3397eb2f506 --- /dev/null +++ b/desktop/src/features/messages/lib/useSessionControlAction.ts @@ -0,0 +1,129 @@ +import * as React from "react"; +import { getChannelDetails } from "@/shared/api/tauriChannels"; +import { revalidateRelayAgents } from "@/shared/api/tauriRelayAgents"; +import { sendChannelMessage } from "@/shared/api/tauriMessages"; +import { + canManageSession, + type SessionPermissionEvidence, +} from "./sessionControlPermissions"; +import { parseSessionCommand, type SessionCommand } from "./sessionControl"; + +export type SessionControlScope = { + targetPubkey: string; + channelId: string; + rootEventId: string; + relayUrl: string; + signerPubkey: string; +}; +/** Targeted, fresh evidence: a failed read propagates instead of authorizing stale data. */ +export async function fetchSessionPermissionEvidence( + scope: SessionControlScope, +): Promise { + const [agents, channel] = await Promise.all([ + revalidateRelayAgents( + [scope.targetPubkey, scope.signerPubkey], + scope.channelId, + ), + getChannelDetails(scope.channelId), + ]); + return { + targetPubkey: scope.targetPubkey, + agent: agents.find((agent) => agent.pubkey === scope.targetPubkey) ?? null, + requester: + agents.find((agent) => agent.pubkey === scope.signerPubkey) ?? null, + channel, + }; +} +const production = { + fetchEvidence: fetchSessionPermissionEvidence, + send: sendChannelMessage, +}; +/** Send a scoped control independently of the composer, fenced across every await. */ +export function useSessionControlAction( + scope: SessionControlScope, + dependencies = production, +) { + const key = JSON.stringify(scope); + const currentKey = React.useRef({ key, generation: 0 }); + if (currentKey.current.key !== key) + currentKey.current = { key, generation: currentKey.current.generation + 1 }; + const mounted = React.useRef(false); + const inFlight = React.useRef(false); + const [state, setState] = React.useState<{ + key: string; + pending: boolean; + error: string | null; + }>({ key, pending: false, error: null }); + React.useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + currentKey.current.generation += 1; + }; + }, []); + const run = React.useCallback( + async (command: SessionCommand) => { + if (inFlight.current || !mounted.current) return false; + const captured = { ...scope }; + const capturedKey = key; + const capturedGeneration = currentKey.current.generation; + const isCurrent = () => + mounted.current && + currentKey.current.key === capturedKey && + currentKey.current.generation === capturedGeneration; + // Never send arbitrary text supplied through a UI action. + const parsed = parseSessionCommand(command.content); + if ( + !parsed || + !captured.relayUrl || + !captured.signerPubkey || + !captured.rootEventId + ) + return false; + inFlight.current = true; + setState({ key, pending: true, error: null }); + try { + const evidence = await dependencies.fetchEvidence(captured); + if (!isCurrent()) return false; + if ( + !canManageSession(evidence, captured.signerPubkey, captured.channelId) + ) + throw new Error("Permission changed"); + await dependencies.send( + captured.channelId, + parsed.content, + captured.rootEventId, + undefined, + [captured.targetPubkey], + 9, + undefined, + undefined, + undefined, + undefined, + captured.relayUrl, + captured.signerPubkey, + captured.rootEventId, + ); + return isCurrent(); + } catch { + if (isCurrent()) + setState({ + key, + pending: false, + error: + "Could not send session control. Check access and connection, then retry.", + }); + return false; + } finally { + inFlight.current = false; + if (isCurrent()) setState((value) => ({ ...value, pending: false })); + } + }, + [dependencies, key, scope], + ); + return { + run, + pending: state.key === key && state.pending, + error: state.key === key ? state.error : null, + }; +} diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts index ec656f22366..f891f514e96 100644 --- a/desktop/src/features/messages/types.ts +++ b/desktop/src/features/messages/types.ts @@ -1,3 +1,4 @@ +import type { MessageSessionControl } from "@/features/messages/lib/sessionControl"; export type TimelineReaction = { emoji: string; /** Custom (image) emoji URL from the reaction's NIP-30 `emoji` tag, if any. */ @@ -11,6 +12,27 @@ export type TimelineReaction = { }>; }; +/** + * Explanation of an agent session transition (see `lib/sessionStatus.ts`). + * Copy only: built from the bounded wire vocabulary, never raw tag text. + */ +export type SessionStatusExplanation = { + title: string; + detail?: string; + history?: string[]; +}; + +/** + * Derived, self-reported session status of an agent's own message. Present + * only when there is something to show; `percent` is absent (never 0) when + * the agent reported no context occupancy. + */ +export type MessageSessionStatus = { + /** Only on the earliest loaded message of its transition. */ + explanation?: SessionStatusExplanation; + percent?: number; +}; + export type TimelineMessage = { id: string; /** Stable local key used to avoid remounting optimistic rows on send ack. */ @@ -49,4 +71,8 @@ export type TimelineMessage = { kind?: number; tags?: string[][]; reactions?: TimelineReaction[]; + /** Derived from the original signed tags; see `lib/sessionStatus.ts`. */ + sessionStatus?: MessageSessionStatus; + /** Derived from the original request and raw target-signed results. */ + sessionControl?: MessageSessionControl; }; diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 082cb0b126d..d1562db4c50 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -559,7 +559,8 @@ export const MessageActionBar = React.memo(function MessageActionBar({ diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 6286ebfb56b..22ba91a7a02 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -98,6 +98,7 @@ function MessageComposerImpl({ showBackgroundUploadProgress = true, showTopBorder = false, toolbarExtraActions, + threadContextControl, typingParentEventId = null, typingRootEventId = null, }: MessageComposerProps) { @@ -961,6 +962,7 @@ function MessageComposerImpl({ composerDisabled={composerDisabled} editor={richText.editor} extraActions={toolbarExtraActions} + threadContextControl={threadContextControl} formattingDisabled={composerDisabled} gifMediaController={media} isEmojiPickerOpen={isEmojiPickerOpen} diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts index b98132609ad..702371ef27b 100644 --- a/desktop/src/features/messages/ui/MessageComposer.types.ts +++ b/desktop/src/features/messages/ui/MessageComposer.types.ts @@ -111,6 +111,8 @@ export type MessageComposerProps = { /** Render the app-wide upload queue above this composer dock. */ showBackgroundUploadProgress?: boolean; toolbarExtraActions?: ReactNode; + /** Thread-only control directly before mention management. */ + threadContextControl?: ReactNode; typingParentEventId?: string | null; typingRootEventId?: string | null; }; diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index 476ffe60629..4c745c6d45c 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -38,6 +38,7 @@ export const MessageComposerToolbar = React.memo( composerDisabled, editor, extraActions, + threadContextControl, formattingDisabled, gifMediaController, isEmojiPickerOpen, @@ -70,6 +71,7 @@ export const MessageComposerToolbar = React.memo( composerDisabled: boolean; editor: Editor | null; extraActions?: React.ReactNode; + threadContextControl?: React.ReactNode; formattingDisabled: boolean; gifMediaController: Pick; isEmojiPickerOpen: boolean; @@ -98,6 +100,22 @@ export const MessageComposerToolbar = React.memo( shakeVersionByPubkey?: Readonly>; }) { const shouldReduceMotion = useReducedMotion(); + const mentionButton = ( + + ); return (
{voiceNoteRecorder ? ( - + {!threadContextControl ? mentionButton : null} + + + Manage context + + {target ? ( + setSelected(null)} + /> + ) : ( + + {!replies ? ( +

+ Thread context is unavailable here. +

+ ) : error ? ( + <> +

+ Could not load thread agents. +

+ { + event.preventDefault(); + if (directory.isError) void directory.refetch(); + if (currentChannel.isError) void currentChannel.refetch(); + if (replies.status === "error") { + if (ownedReplies) { + void queryClient.refetchQueries({ + queryKey: repliesKey, + exact: true, + }); + } else { + void forumReplies.refetch(); + } + } + if (head.id !== rootId && root.isError) void root.refetch(); + }} + > + Retry + + + ) : loading ? ( +

+ Loading thread agents… +

+ ) : agents.length ? ( + agents.map((agent) => ( + { + event.preventDefault(); + setSelected(agent.pubkey); + }} + > + + {agent.label} + + )) + ) : ( +

+ No agents you can manage in this thread. +

+ )} +
+ )} + + ); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 695049ac6a7..b78bb3f6811 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -10443,7 +10443,7 @@ async function handleSendChannelMessage( args.parentEventId, rootEventId, args.mentionPubkeys, - ), + ).filter((tag) => tag[0] !== "p" || tag[1] !== mockPubkey), ...extraTags, ], content: args.content.trim(), diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts index 23dbb5e5f4e..cacb148c977 100644 --- a/desktop/tests/e2e/custom-emoji.spec.ts +++ b/desktop/tests/e2e/custom-emoji.spec.ts @@ -334,7 +334,7 @@ test("message reaction action stays neutral after selecting from the picker", as test("emoji picker keeps Frequently used live within the app session", async ({ page, -}) => { +}, testInfo) => { await page.addInitScript(() => { window.localStorage.setItem("emoji-mart.frequently", "{}"); window.localStorage.removeItem("emoji-mart.last"); @@ -350,9 +350,24 @@ test("emoji picker keeps Frequently used live within the app session", async ({ await row.getByLabel("Open reactions").click(); let picker = page.locator("em-emoji-picker"); + const expectPickerInViewport = async (stage: string) => { + await waitForAnimations(page); + const category = picker.getByRole("button", { name: "Frequently used" }); + await expect(picker).toBeInViewport({ ratio: 1 }); + await expect(category).toBeInViewport({ ratio: 1 }); + await testInfo.attach(`reaction-picker-${stage}-bounds`, { + body: JSON.stringify({ + picker: await picker.boundingBox(), + category: await category.boundingBox(), + viewport: page.viewportSize(), + }), + contentType: "application/json", + }); + }; await expect( picker.getByRole("button", { name: "Frequently used" }), ).toBeVisible(); + await expectPickerInViewport("first-open"); await picker.locator("input[type='search']").fill("unicorn"); await picker.getByRole("button", { name: "🦄" }).first().click(); @@ -361,6 +376,7 @@ test("emoji picker keeps Frequently used live within the app session", async ({ await row.hover(); await row.getByLabel("Open reactions").click(); picker = page.locator("em-emoji-picker"); + await expectPickerInViewport("reopen"); await picker.getByRole("button", { name: "Frequently used" }).click(); await expect( picker.getByRole("button", { name: "🦄" }).first(), diff --git a/desktop/tests/e2e/message-copy-link.spec.ts b/desktop/tests/e2e/message-copy-link.spec.ts index b2e1b0f5a23..196f3f87a76 100644 --- a/desktop/tests/e2e/message-copy-link.spec.ts +++ b/desktop/tests/e2e/message-copy-link.spec.ts @@ -171,26 +171,39 @@ test("pending and huddle rows omit both copy-link surfaces", async ({ ) .toBe(true); - const { huddleId, pendingId } = await page.evaluate((huddleKind) => { - const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; - if (!emit) throw new Error("Mock message emitter is unavailable."); - const pending = emit({ - channelName: "general", - content: "Pending copy-link regression", - id: "c".repeat(64), - pending: true, - }); - const huddle = emit({ - channelName: "general", - content: JSON.stringify({ - ephemeral_channel_id: "10000000-0000-4000-8000-000000000001", - }), - id: "d".repeat(64), - kind: huddleKind, - }); - return { huddleId: huddle.id, pendingId: pending.id }; - }, KIND_HUDDLE_STARTED); - + const { huddleId, pendingId, huddleKind, huddleContent } = + await page.evaluate((huddleKind) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + const pending = emit({ + channelName: "general", + content: "Pending copy-link regression", + id: "c".repeat(64), + pending: true, + }); + const huddle = emit({ + channelName: "general", + content: JSON.stringify({ + ephemeral_channel_id: "10000000-0000-4000-8000-000000000001", + }), + id: "10c4b33e".repeat(8), + kind: huddleKind, + }); + return { + huddleId: huddle.id, + pendingId: pending.id, + huddleKind: huddle.kind, + huddleContent: huddle.content, + }; + }, KIND_HUDDLE_STARTED); + + expect(huddleKind).toBe(KIND_HUDDLE_STARTED); + expect(JSON.parse(huddleContent)).toEqual({ + ephemeral_channel_id: "10000000-0000-4000-8000-000000000001", + }); + const huddleRow = page.locator(`[data-message-id="${huddleId}"]`); + await expect(huddleRow.getByTestId("huddle-attachment")).toBeVisible(); + await expect(huddleRow).toContainText("Huddle"); await expectCopyLinkUnavailable( page.locator(`[data-message-id="${pendingId}"]`), pendingId, diff --git a/desktop/tests/e2e/reaction-order.spec.ts b/desktop/tests/e2e/reaction-order.spec.ts index b37ce56dd13..30adaadb396 100644 --- a/desktop/tests/e2e/reaction-order.spec.ts +++ b/desktop/tests/e2e/reaction-order.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"; import * as fs from "node:fs"; import * as path from "node:path"; -import { installMockBridge } from "../helpers/bridge"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { waitForAnimations } from "../helpers/animations"; // Reaction ordering end-to-end guard. @@ -108,6 +108,7 @@ test("reaction pills render left-to-right in the order reactions were added", as const pills = await getPillOrder(row); expect(pills).toEqual(["👍", "❤️", "😂"]); + await expect(row.getByTestId("manage-current-session")).toHaveCount(0); await expect.poll(() => getBodyToReactionGap(row)).toBe(6); // Screenshot for PR visual proof. @@ -181,3 +182,25 @@ test("a later emoji that accrues more reactors stays to the right of an earlier contentType: "image/png", }); }); + +test("ordinary non-agent reactions stay adjacent to the message body", async ({ + page, +}) => { + await openGeneral(page); + const ordinaryId = await page.evaluate((pubkey) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + return emit({ + channelName: "general", + content: "Ordinary reaction adjacency regression", + id: "6ad1ace0".repeat(8), + pubkey, + }).id; + }, TEST_IDENTITIES.bob.pubkey); + const row = page.locator(`[data-message-id="${ordinaryId}"]`); + await expect(row).toContainText("Ordinary reaction adjacency regression"); + await expect(row.getByTestId("manage-current-session")).toHaveCount(0); + await addReaction(page, row, "👍", "thumbs up"); + expect(await getPillOrder(row)).toEqual(["👍"]); + await expect.poll(() => getBodyToReactionGap(row)).toBe(6); +}); diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index f8f80a38ee8..2d199578559 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -433,12 +433,12 @@ test("does not teleport upward when user abandons fetch by jumping to bottom", a // dispatches a real WheelEvent that whichever element owns scrolling // must honor. Wheel up in 2000-delta chunks (the browser applies its // own delta scaling so the actual scrolled distance is typically a - // fraction of this) until scrollTop crosses below 500 or we hit a - // step cap. + // fraction of this) until the older-fetch indicator is visible or we + // hit the step cap. A position below 500px can still be above the fetch + // boundary, so scroll position alone does not establish the premise. await timeline.hover(); for (let attempt = 0; attempt < 32; attempt += 1) { - const metrics = await getTimelineMetrics(page); - if (metrics.scrollTop < 500) { + if (await page.getByTestId("message-timeline-fetching-older").isVisible()) { break; } await page.mouse.wheel(0, -2000); diff --git a/desktop/tests/e2e/session-controls.spec.ts b/desktop/tests/e2e/session-controls.spec.ts new file mode 100644 index 00000000000..c2e3c2cd6e5 --- /dev/null +++ b/desktop/tests/e2e/session-controls.spec.ts @@ -0,0 +1,1155 @@ +import { expect, test, type Page, type Locator } from "@playwright/test"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; +const AGENT = "a4".repeat(32); +const VIEWER = "deadbeef".repeat(8); +const OWNER = "b4".repeat(32); +const SECOND_AGENT = "c4".repeat(32); +const CHANNEL = "agents"; +const SHOT = "test-results/session-controls"; +async function setup( + page: Page, + revoked = false, + denied = false, + rootAgeSeconds = 0, + sendMessageDelayMs = 0, + mockOptions: Parameters[1] = {}, +) { + await installMockBridge(page, { + ...mockOptions, + sendMessageDelayMs, + ownerOnlyAccessBuild: true, + managedAgents: [], + relayAgentRevalidationRevokedPubkeys: revoked ? [AGENT] : [], + searchProfiles: [ + { + pubkey: AGENT, + displayName: "ControlBot", + avatarUrl: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32'%3E%3Crect width='32' height='32' fill='%233b82f6'/%3E%3Cpath d='M8 10h16v14H8z' fill='white'/%3E%3C/svg%3E", + ownerPubkey: OWNER, + isAgent: true, + }, + { + pubkey: SECOND_AGENT, + displayName: "SecondBot", + ownerPubkey: OWNER, + isAgent: true, + }, + ], + relayAgents: [ + { + pubkey: AGENT, + name: "ControlBot", + ownerPubkey: OWNER, + respondTo: "allowlist", + respondToAllowlist: denied ? [] : [VIEWER], + channelNames: [CHANNEL], + }, + { + pubkey: SECOND_AGENT, + name: "SecondBot", + ownerPubkey: OWNER, + respondTo: "allowlist", + respondToAllowlist: [VIEWER], + channelNames: [CHANNEL], + }, + ], + }); + await page.goto("/"); + await page.getByTestId(`channel-${CHANNEL}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(CHANNEL); + await expect + .poll(() => + page.evaluate( + (channelName) => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName }) ?? + false, + CHANNEL, + ), + ) + .toBe(true); + const event = await page.evaluate( + ({ pubkey, channelName, rootAgeSeconds }) => + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + pubkey, + content: "A known agent can recover without optional telemetry.", + createdAt: Math.floor(Date.now() / 1000) - rootAgeSeconds, + }), + { pubkey: AGENT, channelName: CHANNEL, rootAgeSeconds }, + ); + if (!event?.id) throw new Error("Agent event not emitted"); + await expect(row(page, event.id)).toBeVisible(); + return event.id; +} +function row(page: Page | Locator, id: string) { + return page.locator(`[data-testid="message-row"][data-message-id="${id}"]`); +} +async function openThread(page: Page, root: string) { + await row(page, root).hover(); + const reply = page.getByTestId(`reply-message-${root}`); + await reply.focus(); + await reply.press("Enter"); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); +} +async function selectAgent(page: Page, name = "ControlBot") { + const trigger = page.getByTestId("manage-thread-context"); + await trigger.click(); + const agent = page.getByRole("menuitem", { name, exact: true }); + const avatar = agent.getByTestId("thread-context-agent-avatar"); + await expect(avatar).toHaveAttribute("data-avatar-shape", "squircle"); + await expect(avatar.locator("..")).toHaveAttribute("aria-hidden", "true"); + if (name === "ControlBot") { + await expect( + agent.getByTestId("thread-context-agent-avatar-image"), + ).toBeVisible(); + } else { + await expect( + agent.getByTestId("thread-context-agent-avatar-fallback"), + ).toBeVisible(); + } + await agent.click(); + return trigger; +} +async function captureMenu(page: Page, trigger: Locator, name: string) { + await waitForAnimations(page); + await page.screenshot({ path: `${SHOT}/${name}-full.png` }); + const boxes = await Promise.all([ + trigger.boundingBox(), + ...(await page + .getByRole("menu") + .all() + .then((menus) => menus.map((menu) => menu.boundingBox()))), + ]); + const visible = boxes.filter( + (box): box is NonNullable => box !== null, + ); + const viewport = page.viewportSize(); + if (!viewport || visible.length < 2) + throw new Error("Menu capture geometry unavailable"); + const x = Math.max( + 0, + Math.floor(Math.min(...visible.map((box) => box.x)) - 8), + ); + const y = Math.max( + 0, + Math.floor(Math.min(...visible.map((box) => box.y)) - 8), + ); + const right = Math.min( + viewport.width, + Math.ceil(Math.max(...visible.map((box) => box.x + box.width)) + 8), + ); + const bottom = Math.min( + viewport.height, + Math.ceil(Math.max(...visible.map((box) => box.y + box.height)) + 8), + ); + await waitForAnimations(page); + await page.screenshot({ + path: `${SHOT}/${name}-clip.png`, + clip: { x, y, width: right - x, height: bottom - y }, + }); +} +async function sends(page: Page) { + return page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + (call) => call.command === "send_channel_message", + ), + ); +} +async function composerSelection(input: Locator) { + return input.evaluate((element) => { + const selection = window.getSelection(); + if ( + !selection?.anchorNode || + !selection.focusNode || + !element.contains(selection.anchorNode) || + !element.contains(selection.focusNode) + ) + return null; + const offset = (node: Node, position: number) => { + const range = document.createRange(); + range.selectNodeContents(element); + range.setEnd(node, position); + return range.toString().length; + }; + return { + anchor: offset(selection.anchorNode, selection.anchorOffset), + focus: offset(selection.focusNode, selection.focusOffset), + text: selection.toString(), + }; + }); +} +async function expectContextBeforeMentions(page: Page) { + const composer = page.getByTestId("thread-composer-overlay"); + const context = composer.getByTestId("manage-thread-context"); + const mention = composer.getByTestId("message-insert-mention"); + await expect(context).toBeVisible(); + await expect(context).toBeInViewport(); + await expect(mention).toBeVisible(); + await expect(mention).toBeInViewport(); + expect( + await context.evaluate((element) => + Boolean( + element.nextElementSibling?.querySelector( + '[data-testid="message-insert-mention"]', + ), + ), + ), + ).toBe(true); +} +async function expectDescriptionStyle(item: Locator) { + const style = await item.evaluate((el) => { + const id = el.getAttribute("aria-describedby"); + const description = id ? document.getElementById(id) : null; + const title = description?.previousElementSibling; + if (!description || !title) throw new Error("Menu description unavailable"); + const titleStyle = getComputedStyle(title); + const descriptionStyle = getComputedStyle(description); + return { + titleSize: Number.parseFloat(titleStyle.fontSize), + descriptionSize: Number.parseFloat(descriptionStyle.fontSize), + titleColor: titleStyle.color, + descriptionColor: descriptionStyle.color, + }; + }); + expect(style.descriptionSize).toBeLessThan(style.titleSize); + expect(style.descriptionColor).not.toBe(style.titleColor); +} +test("known_agent_without_telemetry_still_has_current_scope_controls", async ({ + page, +}) => { + const root = await setup(page); + await expect( + page.getByRole("button", { name: "Manage context" }), + ).toHaveCount(0); + await openThread(page, root); + const trigger = page.getByTestId("manage-thread-context"); + await expect(trigger).toBeVisible(); + await expect(trigger).toHaveAccessibleName("Manage context"); + await expect(trigger.locator("svg.lucide-brain-cog")).toHaveAttribute( + "aria-hidden", + "true", + ); + const mention = page + .getByTestId("thread-composer-overlay") + .getByTestId("message-insert-mention"); + expect( + await trigger.evaluate((element) => + element.nextElementSibling?.contains( + document.querySelector( + '[data-testid="thread-composer-overlay"] [data-testid="message-insert-mention"]', + ), + ), + ), + ).toBe(true); + await expect(mention).toBeVisible(); + await expect(page.getByTestId("manage-current-session")).toHaveCount(0); + await waitForAnimations(page); + const threadHead = row( + page.getByTestId("message-thread-panel").getByTestId("message-thread-head"), + root, + ); + await threadHead.screenshot({ path: `${SHOT}/before-controls.png` }); + await expect(threadHead.getByTestId("message-session-status")).toHaveCount(0); + const draft = page + .getByTestId("thread-composer-overlay") + .getByTestId("message-input"); + await draft.fill("Preserve this unfinished request"); + await trigger.focus(); + await page.keyboard.press("Enter"); + await expect( + page.getByRole("menuitem", { name: "ControlBot", exact: true }), + ).toBeVisible(); + await expect( + page + .getByRole("menuitem", { name: "ControlBot", exact: true }) + .getByTestId("thread-context-agent-avatar-image"), + ).toBeVisible(); + await captureMenu(page, trigger, "agent-list-avatar-v1"); + expect(await sends(page)).toHaveLength(0); + await page.getByRole("menuitem", { name: "ControlBot", exact: true }).click(); + await expect( + page.getByRole("menuitem", { name: "Compact context", exact: true }), + ).toBeVisible(); + expect(await sends(page)).toHaveLength(0); + const restart = page.getByRole("menuitem", { name: "Restart", exact: true }); + const restartDescription = + "Stops current work, including subagents. File changes are kept."; + await expect( + restart.getByText(restartDescription, { exact: true }), + ).toBeVisible(); + await expect(restart).toHaveAccessibleDescription(restartDescription); + await expectDescriptionStyle(restart); + await captureMenu(page, trigger, "menu-v3"); + await restart.focus(); + await expect(restart).toBeFocused(); + await page.keyboard.press("ArrowRight"); + for (const [label, description] of [ + ["From scratch", "Keep the original assignment and your next request."], + ["Recent history", "Keep the original assignment and recent messages."], + [ + "Entire thread", + "Use the available thread history, within the history limit.", + ], + ]) { + const option = page.getByRole("menuitem", { name: label, exact: true }); + await expect(option.getByText(description, { exact: true })).toBeVisible(); + await expect(option).toHaveAccessibleDescription(description); + await expectDescriptionStyle(option); + } + await captureMenu(page, trigger, "restart-submenu-v3"); + await page.keyboard.press("Escape"); + await page.keyboard.press("Escape"); + await expect(trigger).toBeFocused(); + expect(await sends(page)).toHaveLength(0); + await selectAgent(page); + await page.getByRole("menuitem", { name: "Restart", exact: true }).click(); + await page + .getByRole("menuitem", { + name: "From scratch", + exact: true, + }) + .click(); + await expect.poll(async () => (await sends(page)).length).toBe(1); + const call = (await sends(page))[0].payload as Record; + expect(call.content).toBe("!restart none"); + expect(call.mentionPubkeys).toEqual([AGENT]); + expect(call.parentEventId).toBe(root); + expect(call.rootEventId).toBe(root); + expect(call.expectedSignerPubkey).toBe(VIEWER); + expect(call.expectedRelayUrl).toBeTruthy(); + await expect(draft).toHaveText("Preserve this unfinished request"); + await expect(page.getByTestId("message-session-control")).toContainText( + "Start over from scratch", + ); + await expect(page.getByTestId("message-session-control")).toContainText( + "Result not confirmed", + ); + const log = await page.evaluate(() => window.__BUZZ_E2E_COMMANDS__ ?? []); + for (const command of [ + "start_managed_agent", + "create_managed_agent", + "attach_managed_agent", + "add_channel_members", + ]) + expect(log).not.toContain(command); + await waitForAnimations(page); + await page + .getByTestId("message-session-control") + .screenshot({ path: `${SHOT}/request-row.png` }); + const commandRow = page + .getByTestId("message-row") + .filter({ has: page.getByTestId("message-session-control") }); + const commandId = await commandRow.getAttribute("data-message-id"); + if (!commandId) throw new Error("Missing command event identity"); + const raw = await page.evaluate( + async (eventId) => + JSON.parse( + String( + await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_event", { + eventId, + }), + ), + ), + commandId, + ); + expect(raw.kind).toBe(9); + expect(raw.content).toBe("!restart none"); + expect(raw.tags.filter((tag: string[]) => tag[0] === "p")).toEqual([ + ["p", AGENT], + ]); + expect(raw.tags.filter((tag: string[]) => tag[0] === "e")).toEqual([ + ["e", root, "", "reply"], + ]); + const report = async (pubkey: string, outcome: string, content: string) => + page.evaluate( + ({ pubkey, outcome, content, commandId, channelName }) => + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + pubkey, + kind: 7, + content, + extraTags: [ + ["e", commandId], + ["buzz-session-result", "1", commandId, outcome, ""], + ], + }), + { pubkey, outcome, content, commandId, channelName: CHANNEL }, + ); + await report(VIEWER, "ok", "✅"); + await expect(page.getByTestId("message-session-control")).not.toContainText( + "Completed", + ); + await report(AGENT, "pending", "⏳"); + await expect(page.getByTestId("message-session-control")).toContainText( + "Waiting for current work to stop", + ); + await report(AGENT, "ok", "✅"); + await expect(page.getByTestId("message-session-control")).toContainText( + "Completed", + ); + await waitForAnimations(page); + await commandRow.screenshot({ path: `${SHOT}/confirmed-row.png` }); +}); +test("revoked send-time permission preserves draft and publishes nothing", async ({ + page, +}) => { + const root = await setup(page, true); + await openThread(page, root); + const draft = page + .getByTestId("thread-composer-overlay") + .getByTestId("message-input"); + await draft.fill("Keep my draft"); + await selectAgent(page); + await page + .getByRole("menuitem", { name: "Compact context", exact: true }) + .click(); + await expect( + page.getByTestId("thread-composer-overlay").getByRole("alert"), + ).toContainText("Could not send session control"); + expect(await sends(page)).toHaveLength(0); + await expect(draft).toHaveText("Keep my draft"); +}); + +for (const [label, body] of [ + ["Compact context", "!compact"], + ["Recent history", "!restart"], + ["Entire thread", "!restart all"], +] as const) { + test(`${label} on a nested agent reply sends to its canonical root`, async ({ + page, + }) => { + const root = await setup(page); + const reply = await page.evaluate( + ({ root, pubkey, channelName }) => + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + pubkey, + parentEventId: root, + content: "A reply in the current scope.", + }), + { root, pubkey: AGENT, channelName: CHANNEL }, + ); + if (!reply?.id) throw new Error("Agent reply not emitted"); + await openThread(page, root); + await selectAgent(page); + if (label !== "Compact context") + await page + .getByRole("menuitem", { name: "Restart", exact: true }) + .click(); + await page.getByRole("menuitem", { name: label, exact: true }).click(); + await expect.poll(async () => (await sends(page)).length).toBe(1); + const payload = (await sends(page))[0].payload as Record; + expect(payload.content).toBe(body); + expect(payload.parentEventId).toBe(root); + expect(payload.rootEventId).toBe(root); + expect(payload.mentionPubkeys).toEqual([AGENT]); + if (body === "!compact") { + const control = page.getByTestId("message-session-control"); + const commandId = await page + .getByTestId("message-row") + .filter({ has: control }) + .getAttribute("data-message-id"); + if (!commandId) throw new Error("Missing compact event identity"); + for (const outcome of ["pending", "ok"]) { + await page.evaluate( + ({ commandId, outcome, pubkey, channelName }) => + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + pubkey, + kind: 7, + content: outcome === "pending" ? "⏳" : "✅", + extraTags: [ + ["e", commandId], + ["buzz-session-result", "1", commandId, outcome, ""], + ], + }), + { commandId, outcome, pubkey: AGENT, channelName: CHANNEL }, + ); + await expect(control).toContainText( + outcome === "pending" + ? "Waiting for current work to finish · Compaction will start automatically" + : "Completed", + ); + } + } + }); +} + +test("two agent menus retain the selected signer and sibling scope", async ({ + page, +}) => { + const firstRoot = await setup(page); + const second = await page.evaluate( + ({ pubkey, channelName }) => + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + pubkey, + content: "Second agent in an independent thread.", + }), + { pubkey: SECOND_AGENT, channelName: CHANNEL }, + ); + if (!second?.id) throw new Error("Second agent event not emitted"); + await openThread(page, second.id); + await page.getByTestId("manage-thread-context").click(); + await expect( + page.getByRole("menuitem", { name: "ControlBot", exact: true }), + ).toHaveCount(0); + await page.getByRole("menuitem", { name: "SecondBot", exact: true }).click(); + await page.getByRole("menuitem", { name: "Restart", exact: true }).click(); + await page + .getByRole("menuitem", { name: "Recent history", exact: true }) + .click(); + await expect.poll(async () => (await sends(page)).length).toBe(1); + const payload = (await sends(page))[0].payload as Record; + expect(payload.content).toBe("!restart"); + expect(payload.mentionPubkeys).toEqual([SECOND_AGENT]); + expect(payload.rootEventId).toBe(second.id); + expect(payload.parentEventId).toBe(second.id); + await expect(row(page, firstRoot)).toContainText( + "A known agent can recover without optional telemetry.", + ); + await expect( + row(page, firstRoot).getByTestId("message-session-control"), + ).toHaveCount(0); +}); + +test("denied viewer has no menu while another accessible agent retains recovery", async ({ + page, +}) => { + const root = await setup(page, false, true); + await expect(row(page, root)).toBeVisible(); + await openThread(page, root); + await page.getByTestId("manage-thread-context").click(); + await expect( + page + .getByRole("status") + .filter({ hasText: "No agents you can manage in this thread." }), + ).toBeVisible(); + await expect(page.getByRole("menuitem")).toHaveCount(0); + expect(await sends(page)).toHaveLength(0); +}); + +test("context controls remain available while sending disables the thread editor", async ({ + page, +}) => { + const root = await setup(page, false, false, 0, 5_000); + await openThread(page, root); + const composer = page.getByTestId("thread-composer-overlay"); + const editor = composer.getByTestId("message-input"); + await editor.fill("Hold this send while managing context"); + await composer.getByTestId("send-message").click(); + await expect(editor).toHaveAttribute("contenteditable", "false"); + await expect(page.getByTestId("manage-thread-context")).toBeEnabled(); + await selectAgent(page); + const compact = page.getByRole("menuitem", { + name: "Compact context", + exact: true, + }); + await expect(compact).toBeEnabled(); + await compact.click(); + await expect.poll(async () => (await sends(page)).length).toBe(2); + const payload = (await sends(page))[1].payload as Record; + expect(payload.content).toBe("!compact"); + expect(payload.mentionPubkeys).toEqual([AGENT]); + expect(payload.rootEventId).toBe(root); + expect(payload.parentEventId).toBe(root); +}); + +test("context selection and Compact preserve an unpinned draft, attachment, selection and caret", async ({ + page, +}) => { + const root = await setup(page, false, false, 0, 0, { + deferredComposerUploads: true, + uploadDescriptors: [ + { + url: `https://mock.relay/media/${"a".repeat(64)}.pdf`, + sha256: "a".repeat(64), + size: 12345, + type: "application/pdf", + uploaded: 0, + filename: "context-draft.pdf", + }, + ], + }); + await openThread(page, root); + const composer = page.getByTestId("thread-composer-overlay"); + const input = composer.getByTestId("message-input"); + const mention = composer.getByTestId("message-insert-mention"); + await mention.click(); + await composer + .getByTestId("mention-autocomplete") + .getByRole("button", { name: "Automatically mention ControlBot" }) + .click(); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT}`), + ).toBeVisible(); + await mention.click(); + await composer.getByTestId(`composer-address-lock-remove-${AGENT}`).click(); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT}`), + ).toHaveCount(0); + await input.fill("Keep this draft"); + const [chooser] = await Promise.all([ + page.waitForEvent("filechooser"), + composer.getByRole("button", { name: "Attach file" }).click(), + ]); + await chooser.setFiles({ + buffer: Buffer.from("context attachment"), + mimeType: "application/pdf", + name: "context-draft.pdf", + }); + await expect(composer).toContainText("context-draft.pdf"); + await expect(input).toHaveText("Keep this draft"); + await input.focus(); + await expect(input).toBeFocused(); + await input.evaluate((element) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode; + const index = (node.textContent ?? "").indexOf("this"); + if (index < 0) continue; + const range = document.createRange(); + range.setStart(node, index); + range.setEnd(node, index + "this".length); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + document.dispatchEvent(new Event("selectionchange")); + return; + } + throw new Error("Could not establish the initial draft selection"); + }); + const selection = { anchor: 5, focus: 9, text: "this" }; + await expect.poll(() => composerSelection(input)).toEqual(selection); + await selectAgent(page); + expect(await sends(page)).toHaveLength(0); + await page.keyboard.press("Escape"); + await input.focus(); + await expect.poll(() => composerSelection(input)).toEqual(selection); + await input.press("ArrowRight"); + const caret = { anchor: 9, focus: 9, text: "" }; + await expect.poll(() => composerSelection(input)).toEqual(caret); + await selectAgent(page); + await page + .getByRole("menuitem", { name: "Compact context", exact: true }) + .click(); + await expect.poll(async () => (await sends(page)).length).toBe(1); + const payload = (await sends(page))[0].payload as Record; + expect(payload.content).toBe("!compact"); + expect(payload.mentionPubkeys).toEqual([AGENT]); + expect(payload.mediaTags ?? []).toEqual([]); + await expect(input).toHaveText("Keep this draft"); + await expect(composer).toContainText("context-draft.pdf"); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT}`), + ).toHaveCount(0); + await expect(mention).toHaveAccessibleName("Mention someone"); + await input.focus(); + await expect.poll(() => composerSelection(input)).toEqual(caret); +}); + +test("context stays before mentions across formatting, focus and narrow thread layouts", async ({ + page, +}) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.addInitScript(() => + localStorage.setItem("buzz.channels.threadViewMode", "split"), + ); + const root = await setup(page); + await openThread(page, root); + const composer = page.getByTestId("thread-composer-overlay"); + const input = composer.getByTestId("message-input"); + await input.fill("Keep my layout draft"); + await expectContextBeforeMentions(page); + await composer.getByRole("button", { name: "Toggle formatting" }).click(); + await expect( + composer.getByRole("button", { name: "Close formatting" }), + ).toBeVisible(); + await expectContextBeforeMentions(page); + await composer.getByTestId("manage-thread-context").focus(); + await page.keyboard.press("Space"); + const agent = page.getByRole("menuitem", { name: "ControlBot", exact: true }); + await expect(agent).toBeVisible(); + await page.keyboard.press("ArrowDown"); + await expect(agent).toBeFocused(); + await page.keyboard.press("Enter"); + await expect( + page.getByRole("menuitem", { name: "Compact context", exact: true }), + ).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(composer.getByTestId("manage-thread-context")).toBeFocused(); + await composer.getByRole("button", { name: "Close formatting" }).click(); + await page + .getByRole("button", { name: "Expand thread", exact: true }) + .click(); + await expect(page.getByTestId("focus-thread-drawer")).toBeVisible(); + await expectContextBeforeMentions(page); + await expect(input).toHaveText("Keep my layout draft"); + await page + .getByRole("button", { name: "Show thread beside channel", exact: true }) + .click(); + await expect(page.getByTestId("focus-thread-drawer")).toHaveCount(0); + await page.setViewportSize({ width: 860, height: 720 }); + await expect(page.getByTestId("thread-view-mode-toggle")).toHaveCount(0); + await expectContextBeforeMentions(page); + await composer.getByTestId("manage-thread-context").click(); + await expect(agent).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(input).toHaveText("Keep my layout draft"); + expect(await sends(page)).toHaveLength(0); +}); + +test.describe("touch context navigation", () => { + test.use({ hasTouch: true }); + test("touch selection and Back retain the first agent-list step without sending", async ({ + page, + }) => { + const root = await setup(page); + await openThread(page, root); + const trigger = page.getByTestId("manage-thread-context"); + await trigger.tap(); + await page.getByRole("menuitem", { name: "ControlBot", exact: true }).tap(); + await expect( + page.getByRole("menuitem", { name: "Compact context", exact: true }), + ).toBeVisible(); + await page + .getByRole("menuitem", { name: "Back to agents", exact: true }) + .tap(); + await expect( + page.getByRole("menuitem", { name: "ControlBot", exact: true }), + ).toBeVisible(); + expect(await sends(page)).toHaveLength(0); + await page.keyboard.press("Escape"); + await expect(trigger).toBeFocused(); + }); +}); + +test("correct-clock viewer sees expired old request and validated manual actor", async ({ + page, +}) => { + const root = await setup(page, false, false, 1202); + // Load existing history when opening the thread. Backdated live events after + // an empty initial fetch do not model how an expired request is encountered. + // Generic seeded replies intentionally include their author p. Confirm that + // such a human target remains ordinary; it is not native send output. + const generic = await page.evaluate( + ({ root, actor, target, channelName }) => + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + pubkey: actor, + content: "!compact", + parentEventId: root, + mentionPubkeys: [target], + createdAt: Math.floor(Date.now() / 1000) - 1201, + }), + { root, actor: VIEWER, target: AGENT, channelName: CHANNEL }, + ); + if (!generic?.id) throw new Error("Generic reply not emitted"); + const old = await page.evaluate( + ({ root, pubkey, channelName }) => + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + pubkey, + kind: 9, + content: "!compact", + mentionPubkeys: ["a4".repeat(32)], + extraTags: [["e", root, "", "reply"]], + createdAt: Math.floor(Date.now() / 1000) - 1200, + }), + { root, pubkey: VIEWER, channelName: CHANNEL }, + ); + if (!old?.id) throw new Error("Old request not emitted"); + const stored = await page.evaluate( + async (ids) => + Promise.all( + ids.map(async (eventId) => + JSON.parse( + String( + await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_event", { + eventId, + }), + ), + ), + ), + ), + [generic.id, old.id], + ); + expect(stored[0].tags.filter((tag: string[]) => tag[0] === "p")).toEqual([ + ["p", VIEWER], + ["p", AGENT], + ]); + expect(stored[1].tags.filter((tag: string[]) => tag[0] === "p")).toEqual([ + ["p", AGENT], + ]); + expect(stored[1].tags.filter((tag: string[]) => tag[0] === "e")).toEqual([ + ["e", root, "", "reply"], + ]); + const summary = page.locator( + `[data-testid="message-thread-summary"][data-thread-head-id="${root}"]`, + ); + await expect(summary).toContainText("2 replies"); + await summary.click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + await expect(row(page, generic.id)).toContainText("!compact"); + await expect( + row(page, generic.id).getByTestId("message-session-control"), + ).toHaveCount(0); + await expect( + row(page, old.id).getByTestId("message-session-control"), + ).toContainText("Request expired · Result not confirmed"); + const transition = "7f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b"; + const response = await page.evaluate( + ({ root, command, transition, pubkey, actor, channelName }) => + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + pubkey, + content: "A manually restarted session.", + parentEventId: root, + extraTags: [ + ["buzz-session", "restarted", "user_restart:recent", ""], + ["buzz-session-info", "1", transition, "thread", "2", "2", "exact"], + ["buzz-session-action", "1", transition, command, actor], + ], + }), + { + root, + command: old.id, + transition, + pubkey: AGENT, + actor: VIEWER, + channelName: CHANNEL, + }, + ); + if (!response?.id) throw new Error("Manual transition not emitted"); + await expect( + row(page, response.id).getByTestId("message-session-status"), + ).toContainText("by npub1mock..."); + await waitForAnimations(page); + await page + .getByTestId("message-thread-panel") + .screenshot({ path: `${SHOT}/expired-and-actor.png` }); +}); + +test("owned observer activity projects typed compaction lifecycle through the real store and panel", async ({ + page, +}) => { + const activityAgent = TEST_IDENTITIES.tyler.pubkey; + await installMockBridge(page, { + managedAgents: [ + { + pubkey: activityAgent, + name: "Activity Agent", + status: "running", + channelNames: [CHANNEL], + }, + ], + }); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function", + ); + await page.getByTestId(`channel-${CHANNEL}`).click(); + const message = page + .getByTestId("message-row") + .filter({ has: page.getByText("Activity Agent", { exact: false }) }) + .first(); + await expect(message).toBeVisible(); + await message.getByRole("button").first().click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + await page.getByTestId(`user-profile-view-activity-${activityAgent}`).click(); + const panel = page.getByTestId("agent-session-thread-panel"); + await expect(panel).toBeVisible(); + const base = { + agentIndex: 0, + channelId: "94a444a4-c0a3-5966-ab05-530c6ddc2301", + sessionId: "activity-session", + turnId: "compact-request", + }; + const frame = (seq: number, kind: string, payload: unknown) => ({ + ...base, + seq, + timestamp: new Date( + Date.parse("2026-10-01T12:00:00Z") + seq * 1000, + ).toISOString(), + kind, + payload, + }); + const typed = ( + seq: number, + status: string, + id = "compact-one", + summary?: string, + ) => + frame(seq, "acp_read", { + method: "session/update", + params: { + sessionId: "activity-session", + update: { + sessionUpdate: "compaction_update", + compactionId: id, + status, + ...(summary ? { summary } : {}), + }, + }, + }); + const seed = async ( + events: Array< + Omit, "channelId" | "sessionId" | "turnId"> & { + channelId: string | null; + sessionId: string | null; + turnId: string | null; + } + >, + ) => + page.evaluate( + ({ agentPubkey, events }) => + window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({ agentPubkey, events }), + { agentPubkey: activityAgent, events }, + ); + const initial = typed(3, "in_progress"); + await seed([ + { + ...frame(1, "agent_initialized", { agentIndex: 0, initializeResult: {} }), + channelId: null, + sessionId: null, + turnId: null, + }, + frame(2, "acp_write", { + method: "session/prompt", + params: { + sessionId: "activity-session", + prompt: [{ type: "text", text: "/compact" }], + }, + }), + initial, + ]); + const lifecycle = panel.getByTestId("transcript-lifecycle-item"); + await expect(lifecycle.filter({ hasText: "Compacting context" })).toHaveCount( + 1, + ); + await expect( + lifecycle.filter({ hasText: "Requested compaction" }), + ).toHaveCount(1); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOT}/activity-running-full.png` }); + await panel.screenshot({ path: `${SHOT}/activity-running-clip.png` }); + const terminal = typed( + 5, + "failed", + "compact-one", + "Provider rejected compaction.", + ); + await seed([ + frame(4, "acp_read", { + method: "session/update", + params: { + sessionId: "activity-session", + update: { + sessionUpdate: "tool_call", + toolCallId: "legacy-hook", + title: "PostCompact", + status: "completed", + }, + }, + }), + terminal, + typed( + 6, + "completed", + "compact-two", + "Second operation retained its outcome.", + ), + ]); + await seed([initial, terminal]); + await expect( + lifecycle.filter({ hasText: "Context compaction failed" }), + ).toHaveCount(1); + await expect(lifecycle.filter({ hasText: "Context compacted" })).toHaveCount( + 1, + ); + await expect( + panel + .getByTestId("transcript-tool-item") + .filter({ hasText: "Context compacted" }), + ).toHaveCount(0); + const failure = lifecycle.filter({ hasText: "Context compaction failed" }); + await expect(failure).toHaveClass(/text-destructive/); + await expect(failure).toContainText( + "Adapter summary: Provider rejected compaction.", + ); + await expect(panel.getByTestId("transcript-suppressed-item")).toHaveCount(0); + const second = lifecycle.filter({ + hasText: "Second operation retained its outcome.", + }); + await expect(second).toContainText("Compaction origin unknown"); + await expect(second).not.toContainText("Requested compaction"); + await seed([ + { + ...frame(7, "agent_initialized", { agentIndex: 0, initializeResult: {} }), + channelId: null, + sessionId: null, + turnId: null, + }, + { ...typed(8, "in_progress"), turnId: "new-worker-turn" }, + ]); + await expect(lifecycle.filter({ hasText: "Compacting context" })).toHaveCount( + 1, + ); + await expect(failure).toHaveCount(1); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOT}/activity-outcomes-full.png` }); + await panel.screenshot({ path: `${SHOT}/activity-outcomes-clip.png` }); + await panel.getByRole("button", { name: "Open activity settings" }).click(); + await page.getByTestId("agent-session-toggle-raw-feed").click(); + await page.keyboard.press("Escape"); + const raw = panel + .locator("details") + .filter({ has: page.locator("summary").filter({ hasText: "#5" }) }); + await raw.locator("summary").click(); + await expect(raw.locator("pre")).toContainText( + '"compactionId": "compact-one"', + ); + await expect(raw.locator("pre")).toContainText('"status": "failed"'); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOT}/activity-raw-full.png` }); + await panel.screenshot({ path: `${SHOT}/activity-raw-clip.png` }); +}); + +for (const [name, response, eventId] of [ + [ + "restart_acknowledgment_is_distinct_from_generated_status", + "The assignment is understood. Validation is still open; the next step is to run the focused checks.", + "d8".repeat(32), + ], + [ + "restart_success_and_followup_failure_remain_independent", + "The session restarted, but I couldn't confirm its task-status reply. Your next message can continue the conversation.", + "e8".repeat(32), + ], +] as const) { + test(name, async ({ page }) => { + const root = await setup(page); + const sibling = await page.evaluate( + ({ channelName, pubkey }) => + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + pubkey, + content: "An independent sibling thread stays unchanged.", + }), + { channelName: CHANNEL, pubkey: SECOND_AGENT }, + ); + if (!sibling?.id) throw new Error("Sibling event not emitted"); + await openThread(page, root); + const panel = page.getByTestId("message-thread-panel"); + const draft = page + .getByTestId("thread-composer-overlay") + .getByTestId("message-input"); + await draft.fill("Preserve the next real task instruction"); + await selectAgent(page); + await page.getByRole("menuitem", { name: "Restart", exact: true }).click(); + await page + .getByRole("menuitem", { name: "From scratch", exact: true }) + .click(); + await expect.poll(async () => (await sends(page)).length).toBe(1); + const payload = (await sends(page))[0].payload as Record; + expect(payload.content).toBe("!restart none"); + expect(payload.mentionPubkeys).toEqual([AGENT]); + expect(payload.parentEventId).toBe(root); + expect(payload.rootEventId).toBe(root); + expect(payload.expectedSignerPubkey).toBe(VIEWER); + expect(payload.expectedRelayUrl).toBeTruthy(); + const control = panel.getByTestId("message-session-control"); + const command = panel + .getByTestId("message-row") + .filter({ has: page.getByTestId("message-session-control") }); + await expect(command).toHaveCount(1); + const commandId = await command.getAttribute("data-message-id"); + if (!commandId) throw new Error("Missing restart event identity"); + await expect(control).toContainText("Result not confirmed"); + await page.evaluate( + ({ channelName, pubkey, commandId }) => + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + pubkey, + kind: 7, + content: "✅", + extraTags: [ + ["e", commandId], + ["buzz-session-result", "1", commandId, "ok", ""], + ], + }), + { channelName: CHANNEL, pubkey: AGENT, commandId }, + ); + // A reset acknowledgment proves no model answer or provider readiness. + await expect(control).toContainText("Completed"); + await expect(panel.getByText(response, { exact: true })).toHaveCount(0); + await expect( + panel.getByText(/session is ready|ready to continue/i), + ).toHaveCount(0); + await expect(draft).toHaveText("Preserve the next real task instruction"); + const createdAt = Math.floor(Date.now() / 1000); + const input = { + channelName: CHANNEL, + pubkey: AGENT, + kind: 9, + content: response, + extraTags: [["e", root, "", "reply"]], + id: eventId, + createdAt, + }; + await page.evaluate( + (input) => window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.(input), + input, + ); + await expect(row(panel, eventId)).toHaveCount(1); + await expect(row(panel, eventId)).toContainText(response); + // Replay the same mock event to bind ordinary timeline deduplication. + await page.evaluate( + (input) => window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.(input), + input, + ); + await expect(row(panel, eventId)).toHaveCount(1); + await expect(control).toContainText("Completed"); + await expect(control).not.toContainText("Failed"); + await expect(draft).toHaveText("Preserve the next real task instruction"); + const raw = await page.evaluate( + async (eventId) => + JSON.parse( + String( + await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_event", { + eventId, + }), + ), + ), + eventId, + ); + expect(raw.kind).toBe(9); + expect(raw.pubkey).toBe(AGENT); + expect(raw.content).toBe(response); + expect(raw.tags.filter((tag: string[]) => tag[0] === "p")).toEqual([]); + expect(raw.tags.filter((tag: string[]) => tag[0] === "broadcast")).toEqual( + [], + ); + expect( + raw.tags + .filter((tag: string[]) => tag[0] === "e") + .map((tag: string[]) => tag[1]), + ).toContain(root); + expect( + raw.tags + .filter((tag: string[]) => tag[0] === "e") + .map((tag: string[]) => tag[1]), + ).not.toContain(sibling.id); + await expect(row(page, sibling.id)).not.toContainText(response); + expect(await sends(page)).toHaveLength(1); + if (name === "restart_success_and_followup_failure_remain_independent") { + await expect( + panel.getByText("Validation is complete and the agent is ready.", { + exact: true, + }), + ).toHaveCount(0); + } + }); +} diff --git a/desktop/tests/e2e/session-status.spec.ts b/desktop/tests/e2e/session-status.spec.ts new file mode 100644 index 00000000000..504f1ea5132 --- /dev/null +++ b/desktop/tests/e2e/session-status.spec.ts @@ -0,0 +1,304 @@ +import { expect, type Locator, type Page, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +// Agent session status rendering: an agent's own kind-9 messages carry +// signed `buzz-session` / `buzz-session-info` tags, and Desktop shows a +// small noninteractive line beneath the body. Ordinary mock-bridge events +// drive every case; no special test hooks are involved. + +const CHANNEL = "agents"; +const SHOT = "test-results/session-status"; +// Must not collide with any e2eBridge constant. +const AGENT_PUBKEY = `5e55${"a".repeat(60)}`; +const T_NEW = "6f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b"; +const T_SLEEP = "0f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b"; +const T_THREAD = "1f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b"; + +type EmittedEvent = { id: string }; +type MockWindow = Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + parentEventId?: string | null; + pubkey?: string; + extraTags?: string[][]; + createdAt?: number; + }) => EmittedEvent; + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + }) => boolean; +}; + +function statusTags( + state: string, + reason: string, + percent: string, + transition: string, + scope: "thread" | "conversation", + history: [string, string, string] = ["", "", "unknown"], +) { + return [ + ["buzz-session", state, reason, percent], + ["buzz-session-info", "1", transition, scope, ...history], + ]; +} + +async function emit( + page: Page, + input: { + content: string; + createdAt: number; + pubkey?: string; + parentEventId?: string; + extraTags?: string[][]; + }, +): Promise { + const event = await page.evaluate( + (payload) => + (window as MockWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.(payload) ?? null, + { channelName: CHANNEL, pubkey: AGENT_PUBKEY, ...input }, + ); + if (!event?.id) throw new Error("mock message emit did not return an id"); + return event.id; +} + +function row(scope: Page | Locator, id: string) { + return scope.locator(`[data-testid="message-row"][data-message-id="${id}"]`); +} + +function statusOf(scope: Page | Locator, id: string) { + return row(scope, id).getByTestId("message-session-status"); +} + +async function expectQuietRows(scope: Locator) { + const attributes = await scope + .getByTestId("message-session-status") + .evaluateAll((rows) => + rows.flatMap((status) => + [status, ...status.querySelectorAll("*")].flatMap((element) => + ["tabindex", "aria-live", "aria-label", "role"].filter((name) => + element.hasAttribute(name), + ), + ), + ), + ); + expect(attributes).toEqual([]); +} + +test.beforeEach(async ({ page }) => { + await installMockBridge(page, { + managedAgents: [], + searchProfiles: [ + { + pubkey: AGENT_PUBKEY, + displayName: "StatusBot", + ownerPubkey: "b4".repeat(32), + isAgent: true, + }, + ], + relayAgents: [ + { + pubkey: AGENT_PUBKEY, + name: "StatusBot", + ownerPubkey: "b4".repeat(32), + respondTo: "allowlist", + respondToAllowlist: ["deadbeef".repeat(8)], + channelNames: [CHANNEL], + }, + ], + }); +}); + +test("session status explains each transition once and shows reported context", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId(`channel-${CHANNEL}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(CHANNEL); + await expect + .poll(() => + page.evaluate( + (channelName) => + (window as MockWindow).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName, + }) ?? false, + CHANNEL, + ), + ) + .toBe(true); + + const base = Math.floor(Date.now() / 1000) - 60; + const first = await emit(page, { + content: "First answer in a new session.", + createdAt: base, + extraTags: statusTags("new", "first_in_thread", "", T_NEW, "conversation", [ + "12", + "37", + "exact", + ]), + }); + const below = await emit(page, { + content: "Continuation just below the threshold.", + createdAt: base + 1, + extraTags: statusTags( + "new", + "first_in_thread", + "79", + T_NEW, + "conversation", + ), + }); + const at = await emit(page, { + content: "Continuation at the threshold.", + createdAt: base + 2, + extraTags: statusTags( + "new", + "first_in_thread", + "80", + T_NEW, + "conversation", + ), + }); + const resumed = await emit(page, { + content: "Back after sleeping; context not reported yet.", + createdAt: base + 3, + extraTags: statusTags("resumed", "idle_sleep", "", T_SLEEP, "conversation"), + }); + + const timeline = page.getByTestId("message-timeline"); + const resumedRow = row(timeline, resumed); + const scrollToLatest = page.getByTestId("message-scroll-to-latest"); + // Live arrivals can wait behind the timeline's pending-tail affordance. + await expect + .poll( + async () => + (await resumedRow.isVisible()) || (await scrollToLatest.isVisible()), + ) + .toBe(true); + if (!(await resumedRow.isVisible())) { + await expect(scrollToLatest).toBeVisible(); + await scrollToLatest.click(); + } + await expect(resumedRow).toBeVisible(); + + // New session: explanation with exact history, no fabricated percentage. + await expect(statusOf(page, first)).toContainText("New session"); + await expect(statusOf(page, first)).toContainText("read 12 of 37 messages"); + await expect(statusOf(page, first)).not.toContainText("%"); + + // Grouped continuation rows keep only their percentage; 79 is normal. + await expect(row(page, below).getByTestId("message-header")).toHaveCount(0); + await expect(statusOf(page, below)).toHaveText(/^79% context$/); + await expect( + statusOf(page, below).getByTestId("message-session-status-percent"), + ).toHaveAttribute("data-level", "normal"); + + // 80 is emphasized and says so in text. + const high = statusOf(page, at).getByTestId("message-session-status-percent"); + await expect(high).toHaveAttribute("data-level", "high"); + await expect(high).toContainText("80% context (high)"); + await expect(statusOf(page, at)).not.toContainText("New session"); + + // Unknown context: explanation only, no percentage or history claim. + await expect(statusOf(page, resumed)).toContainText("Resumed after sleep"); + await expect(statusOf(page, resumed)).not.toContainText("%"); + await expect(statusOf(page, resumed)).not.toContainText("read"); + + // One understandable explanation per loaded transition. + await expect(timeline.getByText("New session", { exact: true })).toHaveCount( + 1, + ); + await expect( + timeline.getByText("Resumed after sleep", { exact: true }), + ).toHaveCount(1); + await expectQuietRows(timeline); + + await waitForAnimations(page); + await page.screenshot({ path: `${SHOT}/channel-status.png` }); + const statusGroup = statusOf(page, first).locator(".."); + await expect( + statusGroup.getByRole("button", { name: "Manage current session" }), + ).toHaveCount(0); + await expect(page.getByTestId("manage-current-session")).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Manage context" }), + ).toHaveCount(0); + await waitForAnimations(page); + await statusGroup.screenshot({ path: `${SHOT}/quiet-status.png` }); + + // Thread scope: a human root, two agent replies in one thread transition. + const root = await emit(page, { + content: "Can you look at the release notes?", + createdAt: base + 4, + pubkey: TEST_IDENTITIES.tyler.pubkey, + }); + const reply1 = await emit(page, { + content: "Looking now.", + createdAt: base + 5, + parentEventId: root, + extraTags: statusTags( + "resumed", + "harness_restart", + "45", + T_THREAD, + "thread", + ), + }); + const reply2 = await emit(page, { + content: "Done, two typos fixed.", + createdAt: base + 6, + parentEventId: root, + extraTags: statusTags( + "resumed", + "harness_restart", + "46", + T_THREAD, + "thread", + ), + }); + + await expect(row(page, root)).toBeVisible(); + await row(timeline, root).hover(); + const reply = page.getByTestId(`reply-message-${root}`); + await reply.focus(); + await reply.press("Enter"); + const threadPanel = page.getByTestId("message-thread-panel"); + await expect(threadPanel).toBeVisible(); + + await expect(statusOf(threadPanel, reply1)).toContainText( + "Resumed after restart", + ); + await expect(statusOf(threadPanel, reply1)).toContainText("45% context"); + await expect(statusOf(threadPanel, reply2)).toHaveText(/^46% context$/); + await expect( + threadPanel.getByText("Resumed after restart", { exact: true }), + ).toHaveCount(1); + // The human root never gets a status line. + await expect(statusOf(threadPanel, root)).toHaveCount(0); + await expectQuietRows(threadPanel); + + const context = threadPanel.getByRole("button", { name: "Manage context" }); + await expect(context).toBeVisible(); + await context.click(); + const agent = page.getByRole("menuitem", { name: "StatusBot", exact: true }); + await expect(agent).toBeVisible(); + await agent.click(); + await expect( + page.getByRole("menuitem", { name: "Compact context", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Restart", exact: true }), + ).toBeVisible(); + await page + .getByRole("menuitem", { name: "Back to agents", exact: true }) + .click(); + await expect(agent).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(context).toBeFocused(); + await expect(page.getByTestId("manage-current-session")).toHaveCount(0); + + await waitForAnimations(page); + await page.screenshot({ path: `${SHOT}/thread-status.png` }); +}); diff --git a/desktop/tests/e2e/thread-head-stale-edit.spec.ts b/desktop/tests/e2e/thread-head-stale-edit.spec.ts index c72ebc78f79..06ea59e5447 100644 --- a/desktop/tests/e2e/thread-head-stale-edit.spec.ts +++ b/desktop/tests/e2e/thread-head-stale-edit.spec.ts @@ -122,7 +122,8 @@ test("thread head reflects the channel-window edit even before thread aux loads" // 3. Open the thread via the reply action (the flow in the bug report). const replyButton = page.getByTestId(`reply-message-${rootId}`); - await replyButton.click({ force: true }); + await timelineRow.hover(); + await replyButton.click(); const threadPanel = page.getByTestId("message-thread-panel"); await expect(threadPanel).toBeVisible(); diff --git a/docs/fork/DEPENDENCY_SECURITY.md b/docs/fork/DEPENDENCY_SECURITY.md index 67d9d5ec74c..fad4114da3e 100644 --- a/docs/fork/DEPENDENCY_SECURITY.md +++ b/docs/fork/DEPENDENCY_SECURITY.md @@ -42,10 +42,11 @@ The native lock also updates `h2` to 0.4.16 and `rtrb` to 0.3.5, fixing [RUSTSEC-2026-0274](https://rustsec.org/advisories/RUSTSEC-2026-0274.html). NetEq 0.8.3 also declared an unused ringbuf dependency affected by [RUSTSEC-2026-0293](https://rustsec.org/advisories/RUSTSEC-2026-0293.html). -The [compatibility pin](https://github.com/reynholm/videocall-rs/commit/16729881b0a80022a7cbe0acc94c9becf2fad313) +The [compatibility pin](https://github.com/security-union/videocall-rs/commit/16729881b0a80022a7cbe0acc94c9becf2fad313) removes only that manifest entry; the decoder/jitter-buffer source and API remain identical to 0.8.3. Remove the pin when an upstream compatible NetEq release drops the unused dependency. +The public source URL preserves the exact pinned commit and dependency contents. Tauri 2.12.4 with utilities 2.10.2 removes the retired Unicode package graph via urlpattern 0.6. The coordinated Tauri 2 update is required because utilities 2.10 adds diff --git a/scripts/check-acp-retirement.py b/scripts/check-acp-retirement.py new file mode 100644 index 00000000000..23c321819fb --- /dev/null +++ b/scripts/check-acp-retirement.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Run the hosted ACP retirement gate, refusing absent or zero-matched tests.""" +import datetime +import hashlib +import json +import os +import platform +from pathlib import Path +import re +import subprocess +import sys + +WINDOWS = sys.platform == "win32" +MACOS = sys.platform == "darwin" +if not (WINDOWS or MACOS): + raise SystemExit("The hosted retirement gate requires native Windows or macOS") + +for name in ( + "BUZZ_ACP_ALLOWED_RESPOND_TO", "BUZZ_ACP_LAZY_POOL", "BUZZ_ACP_IDLE_POOL_SLEEP", + "BUZZ_ACP_SESSION_POLICY", "BUZZ_ACP_SESSION_STORE_DIR", "BUZZ_ACP_NO_SESSION_RESUME", + "BUZZ_ACP_LAUNCH_PREFIX", +): + os.environ.pop(name, None) + +def source_receipt(): + """Report the actual checkout, including synthetic Actions merge commits.""" + return { + "head": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + "tree": subprocess.check_output(["git", "rev-parse", "HEAD^{tree}"], text=True).strip(), + "status": subprocess.check_output(["git", "status", "--porcelain"], text=True), + } + + +before = source_receipt() +print(json.dumps({"phase": "before", "source": before, + "at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "os": platform.platform(), "python": sys.version, + "rustc": subprocess.check_output(["rustc", "--version"], text=True).strip(), + "cargo": subprocess.check_output(["cargo", "--version"], text=True).strip()}), flush=True) + +base = ["cargo", "test", "--locked", "-p", "buzz-acp", "--lib"] +# Discovery and execution use the same filters, then compare exact counts. +gates = [("checked_termination", 9 if WINDOWS else 8), ("portable_retirement", 4)] +if MACOS: + gates.append(("checked_retirement", None)) +try: + for name, expected in gates: + discovery = subprocess.run(base + [name, "--", "--list"], check=False, + capture_output=True, encoding="utf-8", errors="replace") + print(discovery.stderr, end="", file=sys.stderr, flush=True) + if discovery.returncode: + print(discovery.stdout, end="", flush=True) + discovery.check_returncode() + discovered = discovery.stdout + tests = [line.removesuffix(": test") for line in discovered.splitlines() + if line.endswith(": test")] + if not tests or (expected is not None and len(tests) != expected): + raise SystemExit(f"{name}: expected {expected or 'nonzero'} tests, found {tests}") + if name == "checked_retirement" and not any( + test.endswith("checked_retirement_executor_waits_for_verified_kill_and_excludes_refill") + for test in tests + ): + raise SystemExit("Missing actual worker-retirement executor regression") + print(f"Native {sys.platform}: {name}: {len(tests)} tests\n" + "\n".join(tests), flush=True) + result = subprocess.run(base + [name, "--", "--test-threads=1"], + capture_output=True, encoding="utf-8", errors="replace") + print(result.stdout, end="", flush=True) + print(result.stderr, end="", file=sys.stderr, flush=True) + result.check_returncode() + summary = re.search(r"test result: ok\. (\d+) passed; 0 failed; 0 ignored;", result.stdout) + if summary is None or int(summary[1]) != len(tests): + raise SystemExit(f"{name}: executed count differs from discovered {len(tests)} tests") + if WINDOWS: + # Falsify the production parent-reap guard, require a behavioral failure, + # then restore exact bytes even when compilation/execution fails. + source = Path("crates/buzz-acp/src/acp/windows_job.rs") + original = source.read_bytes() + guard = b"if count == 0 && reaped {" + if original.count(guard) != 1: + raise SystemExit("Missing unique parent-reap guard for falsification") + test = ("acp::windows_retirement_tests::" + "checked_termination_windows_empty_query_requires_parent_reap") + command = base + [test, "--", "--exact", "--test-threads=1"] + try: + source.write_bytes(original.replace(guard, b"if count == 0 {")) + mutation = subprocess.run(command, capture_output=True, + encoding="utf-8", errors="replace") + print(mutation.stdout, end="", flush=True) + print(mutation.stderr, end="", file=sys.stderr, flush=True) + if (mutation.returncode == 0 or + "empty job query accepted an unreaped parent" not in mutation.stdout or + not re.search(r"test result: FAILED\. 0 passed; 1 failed; 0 ignored;", + mutation.stdout)): + raise SystemExit("Parent-reap mutation did not fail the actual regression") + finally: + source.write_bytes(original) + if source.read_bytes() != original: + raise SystemExit("Retirement guard restoration differs") + print(json.dumps({"phase": "guard-restored", + "sha256": hashlib.sha256(original).hexdigest()}), flush=True) + restored = subprocess.run(command, capture_output=True, + encoding="utf-8", errors="replace") + print(restored.stdout, end="", flush=True) + print(restored.stderr, end="", file=sys.stderr, flush=True) + restored.check_returncode() + if not re.search(r"test result: ok\. 1 passed; 0 failed; 0 ignored;", restored.stdout): + raise SystemExit("Restored parent-reap regression did not execute once") +finally: + after = source_receipt() + print(json.dumps({"phase": "after", "source": after, + "at": datetime.datetime.now(datetime.timezone.utc).isoformat()}), flush=True) + if after != before: + raise SystemExit("Tested source changed during the retirement gate") diff --git a/scripts/fork/patches.json b/scripts/fork/patches.json index 7342e301e28..a9a80e11966 100644 --- a/scripts/fork/patches.json +++ b/scripts/fork/patches.json @@ -3750,12 +3750,15 @@ }, { "path": "desktop/src/features/agents/AGENTS.md", - "responsibility": "Device sharing creation UI, authoritative home presentation and saved-definition recovery", + "responsibility": "Device sharing creation UI, authoritative home presentation and saved-definition recovery Document scoped session permission, routing, restart warning, original command/result authority and batched actor behavior. Thread-composer agent-first context placement, bounded raw participant roster, disabled-editor availability and captured scope reset.", "is_new": false, - "required_symbols": [], + "required_symbols": [ + "## Scoped session controls" + ], "invocation_seams": [], "verification_commands": [ - "just desktop-test desktop-typecheck" + "just desktop-test desktop-typecheck", + "CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts" ] }, { @@ -4405,7 +4408,10 @@ "path": ".github/workflows/_ci-rust.yml", "responsibility": "Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches", "is_new": false, - "required_symbols": [], + "required_symbols": [ + "python scripts/check-acp-retirement.py", + "python3 scripts/check-acp-retirement.py" + ], "invocation_seams": [], "verification_commands": [ "just ci" @@ -4516,10 +4522,83 @@ }, { "path": "crates/buzz-acp/src/acp.rs", - "responsibility": "Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches", + "responsibility": "Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches Route standard usage_update occupancy to the status binding of its own session ID; the harness owns BUZZ_SESSION_STATUS_DIR in adapter spawns.", "is_new": false, - "required_symbols": [], - "invocation_seams": [], + "required_symbols": [ + "pub async fn session_resume(", + "self.resume_supported = result", + "pub(crate) fn bind_session_status(", + "pub(crate) fn clear_session_status(", + "DispatchRetired,", + "\"session\": {\"compaction\": {}}", + "if update_type == \"compaction_update\"" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/acp.rs", + "callee_symbol": "record_usage_update", + "invocation": "crate::session_status::record_usage_update(&self.session_status, msg);", + "behavior_test": "claude_usage_updates_route_by_their_own_session_id", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/acp.rs", + "callee_symbol": "apply_status_dir_env", + "invocation": "crate::session_status::apply_status_dir_env(&mut cmd, extra_env);", + "behavior_test": "status_dir_env_is_owned_by_the_harness", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1", + "coverage": "The helper is unit-tested; the spawn call is a textual guard (no test mutates the process environment)." + }, + { + "caller_path": "crates/buzz-acp/src/acp.rs", + "callee_symbol": "update", + "invocation": "self.compactions.update(session, update)", + "behavior_test": "compaction_standard_dispatch_terminal_only_duplicate_and_next_request", + "verification_command": "cargo test --locked -p buzz-acp --lib compaction_standard_dispatch_terminal_only_duplicate_and_next_request -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/acp.rs", + "callee_symbol": "compacted", + "invocation": "binding.compacted(origin);", + "behavior_test": "typed_compaction_changes_only_transition_and_manual_actor", + "verification_command": "cargo test --locked -p buzz-acp --lib typed_compaction_changes_only_transition_and_manual_actor -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/acp.rs", + "callee_symbol": "prepare_resume", + "invocation": "self.compactions.prepare_resume(session_id)", + "behavior_test": "resumed_pre_ack_compaction_cannot_become_a_manual_completion_or_actor", + "verification_command": "cargo test --locked -p buzz-acp --lib resumed_pre_ack_compaction_cannot_become_a_manual_completion_or_actor -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/acp.rs", + "callee_symbol": "complete_resume", + "invocation": ".complete_resume(session_id, generation, response.is_ok());", + "behavior_test": "resumed_pre_ack_compaction_cannot_become_a_manual_completion_or_actor", + "verification_command": "cargo test --locked -p buzz-acp --lib resumed_pre_ack_compaction_cannot_become_a_manual_completion_or_actor -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/acp.rs", + "callee_symbol": "bind", + "invocation": "self.compactions.bind(session_id, binding.generation_id());", + "behavior_test": "resumed_pre_ack_compaction_cannot_become_a_manual_completion_or_actor", + "verification_command": "cargo test --locked -p buzz-acp --lib resumed_pre_ack_compaction_cannot_become_a_manual_completion_or_actor -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/acp.rs", + "callee_symbol": "prepare_new", + "invocation": "self.compactions.prepare_new();", + "behavior_test": "compact_uses_pre_ack_new_session_advertisement_after_ordinary_completion", + "verification_command": "cargo test --locked -p buzz-acp --lib compact_uses_pre_ack_new_session_advertisement_after_ordinary_completion -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/acp.rs", + "callee_symbol": "complete_new", + "invocation": "self.compactions.complete_new(generation, returned_session);", + "behavior_test": "failed_or_malformed_new_response_cannot_adopt_pre_ack_advertisement", + "verification_command": "cargo test --locked -p buzz-acp --lib failed_or_malformed_new_response_cannot_adopt_pre_ack_advertisement -- --test-threads=1" + } + ], "verification_commands": [ "just ci" ] @@ -4568,7 +4647,10 @@ "path": "crates/buzz-acp/src/config.rs", "responsibility": "Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches", "is_new": false, - "required_symbols": [], + "required_symbols": [ + "BUZZ_ACP_SESSION_STORE_DIR", + "BUZZ_ACP_NO_SESSION_RESUME" + ], "invocation_seams": [], "verification_commands": [ "just ci" @@ -4596,12 +4678,239 @@ }, { "path": "crates/buzz-acp/src/lib.rs", - "responsibility": "Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches Preserve the heartbeat collection call with a textual drift guard; full live-service-loop behavioral mutation coverage is not claimed.", + "responsibility": "Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches Preserve the heartbeat collection call with a textual drift guard; full live-service-loop behavioral mutation coverage is not claimed. Refresh idle clocks when a non-heartbeat prompt result is handled (port of block/buzz#7173). Enable session status once per harness and retire status on owner rotate and channel removal; forward the status directory to the managed MCP server.", "is_new": false, "required_symbols": [ - "pool.queue_finished_background_work(&mut queue);" + "pool.queue_finished_background_work(&mut queue);", + "fn result_refreshes_idle_clock(source: &PromptSource) -> bool", + "mod session_status;", + "name == session_status::SESSION_STATUS_DIR_ENV", + "ctx.session_controls.compact_pending(&scope) && batch.continuation.is_none()", + "fn handle_owner_cancel_for_scope" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "result_refreshes_idle_clock", + "invocation": "if result_refreshes_idle_clock(&result.source) {", + "behavior_test": "error_outcome_emission_tests::channel_result_refreshes_idle_clock_wiring", + "verification_command": "cargo test -p buzz-acp --lib error_outcome_emission_tests::channel_result_refreshes_idle_clock_wiring -- --exact", + "coverage": "handle_prompt_result wiring; the run_harness loop itself is not exercised" + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "depart_idle_sessions", + "invocation": "pool.depart_idle_sessions(session_store::Departure::IdleSleep);", + "behavior_test": "pool_sleep_marks_every_idle_session", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1", + "coverage": "Textual guard for run_harness wiring; named tests exercise pool/store behavior, not the live run_harness loop. Live loop acceptance uses an isolated relay." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "restart", + "invocation": "match ctx.session_controls.restart(job) {", + "behavior_test": "restart_during_delayed_acquisition_fences_old_dispatch", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1", + "coverage": "Textual guard for main-loop admission. Named test consumes the production authorization capability, persists restart and fences actual delayed acquisition; no live harness/provider acceptance claimed." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "discard_durable_channel", + "invocation": "pool.discard_durable_channel(ch);", + "behavior_test": "channel_removal_then_readd_starts_new_session", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1", + "coverage": "Textual guard for run_harness wiring; named tests exercise pool/store behavior, not the live run_harness loop. Live loop acceptance uses an isolated relay." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "open_session_store", + "invocation": "let session_store = pool::open_session_store(", + "behavior_test": "resume_advertised_uses_session_resume_not_new", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1", + "coverage": "Textual guard for run_harness wiring; named tests exercise pool/store behavior, not the live run_harness loop. Live loop acceptance uses an isolated relay." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "set_session_store", + "invocation": "pool.set_session_store(session_store.clone());", + "behavior_test": "rotate_before_first_resume_discards_disk_session", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1", + "coverage": "Textual guard for run_harness wiring; named tests exercise pool/store behavior, not the live run_harness loop. Live loop acceptance uses an isolated relay." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "enable_session_status", + "invocation": "runtime.enable_session_status();", + "behavior_test": "every_spawn_path_and_managed_mcp_get_the_current_status_dir", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1", + "coverage": "Textual guard for run_harness wiring; the named test calls the same method on a prepared runtime." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "retire_scope", + "invocation": "pool::session_visibility::retire_scope(", + "behavior_test": "rotate_during_a_delayed_turn_retires_before_late_usage", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1", + "coverage": "Textual guard for run_harness wiring; the named test exercises the same retirement call on the status runtime, not the live loop." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "retire_channel", + "invocation": "pool::session_visibility::retire_channel(", + "behavior_test": "channel_removal_retires_every_scope_of_that_channel", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1", + "coverage": "Textual guard for run_harness wiring; the named test exercises the same retirement call on the status runtime, not the live loop." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "admit_authorized", + "invocation": "session_controls::admit_authorized(", + "behavior_test": "controls_use_author_capability_before_subscription_and_reserve_wrong_targets", + "verification_command": "cargo test -p buzz-acp --lib controls_use_author_capability_before_subscription_and_reserve_wrong_targets -- --test-threads=1", + "coverage": "Task2 production seam; interim invalidation is not verified worker retirement or public outcome delivery." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "capture", + "invocation": "agent.state.control_dispatch = Some(ctx.session_controls.capture(&scope));", + "behavior_test": "busy_one_shot_already_consumed_keeps_restart_gate_and_newer_queue", + "verification_command": "cargo test -p buzz-acp --lib busy_one_shot_already_consumed_keeps_restart_gate_and_newer_queue -- --test-threads=1", + "coverage": "Task2 production seam; interim invalidation is not verified worker retirement or public outcome delivery." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "retirement_pending", + "invocation": "if ctx.session_controls.retirement_pending(&scope)\n || ctx.session_controls.status_pending(&scope)\n || (ctx.session_controls.compact_pending(&scope) && batch.continuation.is_none())", + "behavior_test": "busy_one_shot_already_consumed_keeps_restart_gate_and_newer_queue", + "verification_command": "cargo test -p buzz-acp --lib busy_one_shot_already_consumed_keeps_restart_gate_and_newer_queue -- --test-threads=1", + "coverage": "The shared dispatch gate retains restart and bounded status fencing while preserving queued human instructions." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "current", + "invocation": " if result\n .agent\n .state\n .control_dispatch\n .as_ref()\n .is_some_and(|dispatch| !dispatch.current())", + "behavior_test": "late_cancelled_result_cannot_requeue_old_batch_after_restart", + "verification_command": "cargo test -p buzz-acp --lib late_cancelled_result_cannot_requeue_old_batch_after_restart -- --test-threads=1", + "coverage": "Task2 late-result production fence, independently falsified at this exact seam." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "discard_retired_output", + "invocation": "result.outcome = result.outcome.discard_retired_output();", + "behavior_test": "retired_transport_error_replaces_the_worker_through_result_handling", + "verification_command": "cargo test -p buzz-acp --lib retired_transport_error_replaces_the_worker_through_result_handling -- --test-threads=1", + "coverage": "Task2 fix round1: preserve fatal health at stale fences and follow real relay DM ordering; fresh production-path regression and targeted mutation." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "drive", + "invocation": "worker_retirement::drive(", + "behavior_test": "checked_retirement_executor_waits_for_verified_kill_and_excludes_refill", + "verification_command": "cargo test -p buzz-acp --lib checked_retirement_executor_waits_for_verified_kill_and_excludes_refill -- --test-threads=1", + "coverage": "Text guard for main-loop wiring; executor regression exercises actual checked replacement; native provider gate exercises live loop." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "finish", + "invocation": "worker_retirement::finish(", + "behavior_test": "checked_retirement_executor_waits_for_verified_kill_and_excludes_refill", + "verification_command": "cargo test -p buzz-acp --lib checked_retirement_executor_waits_for_verified_kill_and_excludes_refill -- --test-threads=1", + "coverage": "Text guard for live result branch; executor regression and native gate bind the production finish helper." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "return_for_retirement", + "invocation": "pool.return_for_retirement(result.agent);", + "behavior_test": "retirement_return_boundary_bypasses_crash_budget_for_fatal_old_outcome", + "verification_command": "cargo test -p buzz-acp --lib retirement_return_boundary_bypasses_crash_budget_for_fatal_old_outcome -- --test-threads=1", + "coverage": "Actual handle_prompt_result boundary before ordinary return/crash paths." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "publish_result", + "invocation": "session_controls::publish::publish_result(&rest, &publication).await", + "behavior_test": "control_result_publication_has_three_total_signed_attempts", + "verification_command": "cargo test -p buzz-acp --lib control_result_publication_has_three_total_signed_attempts -- --test-threads=1", + "coverage": "Text guard for asynchronous publisher wiring; actual HTTP retry regression binds publisher API." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "plan_retirement", + "invocation": "ctx.session_controls.plan_retirement(&scope, targets);", + "behavior_test": "checked_retirement_requires_every_clean_report_and_retries_after_uncertainty", + "verification_command": "cargo test -p buzz-acp --lib checked_retirement_requires_every_clean_report_and_retries_after_uncertainty -- --test-threads=1", + "coverage": "Text guard for restart admission wiring; all-target controller regression and native gate bind live behavior." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "drive", + "invocation": "orphan_reconciliation.drive(&ctx.session_controls, publication_capacity);", + "behavior_test": "orphan_reconciliation_retries_failed_writes_with_capped_backoff_without_capacity_spin", + "verification_command": "cargo test -p buzz-acp --lib orphan_reconciliation_retries_failed_writes_with_capped_backoff_without_capacity_spin -- --test-threads=1", + "coverage": "Text guard for main-loop scheduling; real store failures, repair and bounded retry through scheduler regression." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "wake", + "invocation": "_ = orphan_reconciliation.wake() => {", + "behavior_test": "orphan_reconciliation_retries_failed_writes_with_capped_backoff_without_capacity_spin", + "verification_command": "cargo test -p buzz-acp --lib orphan_reconciliation_retries_failed_writes_with_capped_backoff_without_capacity_spin -- --test-threads=1", + "coverage": "Text guard for idle select wake; actual scheduler completion and deadline behavior tested." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "join_next", + "invocation": "Some(result) = result_publications.join_next(), if !result_publications.is_empty() => {", + "behavior_test": "orphan_reconciliation_retries_failed_writes_with_capped_backoff_without_capacity_spin", + "verification_command": "cargo test -p buzz-acp --lib orphan_reconciliation_retries_failed_writes_with_capped_backoff_without_capacity_spin -- --test-threads=1", + "coverage": "Text guard for idle publisher capacity release; scheduler regression covers zero-capacity no-spin." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "lose_retirement_evidence", + "invocation": "controls.lose_retirement_evidence(i);", + "behavior_test": "retirement_foreground_panic_cannot_attest_replacement_or_forget_original_on_retry", + "verification_command": "cargo test -p buzz-acp --lib retirement_foreground_panic_cannot_attest_replacement_or_forget_original_on_retry -- --test-threads=1", + "coverage": "Actual foreground Tokio panic and escaped-group lifetime; ordinary crash recovery remains separately tested." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "dispatch", + "invocation": "control_tasks::dispatch(&mut pool, &ctx, &control_tx);", + "behavior_test": "compact_waits_for_exact_busy_owner_without_cancel_or_fork", + "verification_command": "cargo test --locked -p buzz-acp --lib compact_waits_for_exact_busy_owner_without_cancel_or_fork -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "wake_pending", + "invocation": "control_tasks::wake_pending(&mut queue, &ctx.session_controls);", + "behavior_test": "compact_only_lazy_work_requires_existing_session", + "verification_command": "cargo test --locked -p buzz-acp --lib compact_only_lazy_work_requires_existing_session -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "compaction_panicked", + "invocation": "controls.compaction_panicked(i);", + "behavior_test": "compact_actual_panic_terminalizes_started_without_ordinary_retry", + "verification_command": "cargo test --locked -p buzz-acp --lib compact_actual_panic_terminalizes_started_without_ordinary_retry -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "compaction_target", + "invocation": "pool.compaction_target(&job.scope, &ctx.session_controls, config.session_resume)", + "behavior_test": "compact_resume_disabled_disk_refusal_and_replay_never_wake", + "verification_command": "cargo test --locked -p buzz-acp --lib compact_resume_ -- --test-threads=1", + "coverage": "Explicit startup resume policy fences disk-only compact targets before lazy wake, while existing in-memory targets remain eligible." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "handle_owner_cancel_for_scope", + "invocation": "handle_owner_cancel_for_scope(\n &mut pool, &mut queue, &ctx.session_controls, &scope, observer.as_ref(),\n );", + "behavior_test": "uncertain_owner_cancel_has_no_chat_sender_and_fences_held_feedback", + "verification_command": "cargo test -p buzz-acp --lib uncertain_owner_cancel_has_no_chat_sender_and_fences_held_feedback -- --test-threads=1", + "coverage": "Production owner-command scope cancellation has no chat sender; a failed durable cancellation emits an honest existing observer control result and fences held feedback immediately." + } ], - "invocation_seams": [], "verification_commands": [ "just ci" ] @@ -4610,8 +4919,152 @@ "path": "crates/buzz-acp/src/pool.rs", "responsibility": "Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches", "is_new": false, - "required_symbols": [], - "invocation_seams": [], + "required_symbols": [ + "pub(crate) fn discard_retired_output(self) -> Self" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "discard_durable", + "invocation": "self.discard_durable(scope);", + "behavior_test": "invalidate_scope_writes_tombstone", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1", + "coverage": "Production path exercised by the named behavior test; textual registry guard also detects wiring drift." + }, + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "depart_durable_all", + "invocation": "self.depart_durable_all(crate::session_store::Departure::WorkerCrash);", + "behavior_test": "invalidate_all_marks_worker_crash_without_tombstone", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1", + "coverage": "Production path exercised by the named behavior test; textual registry guard also detects wiring drift." + }, + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "persist_delivery", + "invocation": "self.persist_delivery(", + "behavior_test": "delivery_success_persists_ids", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1", + "coverage": "Production path exercised by the named behavior test; textual registry guard also detects wiring drift." + }, + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "acquire_session", + "invocation": "match session_continuity::acquire_session(", + "behavior_test": "resume_advertised_uses_session_resume_not_new", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1", + "coverage": "Production path exercised by the named behavior test; textual registry guard also detects wiring drift." + }, + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "persist_turns", + "invocation": "agent.state.persist_turns(scope);", + "behavior_test": "max_turns_survives_resume", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1", + "coverage": "Production path exercised by the named behavior test; textual registry guard also detects wiring drift." + }, + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "insert_session_marker", + "invocation": "session_continuity::insert_session_marker(", + "behavior_test": "marker_present_once_after_start", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1", + "coverage": "Production path exercised by the named behavior test; textual registry guard also detects wiring drift." + }, + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "invalidate_after_error", + "invocation": "agent.state.invalidate_after_error(&source, &e);", + "behavior_test": "idle_worker_transport_death_keeps_session_resumable", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1", + "coverage": "Real local adapter death reaches production run_prompt_task via broken pipe and preserves resumable provider state." + }, + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "publish_session_start", + "invocation": "agent.publish_session_start(", + "behavior_test": "new_session_status_precedes_the_prompt_and_spans_ordinary_turns", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "rebind_session_status", + "invocation": "agent.rebind_session_status(status_runtime, scope, &sid);", + "behavior_test": "new_session_status_precedes_the_prompt_and_spans_ordinary_turns", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "history_coverage", + "invocation": "binding.set_history(session_visibility::history_coverage(", + "behavior_test": "pinned_assignment_outside_the_window_counts_supplied_events_once", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "reconcile_session_status_scope", + "invocation": "if agent.reconcile_session_status_scope(", + "behavior_test": "retained_conversation_revokes_generation_before_rebind_or_late_updates", + "verification_command": "cargo test -p buzz-acp --lib session_visibility::tests::policy -- --test-threads=1", + "coverage": "Real prompt path suppresses uncertain Conversation creation, revokes retained generations before rebind and preserves provider execution scopes." + }, + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "selected", + "invocation": "session_history::selected(&context_target, b.channel_id, depth, &ctx).await", + "behavior_test": "selected_restart_depths_reach_actual_prompt_and_supplied_ids", + "verification_command": "cargo test -p buzz-acp --lib selected_restart_depths_reach_actual_prompt_and_supplied_ids -- --test-threads=1", + "coverage": "Task2 production seam; interim invalidation is not verified worker retirement or public outcome delivery." + }, + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "reserved_raw", + "invocation": "if crate::session_controls::reserved_raw(obj) {", + "behavior_test": "human_target_reserved_syntax_is_filtered_but_not_executed", + "verification_command": "cargo test -p buzz-acp --lib human_target_reserved_syntax_is_filtered_but_not_executed -- --test-threads=1", + "coverage": "Task2 production seam; interim invalidation is not verified worker retirement or public outcome delivery." + }, + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "foreground_success", + "invocation": "dispatch.foreground_success();", + "behavior_test": "bootstrap_success_and_failed_foreground_preserve_restart_policy", + "verification_command": "cargo test -p buzz-acp --lib bootstrap_success_and_failed_foreground_preserve_restart_policy -- --test-threads=1", + "coverage": "Task2 production seam; interim invalidation is not verified worker retirement or public outcome delivery." + }, + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "current", + "invocation": " let stale = agent\n .state\n .control_dispatch\n .as_ref()\n .is_some_and(|dispatch| !dispatch.current())", + "behavior_test": "second_restart_survives_old_result", + "verification_command": "cargo test -p buzz-acp --lib second_restart_survives_old_result -- --test-threads=1", + "coverage": "Task2 late-result production fence, independently falsified at this exact seam." + }, + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "supplied_mention", + "invocation": "first_mention = session_history::supplied_mention(\n first_mention,\n &context_target,\n conversation_context.as_ref(),\n &rendered_batch_ids,\n );", + "behavior_test": "none_assignment_omitted_after_profile_render_is_explicitly_not_found", + "verification_command": "cargo test -p buzz-acp --lib none_assignment_omitted_after_profile_render_is_explicitly_not_found -- --test-threads=1", + "coverage": "Final Task2 rendered-boundary review; actual run_prompt_task, resolved profile, ACP prompt and supplied IDs." + }, + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "discard_retired_output", + "invocation": "(outcome.discard_retired_output(), None)", + "behavior_test": "retired_cancel_drain_expiry_replaces_the_worker_through_result_handling", + "verification_command": "cargo test -p buzz-acp --lib retired_cancel_drain_expiry_replaces_the_worker_through_result_handling -- --test-threads=1", + "coverage": "Task2 fix round1: preserve fatal health at stale fences and follow real relay DM ordering; fresh production-path regression and targeted mutation." + }, + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "discard_retired_output", + "invocation": "let outcome = match prompt_result {\n Ok(reason) => PromptOutcome::Ok(reason),\n Err(error) => PromptOutcome::Error(error),\n }\n .discard_retired_output();", + "behavior_test": "retired_adapter_death_replaces_the_worker_through_result_handling", + "verification_command": "cargo test -p buzz-acp --lib retired_adapter_death_replaces_the_worker_through_result_handling -- --test-threads=1", + "coverage": "Task2 fix round1: preserve fatal health at stale fences and follow real relay DM ordering; fresh production-path regression and targeted mutation." + } + ], "verification_commands": [ "just ci" ] @@ -5489,12 +5942,17 @@ }, { "path": "desktop/playwright.config.ts", - "responsibility": "Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches", + "responsibility": "Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches. Register the session-status smoke spec. Register the scoped session-control smoke workflow.", "is_new": false, - "required_symbols": [], + "required_symbols": [ + "\"**/session-status.spec.ts\",", + "\"**/session-controls.spec.ts\"," + ], "invocation_seams": [], "verification_commands": [ - "just ci" + "just ci", + "cd desktop && pnpm test:e2e:smoke", + "CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts" ] }, { @@ -6509,12 +6967,15 @@ }, { "path": "desktop/src/testing/e2eBridge.ts", - "responsibility": "Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches", + "responsibility": "Integrate existing reynholm/buzz main 33f54de2 without retiring fork patches Align only native-command mock reply p-tags with the native builder while preserving thread routing.", "is_new": false, - "required_symbols": [], + "required_symbols": [ + ").filter((tag) => tag[0] !== \"p\" || tag[1] !== mockPubkey)," + ], "invocation_seams": [], "verification_commands": [ - "just ci" + "just ci", + "CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts" ] }, { @@ -7871,12 +8332,16 @@ }, { "path": "TESTING.md", - "responsibility": "Document production-writer recovery, replica-floor premises, authenticated CLI/relay regression commands and evidence boundaries.", + "responsibility": "Document production-writer recovery, replica-floor premises, authenticated CLI/relay regression commands and evidence boundaries. Document fresh-build session-control smoke workflow and shared actual-parser fixture coverage. Document typed activity workflow, observed identity boundaries and unchanged observer ownership. Thread-composer context workflow and bounded jsdom failure diagnostics with external process-tree containment.", "is_new": false, - "required_symbols": [], + "required_symbols": [ + "### Desktop scoped session controls", + "Observer access remains owner-scoped;" + ], "invocation_seams": [], "verification_commands": [ - "BUZZ_TEST_BIN_DIR=\"$PWD/target/debug\" cargo test -p buzz-relay --lib handlers::ingest::postgres_tests::managed_delivery_tests:: -- --ignored --test-threads=1" + "BUZZ_TEST_BIN_DIR=\"$PWD/target/debug\" cargo test -p buzz-relay --lib handlers::ingest::postgres_tests::managed_delivery_tests:: -- --ignored --test-threads=1", + "CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts" ] }, { @@ -7916,12 +8381,13 @@ }, { "path": "crates/buzz-acp/src/pool/background_work.rs", - "responsibility": "Retain collection authority in the original worker/session, poll child exit independently of pool sleep, and compose stable-key final-report instructions.", + "responsibility": "Retain collection authority in the original worker/session, poll child exit independently of pool sleep, and compose stable-key final-report instructions. Release uncertainty-only holds without inventing collection; retain observed exit evidence across Unknown.", "is_new": true, "required_symbols": [ "pub(crate) struct RuntimeHold", "pub fn queue_finished_background_work", - "pub(super) fn followup_prompt" + "pub(super) fn followup_prompt", + "if hold.blocking_pids.is_empty() && !hold.collection_pending {" ], "invocation_seams": [ { @@ -7931,10 +8397,36 @@ "behavior_test": "background_managed_delivery_key_stays_on_collection_across_error_and_new_message", "verification_command": "env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp background_managed_delivery_key_stays_on_collection_across_error_and_new_message -- --test-threads=1", "coverage": "Stable managed key and original route in the real collection prompt; bundled process coverage is a mock-relay workflow." + }, + { + "caller_path": "crates/buzz-acp/src/pool/background_work.rs", + "callee_symbol": "contains", + "invocation": "!self.retire_on_return.contains(&index)", + "behavior_test": "retirement_targets_include_checked_out_copies_and_block_idle_claims", + "verification_command": "cargo test -p buzz-acp --lib retirement_targets_include_checked_out_copies_and_block_idle_claims -- --test-threads=1", + "coverage": "Reserved workers cannot be claimed while deliberate retirement owns the slot." + }, + { + "caller_path": "crates/buzz-acp/src/pool.rs", + "callee_symbol": "refresh_runtime_hold", + "invocation": "self.refresh_runtime_hold(&mut agent);", + "behavior_test": "background_unknown_then_empty_releases_hold_without_inventing_collection", + "verification_command": "cargo test -p buzz-acp --lib background_unknown_then_empty_releases_hold_without_inventing_collection -- --test-threads=1", + "coverage": "Scripted discovery results feed the actual AcpClient and production return_agent/hold/poll path; no copied transition predicate." + }, + { + "caller_path": "crates/buzz-acp/src/pool/background_work.rs", + "callee_symbol": "background_child_pids", + "invocation": "let Some(children) = agent.acp.background_child_pids() else {", + "behavior_test": "background_observed_then_unknown_then_empty_collects_exactly_once", + "verification_command": "cargo test -p buzz-acp --lib background_observed_then_unknown_then_empty_collects_exactly_once -- --test-threads=1", + "coverage": "Previously observed work survives Unknown and yields exactly one original-session collection." } ], "verification_commands": [ - "env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp background_work_tests -- --test-threads=1" + "env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp background_work_tests -- --test-threads=1", + "cargo test -p buzz-acp --lib background_unknown_then_empty_releases_hold_without_inventing_collection -- --test-threads=1", + "cargo test -p buzz-acp --lib background_observed_then_unknown_then_empty_collects_exactly_once -- --test-threads=1" ] }, { @@ -7978,18 +8470,24 @@ }, { "path": "crates/buzz-acp/src/process_tree.rs", - "responsibility": "Bounded process snapshots and resident-helper classification; macOS/Linux executable confirmation releases completed Codex turns while preserving actual child work.", + "responsibility": "Bounded process snapshots and resident-helper classification; macOS/Linux executable confirmation releases completed Codex turns while preserving actual child work. Exact Codex default Bash snapshots remain transient bookkeeping; their ordinary descendants and launchers remain work. Scoped missing executable evidence remains uncertainty while definite descendants remain observable.", "is_new": true, "required_symbols": [ "pub fn snapshot()", "pub fn blocking_pids(", - "pub fn is_resident_helper(" + "pub fn is_resident_helper(", + "npm_exec_mcp_chain_is_resident", + "launcher_into_scoped_mcp_package_is_resident", + "pub(crate) fn is_snapshot_helper(", + "pub(crate) fn startup_resident_pids(", + "pub(crate) fn is_snapshot_candidate(", + "pub(crate) fn is_uncertain_snapshot(" ], "invocation_seams": [ { "caller_path": "crates/buzz-acp/src/acp.rs", "callee_symbol": "snapshot", - "invocation": "let tree = crate::process_tree::snapshot()?;", + "invocation": "crate::process_tree::snapshot()", "behavior_test": "prompt_work_is_tracked_under_worker_and_resident_provider", "verification_command": "env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp prompt_work_is_tracked_under_worker_and_resident_provider -- --test-threads=1", "coverage": "Inherited production process classification exercised with real local ACP children; no new runtime mutation claim." @@ -8007,6 +8505,14 @@ "invocation": "self.resident_pids.contains(&child.pid) || tree.is_resident_helper(child)", "behavior_test": "work_under_late_codex_host_holds_slot_until_child_exits", "verification_command": "env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp resident_helper_tests -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/acp.rs", + "callee_symbol": "startup_resident_pids", + "invocation": "self.resident_pids.extend(tree.startup_resident_pids(pid));", + "behavior_test": "codex_snapshot_child_work_survives_late_and_session_capture", + "verification_command": "cargo test -p buzz-acp --lib snapshot_helper_tests -- --test-threads=1", + "coverage": "Actual ACP session/startup capture cannot permanently adopt ordinary work beneath a recognized snapshot shell." } ], "verification_commands": [ @@ -8063,13 +8569,14 @@ }, { "path": "crates/buzz-cli/TESTING.md", - "responsibility": "Specify managed publisher scope, journal durability and retention, recovery opt-in/upgrade requirements and real versus mock-relay checks. Document mixed PDF, ZIP and Markdown attachment checks and authenticated byte comparisons.", + "responsibility": "Specify managed publisher scope, journal durability and retention, recovery opt-in/upgrade requirements and real versus mock-relay checks. Document session-status enrichment, its absence/failure behavior and its automated checks. Document mixed PDF, ZIP and Markdown attachment checks and authenticated byte comparisons.", "is_new": false, "required_symbols": [], "invocation_seams": [], "verification_commands": [ "cargo test -p buzz-cli managed_ -- --test-threads=1", "BUZZ_TEST_BIN_DIR=\"$PWD/target/debug\" cargo test -p buzz-relay --lib handlers::ingest::postgres_tests::managed_delivery_tests:: -- --ignored --test-threads=1", + "cargo test -p buzz-cli session_status -- --test-threads=1", "cargo test -p buzz-cli" ] }, @@ -8109,7 +8616,7 @@ }, { "path": "crates/buzz-cli/src/commands/messages.rs", - "responsibility": "Expose messages deliver and preserve original route, stable UUID and signed callback mentions without suppressing ordinary progress or explicit edits. Emit generic attachments as filename links rather than image markup.", + "responsibility": "Expose messages deliver and preserve original route, stable UUID and signed callback mentions without suppressing ordinary progress or explicit edits. Attach the sender's own best-effort session status to newly prepared kind-9 messages before signing, in the shared send/first-deliver preparation. Emit generic attachments as filename links rather than image markup.", "is_new": false, "required_symbols": [ "MessagesCmd::Deliver {", @@ -8125,6 +8632,14 @@ "verification_command": "BUZZ_TEST_BIN_DIR=\"$PWD/target/debug\" cargo test -p buzz-relay --lib managed_recovery_mention_history_survives_lost_ack -- --ignored --test-threads=1", "coverage": "Actual CLI stdout must return the elected signed recipient in mention_pubkeys after unconfirmed recovery replay; stored p-tags alone are insufficient." }, + { + "caller_path": "crates/buzz-cli/src/commands/messages.rs", + "callee_symbol": "message_status_tags", + "invocation": ".tags(super::session_status::message_status_tags(\n client,\n channel_uuid,\n thread_ref.as_ref().map(|t| &t.root_event_id),\n ))", + "behavior_test": "status_reply_carries_thread_status_and_keeps_existing_tags", + "verification_command": "cargo test -p buzz-cli session_status -- --test-threads=1", + "coverage": "Actual parsed messages send/deliver dispatcher against the signature-checking mock relay; removing the call fails every positive status test and the binary environment test." + }, { "caller_path": "crates/buzz-cli/src/commands/messages.rs", "callee_symbol": "attachment_markdown", @@ -8136,24 +8651,27 @@ "verification_commands": [ "cargo test -p buzz-cli managed_ -- --test-threads=1", "cargo test -p buzz-cli mention -- --test-threads=1", + "cargo test -p buzz-cli session_status -- --test-threads=1", "cargo test -p buzz-cli" ] }, { "path": "crates/buzz-cli/src/commands/mod.rs", - "responsibility": "Wire the managed delivery module into the CLI message dispatcher.", + "responsibility": "Wire the managed delivery module into the CLI message dispatcher. Register the read-only session-status lookup.", "is_new": false, "required_symbols": [ - "mod delivery;" + "mod delivery;", + "pub(crate) mod session_status;" ], "invocation_seams": [], "verification_commands": [ - "cargo test -p buzz-cli managed_ -- --test-threads=1" + "cargo test -p buzz-cli managed_ -- --test-threads=1", + "cargo test -p buzz-cli session_status -- --test-threads=1" ] }, { "path": "crates/buzz-cli/src/delivery_tests.rs", - "responsibility": "Production CLI regressions for same-key replay, namespace/route integrity, concurrency, durable ancestor links, bounded retention/retries and legacy wrapper rejection.", + "responsibility": "Production CLI regressions for same-key replay, namespace/route integrity, concurrency, durable ancestor links, bounded retention/retries and legacy wrapper rejection. Shares its mock relay (palette, upload and HTTP-error modes) with session-status regressions.", "is_new": true, "required_symbols": [ "managed_same_key_changed_text_returns_first_signed_delivery", @@ -8162,7 +8680,8 @@ ], "invocation_seams": [], "verification_commands": [ - "cargo test -p buzz-cli managed_ -- --test-threads=1" + "cargo test -p buzz-cli managed_ -- --test-threads=1", + "cargo test -p buzz-cli session_status -- --test-threads=1" ] }, { @@ -8179,100 +8698,220 @@ }, { "path": "crates/buzz-cli/src/lib.rs", - "responsibility": "Define messages deliver arguments and compile production-publisher regression tests. Compile the attachment formatter and production upload regressions.", + "responsibility": "Define messages deliver arguments and compile production-publisher regression tests. Configure the client with the harness-advertised session-status directory and compile session-status regressions. Compile the attachment formatter and production upload regressions.", "is_new": false, "required_symbols": [ "Deliver {", "mod delivery_tests;", + "mod session_status_tests;", "mod attachments;", "mod attachment_tests;" ], - "invocation_seams": [], + "invocation_seams": [ + { + "caller_path": "crates/buzz-cli/src/lib.rs", + "callee_symbol": "status_dir_from_env", + "invocation": ".with_session_status_dir(commands::session_status::status_dir_from_env());", + "behavior_test": "binary_reads_the_status_directory_from_the_environment", + "verification_command": "cargo test -p buzz-cli --test session_status_env", + "coverage": "Runs the built buzz binary with BUZZ_SESSION_STATUS_DIR set and unset against a local mock relay." + } + ], "verification_commands": [ "cargo test -p buzz-cli managed_ -- --test-threads=1", + "cargo test -p buzz-cli session_status -- --test-threads=1", + "cargo test -p buzz-cli --test session_status_env", "cargo test -p buzz-cli" ] }, { - "path": "crates/buzz-core/src/kind.rs", - "responsibility": "Reserve ephemeral managed recovery kind 20003 for signed canonical election; upgraded relays route it through shared ingest.", + "path": "crates/buzz-cli/src/client.rs", + "responsibility": "Carry the optional session-status instance directory with the client's relay URL and signing key. Upload generic documents and archives and retain a sanitized basename across both Blossom endpoint paths.", "is_new": false, "required_symbols": [ - "KIND_MANAGED_DELIVERY_RECOVERY: u32 = 20003" + "pub fn with_session_status_dir(", + "pub fn session_status_dir(&self)" ], "invocation_seams": [ { - "caller_path": "crates/buzz-relay/src/handlers/event.rs", - "callee_symbol": "is_ephemeral", - "invocation": "if is_ephemeral(kind_u32)\n && kind_u32 != buzz_core::kind::KIND_NIP43_LEAVE_REQUEST\n && kind_u32 != buzz_core::kind::KIND_MANAGED_DELIVERY_RECOVERY", - "behavior_test": "managed_ws_original_ack_correlates_after_recovery_wins", - "verification_command": "BUZZ_TEST_BIN_DIR=\"$PWD/target/debug\" cargo test -p buzz-relay --lib managed_ws_original_ack_correlates_after_recovery_wins -- --ignored --test-threads=1", - "coverage": "Real WS handler keeps recovery out of ephemeral fanout and correlates original EVENT acknowledgment after another canonical event wins." + "caller_path": "crates/buzz-cli/src/client.rs", + "callee_symbol": "validate_upload", + "invocation": "validate_upload(&mime, bytes.len() as u64)?;", + "behavior_test": "rejects_detected_executables_before_upload", + "verification_command": "cargo test -p buzz-cli" } ], "verification_commands": [ - "BUZZ_TEST_BIN_DIR=\"$PWD/target/debug\" cargo test -p buzz-relay --lib handlers::ingest::postgres_tests::managed_delivery_tests:: -- --ignored --test-threads=1" + "cargo test -p buzz-cli session_status -- --test-threads=1", + "cargo test -p buzz-cli" ] }, { - "path": "crates/buzz-core/src/lib.rs", - "responsibility": "Export the shared signed managed-report recovery protocol.", - "is_new": false, + "path": "crates/buzz-cli/src/commands/session_status.rs", + "responsibility": "Bounded read-only status lookup: absolute instance directory, nonblocking instance and generation lock probes, identity/scope/instance validation, thread-or-conversation destination with ambiguity rejection; any failure yields no tags.", + "is_new": true, "required_symbols": [ - "pub mod managed_delivery;" + "pub(crate) const SESSION_STATUS_DIR_ENV: &str = \"BUZZ_SESSION_STATUS_DIR\";", + "pub(crate) fn status_tags(", + "pub(crate) fn message_status_tags(", + "fn held_by_producer(", + "status_dir_value_must_be_a_nonempty_absolute_path" ], "invocation_seams": [], "verification_commands": [ - "cargo test -p buzz-core managed_delivery -- --test-threads=1" + "cargo test -p buzz-cli session_status -- --test-threads=1", + "cargo test -p buzz-cli --test session_status_env" ] }, { - "path": "crates/buzz-core/src/managed_delivery.rs", - "responsibility": "Validate author-signed original report and compact candidate stamp, immutable body/tags/route, canonical UUID and current wrapper/candidate timestamps.", + "path": "crates/buzz-cli/src/session_status_tests.rs", + "responsibility": "Production send/deliver regressions against a harness-shaped status directory: destination scopes, absence/invalid/stale snapshots, released generation and instance locks, no directory mutation, failed sends, kind-9 only and managed retry/recovery keeping the original tags.", "is_new": true, "required_symbols": [ - "pub struct RecoveryRequest", - "pub struct ReportStamp", - "pub fn delivery_key(", - "pub fn same_report(", - "pub fn decode(" - ], - "invocation_seams": [ - { - "caller_path": "crates/buzz-relay/src/handlers/ingest.rs", - "callee_symbol": "decode", - "invocation": "buzz_core::managed_delivery::RecoveryRequest::decode(\n &event,\n nostr::Timestamp::now().as_secs(),\n )", - "behavior_test": "managed_old_absent_report_recovers_with_production_writer", - "verification_command": "BUZZ_TEST_BIN_DIR=\"$PWD/target/debug\" cargo test -p buzz-relay --lib managed_old_absent_report_recovers_with_production_writer -- --ignored --test-threads=1", - "coverage": "B2_ATOMIC_COMMAND_RED.log and B2_STAMP_GUARD_FINAL.log preserve real shared-ingest recovery RED/GREEN with armed writer floor." - } + "status_released_generation_lock_rejects_remaining_snapshot", + "status_released_instance_lock_rejects_live_looking_snapshot", + "status_publication_into_another_thread_never_inherits_status", + "managed_status_retry_keeps_the_originally_prepared_tags", + "managed_status_timestamp_recovery_preserves_original_tags" ], + "invocation_seams": [], "verification_commands": [ - "cargo test -p buzz-core managed_delivery -- --test-threads=1" + "cargo test -p buzz-cli session_status -- --test-threads=1" ] }, { - "path": "crates/buzz-db/src/error.rs", - "responsibility": "Return an explicit canonical delivery conflict for UUID reuse with another report or route.", - "is_new": false, + "path": "crates/buzz-cli/tests/session_status_env.rs", + "responsibility": "Prove the built buzz binary reads BUZZ_SESSION_STATUS_DIR from its environment.", + "is_new": true, "required_symbols": [ - "ManagedDeliveryConflict" + "binary_reads_the_status_directory_from_the_environment" ], "invocation_seams": [], "verification_commands": [ - "BUZZ_TEST_BIN_DIR=\"$PWD/target/debug\" cargo test -p buzz-relay --lib handlers::ingest::postgres_tests::managed_delivery_tests:: -- --ignored --test-threads=1" + "cargo test -p buzz-cli --test session_status_env" ] }, { - "path": "crates/buzz-db/src/store/event.rs", - "responsibility": "Route ordinary marked kind-9 inserts through the same atomic canonical election as recovery while retaining transactional thread metadata and the production commit-time floor.", + "path": "crates/buzz-core/src/kind.rs", + "responsibility": "Reserve ephemeral managed recovery kind 20003 for signed canonical election; upgraded relays route it through shared ingest.", "is_new": false, "required_symbols": [ - "crate::store::managed_delivery::insert(" + "KIND_MANAGED_DELIVERY_RECOVERY: u32 = 20003" ], "invocation_seams": [ { - "caller_path": "crates/buzz-db/src/store/event.rs", + "caller_path": "crates/buzz-relay/src/handlers/event.rs", + "callee_symbol": "is_ephemeral", + "invocation": "if is_ephemeral(kind_u32)\n && kind_u32 != buzz_core::kind::KIND_NIP43_LEAVE_REQUEST\n && kind_u32 != buzz_core::kind::KIND_MANAGED_DELIVERY_RECOVERY", + "behavior_test": "managed_ws_original_ack_correlates_after_recovery_wins", + "verification_command": "BUZZ_TEST_BIN_DIR=\"$PWD/target/debug\" cargo test -p buzz-relay --lib managed_ws_original_ack_correlates_after_recovery_wins -- --ignored --test-threads=1", + "coverage": "Real WS handler keeps recovery out of ephemeral fanout and correlates original EVENT acknowledgment after another canonical event wins." + } + ], + "verification_commands": [ + "BUZZ_TEST_BIN_DIR=\"$PWD/target/debug\" cargo test -p buzz-relay --lib handlers::ingest::postgres_tests::managed_delivery_tests:: -- --ignored --test-threads=1" + ] + }, + { + "path": "crates/buzz-core/src/lib.rs", + "responsibility": "Export the shared signed managed-report recovery protocol and the shared session-status contract. Export the pure session command, result and actor contract.", + "is_new": false, + "required_symbols": [ + "pub mod managed_delivery;", + "pub mod session_status;", + "pub mod session_control;" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-core managed_delivery -- --test-threads=1", + "cargo test -p buzz-core session_status -- --test-threads=1", + "cargo test -p buzz-core -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-core/src/session_status.rs", + "responsibility": "Pure shared session-status contract: canonical status identity, scope file names, instance layout names, bounded reason vocabulary, snapshot format v1 with size/version validation, and the buzz-session wire tags. Read legacy v1 and current v2 snapshots, keep primary/info wire shapes, validate optional manual origin and add bounded control/departure reasons.", + "is_new": true, + "required_symbols": [ + "pub fn canonical_identity(", + "pub fn scope_file_name(", + "pub fn generation_lock_path(", + "pub fn parse_snapshot(", + "pub fn event_tags(", + "pub struct StatusEnvelope", + "pub struct ContextOccupancy", + "pub struct HistoryCoverage", + "pub const STATUS_FORMAT_VERSION: u8 = 2;", + "pub origin: Option" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-core/src/session_status.rs", + "callee_symbol": "event_tag", + "invocation": "tags.push(\n ControlAction {\n transition_id: self.status.transition_id,\n origin: origin.clone(),\n }\n .event_tag()\n .ok_or(StatusError::InvalidOrigin)?,\n );", + "behavior_test": "snapshot_legacy_v1_and_v2_actor_preserve_wire_dimensions", + "verification_command": "cargo test -p buzz-core --test session_control snapshot_legacy_v1_and_v2_actor_preserve_wire_dimensions -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-core/src/session_status.rs", + "callee_symbol": "validate", + "invocation": "envelope.validate()?;", + "behavior_test": "snapshot_rejects_invalid_actor_and_automatic_actor", + "verification_command": "cargo test -p buzz-core --test session_control snapshot_rejects_invalid_actor_and_automatic_actor -- --test-threads=1" + } + ], + "verification_commands": [ + "cargo test -p buzz-core session_status -- --test-threads=1", + "cargo test -p buzz-core -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-core/src/managed_delivery.rs", + "responsibility": "Validate author-signed original report and compact candidate stamp, immutable body/tags/route, canonical UUID and current wrapper/candidate timestamps.", + "is_new": true, + "required_symbols": [ + "pub struct RecoveryRequest", + "pub struct ReportStamp", + "pub fn delivery_key(", + "pub fn same_report(", + "pub fn decode(" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-relay/src/handlers/ingest.rs", + "callee_symbol": "decode", + "invocation": "buzz_core::managed_delivery::RecoveryRequest::decode(\n &event,\n nostr::Timestamp::now().as_secs(),\n )", + "behavior_test": "managed_old_absent_report_recovers_with_production_writer", + "verification_command": "BUZZ_TEST_BIN_DIR=\"$PWD/target/debug\" cargo test -p buzz-relay --lib managed_old_absent_report_recovers_with_production_writer -- --ignored --test-threads=1", + "coverage": "B2_ATOMIC_COMMAND_RED.log and B2_STAMP_GUARD_FINAL.log preserve real shared-ingest recovery RED/GREEN with armed writer floor." + } + ], + "verification_commands": [ + "cargo test -p buzz-core managed_delivery -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-db/src/error.rs", + "responsibility": "Return an explicit canonical delivery conflict for UUID reuse with another report or route.", + "is_new": false, + "required_symbols": [ + "ManagedDeliveryConflict" + ], + "invocation_seams": [], + "verification_commands": [ + "BUZZ_TEST_BIN_DIR=\"$PWD/target/debug\" cargo test -p buzz-relay --lib handlers::ingest::postgres_tests::managed_delivery_tests:: -- --ignored --test-threads=1" + ] + }, + { + "path": "crates/buzz-db/src/store/event.rs", + "responsibility": "Route ordinary marked kind-9 inserts through the same atomic canonical election as recovery while retaining transactional thread metadata and the production commit-time floor.", + "is_new": false, + "required_symbols": [ + "crate::store::managed_delivery::insert(" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-db/src/store/event.rs", "callee_symbol": "insert", "invocation": "crate::store::managed_delivery::insert(\n pool,\n community_id,\n event,\n channel,\n thread_meta,\n None,\n )", "behavior_test": "managed_recovery_and_original_race_share_one_election", @@ -8487,11 +9126,12 @@ }, { "path": "crates/buzz-acp/src/process_tree/codex_helpers.rs", - "responsibility": "Confirm late Codex code-mode host identity with one bounded ps call: macOS comm and Linux exe; missing identity and work arguments cannot free slots.", + "responsibility": "Confirm Codex code-mode host and exact Bash snapshot shell/parent identities with one bounded ps call; uncertain identities remain work.", "is_new": true, "required_symbols": [ "pub(super) fn classify(", - "fn confirmed_hosts(" + "fn confirmed_hosts(", + "pub(super) fn apply_snapshot_identities(" ], "invocation_seams": [ { @@ -8575,219 +9215,1593 @@ ] }, { - "path": "Cargo.toml", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", - "is_new": false, - "required_symbols": [], - "invocation_seams": [], - "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v" - ] - }, - { - "path": "crates/buzz-agent/Cargo.toml", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", - "is_new": false, - "required_symbols": [], - "invocation_seams": [], - "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v" - ] - }, - { - "path": "crates/buzz-agent/src/hints.rs", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", - "is_new": false, + "path": "crates/buzz-acp/src/session_store.rs", + "responsibility": "Community-and-agent isolated, lease-fenced session records with bounded locks, identity validation and channel-wide failure fencing. Preserve restart intent and bounded receipts through claims, commits and tombstones; revision-fence tombstones, enforce byte ceilings, and recover only accepted control metadata from provider quarantine. Lease-fenced automatic attempt cleanup preserves accepted restart policy; explicit scope/channel revocation clears it. Distinguish Alive/Dead/Unknown owner evidence while retaining conservative resume admission.", + "is_new": true, "required_symbols": [ - "yaml_serde::from_str(yaml_block).ok()?" + "pub struct SessionStore", + "pub fn claim(", + "pub fn commit(", + "fn store_namespace(", + "poisoned_channels", + "pub revision: Option", + "pub pending_restart: Option", + "pub control_receipts: Vec", + "if json.len() as u64 > MAX_RECORD_BYTES || !controls::valid_controls(record) {", + "record.pending_restart = owned.pending_restart;", + "enum InstanceLiveness", + "fn instance_liveness(" ], "invocation_seams": [ { - "caller_path": "crates/buzz-agent/src/hints.rs", - "callee_symbol": "from_str", - "invocation": "yaml_serde::from_str(yaml_block).ok()?", - "behavior_test": "hints::yaml_tests::skill_yaml_preserves_words_tags_aliases_and_multiline_text", - "verification_command": "cargo test --locked -p buzz-agent --lib hints::yaml_tests" + "caller_path": "crates/buzz-acp/src/session_store.rs", + "callee_symbol": "read_record_file", + "invocation": "read_record_file(&with_suffix(path, \"stale\"), namespace)?", + "behavior_test": "quarantine_retains_control_metadata_without_recovering_provider_state", + "verification_command": "cargo test -p buzz-acp quarantine_retains_control_metadata_without_recovering_provider_state -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/session_store.rs", + "callee_symbol": "valid_controls", + "invocation": "controls::valid_controls(&record)", + "behavior_test": "malformed_or_oversized_receipt_records_are_rejected", + "verification_command": "cargo test -p buzz-acp malformed_or_oversized_receipt_records_are_rejected -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/session_store.rs", + "callee_symbol": "valid_controls", + "invocation": "controls::valid_controls(record)", + "behavior_test": "malformed_or_oversized_receipt_records_are_rejected", + "verification_command": "cargo test -p buzz-acp malformed_or_oversized_receipt_records_are_rejected -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/session_store.rs", + "callee_symbol": "tombstone", + "invocation": "self.tombstone(Some(&owned))", + "behavior_test": "failed_attempt_discard_preserves_restart_until_matching_completion", + "verification_command": "cargo test -p buzz-acp failed_attempt_discard_preserves_restart_until_matching_completion -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/session_store.rs", + "callee_symbol": "instance_liveness", + "invocation": "self.instance_liveness(instance) != InstanceLiveness::Dead", + "behavior_test": "instance_publication_cannot_be_reaped_before_locking", + "verification_command": "cargo test -p buzz-acp --lib instance_publication_cannot_be_reaped_before_locking -- --test-threads=1", + "coverage": "Resume admission retains conservative ownership while the registry publication lock is held." } ], "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v", - "cargo test --locked -p buzz-agent --lib hints::yaml_tests" + "cargo test -p buzz-acp -- --test-threads=1", + "cargo test -p buzz-acp --lib instance_publication_cannot_be_reaped_before_locking -- --test-threads=1" ] }, { - "path": "crates/buzz-agent/src/hints_yaml_tests.rs", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "path": "crates/buzz-acp/src/session_store_tests.rs", + "responsibility": "Durable record ownership, concurrency, corruption and write-failure regressions. Capture revision in the existing tombstone expectation assertion.", "is_new": true, - "required_symbols": [], + "required_symbols": [ + "busy_does_not_quarantine_owner_record", + "quarantine_requires_matching_lease", + "instance_publication_cannot_be_reaped_before_locking", + "namespace_normalizes_url_spelling_without_aliasing_communities", + "copied_foreign_namespace_record_is_not_resumable", + "legacy_unscoped_records_are_not_adopted", + "channel_enumeration_failure_poisons_unknown_scopes_until_restart" + ], "invocation_seams": [], "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "crates/buzz-backend-kubernetes/src/client.rs", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", - "is_new": false, + "path": "crates/buzz-acp/src/pool/session_continuity.rs", + "responsibility": "Resume or create provider sessions through bounded claim/RPC/commit rounds and preserve delivery state. Retire status generations on the existing discard and depart paths. Keep acquisition fallback exhaustive for the store control error vocabulary.", + "is_new": true, "required_symbols": [ - "let config = config_for(context).await?;" + "pub(super) async fn acquire_session(", + "pub(crate) fn resume_decision(", + "pub(crate) fn insert_session_marker(", + "Err(AcpError::DispatchRetired)" ], "invocation_seams": [ { - "caller_path": "crates/buzz-backend-kubernetes/src/client.rs", - "callee_symbol": "config_for", - "invocation": "let config = config_for(context).await?;", - "behavior_test": "client::yaml_tests::kubeconfig_connect_and_exec_lookup_use_the_selected_context", - "verification_command": "cargo test --locked -p buzz-backend-kubernetes" + "caller_path": "crates/buzz-acp/src/pool/session_continuity.rs", + "callee_symbol": "retire_status", + "invocation": "self.retire_status(scope);", + "behavior_test": "new_session_status_precedes_the_prompt_and_spans_ordinary_turns", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/pool/session_continuity.rs", + "callee_symbol": "retire_all_status", + "invocation": "self.retire_all_status();", + "behavior_test": "worker_crash_retires_status_before_the_replacement_prompt", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1", + "coverage": "Covers both depart_durable_all (crash) and depart_all (sleep, pool_sleep_retires_status_and_wake_starts_a_new_transition)." + }, + { + "caller_path": "crates/buzz-acp/src/pool/session_continuity.rs", + "callee_symbol": "ensure_dispatch_current", + "invocation": "ensure_dispatch_current(agent)?;", + "behavior_test": "restart_during_delayed_acquisition_fences_old_dispatch", + "verification_command": "cargo test -p buzz-acp --lib restart_during_delayed_acquisition_fences_old_dispatch -- --test-threads=1", + "coverage": "Task2 production seam; interim invalidation is not verified worker retirement or public outcome delivery." + }, + { + "caller_path": "crates/buzz-acp/src/pool/session_continuity.rs", + "callee_symbol": "ensure_dispatch_current", + "invocation": "if ensure_dispatch_current(agent).is_err() {", + "behavior_test": "restart_during_failed_resume_never_calls_fallback_new", + "verification_command": "cargo test -p buzz-acp --lib restart_during_failed_resume_never_calls_fallback_new -- --test-threads=1", + "coverage": "Task2 fix round1: resume fallback remains fenced without replacing the original failure classification." + }, + { + "caller_path": "crates/buzz-acp/src/pool/session_continuity.rs", + "callee_symbol": "Err", + "invocation": "return Err(err);\n }\n let code = resume_failure_code(&err);", + "behavior_test": "retired_resume_transport_preserves_health_without_fallback_new", + "verification_command": "cargo test -p buzz-acp --lib retired_resume_transport_preserves_health_without_fallback_new -- --test-threads=1", + "coverage": "Task2 fix round1: a real oversized resume frame produces Protocol after restart; preserve worker recovery and prohibit fallback." + }, + { + "caller_path": "crates/buzz-acp/src/pool/session_continuity.rs", + "callee_symbol": "recycled_reason", + "invocation": "ctx.session_controls.recycled_reason(scope)", + "behavior_test": "checked_retirement_neighbors_resume_or_start_new_with_recycled_reason", + "verification_command": "cargo test -p buzz-acp --lib checked_retirement_neighbors_resume_or_start_new_with_recycled_reason -- --test-threads=1", + "coverage": "No-resume starts a new session with the same WorkerRecycled reason, without persisting provider identity." } ], "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v", - "cargo test --locked -p buzz-backend-kubernetes" + "cargo test -p buzz-acp -- --test-threads=1", + "cargo test -p buzz-acp --lib retired_successful_acquisition_keeps_worker_health_and_fences_old_state -- --test-threads=1" ] }, { - "path": "crates/buzz-backend-kubernetes/src/client_yaml_tests.rs", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "path": "crates/buzz-acp/src/pool/session_continuity_tests.rs", + "responsibility": "Production acquisition, delivery delta, rotation, crash and idle-sleep persistence regressions. Supply default new control fields in existing legacy-record fixtures.", "is_new": true, - "required_symbols": [], - "invocation_seams": [], - "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v" - ] - }, - { - "path": "crates/buzz-backend-kubernetes/src/cluster.rs", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", - "is_new": false, - "required_symbols": [], + "required_symbols": [ + "resume_result_after_rotate_is_dropped", + "max_turns_survives_resume", + "rotate_before_first_resume_discards_disk_session", + "one_reply_window_keeps_first_mention_over_agent_reply", + "idle_worker_transport_death_keeps_session_resumable", + "acquisition_does_not_resume_another_community_or_agent", + "acquisition_resumes_canonical_same_community_after_restart", + "failed_channel_removal_during_sleep_never_resumes_after_readd" + ], "invocation_seams": [], "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "crates/buzz-backend-kubernetes/src/gc.rs", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", - "is_new": false, + "path": "crates/buzz-acp/src/pool/first_mention.rs", + "responsibility": "Bounded forward thread scan with a composite cursor and first-mention context pinning.", + "is_new": true, "required_symbols": [ - "let age_nanos = now_nanos - created.0.as_nanosecond();" - ], - "invocation_seams": [ - { - "caller_path": "crates/buzz-backend-kubernetes/src/gc.rs", - "callee_symbol": "as_nanosecond", - "invocation": "let age_nanos = now_nanos - created.0.as_nanosecond();", - "behavior_test": "gc::tests::age_gate_boundary_is_exact", - "verification_command": "cargo test --locked -p buzz-backend-kubernetes" - } + "pub(crate) async fn find_first_mention", + "pub(crate) fn pin_first_mention" ], + "invocation_seams": [], "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v", - "cargo test --locked -p buzz-backend-kubernetes" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "crates/buzz-backend-kubernetes/src/observe.rs", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", - "is_new": false, + "path": "crates/buzz-acp/src/pool/first_mention_tests.rs", + "responsibility": "First-mention discovery across pages, timestamp ties and scan-budget exhaustion.", + "is_new": true, "required_symbols": [], "invocation_seams": [], "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "crates/buzz-backend-kubernetes/src/reconcile.rs", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", - "is_new": false, + "path": "crates/buzz-acp/src/acp/session_resume_tests.rs", + "responsibility": "ACP resume capability, request parameter and failure-code checks.", + "is_new": true, "required_symbols": [], "invocation_seams": [], "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "crates/buzz-persona/Cargo.toml", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", - "is_new": false, - "required_symbols": [], + "path": "crates/buzz-acp/src/pool/real_resume_tests.rs", + "responsibility": "Opt-in real-provider memory continuity after adapter process replacement.", + "is_new": true, + "required_symbols": [ + "real_claude_resume_remembers_previous_turn", + "real_codex_resume_remembers_previous_turn" + ], "invocation_seams": [], "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "crates/buzz-persona/src/persona.rs", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "path": "crates/buzz-acp/Cargo.toml", + "responsibility": "Declare the filesystem-lock dependency used by durable session ownership.", "is_new": false, "required_symbols": [ - "yaml_serde::from_str(fm_str)?" - ], - "invocation_seams": [ - { - "caller_path": "crates/buzz-persona/src/persona.rs", - "callee_symbol": "from_str", - "invocation": "yaml_serde::from_str(fm_str)?", - "behavior_test": "persona_yaml_preserves_scalar_types_aliases_and_multiline_unicode", - "verification_command": "cargo test --locked -p buzz-persona --test yaml_compatibility" - } + "fs2 = \"0.4\"" ], + "invocation_seams": [], "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v", - "cargo test --locked -p buzz-persona --test yaml_compatibility" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "crates/buzz-persona/src/validate.rs", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", - "is_new": false, + "path": "crates/buzz-acp/src/session_status.rs", + "responsibility": "Session status runtime: private instance directory held by an instance lock, atomic bounded per-scope snapshots, generation locks with shared revocable guards, per-scope failure disabling, usage routing by notification session ID and harness-owned status directory env. Serialize live binding display tags through the shared envelope, refusing retired or replaced generations.", + "is_new": true, "required_symbols": [ - "yaml_serde::from_str(fm_str)" + "pub(crate) const SESSION_STATUS_DIR_ENV: &str = \"BUZZ_SESSION_STATUS_DIR\";", + "pub(crate) struct SessionStatusRuntime", + "pub(crate) fn begin(", + "pub(crate) fn bind_existing(", + "pub(crate) fn retire_channel(", + "fn revoke_locked(", + "pub(crate) fn record_usage_update(", + "pub(crate) fn apply_status_dir_env(", + "pub(crate) fn open_for_config(", + "pub(crate) fn compacted(", + "pub(crate) fn event_tags(", + "let mut next = state.status.clone();" ], "invocation_seams": [ { - "caller_path": "crates/buzz-persona/src/validate.rs", - "callee_symbol": "from_str", - "invocation": "yaml_serde::from_str(fm_str)", - "behavior_test": "pack_validation_preserves_skill_name_warnings_with_unknown_yaml_values", - "verification_command": "cargo test --locked -p buzz-persona --test yaml_compatibility" + "caller_path": "crates/buzz-acp/src/pool/restart_status.rs", + "callee_symbol": "event_tags", + "invocation": ".and_then(crate::session_status::StatusBinding::event_tags)", + "behavior_test": "first_signed_status_carries_selected_history_actor_and_current_or_unknown_occupancy", + "verification_command": "cargo test -p buzz-acp --lib first_signed_status_carries_selected_history_actor_and_current_or_unknown_occupancy -- --test-threads=1" } ], "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v", - "cargo test --locked -p buzz-persona --test yaml_compatibility" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "crates/buzz-persona/tests/yaml_compatibility.rs", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "path": "crates/buzz-acp/src/session_status_tests.rs", + "responsibility": "Status runtime layout, lock, revocation, failed removal/write and wire usage-routing regressions.", "is_new": true, - "required_symbols": [], + "required_symbols": [ + "open_holds_instance_lock_in_private_layout", + "successor_generation_revokes_predecessor", + "retire_releases_lock_and_late_callbacks_never_recreate", + "failed_snapshot_removal_still_revokes_generation", + "write_failure_disables_only_that_scope", + "retire_waits_for_a_delayed_writer_then_removes_snapshot", + "status_dir_env_is_owned_by_the_harness", + "claude_usage_updates_route_by_their_own_session_id", + "codex_usage_updates_route_by_their_own_session_id" + ], "invocation_seams": [], "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "crates/buzz-workflow/Cargo.toml", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", - "is_new": false, - "required_symbols": [], + "path": "desktop/src/features/messages/lib/sessionStatus.ts", + "responsibility": "Strictly parse an agent's self-reported buzz-session and buzz-session-info tags, map the bounded state/reason vocabulary to fixed English copy with truthful exact, lower-bound and omitted-history wording, and attach one transition explanation per loaded (signer, channel, scope, transition) to its earliest message; other messages keep only their reported percentage. Valid primary-only status keeps a per-message explanation without inferred grouping or history. Validate matching bounded manual actor tags, include actor/state/reason in grouping and render fixed compaction/restart/worker-recycle copy.", + "is_new": true, + "required_symbols": [ + "export function parseSessionStatusTags(", + "export function attachSessionStatuses(", + "export const CONTEXT_HIGHLIGHT_PERCENT = 80;", + "normalizePubkey(message.pubkey) !== message.signerPubkey", + "if (key === null || owners.get(key) === message)", + "const action = uniqueTag(tags, \"buzz-session-action\");", + "action[2] === info.transitionId" + ], + "invocation_seams": [], + "verification_commands": [ + "cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionStatus.test.mjs src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs src/features/messages/lib/messageRowEquality.test.mjs src/features/messages/ui/MessageSessionStatus.test.mjs", + "cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionControl.test.mjs src/features/messages/lib/sessionControlPermissions.test.mjs" + ] + }, + { + "path": "desktop/src/features/messages/lib/sessionStatus.test.mjs", + "responsibility": "Parser and copy regressions: states, known and unknown reasons, resume failure codes, percent boundaries, malformed numbers, duplicate/conflicting tags, malformed supporting metadata and hostile reason strings. Retain neutral fallback for unknown reasons and cover implemented bounded control transition copy.", + "is_new": true, + "required_symbols": [ + "conflicting primary tags yield no status", + "unknown but well-formed reasons get neutral copy without the raw string", + "copy: bounded compact and restart reasons describe the actual transition" + ], + "invocation_seams": [], + "verification_commands": [ + "cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionStatus.test.mjs src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs src/features/messages/lib/messageRowEquality.test.mjs src/features/messages/ui/MessageSessionStatus.test.mjs", + "cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionControl.test.mjs src/features/messages/lib/sessionControlPermissions.test.mjs" + ] + }, + { + "path": "desktop/src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs", + "responsibility": "Projection regressions through the production timeline formatter: agents, scopes, channels, interleaving, same-second order, deletion, pagination, edit overlays, signer/author identity and missing supporting metadata.", + "is_new": true, + "required_symbols": [ + "first message of a transition explains it; later ones keep only the percentage", + "an edit cannot add or change status; the original signed tags win", + "a relay-signed event attributed to an agent cannot claim its session state", + "valid primary-only status keeps its explanation per message without counts" + ], + "invocation_seams": [], + "verification_commands": [ + "cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionStatus.test.mjs src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs src/features/messages/lib/messageRowEquality.test.mjs src/features/messages/ui/MessageSessionStatus.test.mjs" + ] + }, + { + "path": "desktop/src/features/messages/ui/MessageSessionStatus.tsx", + "responsibility": "Render the derived session status as a muted, noninteractive line with screen-reader pauses and warning emphasis plus readable text from 80%.", + "is_new": true, + "required_symbols": [ + "export function MessageSessionStatus(", + "font-medium text-warning" + ], + "invocation_seams": [], + "verification_commands": [ + "cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionStatus.test.mjs src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs src/features/messages/lib/messageRowEquality.test.mjs src/features/messages/ui/MessageSessionStatus.test.mjs" + ] + }, + { + "path": "desktop/src/features/messages/ui/MessageSessionStatus.test.mjs", + "responsibility": "Rendered status line text, emphasis threshold, absence of tab stops, live regions or labels, and MessageRow memo invalidation when only the derived status moves.", + "is_new": true, + "required_symbols": [ + "MessageRow memo rerenders when only the derived status moves", + "primary-only signed metadata renders its projected explanation with unknown occupancy" + ], + "invocation_seams": [], + "verification_commands": [ + "cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionStatus.test.mjs src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs src/features/messages/lib/messageRowEquality.test.mjs src/features/messages/ui/MessageSessionStatus.test.mjs" + ] + }, + { + "path": "desktop/tests/e2e/session-status.spec.ts", + "responsibility": "Mock-bridge smoke workflow for session status: new session with exact history, continuation rows at 79% and 80%, unknown context, thread navigation and one explanation per loaded transition. Capture the current quiet status paragraph without a message-row control trigger. Preserve all quiet-status/history/percentage/count assertions while verifying the removed row trigger and the thread-composer context agent/action/Back/Escape flow with natural hover/click.", + "is_new": true, + "required_symbols": [ + "session status explains each transition once and shows reported context", + "quiet-status.png", + "await row(timeline, root).hover();", + "name: \"Manage context\"" + ], + "invocation_seams": [], + "verification_commands": [ + "CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts" + ] + }, + { + "path": "desktop/src/features/messages/lib/formatTimelineMessages.ts", + "responsibility": "Attach the derived agent session status to formatted rows from each event's original signed tags. Derive compact controls from original events and raw reactions, and collect validated manual actors for batched profile reads.", + "is_new": false, + "required_symbols": [ + "export function collectSessionActorPubkeys(" + ], + "invocation_seams": [ + { + "caller_path": "desktop/src/features/messages/lib/formatTimelineMessages.ts", + "callee_symbol": "attachSessionStatuses", + "invocation": "return attachSessionStatuses(\n messages,\n visibleEvents,\n channel?.id ?? null,\n profiles,\n );", + "behavior_test": "first message of a transition explains it; later ones keep only the percentage", + "verification_command": "cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test --test-name-pattern=\"first message of a transition\" src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs" + }, + { + "caller_path": "desktop/src/features/messages/lib/formatTimelineMessages.ts", + "callee_symbol": "attachSessionControls", + "invocation": "attachSessionControls(\n messages,\n visibleEvents,", + "behavior_test": "formatter reads original signed kind9 body", + "verification_command": "cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionControl.test.mjs src/features/messages/lib/sessionControlPermissions.test.mjs" + } + ], + "verification_commands": [ + "cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionStatus.test.mjs src/features/messages/lib/formatTimelineMessages.sessionStatus.test.mjs src/features/messages/lib/messageRowEquality.test.mjs src/features/messages/ui/MessageSessionStatus.test.mjs", + "just desktop-test desktop-typecheck", + "cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionControl.test.mjs src/features/messages/lib/sessionControlPermissions.test.mjs" + ] + }, + { + "path": "desktop/src/features/messages/ui/MessageRow.tsx", + "responsibility": "Render quiet session status and signed compact command/result rows with value-based memo invalidation; context actions live only in the thread composer.", + "is_new": false, + "required_symbols": [], + "invocation_seams": [ + { + "caller_path": "desktop/src/features/messages/ui/MessageRow.tsx", + "callee_symbol": "MessageSessionStatus", + "invocation": "", + "behavior_test": "session status explains each transition once and shows reported context", + "verification_command": "CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts" + }, + { + "caller_path": "desktop/src/features/messages/ui/MessageRow.tsx", + "callee_symbol": "sessionStatusEqual", + "invocation": "sessionStatusEqual(\n prev.message.sessionStatus,\n next.message.sessionStatus,\n )", + "behavior_test": "MessageRow memo rerenders when only the derived status moves", + "verification_command": "cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test --test-name-pattern=\"MessageRow memo rerenders\" src/features/messages/ui/MessageSessionStatus.test.mjs" + }, + { + "caller_path": "desktop/src/features/messages/ui/MessageRow.tsx", + "callee_symbol": "MessageSessionControlRow", + "invocation": " runtime.begin_with_origin(", + "behavior_test": "selected_restart_depths_reach_actual_prompt_and_supplied_ids", + "verification_command": "cargo test -p buzz-acp --lib selected_restart_depths_reach_actual_prompt_and_supplied_ids -- --test-threads=1", + "coverage": "Task2 production seam; interim invalidation is not verified worker retirement or public outcome delivery." + } + ], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/pool/session_visibility_tests.rs", + "responsibility": "Production run_prompt_task status lifecycle, usage routing, history coverage and spawn/MCP environment regressions.", + "is_new": true, + "required_symbols": [ + "new_session_status_precedes_the_prompt_and_spans_ordinary_turns", + "resumed_session_reports_idle_sleep", + "failed_resume_reports_the_actual_fallback_reason", + "interleaved_sessions_on_one_worker_keep_their_own_occupancy", + "heartbeat_turn_never_touches_scope_status", + "rotate_during_a_delayed_turn_retires_before_late_usage", + "pool_sleep_retires_status_and_wake_starts_a_new_transition", + "worker_crash_retires_status_before_the_replacement_prompt", + "successor_acquisition_revokes_the_old_generation_before_its_prompt", + "status_write_failure_never_fails_the_turn", + "pinned_assignment_outside_the_window_counts_supplied_events_once", + "counts_need_no_extra_history_walk", + "every_spawn_path_and_managed_mcp_get_the_current_status_dir", + "fallback_codes_outside_the_allowlist_become_unknown" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/runtime.rs", + "responsibility": "Open the instance session status directory once and route it through the shared adapter spawn environment and prompt context.", + "is_new": false, + "required_symbols": [ + "pub(crate) fn enable_session_status(" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/runtime.rs", + "callee_symbol": "open_for_config", + "invocation": "let runtime = session_status::open_for_config(&self.config)?;", + "behavior_test": "every_spawn_path_and_managed_mcp_get_the_current_status_dir", + "verification_command": "cargo test -p buzz-acp -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/runtime.rs", + "callee_symbol": "session_policy", + "invocation": "session_policy: config.session_policy,", + "behavior_test": "configured_policy_reaches_prompt_status_context_without_base_prompt", + "verification_command": "cargo test -p buzz-acp --lib session_visibility::tests::policy -- --test-threads=1" + } + ], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/pool/session_visibility_policy_tests.rs", + "responsibility": "Production prompt/status provenance regressions for uncertain Conversation acquisition, recovered metadata, failed sibling-thread status, retained-session revocation and configured-policy forwarding.", + "is_new": true, + "required_symbols": [ + "conversation_status_requires_channel_policy_or_verified_dm", + "uncertain_conversation_cannot_outlive_metadata_recovery_and_failed_thread_status", + "retained_conversation_revokes_generation_before_rebind_or_late_updates", + "configured_policy_reaches_prompt_status_context_without_base_prompt" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-acp --lib session_visibility::tests::policy -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-core/src/session_control.rs", + "responsibility": "Exact kind-9 command grammar, typed terminal/pending reaction results with precedence, strict actor enrichment and freshness constants; no I/O.", + "is_new": true, + "required_symbols": [ + "pub enum RestartDepth", + "pub enum SessionCommand", + "pub struct ControlOrigin", + "pub enum ControlFailure", + "pub type ControlOutcome", + "pub enum ControlResult", + "pub struct SessionResult", + "pub struct ControlAction", + "pub fn control_is_fresh(" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-core -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-core/src/session_status_tests.rs", + "responsibility": "Extract unchanged status regression coverage from production module and update schema-version assertions for v2.", + "is_new": true, + "required_symbols": [ + "fn snapshot_round_trips()", + "fn snapshot_rejects_unknown_versions_and_bad_shapes()" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-core -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-core/tests/session_control.rs", + "responsibility": "Behavioral contract regressions for exact grammar, strict result/action parsing, precedence, v1/v2 origin rules and shared fixtures. Bind the shared command corpus to the actual Rust parser.", + "is_new": true, + "required_symbols": [ + "fn command_grammar_is_exact()", + "fn shared_reason_and_origin_fixture_corpus()", + "fn snapshot_rejects_invalid_actor_and_automatic_actor()", + "for case in cases[\"commands\"].as_array().unwrap() {" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-core -- --test-threads=1", + "cargo test -p buzz-core --test session_control" + ] + }, + { + "path": "crates/buzz-core/tests/fixtures/session_controls.json", + "responsibility": "Shared Rust/TypeScript fixture corpus for bounded reasons, canonical actor origins and pending/terminal precedence. Cover all exact command spellings, ordinary near-misses and the cross-language whitespace boundary.", + "is_new": true, + "required_symbols": [ + "\"commands\": [" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-core -- --test-threads=1", + "cargo test -p buzz-core --test session_control" + ] + }, + { + "path": "crates/buzz-acp/src/session_store_controls.rs", + "responsibility": "Atomic restart tombstone/intent/Started receipts, bounded fresh deduplication, token-fenced completion and explicit owner-generation orphan reconciliation.", + "is_new": true, + "required_symbols": [ + "pub struct RestartIntent", + "pub enum ControlAttempt", + "pub fn request_restart(", + "pub fn complete_restart(", + "pub fn begin_control_attempt(", + "pub fn finish_control_attempt(", + "pub(crate) fn reconcile_control_attempt(", + "pub(crate) fn retry_control_outcome(", + "if expected.owner_instance != self.instance", + "receipt.owner_generation != expected.owner_generation", + "if let ControlReceiptState::Finished(prior) = receipt.state", + "pub(crate) fn begin_admitted_control_with_receipt(", + "if !control_is_fresh(created_at, admitted_at)" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/session_store_controls.rs", + "callee_symbol": "add_started", + "invocation": "add_started(\n &mut record,\n &intent.origin.command_event_id,\n now,\n self.instance,\n );", + "behavior_test": "restart_started_receipt_is_written_with_tombstone_intent", + "verification_command": "cargo test -p buzz-acp restart_started_receipt_is_written_with_tombstone_intent -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/session_store_controls.rs", + "callee_symbol": "add_started", + "invocation": "observed = Some(add_started(\n &mut record,\n command_id,\n created_at,\n self.instance,\n ));", + "behavior_test": "started_compact_redelivery_never_reexecutes", + "verification_command": "cargo test -p buzz-acp started_compact_redelivery_never_reexecutes -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/session_store_controls.rs", + "callee_symbol": "update_with_quarantine", + "invocation": "self.update_with_quarantine(path, decide, false)", + "behavior_test": "restart_started_receipt_is_written_with_tombstone_intent", + "verification_command": "cargo test -p buzz-acp restart_started_receipt_is_written_with_tombstone_intent -- --test-threads=1" + } + ], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/session_store_control_tests.rs", + "responsibility": "Durable controls regression coverage including concurrency, atomic failures, legacy records, ABA, expiry/capacity, orphan fences and quarantine boundaries. Prove failed-attempt intent retention and explicit policy revocation with stale-lease rejection.", + "is_new": true, + "required_symbols": [ + "fn restart_intent_is_atomic()", + "fn orphan_started_becomes_terminal_without_reexecution()", + "fn quarantine_retains_control_metadata_without_recovering_provider_state()", + "fn malformed_or_oversized_receipt_records_are_rejected()", + "fn failed_attempt_discard_preserves_restart_until_matching_completion()", + "fn explicit_scope_and_channel_revocation_clear_intent_but_keep_receipts()" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/session_controls.rs", + "responsibility": "Authorized session-control admission, per-dispatch generation fences and bounded selected restart history", + "is_new": true, + "required_symbols": [ + "pub(crate) fn restart_intent", + "pub(crate) fn complete_restart", + "pub(crate) fn restart(", + "pub(crate) fn retirement_pending", + "pub(crate) fn capture(", + "pub(crate) fn current(", + "pub(crate) fn revoke_channel", + "pub(crate) fn finish(", + "self.runtime.epoch(&self.scope) == self.epoch", + "restart_status_token: Some(intent.token)" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/session_controls.rs", + "callee_symbol": "request_restart_status", + "invocation": "let superseded = store.request_restart_status(&job.scope, &intent, status)?;", + "behavior_test": "checked_retirement_supersedes_started_compact_without_a_compact_rpc", + "verification_command": "cargo test -p buzz-acp --lib checked_retirement_supersedes_started_compact_without_a_compact_rpc -- --test-threads=1", + "coverage": "Atomic restart admission persists its bounded status ownership before generation replacement; checked retirement still owns reset completion." + } + ], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/pool/session_history.rs", + "responsibility": "Authorized session-control admission, per-dispatch generation fences and bounded selected restart history", + "is_new": true, + "required_symbols": [ + "HISTORY_BYTES: usize = 256 * 1024", + "RESPONSE_BYTES: usize = 4 * 1024 * 1024", + "MAX_PAGES: usize = 20", + "MAX_CANDIDATES: usize = 10_000", + "pub(super) async fn selected", + "pub(super) fn enforce_render_budget", + "while crate::queue::format_conversation_context(context, profiles).len() > HISTORY_BYTES {", + "const RETAINED_HEAD_BYTES: usize = HISTORY_BYTES + MAX_CANDIDATES * 5;", + "pub(super) fn supplied_mention" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/pool/session_history.rs", + "callee_symbol": "query_raw_bounded", + "invocation": ".query_raw_bounded(filters, RESPONSE_BYTES),", + "behavior_test": "bounded_history_reader_rejects_chunked_actual_bytes_and_times_out", + "verification_command": "cargo test -p buzz-acp --lib bounded_history_reader_rejects_chunked_actual_bytes_and_times_out -- --test-threads=1", + "coverage": "Task2 production seam; interim invalidation is not verified worker retirement or public outcome delivery." + }, + { + "caller_path": "crates/buzz-acp/src/pool/session_history.rs", + "callee_symbol": "saturating_sub", + "invocation": "if requested > MAX_CANDIDATES.saturating_sub(self.candidates) {", + "behavior_test": "all_history_request_budget_reserves_tail_without_overfetching_candidates", + "verification_command": "cargo test -p buzz-acp --lib all_history_request_budget_reserves_tail_without_overfetching_candidates -- --test-threads=1", + "coverage": "Final Task2 self-review guard, freshly RED before fix and independently falsified after GREEN." + }, + { + "caller_path": "crates/buzz-acp/src/pool/session_history.rs", + "callee_symbol": "saturating_sub", + "invocation": "rendered.len().saturating_sub(empty.len()) + 5", + "behavior_test": "all_history_many_small_messages_fit_the_actual_rendered_budget", + "verification_command": "cargo test -p buzz-acp --lib all_history_many_small_messages_fit_the_actual_rendered_budget -- --test-threads=1", + "coverage": "Final Task2 rendered-boundary review; fresh compiled RED/GREEN and independently falsified body accounting." + }, + { + "caller_path": "crates/buzz-acp/src/pool/session_history.rs", + "callee_symbol": "sort_by", + "invocation": "events.sort_by(|a, b| {\n b[\"created_at\"]\n .as_u64()\n .cmp(&a[\"created_at\"].as_u64())\n .then_with(|| a[\"id\"].as_str().cmp(&b[\"id\"].as_str()))\n });", + "behavior_test": "dm_same_second_all_fits_with_older_assignment_under_real_relay_cursor", + "verification_command": "cargo test -p buzz-acp --lib dm_same_second_all_fits_with_older_assignment_under_real_relay_cursor -- --test-threads=1", + "coverage": "Task2 fix round1: preserve fatal health at stale fences and follow real relay DM ordering; fresh production-path regression and targeted mutation." + }, + { + "caller_path": "crates/buzz-acp/src/pool/session_history.rs", + "callee_symbol": "key", + "invocation": "root.is_none() && key(&event) < key(prior)", + "behavior_test": "dm_same_second_none_finds_earliest_assignment_under_real_relay_cursor", + "verification_command": "cargo test -p buzz-acp --lib dm_same_second_none_finds_earliest_assignment_under_real_relay_cursor -- --test-threads=1", + "coverage": "Task2 fix round1: preserve fatal health at stale fences and follow real relay DM ordering; fresh production-path regression and targeted mutation." + } + ], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/pool/session_history_tests.rs", + "responsibility": "Production-seam regressions for authorized session controls and bounded selected history", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/pool/session_control_runtime_tests.rs", + "responsibility": "Production-seam regressions for authorized session controls and bounded selected history", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/pool/session_control_history_tests.rs", + "responsibility": "Production-seam regressions for authorized session controls and bounded selected history", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/filter.rs", + "responsibility": "Authorized session-control admission, per-dispatch generation fences and bounded selected restart history", + "is_new": false, + "required_symbols": [], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/filter.rs", + "callee_symbol": "reserved_raw", + "invocation": "crate::session_controls::reserved_raw(&raw)", + "behavior_test": "stale_replay_does_not_overwrite_completed_ack", + "verification_command": "cargo test -p buzz-acp --lib stale_replay_does_not_overwrite_completed_ack -- --test-threads=1", + "coverage": "Task2 production seam; interim invalidation is not verified worker retirement or public outcome delivery." + } + ], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/relay.rs", + "responsibility": "Authorized session-control admission, per-dispatch generation fences and bounded selected restart history", + "is_new": false, + "required_symbols": [ + "pub async fn query_raw_bounded", + "chunk.len() > max_bytes.saturating_sub(bytes.len())" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/acp/terminate.rs", + "responsibility": "Checked bounded Unix process-group and Windows contained-job retirement", + "is_new": true, + "required_symbols": [ + "pub enum TerminationReport", + "pub async fn terminate(", + "fn terminate_with_snapshot", + "fn probe_alive" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/acp/terminate.rs", + "callee_symbol": "snapshot", + "invocation": "tokio::task::spawn_blocking(crate::process_tree::retirement::snapshot)", + "behavior_test": "checked_termination_kills_snapshotted_separate_shell_group", + "verification_command": "cargo test -p buzz-acp --lib checked_termination_kills_snapshotted_separate_shell_group -- --test-threads=1", + "coverage": "Actual process probe requires the real pre-shutdown detached PGID snapshot." + }, + { + "caller_path": "crates/buzz-acp/src/acp/terminate.rs", + "callee_symbol": "verified_groups", + "invocation": "let mut groups = first.verified_groups(&last, pid, own_group);", + "behavior_test": "checked_termination_kills_snapshotted_separate_shell_group", + "verification_command": "cargo test -p buzz-acp --lib checked_termination_kills_snapshotted_separate_shell_group -- --test-threads=1", + "coverage": "Actual detached shell group is killed only with retained identity evidence." + }, + { + "caller_path": "crates/buzz-acp/src/acp/terminate.rs", + "callee_symbol": "probe_alive", + "invocation": "probe_alive(killpg(Pid::from_raw(group), None))", + "behavior_test": "permission_denied_process_group_is_alive", + "verification_command": "cargo test -p buzz-acp --lib permission_denied_process_group_is_alive -- --test-threads=1", + "coverage": "Production signal0 probe shares the complete conservative error policy table." + }, + { + "caller_path": "crates/buzz-acp/src/acp/terminate.rs", + "callee_symbol": "worker_present", + "invocation": "snapshot().await.filter(|tree| tree.worker_present(pid))", + "behavior_test": "checked_termination_missing_worker_in_initial_snapshot_never_starts", + "verification_command": "cargo test -p buzz-acp --lib checked_termination_missing_worker_in_initial_snapshot_never_starts -- --test-threads=1", + "coverage": "Incomplete snapshot cannot admit EOF or signals." + } + ], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/acp/terminate_tests.rs", + "responsibility": "Actual subprocess retirement escalation and no-signal regressions", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/process_tree/retirement.rs", + "responsibility": "Bounded PGID snapshots and escaped-process identity validation", + "is_new": true, + "required_symbols": [ + "struct RetirementSnapshot", + "fn verified_groups", + "fn snapshot()" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/process_tree/retirement_tests.rs", + "responsibility": "Process-group parsing, harness exclusion and PID identity regressions", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/pool/session_retirement.rs", + "responsibility": "Reserve every cached worker copy and retire only after foreign work releases", + "is_new": true, + "required_symbols": [ + "fn retirement_targets", + "fn mark_retire_on_return", + "fn take_retirement_target", + "fn finish_worker_retirement" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/pool/session_retirement.rs", + "callee_symbol": "mark_departed", + "invocation": "match store.mark_departed(", + "behavior_test": "checked_retirement_cannot_depart_neighbor_owned_by_a_newer_worker", + "verification_command": "cargo test -p buzz-acp --lib checked_retirement_cannot_depart_neighbor_owned_by_a_newer_worker -- --test-threads=1", + "coverage": "Original lease CAS preserves newer ownership." + } + ], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/pool/session_retirement_tests.rs", + "responsibility": "Pool reservation, foreign foreground/background and task-return retirement regressions", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/pool/session_retirement_runtime_tests.rs", + "responsibility": "Controller/executor, supersession, delayed completion and neighbor lifecycle regressions", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/session_controls/retirement.rs", + "responsibility": "Controller-owned checked gates, all-target results and retained failure retry", + "is_new": true, + "required_symbols": [ + "fn plan_retirement", + "fn retirement_report", + "fn take_publications_up_to", + "fn lose_retirement_evidence", + "fn retirement_evidence_lost", + "fn owns_retirement", + "let status_current = entry.epoch == job.dispatch_epoch;" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/session_controls/retirement.rs", + "callee_symbol": "finish_control_attempt", + "invocation": "store.finish_control_attempt(scope, &job.origin.command_event_id, &outcome)", + "behavior_test": "checked_retirement_terminal_store_failure_retains_gate_and_reports_uncertainty", + "verification_command": "cargo test -p buzz-acp --lib checked_retirement_terminal_store_failure_retains_gate_and_reports_uncertainty -- --test-threads=1", + "coverage": "Terminal persistence failure stays gated and publishes uncertainty." + }, + { + "caller_path": "crates/buzz-acp/src/session_controls/retirement.rs", + "callee_symbol": "status_ready", + "invocation": "if release && outcome.is_ok() && status_current {\n Self::status_ready(state, store, scope);", + "behavior_test": "unavailable_cancel_during_retirement_cannot_recreate_the_followup_after_clean_exit", + "verification_command": "cargo test -p buzz-acp --lib unavailable_cancel_during_retirement_cannot_recreate_the_followup_after_clean_exit -- --test-threads=1", + "coverage": "Checked retirement confirms the reset independently but cannot recreate a cancelled follow-up under a fresh epoch after record-read or write failure." + } + ], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/session_controls/publish.rs", + "responsibility": "Signed bounded restart and compaction result publication", + "is_new": true, + "required_symbols": [ + "fn build_result", + "async fn publish_result" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/session_controls/publish.rs", + "callee_symbol": "submit_event_once", + "invocation": "rest.submit_event_once(&event)", + "behavior_test": "control_result_publication_has_three_total_signed_attempts", + "verification_command": "cargo test -p buzz-acp --lib control_result_publication_has_three_total_signed_attempts -- --test-threads=1", + "coverage": "Actual HTTP server counts three total attempts with stable signed event identity." + } + ], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/session_store_orphans.rs", + "responsibility": "Dead-owner durable Started receipt reconciliation at startup Retain autonomous revisit work for live or uncertain foreign Started owners.", + "is_new": true, + "required_symbols": [ + "fn sweep_orphan_controls", + "pub revisit: bool", + "progress.revisit = true;" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/session_store_orphans.rs", + "callee_symbol": "canonical_key", + "invocation": "let Some(path) = canonical_key(&entry_path) else {", + "behavior_test": "startup_sweep_recovers_stale_only_started_receipt_and_deduplicates_record_key", + "verification_command": "cargo test -p buzz-acp --lib startup_sweep_recovers_stale_only_started_receipt_and_deduplicates_record_key -- --test-threads=1", + "coverage": "Stale-only canonical reader recovery, locked receipt CAS and duplicate suppression." + }, + { + "caller_path": "crates/buzz-acp/src/session_store_orphans.rs", + "callee_symbol": "instance_liveness", + "invocation": "if self.instance_liveness(receipt.owner_instance)\n != InstanceLiveness::Dead\n {", + "behavior_test": "startup_sweep_terminalizes_dead_started_controls_but_preserves_live_owner", + "verification_command": "cargo test -p buzz-acp --lib startup_sweep_terminalizes_dead_started_controls_but_preserves_live_owner -- --test-threads=1", + "coverage": "Live foreign owner proof rechecked under canonical record lock." + }, + { + "caller_path": "crates/buzz-acp/src/session_store_orphans.rs", + "callee_symbol": "instance_liveness", + "invocation": "match self.instance_liveness(receipt.owner_instance) {", + "behavior_test": "orphan_registry_unknown_revisits_after_lock_release_without_external_wake", + "verification_command": "cargo test -p buzz-acp --lib orphan_registry_unknown_revisits_after_lock_release_without_external_wake -- --test-threads=1", + "coverage": "Real registry lock exceeds STORE_LOCK_WAIT; autonomous reconciliation publishes once only after lock release and Dead proof." + } + ], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1", + "cargo test -p buzz-acp --lib orphan_registry_unknown_revisits_after_lock_release_without_external_wake -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/worker_retirement.rs", + "responsibility": "Nonblocking checked worker replacement and retained ownership on uncertainty Explicitly drain parked and cancellation-returned original ownership at orderly shutdown using retained checked evidence.", + "is_new": true, + "required_symbols": [ + "struct RetirementReturn", + "struct RetirementGuard", + "fn drive(", + "fn finish(", + "pub(crate) async fn shutdown(" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/worker_retirement.rs", + "callee_symbol": "terminate", + "invocation": "Some(agent) => agent.acp.terminate().await,", + "behavior_test": "checked_retirement_executor_waits_for_verified_kill_and_excludes_refill", + "verification_command": "cargo test -p buzz-acp --lib checked_retirement_executor_waits_for_verified_kill_and_excludes_refill -- --test-threads=1", + "coverage": "Actual executor uses checked termination before replacing a TERM-resistant worker." + }, + { + "caller_path": "crates/buzz-acp/src/worker_retirement.rs", + "callee_symbol": "retirement_report", + "invocation": "controls.retirement_report(result.index, TerminationReport::Clean);", + "behavior_test": "checked_retirement_executor_waits_for_verified_kill_and_excludes_refill", + "verification_command": "cargo test -p buzz-acp --lib checked_retirement_executor_waits_for_verified_kill_and_excludes_refill -- --test-threads=1", + "coverage": "Gate opens only after checked termination and successful initialized replacement." + }, + { + "caller_path": "crates/buzz-acp/src/worker_retirement.rs", + "callee_symbol": "prepare_retired_neighbors", + "invocation": "agent.prepare_retired_neighbors(&scopes, &controls)", + "behavior_test": "checked_retirement_cannot_depart_neighbor_owned_by_a_newer_worker", + "verification_command": "cargo test -p buzz-acp --lib checked_retirement_cannot_depart_neighbor_owned_by_a_newer_worker -- --test-threads=1", + "coverage": "Actual retirement executor marks only its original neighbor lease after checked Clean." + }, + { + "caller_path": "crates/buzz-acp/src/worker_retirement.rs", + "callee_symbol": "lose_retirement_evidence", + "invocation": "controls.lose_retirement_evidence(result.index);", + "behavior_test": "retirement_foreground_panic_cannot_attest_replacement_or_forget_original_on_retry", + "verification_command": "cargo test -p buzz-acp --lib retirement_foreground_panic_cannot_attest_replacement_or_forget_original_on_retry -- --test-threads=1", + "coverage": "Late ordinary replacement cannot attest the original; explicit retry remains unconfirmed." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "shutdown", + "invocation": "worker_retirement::shutdown(", + "behavior_test": "retirement_shutdown_retries_original_parked_after_snapshot_failure", + "verification_command": "cargo test -p buzz-acp --lib retirement_shutdown_retries_original_parked_after_snapshot_failure -- --test-threads=1", + "coverage": "Production shutdown call is a textual inventory guard; named regression calls the actual helper after production finish parks the original client." + }, + { + "caller_path": "crates/buzz-acp/src/worker_retirement.rs", + "callee_symbol": "terminate", + "invocation": "let report = agent.acp.terminate().await;", + "behavior_test": "retirement_shutdown_drains_original_returned_by_aborted_guard", + "verification_command": "cargo test -p buzz-acp --lib retirement_shutdown_drains_original_returned_by_aborted_guard -- --test-threads=1", + "coverage": "Actual helper aborts/awaits RetirementGuard and checks the returned original client; no replacement-process proof." + } + ], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1", + "cargo test -p buzz-acp --lib retirement_shutdown_retries_original_parked_after_snapshot_failure -- --test-threads=1", + "cargo test -p buzz-acp --lib retirement_shutdown_drains_original_returned_by_aborted_guard -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/session_controls/orphans.rs", + "responsibility": "Idle-waking bounded-backoff orphan reconciliation with partial durable publication Back off live/unknown revisits without declaring startup reconciliation complete.", + "is_new": true, + "required_symbols": [ + "struct OrphanReconciliation", + "fn sweep_orphans", + "fn drive(", + "fn wake(", + "controls.has_receipt_writes()", + "revisit: progress.revisit,", + "} else if progress.revisit {" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/session_controls/orphans.rs", + "callee_symbol": "sweep_orphan_controls", + "invocation": "store.sweep_orphan_controls(capacity.saturating_sub(local.completed))?", + "behavior_test": "startup_sweep_keeps_healthy_and_committed_prefix_outcomes_across_bad_records", + "verification_command": "cargo test -p buzz-acp --lib startup_sweep_keeps_healthy_and_committed_prefix_outcomes_across_bad_records -- --test-threads=1", + "coverage": "Actual corrupt and busy records preserve committed healthy prefix publication." + }, + { + "caller_path": "crates/buzz-acp/src/session_controls/orphans.rs", + "callee_symbol": "publish", + "invocation": "state.publish(Publication {", + "behavior_test": "startup_sweep_keeps_healthy_and_committed_prefix_outcomes_across_bad_records", + "verification_command": "cargo test -p buzz-acp --lib startup_sweep_keeps_healthy_and_committed_prefix_outcomes_across_bad_records -- --test-threads=1", + "coverage": "Successful prefix IDs publish despite unrelated per-record failures." + }, + { + "caller_path": "crates/buzz-acp/src/session_controls/orphans.rs", + "callee_symbol": "retry_receipt_writes", + "invocation": "self.retry_receipt_writes(capacity)", + "behavior_test": "compact_terminal_write_retries_after_repair_without_reexecuting_effect", + "verification_command": "cargo test --locked -p buzz-acp --lib compact_terminal_write_retries_after_repair_without_reexecuting_effect -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/session_controls/orphans.rs", + "callee_symbol": "sweep_orphans", + "invocation": "controls.sweep_orphans(capacity.min(128))", + "behavior_test": "orphan_alive_foreign_owner_is_revisited_after_it_dies", + "verification_command": "cargo test -p buzz-acp --lib orphan_alive_foreign_owner_is_revisited_after_it_dies -- --test-threads=1", + "coverage": "Production driver wakes without an unrelated event after a foreign owner initially seen alive later exits." + } + ], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1", + "cargo test -p buzz-acp --lib orphan_alive_foreign_owner_is_revisited_after_it_dies -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/pool/session_retirement_review_tests.rs", + "responsibility": "Actual executor no-resume neighbor race and panic escaped-group false-Clean regressions", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/acp/windows_retirement_tests.rs", + "responsibility": "Hosted Windows actual ACP containment and checked termination regressions", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "python3 scripts/fork/sync.py validate" + ] + }, + { + "path": "crates/buzz-acp/src/pool/portable_retirement_tests.rs", + "responsibility": "Portable actual fake-ACP all-target controller/store/foreign-return gates", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "python3 scripts/fork/sync.py validate" + ] + }, + { + "path": "scripts/check-acp-retirement.py", + "responsibility": "Native hosted ACP retirement gate with test discovery and exact execution counts", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "python3 scripts/fork/sync.py validate" + ] + }, + { + "path": "Cargo.toml", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-agent/Cargo.toml", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-agent/src/hints.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [ + "yaml_serde::from_str(yaml_block).ok()?" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-agent/src/hints.rs", + "callee_symbol": "from_str", + "invocation": "yaml_serde::from_str(yaml_block).ok()?", + "behavior_test": "hints::yaml_tests::skill_yaml_preserves_words_tags_aliases_and_multiline_text", + "verification_command": "cargo test --locked -p buzz-agent --lib hints::yaml_tests" + } + ], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v", + "cargo test --locked -p buzz-agent --lib hints::yaml_tests" + ] + }, + { + "path": "crates/buzz-agent/src/hints_yaml_tests.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-backend-kubernetes/src/client.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [ + "let config = config_for(context).await?;" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-backend-kubernetes/src/client.rs", + "callee_symbol": "config_for", + "invocation": "let config = config_for(context).await?;", + "behavior_test": "client::yaml_tests::kubeconfig_connect_and_exec_lookup_use_the_selected_context", + "verification_command": "cargo test --locked -p buzz-backend-kubernetes" + } + ], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v", + "cargo test --locked -p buzz-backend-kubernetes" + ] + }, + { + "path": "crates/buzz-backend-kubernetes/src/client_yaml_tests.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-backend-kubernetes/src/cluster.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-backend-kubernetes/src/gc.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [ + "let age_nanos = now_nanos - created.0.as_nanosecond();" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-backend-kubernetes/src/gc.rs", + "callee_symbol": "as_nanosecond", + "invocation": "let age_nanos = now_nanos - created.0.as_nanosecond();", + "behavior_test": "gc::tests::age_gate_boundary_is_exact", + "verification_command": "cargo test --locked -p buzz-backend-kubernetes" + } + ], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v", + "cargo test --locked -p buzz-backend-kubernetes" + ] + }, + { + "path": "crates/buzz-backend-kubernetes/src/observe.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-backend-kubernetes/src/reconcile.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-persona/Cargo.toml", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-persona/src/persona.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [ + "yaml_serde::from_str(fm_str)?" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-persona/src/persona.rs", + "callee_symbol": "from_str", + "invocation": "yaml_serde::from_str(fm_str)?", + "behavior_test": "persona_yaml_preserves_scalar_types_aliases_and_multiline_unicode", + "verification_command": "cargo test --locked -p buzz-persona --test yaml_compatibility" + } + ], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v", + "cargo test --locked -p buzz-persona --test yaml_compatibility" + ] + }, + { + "path": "crates/buzz-persona/src/validate.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [ + "yaml_serde::from_str(fm_str)" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-persona/src/validate.rs", + "callee_symbol": "from_str", + "invocation": "yaml_serde::from_str(fm_str)", + "behavior_test": "pack_validation_preserves_skill_name_warnings_with_unknown_yaml_values", + "verification_command": "cargo test --locked -p buzz-persona --test yaml_compatibility" + } + ], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v", + "cargo test --locked -p buzz-persona --test yaml_compatibility" + ] + }, + { + "path": "crates/buzz-persona/tests/yaml_compatibility.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-workflow/Cargo.toml", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-workflow/src/error.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-workflow/src/schema.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [ + "yaml_serde::from_str(yaml)?" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-workflow/src/schema.rs", + "callee_symbol": "from_str", + "invocation": "yaml_serde::from_str(yaml)?", + "behavior_test": "schema::yaml_tests::workflow_yaml_preserves_trigger_aliases_and_message_text", + "verification_command": "cargo test --locked -p buzz-workflow --lib schema::yaml_tests" + } + ], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v", + "cargo test --locked -p buzz-workflow --lib schema::yaml_tests" + ] + }, + { + "path": "crates/buzz-workflow/src/schema_yaml_tests.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "desktop/src-tauri/src/commands/workflows.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [ + "yaml_serde::from_str::(yaml)" + ], + "invocation_seams": [ + { + "caller_path": "desktop/src-tauri/src/commands/workflows.rs", + "callee_symbol": "from_str", + "invocation": "yaml_serde::from_str::(yaml)", + "behavior_test": "commands::workflows::tests::workflow_yaml_json_projection_preserves_words_and_fallbacks", + "verification_command": "cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib commands::workflows::tests::workflow_yaml_json_projection_preserves_words_and_fallbacks" + } + ], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v", + "cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib commands::workflows::tests::workflow_yaml_json_projection_preserves_words_and_fallbacks" + ] + }, + { + "path": "desktop/src-tauri/src/commands/workflows_tests.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "desktop/src-tauri/src/managed_agents/config_bridge/goose.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [ + "yaml_serde::from_str(yaml_str).ok()?" + ], + "invocation_seams": [ + { + "caller_path": "desktop/src-tauri/src/managed_agents/config_bridge/goose.rs", + "callee_symbol": "from_str", + "invocation": "yaml_serde::from_str(yaml_str).ok()?", + "behavior_test": "managed_agents::config_bridge::goose::yaml_tests::goose_yaml_preserves_nested_providers_and_ignores_unknown_tagged_fields", + "verification_command": "cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib managed_agents::config_bridge::goose::yaml_tests" + } + ], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v", + "cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib managed_agents::config_bridge::goose::yaml_tests" + ] + }, + { + "path": "desktop/src-tauri/src/managed_agents/config_bridge/goose_yaml_tests.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "desktop/src-tauri/src/migration_yaml_tests.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "docs/fork/YAML_COMPATIBILITY.md", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "scripts/fork/tests/test_yaml_dependencies.py", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": true, + "required_symbols": [], "invocation_seams": [], "verification_commands": [ "just ci", @@ -8795,357 +10809,1542 @@ ] }, { - "path": "crates/buzz-workflow/src/error.rs", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "path": "AGENTS.md", + "responsibility": "Fork rule: public text is in English and carries no internal workspace details Fork screenshot publication requires explicit paired repository/remote selection and normal pushes with DCO.", + "is_new": false, + "required_symbols": [ + "## Fork: public text is in English" + ], + "invocation_seams": [], + "verification_commands": [ + "python3 scripts/fork/sync.py validate" + ] + }, + { + "path": "crates/buzz-acp/src/acp/compaction.rs", + "responsibility": "Bound compaction IDs and typed lifecycles by provider session and local generation; terminal enrichment cannot satisfy a later manual operation. Retain bounded creation advertisements under the current in-flight token and adopt only the successfully returned session.", + "is_new": true, + "required_symbols": [ + "if self.entries.len() >= MAX_IDS", + "after: self.sequence,", + ".is_some_and(|route| route.generation == generation)", + ".filter(|route| route.adoptable_binding)", + "route.adoptable_binding = false;", + " if self\n .routes\n .get(session)\n .is_none_or(|route| route.generation != generation)\n {\n return;\n }", + "pub(super) fn prepare_new(", + "pub(super) fn complete_new(", + "pending.advertisements.len() < MAX_IDS", + "bytes.saturating_add(session.len()) <= MAX_CREATION_BYTES", + "self.creation = None;", + ".is_none_or(|pending| pending.generation != generation)" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/acp/compaction.rs", + "callee_symbol": "retain", + "invocation": "self.entries\n .retain(|entry| sessions.contains(&entry.session));", + "behavior_test": "compaction_standard_dispatch_never_guesses_success", + "verification_command": "cargo test --locked -p buzz-acp --lib compaction_standard_dispatch_never_guesses_success -- --test-threads=1" + } + ], + "verification_commands": [ + "cargo test --locked -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/acp/compaction_tests.rs", + "responsibility": "Exercise actual initialize frames, standard compaction dispatch, tracker bounds, and status transition ownership.", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "cargo test --locked -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/pool/compaction.rs", + "responsibility": "Acquire only the exact existing provider session, persist Started before a single compact prompt, and return separately typed control health/results. Reset stale worker observer context to the admitted channel, existing provider session and command identity before resume or compact I/O. Reject disk-only targets before lazy wake when provider resume is disabled, while preserving existing in-memory compaction.", + "is_new": true, + "required_symbols": [ + "pub(crate) struct ExistingSession", + "pub(crate) async fn acquire_existing_session_for_compaction(", + "pub(crate) async fn run_compaction_task(", + "if live.lease != *lease || live.session_id != *session_id", + "Ok(_) => terminal,", + "let execution = tokio::time::timeout_at(job.deadline, async {", + "job.job.origin.command_event_id.clone(),", + "allow_disk_resume: bool,", + "if !allow_disk_resume {" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/pool/compaction.rs", + "callee_symbol": "begin_compaction_effect", + "invocation": "ctx.session_controls.begin_compaction_effect(&job)", + "behavior_test": "compact_exact_prompt_and_durable_started_preserve_delivery_and_turns", + "verification_command": "cargo test --locked -p buzz-acp --lib compact_exact_prompt_and_durable_started_preserve_delivery_and_turns -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/pool/compaction.rs", + "callee_symbol": "session_resume", + "invocation": ".session_resume(session_id, &ctx.cwd, ctx.mcp_servers.clone(), None, None)", + "behavior_test": "compact_never_calls_session_new_memory_or_disk_success", + "verification_command": "cargo test --locked -p buzz-acp --lib compact_never_calls_session_new_memory_or_disk_success -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/pool/compaction.rs", + "callee_symbol": "session_prompt_blocks_with_idle_timeout", + "invocation": "agent.acp.session_prompt_blocks_with_idle_timeout(&session.session_id, &[\"/compact\"], ctx.idle_timeout, ctx.max_turn_duration)", + "behavior_test": "compact_exact_prompt_and_durable_started_preserve_delivery_and_turns", + "verification_command": "cargo test --locked -p buzz-acp --lib compact_exact_prompt_and_durable_started_preserve_delivery_and_turns -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/pool/compaction.rs", + "callee_symbol": "set_observer_context", + "invocation": "agent.acp.set_observer_context(observer::context_for_turn(", + "behavior_test": "compact_observer_routes_resume_prompt_and_updates_to_admitted_scope", + "verification_command": "cargo test --locked -p buzz-acp compact_ -- --test-threads=1" + } + ], + "verification_commands": [ + "cargo test --locked -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/pool/compaction_tests.rs", + "responsibility": "Exercise existing-only acquisition and actual compact RPC, durable markers, signed result submission and failure/redelivery behavior. Regress actual disk-resume observer envelope routing from stale prior scope.", + "is_new": true, + "required_symbols": [ + "compact_observer_routes_resume_prompt_and_updates_to_admitted_scope" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test --locked -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/pool/compaction_runtime_tests.rs", + "responsibility": "Exercise authorized controller admission, exact-owner scheduling, lazy wake, collection protection, bounded pending work and lifecycle termination. Regress actual cached-worker control-dispatch observer envelope routing.", + "is_new": true, + "required_symbols": [ + "compact_cached_worker_replaces_previous_observer_scope", + "mod resume_policy_tests;" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test --locked -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/pool/compaction_resume_policy_tests.rs", + "responsibility": "Bind authorized target selection and scheduler wake to disabled disk resume, durable refusal replay, enabled disk resume and successful no-store in-memory compaction.", + "is_new": true, + "required_symbols": [ + "compact_resume_disabled_disk_refusal_and_replay_never_wake", + "compact_resume_enabled_disk_wakes_and_resumes_without_new_session", + "compact_resume_disabled_memory_owner_still_compacts_without_resume" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test --locked -p buzz-acp --lib compact_resume_ -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/session_controls/compaction.rs", + "responsibility": "Keep pending manual compactions, bounded terminal outcomes and durable redelivery independent of disposable pools.", + "is_new": true, + "required_symbols": [ + "pub(crate) struct AdmittedSessionControl", + "if scopes.len() + state.receipt_writes.len() >= 128", + "if self.outcomes.len() >= 128", + "if pending_scopes + state.receipt_writes.len() >= 128", + "pending.started = Some(receipt);", + "Err(_) => true,", + "outcome = Err(ControlFailure::StoreUnavailable);", + "COMPACT_QUEUE_WAIT", + "COMPACT_EXECUTION_WAIT", + "pub(crate) fn report_compaction_wait(" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/session_controls/compaction.rs", + "callee_symbol": "begin_admitted_control_with_receipt", + "invocation": ".begin_admitted_control_with_receipt(\n &job.job.scope,\n &job.job.origin.command_event_id,\n job.job.event.created_at.as_secs(),\n job.job.admitted_at,\n )", + "behavior_test": "compact_exact_prompt_and_durable_started_preserve_delivery_and_turns", + "verification_command": "cargo test --locked -p buzz-acp --lib compact_exact_prompt_and_durable_started_preserve_delivery_and_turns -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/session_controls/compaction.rs", + "callee_symbol": "begin_admitted_control_with_receipt", + "invocation": ".begin_admitted_control_with_receipt(\n &job.job.scope,\n &job.job.origin.command_event_id,\n job.job.event.created_at.as_secs(),\n job.job.admitted_at,\n )", + "behavior_test": "compact_finalized_started_survives_both_locked_finalization_calls", + "verification_command": "cargo test --locked -p buzz-acp --lib compact_finalized_started_survives_both_locked_finalization_calls -- --test-threads=1" + } + ], + "verification_commands": [ + "cargo test --locked -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/control_tasks.rs", + "responsibility": "Dispatch typed control tasks through shared worker ownership and retirement, with separate result accounting and completion activity.", + "is_new": true, + "required_symbols": [ + "queue.has_flushable_work()\n || !controls.pending_compactions().is_empty()\n || !controls.pending_statuses().is_empty()", + "} else if tokio::time::Instant::now() >= job.deadline {", + "*last_activity = tokio::time::Instant::now();" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/control_tasks.rs", + "callee_symbol": "run_compaction_task", + "invocation": "run_compaction_task(agent, job, ctx_clone, control_rx).await", + "behavior_test": "compact_waits_for_exact_busy_owner_without_cancel_or_fork", + "verification_command": "cargo test --locked -p buzz-acp --lib compact_waits_for_exact_busy_owner_without_cancel_or_fork -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/control_tasks.rs", + "callee_symbol": "run_compaction_task", + "invocation": "run_compaction_task(agent, job, ctx_clone, control_rx).await", + "behavior_test": "compact_valid_admission_crosses_wire_expiry_while_waiting_for_owner", + "verification_command": "cargo test --locked -p buzz-acp --lib compact_valid_admission_crosses_wire_expiry_while_waiting_for_owner -- --test-threads=1" + } + ], + "verification_commands": [ + "cargo test --locked -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/session_controls/receipt_writes.rs", + "responsibility": "Bound known-finalized local terminal receipt writes, fence persistence by exact owner/generation, and reuse reconciliation backoff without repeating provider effects. Use exact Started evidence captured before effects; retaining repair never requires a later store read.", + "is_new": true, + "required_symbols": [ + "pub(super) fn retain_receipt_write(", + "pub(super) fn retry_receipt_writes(", + "receipt: ControlReceipt,", + "receipt.owner_instance != store.instance()" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/session_controls/receipt_writes.rs", + "callee_symbol": "retry_control_outcome", + "invocation": "store.retry_control_outcome(&entry.scope, &entry.receipt, &entry.outcome)", + "behavior_test": "compact_terminal_write_retries_after_repair_without_reexecuting_effect", + "verification_command": "cargo test --locked -p buzz-acp --lib compact_terminal_write_retries_after_repair_without_reexecuting_effect -- --test-threads=1" + } + ], + "verification_commands": [ + "cargo test --locked -p buzz-acp -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/acp/windows_job.rs", + "responsibility": "Suspended Windows worker containment with independent zero-job and direct-child retirement proof", + "is_new": true, + "required_symbols": [ + "struct AssignQueryJob", + "pub(super) fn spawn", + "async fn terminate_windows", + "query_process_id_list", + "graceful_deadline", + "verify_deadline", + "async fn terminate_windows_with_query" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/acp.rs", + "callee_symbol": "spawn", + "invocation": "let spawned = windows_job::spawn(cmd);", + "behavior_test": "checked_termination_windows_harness_drop_kills_detached_tree", + "verification_command": "cargo test -p buzz-acp --lib checked_termination_windows -- --test-threads=1", + "coverage": "Native Windows tests exercise actual suspended ACP spawn, owned job drop and independently observed descendant death." + }, + { + "caller_path": "crates/buzz-acp/src/acp/terminate.rs", + "callee_symbol": "terminate_windows", + "invocation": "self.terminate_windows().await", + "behavior_test": "checked_termination_windows_detached_grandchild_blocks_clean_after_parent_exit", + "verification_command": "cargo test -p buzz-acp --lib checked_termination_windows -- --test-threads=1", + "coverage": "Cancellation/reentry cannot confuse a cached parent exit with an empty contained tree." + }, + { + "caller_path": "crates/buzz-acp/src/acp/windows_job.rs", + "callee_symbol": "terminate_windows_with_query", + "invocation": "self.terminate_windows_with_query(|job| {", + "behavior_test": "checked_termination_windows_empty_query_requires_parent_reap", + "verification_command": "python scripts/check-acp-retirement.py", + "coverage": "Native fake-adapter test requires both job-zero and direct-parent-reap proof; hosted mutation removes guard and requires the actual assertion to fail, exact restoration and focused green recheck." + } + ], + "verification_commands": [ + "cargo test -p buzz-acp --lib checked_termination_windows -- --test-threads=1" + ] + }, + { + "path": "desktop/src/features/messages/lib/sessionControl.ts", + "responsibility": "Parse the shared exact grammar with Rust whitespace parity; project original signed command requests and independent raw target-signed bounded outcomes.", + "is_new": true, + "required_symbols": [ + "export function parseSessionCommand(", + "export function attachSessionControls(", + "event.pubkey === target", + "parseSessionCommand(event.content)", + "const EXTERNAL_WHITESPACE = /^\\p{White_Space}+|\\p{White_Space}+$/gu;", + "content.replace(EXTERNAL_WHITESPACE, \"\")", + "event.tags.some((tag) => tag[0] === \"buzz-session-result\")", + "expiresAt?: number;" + ], + "invocation_seams": [], + "verification_commands": [ + "cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionControl.test.mjs src/features/messages/lib/sessionControlPermissions.test.mjs" + ] + }, + { + "path": "desktop/src/features/messages/lib/sessionControlPermissions.ts", + "responsibility": "Authorize exact current scope from verified directory policy, owner/sibling coordinates, channel type and membership; preserve DM restrictions and remote cross-owner admission without local inventory authority.", + "is_new": true, + "required_symbols": [ + "export function canManageSession(", + "!agent.channelIds.includes(channelId)", + "if (channel.channelType === \"dm\") return false;" + ], + "invocation_seams": [], + "verification_commands": [ + "cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionControl.test.mjs src/features/messages/lib/sessionControlPermissions.test.mjs" + ] + }, + { + "path": "desktop/src/features/messages/lib/useSessionControlAction.ts", + "responsibility": "Capture exact target, thread, relay and signer, refresh verified permission, fence stale async actions and publish one exact kind-9 control without composer or lifecycle mutations.", + "is_new": true, + "required_symbols": [ + "export function useSessionControlAction(", + "if (!isCurrent()) return false;", + "currentKey.current.generation === capturedGeneration" + ], + "invocation_seams": [], + "verification_commands": [ + "cd desktop && node --import ./test-jsdom-setup.mjs --import ./test-loader.mjs --experimental-strip-types --test-force-exit --test src/features/messages/lib/useSessionControlAction.jsdom-test.mjs src/features/messages/ui/MessageSessionControlRow.jsdom-test.mjs" + ] + }, + { + "path": "desktop/src/features/messages/lib/sessionControl.test.mjs", + "responsibility": "Production formatter and shared grammar corpus regressions for original versus edited bodies, raw target results, conflicts, multiple targets, expiry, actor metadata and known bot membership.", + "is_new": true, + "required_symbols": [ + "shared exact grammar matches the actual core parser whitespace corpus", + "formatter reads original signed kind9 body", + "results require exact target raw signer" + ], + "invocation_seams": [], + "verification_commands": [ + "cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionControl.test.mjs src/features/messages/lib/sessionControlPermissions.test.mjs" + ] + }, + { + "path": "desktop/src/features/messages/lib/sessionControlPermissions.test.mjs", + "responsibility": "Permission matrix covers owner/sibling, allowlist/anyone, DM restrictions, missing and mismatched scope evidence and local inventory denial.", + "is_new": true, + "required_symbols": [ + "verified scope and exact policy evidence admit owner", + "unknown, stale local inventory" + ], + "invocation_seams": [], + "verification_commands": [ + "cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/sessionControl.test.mjs src/features/messages/lib/sessionControlPermissions.test.mjs" + ] + }, + { + "path": "desktop/src/features/messages/lib/useSessionControlAction.jsdom-test.mjs", + "responsibility": "Exercise the production hook for revocation, query/publication failures, scope switches, round trips, unmount, duplicate clicks and actual channel profile batching.", + "is_new": true, + "required_symbols": [ + "switching away and back cannot revive an old action", + "manual actor joins the actual channel surface batch" + ], + "invocation_seams": [], + "verification_commands": [ + "cd desktop && node --import ./test-jsdom-setup.mjs --import ./test-loader.mjs --experimental-strip-types --test-force-exit --test src/features/messages/lib/useSessionControlAction.jsdom-test.mjs src/features/messages/ui/MessageSessionControlRow.jsdom-test.mjs" + ] + }, + { + "path": "desktop/src/features/messages/ui/MessageSessionControls.tsx", + "responsibility": "Selected-agent action surface with explicit captured scope, Back navigation, exact restart/compact grammar, single restart warning, shared-session disclosure and fresh fenced publication.", + "is_new": true, + "required_symbols": [ + "export function MessageSessionControls(", + "Stops current work, including subagents. File changes are", + "Back to agents" + ], + "invocation_seams": [ + { + "caller_path": "desktop/src/features/messages/ui/ThreadContextControls.tsx", + "callee_symbol": "MessageSessionControls", + "invocation": " 128", + "const terminal = prior && prior.status !== \"in_progress\";", + "if (typedScopes.has(hookScope))", + "Number.isSafeInteger(rpc.id)", + "const firstNew = candidates.find(", + "item.id === firstNew?.id", + "sessionId: params.sessionId,", + "update.summary.slice(0, MAX_SUMMARY)", + "activity.status === \"failed\" || activity.conflict", + "rpc.error === undefined &&", + "const newer = next.pendingAttachments.some(", + "next = rememberBoundary(next, {", + "if (!newer)", + "entry.id === boundary.id && entry.agent === boundary.agent" + ], + "invocation_seams": [ + { + "caller_path": "desktop/src/features/agents/ui/agentSessionCompaction.ts", + "callee_symbol": "parseCompactionUpdate", + "invocation": "parseCompactionUpdate(event.payload)", + "behavior_test": "production transcript mutates one compaction row to failed", + "verification_command": "node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/observerCompaction.test.mjs" + } + ], + "verification_commands": [ + "node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/observerCompaction.test.mjs" + ] + }, + { + "path": "desktop/src/features/agents/ui/agentSessionTranscript.ts", + "responsibility": "Consume the typed compaction lifecycle projection in actual incremental and rebuilt transcripts.", + "is_new": false, + "required_symbols": [ + "compactions: emptyCompactionActivityState(),", + "updateType !== \"compaction_update\"" + ], + "invocation_seams": [ + { + "caller_path": "desktop/src/features/agents/ui/agentSessionTranscript.ts", + "callee_symbol": "processCompactionActivity", + "invocation": "processCompactionActivity(d.compactions, event, d.items)", + "behavior_test": "production transcript mutates one compaction row to failed", + "verification_command": "node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/observerCompaction.test.mjs" + } + ], + "verification_commands": [ + "node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/observerCompaction.test.mjs" + ] + }, + { + "path": "desktop/src/features/agents/ui/agentSessionTypes.ts", + "responsibility": "Carry typed compaction state on existing lifecycle items and a scoped legacy-hook correlation key.", + "is_new": false, + "required_symbols": [ + "compaction?: CompactionActivity;", + "compactionScope?: string;" + ], + "invocation_seams": [], + "verification_commands": [ + "node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/observerCompaction.test.mjs" + ] + }, + { + "path": "desktop/src/features/agents/ui/agentSessionCompaction.test.mjs", + "responsibility": "Bind compaction semantics to production incremental/build, grouping and meaningful-item seams.", + "is_new": true, + "required_symbols": [ + "production transcript mutates one compaction row to", + "real worker initialization and session resume separate generations; turns do not" + ], + "invocation_seams": [], + "verification_commands": [ + "node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/observerCompaction.test.mjs" + ] + }, + { + "path": "desktop/src/features/agents/ui/agentSessionCompaction.jsdom-test.mjs", + "responsibility": "Render actual parsed/grouped compaction failure through the production activity presenter with literal untrusted summary text. Mount the production channel panel against the actual observer store to verify worker context and absence of duplicate legacy rows.", + "is_new": true, + "required_symbols": [ + "typed compaction failure survives production grouping and renders salient literal summary", + "production channel panel reads real observer store with worker context and hides only the correlated legacy duplicate" + ], + "invocation_seams": [], + "verification_commands": [ + "node --import ./desktop/test-jsdom-setup.mjs --import ./desktop/test-loader.mjs --experimental-strip-types --test-force-exit --test desktop/src/features/agents/ui/agentSessionCompaction.jsdom-test.mjs" + ] + }, + { + "path": "desktop/src/features/agents/observerCompaction.test.mjs", + "responsibility": "Exercise production observer-store out-of-order rebuild, reconnect replay dedup, raw retention and reset.", + "is_new": true, + "required_symbols": [ + "production observer store handles replay and out-of-order rebuild, retains raw frames and resets lifecycle state" + ], + "invocation_seams": [ + { + "caller_path": "desktop/src/features/agents/observerRelayStore.ts", + "callee_symbol": "processTranscriptEvent", + "invocation": "processTranscriptEvent(transcriptState, event)", + "behavior_test": "production observer store handles replay and out-of-order rebuild, retains raw frames and resets lifecycle state", + "verification_command": "node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/observerCompaction.test.mjs" + } + ], + "verification_commands": [ + "node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/observerCompaction.test.mjs" + ] + }, + { + "path": "desktop/src/features/agents/ui/agentSessionPanelLayout.ts", + "responsibility": "Keep global worker initialization as non-rendering context only when deriving a channel transcript; raw rail remains channel-scoped.", + "is_new": false, + "required_symbols": [ + "includeWorkerBoundaries = false,", + "includeWorkerBoundaries &&" + ], + "invocation_seams": [], + "verification_commands": [ + "node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs desktop/src/features/agents/observerCompaction.test.mjs" + ] + }, + { + "path": "desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs", + "responsibility": "Regress scoped lifecycle generation and scroll-key preservation while raw channel filtering remains exact.", + "is_new": false, + "required_symbols": [ + "channel transcript retains global worker boundaries without expanding the raw channel rail" + ], + "invocation_seams": [], + "verification_commands": [ + "node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs desktop/src/features/agents/observerCompaction.test.mjs" + ] + }, + { + "path": "desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx", + "responsibility": "Use worker boundary context for channel transcript generation identity while preserving the raw/header channel window.", + "is_new": false, + "required_symbols": [], + "invocation_seams": [ + { + "caller_path": "desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx", + "callee_symbol": "scopeByChannel", + "invocation": "scopeByChannel(events, channelId, true)", + "behavior_test": "production channel panel reads real observer store with worker context and hides only the correlated legacy duplicate", + "verification_command": "node --import ./desktop/test-jsdom-setup.mjs --import ./desktop/test-loader.mjs --experimental-strip-types --test-force-exit --test desktop/src/features/agents/ui/agentSessionCompaction.jsdom-test.mjs" + } + ], + "verification_commands": [ + "node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs desktop/src/features/agents/observerCompaction.test.mjs" + ] + }, + { + "path": "desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx", + "responsibility": "Use worker boundary context for channel transcript generation identity while preserving the raw/header channel window.", + "is_new": false, + "required_symbols": [], + "invocation_seams": [ + { + "caller_path": "desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx", + "callee_symbol": "scopeByChannel", + "invocation": "scopeByChannel(events, sessionChannelId, true)", + "behavior_test": "production channel panel reads real observer store with worker context and hides only the correlated legacy duplicate", + "verification_command": "node --import ./desktop/test-jsdom-setup.mjs --import ./desktop/test-loader.mjs --experimental-strip-types --test-force-exit --test desktop/src/features/agents/ui/agentSessionCompaction.jsdom-test.mjs" + } + ], + "verification_commands": [ + "node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs desktop/src/features/agents/observerCompaction.test.mjs" + ] + }, + { + "path": "desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts", + "responsibility": "Exclude only proven typed-compaction duplicate legacy hooks from polished presentation; retain other suppressed rows and raw observer evidence.", + "is_new": false, + "required_symbols": [ + "item.descriptor?.reason !== \"typed-compaction-duplicate\"" + ], + "invocation_seams": [ + { + "caller_path": "desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts", + "callee_symbol": "splitIntoSessionRuns", + "invocation": "splitIntoSessionRuns(", + "behavior_test": "grouped production transcript removes correlated legacy hook instead of rendering a quiet duplicate", + "verification_command": "node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs desktop/src/features/agents/observerCompaction.test.mjs" + } + ], + "verification_commands": [ + "node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/agents/ui/agentSessionCompaction.test.mjs desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs desktop/src/features/agents/observerCompaction.test.mjs" + ] + }, + { + "path": "scripts/post-screenshots.sh", + "responsibility": "Publish immutable screenshot URLs to an explicit fork and remote with normal nonforce Git push, trusted DCO/signing, and no comment after a rejected push; preserve upstream defaults.", + "is_new": false, + "required_symbols": [ + "EXPLICIT_REPO=false", + "TRACKING_REF=", + "Signed-off-by: %s", + "git push \"$REMOTE\"" + ], + "invocation_seams": [], + "verification_commands": [ + "python3 -m unittest discover -s scripts/fork/tests -p test_post_screenshots.py -v" + ] + }, + { + "path": "scripts/fork/tests/test_post_screenshots.py", + "responsibility": "Exercise actual screenshot workflow with a local bare fork, real Git history/DCO/immutable URLs and rejected-push publication ordering, without network publication.", + "is_new": true, + "required_symbols": [ + "test_explicit_fork_preserves_remote_history_and_signs_off" + ], + "invocation_seams": [], + "verification_commands": [ + "python3 -m unittest discover -s scripts/fork/tests -p test_post_screenshots.py -v" + ] + }, + { + "path": "crates/buzz-acp/src/session_controls/orphan_owner_tests.rs", + "responsibility": "Production-driver foreign owner reconciliation with real registry-lock uncertainty, later owner death and capped idle wake backoff.", + "is_new": true, + "required_symbols": [ + "orphan_registry_unknown_revisits_after_lock_release_without_external_wake", + "orphan_alive_foreign_owner_is_revisited_after_it_dies", + "orphan_live_owner_revisit_uses_capped_backoff_and_never_spins_without_capacity" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-acp --lib orphan_registry_unknown_revisits_after_lock_release_without_external_wake -- --test-threads=1", + "cargo test -p buzz-acp --lib orphan_alive_foreign_owner_is_revisited_after_it_dies -- --test-threads=1", + "cargo test -p buzz-acp --lib orphan_live_owner_revisit_uses_capped_backoff_and_never_spins_without_capacity -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/worker_retirement_shutdown_tests.rs", + "responsibility": "Production orderly-shutdown regressions for parked originals, abort guard returns and reused original retirement evidence.", + "is_new": true, + "required_symbols": [ + "retirement_shutdown_retries_original_parked_after_snapshot_failure", + "retirement_shutdown_drains_original_returned_by_aborted_guard", + "retirement_shutdown_reuses_original_evidence_after_parent_reaped", + "retirement_shutdown_retains_uncertain_original_without_fabricating_clean" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-acp --lib retirement_shutdown_retries_original_parked_after_snapshot_failure -- --test-threads=1", + "cargo test -p buzz-acp --lib retirement_shutdown_drains_original_returned_by_aborted_guard -- --test-threads=1", + "cargo test -p buzz-acp --lib retirement_shutdown_reuses_original_evidence_after_parent_reaped -- --test-threads=1", + "cargo test -p buzz-acp --lib retirement_shutdown_retains_uncertain_original_without_fabricating_clean -- --test-threads=1" + ] + }, + { + "path": "crates/buzz-acp/src/pool/background_uncertainty_tests.rs", + "responsibility": "Actual return/hold/poll regressions distinguish discovery uncertainty from observed completion.", + "is_new": true, + "required_symbols": [ + "background_unknown_then_empty_releases_hold_without_inventing_collection", + "background_observed_then_unknown_then_empty_collects_exactly_once", + "background_observed_exit_during_return_survives_later_unknown" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-acp --lib background_unknown_then_empty_releases_hold_without_inventing_collection -- --test-threads=1", + "cargo test -p buzz-acp --lib background_observed_then_unknown_then_empty_collects_exactly_once -- --test-threads=1", + "cargo test -p buzz-acp --lib background_observed_exit_during_return_survives_later_unknown -- --test-threads=1" + ] + }, + { + "path": "desktop/tests/e2e/message-copy-link.spec.ts", + "responsibility": "Unambiguous huddle event identity/kind/content with unchanged pending/huddle copy-link exclusions.", + "is_new": false, + "required_symbols": [ + "expect(huddleKind).toBe(KIND_HUDDLE_STARTED);", + "getByTestId(\"huddle-attachment\")" + ], + "invocation_seams": [], + "verification_commands": [ + "CI=1 pnpm --dir desktop test:e2e:smoke message-copy-link.spec.ts" + ] + }, + { + "path": "desktop/tests/e2e/reaction-order.spec.ts", + "responsibility": "Preserve chronological reaction ordering, exact six-pixel body adjacency for ordinary and metadata-free agent messages, and absence of removed per-message session controls.", + "is_new": false, + "required_symbols": [ + "ordinary non-agent reactions stay adjacent to the message body", + "getByTestId(\"manage-current-session\")" + ], + "invocation_seams": [], + "verification_commands": [ + "CI=1 pnpm --dir desktop test:e2e:smoke reaction-order.spec.ts" + ] + }, + { + "path": "desktop/src/features/messages/ui/MessageActionBar.tsx", + "responsibility": "Bound the reaction picker host to Radix available viewport height while preserving session controls and picker navigation.", + "is_new": false, + "required_symbols": [ + "[&_em-emoji-picker]:max-h-[var(--radix-popover-content-available-height)]", + "[&_em-emoji-picker]:min-h-0", + "collisionPadding={8}" + ], + "invocation_seams": [ + { + "caller_path": "desktop/src/features/messages/ui/MessageActionBar.tsx", + "callee_symbol": "EmojiPicker", + "invocation": "" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-cli" + ] + }, + { + "path": "crates/buzz-cli/src/attachment_tests.rs", + "responsibility": "Exercise document, archive and Markdown uploads, primary and legacy metadata, message links, media compatibility, and executable and size rejection.", + "is_new": true, + "required_symbols": [ + "uploads_documents_archives_and_markdown_with_original_names", + "messages_emit_file_links_for_multiple_attachments_and_keep_media_markup", + "unicode_filenames_fit_relay_byte_limit_and_keep_extensions" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-cli" + ] + }, + { + "path": "desktop/tests/e2e/file-attachment.spec.ts", + "responsibility": "Verify Markdown and ZIP picker uploads, file-card labels and sizes, and native download filenames alongside existing attachment flows.", "is_new": false, "required_symbols": [], "invocation_seams": [], "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v" + "cd desktop && pnpm test:e2e:smoke -- file-attachment.spec.ts" ] }, { - "path": "crates/buzz-workflow/src/schema.rs", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "path": "desktop/src-tauri/src/commands/media_download.rs", + "responsibility": "Fetch generic attachments through a bounded 100 MiB native path while retaining 50 MiB image/editor/clipboard caps and existing auth, redirect, and cancellation protections.", "is_new": false, "required_symbols": [ - "yaml_serde::from_str(yaml)?" + "MAX_FILE_DOWNLOAD_BYTES", + "mod file_download_tests;" ], "invocation_seams": [ { - "caller_path": "crates/buzz-workflow/src/schema.rs", - "callee_symbol": "from_str", - "invocation": "yaml_serde::from_str(yaml)?", - "behavior_test": "schema::yaml_tests::workflow_yaml_preserves_trigger_aliases_and_message_text", - "verification_command": "cargo test --locked -p buzz-workflow --lib schema::yaml_tests" + "caller_path": "desktop/src-tauri/src/commands/media_download.rs", + "callee_symbol": "fetch_blob_bytes_with_cap", + "invocation": "fetch_blob_bytes_with_cap(url, state, MAX_FILE_DOWNLOAD_BYTES, None).await", + "behavior_test": "generic_download_above_fifty_mib_is_byte_exact", + "verification_command": "cargo test --manifest-path desktop/src-tauri/Cargo.toml --lib commands::media_download::file_download_tests::generic_download_above_fifty_mib_is_byte_exact -- --exact" } ], "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v", - "cargo test --locked -p buzz-workflow --lib schema::yaml_tests" + "cargo test --manifest-path desktop/src-tauri/Cargo.toml --workspace" + ] + }, + { + "path": "desktop/src-tauri/src/commands/media_download_file_tests.rs", + "responsibility": "Exercise the native HTTP file fetch above 50 MiB, the 100 MiB boundary, declared and streamed oversize rejection, preserved image caps, and an opt-in real-relay hash check.", + "is_new": true, + "required_symbols": [ + "generic_download_above_fifty_mib_is_byte_exact", + "generic_download_rejects_over_hundred_mib_with_or_without_length", + "native_download_from_relay_matches_expected_sha256" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test --manifest-path desktop/src-tauri/Cargo.toml --workspace" + ] + }, + { + "path": "desktop/src-tauri/src/commands/media_filename.rs", + "responsibility": "Bound sanitized filenames by UTF-8 bytes at character boundaries while preserving the extension for imeta ingest.", + "is_new": false, + "required_symbols": [ + "unicode_labels_fit_relay_utf8_byte_limit_without_losing_extensions" + ], + "invocation_seams": [], + "verification_commands": [ + "cargo test --manifest-path desktop/src-tauri/Cargo.toml --workspace" + ] + }, + { + "path": "crates/buzz-mesh-smoke/Cargo.toml", + "responsibility": "Use the same immutable patched MeshLLM source as the desktop consumer to avoid duplicate runtime types.", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "cargo test -p buzz-mesh-smoke" + ] + }, + { + "path": "desktop/src-tauri/src/commands/agent_discovery/managed_node.rs", + "responsibility": "Preserve confined Node.js extraction while propagating ZIP 9 entry-name decoding errors.", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just desktop-tauri-test desktop-tauri-check" + ] + }, + { + "path": "desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs", + "responsibility": "Exercise the production version probe with immutable scripts and explicit interpreter PATH inputs.", + "is_new": false, + "required_symbols": [ + "probe_codex_acp_version_uses_augmented_path_for_env_shebang_interpreter" + ], + "invocation_seams": [], + "verification_commands": [ + "just desktop-tauri-test desktop-tauri-check" + ] + }, + { + "path": "desktop/src-tauri/src/managed_agents/discovery/tests/codex_version_fixture.rs", + "responsibility": "Provide immutable executable symlinks, mutable version data and cleanup for the deliberately surviving descendant.", + "is_new": true, + "required_symbols": [ + "struct VersionFixture", + "impl Drop for VersionFixture" + ], + "invocation_seams": [], + "verification_commands": [ + "just desktop-tauri-test desktop-tauri-check" + ] + }, + { + "path": "docs/fork/DEPENDENCY_SECURITY.md", + "responsibility": "Document patched release dependencies, immutable compatibility pins, target scope and temporary transitive maintenance exceptions.", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "python3 scripts/fork/sync.py validate" + ] + }, + { + "path": "desktop/src-tauri/src/managed_agents/discovery/tests/fixtures/codex-version.sh", + "responsibility": "Immutable executable fixture for production version-probe regression tests.", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just desktop-tauri-test desktop-tauri-check" + ] + }, + { + "path": "desktop/src-tauri/src/managed_agents/discovery/tests/fixtures/codex-hung.sh", + "responsibility": "Immutable executable fixture for production version-probe regression tests.", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just desktop-tauri-test desktop-tauri-check" + ] + }, + { + "path": "desktop/src-tauri/src/managed_agents/discovery/tests/fixtures/codex-descendant.sh", + "responsibility": "Immutable executable fixture for production version-probe regression tests.", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just desktop-tauri-test desktop-tauri-check" ] }, { - "path": "crates/buzz-workflow/src/schema_yaml_tests.rs", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "path": "desktop/src-tauri/src/managed_agents/discovery/tests/fixtures/env-node.sh", + "responsibility": "Immutable executable fixture for production version-probe regression tests.", "is_new": true, "required_symbols": [], "invocation_seams": [], "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v" + "just desktop-tauri-test desktop-tauri-check" ] }, { - "path": "desktop/src-tauri/src/commands/workflows.rs", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", - "is_new": false, + "path": "crates/buzz-acp/src/acp/claude_context.rs", + "responsibility": "Use provider-confirmed Claude context capacity with same-session generation fencing; suppress provisional denominators, retain confirmed capacity across ordinary prompts, and consume filtered SDK results before diagnostic publication.", + "is_new": true, "required_symbols": [ - "yaml_serde::from_str::(yaml)" + "fn authoritative_window(result: &Value) -> Option", + "state.confirmed_window", + "state.generation != binding.generation_id()", + "models.len() > 64" ], "invocation_seams": [ { - "caller_path": "desktop/src-tauri/src/commands/workflows.rs", - "callee_symbol": "from_str", - "invocation": "yaml_serde::from_str::(yaml)", - "behavior_test": "commands::workflows::tests::workflow_yaml_json_projection_preserves_words_and_fallbacks", - "verification_command": "cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib commands::workflows::tests::workflow_yaml_json_projection_preserves_words_and_fallbacks" + "caller_path": "crates/buzz-acp/src/acp.rs", + "callee_symbol": "consume_claude_context_frame", + "invocation": "if self.consume_claude_context_frame(&msg) {", + "behavior_test": "claude_context_uses_provider_window_and_the_final_context_numerator", + "verification_command": "cargo test --locked -p buzz-acp -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/acp.rs", + "callee_symbol": "begin_claude_context_turn", + "invocation": "self.begin_claude_context_turn(session_id);", + "behavior_test": "claude_confirmed_capacity_survives_an_ordinary_prompt_for_tool_published_answers", + "verification_command": "cargo test --locked -p buzz-acp -- --test-threads=1" } ], "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v", - "cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib commands::workflows::tests::workflow_yaml_json_projection_preserves_words_and_fallbacks" + "cargo test --locked -p buzz-acp -- --test-threads=1" ] }, { - "path": "desktop/src-tauri/src/commands/workflows_tests.rs", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "path": "desktop/tests/e2e/thread-head-stale-edit.spec.ts", + "responsibility": "Exercise the edited thread head only after making its hover-only Reply action actionable; retain gated backfill and edited-content regression assertions.", "is_new": false, - "required_symbols": [], + "required_symbols": [ + "await timelineRow.hover();", + "await replyButton.click();", + "deferThreadReplies: true" + ], "invocation_seams": [], "verification_commands": [ - "just ci", - "python3 -m unittest discover -s scripts/fork/tests -v" + "pnpm --dir desktop exec playwright test --project=smoke tests/e2e/thread-head-stale-edit.spec.ts" ] }, { - "path": "desktop/src-tauri/src/managed_agents/config_bridge/goose.rs", - "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", - "is_new": false, + "path": "desktop/src/features/messages/ui/ThreadContextControls.tsx", + "responsibility": "Thread-scoped accessible agent roster from bounded canonical raw history, verified directory and current authority; agent-first picker, scope reset, independent context toolbar trigger and selected action lifetime. Passively subscribe to the active owning validated thread query without replacing query/retry options; ownerless read-only forum previews retain an isolated bounded fallback, while other absent/disabled owners are explicitly unavailable. Resolve eligible-agent profile avatars with decorative shared squircle rendering and initials fallback, preserving accessible collision labels.", + "is_new": true, "required_symbols": [ - "yaml_serde::from_str(yaml_str).ok()?" + "export function ThreadContextControls(", + "key={JSON.stringify([relayUrl, signerPubkey, channel.id, rootId])}", + "aria-label=\"Manage context\"", + "return query?.isActive() ? query.state : undefined;" ], "invocation_seams": [ { - "caller_path": "desktop/src-tauri/src/managed_agents/config_bridge/goose.rs", - "callee_symbol": "from_str", - "invocation": "yaml_serde::from_str(yaml_str).ok()?", - "behavior_test": "managed_agents::config_bridge::goose::yaml_tests::goose_yaml_preserves_nested_providers_and_ignores_unknown_tagged_fields", - "verification_command": "cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib managed_agents::config_bridge::goose::yaml_tests" + "caller_path": "desktop/src/features/messages/ui/MessageThreadPanel.tsx", + "callee_symbol": "ThreadContextControls", + "invocation": "", + "behavior_test": "known_agent_without_telemetry_still_has_current_scope_controls", + "verification_command": "CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts" + }, + { + "caller_path": "desktop/src/features/messages/ui/ThreadContextControls.tsx", + "callee_symbol": "useSyncExternalStore", + "invocation": "const ownedReplies = React.useSyncExternalStore(", + "behavior_test": "opening the picker preserves the owning notification query validation across gated retries (stream)", + "verification_command": "cd desktop && node --import ./test-jsdom-setup.mjs --import ./test-loader.mjs --experimental-strip-types --test-force-exit --test src/features/messages/ui/ThreadContextControls.jsdom-test.mjs" + }, + { + "caller_path": "desktop/src/features/messages/ui/ThreadContextControls.tsx", + "callee_symbol": "projectThreadContextAgents", + "invocation": "projectThreadContextAgents({", + "behavior_test": "same-name agents remain distinct and a relay switch closes selected actions", + "verification_command": "cd desktop && node --import ./test-jsdom-setup.mjs --import ./test-loader.mjs --experimental-strip-types --test-force-exit --test src/features/messages/ui/ThreadContextControls.jsdom-test.mjs" + }, + { + "caller_path": "desktop/src/features/messages/ui/ThreadContextControls.tsx", + "callee_symbol": "loadThreadReplies", + "invocation": "queryFn: () => loadThreadReplies(queryClient, channel.id, rootId)", + "behavior_test": "read-only forum previews retain a bounded roster without replacing the disabled owner", + "verification_command": "cd desktop && node --import ./test-jsdom-setup.mjs --import ./test-loader.mjs --experimental-strip-types --test-force-exit --test src/features/messages/ui/ThreadContextControls.jsdom-test.mjs" + }, + { + "caller_path": "desktop/src/features/messages/ui/ThreadContextControls.tsx", + "callee_symbol": "useUsersBatchQuery", + "invocation": "const profiles = useUsersBatchQuery(", + "behavior_test": "production roster resolves profile images and falls back without duplicating accessible labels", + "verification_command": "cd desktop && node --import ./test-jsdom-setup.mjs --import ./test-loader.mjs --experimental-strip-types --test-force-exit --test src/features/messages/ui/ThreadContextControls.jsdom-test.mjs" + }, + { + "caller_path": "desktop/src/features/messages/ui/ThreadContextControls.tsx", + "callee_symbol": "UserAvatar", + "invocation": "" + "threadContextControl={threadContextControl}" + ], + "invocation_seams": [ + { + "caller_path": "desktop/src/features/messages/ui/MessageComposer.tsx", + "callee_symbol": "ComposerDockToolbar", + "invocation": "", + "behavior_test": "known_agent_without_telemetry_still_has_current_scope_controls", + "verification_command": "CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts" + } ], - "invocation_seams": [], "verification_commands": [ - "cargo test -p buzz-cli" + "CI=1 pnpm --dir desktop test:e2e:smoke session-controls.spec.ts session-status.spec.ts" ] }, { - "path": "crates/buzz-cli/src/client.rs", - "responsibility": "Upload generic documents and archives and retain a sanitized basename across both Blossom endpoint paths.", - "is_new": false, - "required_symbols": [], + "path": "crates/buzz-acp/src/acp/restart_status.rs", + "responsibility": "Bounded exact-session text capture for a status-only ACP turn, excluding thoughts, tools and overflow.", + "is_new": true, + "required_symbols": [ + "fn begin_status_capture", + "fn take_status_text", + "fn capture_status_update" + ], "invocation_seams": [ { - "caller_path": "crates/buzz-cli/src/client.rs", - "callee_symbol": "validate_upload", - "invocation": "validate_upload(&mime, bytes.len() as u64)?;", - "behavior_test": "rejects_detected_executables_before_upload", - "verification_command": "cargo test -p buzz-cli" + "caller_path": "crates/buzz-acp/src/acp.rs", + "callee_symbol": "capture_status_update", + "invocation": "self.capture_status_update(msg);", + "behavior_test": "overflow_empty_thought_and_rate_limited_output_never_publish_partial_answers", + "verification_command": "cargo test -p buzz-acp --lib overflow_empty_thought_and_rate_limited_output_never_publish_partial_answers -- --test-threads=1" } ], "verification_commands": [ - "cargo test -p buzz-cli" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "desktop/tests/e2e/file-attachment.spec.ts", - "responsibility": "Verify Markdown and ZIP picker uploads, file-card labels and sizes, and native download filenames alongside existing attachment flows.", - "is_new": false, - "required_symbols": [], - "invocation_seams": [], + "path": "crates/buzz-acp/src/status_tasks.rs", + "responsibility": "Typed finite-priority restart-status scheduling without human queue mutation or unrelated-scope blocking.", + "is_new": true, + "required_symbols": [ + "fn dispatch(", + "fn finish(" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "status_tasks::dispatch", + "invocation": "status_tasks::dispatch(&mut pool, &ctx, &status_tx);", + "behavior_test": "checked_restart_depths_publish_one_signed_status_and_resume_without_bootstrap", + "verification_command": "cargo test -p buzz-acp --lib checked_restart_depths_publish_one_signed_status_and_resume_without_bootstrap -- --test-threads=1" + } + ], "verification_commands": [ - "cd desktop && pnpm test:e2e:smoke -- file-attachment.spec.ts" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "desktop/src-tauri/src/commands/media_download.rs", - "responsibility": "Fetch generic attachments through a bounded 100 MiB native path while retaining 50 MiB image/editor/clipboard caps and existing auth, redirect, and cancellation protections.", - "is_new": false, + "path": "crates/buzz-acp/src/pool/restart_status.rs", + "responsibility": "First replacement-session status turn with selected context, exact-authority signed publication and separately prepared failure feedback. Attach shared live generation/history/actor/occupancy tags before signing the first successful reply, retaining exact Prepared bytes across retries and leaving failure notices untagged.", + "is_new": true, "required_symbols": [ - "MAX_FILE_DOWNLOAD_BYTES", - "mod file_download_tests;" + "fn run_status_task", + "fn publish_prepared", + "fn report_expired", + ".tags(status_tags)" ], "invocation_seams": [ { - "caller_path": "desktop/src-tauri/src/commands/media_download.rs", - "callee_symbol": "fetch_blob_bytes_with_cap", - "invocation": "fetch_blob_bytes_with_cap(url, state, MAX_FILE_DOWNLOAD_BYTES, None).await", - "behavior_test": "generic_download_above_fifty_mib_is_byte_exact", - "verification_command": "cargo test --manifest-path desktop/src-tauri/Cargo.toml --lib commands::media_download::file_download_tests::generic_download_above_fifty_mib_is_byte_exact -- --exact" + "caller_path": "crates/buzz-acp/src/pool/restart_status.rs", + "callee_symbol": "tags", + "invocation": ".tags(status_tags)", + "behavior_test": "first_signed_status_carries_selected_history_actor_and_current_or_unknown_occupancy", + "verification_command": "cargo test -p buzz-acp --lib first_signed_status_carries_selected_history_actor_and_current_or_unknown_occupancy -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/status_tasks.rs", + "callee_symbol": "run_status_task", + "invocation": "run_status_task(agent, job, ctx, task_dispatch, control_rx).await", + "behavior_test": "checked_restart_depths_publish_one_signed_status_and_resume_without_bootstrap", + "verification_command": "cargo test -p buzz-acp --lib checked_restart_depths_publish_one_signed_status_and_resume_without_bootstrap -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/status_tasks.rs", + "callee_symbol": "report_expired", + "invocation": "pool::restart_status::report_expired(job, ctx, dispatch)", + "behavior_test": "expiry_scheduler_keeps_its_fence_when_cancellation_persistence_recovers", + "verification_command": "cargo test -p buzz-acp --lib expiry_scheduler_keeps_its_fence_when_cancellation_persistence_recovers -- --test-threads=1", + "coverage": "The scheduler captures the exact fence synchronously before spawning expired feedback; failed cancellation writes never rebase the future." + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "report_expired", + "invocation": "pool::restart_status::report_expired(job, ctx, dispatch)", + "behavior_test": "a_known_local_panic_reports_failure_without_restarting_the_model", + "verification_command": "cargo test -p buzz-acp --lib a_known_local_panic_reports_failure_without_restarting_the_model -- --test-threads=1", + "coverage": "Textual panic-handler wiring guard; the behavior test exercises the same known-local-panic status claim and bounded feedback function without inducing an actual harness panic." } ], "verification_commands": [ - "cargo test --manifest-path desktop/src-tauri/Cargo.toml --workspace" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "desktop/src-tauri/src/commands/media_download_file_tests.rs", - "responsibility": "Exercise the native HTTP file fetch above 50 MiB, the 100 MiB boundary, declared and streamed oversize rejection, preserved image caps, and an opt-in real-relay hash check.", + "path": "crates/buzz-acp/src/pool/status_language.rs", + "responsibility": "Bounded signed conversation-language inference under owner/requester trust policy; numbered prose retained, substantive unsupported prose replaces old certainty, verified agents excluded.", "is_new": true, "required_symbols": [ - "generic_download_above_fifty_mib_is_byte_exact", - "generic_download_rejects_over_hundred_mib_with_or_without_length", - "native_download_from_relay_matches_expected_sha256" + "struct Evidence", + "fn trusted_authors", + "fn infer(" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/pool/session_history.rs", + "callee_symbol": "language.observe", + "invocation": "language.observe(event, humans, channel, target);", + "behavior_test": "numbered_and_unsupported_latest_prose_replace_old_english_at_every_depth", + "verification_command": "cargo test -p buzz-acp --lib numbered_and_unsupported_latest_prose_replace_old_english_at_every_depth -- --test-threads=1" + } ], - "invocation_seams": [], "verification_commands": [ - "cargo test --manifest-path desktop/src-tauri/Cargo.toml --workspace" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "desktop/src-tauri/src/commands/media_filename.rs", - "responsibility": "Bound sanitized filenames by UTF-8 bytes at character boundaries while preserving the extension for imeta ingest.", - "is_new": false, + "path": "crates/buzz-acp/src/session_controls/status.rs", + "responsibility": "Exact accepted-token local ownership and immediate cancellation fencing independent of store readability, fresh admission checks, observer cancellation targets and finite status priority.", + "is_new": true, "required_symbols": [ - "unicode_labels_fit_relay_utf8_byte_limit_without_losing_extensions" + "struct StatusJob", + "fn status_current", + "fn cancel_status_for_token", + "enum StatusCancellation", + "fn cancellable_status_scopes" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/session_controls/retirement.rs", + "callee_symbol": "status_ready", + "invocation": "Self::status_ready(state, store, scope);", + "behavior_test": "checked_restart_depths_publish_one_signed_status_and_resume_without_bootstrap", + "verification_command": "cargo test -p buzz-acp --lib checked_restart_depths_publish_one_signed_status_and_resume_without_bootstrap -- --test-threads=1" + }, + { + "caller_path": "crates/buzz-acp/src/lib.rs", + "callee_symbol": "cancellable_status_scopes", + "invocation": ".cancellable_status_scopes()", + "behavior_test": "unavailable_pending_record_cancel_retires_local_fence_before_a_worker_is_free", + "verification_command": "cargo test -p buzz-acp --lib unavailable_pending_record_cancel_retires_local_fence_before_a_worker_is_free -- --test-threads=1", + "coverage": "Actual observer cancellation selects locally owned pending, retiring and workerless feedback scopes without depending on a durable record read." + } ], - "invocation_seams": [], "verification_commands": [ - "cargo test --manifest-path desktop/src-tauri/Cargo.toml --workspace" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "crates/buzz-mesh-smoke/Cargo.toml", - "responsibility": "Use the same immutable patched MeshLLM source as the desktop consumer to avoid duplicate runtime types.", - "is_new": false, - "required_symbols": [], - "invocation_seams": [], + "path": "crates/buzz-acp/src/session_store_status.rs", + "responsibility": "Bounded backward-compatible restart-status metadata, immutable prepared events and no-effect dead-owner reconciliation.", + "is_new": true, + "required_symbols": [ + "struct RestartStatus", + "fn status_transition", + "fn recover_status" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-acp/src/session_store_orphans.rs", + "callee_symbol": "recover_status", + "invocation": "match self.recover_status(saved) {", + "behavior_test": "reconstruction_claims_only_dead_owners_and_never_replays_started_model_attempts", + "verification_command": "cargo test -p buzz-acp --lib reconstruction_claims_only_dead_owners_and_never_replays_started_model_attempts -- --test-threads=1" + } + ], "verification_commands": [ - "cargo test -p buzz-mesh-smoke" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "desktop/src-tauri/src/commands/agent_discovery/managed_node.rs", - "responsibility": "Preserve confined Node.js extraction while propagating ZIP 9 entry-name decoding errors.", - "is_new": false, - "required_symbols": [], + "path": "crates/buzz-acp/src/pool/restart_status_runtime_tests.rs", + "responsibility": "Production-bound authorized Restart, scheduler, fake ACP, signed HTTP, language, failure, cancellation and reconstruction regressions.", + "is_new": true, + "required_symbols": [ + "fn checked_restart_depths_publish_one_signed_status_and_resume_without_bootstrap", + "fn reconstruction_claims_only_dead_owners_and_never_replays_started_model_attempts" + ], "invocation_seams": [], "verification_commands": [ - "just desktop-tauri-test desktop-tauri-check" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "desktop/src-tauri/src/managed_agents/discovery/tests/codex_version.rs", - "responsibility": "Exercise the production version probe with immutable scripts and explicit interpreter PATH inputs.", - "is_new": false, + "path": "crates/buzz-acp/src/pool/restart_status_edge_tests.rs", + "responsibility": "Production-bound overflow, rate-limit, reply routing, deadline, persistence failure and verified-agent language regressions.", + "is_new": true, "required_symbols": [ - "probe_codex_acp_version_uses_augmented_path_for_env_shebang_interpreter" + "fn overflow_empty_thought_and_rate_limited_output_never_publish_partial_answers", + "fn acknowledgment_without_a_completion_write_retains_prepared_bytes_and_token" ], "invocation_seams": [], "verification_commands": [ - "just desktop-tauri-test desktop-tauri-check" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "desktop/src-tauri/src/managed_agents/discovery/tests/codex_version_fixture.rs", - "responsibility": "Provide immutable executable symlinks, mutable version data and cleanup for the deliberately surviving descendant.", + "path": "crates/buzz-acp/SESSION_CONTROLS.md", + "responsibility": "Documented status-only Restart behavior, signed delivery, cancellation and crash uncertainty, language trust policy and provider limits.", "is_new": true, "required_symbols": [ - "struct VersionFixture", - "impl Drop for VersionFixture" + "# Restart status turns" ], "invocation_seams": [], "verification_commands": [ - "just desktop-tauri-test desktop-tauri-check" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "docs/fork/DEPENDENCY_SECURITY.md", - "responsibility": "Document patched release dependencies, immutable compatibility pins, target scope and temporary transitive maintenance exceptions.", - "is_new": true, - "required_symbols": [], + "path": "crates/buzz-acp/src/session_model_thread.md", + "responsibility": "Restart-status runtime guidance and acceptance contract.", + "is_new": false, + "required_symbols": [ + "" + ], "invocation_seams": [], "verification_commands": [ - "python3 scripts/fork/sync.py validate" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "desktop/src-tauri/src/managed_agents/discovery/tests/fixtures/codex-version.sh", - "responsibility": "Immutable executable fixture for production version-probe regression tests.", - "is_new": true, - "required_symbols": [], + "path": "crates/buzz-acp/src/session_model_channel.md", + "responsibility": "Restart-status runtime guidance and acceptance contract.", + "is_new": false, + "required_symbols": [ + "" + ], "invocation_seams": [], "verification_commands": [ - "just desktop-tauri-test desktop-tauri-check" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "desktop/src-tauri/src/managed_agents/discovery/tests/fixtures/codex-hung.sh", - "responsibility": "Immutable executable fixture for production version-probe regression tests.", + "path": "crates/buzz-acp/src/pool/restart_status_review_tests.rs", + "responsibility": "Production-path review regressions for numbered/unsupported language across history depths and true nonowner admission, pre-first-poll status/expiry fences, uncertain owner cancellation and durable failure delivery.", "is_new": true, - "required_symbols": [], + "required_symbols": [ + "fn numbered_and_unsupported_latest_prose_replace_old_english_at_every_depth", + "fn scheduler_keeps_its_original_fence_before_the_first_status_poll", + "fn expiry_scheduler_keeps_its_fence_when_cancellation_persistence_recovers", + "fn uncertain_owner_cancel_has_no_chat_sender_and_fences_held_feedback", + "fn owned_failure_feedback_requires_persistence_and_retries_only_its_saved_bytes" + ], "invocation_seams": [], "verification_commands": [ - "just desktop-tauri-test desktop-tauri-check" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "desktop/src-tauri/src/managed_agents/discovery/tests/fixtures/codex-descendant.sh", - "responsibility": "Immutable executable fixture for production version-probe regression tests.", + "path": "crates/buzz-acp/src/pool/restart_status_unavailable_tests.rs", + "responsibility": "Actual checked-retirement and scheduler regressions for owner/observer cancellation with missing/unreadable records, WaitingRetirement write failure, held expiry feedback, queued human preservation, stale-token protection and confirmed terminal status.", "is_new": true, - "required_symbols": [], + "required_symbols": [ + "fn unavailable_pending_record_cancel_retires_local_fence_before_a_worker_is_free", + "fn unavailable_cancel_during_retirement_cannot_recreate_the_followup_after_clean_exit", + "fn unavailable_record_cancel_fences_already_scheduled_workerless_expiry_feedback", + "fn stale_token_cleanup_cannot_fence_newer_pending_or_retiring_work_when_reads_fail", + "fn cancellation_preserves_a_confirmed_terminal_status_even_when_the_busy_cache_lags" + ], "invocation_seams": [], "verification_commands": [ - "just desktop-tauri-test desktop-tauri-check" + "cargo test -p buzz-acp -- --test-threads=1" ] }, { - "path": "desktop/src-tauri/src/managed_agents/discovery/tests/fixtures/env-node.sh", - "responsibility": "Immutable executable fixture for production version-probe regression tests.", + "path": "crates/buzz-acp/src/pool/restart_status_metadata_tests.rs", + "responsibility": "Actual authorized Restart admission, scheduler, ACP usage notifications and signed HTTP publication regressions for first-reply history/actor/current-generation tags, current or unavailable occupancy and exact prepared event persistence across all selected depths.", "is_new": true, - "required_symbols": [], + "required_symbols": [ + "fn first_signed_status_carries_selected_history_actor_and_current_or_unknown_occupancy" + ], "invocation_seams": [], "verification_commands": [ - "just desktop-tauri-test desktop-tauri-check" + "cargo test -p buzz-acp --lib first_signed_status_carries_selected_history_actor_and_current_or_unknown_occupancy -- --test-threads=1" ] } ] diff --git a/scripts/fork/tests/test_post_screenshots.py b/scripts/fork/tests/test_post_screenshots.py new file mode 100644 index 00000000000..c6b8b1f6e1b --- /dev/null +++ b/scripts/fork/tests/test_post_screenshots.py @@ -0,0 +1,94 @@ +"""Exercise screenshot publication against a local bare fork, never GitHub.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[2] / 'post-screenshots.sh' + + +class ScreenshotPublicationTests(unittest.TestCase): + def test_explicit_fork_preserves_remote_history_and_signs_off(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + checkout, remote, tools = root / 'checkout', root / 'fork.git', root / 'bin' + checkout.mkdir() + tools.mkdir() + env = {key: value for key, value in os.environ.items() + if not key.startswith(('GIT_CONFIG_', 'GIT_AUTHOR_', 'GIT_COMMITTER_'))} + env.update(GIT_CONFIG_GLOBAL='/dev/null', GIT_CONFIG_NOSYSTEM='1', + PATH=str(tools) + os.pathsep + env['PATH'], GH_CAPTURE=str(root / 'gh.json')) + + def git(*args, cwd=checkout): + return subprocess.check_output(['git', *args], cwd=cwd, env=env, text=True).strip() + + git('init', '--bare', str(remote)) + git('init') + git('config', 'user.name', 'Synthetic Contributor') + git('config', 'user.email', 'contributor@example.invalid') + git('remote', 'add', 'fork', str(remote)) + real_git = shutil.which('git') + env.update(REAL_GIT=real_git, GIT_CAPTURE=str(root / 'git.jsonl')) + (tools / 'git').write_text( + '#!/usr/bin/env python3\nimport json,os,sys\n' + 'with open(os.environ["GIT_CAPTURE"],"a") as log: log.write(json.dumps(sys.argv[1:])+"\\n")\n' + 'os.execv(os.environ["REAL_GIT"],[os.environ["REAL_GIT"],*sys.argv[1:]])\n') + (tools / 'git').chmod(0o755) + (tools / 'gh').write_text( + '#!/usr/bin/env python3\nimport json,os,sys\n' + 'if sys.argv[1:3] == ["api","user"]: print("synthetic")\n' + 'else: open(os.environ["GH_CAPTURE"],"w").write(json.dumps(sys.argv[1:]))\n') + (tools / 'gh').chmod(0o755) + images = root / 'images' + images.mkdir() + image = images / 'control.png' + image.write_bytes(b'synthetic first image') + body = root / 'body.md' + body.write_text('### Session control\n\n{{control}}\n') + + def publish(): + return subprocess.run(['bash', str(SCRIPT), '--repo', 'example/buzz-fork', + '--remote', 'fork', '15', str(images), str(body)], + cwd=checkout, env=env, capture_output=True, text=True) + + first = publish() + self.assertEqual(first.returncode, 0, first.stderr) + ref = 'refs/heads/agent-screenshots/synthetic' + initial = git('rev-parse', ref, cwd=remote) + image.write_bytes(b'synthetic replacement image') + second = publish() + self.assertEqual(second.returncode, 0, second.stderr) + final = git('rev-parse', ref, cwd=remote) + self.assertNotEqual(initial, final) + self.assertEqual(git('rev-parse', final + '^', cwd=remote), initial) + self.assertEqual(git('show', final + ':pr-15--control.png', cwd=remote), + 'synthetic replacement image') + self.assertIn('Signed-off-by: Synthetic Contributor ', + git('show', '-s', '--format=%B', final, cwd=remote)) + comment = json.loads((root / 'gh.json').read_text()) + self.assertEqual(comment[:5], ['pr', 'comment', '15', '--repo', 'example/buzz-fork']) + self.assertIn(f'https://raw.githubusercontent.com/example/buzz-fork/{final}/pr-15--control.png', + comment[-1]) + self.assertNotIn('{{control}}', comment[-1]) + pushes = [json.loads(line) for line in (root / 'git.jsonl').read_text().splitlines() + if json.loads(line)[0] == 'push'] + self.assertEqual(pushes, [['push', 'fork', f'{initial}:{ref}'], + ['push', 'fork', f'{final}:{ref}']]) + # Publication is ordered: a rejected Git push cannot post broken links. + hook = remote / 'hooks/pre-receive' + hook.write_text('#!/bin/sh\nexit 1\n') + hook.chmod(0o755) + (root / 'gh.json').unlink() + image.write_bytes(b'rejected image') + rejected = publish() + self.assertNotEqual(rejected.returncode, 0) + self.assertEqual(git('rev-parse', ref, cwd=remote), final) + self.assertFalse((root / 'gh.json').exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/post-screenshots.sh b/scripts/post-screenshots.sh index 993628518f0..e84299bff48 100755 --- a/scripts/post-screenshots.sh +++ b/scripts/post-screenshots.sh @@ -1,8 +1,36 @@ #!/usr/bin/env bash set -euo pipefail -if [[ $# -lt 2 ]]; then - echo "Usage: $0 [comment-body-file]" >&2 +REPO="block/buzz" +REMOTE="origin" +EXPLICIT_REPO=false +EXPLICIT_REMOTE=false +while [[ $# -gt 0 ]]; do + case "$1" in + --repo|--remote) + if [[ $# -lt 2 || -z "$2" ]]; then + echo "error: $1 requires a value" >&2 + exit 1 + fi + if [[ "$1" == --repo ]]; then + REPO="$2"; EXPLICIT_REPO=true + else + REMOTE="$2"; EXPLICIT_REMOTE=true + fi + shift 2 ;; + *) break ;; + esac +done +if [[ $# -lt 2 || $# -gt 3 ]]; then + echo "Usage: $0 [--repo owner/repo --remote remote-name] [comment-body-file]" >&2 + exit 1 +fi +if [[ "$EXPLICIT_REPO" != "$EXPLICIT_REMOTE" ]]; then + echo "error: select both --repo and --remote for a fork" >&2 + exit 1 +fi +if ! [[ "$REPO" =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ && "$REMOTE" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]]; then + echo "error: invalid repository or remote name" >&2 exit 1 fi @@ -10,14 +38,23 @@ PR="$1" PNG_DIR="$2" BODY_FILE="${3:-}" -if ! [[ "$PR" =~ ^[0-9]+$ ]]; then +if ! [[ "$PR" =~ ^[1-9][0-9]*$ ]]; then echo "error: PR number must be a positive integer" >&2 exit 1 fi GH_USER=$(gh api user --jq .login) +if ! [[ "$GH_USER" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]*$ ]]; then + echo "error: invalid GitHub login" >&2 + exit 1 +fi BRANCH="agent-screenshots/${GH_USER}" -REPO="block/buzz" +TRACKING_REF="refs/remotes/${REMOTE}/${BRANCH}" +git remote get-url "$REMOTE" >/dev/null +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -n "$BODY_FILE" ]]; then + "$SCRIPT_DIR/check-pr-image-urls.sh" "$BODY_FILE" +fi # macOS ships bash 3.2, which lacks mapfile — build the array with read. PNGS=() @@ -30,8 +67,8 @@ if [[ ${#PNGS[@]} -eq 0 ]]; then fi EXISTING_ENTRIES="" -if git fetch origin "refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}" 2>/dev/null; then - EXISTING_ENTRIES=$(git ls-tree "origin/${BRANCH}" | grep -v $'\t'"\"\\{0,1\\}pr-${PR}--" || true) +if git fetch "$REMOTE" "refs/heads/${BRANCH}:${TRACKING_REF}" 2>/dev/null; then + EXISTING_ENTRIES=$(git ls-tree "$TRACKING_REF" | grep -v $'\t'"\"\\{0,1\\}pr-${PR}--" || true) fi NEW_ENTRIES="" @@ -52,12 +89,20 @@ COMBINED=$(printf '%s\n' "$EXISTING_ENTRIES" "$NEW_ENTRIES" | grep -v '^$') TREE=$(echo "$COMBINED" | git mktree) PARENT_ARGS=() -if git rev-parse "origin/${BRANCH}" >/dev/null 2>&1; then - PARENT_ARGS=(-p "origin/${BRANCH}") +if git rev-parse "$TRACKING_REF" >/dev/null 2>&1; then + PARENT_ARGS=(-p "$TRACKING_REF") fi # ${arr[@]+...} guards the empty-array case, which trips set -u on bash 3.2. -COMMIT=$(git commit-tree "$TREE" ${PARENT_ARGS[@]+"${PARENT_ARGS[@]}"} -m "screenshots: PR #${PR}") -git push --force-with-lease origin "${COMMIT}:refs/heads/${BRANCH}" +SIGNING_ARGS=() +if [[ "$(git config --bool --get commit.gpgsign || true)" == true ]]; then + SIGNING_ARGS=(-S) +fi +COMMITTER=$(git var GIT_COMMITTER_IDENT) +SIGNOFF="${COMMITTER%>*}>" +COMMIT=$(printf 'screenshots: PR #%s\n\nSigned-off-by: %s\n' "$PR" "$SIGNOFF" | + git commit-tree "$TREE" ${PARENT_ARGS[@]+"${PARENT_ARGS[@]}"} ${SIGNING_ARGS[@]+"${SIGNING_ARGS[@]}"} -F -) +# A concurrent publisher must fail safely; never rewrite the screenshot branch. +git push "$REMOTE" "${COMMIT}:refs/heads/${BRANCH}" RAW_BASE="https://raw.githubusercontent.com/${REPO}/${COMMIT}" @@ -70,8 +115,6 @@ for i in "${!PNGS[@]}"; do done if [[ -n "$BODY_FILE" ]]; then - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - "$SCRIPT_DIR/check-pr-image-urls.sh" "$BODY_FILE" COMMENT_BODY="$(cat "$BODY_FILE")" UNREFERENCED=() for i in "${!IMAGE_NAMES[@]}"; do