From 85b7331784804fbf9bb07df4199d4dc6a2b42ade Mon Sep 17 00:00:00 2001 From: Ian Oberst Date: Tue, 13 Oct 2026 13:57:54 -0700 Subject: [PATCH 01/11] fix(relay): partition audit observability follow-ups (#7994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!IMPORTANT] > **The alert rules changed in c3a314e71.** If you reviewed the alert guidance earlier, please look again. > - **The two range-based rules now apply only to live pods.** A range selector such as `[30m]` keeps reading a terminated pod's samples until they leave the window. So `BuzzPartitionAuditNotRunning` could fire for about 10 minutes after normal pod turnover, and `BuzzPartitionAuditFailing` could keep firing for up to an hour after the pod was gone. Each rule now adds `and `, which requires the counter to be exported right now: > ```yaml > expr: | > sum without (outcome) (increase(buzz_partition_audit_runs_total[30m])) == 0 > and sum without (outcome) (buzz_partition_audit_runs_total) > ``` > ```yaml > expr: | > increase(buzz_partition_audit_failures_total[1h]) >= 2 > and buzz_partition_audit_failures_total > ``` > - **The audit counters start at 0.** When the relay starts, it exports every `buzz_partition_audit_runs_total{table, outcome}` series and `buzz_partition_audit_failures_total` at 0, before the first audit. `increase()` ignores a new series' first sample. Before this change, it could miss the first periodic failure, or read a table's first `degraded` run as a stopped loop. This change is also what makes the `and ` gate work: the counters now exist for a relay's whole life. > > The doc's old claim that pod turnover can't leave an alert firing was true only of the instant-selector stale rule. The doc now explains the difference. ## Why These are the observability follow-ups deferred from #6515 ("fix(db): audit partition catalog before creation"): - **Timestamp-age alerts go silent** ([review comment](https://github.com/block/buzz/pull/6515#discussion_r4127212141)). Partition gauges are set only when a table audits successfully, and the exporter evicts idle gauges after 2,700 s by default. After sustained failures, `buzz_partition_audit_last_success_timestamp_seconds` disappears. A table that has never audited successfully never has it. An alert on `time() - last_success` then has no input. - **Startup fallback failures are not counted** ([review comment](https://github.com/block/buzz/pull/6515#discussion_r4127212158)). When the startup fallback audit fails, it logs an error but does not increment `buzz_partition_audit_failures_total`. Periodic failures do. - **Repaired environments are always `degraded`**. `PartitionTableAudit::degraded()` counts every legacy leaf, even one wholly in the past. The `bb-block-staging` audit of the repaired catalog reports `degraded` only because of `events_p2026_07_08_legacy` `[2026-07-01, 2026-09-01)`. With this rule, every repaired environment stays `degraded`, so nobody can alert on the outcome. - **Partition config tests can see a poisoned env** ([review comment](https://github.com/block/buzz/pull/6515#discussion_r4137416297)). Two partition config tests take only `ENV_MUTEX`. A NIP-FI test running in the same process can make them see its deliberately invalid environment. Partition health is still diagnostic only, through `/_status.partition_catalog` and metrics. Nothing here affects `/_readiness`. Handling concurrent creation (#6515 item 4) is out of scope and stays with the catch-all advancement work. ## What - **Success timestamp survives failures.** Each failed table audit refreshes `buzz_partition_audit_last_success_timestamp_seconds` with `increment(0.0)`, which leaves the value unchanged. It still resets the exporter's idle tracking, the same mechanism as `refresh_legacy_active_gauge_recency`. Sustained failures therefore keep the last success exported and its age rising. A table that has never succeeded exports `0`. A failed audit still never makes the last success look fresh. - **Alert guidance.** New `docs/partition-catalog-monitoring.md` covers the metrics, how long each series lives, and the audit outcomes. It notes that `runs_total` has no `unsafe` outcome. It gives three alert rules: - timestamp age, which also covers never-successful tables and sustained failures; - `sum without (outcome) (increase(buzz_partition_audit_runs_total[30m])) == 0` for a stopped audit loop. Counters are never idle-evicted, and this rule fires before the stopped loop's timestamp is evicted, so there is no false resolve; - an aggregate failure-counter rule. Both range-based rules also require their counter to be exported right now (`and `), so a terminated pod's leftover samples can't raise or hold an alert. - **Zero counter baselines.** `try_install` exports each `buzz_partition_audit_runs_total{table, outcome}` series (`ok`, `degraded`, and `error` for both tables) and `buzz_partition_audit_failures_total` at 0 before the startup audit, the same way the auth series already start at 0. A failure in the startup audit itself can still happen before the first scrape. The doc says so; the stale rule covers that case. - **Failure counter.** `buzz_partition_audit_failures_total` now counts failed explicit audit attempts: the startup fallback audit and each periodic audit. Both paths go through one helper, `counted_partition_audit`. A failed `ensure_future_partitions` call, such as a DDL error that the fallback then recovers, is not counted by itself. A single attempt never counts twice. - **Historical legacy leaves.** A legacy leaf is historical when its finite upper bound is at or before the current month start. It is still reported under `children` as `legacy_leaf`, but it no longer degrades the table. A legacy leaf that still covers the current month or any lookahead month stays degraded. All other degradation causes are unchanged. The verdict now lives in `PartitionAuditReport::outcome()`, which `buzz-admin partition-audit` uses. The JSON outcome names and exit codes are the same as before. - **Test hygiene.** Both partition config tests now use `env_guards()`. ## How it was tested - `cargo fmt --all -- --check` and `cargo clippy -p buzz-db -p buzz-relay -p buzz-admin --all-targets -- -D warnings`. - New unit tests: - The repaired incident layout with an injected clock: `degraded` in August 2026 and `ok` on 2026-09-01T00:00Z, when the upper bound equals the current month start, and on 2026-09-15. The legacy leaf is still in the serialized report. The `outcome` label on the run counter follows the verdict. - A table test of the legacy-leaf predicate: the upper bound before, at, and one second after the month start; lookahead and beyond-horizon leaves; historical leaves with trigger drift; anomalous and `DEFAULT` children; no audited month. - Outcome precedence (error, then unsafe, then degraded) and the `buzz-admin` exit-code mapping. - Startup: a failed fallback counts once, a successful fallback after a DDL failure counts nothing, and a successful ensure never runs the fallback. Each periodic failure counts once. Against an unreachable database, startup counts one aggregate failure while each table still records two `error` runs. - On the production exporter, never-successful audits leave run counters and a `0` success timestamp for both tables. - On the production exporter, all six run-counter series and the failure counter read 0 before any audit. After one failed audit, `error` reads 1 and `ok`/`degraded` stay at 0. Removing the initialization fails this test. - PostgreSQL lane (`scripts/postgres-test-run.sh`) with new or extended tests: - The repaired layout is not degraded in September. The same catalog is degraded in August. - With a 1 s idle timeout, a failed audit leaves the success timestamp unchanged, and sustained failures keep it exported past the idle timeout. When audits stop, the timestamp is evicted but the run counters remain. The next success exports a fresh timestamp. - Mutation check: removing the failure-path refresh fails both alert fixtures. - These fixtures check the emitted series. They do not evaluate the rules themselves, including the `and ` gate. `promtool` isn't available locally or in CI, and the doc points to `promtool test rules` for that. - `scripts/test-postgres-test-discovery.sh`. - `config::` and `nip_fi_config::` tests run together in one process five times. ## Related This PR conflicts textually with #7993 (catch-all advancement) in `crates/buzz-db/src/store/partition.rs` and `crates/buzz-relay/src/main.rs`. Whichever PR merges second will be rebased onto the other. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Ian Oberst Co-authored-by: Ian Oberst Co-authored-by: Claude Opus 5.5 --- Justfile | 12 +- crates/buzz-admin/src/main.rs | 45 +- crates/buzz-db/src/store/partition.rs | 457 +++++++++++++++++- crates/buzz-relay/src/config.rs | 4 +- crates/buzz-relay/src/main.rs | 229 +++++++-- crates/buzz-relay/src/metrics.rs | 172 +++++++ crates/buzz-relay/src/state.rs | 8 +- docs/partition-catalog-monitoring.md | 150 ++++++ ...artition-catalog-monitoring.rules.test.yml | 122 +++++ docs/partition-catalog-monitoring.rules.yml | 20 + scripts/run-tests.sh | 6 + 11 files changed, 1156 insertions(+), 69 deletions(-) create mode 100644 docs/partition-catalog-monitoring.md create mode 100644 docs/partition-catalog-monitoring.rules.test.yml create mode 100644 docs/partition-catalog-monitoring.rules.yml diff --git a/Justfile b/Justfile index 8d107af69ee..d73a9cc5470 100644 --- a/Justfile +++ b/Justfile @@ -448,10 +448,11 @@ test-unit: # suites are otherwise absent from the infra-free unit lane. cargo nextest run -p buzz-media --lib \ -E 'test(=bucket_index::tests::bucket_snapshot_json_round_trip_preserves_community_keys)' - # buzz-admin: storage-snapshot worker plus the community archive - # command parser, validation, and propagation-evidence tests. + # buzz-admin: storage-snapshot worker, the community archive command + # parser, validation, and propagation-evidence tests, and the partition + # audit outcome contract. cargo nextest run -p buzz-admin \ - -E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/)' + -E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes)' # Multi-tenant conformance gate (buzz-conformance): the independent # replay checker + golden fixtures. No infra — pure in-process trace # replay — so it belongs in the unit job. Run all targets (lib + the @@ -539,7 +540,9 @@ test-unit: # DB-backed audio join tests use #[ignore] and run in the postgres lane. # NIP-FI (S3/S4) relay witnesses: the wholly-new nip_fi_upgrade and # api::nip_fi modules, - # the auth metrics contract module, plus the exact NIP-FI tests added, + # the auth metrics contract module, the DB-free partition alert series + # tests (their Postgres case is #[ignore]d and runs in the PostgreSQL + # lane), plus the exact NIP-FI tests added, # or whose assertions changed, in mixed modules (audio::room, # connection, handlers::*, state). nip_fi_config and router are # selected whole by the command above. Mixed modules are listed by exact @@ -550,6 +553,7 @@ test-unit: cargo nextest run -p buzz-relay --lib -E ' test(/^nip_fi_upgrade::/) + test(/^metrics::contract_tests::/) + + test(/^metrics::partition_alert_tests::/) + test(=audio::room::tests::roster_revisions_are_ordered_and_snapshot_is_authoritative) + test(=connection::tests::auth_lifecycle_reconciles_every_terminal_and_never_leaks_gauge) + test(=audio::room::tests::b1_pending_peer_removed_before_commit_emits_no_delta) diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 66780e20419..33fc74b6d1d 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -31,7 +31,10 @@ use std::time::Instant; use anyhow::Result; use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST; use buzz_core::tenant::{relay_url_authority, TenantContext}; -use buzz_db::{partition::PartitionAuditReport, Db, DbConfig}; +use buzz_db::{ + partition::{PartitionAuditOutcome, PartitionAuditReport}, + Db, DbConfig, +}; use buzz_media::{BucketSnapshot, MediaConfig, MediaStorage, S3AddressingStyle, SweepError}; use buzz_pubsub::{EventTopic, PubSubManager}; use clap::{Parser, Subcommand}; @@ -358,7 +361,7 @@ struct PartitionAuditOutput { source_sha: &'static str, build_id: &'static str, build_url: &'static str, - outcome: &'static str, + outcome: PartitionAuditOutcome, months_ahead: u32, identity: PartitionAuditIdentity, report: PartitionAuditReport, @@ -399,20 +402,8 @@ async fn cmd_partition_audit(months_ahead: u32) -> Result { let report = Db::from_pool(pool) .audit_partitions_report(months_ahead) .await; - let outcome = if !report.errors.is_empty() { - "error" - } else if !report.serving_safe() { - "unsafe" - } else if report.tables.iter().any(|table| table.degraded()) { - "degraded" - } else { - "ok" - }; - let code = match outcome { - "error" => 5, - "unsafe" => 2, - _ => 0, - }; + let outcome = report.outcome(); + let code = partition_audit_exit_code(outcome); println!( "{}", serde_json::to_string_pretty(&PartitionAuditOutput { @@ -430,6 +421,15 @@ async fn cmd_partition_audit(months_ahead: u32) -> Result { Ok(code) } +/// Exit nonzero only when the audit is incomplete or a table cannot serve. +fn partition_audit_exit_code(outcome: PartitionAuditOutcome) -> i32 { + match outcome { + PartitionAuditOutcome::Error => 5, + PartitionAuditOutcome::Unsafe => 2, + PartitionAuditOutcome::Degraded | PartitionAuditOutcome::Ok => 0, + } +} + async fn cmd_add_member(pubkey_arg: String, role: String) -> Result { if let Err(msg) = validate_role(&role) { eprintln!("error: {msg}"); @@ -1090,4 +1090,17 @@ mod tests { assert!(command.is_err()); } + + #[test] + fn partition_audit_outcomes_keep_their_json_names_and_exit_codes() { + for (outcome, name, code) in [ + (PartitionAuditOutcome::Ok, "ok", 0), + (PartitionAuditOutcome::Degraded, "degraded", 0), + (PartitionAuditOutcome::Unsafe, "unsafe", 2), + (PartitionAuditOutcome::Error, "error", 5), + ] { + assert_eq!(serde_json::to_value(outcome).unwrap(), name); + assert_eq!(partition_audit_exit_code(outcome), code, "{name}"); + } + } } diff --git a/crates/buzz-db/src/store/partition.rs b/crates/buzz-db/src/store/partition.rs index 02dfa4e7087..8322313de3c 100644 --- a/crates/buzz-db/src/store/partition.rs +++ b/crates/buzz-db/src/store/partition.rs @@ -186,7 +186,7 @@ pub struct PartitionTableAudit { pub children: Vec, /// Cached effective bounds for every routable leaf in the catalog tree. pub coverage_leaves: Vec, - /// Coverage of the current month and the requested future months. + /// Coverage of the current month, first, and the requested future months. pub months: Vec, /// Whether a parseable routable leaf covers the audit timestamp. pub serving_safe: bool, @@ -234,15 +234,18 @@ impl PartitionTableAudit { } /// Whether the table is serving but has state requiring operator attention. + /// + /// A legacy leaf that ends at or before the current month start is + /// historical: it stays in `children` but no longer degrades the table. pub fn degraded(&self) -> bool { !self.partition_key_valid || self.children.iter().any(|child| { matches!( child.kind, - PartitionChildKind::LegacyLeaf - | PartitionChildKind::Default - | PartitionChildKind::Anomalous - ) || !child.missing_triggers.is_empty() + PartitionChildKind::Default | PartitionChildKind::Anomalous + ) || (child.kind == PartitionChildKind::LegacyLeaf + && !self.ends_by_current_month(child)) + || !child.missing_triggers.is_empty() || !child.extra_triggers.is_empty() || child.catch_all_nonempty == Some(true) || child.default_nonempty == Some(true) @@ -253,6 +256,18 @@ impl PartitionTableAudit { .any(|month| month.kind != MonthCoverageKind::CoveredByMonthly) } + /// Whether `child` has a finite upper bound at or before the audited month start. + /// + /// Without an audited month, no child is treated as historical. + fn ends_by_current_month(&self, child: &PartitionChildAudit) -> bool { + self.months.first().is_some_and(|current_month| { + matches!( + &child.upper, + Some(PartitionBound::Finite(upper)) if *upper <= current_month.start + ) + }) + } + /// Whether the catalog is understood well enough to authorize automatic DDL. fn structurally_safe_for_creation(&self) -> bool { self.partition_key_valid @@ -297,6 +312,20 @@ pub struct PartitionAuditReport { pub errors: Vec, } +/// Operator-facing verdict for a best-effort catalog audit report. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PartitionAuditOutcome { + /// Every managed parent is serving safely without operator attention. + Ok, + /// Every managed parent is serving safely, but one needs operator attention. + Degraded, + /// The report is complete, but a managed parent is not serving safely. + Unsafe, + /// At least one managed parent could not be audited. + Error, +} + impl PartitionAuditReport { /// Whether the report is complete and every managed parent is serving safely. pub fn serving_safe(&self) -> bool { @@ -305,6 +334,19 @@ impl PartitionAuditReport { && self.tables.iter().all(|table| table.serving_safe) } + /// Most severe verdict across audit errors, serving safety, and degradation. + pub fn outcome(&self) -> PartitionAuditOutcome { + if !self.errors.is_empty() { + PartitionAuditOutcome::Error + } else if !self.serving_safe() { + PartitionAuditOutcome::Unsafe + } else if self.tables.iter().any(PartitionTableAudit::degraded) { + PartitionAuditOutcome::Degraded + } else { + PartitionAuditOutcome::Ok + } + } + fn into_complete_audit(self) -> Result { if self.errors.is_empty() { Ok(PartitionAudit { @@ -421,6 +463,7 @@ async fn audit_partition_catalog_report_at_with_timeout( "outcome" => "error" ) .increment(1); + refresh_last_success_recency(table); metrics::histogram!( "buzz_partition_audit_duration_seconds", "table" => table @@ -438,6 +481,7 @@ async fn audit_partition_catalog_report_at_with_timeout( "outcome" => "error" ) .increment(1); + refresh_last_success_recency(table); metrics::histogram!( "buzz_partition_audit_duration_seconds", "table" => table @@ -923,17 +967,7 @@ async fn audit_table_on( .map(|leaf| leaf.audit) .collect::>(); - let mut months = Vec::with_capacity(months_ahead as usize + 1); - for offset in 0..=months_ahead { - let (year, month) = add_months(now.year(), now.month(), offset)?; - let start = month_start(year, month)?; - let (end_year, end_month) = add_months(year, month, 1)?; - let end = month_start(end_year, end_month)?; - months.push(MonthCoverage { - start, - kind: coverage_for_range(&coverage_leaves, &start, &end), - }); - } + let months = month_coverage(&coverage_leaves, months_ahead, now)?; let serving_safe = coverage_leaves .iter() @@ -951,6 +985,26 @@ async fn audit_table_on( }) } +/// Classify the month containing `now`, then each lookahead month, in order. +fn month_coverage( + coverage_leaves: &[PartitionLeafAudit], + months_ahead: i32, + now: DateTime, +) -> Result> { + let mut months = Vec::with_capacity(months_ahead as usize + 1); + for offset in 0..=months_ahead { + let (year, month) = add_months(now.year(), now.month(), offset)?; + let start = month_start(year, month)?; + let (end_year, end_month) = add_months(year, month, 1)?; + let end = month_start(end_year, end_month)?; + months.push(MonthCoverage { + start, + kind: coverage_for_range(coverage_leaves, &start, &end), + }); + } + Ok(months) +} + fn validated_months_ahead(months_ahead: u32) -> Result { if months_ahead > MAX_PARTITION_MONTHS_AHEAD { return Err(DbError::InvalidData(format!( @@ -987,6 +1041,35 @@ fn qualified_relation_name(schema: &str, relation: &str) -> String { ) } +/// Export every `buzz_partition_audit_runs_total` series at zero. +/// +/// Prometheus `increase()` ignores the first sample of a new series, so an +/// outcome first seen mid-run would otherwise be invisible to rate-based +/// alerts. Call once at process start, before the first audit. +pub fn initialize_audit_metric_series() { + for &table in PARTITIONED_TABLES { + for outcome in ["ok", "degraded", "error"] { + metrics::counter!( + "buzz_partition_audit_runs_total", + "table" => table, + "outcome" => outcome + ) + .increment(0); + } + } +} + +/// Keep a table's last success timestamp exported through failed audits +/// without changing it, so the exporter's idle timeout cannot evict it. A +/// table that never succeeded exports 0, which reads as maximally stale. +fn refresh_last_success_recency(table: &'static str) { + metrics::gauge!( + "buzz_partition_audit_last_success_timestamp_seconds", + "table" => table + ) + .increment(0.0); +} + fn emit_audit_metrics(audit: &PartitionTableAudit, duration_seconds: f64, now: DateTime) { let outcome = if audit.degraded() { "degraded" } else { "ok" }; let uncovered = audit @@ -1788,6 +1871,335 @@ mod tests { assert_eq!(extra, Some(1.0)); } + fn month_bound(year: i32, month: u32) -> PartitionBound { + PartitionBound::Finite(Utc.with_ymd_and_hms(year, month, 1, 0, 0, 0).unwrap()) + } + + fn child_audit( + name: &str, + kind: PartitionChildKind, + lower: PartitionBound, + upper: PartitionBound, + ) -> PartitionChildAudit { + PartitionChildAudit { + schema: "public".to_string(), + name: name.to_string(), + relation_kind: "r".to_string(), + catch_all_nonempty: (upper == PartitionBound::MaxValue).then_some(false), + lower: Some(lower), + upper: Some(upper), + kind, + pending_detach: false, + default_nonempty: None, + missing_triggers: Vec::new(), + extra_triggers: Vec::new(), + } + } + + /// The repaired incident catalog, classified and covered by production code at `now`. + fn repaired_incident_audit(table: &'static str, now: DateTime) -> PartitionTableAudit { + let mut bounds = vec![ + ( + format!("{table}_p_past"), + PartitionBound::MinValue, + month_bound(2026, 7), + ), + ( + format!("{table}_p2026_07_08_legacy"), + month_bound(2026, 7), + month_bound(2026, 9), + ), + ]; + for month in 9..=12 { + let (end_year, end_month) = add_months(2026, month, 1).unwrap(); + bounds.push(( + format!("{table}_p2026_{month:02}"), + month_bound(2026, month), + month_bound(end_year, end_month), + )); + } + bounds.push(( + format!("{table}_p_future_next"), + month_bound(2027, 1), + PartitionBound::MaxValue, + )); + let children = bounds + .iter() + .map(|(name, lower, upper)| { + let kind = classify_child("r", table, name, Some(lower), Some(upper)); + child_audit(name, kind, lower.clone(), upper.clone()) + }) + .collect::>(); + let coverage_leaves = bounds + .into_iter() + .map(|(name, lower, upper)| PartitionLeafAudit { + schema: "public".to_string(), + root_child_schema: "public".to_string(), + root_child: name.clone(), + name, + catch_all_nonempty: (upper == PartitionBound::MaxValue).then_some(false), + lower, + upper, + nested: false, + is_default: false, + default_routed: false, + routing_exclusions: Vec::new(), + default_nonempty: None, + }) + .collect::>(); + let months = month_coverage(&coverage_leaves, 3, now).expect("month coverage"); + let serving_safe = coverage_leaves + .iter() + .any(|leaf| leaf_covers_timestamp(leaf, &now)); + PartitionTableAudit { + table, + partition_key: expected_partition_key(table).map(str::to_string), + expected_partition_key: expected_partition_key(table).unwrap(), + partition_key_valid: true, + children, + coverage_leaves, + months, + serving_safe, + } + } + + #[test] + fn repaired_incident_layout_is_historical_once_its_months_have_passed() { + for (now, degraded, outcome) in [ + ( + Utc.with_ymd_and_hms(2026, 8, 15, 12, 0, 0).unwrap(), + true, + PartitionAuditOutcome::Degraded, + ), + ( + // The legacy upper bound equals the current month start. + Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap(), + false, + PartitionAuditOutcome::Ok, + ), + ( + Utc.with_ymd_and_hms(2026, 9, 15, 12, 0, 0).unwrap(), + false, + PartitionAuditOutcome::Ok, + ), + ] { + let report = PartitionAuditReport { + audited_at: now, + tables: PARTITIONED_TABLES + .iter() + .map(|table| repaired_incident_audit(table, now)) + .collect(), + errors: Vec::new(), + }; + assert!(report.serving_safe(), "{now}"); + assert_eq!(report.outcome(), outcome, "{now}"); + for table in &report.tables { + assert_eq!(table.degraded(), degraded, "{} at {now}", table.table); + assert!(table + .months + .iter() + .all(|month| month.kind == MonthCoverageKind::CoveredByMonthly)); + // Historical leaves stay visible to operators. + assert_eq!( + table + .children + .iter() + .filter(|child| child.kind == PartitionChildKind::LegacyLeaf) + .map(|child| child.name.as_str()) + .collect::>(), + [format!("{}_p2026_07_08_legacy", table.table)] + ); + } + let json = serde_json::to_value(&report).expect("serialize report"); + assert!(json["tables"][0]["children"] + .as_array() + .expect("children") + .iter() + .any(|child| child["name"] == "events_p2026_07_08_legacy" + && child["kind"] == "legacy_leaf")); + + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + metrics::with_local_recorder(&recorder, || { + emit_audit_metrics(&report.tables[0], 0.01, now) + }); + let run_outcome = snapshotter + .snapshot() + .into_vec() + .into_iter() + .find_map(|(key, _, _, _)| { + (key.key().name() == "buzz_partition_audit_runs_total").then(|| { + key.key() + .labels() + .find(|label| label.key() == "outcome") + .map(|label| label.value().to_string()) + }) + }) + .flatten(); + let expected = if degraded { "degraded" } else { "ok" }; + assert_eq!(run_outcome.as_deref(), Some(expected), "{now}"); + } + } + + #[test] + fn report_outcome_ranks_errors_then_unsafe_then_degraded() { + let august = Utc.with_ymd_and_hms(2026, 8, 15, 12, 0, 0).unwrap(); + let degraded_tables = || { + PARTITIONED_TABLES + .iter() + .map(|table| repaired_incident_audit(table, august)) + .collect::>() + }; + let mut incomplete = degraded_tables(); + incomplete.pop(); + let error = PartitionAuditError { + table: "delivery_log", + error: "statement timeout".to_string(), + }; + for (tables, errors, outcome) in [ + ( + degraded_tables(), + Vec::new(), + PartitionAuditOutcome::Degraded, + ), + ( + incomplete.clone(), + Vec::new(), + PartitionAuditOutcome::Unsafe, + ), + (incomplete, vec![error], PartitionAuditOutcome::Error), + ] { + let report = PartitionAuditReport { + audited_at: august, + tables, + errors, + }; + assert_eq!(report.outcome(), outcome); + } + } + + #[test] + fn legacy_leaf_degrades_until_its_upper_bound_reaches_the_current_month() { + let now = Utc.with_ymd_and_hms(2026, 9, 15, 12, 0, 0).unwrap(); + let september = Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap(); + let historical = || { + child_audit( + "events_legacy", + PartitionChildKind::LegacyLeaf, + month_bound(2026, 6), + month_bound(2026, 8), + ) + }; + let with = |child: PartitionChildAudit| vec![child]; + let mut missing_trigger = historical(); + missing_trigger.missing_triggers = vec!["partition_probe".to_string()]; + let mut extra_trigger = historical(); + extra_trigger.extra_triggers = vec!["child_only_probe".to_string()]; + let mut anomalous = historical(); + anomalous.kind = PartitionChildKind::Anomalous; + let mut default = historical(); + default.kind = PartitionChildKind::Default; + let cases = [ + ( + "ends before the current month", + with(historical()), + 3, + false, + ), + ( + "ends exactly at the current month start", + with(child_audit( + "events_legacy", + PartitionChildKind::LegacyLeaf, + month_bound(2026, 7), + month_bound(2026, 9), + )), + 3, + false, + ), + ( + "ends one second into the current month", + with(child_audit( + "events_legacy", + PartitionChildKind::LegacyLeaf, + month_bound(2026, 7), + PartitionBound::Finite(september + chrono::Duration::seconds(1)), + )), + 3, + true, + ), + ( + "covers lookahead months", + with(child_audit( + "events_legacy", + PartitionChildKind::LegacyLeaf, + month_bound(2026, 10), + month_bound(2026, 12), + )), + 3, + true, + ), + ( + "starts beyond the lookahead", + with(child_audit( + "events_legacy", + PartitionChildKind::LegacyLeaf, + month_bound(2027, 6), + month_bound(2027, 8), + )), + 3, + true, + ), + ( + "historical but missing a trigger", + with(missing_trigger), + 3, + true, + ), + ( + "historical but has an extra trigger", + with(extra_trigger), + 3, + true, + ), + ( + "historical bounds on an anomalous child", + with(anomalous), + 3, + true, + ), + ( + "historical bounds on a default child", + with(default), + 3, + true, + ), + ("no audited month to compare", with(historical()), -1, true), + ]; + for (case, children, months_ahead, degraded) in cases { + let months = (0..=months_ahead) + .map(|offset| { + let (year, month) = add_months(2026, 9, offset).unwrap(); + MonthCoverage { + start: month_start(year, month).unwrap(), + kind: MonthCoverageKind::CoveredByMonthly, + } + }) + .collect(); + let audit = PartitionTableAudit { + table: "events", + partition_key: Some("RANGE (created_at)".to_string()), + expected_partition_key: "RANGE (created_at)", + partition_key_valid: true, + children, + coverage_leaves: Vec::new(), + months, + serving_safe: true, + }; + assert_eq!(audit.degraded(), degraded, "{case} at {now}"); + } + } + pub(super) mod postgres_tests { use sqlx::postgres::PgPoolOptions; use uuid::Uuid; @@ -2480,7 +2892,20 @@ mod tests { .months .iter() .all(|month| month.kind == MonthCoverageKind::CoveredByMonthly)); + assert!( + !table.degraded(), + "{} is historical in September", + table.table + ); } + + let september = audit_partition_catalog_report_at(&pool, 3, fixed_now()).await; + assert_eq!(september.outcome(), PartitionAuditOutcome::Ok); + let august = Utc.with_ymd_and_hms(2026, 8, 15, 12, 0, 0).unwrap(); + let august = audit_partition_catalog_report_at(&pool, 3, august).await; + assert!(august.serving_safe()); + assert!(august.tables.iter().all(PartitionTableAudit::degraded)); + assert_eq!(august.outcome(), PartitionAuditOutcome::Degraded); assert_eq!(catalog_snapshot(&pool).await, before); drop_schema(&admin, &schema).await; } diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 44662bb9e10..d5c3cf60a2f 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -2022,7 +2022,7 @@ mod tests { #[test] fn partition_manager_config_has_bounded_interval_and_create_kill_switch() { - let _guard = ENV_MUTEX.lock().unwrap(); + let _guards = env_guards(); let previous_interval = std::env::var_os("BUZZ_PARTITION_AUDIT_INTERVAL_SECS"); let previous_create = std::env::var_os("BUZZ_PARTITION_MANAGER_CREATE_ENABLED"); @@ -2054,7 +2054,7 @@ mod tests { #[test] fn partition_manager_create_kill_switch_parses_false_values_strictly() { - let _guard = ENV_MUTEX.lock().unwrap(); + let _guards = env_guards(); let previous = std::env::var_os("BUZZ_PARTITION_MANAGER_CREATE_ENABLED"); for value in ["FALSE", "off", " false "] { diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index e42c6a49756..4ed127dfffa 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -12,7 +12,10 @@ use uuid::Uuid; use buzz_audit::AuditService; use buzz_auth::AuthService; use buzz_core::CommunityId; -use buzz_db::{partition::PARTITION_MANAGER_MONTHS_AHEAD, Db, DbConfig}; +use buzz_db::{ + partition::{PartitionAudit, PARTITION_MANAGER_MONTHS_AHEAD}, + Db, DbConfig, +}; use buzz_pubsub::PubSubManager; use buzz_search::SearchService; @@ -249,6 +252,49 @@ impl FleetUsageSchedule { const USAGE_METRICS_LOCK_KEY: i64 = 0x4255_5A5A_4D45_5452; +/// Run one explicit partition catalog audit attempt. +/// +/// `buzz_partition_audit_failures_total` counts failed explicit attempts: the +/// startup fallback audit and each periodic audit, or the periodic fallback +/// audit when advancement runs periodic maintenance. The audit inside +/// `maintain_partitions` is not an explicit attempt; its failure is counted +/// only through the fallback that follows it. +async fn counted_partition_audit( + attempt: impl std::future::Future>, +) -> buzz_db::Result { + let result = attempt.await; + if result.is_err() { + metrics::counter!("buzz_partition_audit_failures_total").increment(1); + } + result +} + +/// Maintain startup partitions, falling back to a read-only audit on failure. +/// +/// A maintenance failure alone never counts as an audit failure; a failed +/// fallback audit counts once. +async fn startup_partition_audit( + maintain: impl std::future::Future>, + fallback_audit: impl FnOnce() -> F, +) -> Option +where + F: std::future::Future>, +{ + match maintain.await { + Ok(audit) => Some(audit), + Err(error) => { + error!(%error, "Failed to ensure partitions"); + match counted_partition_audit(fallback_audit()).await { + Ok(audit) => Some(audit), + Err(error) => { + error!(%error, "Initial partition catalog audit failed"); + None + } + } + } + } +} + /// Retry missing diagnostics promptly, without tying audit recovery to readiness. /// Delays start after each attempt finishes, so slow audits cannot cause bursts. struct PartitionAuditSchedule { @@ -486,23 +532,15 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { advance_enabled: config.partition_manager_advance_enabled, }; let mut step = StepTimer::start(StartupStep::PartitionEnsure); - let startup_partition_audit = match db - .maintain_partitions(PARTITION_MANAGER_MONTHS_AHEAD, partition_policy) - .await - { - Ok(audit) => Some(audit), - Err(error) => { - step.degrade(); - error!(%error, "Failed to ensure partitions"); - match db.audit_partitions(PARTITION_MANAGER_MONTHS_AHEAD).await { - Ok(audit) => Some(audit), - Err(error) => { - error!(%error, "Initial partition catalog audit failed"); - None - } - } - } - }; + let startup_partition_audit = startup_partition_audit( + async { + db.maintain_partitions(PARTITION_MANAGER_MONTHS_AHEAD, partition_policy) + .await + .inspect_err(|_| step.degrade()) + }, + || db.audit_partitions(PARTITION_MANAGER_MONTHS_AHEAD), + ) + .await; step.finish(); let step = StepTimer::start(StartupStep::DeletionFenceVerify); @@ -837,19 +875,20 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { Ok(audit) => Ok(audit), Err(error) => { warn!(%error, "Periodic partition maintenance failed"); - db.audit_partitions(PARTITION_MANAGER_MONTHS_AHEAD).await + counted_partition_audit( + db.audit_partitions(PARTITION_MANAGER_MONTHS_AHEAD), + ) + .await } } } else { - db.audit_partitions(PARTITION_MANAGER_MONTHS_AHEAD).await + counted_partition_audit(db.audit_partitions(PARTITION_MANAGER_MONTHS_AHEAD)) + .await }; schedule.record_attempt(result.is_ok()); match result { Ok(audit) => partition_state.record_partition_audit(audit), - Err(error) => { - metrics::counter!("buzz_partition_audit_failures_total").increment(1); - warn!(%error, "Periodic partition catalog audit failed") - } + Err(error) => warn!(%error, "Periodic partition catalog audit failed"), } } }); @@ -3030,12 +3069,12 @@ mod tests { use super::{ apply_activity_refresh, apply_stock_refresh, buzz_auto_migrate_enabled, connect_audit_pool, - dropped_in_memory_keys, emit_cached_fleet_usage_metrics, idle_timeout_secs, - jwks_next_retry_after_failed_refresh, nip_fi_jwks_refresh_loop, + counted_partition_audit, dropped_in_memory_keys, emit_cached_fleet_usage_metrics, + idle_timeout_secs, jwks_next_retry_after_failed_refresh, nip_fi_jwks_refresh_loop, record_legacy_usage_collection, refresh_due_fleet_usage, refresh_legacy_active_gauge_recency, relay_keypair_from_config, - run_jwks_refresh_supervisor, run_periodic_until_cancelled, EmissionScope, - FleetUsageSchedule, InMemoryMetricKey, PartitionAuditSchedule, + run_jwks_refresh_supervisor, run_periodic_until_cancelled, startup_partition_audit, + EmissionScope, FleetUsageSchedule, InMemoryMetricKey, PartitionAuditSchedule, FLEET_ACTIVITY_COLLECTION_DEADLINE, FLEET_STOCK_COLLECTION_DEADLINE, }; use buzz_db::DbConfig; @@ -3204,6 +3243,140 @@ mod tests { ); } + fn partition_audit_ok() -> buzz_db::Result { + Ok(buzz_db::partition::PartitionAudit { + audited_at: chrono::Utc::now(), + tables: Vec::new(), + }) + } + + fn partition_audit_err(detail: &str) -> buzz_db::Result { + Err(buzz_db::DbError::InvalidData(detail.to_string())) + } + + /// One recorded counter: name, labels, and value. + type CounterSample = (String, Vec<(String, String)>, u64); + + /// Drive `future` under a local recorder and return its counter values. + fn with_partition_counters( + future: F, + ) -> (F::Output, Vec) { + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let output = metrics::with_local_recorder(&recorder, || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread runtime") + .block_on(future) + }); + let counters = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter_map(|(key, _, _, value)| match value { + DebugValue::Counter(value) => Some(( + key.key().name().to_string(), + key.key() + .labels() + .map(|label| (label.key().to_string(), label.value().to_string())) + .collect(), + value, + )), + _ => None, + }) + .collect(); + (output, counters) + } + + fn aggregate_audit_failures(counters: &[CounterSample]) -> u64 { + counters + .iter() + .filter(|(name, _, _)| name == "buzz_partition_audit_failures_total") + .map(|(_, _, value)| value) + .sum() + } + + #[test] + fn startup_partition_audit_counts_only_a_failed_fallback_once() { + let (audit, counters) = with_partition_counters(startup_partition_audit( + async { partition_audit_err("partition creation failed") }, + || async { partition_audit_err("catalog audit failed") }, + )); + assert!(audit.is_none()); + assert_eq!(aggregate_audit_failures(&counters), 1); + + // A DDL failure recovered by the fallback audit is not an audit failure. + let (audit, counters) = with_partition_counters(startup_partition_audit( + async { partition_audit_err("partition creation failed") }, + || async { partition_audit_ok() }, + )); + assert!(audit.is_some()); + assert_eq!(aggregate_audit_failures(&counters), 0); + + let (audit, counters) = with_partition_counters(startup_partition_audit( + async { partition_audit_ok() }, + || async { panic!("successful ensure must not run the fallback audit") }, + )); + assert!(audit.is_some()); + assert_eq!(aggregate_audit_failures(&counters), 0); + } + + #[test] + fn periodic_partition_audit_counts_each_failed_attempt() { + let (result, counters) = + with_partition_counters(counted_partition_audit(async { partition_audit_ok() })); + assert!(result.is_ok()); + assert_eq!(aggregate_audit_failures(&counters), 0); + + let ((), counters) = with_partition_counters(async { + for _ in 0..2 { + assert!( + counted_partition_audit(async { partition_audit_err("timeout") }) + .await + .is_err() + ); + } + }); + assert_eq!(aggregate_audit_failures(&counters), 2); + } + + #[test] + fn unreachable_database_startup_counts_one_aggregate_audit_failure() { + let (audit, counters) = with_partition_counters(async { + let pool = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(Duration::from_millis(200)) + .connect_lazy("postgres://127.0.0.1:1/buzz") + .expect("lazy test pool"); + let db = buzz_db::Db::from_pool(pool); + let months_ahead = buzz_db::partition::PARTITION_MANAGER_MONTHS_AHEAD; + let policy = buzz_db::partition::PartitionMaintenancePolicy { + create_enabled: true, + advance_enabled: false, + }; + startup_partition_audit(db.maintain_partitions(months_ahead, policy), || { + db.audit_partitions(months_ahead) + }) + .await + }); + assert!(audit.is_none()); + assert_eq!(aggregate_audit_failures(&counters), 1); + // Each per-table attempt, including the one inside maintenance, still has + // its own error outcome. + for table in ["events", "delivery_log"] { + let errors: u64 = counters + .iter() + .filter(|(name, labels, _)| { + name == "buzz_partition_audit_runs_total" + && labels.contains(&("table".to_string(), table.to_string())) + && labels.contains(&("outcome".to_string(), "error".to_string())) + }) + .map(|(_, _, value)| value) + .sum(); + assert_eq!(errors, 2, "{table}"); + } + } + #[test] fn buzz_auto_migrate_is_opt_in() { assert!(!buzz_auto_migrate_enabled(None)); diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index 3566c0c019f..67e4d3b35e2 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -299,6 +299,7 @@ pub fn try_install(port: u16, gauge_idle_timeout_secs: u64) -> Result<(), Metric describe_auth_metrics(); crate::startup_steps::describe_metrics(); initialize_auth_metric_series(); + initialize_partition_audit_metric_series(); tokio::spawn(exporter); Ok(()) } @@ -522,6 +523,13 @@ pub(crate) fn initialize_auth_metric_series() { metrics::gauge!("buzz_ws_authenticated_connections_active").set(0.0); } +/// Export the partition audit counters at zero before the first audit, so +/// `increase()`-based alerts count the first failure and every new outcome. +pub(crate) fn initialize_partition_audit_metric_series() { + buzz_db::partition::initialize_audit_metric_series(); + metrics::counter!("buzz_partition_audit_failures_total").increment(0); +} + /// Record one issued NIP-42 challenge after it enters the connection writer. /// /// Before this lifecycle contract, `buzz_auth_attempts_total` incremented only @@ -1161,3 +1169,167 @@ mod tests { ); } } + +/// Fixtures for the partition audit alerts in +/// `docs/partition-catalog-monitoring.md`: the production exporter configuration +/// and audit metric emission export the series those rules read. +#[cfg(test)] +mod partition_alert_tests { + use std::collections::BTreeMap; + use std::time::Duration; + + const RUNS: &str = "buzz_partition_audit_runs_total"; + const LAST_SUCCESS: &str = "buzz_partition_audit_last_success_timestamp_seconds"; + const MANAGED_TABLES: [&str; 2] = ["delivery_log", "events"]; + + /// Sample value by `table` label for every sample of `metric` in a scrape. + /// Counters with several `outcome` labels keep the last sample per table. + fn samples(scrape: &str, metric: &str) -> BTreeMap { + scrape + .lines() + .filter_map(|line| line.strip_prefix(metric)?.strip_prefix('{')) + .filter_map(|labels| { + let (_, rest) = labels.split_once(r#"table=""#)?; + let (table, rest) = rest.split_once('"')?; + let (_, value) = rest.rsplit_once(' ')?; + Some((table.to_string(), value.parse().expect("numeric sample"))) + }) + .collect() + } + + fn tables(scrape: &str, metric: &str) -> Vec { + samples(scrape, metric).into_keys().collect() + } + + fn managed_tables() -> Vec { + MANAGED_TABLES + .iter() + .map(|table| table.to_string()) + .collect() + } + + fn unreachable_db() -> buzz_db::Db { + let pool = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(Duration::from_millis(200)) + .connect_lazy("postgres://127.0.0.1:1/buzz") + .expect("lazy test pool"); + buzz_db::Db::from_pool(pool) + } + + fn current_thread_runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread runtime") + } + + #[test] + fn never_successful_audits_export_a_zero_success_timestamp() { + let (recorder, handle) = super::readiness_test_recorder_with_idle_timeout(1); + metrics::with_local_recorder(&recorder, || { + current_thread_runtime().block_on(async { + assert!(unreachable_db().audit_partitions(3).await.is_err()); + }); + }); + + let scrape = handle.render(); + assert_eq!(tables(&scrape, RUNS), managed_tables(), "{scrape}"); + let zero = MANAGED_TABLES + .iter() + .map(|table| (table.to_string(), 0.0)) + .collect::>(); + assert_eq!(samples(&scrape, LAST_SUCCESS), zero, "{scrape}"); + } + + #[test] + fn audit_counters_export_zero_baselines_before_the_first_audit() { + let (recorder, handle) = super::readiness_test_recorder_with_idle_timeout(1); + metrics::with_local_recorder(&recorder, super::initialize_partition_audit_metric_series); + + let scrape = handle.render(); + let zero_runs = scrape + .lines() + .filter(|line| line.starts_with(&format!("{RUNS}{{")) && line.ends_with(" 0")) + .count(); + assert_eq!(zero_runs, MANAGED_TABLES.len() * 3, "{scrape}"); + assert!( + scrape + .lines() + .any(|line| line == "buzz_partition_audit_failures_total 0"), + "{scrape}" + ); + + // A first failure then reads as an increase from the zero baseline. + metrics::with_local_recorder(&recorder, || { + current_thread_runtime().block_on(async { + assert!(unreachable_db().audit_partitions(3).await.is_err()); + }); + }); + let scrape = handle.render(); + for table in MANAGED_TABLES { + for (outcome, value) in [("ok", 0), ("degraded", 0), ("error", 1)] { + let line = scrape + .lines() + .find(|line| { + line.starts_with(&format!("{RUNS}{{")) + && line.contains(&format!(r#"table="{table}""#)) + && line.contains(&format!(r#"outcome="{outcome}""#)) + }) + .unwrap_or_else(|| panic!("{table}/{outcome} missing:\n{scrape}")); + assert!(line.ends_with(&format!(" {value}")), "{line}"); + } + } + } + + mod postgres_tests { + use super::*; + + #[test] + #[ignore = "requires Postgres"] + fn failed_audits_keep_the_last_success_past_the_idle_timeout() { + let idle_timeout = Duration::from_secs(1); + let (recorder, handle) = + crate::metrics::readiness_test_recorder_with_idle_timeout(idle_timeout.as_secs()); + let runtime = current_thread_runtime(); + let (working, failing) = metrics::with_local_recorder(&recorder, || { + runtime.block_on(async { + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect test database"); + let working = buzz_db::Db::from_pool(pool); + working.audit_partitions(3).await.expect("successful audit"); + (working, unreachable_db()) + }) + }); + + let scrape = handle.render(); + let first = samples(&scrape, LAST_SUCCESS); + assert_eq!(first.keys().cloned().collect::>(), managed_tables()); + + let audit = |db: &buzz_db::Db| { + metrics::with_local_recorder(&recorder, || { + runtime.block_on(async { db.audit_partitions(3).await.is_ok() }) + }) + }; + // A failed audit never makes the last success look fresh, and + // sustained failures keep it exported past the idle timeout, so its + // age keeps rising instead of the series disappearing. + assert!(!audit(&failing)); + assert_eq!(samples(&handle.render(), LAST_SUCCESS), first); + std::thread::sleep(idle_timeout + Duration::from_millis(250)); + assert!(!audit(&failing)); + assert_eq!(samples(&handle.render(), LAST_SUCCESS), first); + + // When audits stop running altogether the timestamp is evicted, + // but the run counters remain for the not-running rule. + std::thread::sleep(idle_timeout + Duration::from_millis(250)); + let scrape = handle.render(); + assert!(tables(&scrape, LAST_SUCCESS).is_empty(), "{scrape}"); + assert_eq!(tables(&scrape, RUNS), managed_tables(), "{scrape}"); + + // The next success exports a fresh timestamp again. + assert!(audit(&working)); + assert_eq!(tables(&handle.render(), LAST_SUCCESS), managed_tables()); + } + } +} diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index e762095ca32..7e015a5dc89 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1649,9 +1649,11 @@ impl AppState { /// Snapshot the last completed audit without accessing the database. /// - /// Periodic refresh failures retain the last-known-good audit by design; - /// operators should alert on staleness of - /// `buzz_partition_audit_last_success_timestamp_seconds`. + /// Periodic refresh failures retain the last-known-good audit by design. + /// Alert on the age of + /// `buzz_partition_audit_last_success_timestamp_seconds`, which failed + /// audits keep exported, and on audit runs stopping. See + /// `docs/partition-catalog-monitoring.md`. pub fn partition_audit_snapshot(&self) -> Option { let cached = match self.partition_audit.read() { Ok(cached) => cached, diff --git a/docs/partition-catalog-monitoring.md b/docs/partition-catalog-monitoring.md new file mode 100644 index 00000000000..8371403d934 --- /dev/null +++ b/docs/partition-catalog-monitoring.md @@ -0,0 +1,150 @@ +# Partition catalog monitoring + +The relay audits the `events` and `delivery_log` partition catalogs at startup +and every `BUZZ_PARTITION_AUDIT_INTERVAL_SECS` (default 900 seconds). Results are +diagnostic only. They appear in `/_status.partition_catalog` and in the metrics +below, and they never change `/_readiness` or `/_liveness`. Alert on them; do not +gate traffic on them. + +## Outcomes + +`buzz-admin partition-audit` prints one outcome for the whole catalog. + +| Outcome | Meaning | `buzz-admin` exit code | +|---|---|---| +| `ok` | Every managed table covers now and needs no operator attention. | 0 | +| `degraded` | Every table covers now, but one needs attention. | 0 | +| `unsafe` | A table cannot route a row timestamped now. | 2 | +| `error` | At least one table could not be audited. | 5 | + +The relay's `buzz_partition_audit_runs_total{outcome}` is per table and is only +`ok`, `degraded`, or `error`. There is no `unsafe` outcome there: a table that +cannot route a row timestamped now is `degraded` and has +`buzz_partition_serving_safe` 0. + +A table is `degraded` when it has a partition-key mismatch, a `DEFAULT` or +anomalous child, trigger drift, an occupied catch-all or `DEFAULT` leaf, or a +current or lookahead month not covered by a bounded leaf. A bounded legacy leaf +with a non-canonical name degrades the table while it covers the current month or +later. Once its upper bound is at or before the start of the current month, it is +historical. It remains in the per-child report as `legacy_leaf` but no longer +degrades the table. + +## Metrics + +Per-table gauges are set only by a successful audit of that table: +`buzz_partition_audit_last_success_timestamp_seconds`, +`buzz_partition_serving_safe`, `buzz_partition_uncovered_months`, +`buzz_partition_catch_all_covered_months`, `buzz_partition_default_covered_months`, +`buzz_partition_anomalous_children`, `buzz_partition_catch_all_nonempty`, +`buzz_partition_default_nonempty`, `buzz_partition_trigger_parity_missing`, and +`buzz_partition_trigger_parity_extra`. A failed audit leaves them unchanged, so a +failure never makes the last success look fresh. + +The exporter removes a gauge that has not been updated within its idle timeout. +The default is 2,700 seconds: three times the larger of the usage-metrics and +partition-audit intervals. A failed audit of a table refreshes that table's +`buzz_partition_audit_last_success_timestamp_seconds` without changing its +value, so sustained failures keep the last success exported and its age keeps +rising. A table that has never been audited successfully exports `0`. The +timestamp disappears only when audits stop running for longer than the idle +timeout; the other per-table gauges can be evicted during sustained failures. + +Counters are not idle-evicted while the process runs. The relay exports every +run outcome for both managed tables, and the failure counter, at 0 when its +metrics exporter starts, so `increase()` counts the first failure and the first +run with a new outcome: + +- `buzz_partition_audit_runs_total{table, outcome}` increments once per table + per audit. The outcome is `ok`, `degraded`, or `error`, including the opening + audit of each partition maintenance pass. +- `buzz_partition_audit_failures_total` counts failed explicit audit attempts: + the fallback audit that runs when a maintenance pass fails, and each periodic + audit when maintenance does not run. A maintenance failure is not counted by + itself. If the fallback audit then fails, that attempt counts once. +- `buzz_partition_create_attempts_total{table, outcome}` counts per-month + decisions in each maintenance pass: `created`, `error`, or `skipped_covered` + for a month that was already covered. Maintenance runs at startup, and on + every periodic audit when `BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED` is on, so + `skipped_covered` then grows on every pass in steady state. + +## Alerts + +Use all three rules below. Aggregate with `without (...)`, not `by (...)`, so +cluster, account, environment, namespace, and pod labels stay in the alert. + +When a pod terminates, its scrape target disappears and its series go stale. +Instant selectors drop a stale series at once, but range selectors such as +`[30m]` keep reading its earlier samples until they leave the window. Each +range-based rule therefore also requires the counter to be exported now, which +it is for the whole life of a relay, so a terminated pod cannot keep or start an +alert. + +`BuzzPartitionAuditNotRunning` also requires the run counter to have existed one +window ago. After a successful startup audit, the first periodic audit runs a +full interval later, so a new pod's counter stays flat until then and would +otherwise read as a stopped loop. + +```yaml +# The last success is older than two default audit intervals. This also fires +# for a table that has never audited successfully, whose timestamp is 0, and +# keeps firing through sustained failures. Scale 1800 with +# BUZZ_PARTITION_AUDIT_INTERVAL_SECS. +- alert: BuzzPartitionAuditStale + expr: time() - buzz_partition_audit_last_success_timestamp_seconds > 1800 + for: 10m + +# The relay is running but its audit loop has stopped. Use a window of about +# two audit intervals, and keep the window plus `for` below the gauge idle +# timeout (2,700 s by default), so this fires before the stopped loop's +# timestamp is evicted and the stale rule resolves. Use the same duration for +# the window and the offset. A loop that stops in a pod's first window is +# caught once the pod is a window old, at about 35 minutes. +- alert: BuzzPartitionAuditNotRunning + expr: | + sum without (outcome) (increase(buzz_partition_audit_runs_total[30m])) == 0 + and sum without (outcome) (buzz_partition_audit_runs_total) + and sum without (outcome) (buzz_partition_audit_runs_total offset 30m) + for: 5m + +# Explicit audit attempts keep failing, even if a later attempt succeeds. +- alert: BuzzPartitionAuditFailing + expr: | + increase(buzz_partition_audit_failures_total[1h]) >= 2 + and buzz_partition_audit_failures_total +``` + +The counters exist from relay start, and the success timestamp exists after the +first audit attempt, whether or not it succeeded. The startup audit runs within +seconds of the zero baselines, usually before the first scrape, so +`BuzzPartitionAuditFailing` can miss a failure in that one attempt; if it does +not recover, `BuzzPartitionAuditStale` fires. The run counters are exported +before the relay connects to the database, so a live relay that is stuck before +its first audit shows flat zero counters, which `BuzzPartitionAuditNotRunning` +catches. + +`degraded` asks for operator attention; it does not mean rows cannot be +written. Historical legacy leaves no longer count, so a repaired catalog +returns to `ok` once the current month passes the repaired range and every +current and lookahead month is a bounded monthly partition. Months still covered +by a catch-all stay `degraded` until advancement moves the catch-all. Coverage +problems show directly in `buzz_partition_serving_safe` and +`buzz_partition_uncovered_months`. + +The tests in `crates/buzz-relay/src/metrics.rs` (`partition_alert_tests`) check +that the production exporter and audit emit the series these rules read: zero +counter baselines before the first audit, a `0` timestamp before any success, +the last success kept through failures past the idle timeout, and run counters +that outlive an evicted timestamp. They do not +evaluate the rules, their `for` durations, or label matching. +`partition-catalog-monitoring.rules.test.yml` evaluates the rules in +`partition-catalog-monitoring.rules.yml`, including their `for` durations, +against a fresh pod, a stopped loop, a terminated pod, a table that never +succeeded, and repeated failures. CI does not run it. Keep the rules file +identical to the block above, and run it after changing either: + +```bash +promtool test rules docs/partition-catalog-monitoring.rules.test.yml +``` + +Then validate label matching against your own alerting configuration. diff --git a/docs/partition-catalog-monitoring.rules.test.yml b/docs/partition-catalog-monitoring.rules.test.yml new file mode 100644 index 00000000000..3a897c5b61b --- /dev/null +++ b/docs/partition-catalog-monitoring.rules.test.yml @@ -0,0 +1,122 @@ +# Run with: promtool test rules docs/partition-catalog-monitoring.rules.test.yml +# +# Series are sampled every minute and use the default 900 s audit interval. The +# startup audit finishes before the first scrape, so run counters start at 1. +rule_files: + - partition-catalog-monitoring.rules.yml + +evaluation_interval: 1m + +tests: + # A healthy new pod audits at startup and then every 15 minutes. Its counter + # is flat for the first interval, which must not read as a stopped loop. + - interval: 1m + input_series: + - series: 'buzz_partition_audit_runs_total{pod="fresh",table="events",outcome="ok"}' + values: '1x14 2x14 3x14 4x14 5x14' + - series: 'buzz_partition_audit_runs_total{pod="fresh",table="events",outcome="degraded"}' + values: '0x74' + - series: 'buzz_partition_audit_last_success_timestamp_seconds{pod="fresh",table="events"}' + values: '0x14 900x14 1800x14 2700x14 3600x14' + alert_rule_test: + - eval_time: 10m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 14m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 29m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 70m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 70m + alertname: BuzzPartitionAuditStale + exp_alerts: [] + + # The audit loop stops after the 15-minute audit while the pod keeps running. + # Both rules fire before the timestamp's 2,700 s idle eviction at 60 minutes. + - interval: 1m + input_series: + - series: 'buzz_partition_audit_runs_total{pod="stopped",table="events",outcome="ok"}' + values: '1x14 2x60' + - series: 'buzz_partition_audit_last_success_timestamp_seconds{pod="stopped",table="events"}' + values: '0x14 900x45' + alert_rule_test: + - eval_time: 40m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 52m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: + - exp_labels: {pod: stopped, table: events} + - eval_time: 57m + alertname: BuzzPartitionAuditStale + exp_alerts: + - exp_labels: {pod: stopped, table: events} + + # The audit loop stops right after the startup audit. It is caught once the + # pod is one window old. + - interval: 1m + input_series: + - series: 'buzz_partition_audit_runs_total{pod="early",table="events",outcome="ok"}' + values: '1x80' + alert_rule_test: + - eval_time: 33m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 36m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: + - exp_labels: {pod: early, table: events} + + # A pod terminates at 24 minutes. Range selectors still read its earlier + # samples, long enough to complete `for: 5m` at 50 minutes, but it must not + # start an alert. + - interval: 1m + input_series: + - series: 'buzz_partition_audit_runs_total{pod="gone",table="events",outcome="ok"}' + values: '1x14 2x9 stale' + - series: 'buzz_partition_audit_failures_total{pod="gone"}' + values: '0x10 1 2 3x7 stale' + alert_rule_test: + - eval_time: 45m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 52m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 60m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 30m + alertname: BuzzPartitionAuditFailing + exp_alerts: [] + + # A table that has never audited successfully exports a 0 timestamp. + - interval: 1m + input_series: + - series: 'buzz_partition_audit_last_success_timestamp_seconds{pod="never",table="events"}' + values: '0x60' + alert_rule_test: + - eval_time: 35m + alertname: BuzzPartitionAuditStale + exp_alerts: [] + - eval_time: 45m + alertname: BuzzPartitionAuditStale + exp_alerts: + - exp_labels: {pod: never, table: events} + + # One failure does not alert; a second within an hour does. + - interval: 1m + input_series: + - series: 'buzz_partition_audit_failures_total{pod="once"}' + values: '0x10 1x50' + - series: 'buzz_partition_audit_failures_total{pod="twice"}' + values: '0x10 1x10 2x40' + alert_rule_test: + - eval_time: 40m + alertname: BuzzPartitionAuditFailing + exp_alerts: + - exp_labels: {pod: twice} diff --git a/docs/partition-catalog-monitoring.rules.yml b/docs/partition-catalog-monitoring.rules.yml new file mode 100644 index 00000000000..cef7430109c --- /dev/null +++ b/docs/partition-catalog-monitoring.rules.yml @@ -0,0 +1,20 @@ +# Alert rules from docs/partition-catalog-monitoring.md. Keep them identical to +# the doc; partition-catalog-monitoring.rules.test.yml tests them. +groups: + - name: buzz-partition-catalog + rules: + - alert: BuzzPartitionAuditStale + expr: time() - buzz_partition_audit_last_success_timestamp_seconds > 1800 + for: 10m + + - alert: BuzzPartitionAuditNotRunning + expr: | + sum without (outcome) (increase(buzz_partition_audit_runs_total[30m])) == 0 + and sum without (outcome) (buzz_partition_audit_runs_total) + and sum without (outcome) (buzz_partition_audit_runs_total offset 30m) + for: 5m + + - alert: BuzzPartitionAuditFailing + expr: | + increase(buzz_partition_audit_failures_total[1h]) >= 2 + and buzz_partition_audit_failures_total diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index b51e5759737..9b8d6136470 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -135,6 +135,9 @@ run_unit_tests() { run_test_step "buzz-admin community archive command tests" \ cargo test -p buzz-admin communities -- --nocapture + run_test_step "buzz-admin partition audit outcome contract test" \ + cargo test -p buzz-admin tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes -- --exact --nocapture + # Multi-tenant conformance gate: independent replay checker + golden # fixtures (buzz-conformance). Pure in-process trace replay, no infra. run_test_step "buzz-conformance tests" \ @@ -287,6 +290,9 @@ run_unit_tests() { run_test_step "buzz-relay auth metrics contract tests" \ cargo test -p buzz-relay --lib metrics::contract_tests:: -- --nocapture + run_test_step "buzz-relay partition alert series tests" \ + cargo test -p buzz-relay --lib metrics::partition_alert_tests:: -- --nocapture + local nip_fi_exact_tests=( audio::room::tests::roster_revisions_are_ordered_and_snapshot_is_authoritative connection::tests::auth_lifecycle_reconciles_every_terminal_and_never_leaks_gauge From 43b88c3a76dada96fcbdc23e521569b95c9fd206 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 13 Oct 2026 17:24:29 -0400 Subject: [PATCH 02/11] docs(nips): allow NIP-FA session token on the adapter origin (#8198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 NIP-FA limited the adapter session token to the NIP's own endpoints, which stopped a client from reusing the same login for the adapter operator's other APIs on that host. This changes the rule to the adapter's origin (the scheme, host, and port of `{adapter_base}`). The token still never goes to a relay or any other origin, and the rest of the security bullet is unchanged. Signed-off-by: Will Pfleger Co-authored-by: Alia --- docs/nips/NIP-FA.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/nips/NIP-FA.md b/docs/nips/NIP-FA.md index d7c06fe6432..f2554f93de6 100644 --- a/docs/nips/NIP-FA.md +++ b/docs/nips/NIP-FA.md @@ -171,7 +171,7 @@ WebSocket connections send only `Nostr-Federated-Identity` on the upgrade reques ## Security Considerations -- Clients MUST send `session_token` only to the adapter endpoints defined in this NIP, and MUST NOT send it to a relay or any other origin. A leaked session token lets its holder request assertions for a key they control until the adapter binds the user to a key. +- Clients MUST send `session_token` only to the adapter's origin (the scheme, host, and port of `{adapter_base}`), and MUST NOT send it to a relay or any other origin. A leaked session token lets its holder request assertions for a key they control until the adapter binds the user to a key. - Clients and adapters MUST NOT log credentials: the `Authorization` and `Nostr-Authorization` header values on every adapter request, including `/v1/session`, and the `handoff_secret` and `session_token` values in the code exchange request and response. ## Privacy From 8599efb6a3cfbd3d4bc4534f6de766b4481c7fca Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison <72240382+jsibbison-square@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:30:20 +1100 Subject: [PATCH 03/11] feat(relay): allow HTTP operator listener delivery URLs (#8287) ## Summary Operator listener configuration currently rejects HTTP URLs, preventing delivery to sibling services over internal HTTP connections. Accept HTTP and HTTPS delivery URLs while retaining the existing pubkey, duplicate-route, and URL restrictions. Add configuration coverage for internal hostnames and custom ports, verify signed delivery through the real HTTP client, and include the listener suite in both unit-test runners. Shared NIP-98 signing and push gateway code are unchanged. ### Related issue Related: #7934 refactors operator listener delivery tests. No matching open issue found. ### Testing - `cargo test -p buzz-relay --lib operator_listener`: 13 passed, including signed HTTP delivery and redirect rejection. - `cargo fmt --all -- --check`, shell syntax, Justfile parsing, and diff whitespace checks passed. - `just test`: passed. - `just ci`: stopped at mobile analysis with an existing undefined `buzzDevPushAppProfile` in `mobile/test/features/settings/notification_toggle_test.dart:81`. Workspace and native desktop formatting/Clippy, desktop frontend checks, and web checks passed. The mobile file is unchanged by this PR. Locally verified with a spiked listener client Signed-off-by: jsibbison-square --- .env.example | 2 + Justfile | 2 +- crates/buzz-relay/src/config.rs | 39 ++++++++----- crates/buzz-relay/src/operator_listener.rs | 67 +++++++++++++++++++++- scripts/run-tests.sh | 3 + 5 files changed, 97 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index 8e1fe159391..61f44af72d6 100644 --- a/.env.example +++ b/.env.example @@ -150,6 +150,8 @@ BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns # Deployment-global operator-listener routes. Each configured Nostr identity # may register target pubkeys and receives mention notifications at its URL. +# URLs may use HTTP or HTTPS. Use HTTP only for operator-controlled internal +# connections: NIP-98 signs requests. # Format: pubkey:deliveryUrl;pubkey:deliveryUrl # BUZZ_OPERATOR_LISTENER_TIMEOUT_MS=5000 # BUZZ_OPERATOR_LISTENERS=<64-char hex pubkey>:https://listener.example/mentions diff --git a/Justfile b/Justfile index d73a9cc5470..312ec115b97 100644 --- a/Justfile +++ b/Justfile @@ -531,7 +531,7 @@ test-unit: # are infra-free; config parses env into a struct and never connects. # `^config::` is anchored so it does not also select nip_fi_config. cargo nextest run -p buzz-relay --lib --bin buzz-relay \ - -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' + -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' # Note on audio::join::tests scope: the full suite is infra-free (no DB, # no Redis). The infra-free audio/FI regression witnesses — bootstrap # ordering barrier, CommitConfirmed arm, pending-close invisibility, diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index d5c3cf60a2f..45c8f5e35d2 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -289,7 +289,7 @@ pub struct Config { /// skipped — a typo must not silently disable an operator. pub relay_operator_pubkeys: Vec, - /// Configured operator-listener identities and their HTTPS delivery URLs. + /// Configured operator-listener identities and their HTTP(S) delivery URLs. pub operator_listener_delivery_urls: HashMap, /// Allow NIP-OA owner attestation for relay membership. @@ -532,7 +532,7 @@ fn parse_operator_listener_delivery_urls( "BUZZ_OPERATOR_LISTENERS endpoint is not a valid URL: {e}" )) })?; - if url.scheme() != "https" + if !matches!(url.scheme(), "http" | "https") || url.host().is_none() || !url.username().is_empty() || url.password().is_some() @@ -540,7 +540,7 @@ fn parse_operator_listener_delivery_urls( || url.fragment().is_some() { return Err(ConfigError::InvalidValue( - "BUZZ_OPERATOR_LISTENERS endpoints must be HTTPS URLs without credentials, query, or fragment" + "BUZZ_OPERATOR_LISTENERS endpoints must be HTTP(S) URLs without credentials, query, or fragment" .to_string(), )); } @@ -2686,9 +2686,14 @@ mod tests { fn operator_listener_routes_reject_unsafe_or_duplicate_entries() { let key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; for raw in [ - format!("{key}:http://listener.example/mentions"), + format!("{key}:ftp://listener.example/mentions"), + format!("{key}:http://user:pass@listener:8080/mentions"), + format!("{key}:http://listener:8080/mentions?token=secret"), + format!("{key}:http://listener:8080/mentions#fragment"), format!("{key}:https://user:pass@listener.example/mentions"), format!("{key}:https://listener.example/mentions?token=secret"), + format!("{key}:https://listener.example/mentions#fragment"), + format!("{key}:http://listener:8080/mentions;{key}:https://other.example"), format!("{key}:https://listener.example/mentions;{key}:https://other.example"), ] { assert!( @@ -2740,15 +2745,23 @@ mod tests { fn valid_and_absent_operator_listener_config_preserve_routes() { let _guards = env_guards(); let key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - let raw = format!("{key}:https://listener.example/mentions"); - let config = config_with_operator_listeners(Some(&raw)).expect("valid listener config"); - assert_eq!( - config - .operator_listener_delivery_urls - .get(key) - .map(url::Url::as_str), - Some("https://listener.example/mentions") - ); + for endpoint in [ + "https://listener.example/mentions", + "http://operator-listener/mentions", + "http://operator-listener:8080/mentions", + "http://127.0.0.1:8080/mentions", + "http://[::1]:8080/mentions", + ] { + let raw = format!("{key}:{endpoint}"); + let config = config_with_operator_listeners(Some(&raw)).expect("valid listener config"); + assert_eq!( + config + .operator_listener_delivery_urls + .get(key) + .map(url::Url::as_str), + Some(endpoint) + ); + } let config = config_with_operator_listeners(None).expect("absent listener config"); assert!(config.operator_listener_delivery_urls.is_empty()); diff --git a/crates/buzz-relay/src/operator_listener.rs b/crates/buzz-relay/src/operator_listener.rs index a21292d2aec..8545497e388 100644 --- a/crates/buzz-relay/src/operator_listener.rs +++ b/crates/buzz-relay/src/operator_listener.rs @@ -636,7 +636,7 @@ mod tests { ); } - async fn read_http_request(stream: &mut TcpStream) -> (String, Vec) { + async fn read_http_request(stream: &mut TcpStream) -> (String, Vec, String) { let mut reader = BufReader::new(stream); let mut request_line = String::new(); reader @@ -645,6 +645,7 @@ mod tests { .expect("read request line"); let mut content_length = 0; + let mut authorization = String::new(); loop { let mut line = String::new(); reader.read_line(&mut line).await.expect("read header"); @@ -654,6 +655,8 @@ mod tests { if let Some((name, value)) = line.split_once(':') { if name.eq_ignore_ascii_case("content-length") { content_length = value.trim().parse().expect("valid content length"); + } else if name.eq_ignore_ascii_case("authorization") { + authorization = value.trim().to_owned(); } } } @@ -663,7 +666,67 @@ mod tests { .read_exact(&mut body) .await .expect("read request body"); - (request_line, body) + (request_line, body, authorization) + } + + #[tokio::test] + async fn successful_http_delivery_is_signed_and_completes_claim() { + use nostr::nips::nip98::{verify_auth_header, HttpMethod}; + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("HTTP listener"); + let endpoint = url::Url::parse(&format!( + "http://{}/mentions", + listener.local_addr().expect("listener address") + )) + .expect("HTTP endpoint"); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept delivery"); + let request = read_http_request(&mut stream).await; + stream + .write_all(b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n") + .await + .expect("write success response"); + request + }); + let (_, relay_keypair, delivery) = delivery_fixture(1); + let routes = HashMap::from([(hex::encode(&delivery.listener_pubkey), endpoint.clone())]); + let store = MockStore::default(); + let http = build_delivery_http_client(Duration::from_secs(2)).expect("delivery client"); + let transport = ReqwestDeliveryTransport { http: &http }; + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + let (request_line, body, authorization) = + tokio::time::timeout(Duration::from_secs(2), server) + .await + .expect("HTTP server completed") + .expect("HTTP server task"); + assert!(request_line.starts_with("POST /mentions HTTP/1.1")); + let signer = verify_auth_header( + &authorization, + &endpoint, + HttpMethod::POST, + nostr::Timestamp::now(), + Some(&body), + ) + .expect("valid signature binding the HTTP endpoint and body"); + assert_eq!(signer, relay_keypair.public_key()); + let payload: serde_json::Value = serde_json::from_slice(&body).expect("notification JSON"); + assert_eq!(payload["pubkey"], hex::encode(&delivery.target_pubkey)); + assert_eq!(payload["event_id"], hex::encode(&delivery.event_id)); + assert_eq!( + store.calls(), + [StoreCall::Complete(delivery.id, delivery.claim_id)] + ); } #[tokio::test] diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 9b8d6136470..efdd511dba7 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -178,6 +178,9 @@ run_unit_tests() { run_test_step "buzz-relay push runtime tests" \ cargo test -p buzz-relay --lib push_runtime:: -- --nocapture + run_test_step "buzz-relay operator listener tests" \ + cargo test -p buzz-relay --lib operator_listener::tests:: -- --nocapture + run_test_step "buzz-relay channel authorization tests" \ cargo test -p buzz-relay --lib handlers::channel_authz:: -- --nocapture From cb74c297e6043010c2b4fea14978e2de4f6fc86d Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 13 Oct 2026 21:00:12 -0500 Subject: [PATCH 04/11] fix(desktop): pin the ACP adapter versions Dreamforge installs The catalog installed @agentclientprotocol/claude-agent-acp and @agentclientprotocol/codex-acp unpinned, so a fresh install or an outdated-codex reinstall pulled whatever npm served: codex-acp 2.x, which Dreamforge deliberately skips (block/buzz #8247), or an untested claude-agent-acp. Pin both to the versions verified on the owner's machine (codex-acp 1.13.1, claude-agent-acp 0.70.0). Each pin lives in one macro in catalog.rs that feeds both the install command and the hint shown to users. The outdated-codex reinstall now reuses the catalog command instead of its own unpinned copy. The error copy and the buzz-acp README point at the same pinned commands. MIN_CODEX_ACP_VERSION (1.10.0) stays the readiness floor. Co-Authored-By: Claude Opus 5.5 --- crates/buzz-acp/README.md | 8 +++--- .../src-tauri/src/commands/agent_discovery.rs | 26 +++++++++---------- .../agent_discovery/managed_node_tests.rs | 4 +-- .../src/managed_agents/discovery/catalog.rs | 22 +++++++++++++--- .../discovery/runtime_metadata.rs | 14 ++++++++++ .../agents/lib/friendlyAgentLastError.ts | 2 +- 6 files changed, 52 insertions(+), 24 deletions(-) diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index 1161dc1034e..48b767ca222 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -74,8 +74,8 @@ That's it. The harness spawns `goose acp`, connects to the relay, discovers chan [codex-acp](https://github.com/agentclientprotocol/codex-acp) wraps OpenAI Codex in an ACP interface. ```bash -# Install the adapter (npm package — no Rust build required) -npm install -g @agentclientprotocol/codex-acp +# Install the adapter (npm package — no Rust build required; pinned to the tested version) +npm install -g @agentclientprotocol/codex-acp@1.13.1 # Run export OPENAI_API_KEY="sk-..." # required — use an OpenAI API key, not a ChatGPT subscription @@ -90,8 +90,8 @@ buzz-acp [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp) wraps the Claude Agent SDK in an ACP interface. ```bash -# Install the current adapter package -npm install -g @agentclientprotocol/claude-agent-acp +# Install the adapter (pinned to the version Dreamforge is tested with) +npm install -g @agentclientprotocol/claude-agent-acp@0.70.0 # Run export ANTHROPIC_API_KEY="sk-ant-..." diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 5ff3407f37c..32fe607d2cb 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -18,7 +18,7 @@ fn active_installs() -> &'static std::sync::Mutex( @@ -40,10 +40,10 @@ pub(crate) fn plan_adapter_install<'c>( } // Codex adapter is outdated: uninstall the old package first so npm // doesn't hit EEXIST on the shared `codex-acp` bin-link, then install. - Some(_) => Some(vec![ - "npm uninstall -g @zed-industries/codex-acp", - "npm install -g @agentclientprotocol/codex-acp", - ]), + Some(_) => { + let uninstall: &[&str] = &["npm uninstall -g @zed-industries/codex-acp"]; + Some([uninstall, adapter_install_commands].concat()) + } // Adapter missing: use the catalog's install commands directly. None => Some(adapter_install_commands.to_vec()), } @@ -1053,14 +1053,14 @@ mod tests { #[test] fn test_is_npm_global_install_accepts_catalog_claude_command() { assert!(is_npm_global_install( - "npm install -g @agentclientprotocol/claude-agent-acp" + "npm install -g @agentclientprotocol/claude-agent-acp@0.70.0" )); } #[test] fn test_is_npm_global_install_accepts_catalog_codex_command() { assert!(is_npm_global_install( - "npm install -g @agentclientprotocol/codex-acp" + "npm install -g @agentclientprotocol/codex-acp@1.13.1" )); } @@ -1138,7 +1138,7 @@ mod tests { std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) .expect("chmod script"); - let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"]; + let install_cmds = &["npm install -g @agentclientprotocol/codex-acp@1.13.1"]; let plan = plan_adapter_install("codex", Some(&bin), install_cmds, Some("/usr/bin:/bin")); assert!( @@ -1151,7 +1151,7 @@ mod tests { cmds, vec![ "npm uninstall -g @zed-industries/codex-acp", - "npm install -g @agentclientprotocol/codex-acp", + "npm install -g @agentclientprotocol/codex-acp@1.13.1", ], "outdated codex adapter must produce uninstall-then-install sequence; got {cmds:?}" ); @@ -1173,7 +1173,7 @@ mod tests { std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) .expect("chmod script"); - let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"]; + let install_cmds = &["npm install -g @agentclientprotocol/codex-acp@1.13.1"]; let plan = plan_adapter_install("codex", Some(&bin), install_cmds, Some("/usr/bin:/bin")); assert!( @@ -1198,7 +1198,7 @@ mod tests { std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) .expect("chmod script"); - let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"]; + let install_cmds = &["npm install -g @agentclientprotocol/codex-acp@1.13.1"]; let plan = plan_adapter_install("codex", Some(&bin), install_cmds, Some("/usr/bin:/bin")); assert!( @@ -1209,14 +1209,14 @@ mod tests { #[test] fn test_plan_adapter_install_returns_catalog_cmds_when_no_adapter_path() { - let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"]; + let install_cmds = &["npm install -g @agentclientprotocol/codex-acp@1.13.1"]; let plan = plan_adapter_install("codex", None, install_cmds, None); assert!(plan.is_some(), "missing adapter must trigger install plan"); // Missing arm: use the catalog's install commands directly (no prior // package to uninstall — fresh install, not a reinstall). assert_eq!( plan.unwrap(), - vec!["npm install -g @agentclientprotocol/codex-acp"], + vec!["npm install -g @agentclientprotocol/codex-acp@1.13.1"], "missing codex adapter must use catalog install commands only" ); } diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs index a8e1d7f4c81..bf1e50fb29e 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs @@ -13,10 +13,10 @@ fn test_npm_eacces_hint_guidance_mentions_buzz_private_dir() { fn test_rewrite_npm_install_uses_private_prefix() { assert_eq!( rewrite_npm_global_install( - "npm install -g @agentclientprotocol/codex-acp", + "npm install -g @agentclientprotocol/codex-acp@1.13.1", "'/tmp/Buzz Node'" ), - "npm install --global --prefix '/tmp/Buzz Node' @agentclientprotocol/codex-acp" + "npm install --global --prefix '/tmp/Buzz Node' @agentclientprotocol/codex-acp@1.13.1" ); } diff --git a/desktop/src-tauri/src/managed_agents/discovery/catalog.rs b/desktop/src-tauri/src/managed_agents/discovery/catalog.rs index 8f48b396c14..9e920923c08 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/catalog.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/catalog.rs @@ -9,6 +9,20 @@ use super::runtime_metadata::{ }; use super::{BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL}; +// Adapter installs are pinned to versions verified with Dreamforge: unpinned +// ones pull whatever npm has, including codex-acp 2.x (skipped, block/buzz +// #8247). Macros rather than consts so `concat!` can reuse them in the hints. +macro_rules! claude_agent_acp_install_command { + () => { + "npm install -g @agentclientprotocol/claude-agent-acp@0.70.0" + }; +} +macro_rules! codex_acp_install_command { + () => { + "npm install -g @agentclientprotocol/codex-acp@1.13.1" + }; +} + pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ KnownAcpRuntime { id: "goose", @@ -58,11 +72,11 @@ pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ underlying_cli: Some("claude"), cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], cli_install_commands_windows: &[windows_install_command!("claude", "https://claude.ai/install.ps1")], - adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], + adapter_install_commands: &[claude_agent_acp_install_command!()], cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started", adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", cli_install_hint: "Buzz talks to Claude Code through the Claude Code CLI.", - adapter_install_hint: "Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/claude-agent-acp.", + adapter_install_hint: concat!("Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: ", claude_agent_acp_install_command!(), "."), skill_dir: Some(".claude/skills"), supports_acp_model_switching: false, model_env_var: None, @@ -97,11 +111,11 @@ pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ underlying_cli: None, cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], cli_install_commands_windows: &[windows_install_command!("codex", "https://chatgpt.com/codex/install.ps1")], - adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], + adapter_install_commands: &[codex_acp_install_command!()], cli_install_instructions_url: "https://developers.openai.com/codex/cli/", adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", cli_install_hint: "Buzz talks to Codex through the Codex CLI.", - adapter_install_hint: "Buzz talks to the Codex CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/codex-acp.", + adapter_install_hint: concat!("Buzz talks to the Codex CLI through an ACP adapter. Install it with: ", codex_acp_install_command!(), "."), skill_dir: Some(".codex/skills"), supports_acp_model_switching: false, model_env_var: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index 6b45183e869..3fb3307ef5d 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -209,6 +209,13 @@ mod tests { .adapter_install_instructions_url .contains("claude-agent-acp")); assert!(claude.cli_install_hint.contains("Claude Code CLI")); + assert_eq!( + claude.adapter_install_commands, + ["npm install -g @agentclientprotocol/claude-agent-acp@0.70.0"] + ); + assert!(claude + .adapter_install_hint + .contains(claude.adapter_install_commands[0])); let codex = known_acp_runtime_exact("codex").unwrap(); assert_eq!( @@ -217,6 +224,13 @@ mod tests { ); assert!(codex.adapter_install_instructions_url.contains("codex-acp")); assert!(codex.cli_install_hint.contains("Codex CLI")); + assert_eq!( + codex.adapter_install_commands, + ["npm install -g @agentclientprotocol/codex-acp@1.13.1"] + ); + assert!(codex + .adapter_install_hint + .contains(codex.adapter_install_commands[0])); } /// codex-acp runs the Codex engine it bundles (or `CODEX_PATH`), so an diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.ts b/desktop/src/features/agents/lib/friendlyAgentLastError.ts index 60c77bb04cc..ace61b5b113 100644 --- a/desktop/src/features/agents/lib/friendlyAgentLastError.ts +++ b/desktop/src/features/agents/lib/friendlyAgentLastError.ts @@ -43,7 +43,7 @@ export const MODEL_NOT_FOUND_COPY = "The configured model is not available — open agent settings and select a different one from the dropdown."; export const CLI_ACP_INTERNAL_ERROR_COPY = - "The agent's harness reported an internal error. For Codex agents this can mean the configured model isn't supported by your installed codex-acp — check the model in `~/.codex/config.toml` or upgrade the adapter (`brew upgrade codex-acp`)."; + "The agent's harness reported an internal error. For Codex agents this can mean the configured model isn't supported by your installed codex-acp — check the model in `~/.codex/config.toml` or reinstall the adapter (`npm install -g @agentclientprotocol/codex-acp@1.13.1`)."; const EMBEDDED_CODE_RE = /^Agent reported error \(code (-?\d+)\): /; /** Bare form of the standard JSON-RPC -32603 message (after stripping the ACP wrapper prefix). */ From 7c113e2392ea04610a2b4ff685dcf495f5c0ac27 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 13 Oct 2026 21:03:09 -0500 Subject: [PATCH 05/11] fix(agents): say "restart Dreamforge" in agent setup messages The fork ships as Dreamforge, but the agent setup nudge, the runtime install hints and the managed Node install errors still told people to "restart Buzz", an app they do not have. Rename the product in those user-visible strings only (the whole sentence, so one message does not name both apps) and update the tests that pin them. Code identifiers, crate names, env vars and log lines keep their names. Co-Authored-By: Claude Opus 5.5 --- crates/buzz-acp/src/setup_mode.rs | 4 ++-- .../src-tauri/src/commands/agent_discovery/managed_node.rs | 6 +++--- .../src/commands/agent_discovery/managed_node_tests.rs | 2 +- .../commands/agent_discovery/post_install_verification.rs | 2 +- desktop/src-tauri/src/managed_agents/discovery/presets.rs | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index 21e43d4dacf..758dcdad8a4 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -279,7 +279,7 @@ impl SetupPayload { let footer = if has_doctor_requirement { "Open Agent runtimes in Settings, install Git for Windows, then re-check and restart the agent.".to_string() } else if all_missing_binary { - "Install the missing binary or update PATH, then restart Buzz.".to_string() + "Install the missing binary or update PATH, then restart Dreamforge.".to_string() } else if all_external { // All requirements are external config files — Edit Agent cannot // help. Don't send the user there. @@ -797,7 +797,7 @@ mod tests { )); let body = payload.nudge_body(); assert!(body.contains("install `buzz-pi-acp` or add it to PATH")); - assert!(body.contains("restart Buzz")); + assert!(body.contains("restart Dreamforge")); assert!(!body.contains("Open Edit Agent")); } diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs index fbfb068c0e5..679caac60e2 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs @@ -80,7 +80,7 @@ fn managed_node_unsupported_step() -> InstallStepResult { ), exit_code: None, hint: Some( - "Install Node.js from https://nodejs.org, restart Buzz, then click Install again." + "Install Node.js from https://nodejs.org, restart Dreamforge, then click Install again." .to_string(), ), } @@ -596,7 +596,7 @@ fn verify_node_tree(dir: &std::path::Path) -> Result<(), String> { /// Guidance text shown when the Buzz-private npm prefix is not available. fn managed_npm_prefix_hint() -> String { - "Buzz could not create its private Node tools directory. Check app-data directory permissions, restart Buzz, then click Install again.".to_string() + "Dreamforge could not create its private Node tools directory. Check app-data directory permissions, restart Dreamforge, then click Install again.".to_string() } pub(super) fn managed_npm_command(command: &str) -> Result, Box> { @@ -657,7 +657,7 @@ fn shell_quote(path: &std::path::Path) -> String { pub(super) fn npm_eacces_hint(stderr: &str, _command: &str) -> Option { if stderr.contains("EACCES: permission denied") || stderr.contains("npm error EACCES") { Some( - "npm could not write to Buzz's private Node tools directory. Check app-data directory permissions, restart Buzz, then click Install again." + "npm could not write to Dreamforge's private Node tools directory. Check app-data directory permissions, restart Dreamforge, then click Install again." .to_string(), ) } else { diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs index bf1e50fb29e..e679eb3d215 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs @@ -4,7 +4,7 @@ use super::*; fn test_npm_eacces_hint_guidance_mentions_buzz_private_dir() { let hint = npm_eacces_hint("EACCES: permission denied", "npm install -g foo").unwrap(); assert!( - hint.contains("Buzz's private Node tools directory"), + hint.contains("Dreamforge's private Node tools directory"), "hint: {hint}" ); } diff --git a/desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs b/desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs index 535d4c9ecb1..48473c86f2e 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs @@ -38,7 +38,7 @@ fn failure( ), exit_code: None, hint: Some( - "Buzz requires the vendor CLI executable, not only its desktop app. If the CLI was installed while Buzz was open, restart Buzz and check again." + "Dreamforge requires the vendor CLI executable, not only its desktop app. If the CLI was installed while Dreamforge was open, restart Dreamforge and check again." .to_string(), ), }) diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index e1c94c1d136..f8fb7c11a14 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -110,7 +110,7 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[ command: "buzz-pi-acp", args: &[], install_instructions_url: "https://github.com/salman1993/buzz-pi-acp", - install_hint: "Requires Node.js 22 or newer. Install the Pi ACP adapter with `npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#86b201e'`. Make sure `buzz-pi-acp` is on PATH, then restart Buzz.", + install_hint: "Requires Node.js 22 or newer. Install the Pi ACP adapter with `npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#86b201e'`. Make sure `buzz-pi-acp` is on PATH, then restart Dreamforge.", underlying_cli: Some("pi"), underlying_cli_install_hint: Some( "Install Pi with `npm install -g @earendil-works/pi-coding-agent`, then run `pi` to configure its model provider.", @@ -434,7 +434,7 @@ mod tests { assert!(adapter_missing.default_args.is_empty()); assert_eq!( adapter_missing.install_hint, - "Requires Node.js 22 or newer. Install the Pi ACP adapter with `npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#86b201e'`. Make sure `buzz-pi-acp` is on PATH, then restart Buzz." + "Requires Node.js 22 or newer. Install the Pi ACP adapter with `npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#86b201e'`. Make sure `buzz-pi-acp` is on PATH, then restart Dreamforge." ); assert_eq!( adapter_missing.install_instructions_url, @@ -462,7 +462,7 @@ mod tests { ); assert_eq!( not_installed.install_hint, - "Install Pi with `npm install -g @earendil-works/pi-coding-agent`, then run `pi` to configure its model provider. Requires Node.js 22 or newer. Install the Pi ACP adapter with `npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#86b201e'`. Make sure `buzz-pi-acp` is on PATH, then restart Buzz." + "Install Pi with `npm install -g @earendil-works/pi-coding-agent`, then run `pi` to configure its model provider. Requires Node.js 22 or newer. Install the Pi ACP adapter with `npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#86b201e'`. Make sure `buzz-pi-acp` is on PATH, then restart Dreamforge." ); } From a63623eaa7865e1302f4447522761b893bc3c433 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 13 Oct 2026 21:09:07 -0500 Subject: [PATCH 06/11] fix(acp): fit observer frames to the NIP-44 encoder's real plaintext cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents logged "failed to encrypt relay observer event: NIP-44 error: message too long" and dropped the frame. The trimmer and the batch packer fitted frames to buzz-core's 65_535-byte OBSERVER_MAX_PLAINTEXT_LEN, but nostr's NIP-44 v2 encoder rejects plaintext over 65_536 - 128 = 65_408 bytes. Frames that landed between the two passed the fit and then failed encryption, and greedy batching packs frames right up to the cap, so this hit often. Fit and pack observer telemetry to a buzz-acp OBSERVER_FRAME_MAX_LEN of 65_408 instead. The existing elision (head/tail kept, "…[elided N bytes]…" marker) does the trimming, so the JSON stays valid and the frame is published. buzz-core's 65_535 hard cap is unchanged, and so is its decrypt side. A new test encrypts a 65_500-byte frame (the failing band) and a 100_000-byte frame after fitting, then decrypts both to valid JSON. It failed with the production error before this change. Co-Authored-By: Claude Opus 5.5 --- crates/buzz-acp/src/lib.rs | 78 +++++++++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 17 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 1c3531f93df..dc9cea9e7d9 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -40,7 +40,6 @@ use buzz_core::kind::{ }; use buzz_core::observer::{ decrypt_observer_payload, encrypt_observer_payload, OBSERVER_FRAME_TELEMETRY, - OBSERVER_MAX_PLAINTEXT_LEN, }; use clap::Parser; use config::{ @@ -1310,7 +1309,7 @@ impl ObserverPublishQueue { /// Pack and remove AT MOST ONE publishable frame: the front event's /// channel, gathered queue-wide in FIFO order (packed greedily until /// adding the next event would push the envelope over - /// `OBSERVER_MAX_PLAINTEXT_LEN`). Singletons ship unwrapped. + /// `OBSERVER_FRAME_MAX_LEN`). Singletons ship unwrapped. /// /// Two invariants bound the gather: /// - A frame never mixes channels (the desktop archive indexes a frame @@ -1348,7 +1347,7 @@ impl ObserverPublishQueue { if gathering && event.channel_id == channel { picked.push(event); if picked.len() > 1 - && serialized_len(&batch_envelope(&picked)) > OBSERVER_MAX_PLAINTEXT_LEN + && serialized_len(&batch_envelope(&picked)) > OBSERVER_FRAME_MAX_LEN { // Frame full: the overflow event stays queued and leads // its channel's next slot. @@ -1534,7 +1533,7 @@ struct ObserverChunkKey { /// Flush coalesced chunks before they exceed the NIP-44 plaintext limit (65,535 bytes). /// Leave headroom for the JSON envelope wrapping the text. This is a SOFT pre-flush /// of raw text below the hard cap; `fit_observer_event_to_budget` (the final ceiling, -/// keyed to `OBSERVER_MAX_PLAINTEXT_LEN` in buzz-core/observer.rs:25) is what actually +/// keyed to `OBSERVER_FRAME_MAX_LEN`) is what actually /// guarantees the serialized frame fits. Edit one of these two and review the other. const OBSERVER_CHUNK_MAX_TEXT_BYTES: usize = 60_000; @@ -1668,8 +1667,14 @@ fn set_observer_chunk_text(payload: &mut serde_json::Value, text: String) { /// eliding actually shrinks the frame. const OBSERVER_LEAF_RETAIN_BYTES: usize = 3_000; +/// Serialized-frame ceiling for observer telemetry. nostr's NIP-44 v2 encoder +/// rejects plaintext over 65_536 - 128 bytes (its `MAX_SUPPORTED_PLAINTEXT_SIZE`), +/// tighter than buzz-core's 65_535 `OBSERVER_MAX_PLAINTEXT_LEN`, so frames fitted +/// to 65_535 failed with "NIP-44 error: message too long" and were dropped. +const OBSERVER_FRAME_MAX_LEN: usize = 65_536 - 128; + /// Trim an oversized observer telemetry frame so its SERIALIZED form fits under -/// `OBSERVER_MAX_PLAINTEXT_LEN`, instead of dropping the whole frame (silent +/// `OBSERVER_FRAME_MAX_LEN`, instead of dropping the whole frame (silent /// telemetry loss). The common case — a frame already under budget — is left /// byte-identical. /// @@ -1694,7 +1699,7 @@ const OBSERVER_LEAF_RETAIN_BYTES: usize = 3_000; /// are out of this change's scope (buzz-core stays untouched). The clean `&mut` /// signature with one cheap redundant serialize is the deliberate tradeoff. fn fit_observer_event_to_budget(event: &mut observer::ObserverEvent) { - if serialized_len(event) <= OBSERVER_MAX_PLAINTEXT_LEN { + if serialized_len(event) <= OBSERVER_FRAME_MAX_LEN { return; } @@ -1710,7 +1715,7 @@ fn fit_observer_event_to_budget(event: &mut observer::ObserverEvent) { // never be re-elided, so the loop is bounded by the leaf count. while let Some(leaf) = largest_shrinkable_leaf(&mut event.payload) { elide_leaf(leaf); - if serialized_len(event) <= OBSERVER_MAX_PLAINTEXT_LEN { + if serialized_len(event) <= OBSERVER_FRAME_MAX_LEN { return; } } @@ -9826,7 +9831,7 @@ mod observer_publish_queue_tests { frames.len() ); for frame in &frames { - assert!(serialized_len(frame) <= OBSERVER_MAX_PLAINTEXT_LEN); + assert!(serialized_len(frame) <= OBSERVER_FRAME_MAX_LEN); } // Within each channel, FIFO order survives the gather. let mut seqs_a = Vec::new(); @@ -9867,7 +9872,7 @@ mod observer_publish_queue_tests { let mut seen = Vec::new(); for frame in &frames { assert!( - serialized_len(frame) <= OBSERVER_MAX_PLAINTEXT_LEN, + serialized_len(frame) <= OBSERVER_FRAME_MAX_LEN, "every emitted frame must fit the plaintext cap" ); seen.extend(frame_seqs(frame)); @@ -13881,7 +13886,7 @@ mod observer_payload_trim_tests { fit_observer_event_to_budget(&mut event); assert!( - serialized(&event).len() <= OBSERVER_MAX_PLAINTEXT_LEN, + serialized(&event).len() <= OBSERVER_FRAME_MAX_LEN, "frame must fit after trimming" ); // Envelope intact. @@ -13944,14 +13949,14 @@ mod observer_payload_trim_tests { }), ); assert!( - serialized(&event).len() > OBSERVER_MAX_PLAINTEXT_LEN, + serialized(&event).len() > OBSERVER_FRAME_MAX_LEN, "precondition: oversized event body pushes the frame over the cap" ); fit_observer_event_to_budget(&mut event); assert!( - serialized(&event).len() <= OBSERVER_MAX_PLAINTEXT_LEN, + serialized(&event).len() <= OBSERVER_FRAME_MAX_LEN, "frame must fit after trimming" ); let blocks = event.payload["params"]["prompt"] @@ -13981,6 +13986,45 @@ mod observer_payload_trim_tests { ); } + #[test] + fn test_fitted_oversized_frame_encrypts_and_decrypts_to_valid_json() { + // 65_500 sits in the band nostr's NIP-44 encoder rejects (> 65_536 - 128) + // but the 65_535 hard cap allowed, which is where frames were dropped. + let sender = nostr::Keys::generate(); + let recipient = nostr::Keys::generate(); + let base = serialized(&event_with_payload( + "acp_read", + serde_json::json!({ "body": "" }), + )) + .len(); + for target in [65_500, 100_000] { + let body = "x".repeat(target - base); + let mut event = event_with_payload("acp_read", serde_json::json!({ "body": body })); + assert_eq!(serialized(&event).len(), target, "precondition: frame size"); + + fit_observer_event_to_budget(&mut event); + let encrypted = encrypt_observer_payload(&sender, &recipient.public_key(), &event) + .unwrap_or_else(|error| panic!("{target}-byte frame must encrypt: {error}")); + + let plaintext = nostr::nips::nip44::decrypt( + recipient.secret_key(), + &sender.public_key(), + encrypted, + ) + .expect("decrypt"); + let decoded: serde_json::Value = + serde_json::from_str(&plaintext).expect("decrypted frame is valid JSON"); + assert_eq!(decoded["kind"], "acp_read"); + assert!( + decoded["payload"]["body"] + .as_str() + .unwrap() + .contains("…[elided"), + "{target}-byte frame was trimmed with the elision marker" + ); + } + } + #[test] fn test_multi_leaf_elides_largest_shrinkable_first_and_stops_when_it_fits() { // One leaf alone over the cap; a second smaller-but-still-large leaf. @@ -13994,7 +14038,7 @@ mod observer_payload_trim_tests { ); fit_observer_event_to_budget(&mut event); - assert!(serialized(&event).len() <= OBSERVER_MAX_PLAINTEXT_LEN); + assert!(serialized(&event).len() <= OBSERVER_FRAME_MAX_LEN); assert!( event.payload["huge"].as_str().unwrap().contains("…[elided"), "the largest leaf is elided" @@ -14024,7 +14068,7 @@ mod observer_payload_trim_tests { ); fit_observer_event_to_budget(&mut event); - assert!(serialized(&event).len() <= OBSERVER_MAX_PLAINTEXT_LEN); + assert!(serialized(&event).len() <= OBSERVER_FRAME_MAX_LEN); let text = event.payload["params"]["update"]["content"]["text"] .as_str() .unwrap(); @@ -14042,13 +14086,13 @@ mod observer_payload_trim_tests { .collect(); let mut event = event_with_payload("acp_read", serde_json::json!({ "items": items })); assert!( - serialized(&event).len() > OBSERVER_MAX_PLAINTEXT_LEN, + serialized(&event).len() > OBSERVER_FRAME_MAX_LEN, "precondition: frame is over the cap" ); fit_observer_event_to_budget(&mut event); - assert!(serialized(&event).len() <= OBSERVER_MAX_PLAINTEXT_LEN); + assert!(serialized(&event).len() <= OBSERVER_FRAME_MAX_LEN); assert_eq!( event.payload["elided"].as_str().unwrap(), "acp_read payload too large", @@ -14079,7 +14123,7 @@ mod observer_payload_trim_tests { let mut event = event_with_payload("acp_read", serde_json::json!({ "body": big })); fit_observer_event_to_budget(&mut event); - assert!(serialized(&event).len() <= OBSERVER_MAX_PLAINTEXT_LEN); + assert!(serialized(&event).len() <= OBSERVER_FRAME_MAX_LEN); let leaf = event.payload["body"].as_str().unwrap(); // Valid UTF-8 by construction (it's a &str); confirm head/tail are whole // multi-byte chars and the marker is present. From aee069cd47e374949767c84dd683f91e5feeabb9 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 13 Oct 2026 21:21:44 -0500 Subject: [PATCH 07/11] fix(desktop): stop storing a fake 320s turn timeout on agent records turn_timeout_seconds is not enforced anywhere. The spawn never emits BUZZ_ACP_TURN_TIMEOUT (runtime.rs only sets BUZZ_ACP_IDLE_TIMEOUT and BUZZ_ACP_MAX_TURN_DURATION when a record sets them), create and update accept the field and drop it, the Kubernetes provider does not map it, and no UI component shows or edits it (upstream #1715 removed the editor). The limits that apply are the harness defaults: 1500s idle and 7200s per turn. Even so, every new record was stamped with 320, and the constant's comment claimed that matched the harness idle default. Anyone reading managed-agents.json saw a five-minute turn limit that does not exist. There is no UI to remove or wire up, and wiring the field to the per-turn limit would only duplicate max_turn_duration_seconds. So the fix is in the record: the field is skip_deserializing, which makes a stored 320 load as 0 and get rewritten as 0 on the next save, and new records start at 0. The key is still written, not skipped, because older builds that share this store need it to parse the file and would refuse to load every agent without it. Existing managed-agents.json files keep loading. The recorded provider-wire fixture now carries the 0 the desktop actually sends. Co-Authored-By: Claude Opus 5.5 --- .../provider-wire/deploy-full-launch.request.json | 2 +- desktop/src-tauri/src/commands/agents.rs | 10 ++++------ desktop/src-tauri/src/managed_agents/types.rs | 13 +++++++------ desktop/src-tauri/src/managed_agents/types/tests.rs | 11 +++++++++++ 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json index fb291a7db54..95ab9ff5910 100644 --- a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json @@ -44,7 +44,7 @@ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" ], "system_prompt": null, - "turn_timeout_seconds": 300 + "turn_timeout_seconds": 0 }, "provider_config": { "namespace": "buzz-agents-test", diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index bdc0c7880e2..01a9c0ef617 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -13,7 +13,7 @@ use crate::{ stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, - DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, }, relay::relay_ws_url_with_override, util::now_iso, @@ -681,11 +681,9 @@ async fn create_managed_agent_in( agent_command_override, agent_args, mcp_command, - // BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness; - // store the schema default only. Use idle_timeout_seconds or - // max_turn_duration_seconds for actual turn-length control. - turn_timeout_seconds: DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, - // 0 or None → harness uses its own default (320s idle, 3600s max), and the CLI also clamps 0 → minimum. + // Deprecated and ignored by the harness (see the record field). + turn_timeout_seconds: 0, + // 0 or None → harness uses its own defaults, and the CLI also clamps 0 → minimum. idle_timeout_seconds: input.idle_timeout_seconds.filter(|s| *s > 0), max_turn_duration_seconds: input.max_turn_duration_seconds.filter(|s| *s > 0), parallelism: minted.parallelism.unwrap_or(DEFAULT_AGENT_PARALLELISM), diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 4c57a58ec3a..8f8461c101f 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -143,7 +143,7 @@ impl AgentDefinition { agent_command_override: None, agent_args: Vec::new(), mcp_command: String::new(), - turn_timeout_seconds: DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + turn_timeout_seconds: 0, idle_timeout_seconds: None, max_turn_duration_seconds: None, parallelism: default_agent_parallelism(), @@ -303,9 +303,12 @@ pub struct ManagedAgentRecord { /// serde compatibility with existing stores. pub mcp_command: String, /// Deprecated: `BUZZ_ACP_TURN_TIMEOUT` is ignored by the harness and the - /// desktop no longer emits or edits it. Kept for serde compatibility with - /// existing stores; use `idle_timeout_seconds` or - /// `max_turn_duration_seconds` for turn-length control. + /// desktop no longer emits or edits it; use `idle_timeout_seconds` or + /// `max_turn_duration_seconds` for turn-length control. Any stored value + /// is dropped on load (a stale 320 read like a live limit) and the field + /// is written as 0 only because older builds require the key to parse + /// the store. + #[serde(skip_deserializing)] pub turn_timeout_seconds: u64, /// Idle timeout in seconds (`BUZZ_ACP_IDLE_TIMEOUT`): how long the agent /// may stay silent on its ACP channel mid-turn before the harness times @@ -850,8 +853,6 @@ pub struct AgentModelInfo { } pub const DEFAULT_ACP_COMMAND: &str = "buzz-acp"; -/// ~5 min (320s) — matches the CLI harness default (BUZZ_ACP_IDLE_TIMEOUT). -pub const DEFAULT_AGENT_TURN_TIMEOUT_SECONDS: u64 = 320; pub const DEFAULT_AGENT_PARALLELISM: u32 = 10; fn default_agent_parallelism() -> u32 { diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 650dce09e82..9049127a47a 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -460,6 +460,17 @@ fn pending_provider_policy_round_trips() { assert!(reloaded.provider_policy_pending); } +/// The harness ignores `turn_timeout_seconds`, so a stored value must not +/// survive a load, but the key must still be written: older builds require it. +#[test] +fn deprecated_turn_timeout_is_dropped_on_load_but_still_written() { + let record = sample_agent_record(); // the fixture stores 320 + assert_eq!(record.turn_timeout_seconds, 0); + + let json = serde_json::to_value(&record).expect("serialize"); + assert_eq!(json["turn_timeout_seconds"], 0); +} + #[test] fn stored_record_unknown_or_null_session_policy_degrades_to_channel() { for session_policy in [serde_json::json!("future"), serde_json::Value::Null] { From 7b4673b129d7c4547eed13fb2bdde291b1eaf561 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 13 Oct 2026 21:25:26 -0500 Subject: [PATCH 08/11] chore(ci): skip Block's phone app jobs on the Dreamforge fork Dreamforge does not build or ship the phone app; owners install Block's store build. On the fork, Clients / Mobile runs on every push to main and times out at 30 minutes, which fails the Mobile gate and the whole CI run. Gate the Mobile job, the Mobile gate and the Mobile Swift call on github.repository == 'block/buzz' so they are skipped on the fork and run unchanged upstream. A skipped gate reads as passing for required checks (required-check-succeeded.jq also accepts skipped). The gate test now substitutes github.repository with block/buzz so it keeps checking the upstream behavior. Signed-off-by: QuicksilverSlick Co-Authored-By: Claude Opus 5.5 --- .github/workflows/_ci-clients.yml | 3 ++- .github/workflows/ci.yml | 6 ++++-- scripts/ci-required-gates.test.mjs | 3 ++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index 310ab676b75..34f33f70f39 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -61,7 +61,8 @@ jobs: name: Mobile runs-on: ubuntu-latest timeout-minutes: 30 - if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.mobile) + # The Dreamforge fork does not build or ship the phone app (owners use Block's store build). + if: inputs.lane == 'required' && github.repository == 'block/buzz' && (github.event_name == 'push' || inputs.mobile) permissions: contents: read steps: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c70729c00d8..50ab3910a74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,7 +245,8 @@ jobs: mobile-swift-domain: name: Mobile Swift Domain needs: [changes] - if: needs.changes.outputs.mobile == 'true' + # The Dreamforge fork does not build or ship the phone app (owners use Block's store build). + if: github.repository == 'block/buzz' && needs.changes.outputs.mobile == 'true' uses: ./.github/workflows/_ci-clients.yml with: web: ${{ needs.changes.outputs.web == 'true' }} @@ -416,7 +417,8 @@ jobs: mobile: name: Mobile - if: always() && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.mobile == 'true'))) + # The Dreamforge fork does not build or ship the phone app (owners use Block's store build). + if: always() && github.repository == 'block/buzz' && (needs.changes.result != 'success' || ((github.event_name == 'push' || needs.changes.outputs.mobile == 'true'))) needs: [changes, clients] runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/scripts/ci-required-gates.test.mjs b/scripts/ci-required-gates.test.mjs index db0bb38480d..5a4a475cfd4 100644 --- a/scripts/ci-required-gates.test.mjs +++ b/scripts/ci-required-gates.test.mjs @@ -45,9 +45,10 @@ for (const gate of gates) { const expression = condition .replace(/always\(\)/g, "true") .replace( - /github\.event_name|needs\.[\w-]+\.(?:result|outputs\.[\w-]+)/g, + /github\.(?:event_name|repository)|needs\.[\w-]+\.(?:result|outputs\.[\w-]+)/g, (key) => { if (key === "github.event_name") return JSON.stringify(event); + if (key === "github.repository") return JSON.stringify("block/buzz"); if (key === "needs.changes.result") return JSON.stringify(selection); if (key === "needs.relay-artifacts-domain.result") return JSON.stringify(artifacts); From f66fcda1f9fc1eca85e3d2fdf6a3c3860a864ce7 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 13 Oct 2026 21:35:43 -0500 Subject: [PATCH 09/11] fix(cli): add --content-file so Windows agents keep non-ASCII text Windows PowerShell 5.1 encodes text piped into a native command with $OutputEncoding (ASCII), so `Get-Content -Raw msg.txt | buzz messages send ... --content -` delivers every em dash, curly quote and emoji as "?". Reading the file directly skips the shell's re-encoding. `--content-file ` on `messages send`, `canvas set` and `issues create` (the long-text commands that share read_or_stdin) reads the file as UTF-8, drops one leading BOM (Set-Content -Encoding UTF8 writes one), fails with a usage error on invalid UTF-8, and conflicts with --content. Content is now resolved in dispatch, so file text that is exactly "-" is never mistaken for the stdin marker, and `issues create` reads its body before it can create a repository. The shared base prompt now tells agents to write long or non-ASCII text to a UTF-8 file and pass --content-file, always on Windows. Signed-off-by: QuicksilverSlick Co-Authored-By: Claude Opus 5.5 --- crates/buzz-acp/src/base_prompt.md | 2 +- crates/buzz-acp/src/lib.rs | 7 +++ crates/buzz-cli/README.md | 1 + crates/buzz-cli/src/commands/channels.rs | 16 ++++--- crates/buzz-cli/src/commands/issues.rs | 12 ++++-- crates/buzz-cli/src/commands/messages.rs | 54 +++++++++++++++++++----- crates/buzz-cli/src/lib.rs | 47 ++++++++++++++++++--- crates/buzz-cli/src/validate.rs | 51 ++++++++++++++++++++++ 8 files changed, 162 insertions(+), 28 deletions(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 6f41d0d0a68..088129e1dfe 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -15,7 +15,7 @@ Auth env vars: `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`. Exit codes 0 ok, 1 user error, 2 network, 3 auth, 4 other, 5 write conflict. Output is structured JSON. `--format compact` is global — it goes before the subcommand. -Run `buzz --help` or `buzz --help` for full usage. For multiline message content, pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat. +Run `buzz --help` or `buzz --help` for full usage. For multiline or non-ASCII content (em dashes, curly quotes, emoji), write the text to a UTF-8 file with your file-editing tool and pass its path: `buzz messages send ... --content-file msg.md` (`buzz issues create` and `buzz canvas set` take `--content-file` too). On Windows always use `--content-file`: PowerShell re-encodes piped text, so every non-ASCII character arrives as `?`. Elsewhere you can also pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat. When opening a pull request in response to channel work, always pass `--channel ` using the UUID from ``. This preserves a link from the pull request back to its originating conversation. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index dc9cea9e7d9..31cf6e7b063 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -6133,6 +6133,13 @@ mod agent_draft_prompt_tests { assert!(prompt.contains("buzz messages send ... --content -")); } + #[test] + fn shared_base_prompt_teaches_content_file_for_non_ascii_on_windows() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("buzz messages send ... --content-file msg.md")); + assert!(prompt.contains("On Windows always use `--content-file`")); + } + #[test] fn shared_base_prompt_teaches_repo_context_and_learning_loop() { let prompt = include_str!("base_prompt.md"); diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 29ec19a04c2..c610a65ab11 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -32,6 +32,7 @@ export BUZZ_RELAY_URL="https://relay.example.com" buzz messages send --channel --content "Hello" buzz messages send --channel --content "Reply" --reply-to --broadcast buzz messages send --channel --content - < message.md # read body from stdin +buzz messages send --channel --content-file message.md # UTF-8 file; use on Windows (PowerShell pipes drop non-ASCII) buzz messages get --channel --limit 20 buzz messages thread --channel --event buzz messages thread --link 'buzz://message?channel=&id=&thread=' diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index f956d0f7f9d..1fd1ddc2b1a 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -15,7 +15,7 @@ use crate::commands::agents::fetch_archived_snapshot; use crate::commands::channel_templates::{self, ChannelTemplateRecord, TemplateAgentRoster}; use crate::commands::users::presence_subject; use crate::error::CliError; -use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid}; +use crate::validate::{parse_uuid, read_content_or_file, validate_hex64, validate_uuid}; fn extract_channel_metadata(e: &serde_json::Value) -> serde_json::Value { serde_json::json!({ @@ -1825,7 +1825,6 @@ pub async fn cmd_set_canvas( channel_id: &str, content: &str, ) -> Result<(), CliError> { - let content = read_or_stdin(content)?; let channel_uuid = parse_uuid(channel_id)?; // `set` is an unconditional replace — no `expected-revision` tag, so the @@ -1846,11 +1845,11 @@ pub async fn cmd_set_canvas( let head_created_at = head.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); buzz_sdk::build_set_canvas_unconditional_after_head( channel_uuid, - &content, + content, head_created_at, ) } - None => buzz_sdk::build_set_canvas(channel_uuid, &content, None), + None => buzz_sdk::build_set_canvas(channel_uuid, content, None), } .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; @@ -1971,7 +1970,14 @@ pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &BuzzClient) -> Resu CanvasCmd::Get { channel, revision } => { cmd_get_canvas(client, &channel, revision.as_deref()).await } - CanvasCmd::Set { channel, content } => cmd_set_canvas(client, &channel, &content).await, + CanvasCmd::Set { + channel, + content, + content_file, + } => { + let content = read_content_or_file(content.as_deref(), content_file.as_deref())?; + cmd_set_canvas(client, &channel, &content).await + } CanvasCmd::History { channel, limit } => cmd_canvas_history(client, &channel, limit).await, CanvasCmd::Restore { channel, revision } => { cmd_restore_canvas(client, &channel, &revision, &mut std::io::stdout()).await diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 7c90d47b423..8ae756617d5 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -4,7 +4,9 @@ use crate::client::BuzzClient; use crate::commands::with_git_provenance; use crate::commands::GIT_ORIGIN_CHANNEL_ENV; use crate::error::CliError; -use crate::validate::{read_or_stdin, sdk_err, validate_hex64, validate_repo_id}; +use crate::validate::{ + read_content_or_file, read_or_stdin, sdk_err, validate_hex64, validate_repo_id, +}; use buzz_sdk::{GitIssueMeta, GitRepoCoord, GitStatusMeta}; use nostr::Timestamp; use serde::Deserialize; @@ -240,7 +242,6 @@ pub async fn cmd_create_issue( ) -> Result<(), CliError> { validate_hex64(repo_owner)?; validate_repo_id(repo_id)?; - let body = read_or_stdin(content)?; let meta = GitIssueMeta { labels: labels.to_vec(), @@ -253,7 +254,7 @@ pub async fn cmd_create_issue( }; let builder = with_git_provenance( - buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?, + buzz_sdk::build_git_issue(&repo, subject, content, &meta).map_err(sdk_err)?, )?; let event = client.sign_event(builder)?; let event_id = event.id.to_hex(); @@ -598,9 +599,12 @@ pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), channel, title, content, + content_file, label, to, } => { + // Read the body first so an unreadable file fails before a repo is created. + let body = read_content_or_file(content.as_deref(), content_file.as_deref())?; let (repo_owner, repo_id) = resolve_issue_repo_target( client, repo_owner.as_deref(), @@ -608,7 +612,7 @@ pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), channel.as_deref(), ) .await?; - cmd_create_issue(client, &repo_owner, &repo_id, &title, &content, &label, &to).await + cmd_create_issue(client, &repo_owner, &repo_id, &title, &body, &label, &to).await } IssuesCmd::Get { event } => cmd_get_issue(client, &event).await, IssuesCmd::List { diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index f80f928d316..bd53ac1da74 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use crate::client::{normalize_events, normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::{ - infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff, + infer_language, parse_event_id, parse_uuid, read_content_or_file, read_or_stdin, truncate_diff, validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, }; use buzz_sdk::mentions::{ @@ -608,15 +608,7 @@ pub struct SendMessageParams { pub mentions: Vec, } -pub async fn cmd_send_message( - client: &BuzzClient, - mut p: SendMessageParams, -) -> Result<(), CliError> { - // Allow '-' to read content from stdin. This keeps callers from having to - // jam shell-metacharacter-heavy text (backticks, $vars, etc.) through argv - // quoting — the source of countless self-inflicted command-substitution - // bugs for agent and human users alike. - p.content = read_or_stdin(&p.content)?; +pub async fn cmd_send_message(client: &BuzzClient, p: SendMessageParams) -> Result<(), CliError> { validate_content_size(&p.content)?; if let Some(ref r) = p.reply_to { validate_hex64(r)?; @@ -940,6 +932,7 @@ pub async fn dispatch( MessagesCmd::Send { channel, content, + content_file, kind, reply_to, broadcast, @@ -950,7 +943,12 @@ pub async fn dispatch( client, SendMessageParams { channel_id: channel, - content, + // Allow '-' (stdin) or --content-file. This keeps callers from + // having to jam shell-metacharacter-heavy text (backticks, + // $vars, etc.) through argv quoting — the source of countless + // self-inflicted command-substitution bugs for agent and human + // users alike. + content: read_content_or_file(content.as_deref(), content_file.as_deref())?, kind, reply_to, broadcast, @@ -1815,6 +1813,40 @@ mod tests { ); } + #[tokio::test] + async fn send_content_file_preserves_non_ascii_through_cli_dispatch() { + use clap::Parser; + // Windows PowerShell pipes turn these into `?`; --content-file must not. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("msg.txt"); + std::fs::write(&path, "\u{feff}Ship it \u{2014} now \u{1f680}\n").unwrap(); + + let cli = crate::Cli::try_parse_from([ + "buzz", + "messages", + "send", + "--channel", + SEND_TEST_CHANNEL, + "--content-file", + path.to_str().unwrap(), + ]) + .unwrap(); + let crate::Cmd::Messages(cmd) = cli.command else { + panic!("expected a messages command"); + }; + + let (url, _, captured_event) = fake_send_relay(send_palette_response()).await; + let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + super::dispatch(cmd, &client, &crate::OutputFormat::Json) + .await + .unwrap(); + + let raw = captured_event.lock().unwrap(); + let raw = raw.as_ref().expect("event must have been submitted"); + let event: serde_json::Value = serde_json::from_str(&raw.body).unwrap(); + assert_eq!(event["content"], "Ship it \u{2014} now \u{1f680}\n"); + } + #[tokio::test] async fn cmd_send_message_succeeds_when_palette_query_errors() { // Palette enrichment is decorative — a 500 from the `/query` endpoint diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index c2f6b7e089c..2ee4d5bbb51 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -410,15 +410,19 @@ buzz agents archived" pub enum MessagesCmd { /// Send a message to a channel #[command( - after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel --content -" + after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel --content -\n buzz messages send --channel --content-file message.md # UTF-8 file; use this on Windows" )] Send { /// Channel UUID (from 'buzz channels list') #[arg(long)] channel: String, /// Message text — supports @mentions and markdown. Use '-' to read from stdin. - #[arg(long)] - content: String, + #[arg(long, required_unless_present = "content_file")] + content: Option, + /// Read the message text from a UTF-8 file. Use this on Windows, where + /// PowerShell pipes turn non-ASCII characters into '?'. + #[arg(long, conflicts_with = "content")] + content_file: Option, /// Nostr event kind (default: channel default) #[arg(long)] kind: Option, @@ -771,8 +775,11 @@ pub enum CanvasCmd { #[arg(long)] channel: String, /// Canvas content (markdown; use '-' to read from stdin) - #[arg(long)] - content: String, + #[arg(long, required_unless_present = "content_file")] + content: Option, + /// Read the canvas content from a UTF-8 file (use this on Windows) + #[arg(long, conflicts_with = "content")] + content_file: Option, }, /// List canvas revision history for a channel, newest first History { @@ -1790,8 +1797,11 @@ pub enum IssuesCmd { #[arg(long, alias = "subject")] title: String, /// Issue body, markdown. Use '-' to read from stdin. - #[arg(long)] - content: String, + #[arg(long, required_unless_present = "content_file")] + content: Option, + /// Read the issue body from a UTF-8 file (use this on Windows) + #[arg(long, conflicts_with = "content")] + content_file: Option, /// Label — can be specified multiple times #[arg(long = "label")] label: Vec, @@ -2352,6 +2362,29 @@ mod tests { } } + #[test] + fn content_and_content_file_are_mutually_exclusive() { + let channel = "123e4567-e89b-12d3-a456-426614174000"; + for cmd in [ + &["messages", "send", "--channel", channel][..], + &["canvas", "set", "--channel", channel][..], + &["issues", "create", "--title", "t"][..], + ] { + let parse = |extra: &[&str]| { + Cli::try_parse_from(["buzz"].iter().chain(cmd).chain(extra).copied()) + }; + let both = parse(&["--content", "x", "--content-file", "m.txt"]); + assert_eq!( + both.err().map(|e| e.kind()), + Some(clap::error::ErrorKind::ArgumentConflict), + "{cmd:?}" + ); + assert!(parse(&[]).is_err(), "{cmd:?} must require one of them"); + assert!(parse(&["--content", "x"]).is_ok(), "{cmd:?}"); + assert!(parse(&["--content-file", "m.txt"]).is_ok(), "{cmd:?}"); + } + } + #[test] fn set_status_requires_text_or_clear() { assert!(Cli::try_parse_from(["buzz", "users", "set-status"]).is_err()); diff --git a/crates/buzz-cli/src/validate.rs b/crates/buzz-cli/src/validate.rs index 4985b441417..55b66fed9aa 100644 --- a/crates/buzz-cli/src/validate.rs +++ b/crates/buzz-cli/src/validate.rs @@ -178,6 +178,33 @@ pub fn read_or_stdin(value: &str) -> Result { } } +/// Resolve a `--content` value (literal text, or `-` for stdin) or, when +/// given, a `--content-file` path read as UTF-8. +/// +/// The file path exists for Windows: PowerShell 5.1 re-encodes piped text +/// with `$OutputEncoding` (ASCII), so non-ASCII characters reach stdin as `?`. +pub fn read_content_or_file( + content: Option<&str>, + content_file: Option<&str>, +) -> Result { + let Some(path) = content_file else { + return read_or_stdin(content.unwrap_or_default()); + }; + let bytes = std::fs::read(path) + .map_err(|e| CliError::Usage(format!("failed to read {path:?}: {e}")))?; + let text = String::from_utf8(bytes).map_err(|e| { + CliError::Usage(format!( + "{path:?} is not valid UTF-8 (invalid byte at offset {}); save it as UTF-8", + e.utf8_error().valid_up_to() + )) + })?; + // `Set-Content -Encoding UTF8` in Windows PowerShell 5.1 writes a BOM. + Ok(match text.strip_prefix('\u{feff}') { + Some(rest) => rest.to_owned(), + None => text, + }) +} + /// Read content from a file path, or stdin if the value is "-". /// /// Unlike [`read_or_stdin`], `value` is never treated as literal content — @@ -503,4 +530,28 @@ mod tests { let err = super::read_file_or_stdin("0001-does-not-exist.patch").unwrap_err(); assert!(matches!(err, CliError::Usage(_))); } + + // --- read_content_or_file --- + + #[test] + fn read_content_or_file_strips_one_bom_and_rejects_invalid_utf8() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("msg.txt"); + let path_str = path.to_str().unwrap(); + + std::fs::write(&path, "\u{feff}\u{feff}a — b 🚀").unwrap(); + assert_eq!( + super::read_content_or_file(None, Some(path_str)).unwrap(), + "\u{feff}a — b 🚀", + "exactly one leading BOM is dropped" + ); + + // Windows-1252 em dash: what an ANSI-encoded save produces. + std::fs::write(&path, b"a \x97 b").unwrap(); + let err = super::read_content_or_file(None, Some(path_str)).unwrap_err(); + assert!( + matches!(&err, CliError::Usage(msg) if msg.contains("not valid UTF-8")), + "got {err:?}" + ); + } } From 842effd0435d9a45fd2c10e96254564e18d7c007 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 13 Oct 2026 22:04:47 -0500 Subject: [PATCH 10/11] fix: address review findings The base prompt told agents to write outgoing text to a fixed `msg.md` and send it with `--content-file msg.md`. All pool workers of one agent share a single working directory and desktop runs up to 10 turns at once, so two channels' turns could overwrite each other's file and post one channel's text (possibly private) into another. A relative msg.md also lands in the agent's checkout, where `git add -A` commits it. The prompt now asks for a new file per message in the OS temp directory, named with the channel UUID plus a unique suffix, passed by absolute path and deleted after sending. The prompt test asserts the new example. Signed-off-by: QuicksilverSlick Co-Authored-By: Claude Opus 5.5 --- crates/buzz-acp/src/base_prompt.md | 2 +- crates/buzz-acp/src/lib.rs | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 088129e1dfe..5c912a56330 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -15,7 +15,7 @@ Auth env vars: `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`. Exit codes 0 ok, 1 user error, 2 network, 3 auth, 4 other, 5 write conflict. Output is structured JSON. `--format compact` is global — it goes before the subcommand. -Run `buzz --help` or `buzz --help` for full usage. For multiline or non-ASCII content (em dashes, curly quotes, emoji), write the text to a UTF-8 file with your file-editing tool and pass its path: `buzz messages send ... --content-file msg.md` (`buzz issues create` and `buzz canvas set` take `--content-file` too). On Windows always use `--content-file`: PowerShell re-encodes piped text, so every non-ASCII character arrives as `?`. Elsewhere you can also pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat. +Run `buzz --help` or `buzz --help` for full usage. For multiline or non-ASCII content (em dashes, curly quotes, emoji), write the text with your file-editing tool to a new UTF-8 file in the OS temp directory, named with the channel UUID plus a timestamp or random suffix, pass its absolute path, and delete the file after sending: `buzz messages send ... --content-file /buzz--.md` (`buzz issues create` and `buzz canvas set` take `--content-file` too). Never reuse a fixed file name or write the file inside a git checkout: turns in other channels run in parallel from the same working directory and would send each other's text, and `git add -A` would commit it. On Windows always use `--content-file`: PowerShell re-encodes piped text, so every non-ASCII character arrives as `?`. Elsewhere you can also pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat. When opening a pull request in response to channel work, always pass `--channel ` using the UUID from ``. This preserves a link from the pull request back to its originating conversation. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 31cf6e7b063..0ccacf3d1c0 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -6136,7 +6136,11 @@ mod agent_draft_prompt_tests { #[test] fn shared_base_prompt_teaches_content_file_for_non_ascii_on_windows() { let prompt = include_str!("base_prompt.md"); - assert!(prompt.contains("buzz messages send ... --content-file msg.md")); + assert!(prompt.contains( + "buzz messages send ... --content-file /buzz--.md" + )); + assert!(prompt + .contains("Never reuse a fixed file name or write the file inside a git checkout")); assert!(prompt.contains("On Windows always use `--content-file`")); } From b2addcdf7bd4d6b8842c4462c3246da75a14ab45 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 13 Oct 2026 22:06:33 -0500 Subject: [PATCH 11/11] fix(desktop): point the codex-acp error at Dreamforge's own adapter install A terminal `npm install -g` only updates the system copy, while Dreamforge runs the adapter it installed under its private Node prefix, so the advice left the error in place. Send people to Settings > Agents > Agent runtimes, which installs the pinned version where Dreamforge looks. Co-Authored-By: Claude Opus 5.5 Signed-off-by: QuicksilverSlick --- desktop/src/features/agents/lib/friendlyAgentLastError.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.ts b/desktop/src/features/agents/lib/friendlyAgentLastError.ts index ace61b5b113..1ef234369a2 100644 --- a/desktop/src/features/agents/lib/friendlyAgentLastError.ts +++ b/desktop/src/features/agents/lib/friendlyAgentLastError.ts @@ -43,7 +43,7 @@ export const MODEL_NOT_FOUND_COPY = "The configured model is not available — open agent settings and select a different one from the dropdown."; export const CLI_ACP_INTERNAL_ERROR_COPY = - "The agent's harness reported an internal error. For Codex agents this can mean the configured model isn't supported by your installed codex-acp — check the model in `~/.codex/config.toml` or reinstall the adapter (`npm install -g @agentclientprotocol/codex-acp@1.13.1`)."; + "The agent's harness reported an internal error. For Codex agents this can mean the configured model isn't supported by your installed codex-acp — check the model in `~/.codex/config.toml` or reinstall the adapter from Settings → Agents → Agent runtimes."; const EMBEDDED_CODE_RE = /^Agent reported error \(code (-?\d+)\): /; /** Bare form of the standard JSON-RPC -32603 message (after stripping the ACP wrapper prefix). */