diff --git a/ROADMAP.md b/ROADMAP.md index 7ae78df5..923b07c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7559,3 +7559,5 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 696. **`claw compact` (and likely other REPL-only commands) hangs indefinitely in non-interactive mode with no TTY — there is no timeout, no stdin-closed guard, and no `--output-format json` fast-exit path** — dogfooded 2026-05-25 on `bb2a9238`. Running `./rust/target/debug/claw compact --output-format json --help ` flag or default 30 s watchdog for non-interactive invocations; (d) add regression coverage proving `claw compact ` silently returns `status:"ok"` with exit 0 when the named plugin does not exist — no `not_found` error, no non-zero exit, no indication the operation was a no-op; sibling: `claw agents ` returns `action:"help"` with exit 0 instead of a typed `unknown_subcommand` error** — dogfooded 2026-05-25 on `63a5a874`. Reproduction: `claw plugins remove nonexistent-plugin --output-format json "}` with exit 1 when the plugin is absent; (b) `agents ` must emit `{"kind":"agents","action":"error","error_kind":"unknown_subcommand","subcommand":"","supported":["list","help"]}` with exit 1 instead of falling back to help output with exit 0; (c) add regression tests proving both paths exit 1 with typed error envelopes. **Why this matters:** idempotent-but-silent remove is fine for infrastructure tools with explicit idempotency contracts; claw has no such contract, and `status:"ok"` for a name-miss means automation cannot audit whether a remove actually ran vs was a no-op. Source: Jobdori dogfood on `63a5a874`, 2026-05-25. + +698. **Config deprecation warnings emit once per `ConfigLoader::load()` call, so surfaces that call `load()` multiple times in a single invocation emit duplicate `warning:` lines to stderr — `claw plugins list` and `claw mcp list` each print the same deprecation warning twice** — dogfooded 2026-05-25 on `c345ce6d`. Reproduction: `echo '{"enabledPlugins": {}}' > ~/.claw/settings.json && claw plugins list 2>&1 | grep warning` prints the same `field "enabledPlugins" is deprecated. Use "plugins.enabled" instead` line twice. Root cause: `config.rs:304` emits `eprintln!("warning: {warning}")` for every warning in every `loader.load()` call; surfaces like `plugins_command_payload_for` and `render_mcp_report_json_for` each trigger an independent `loader.load()` (one for runtime config, one inside the command handler), multiplying the stderr output. `skills list` emits only one warning because its command path calls `load()` once; `plugins` and `mcp` emit two. **Required fix shape:** (a) track already-emitted warning strings in a process-lifetime `std::sync::OnceLock>>` in `config.rs` and skip re-emitting duplicates within the same process run; or (b) collect all warnings at a single call site after all config loads are complete and emit once with dedup; or (c) change `load()` to return warnings alongside the result instead of eagerly printing them, letting call sites emit once. Option (a) is a minimal one-file fix. **Why this matters:** duplicate warnings make the CLI look buggy, cause CI log noise, and — when the deprecation warning fires on every invocation — are more likely to be `tail -f`'d away than acted on. A single clean warning per invocation is the standard. Source: Jobdori dogfood on `c345ce6d`, 2026-05-25. diff --git a/rust/crates/runtime/Cargo.toml b/rust/crates/runtime/Cargo.toml index b1bd04f3..38436133 100644 --- a/rust/crates/runtime/Cargo.toml +++ b/rust/crates/runtime/Cargo.toml @@ -16,5 +16,8 @@ telemetry = { path = "../telemetry" } tokio = { version = "1", features = ["io-std", "io-util", "macros", "process", "rt", "rt-multi-thread", "time"] } walkdir = "2" +[dev-dependencies] +tempfile = "3" + [lints] workspace = true diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index e9fb5d65..073eb6fb 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -1,7 +1,22 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::fmt::{Display, Formatter}; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +/// Process-lifetime set of already-emitted config deprecation warning strings. +/// Prevents duplicate warnings when `ConfigLoader::load()` is called multiple +/// times within a single CLI invocation. (ROADMAP #698) +static EMITTED_CONFIG_WARNINGS: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +fn emit_config_warning_once(warning: &str) { + let set = EMITTED_CONFIG_WARNINGS.get_or_init(|| Mutex::new(HashSet::new())); + let mut guard = set.lock().unwrap_or_else(|e| e.into_inner()); + if guard.insert(warning.to_string()) { + eprintln!("warning: {warning}"); + } +} use crate::json::JsonValue; use crate::sandbox::{FilesystemIsolationMode, SandboxConfig}; @@ -301,7 +316,7 @@ impl ConfigLoader { } for warning in &all_warnings { - eprintln!("warning: {warning}"); + emit_config_warning_once(&warning.to_string()); } let merged_value = JsonValue::Object(merged.clone());