Compare commits

...
12 Commits
Author SHA1 Message Date
Timmy 3198309db5 Bump version to 0.14.2
Publish sled artifact / publish-dev-artifact (push) Canceled after 0s
2026-07-19 00:31:16 +01:00
Huskies Agent 59481515b5 huskies: regen source-map.json 2026-07-18 19:56:30 +00:00
Huskies Agent a6cce683f5 huskies: merge 1222 bug show fails with "content unavailable" on done/archived stories — content should never be evicted 2026-07-18 19:56:30 +00:00
Huskies Agent 2b0e8e6f10 huskies: merge 1225 bug Work-item tools use active project as an implicit global; project arg ignored, creates/reads misfile silently 2026-07-18 19:20:46 +00:00
Timmy 34fe84fdd9 Bump version to 0.14.1
Publish sled artifact / publish-dev-artifact (push) Canceled after 0s
2026-07-18 19:08:17 +01:00
Huskies Agent a4e70af157 huskies: merge 1214 bug find_free_port can return a reserved/occupied port (2200), flaking its test and merge gates 2026-07-18 14:18:29 +00:00
Huskies Agent 15e9aa6b34 huskies: regen source-map.json 2026-07-18 13:58:35 +00:00
Huskies Agent 7d3de2bb44 huskies: merge 1218 story Remembered/sticky permission approvals so constrained agents stop re-prompting for the same action class 2026-07-18 13:58:35 +00:00
Huskies Agent fbaf5bf959 huskies: regen source-map.json 2026-07-18 13:50:49 +00:00
Huskies Agent 9c61dfa595 huskies: merge 1219 story Add runtime artifacts to the default project scaffold .gitignore 2026-07-18 13:50:49 +00:00
Huskies Agent 90cb005019 huskies: merge 1216 story Shorten long-turn notification to "Working..." 2026-07-18 12:43:46 +00:00
Huskies Agent 12580ae9ce huskies: merge 1215 story Pipeline board shows active-agent state distinctly from idle-blocked 2026-07-18 12:37:49 +00:00
39 changed files with 1241 additions and 349 deletions
+3
View File
@@ -35,3 +35,6 @@ double_timmy_log.md
pipeline.db pipeline.db
pipeline.db.bak* pipeline.db.bak*
session_store.json session_store.json
# Full untruncated merge attempt reports (one file per attempt, pruned automatically)
merge_reports/
+20 -9
View File
@@ -1671,16 +1671,21 @@
"fn init_content_store" "fn init_content_store"
], ],
"server/src/db/gc.rs": [ "server/src/db/gc.rs": [
"fn purge_ephemeral_content_keys_for_story",
"fn purge_content_keys_for_story", "fn purge_content_keys_for_story",
"fn spawn_content_gc_subscriber", "fn spawn_content_gc_subscriber",
"fn sweep_zombie_content_on_startup" "fn sweep_zombie_content_on_startup",
"fn backfill_evicted_story_content"
], ],
"server/src/db/mod.rs": [ "server/src/db/mod.rs": [
"mod content_store", "mod content_store",
"mod gc", "mod gc",
"mod ops", "mod ops",
"mod recover", "mod recover",
"mod shadow_write" "mod shadow_write",
"mod tests",
"static SHADOW_RT",
"fn ensure_shadow_db"
], ],
"server/src/db/ops.rs": [ "server/src/db/ops.rs": [
"struct ItemMeta", "struct ItemMeta",
@@ -1846,6 +1851,7 @@
"mod merge_tools", "mod merge_tools",
"mod progress", "mod progress",
"mod qa_tools", "mod qa_tools",
"mod session",
"mod shell_tools", "mod shell_tools",
"mod status_tools", "mod status_tools",
"mod story_tools", "mod story_tools",
@@ -1867,6 +1873,11 @@
"fn tool_reject_qa", "fn tool_reject_qa",
"fn tool_launch_qa_app" "fn tool_launch_qa_app"
], ],
"server/src/http/mcp/session.rs": [
"const DEFAULT_SESSION",
"static SESSION_ID",
"fn current"
],
"server/src/http/mcp/shell_tools/exec.rs": [ "server/src/http/mcp/shell_tools/exec.rs": [
"fn validate_working_dir", "fn validate_working_dir",
"fn tool_run_command" "fn tool_run_command"
@@ -2129,6 +2140,7 @@
"fn append_root_gitignore_entries" "fn append_root_gitignore_entries"
], ],
"server/src/io/fs/scaffold/mod.rs": [ "server/src/io/fs/scaffold/mod.rs": [
"fn ensure_gitignore_entries",
"fn scaffold_story_kit" "fn scaffold_story_kit"
], ],
"server/src/io/fs/scaffold/templates.rs": [ "server/src/io/fs/scaffold/templates.rs": [
@@ -2576,17 +2588,12 @@
"server/src/service/common/mod.rs": [ "server/src/service/common/mod.rs": [
"mod item_id" "mod item_id"
], ],
"server/src/service/diagnostics/io.rs": [
"fn add_permission_rule"
],
"server/src/service/diagnostics/mod.rs": [ "server/src/service/diagnostics/mod.rs": [
"mod io",
"mod permission", "mod permission",
"enum Error" "enum Error"
], ],
"server/src/service/diagnostics/permission.rs": [ "server/src/service/diagnostics/permission.rs": [
"fn generate_permission_rule", "fn generate_permission_rule"
"fn is_dominated_by_wildcard"
], ],
"server/src/service/disk_watch/io.rs": [ "server/src/service/disk_watch/io.rs": [
"fn free_space_bytes", "fn free_space_bytes",
@@ -2976,7 +2983,11 @@
"fn new", "fn new",
"fn insert", "fn insert",
"fn resolve_oldest", "fn resolve_oldest",
"fn remove_by_request_id" "fn remove_by_request_id",
"struct RememberedPermissions",
"fn new",
"fn remember",
"fn is_remembered"
], ],
"server/src/service/pipeline/mod.rs": [ "server/src/service/pipeline/mod.rs": [
"fn aggregate_pipeline_counts" "fn aggregate_pipeline_counts"
Generated
+80 -69
View File
@@ -100,9 +100,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.103" version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]] [[package]]
name = "anymap2" name = "anymap2"
@@ -221,13 +221,13 @@ dependencies = [
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.91"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.0",
] ]
[[package]] [[package]]
@@ -456,9 +456,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.67" version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@@ -1467,9 +1467,9 @@ dependencies = [
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.32" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
dependencies = [ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@@ -1482,9 +1482,9 @@ dependencies = [
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.32" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink", "futures-sink",
@@ -1492,15 +1492,15 @@ dependencies = [
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.32" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
[[package]] [[package]]
name = "futures-executor" name = "futures-executor"
version = "0.3.32" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-task", "futures-task",
@@ -1520,15 +1520,15 @@ dependencies = [
[[package]] [[package]]
name = "futures-io" name = "futures-io"
version = "0.3.32" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.32" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -1537,21 +1537,21 @@ dependencies = [
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.32" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.32" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.32" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
dependencies = [ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@@ -1925,7 +1925,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]] [[package]]
name = "huskies" name = "huskies"
version = "0.14.0" version = "0.14.2"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"async-stream", "async-stream",
@@ -2351,7 +2351,7 @@ dependencies = [
"jni-sys", "jni-sys",
"log", "log",
"simd_cesu8", "simd_cesu8",
"thiserror 2.0.18", "thiserror 2.0.19",
"walkdir", "walkdir",
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
@@ -2612,7 +2612,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3d65d46b7379dd0afa4a42f9b2269821d31afdee0111b5e0d74e3bee03553a0" checksum = "b3d65d46b7379dd0afa4a42f9b2269821d31afdee0111b5e0d74e3bee03553a0"
dependencies = [ dependencies = [
"matrix-pickle-derive", "matrix-pickle-derive",
"thiserror 2.0.18", "thiserror 2.0.19",
] ]
[[package]] [[package]]
@@ -2676,7 +2676,7 @@ dependencies = [
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.19",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tokio-util", "tokio-util",
@@ -2709,7 +2709,7 @@ dependencies = [
"ruma", "ruma",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.18", "thiserror 2.0.19",
"tokio", "tokio",
"tracing", "tracing",
"unicode-normalization", "unicode-normalization",
@@ -2730,7 +2730,7 @@ dependencies = [
"ruma", "ruma",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.18", "thiserror 2.0.19",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -2769,7 +2769,7 @@ dependencies = [
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
"subtle", "subtle",
"thiserror 2.0.18", "thiserror 2.0.19",
"time", "time",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
@@ -2803,7 +2803,7 @@ dependencies = [
"serde-wasm-bindgen", "serde-wasm-bindgen",
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
"thiserror 2.0.18", "thiserror 2.0.19",
"tokio", "tokio",
"tracing", "tracing",
"uuid", "uuid",
@@ -2833,7 +2833,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_path_to_error", "serde_path_to_error",
"thiserror 2.0.18", "thiserror 2.0.19",
"tokio", "tokio",
"tracing", "tracing",
"vodozemac", "vodozemac",
@@ -2858,7 +2858,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
"thiserror 2.0.18", "thiserror 2.0.19",
"zeroize", "zeroize",
] ]
@@ -2880,7 +2880,7 @@ dependencies = [
"serde", "serde",
"serde-wasm-bindgen", "serde-wasm-bindgen",
"smallvec", "smallvec",
"thiserror 2.0.18", "thiserror 2.0.19",
"tokio", "tokio",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
@@ -3333,7 +3333,7 @@ dependencies = [
"smallvec", "smallvec",
"sse-codec", "sse-codec",
"sync_wrapper", "sync_wrapper",
"thiserror 2.0.18", "thiserror 2.0.19",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tokio-tungstenite 0.27.0", "tokio-tungstenite 0.27.0",
@@ -3510,7 +3510,7 @@ dependencies = [
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"socket2", "socket2",
"thiserror 2.0.18", "thiserror 2.0.19",
"tokio", "tokio",
"tracing", "tracing",
"web-time", "web-time",
@@ -3533,7 +3533,7 @@ dependencies = [
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"slab", "slab",
"thiserror 2.0.18", "thiserror 2.0.19",
"tinyvec", "tinyvec",
"tracing", "tracing",
"web-time", "web-time",
@@ -3723,22 +3723,22 @@ dependencies = [
[[package]] [[package]]
name = "ref-cast" name = "ref-cast"
version = "1.0.25" version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
dependencies = [ dependencies = [
"ref-cast-impl", "ref-cast-impl",
] ]
[[package]] [[package]]
name = "ref-cast-impl" name = "ref-cast-impl"
version = "1.0.25" version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.0",
] ]
[[package]] [[package]]
@@ -3910,7 +3910,7 @@ dependencies = [
"serde", "serde",
"serde_html_form", "serde_html_form",
"serde_json", "serde_json",
"thiserror 2.0.18", "thiserror 2.0.19",
"url", "url",
"web-time", "web-time",
] ]
@@ -3939,7 +3939,7 @@ dependencies = [
"serde", "serde",
"serde_html_form", "serde_html_form",
"serde_json", "serde_json",
"thiserror 2.0.18", "thiserror 2.0.19",
"time", "time",
"tracing", "tracing",
"url", "url",
@@ -3963,7 +3963,7 @@ dependencies = [
"ruma-macros", "ruma-macros",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.18", "thiserror 2.0.19",
"tracing", "tracing",
"web-time", "web-time",
"wildmatch", "wildmatch",
@@ -3989,7 +3989,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d6cff00317675f487c4e7ccfb18875a14c5a14867b51d13f2a826053f03c432" checksum = "9d6cff00317675f487c4e7ccfb18875a14c5a14867b51d13f2a826053f03c432"
dependencies = [ dependencies = [
"js_int", "js_int",
"thiserror 2.0.18", "thiserror 2.0.19",
] ]
[[package]] [[package]]
@@ -4229,9 +4229,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [ dependencies = [
"serde_core", "serde_core",
"serde_derive", "serde_derive",
@@ -4260,22 +4260,22 @@ dependencies = [
[[package]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.228" version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.228" version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.0",
] ]
[[package]] [[package]]
@@ -4622,7 +4622,7 @@ dependencies = [
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
"smallvec", "smallvec",
"thiserror 2.0.18", "thiserror 2.0.19",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tracing", "tracing",
@@ -4661,7 +4661,7 @@ dependencies = [
"sqlx-core", "sqlx-core",
"sqlx-sqlite", "sqlx-sqlite",
"syn 2.0.119", "syn 2.0.119",
"thiserror 2.0.18", "thiserror 2.0.19",
"tokio", "tokio",
"url", "url",
] ]
@@ -4688,7 +4688,7 @@ dependencies = [
"sha1 0.11.0", "sha1 0.11.0",
"sha2 0.11.0", "sha2 0.11.0",
"sqlx-core", "sqlx-core",
"thiserror 2.0.18", "thiserror 2.0.19",
"tracing", "tracing",
] ]
@@ -4722,7 +4722,7 @@ dependencies = [
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror 2.0.18", "thiserror 2.0.19",
"tracing", "tracing",
"whoami", "whoami",
] ]
@@ -4746,7 +4746,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"serde", "serde",
"sqlx-core", "sqlx-core",
"thiserror 2.0.18", "thiserror 2.0.19",
"tracing", "tracing",
"url", "url",
] ]
@@ -4843,6 +4843,17 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "syn"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]] [[package]]
name = "sync_wrapper" name = "sync_wrapper"
version = "1.0.2" version = "1.0.2"
@@ -4917,11 +4928,11 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.18" version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [ dependencies = [
"thiserror-impl 2.0.18", "thiserror-impl 2.0.19",
] ]
[[package]] [[package]]
@@ -4937,13 +4948,13 @@ dependencies = [
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.18" version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.0",
] ]
[[package]] [[package]]
@@ -5277,7 +5288,7 @@ dependencies = [
"log", "log",
"rand 0.9.5", "rand 0.9.5",
"sha1 0.10.7", "sha1 0.10.7",
"thiserror 2.0.18", "thiserror 2.0.19",
"utf-8", "utf-8",
] ]
@@ -5296,7 +5307,7 @@ dependencies = [
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"sha1 0.11.0", "sha1 0.11.0",
"thiserror 2.0.18", "thiserror 2.0.19",
] ]
[[package]] [[package]]
@@ -5477,7 +5488,7 @@ dependencies = [
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
"subtle", "subtle",
"thiserror 2.0.18", "thiserror 2.0.19",
"x25519-dalek", "x25519-dalek",
"zeroize", "zeroize",
] ]
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "huskies", "name": "huskies",
"version": "0.14.0", "version": "0.14.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "huskies", "name": "huskies",
"version": "0.14.0", "version": "0.14.2",
"dependencies": { "dependencies": {
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"react": "^19.1.0", "react": "^19.1.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "huskies", "name": "huskies",
"private": true, "private": true,
"version": "0.14.0", "version": "0.14.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -135,7 +135,7 @@ export function PermissionDialog({
fontSize: "0.9em", fontSize: "0.9em",
}} }}
> >
Always Allow Don't ask again this session
</button> </button>
</div> </div>
</div> </div>
@@ -453,6 +453,67 @@ describe("StagePanel", () => {
expect(badge).toHaveTextContent("BLOCKED"); expect(badge).toHaveTextContent("BLOCKED");
}); });
// Story 1215 — confirms the board shows an active-agent indicator instead
// of the plain blocked icon whenever a running/pending agent is present,
// even though blocked=true, and falls back to the plain icon otherwise.
it("shows active-agent indicator instead of blocked icon when a running/pending agent is present, even if blocked=true", () => {
const items: PipelineStageItem[] = [
{
story_id: "54_story_blocked_active_agent",
name: "Blocked With Active Agent",
error: null,
merge_failure: null,
agent: { agent_name: "coder", model: "claude", status: "running" },
review_hold: null,
qa: null,
depends_on: null,
blocked: true,
},
];
render(<StagePanel title="Current" items={items} />);
const badge = screen.getByTestId("blocked-badge-54_story_blocked_active_agent");
expect(badge).not.toHaveTextContent("BLOCKED");
expect(badge).toHaveTextContent("RECOVERING");
});
it("drives the distinct indicator off the agent's running/pending status, not the blocked flag alone", () => {
const items: PipelineStageItem[] = [
{
story_id: "55_story_blocked_stale_agent",
name: "Blocked With Completed Agent",
error: null,
merge_failure: null,
agent: { agent_name: "coder", model: "claude", status: "completed" },
review_hold: null,
qa: null,
depends_on: null,
blocked: true,
},
];
render(<StagePanel title="Current" items={items} />);
const badge = screen.getByTestId("blocked-badge-55_story_blocked_stale_agent");
expect(badge).toHaveTextContent("BLOCKED");
});
it("shows the plain blocked icon for a story that is blocked with no live agent", () => {
const items: PipelineStageItem[] = [
{
story_id: "56_story_blocked_no_agent",
name: "Blocked No Agent",
error: null,
merge_failure: null,
agent: null,
review_hold: null,
qa: null,
depends_on: null,
blocked: true,
},
];
render(<StagePanel title="Current" items={items} />);
const badge = screen.getByTestId("blocked-badge-56_story_blocked_no_agent");
expect(badge).toHaveTextContent("BLOCKED");
});
it("shows spinning icon for merge_failure item with running mergemaster", () => { it("shows spinning icon for merge_failure item with running mergemaster", () => {
const items: PipelineStageItem[] = [ const items: PipelineStageItem[] = [
{ {
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "huskies" name = "huskies"
version = "0.14.0" version = "0.14.2"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"
+1
View File
@@ -82,6 +82,7 @@ pub(super) fn build_agent_app_context(
permission_registry, permission_registry,
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(), pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
permission_timeout_secs: 120, permission_timeout_secs: 120,
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
status: agents.status_broadcaster(), status: agents.status_broadcaster(),
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)), chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
}); });
@@ -317,6 +317,7 @@ mod tests {
permission_registry: ResponderRegistry::new(), permission_registry: ResponderRegistry::new(),
pending_perm_replies: PendingPermReplies::new(), pending_perm_replies: PendingPermReplies::new(),
permission_timeout_secs: 120, permission_timeout_secs: 120,
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
status: Arc::new(crate::service::status::StatusBroadcaster::new()), status: Arc::new(crate::service::status::StatusBroadcaster::new()),
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)), chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
}) })
@@ -19,7 +19,7 @@ use super::format_user_prompt;
/// Text posted to the room by [`spawn_digging_in_watcher`] when a turn runs /// Text posted to the room by [`spawn_digging_in_watcher`] when a turn runs
/// long without emitting any user-facing text. /// long without emitting any user-facing text.
const DIGGING_IN_MESSAGE: &str = "Still digging in — this turn is taking a bit longer than usual."; const DIGGING_IN_MESSAGE: &str = "Working...";
/// Spawns a background watcher that posts a single "digging in" notice to /// Spawns a background watcher that posts a single "digging in" notice to
/// `room_id` if `threshold` elapses before `sent_any_text` becomes `true`. /// `room_id` if `threshold` elapses before `sent_any_text` becomes `true`.
@@ -392,7 +392,7 @@ mod tests {
} }
/// AC 1: a turn that runs longer than the threshold without sending any /// AC 1: a turn that runs longer than the threshold without sending any
/// text gets exactly one "digging in" notice. /// text gets exactly one "Working..." notice.
#[tokio::test] #[tokio::test]
async fn digging_in_fires_after_threshold_when_no_text_sent() { async fn digging_in_fires_after_threshold_when_no_text_sent() {
let transport = Arc::new(CapturingTransport::new()); let transport = Arc::new(CapturingTransport::new());
@@ -405,14 +405,7 @@ mod tests {
); );
handle.await.unwrap(); handle.await.unwrap();
assert_eq!(transport.sent_count(), 1); assert_eq!(transport.sent_count(), 1);
assert!( assert_eq!(transport.last_message().unwrap(), "Working...");
transport
.last_message()
.unwrap()
.to_lowercase()
.contains("digging in"),
"notice should mention 'digging in'"
);
} }
/// AC 2: if text is sent before the threshold elapses, the watcher must /// AC 2: if text is sent before the threshold elapses, the watcher must
@@ -173,6 +173,7 @@ mod tests {
permission_registry: crate::service::permission_router::ResponderRegistry::new(), permission_registry: crate::service::permission_router::ResponderRegistry::new(),
pending_perm_replies: PendingPermReplies::new(), pending_perm_replies: PendingPermReplies::new(),
permission_timeout_secs: 120, permission_timeout_secs: 120,
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
status: Arc::new(crate::service::status::StatusBroadcaster::new()), status: Arc::new(crate::service::status::StatusBroadcaster::new()),
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)), chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
}) })
@@ -624,6 +624,7 @@ mod tests {
permission_registry: registry, permission_registry: registry,
pending_perm_replies: PendingPermReplies::new(), pending_perm_replies: PendingPermReplies::new(),
permission_timeout_secs: 120, permission_timeout_secs: 120,
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
status: Arc::new(crate::service::status::StatusBroadcaster::new()), status: Arc::new(crate::service::status::StatusBroadcaster::new()),
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)), chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
}); });
@@ -655,6 +656,7 @@ mod tests {
permission_registry: ResponderRegistry::new(), permission_registry: ResponderRegistry::new(),
pending_perm_replies: PendingPermReplies::new(), pending_perm_replies: PendingPermReplies::new(),
permission_timeout_secs: 120, permission_timeout_secs: 120,
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
status: Arc::new(crate::service::status::StatusBroadcaster::new()), status: Arc::new(crate::service::status::StatusBroadcaster::new()),
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)), chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
}); });
@@ -1440,12 +1440,18 @@ fn interpret_docker_run_error(stderr: &str, image: &str) -> String {
} }
} }
/// Host ports reserved for the sled SSH range and never handed out as a
/// newly allocated `ssh_port`, even when momentarily bindable during a scan.
const RESERVED_SSH_PORTS: std::ops::RangeInclusive<u16> = 2200..=2202;
/// Scan `start..start+range` for a bindable TCP port on 127.0.0.1. /// Scan `start..start+range` for a bindable TCP port on 127.0.0.1.
/// ///
/// Returns `Some(port)` for the first port that can be bound, or `None` if all /// Returns `Some(port)` for the first port that can be bound, or `None` if all
/// ports in the range are occupied. /// ports in the range are occupied. Ports in [`RESERVED_SSH_PORTS`] are never
/// returned, even if bindable at scan time.
fn find_free_port_in_range(start: u16, range: u16) -> Option<u16> { fn find_free_port_in_range(start: u16, range: u16) -> Option<u16> {
(start..start.saturating_add(range)) (start..start.saturating_add(range))
.filter(|port| !RESERVED_SSH_PORTS.contains(port))
.find(|&port| std::net::TcpListener::bind(("127.0.0.1", port)).is_ok()) .find(|&port| std::net::TcpListener::bind(("127.0.0.1", port)).is_ok())
} }
@@ -1720,6 +1726,21 @@ mod tests {
assert_eq!(find_free_port_in_range(port, 1), None); assert_eq!(find_free_port_in_range(port, 1), None);
} }
#[test]
fn find_free_port_skips_reserved_ssh_range() {
// The reserved 2200-2202 sled SSH range must never be handed out as
// a newly allocated ssh_port, even when those ports are bindable at
// scan time. Loop many times to rule out a flaky, timing-dependent
// pass rather than trusting a single lucky draw.
for _ in 0..100 {
let port = find_free_port(2200).expect("expected Some(port) in scan range");
assert!(
!RESERVED_SSH_PORTS.contains(&port),
"returned port {port} falls within the reserved 2200-2202 SSH range"
);
}
}
#[test] #[test]
fn detect_stack_go_mod() { fn detect_stack_go_mod() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -314,6 +314,7 @@ mod tests {
permission_registry: crate::service::permission_router::ResponderRegistry::new(), permission_registry: crate::service::permission_router::ResponderRegistry::new(),
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(), pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
permission_timeout_secs: 120, permission_timeout_secs: 120,
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)), chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
}); });
Arc::new(WhatsAppWebhookContext { Arc::new(WhatsAppWebhookContext {
+309 -34
View File
@@ -1,8 +1,10 @@
//! Content-store garbage collection: TransitionFired subscriber and startup sweep. //! Content-store garbage collection: TransitionFired subscriber, startup
//! sweep, and story-content backfill.
//! //!
//! When a pipeline item reaches a terminal stage (Done, Archived, Abandoned, //! Two purge paths clear the *ephemeral* `ContentKey::*` working-state keys
//! Superseded, Rejected) every `ContentKey::*` entry for that story is purged //! (gate output, respawn counters, merge-fixup flags, etc.) when a pipeline
//! from the in-memory content store. There are two purge paths: //! item reaches a terminal stage (Done, Archived, Abandoned, Superseded,
//! Rejected):
//! //!
//! 1. **Subscriber** ([`spawn_content_gc_subscriber`]) — reacts to //! 1. **Subscriber** ([`spawn_content_gc_subscriber`]) — reacts to
//! [`crate::pipeline_state::TransitionFired`] events and runs for new //! [`crate::pipeline_state::TransitionFired`] events and runs for new
@@ -10,19 +12,35 @@
//! //!
//! 2. **Startup sweep** ([`sweep_zombie_content_on_startup`]) — cleans up //! 2. **Startup sweep** ([`sweep_zombie_content_on_startup`]) — cleans up
//! zombie entries left over from sessions that predate the subscriber. //! zombie entries left over from sessions that predate the subscriber.
//!
//! `ContentKey::Story` — the story's markdown body — is deliberately
//! excluded from both paths (story 1222): reaching a terminal stage is not
//! deletion, and `show()` must keep returning content for Done/Archived
//! stories indefinitely. The story body is only fully purged
//! ([`purge_content_keys_for_story`]) when a story is genuinely tombstoned
//! (absent from the live CRDT index — `evict_item`/`purge_story`), which
//! the startup sweep still detects and cleans up.
//!
//! [`backfill_evicted_story_content`] is an idempotent, run-on-every-startup
//! repair pass that restores `ContentKey::Story` for any terminal-stage
//! story whose in-memory content was wiped by the pre-1222 purge bug, using
//! the durable SQLite `pipeline_items.content` shadow column (which the
//! purge never touched). Stories with no SQLite copy either are reported as
//! unrecoverable.
use crate::db::{ContentKey, all_content_ids, delete_content}; use crate::db::{ContentKey, all_content_ids, delete_content, get_shared_pool, write_content};
use crate::pipeline_state::{Pipeline, Stage, Status}; use crate::pipeline_state::{Pipeline, Stage, Status};
use crate::slog; use crate::slog;
use crate::slog_warn; use crate::slog_warn;
/// Purge every [`ContentKey`] variant for `story_id` from the in-memory content store. /// Purge the eight ephemeral (non-body) `ContentKey` variants for
/// `story_id` — everything except `ContentKey::Story`.
/// ///
/// All eight key namespaces are deleted unconditionally — deletes for absent /// Call this when a work item reaches a terminal stage: agent working-state
/// keys are no-ops. Call this when a work item reaches a terminal stage to /// (gate output, respawn counters, merge-fixup flags) is no longer needed,
/// prevent long-lived zombie entries from accumulating in the process heap. /// but the story's markdown body must be retained indefinitely so `show()`
pub(crate) fn purge_content_keys_for_story(story_id: &str) { /// keeps working (story 1222). Deletes for absent keys are no-ops.
delete_content(ContentKey::Story(story_id)); pub(crate) fn purge_ephemeral_content_keys_for_story(story_id: &str) {
delete_content(ContentKey::GateOutput(story_id)); delete_content(ContentKey::GateOutput(story_id));
delete_content(ContentKey::AbortRespawnCount(story_id)); delete_content(ContentKey::AbortRespawnCount(story_id));
delete_content(ContentKey::MergeMasterSpawnCount(story_id)); delete_content(ContentKey::MergeMasterSpawnCount(story_id));
@@ -33,13 +51,28 @@ pub(crate) fn purge_content_keys_for_story(story_id: &str) {
delete_content(ContentKey::MergeReport(story_id)); delete_content(ContentKey::MergeReport(story_id));
} }
/// Spawn a background task that purges content-store entries when a story reaches a terminal stage. /// Purge every `ContentKey` variant for `story_id`, including the markdown
/// body (`ContentKey::Story`), from the in-memory content store.
/// ///
/// Subscribes to [`crate::pipeline_state::subscribe_transitions`]. On each /// Use this ONLY for stories that are genuinely gone — tombstoned / absent
/// from the live CRDT index via `evict_item`/`purge_story`. A terminal
/// pipeline stage (Done, Archived, …) is NOT deletion — use
/// [`purge_ephemeral_content_keys_for_story`] for that case so `show()`
/// keeps returning content (story 1222). Deletes for absent keys are no-ops.
pub(crate) fn purge_content_keys_for_story(story_id: &str) {
delete_content(ContentKey::Story(story_id));
purge_ephemeral_content_keys_for_story(story_id);
}
/// Spawn a background task that purges ephemeral content-store entries when a story reaches a terminal stage.
///
/// Subscribes to [`crate::pipeline_state::subscribe_transitions`]. On each
/// [`crate::pipeline_state::TransitionFired`] where `after` is `Done`, /// [`crate::pipeline_state::TransitionFired`] where `after` is `Done`,
/// `Archived`, `Abandoned`, `Superseded`, or `Rejected`, all `ContentKey::*` /// `Archived`, `Abandoned`, `Superseded`, or `Rejected`, the eight ephemeral
/// entries for that story are purged. Lag events are logged as warnings — /// `ContentKey::*` entries for that story are purged — the markdown body
/// a missed event leaves zombie entries that the next startup sweep will remove. /// (`ContentKey::Story`) is retained (story 1222). Lag events are logged as
/// warnings — a missed event leaves zombie entries that the next startup
/// sweep will remove.
pub(crate) fn spawn_content_gc_subscriber() { pub(crate) fn spawn_content_gc_subscriber() {
let mut rx = crate::pipeline_state::subscribe_transitions(); let mut rx = crate::pipeline_state::subscribe_transitions();
tokio::spawn(async move { tokio::spawn(async move {
@@ -50,9 +83,9 @@ pub(crate) fn spawn_content_gc_subscriber() {
let story_id = &fired.story_id.0; let story_id = &fired.story_id.0;
slog!( slog!(
"[content-gc] Story '{story_id}' reached terminal stage; \ "[content-gc] Story '{story_id}' reached terminal stage; \
purging all content-store entries." purging ephemeral content-store entries (body retained)."
); );
purge_content_keys_for_story(story_id); purge_ephemeral_content_keys_for_story(story_id);
} }
} }
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
@@ -70,7 +103,12 @@ pub(crate) fn spawn_content_gc_subscriber() {
/// One-shot startup sweep: purge content-store entries for stories that have /// One-shot startup sweep: purge content-store entries for stories that have
/// already reached terminal stages or are absent from the CRDT. /// already reached terminal stages or are absent from the CRDT.
/// ///
/// Idempotent — safe to call more than once. Intended to clean up zombie /// Genuinely tombstoned/absent stories get a full purge (including the
/// markdown body). Stories that are still live in the CRDT but sitting in a
/// terminal stage only have their ephemeral working-state keys purged — the
/// markdown body is retained so `show()` keeps working (story 1222).
///
/// Idempotent — safe to call more than once. Intended to clean up zombie
/// entries left over from server sessions that predate the GC subscriber. /// entries left over from server sessions that predate the GC subscriber.
pub(crate) fn sweep_zombie_content_on_startup() { pub(crate) fn sweep_zombie_content_on_startup() {
let raw_keys = all_content_ids(); let raw_keys = all_content_ids();
@@ -92,25 +130,94 @@ pub(crate) fn sweep_zombie_content_on_startup() {
let mut swept = 0usize; let mut swept = 0usize;
for story_id in &story_ids { for story_id in &story_ids {
let should_purge = match crate::crdt_state::read_item(story_id) { match crate::crdt_state::read_item(story_id) {
// Tombstoned or absent from the live CRDT index — purge. // Tombstoned or absent from the live CRDT index — genuinely
None => true, // gone, safe to purge everything including the story body.
Some(item) => is_terminal_stage(item.stage()), None => {
}; purge_content_keys_for_story(story_id);
if should_purge { swept += 1;
purge_content_keys_for_story(story_id); }
swept += 1; // Still a live item, just sitting in a terminal stage — retain
// the story body (story 1222), only purge ephemeral state.
Some(item) if is_terminal_stage(item.stage()) => {
purge_ephemeral_content_keys_for_story(story_id);
swept += 1;
}
Some(_) => {}
} }
} }
if swept > 0 { if swept > 0 {
slog!( slog!(
"[content-gc] Startup sweep purged content-store entries for \ "[content-gc] Startup sweep purged ephemeral content-store entries for \
{swept} zombie story(s)." {swept} zombie story(s)."
); );
} }
} }
/// Restore in-memory `ContentKey::Story` content for terminal-stage stories
/// whose markdown body was wiped by the pre-1222 purge bug (which deleted
/// `ContentKey::Story` on every terminal transition), using the durable
/// SQLite `pipeline_items.content` shadow column — `delete_content` only
/// ever touched the in-memory map, so the SQLite copy survived.
///
/// Idempotent and cheap to run on every startup: stories that already have
/// in-memory content are skipped. Returns the story IDs that could not be
/// recovered (also absent from the SQLite shadow column) — the caller logs
/// this as a report (story 1222, AC4).
pub(crate) async fn backfill_evicted_story_content() -> Vec<String> {
let Some(pool) = get_shared_pool() else {
return Vec::new();
};
let Some(items) = crate::crdt_state::read_all_items() else {
return Vec::new();
};
let mut restored = 0usize;
let mut unrecoverable = Vec::new();
for item in items {
if !is_terminal_stage(item.stage()) {
continue;
}
let story_id = item.story_id();
if crate::db::read_content(ContentKey::Story(story_id)).is_some() {
continue;
}
let row: Result<Option<(Option<String>,)>, sqlx::Error> =
sqlx::query_as("SELECT content FROM pipeline_items WHERE id = ?1")
.bind(story_id)
.fetch_optional(pool)
.await;
match row {
Ok(Some((Some(content),))) => {
write_content(ContentKey::Story(story_id), &content);
restored += 1;
}
_ => unrecoverable.push(story_id.to_string()),
}
}
if restored > 0 {
slog!(
"[content-gc] Backfill restored content for {restored} terminal-stage \
story(s) from the SQLite shadow table."
);
}
if !unrecoverable.is_empty() {
slog_warn!(
"[content-gc] Backfill could not recover content for {} story(s) — \
compacted away with no SQLite copy: {}",
unrecoverable.len(),
unrecoverable.join(", ")
);
}
unrecoverable
}
/// Return `true` when `stage` is one of the terminal pipeline classifications. /// Return `true` when `stage` is one of the terminal pipeline classifications.
/// ///
/// Story 1086: matches via the [`Status`] projection (Done / Abandoned / /// Story 1086: matches via the [`Status`] projection (Done / Abandoned /
@@ -181,6 +288,44 @@ mod tests {
); );
} }
/// Assert the seven ephemeral ContentKey variants seeded by
/// `seed_all_keys` are absent, but `ContentKey::Story` (the markdown
/// body) is still present — story 1222.
fn assert_ephemeral_keys_absent_but_story_present(story_id: &str) {
assert!(
read_content(ContentKey::Story(story_id)).is_some(),
"Story key must be RETAINED across a terminal-stage transition (story 1222)"
);
assert!(
read_content(ContentKey::GateOutput(story_id)).is_none(),
"GateOutput key must be absent"
);
assert!(
read_content(ContentKey::AbortRespawnCount(story_id)).is_none(),
"AbortRespawnCount key must be absent"
);
assert!(
read_content(ContentKey::MergeMasterSpawnCount(story_id)).is_none(),
"MergeMasterSpawnCount key must be absent"
);
assert!(
read_content(ContentKey::RunTestsOk(story_id)).is_none(),
"RunTestsOk key must be absent"
);
assert!(
read_content(ContentKey::CommitRecoveryPending(story_id)).is_none(),
"CommitRecoveryPending key must be absent"
);
assert!(
read_content(ContentKey::MergeFixupPending(story_id)).is_none(),
"MergeFixupPending key must be absent"
);
assert!(
read_content(ContentKey::MergeFailureKind(story_id)).is_none(),
"MergeFailureKind key must be absent"
);
}
/// AC1: purge_content_keys_for_story removes all eight ContentKey namespaces. /// AC1: purge_content_keys_for_story removes all eight ContentKey namespaces.
#[test] #[test]
fn purge_clears_all_eight_content_key_namespaces() { fn purge_clears_all_eight_content_key_namespaces() {
@@ -216,10 +361,11 @@ mod tests {
assert_all_keys_absent(id); assert_all_keys_absent(id);
} }
/// AC1 + AC4: the GC subscriber reacts to an Abandoned terminal transition and /// Story 1222, AC1: the GC subscriber reacts to an Abandoned terminal
/// purges all content-store entries for the story. /// transition and purges ephemeral content-store entries for the story,
/// but RETAINS the story body so `show()` keeps working.
#[tokio::test] #[tokio::test]
async fn subscriber_purges_content_on_terminal_transition() { async fn subscriber_purges_ephemeral_keys_but_retains_story_content_on_terminal_transition() {
crate::crdt_state::init_for_test(); crate::crdt_state::init_for_test();
ensure_content_store(); ensure_content_store();
@@ -242,7 +388,7 @@ mod tests {
// Give the subscriber task time to run. // Give the subscriber task time to run.
tokio::time::sleep(std::time::Duration::from_millis(200)).await; tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert_all_keys_absent(story_id); assert_ephemeral_keys_absent_but_story_present(story_id);
} }
/// AC4: the subscriber does NOT purge content for stories that remain in /// AC4: the subscriber does NOT purge content for stories that remain in
@@ -272,8 +418,9 @@ mod tests {
tokio::time::sleep(std::time::Duration::from_millis(200)).await; tokio::time::sleep(std::time::Duration::from_millis(200)).await;
// Terminal story's content must be gone. // Terminal story's ephemeral keys must be gone, but body retained
assert_all_keys_absent(terminal_id); // (story 1222).
assert_ephemeral_keys_absent_but_story_present(terminal_id);
// Active story's main content key must still be present. // Active story's main content key must still be present.
assert!( assert!(
@@ -309,6 +456,49 @@ mod tests {
assert_all_keys_absent(story_id); assert_all_keys_absent(story_id);
} }
/// Story 1222, AC1 + AC3: a story that reaches Done but is still LIVE in
/// the CRDT (not tombstoned) must keep its body across the startup sweep
/// — only ephemeral working-state keys are purged.
#[test]
fn startup_sweep_retains_story_content_for_live_done_item() {
crate::crdt_state::init_for_test();
ensure_content_store();
let story_id = "1222_test_sweep_live_done";
write_item_with_content(
story_id,
"5_done",
"---\nname: Live Done Test\n---\n",
ItemMeta::named("Live Done Test"),
);
seed_all_keys(story_id);
sweep_zombie_content_on_startup();
assert_ephemeral_keys_absent_but_story_present(story_id);
}
/// Story 1222, AC1 + AC3: same as above but for Archived, the other
/// stage explicitly named in the AC.
#[test]
fn startup_sweep_retains_story_content_for_live_archived_item() {
crate::crdt_state::init_for_test();
ensure_content_store();
let story_id = "1222_test_sweep_live_archived";
write_item_with_content(
story_id,
"6_archived",
"---\nname: Live Archived Test\n---\n",
ItemMeta::named("Live Archived Test"),
);
seed_all_keys(story_id);
sweep_zombie_content_on_startup();
assert_ephemeral_keys_absent_but_story_present(story_id);
}
/// AC2: sweep_zombie_content_on_startup leaves active stories' content intact. /// AC2: sweep_zombie_content_on_startup leaves active stories' content intact.
#[test] #[test]
fn startup_sweep_preserves_active_story_content() { fn startup_sweep_preserves_active_story_content() {
@@ -342,4 +532,89 @@ mod tests {
sweep_zombie_content_on_startup(); sweep_zombie_content_on_startup();
sweep_zombie_content_on_startup(); sweep_zombie_content_on_startup();
} }
/// Story 1222, AC4: with no shared SQLite pool initialised, the backfill
/// is a safe no-op (returns no unrecoverable IDs, does not panic).
#[tokio::test]
async fn backfill_is_noop_without_shared_pool() {
crate::crdt_state::init_for_test();
ensure_content_store();
let unrecoverable = backfill_evicted_story_content().await;
assert!(unrecoverable.is_empty());
}
/// Story 1222, AC4: the backfill restores in-memory Story content for a
/// terminal-stage story whose body was wiped from the in-memory store,
/// using the durable SQLite shadow column that the (buggy, now-fixed)
/// terminal-transition purge never touched.
#[tokio::test]
async fn backfill_restores_story_content_from_sqlite_shadow_column() {
crate::crdt_state::init_for_test();
ensure_content_store();
crate::db::tests::ensure_shadow_db().await;
let story_id = "1222_test_backfill_restore";
let body = "---\nname: Backfill Restore Test\n---\n# Body\n";
write_item_with_content(
story_id,
"5_done",
body,
ItemMeta::named("Backfill Restore Test"),
);
// Let the shadow-write background task flush the insert to SQLite.
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
// Simulate the pre-1222 bug: wipe the in-memory Story key only.
// SQLite still has it — delete_content never touches the shadow
// table.
delete_content(ContentKey::Story(story_id));
assert!(read_content(ContentKey::Story(story_id)).is_none());
let unrecoverable = backfill_evicted_story_content().await;
assert!(
!unrecoverable.contains(&story_id.to_string()),
"story with a surviving SQLite copy must not be reported unrecoverable"
);
assert_eq!(
read_content(ContentKey::Story(story_id)).as_deref(),
Some(body),
"backfill must restore the body from the SQLite shadow column"
);
}
/// Story 1222, AC4: a terminal-stage story with no SQLite copy either
/// (never shadow-written) is reported as unrecoverable, not silently
/// dropped.
#[tokio::test]
async fn backfill_reports_unrecoverable_story_with_no_sqlite_copy() {
crate::crdt_state::init_for_test();
ensure_content_store();
crate::db::tests::ensure_shadow_db().await;
let story_id = "1222_test_backfill_unrecoverable";
// Write directly to the CRDT only — bypass write_item_with_content
// so no shadow-write message is ever sent, and never write
// in-memory content either, simulating a story whose SQLite copy
// was never captured (or was compacted away).
crate::crdt_state::write_item_str(
story_id,
"5_done",
Some("Unrecoverable Test"),
None,
None,
None,
);
let unrecoverable = backfill_evicted_story_content().await;
assert!(
unrecoverable.contains(&story_id.to_string()),
"story with no in-memory AND no SQLite content must be reported unrecoverable"
);
}
} }
+11 -3
View File
@@ -37,8 +37,10 @@ pub use shadow_write::{check_schema_drift, get_shared_pool, init};
#[cfg(test)] #[cfg(test)]
pub use content_store::ensure_content_store; pub use content_store::ensure_content_store;
/// Shared test helpers for the `db` module, including [`tests::ensure_shadow_db`]
/// which `db::gc::tests` reuses for backfill tests (story 1222).
#[cfg(test)] #[cfg(test)]
mod tests { pub(crate) mod tests {
use super::*; use super::*;
use std::fs; use std::fs;
@@ -597,10 +599,16 @@ mod tests {
/// Park the init on a leaked multi-thread runtime so the bg task lives for /// Park the init on a leaked multi-thread runtime so the bg task lives for
/// the whole test process; mirrors `db::ops::tests::ensure_shadow_db`. /// the whole test process; mirrors `db::ops::tests::ensure_shadow_db`.
#[cfg(test)] #[cfg(test)]
static SHADOW_RT: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new(); pub(crate) static SHADOW_RT: std::sync::OnceLock<tokio::runtime::Runtime> =
std::sync::OnceLock::new();
/// Shared test helper: initialise the shadow SQLite DB exactly once per
/// test binary, parked on a leaked multi-thread runtime so the
/// background write task survives past any single `#[tokio::test]`'s
/// per-test runtime teardown. Reused by `db::gc::tests` for backfill
/// tests (story 1222) — do not duplicate this dance elsewhere.
#[cfg(test)] #[cfg(test)]
async fn ensure_shadow_db() { pub(crate) async fn ensure_shadow_db() {
static INIT: std::sync::OnceLock<()> = std::sync::OnceLock::new(); static INIT: std::sync::OnceLock<()> = std::sync::OnceLock::new();
if INIT.get().is_some() { if INIT.get().is_some() {
return; return;
+7 -2
View File
@@ -18,8 +18,12 @@ pub enum PermissionDecision {
Deny, Deny,
/// One-time approval. /// One-time approval.
Approve, Approve,
/// Approve and persist the rule to `.claude/settings.json` so Claude Code's /// Approve, and remember `(tool, target-pattern)` for the rest of the
/// built-in permission system handles future checks without prompting. /// requesting agent's session (story 1218) — subsequent matching
/// requests auto-approve without forwarding to chat. Scoped in-memory to
/// the session that made the request (see
/// `service::permission_router::RememberedPermissions`); never persisted
/// to disk and never shared with another agent or story.
AlwaysAllow, AlwaysAllow,
} }
@@ -125,6 +129,7 @@ impl AppContext {
permission_registry, permission_registry,
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(), pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
permission_timeout_secs: 120, permission_timeout_secs: 120,
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
status: agents.status_broadcaster(), status: agents.status_broadcaster(),
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)), chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
}); });
+292 -1
View File
@@ -350,7 +350,25 @@ pub async fn gateway_mcp_post_handler(
Some(project) => { Some(project) => {
proxy_and_respond_for_project(&state, project, &bytes, rpc.id).await proxy_and_respond_for_project(&state, project, &bytes, rpc.id).await
} }
None => proxy_and_respond(&state, &bytes, rpc.id).await, None if is_create_tool(tool_name) => {
// Story 1225 AC 3: with >1 project registered, a
// create call omitting `project` is ambiguous — fail
// loudly instead of silently filing into whichever
// project happens to be active.
let project_count = state.projects.read().await.len();
if project_count > 1 {
to_json_response(JsonRpcResponse::error(
rpc.id,
-32602,
format!(
"'{tool_name}' requires an explicit `project` argument when more than one project is registered (see list_projects) — the active project is not used implicitly for creates."
),
))
} else {
proxy_and_respond_with_resolved_project(&state, &bytes, rpc.id).await
}
}
None => proxy_and_respond_with_resolved_project(&state, &bytes, rpc.id).await,
} }
} }
} }
@@ -376,6 +394,50 @@ async fn proxy_and_respond(state: &GatewayState, bytes: &[u8], id: Option<Value>
} }
} }
/// Returns `true` for tool names that create a new work item or resource
/// (`create_bug`, `create_story`, `create_worktree`, ...), used to gate
/// implicit active-project routing behind an explicit `project` argument
/// when the destination is ambiguous (story 1225 AC 3).
fn is_create_tool(tool_name: &str) -> bool {
tool_name.starts_with("create_")
}
/// Proxy a request to the active project and, on success, annotate the
/// response with which project actually handled it (story 1225 AC 3) — so a
/// caller who omitted `project` can see whether their call landed where they
/// expected instead of silently trusting `active_project`.
async fn proxy_and_respond_with_resolved_project(
state: &GatewayState,
bytes: &[u8],
id: Option<Value>,
) -> Response {
let active = state.active_project.read().await.clone();
match state.proxy_active_mcp(bytes).await {
Ok(resp_body) => Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(Body::from(annotate_resolved_project(&resp_body, &active))),
Err(e) => to_json_response(JsonRpcResponse::error(
id,
-32603,
format!("proxy error: {e}"),
)),
}
}
/// Insert a `resolved_project` field into a JSON-RPC `result` object,
/// leaving the bytes unchanged if they don't parse as JSON or `result` isn't
/// an object (e.g. an error response, which has no `result` at all).
fn annotate_resolved_project(bytes: &[u8], project: &str) -> Vec<u8> {
let Ok(mut value) = serde_json::from_slice::<Value>(bytes) else {
return bytes.to_vec();
};
if let Some(result) = value.get_mut("result").and_then(|r| r.as_object_mut()) {
result.insert("resolved_project".to_string(), json!(project));
}
serde_json::to_vec(&value).unwrap_or_else(|_| bytes.to_vec())
}
/// Proxy a request to an explicitly named project (story 1208 AC 1) rather /// Proxy a request to an explicitly named project (story 1208 AC 1) rather
/// than whatever project is currently active, so a single ops/LLM session /// than whatever project is currently active, so a single ops/LLM session
/// can address any registered project per-call without a prior /// can address any registered project per-call without a prior
@@ -1310,6 +1372,7 @@ async fn handle_pipeline_get(state: &GatewayState, id: Option<Value>) -> JsonRpc
mod tests { mod tests {
use super::*; use super::*;
use crate::service::gateway::config::{GatewayConfig, ProjectEntry}; use crate::service::gateway::config::{GatewayConfig, ProjectEntry};
use poem::EndpointExt as _;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::sync::Arc; use std::sync::Arc;
@@ -1688,4 +1751,232 @@ mod tests {
inject_project_arg_schema(&mut tools); inject_project_arg_schema(&mut tools);
assert_eq!(tools[0], json!({"name": "no_schema"})); assert_eq!(tools[0], json!({"name": "no_schema"}));
} }
// ── project arg is honored end-to-end, not silently misrouted (story 1225) ─
/// Register a fake sled connection for `name` whose background task
/// answers every `mcp_request` with a canned response naming itself, so
/// tests can assert *which* project actually handled a routed call.
async fn spawn_fake_sled(name: &str) -> gateway::SledConnection {
let (tx, mut rx) =
tokio::sync::mpsc::unbounded_channel::<crate::sled_uplink::UplinkEnvelope>();
let in_flight: Arc<
tokio::sync::Mutex<
std::collections::HashMap<String, tokio::sync::oneshot::Sender<Value>>,
>,
> = Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
let in_flight_task = Arc::clone(&in_flight);
let label = name.to_string();
tokio::spawn(async move {
while let Some(env) = rx.recv().await {
if let Some(sender) = in_flight_task.lock().await.remove(&env.req_id) {
let _ = sender.send(json!({
"jsonrpc": "2.0",
"id": 1,
"result": { "content": [{ "type": "text", "text": format!("handled_by:{label}") }] }
}));
}
}
});
gateway::SledConnection {
tx,
last_heartbeat_ms: Arc::new(std::sync::atomic::AtomicI64::new(
chrono::Utc::now().timestamp_millis(),
)),
in_flight,
}
}
async fn make_two_project_state(dir: &std::path::Path) -> Arc<GatewayState> {
let mut projects = BTreeMap::new();
projects.insert("alpha".to_string(), ProjectEntry::with_url("http://a:3001"));
projects.insert("beta".to_string(), ProjectEntry::with_url("http://b:3002"));
let config = GatewayConfig {
projects,
sled_tokens: BTreeMap::new(),
release_channels: BTreeMap::new(),
};
let state = Arc::new(GatewayState::new(config, dir.to_path_buf(), 3000).unwrap());
assert_eq!(*state.active_project.read().await, "alpha");
state
.register_sled_connection("alpha".to_string(), spawn_fake_sled("alpha").await)
.await;
state
.register_sled_connection("beta".to_string(), spawn_fake_sled("beta").await)
.await;
state
}
#[tokio::test]
async fn create_tool_with_explicit_project_lands_in_non_active_project() {
let dir = tempfile::tempdir().unwrap();
let state = make_two_project_state(dir.path()).await;
let app = poem::Route::new()
.at("/mcp", poem::post(gateway_mcp_post_handler))
.data(state);
let cli = poem::test::TestClient::new(app);
let rpc_body = serde_json::to_vec(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "create_bug",
"arguments": { "project": "beta", "title": "x", "description": "y" }
}
}))
.unwrap();
let resp = cli
.post("/mcp")
.header("content-type", "application/json")
.body(rpc_body)
.send()
.await;
let parsed: Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(
parsed["result"]["content"][0]["text"], "handled_by:beta",
"explicit project=beta must land in beta even though alpha is active: {parsed}"
);
}
#[tokio::test]
async fn read_tool_with_explicit_project_reads_from_non_active_project() {
let dir = tempfile::tempdir().unwrap();
let state = make_two_project_state(dir.path()).await;
let app = poem::Route::new()
.at("/mcp", poem::post(gateway_mcp_post_handler))
.data(state);
let cli = poem::test::TestClient::new(app);
let rpc_body = serde_json::to_vec(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_pipeline_status",
"arguments": { "project": "beta" }
}
}))
.unwrap();
let resp = cli
.post("/mcp")
.header("content-type", "application/json")
.body(rpc_body)
.send()
.await;
let parsed: Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(
parsed["result"]["content"][0]["text"], "handled_by:beta",
"explicit project=beta must read from beta even though alpha is active: {parsed}"
);
}
#[tokio::test]
async fn create_tool_without_project_errors_when_multiple_projects_registered() {
let dir = tempfile::tempdir().unwrap();
let state = make_two_project_state(dir.path()).await;
let app = poem::Route::new()
.at("/mcp", poem::post(gateway_mcp_post_handler))
.data(state);
let cli = poem::test::TestClient::new(app);
let rpc_body = serde_json::to_vec(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "create_bug",
"arguments": { "title": "x", "description": "y" }
}
}))
.unwrap();
let resp = cli
.post("/mcp")
.header("content-type", "application/json")
.body(rpc_body)
.send()
.await;
let parsed: Value = resp.0.into_body().into_json().await.unwrap();
assert!(
parsed["error"]["message"]
.as_str()
.unwrap()
.contains("requires an explicit `project`"),
"create call omitting `project` with >1 project registered must error, not \
silently file into the active project: {parsed}"
);
}
#[tokio::test]
async fn read_tool_without_project_annotates_resolved_project() {
let dir = tempfile::tempdir().unwrap();
let state = make_two_project_state(dir.path()).await;
let app = poem::Route::new()
.at("/mcp", poem::post(gateway_mcp_post_handler))
.data(state);
let cli = poem::test::TestClient::new(app);
let rpc_body = serde_json::to_vec(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_pipeline_status",
"arguments": {}
}
}))
.unwrap();
let resp = cli
.post("/mcp")
.header("content-type", "application/json")
.body(rpc_body)
.send()
.await;
let parsed: Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(
parsed["result"]["content"][0]["text"], "handled_by:alpha",
"sanity: implicit routing hit the active project: {parsed}"
);
assert_eq!(
parsed["result"]["resolved_project"], "alpha",
"omitting `project` must echo which project was actually resolved: {parsed}"
);
}
#[test]
fn annotate_resolved_project_inserts_field_into_result_object() {
let bytes = serde_json::to_vec(&json!({
"jsonrpc": "2.0",
"id": 1,
"result": { "content": [{ "type": "text", "text": "ok" }] }
}))
.unwrap();
let annotated = annotate_resolved_project(&bytes, "alpha");
let parsed: Value = serde_json::from_slice(&annotated).unwrap();
assert_eq!(parsed["result"]["resolved_project"], "alpha");
assert_eq!(parsed["result"]["content"][0]["text"], "ok");
}
#[test]
fn annotate_resolved_project_leaves_error_response_untouched() {
let bytes = serde_json::to_vec(&json!({
"jsonrpc": "2.0",
"id": 1,
"error": { "code": -32603, "message": "boom" }
}))
.unwrap();
let annotated = annotate_resolved_project(&bytes, "alpha");
let parsed: Value = serde_json::from_slice(&annotated).unwrap();
assert_eq!(parsed["error"]["message"], "boom");
assert!(parsed.get("result").is_none());
}
#[test]
fn is_create_tool_matches_create_prefixed_names_only() {
assert!(is_create_tool("create_bug"));
assert!(is_create_tool("create_story"));
assert!(is_create_tool("create_worktree"));
assert!(!is_create_tool("get_pipeline_status"));
assert!(!is_create_tool("show"));
}
} }
+91 -79
View File
@@ -3,7 +3,8 @@
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::http::context::AppContext; use crate::http::context::AppContext;
use crate::service::diagnostics::{add_permission_rule, generate_permission_rule}; use crate::http::mcp::session;
use crate::service::diagnostics::generate_permission_rule;
use crate::slog; use crate::slog;
use crate::slog_warn; use crate::slog_warn;
@@ -29,6 +30,23 @@ pub(crate) async fn tool_prompt_permission(
return Ok(json!({"behavior": "allow", "updatedInput": tool_input}).to_string()); return Ok(json!({"behavior": "allow", "updatedInput": tool_input}).to_string());
} }
let session_id = session::current();
let rule = generate_permission_rule(&tool_name, &tool_input);
// Auto-approve without forwarding when this exact (tool, target-pattern)
// was already remembered for this agent's session (story 1218). Logged
// here for auditability since it bypasses the chat approval dialog.
if ctx
.services
.remembered_permissions
.is_remembered(&session_id, &rule)
{
crate::slog!(
"[permission] Auto-approved '{tool_name}' (remembered rule '{rule}' for session '{session_id}')"
);
return Ok(json!({"behavior": "allow", "updatedInput": tool_input}).to_string());
}
// Auto-deny immediately if no responder is currently registered to // Auto-deny immediately if no responder is currently registered to
// receive forwarded permission requests. The Matrix bot's // receive forwarded permission requests. The Matrix bot's
// permission_listener task, sled uplinks, and per-message chat transports // permission_listener task, sled uplinks, and per-message chat transports
@@ -83,15 +101,14 @@ pub(crate) async fn tool_prompt_permission(
.map_err(|_| "Permission response channel closed unexpectedly".to_string())?; .map_err(|_| "Permission response channel closed unexpectedly".to_string())?;
if decision == PermissionDecision::AlwaysAllow { if decision == PermissionDecision::AlwaysAllow {
// Persist the rule so Claude Code won't prompt again for this tool. // Remember for the rest of this agent's session (story 1218) — never
if let Some(root) = ctx.state.project_root.lock().unwrap().clone() { // written to disk, never visible to another session's requests.
let rule = generate_permission_rule(&tool_name, &tool_input); ctx.services
if let Err(e) = add_permission_rule(&root, &rule) { .remembered_permissions
slog_warn!("[permission] Failed to write always-allow rule: {e}"); .remember(&session_id, &rule);
} else { slog!(
slog!("[permission] Added always-allow rule: {rule}"); "[permission] Remembered rule '{rule}' for session '{session_id}' — future matches auto-approve without prompting"
} );
}
} }
if decision == PermissionDecision::Approve || decision == PermissionDecision::AlwaysAllow { if decision == PermissionDecision::Approve || decision == PermissionDecision::AlwaysAllow {
@@ -238,91 +255,86 @@ mod tests {
assert_eq!(rule, "mcp__huskies__create_story"); assert_eq!(rule, "mcp__huskies__create_story");
} }
// ── Settings.json writing tests ────────────────────────────── // ── Remembered ("don't ask again this session") tests (story 1218) ──
#[test] #[tokio::test]
fn add_rule_creates_settings_file_when_missing() { async fn remembered_rule_auto_approves_without_forwarding() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
add_permission_rule(tmp.path(), "Edit").unwrap(); let ctx = test_ctx(tmp.path());
let content = fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap(); // Pre-remember the rule for the default session (no X-Huskies-Session
let settings: Value = serde_json::from_str(&content).unwrap(); // header scope installed in this test) — no responder is registered,
let allow = settings["permissions"]["allow"].as_array().unwrap(); // so if the request were forwarded it would auto-deny instead.
assert!(allow.contains(&json!("Edit"))); ctx.services
} .remembered_permissions
.remember(&session::current(), "Bash(git *)");
#[test] let result = tool_prompt_permission(
fn add_rule_does_not_duplicate_existing() { &json!({"tool_name": "Bash", "input": {"command": "git status"}}),
let tmp = tempfile::tempdir().unwrap(); &ctx,
add_permission_rule(tmp.path(), "Edit").unwrap();
add_permission_rule(tmp.path(), "Edit").unwrap();
let content = fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
let settings: Value = serde_json::from_str(&content).unwrap();
let allow = settings["permissions"]["allow"].as_array().unwrap();
let count = allow.iter().filter(|v| v == &&json!("Edit")).count();
assert_eq!(count, 1);
}
#[test]
fn add_rule_skips_when_wildcard_already_covers() {
let tmp = tempfile::tempdir().unwrap();
let claude_dir = tmp.path().join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
fs::write(
claude_dir.join("settings.json"),
r#"{"permissions":{"allow":["mcp__huskies__*"]}}"#,
) )
.unwrap(); .await
.expect("remembered rule must short-circuit before the no-responder auto-deny");
add_permission_rule(tmp.path(), "mcp__huskies__create_story").unwrap(); let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["behavior"], "allow");
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
let settings: Value = serde_json::from_str(&content).unwrap();
let allow = settings["permissions"]["allow"].as_array().unwrap();
assert_eq!(allow.len(), 1);
assert_eq!(allow[0], "mcp__huskies__*");
} }
#[test] #[tokio::test]
fn add_rule_appends_to_existing_rules() { async fn always_allow_decision_remembers_rule_for_session_not_disk() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let claude_dir = tmp.path().join(".claude"); let ctx = test_ctx(tmp.path());
fs::create_dir_all(&claude_dir).unwrap();
fs::write( let (guard, mut rx) = ctx.services.permission_registry.register();
claude_dir.join("settings.json"), tokio::spawn(async move {
r#"{"permissions":{"allow":["Edit"]}}"#, if let Some(forward) = rx.recv().await {
let _ = forward
.response_tx
.send(crate::http::context::PermissionDecision::AlwaysAllow);
}
drop(guard);
});
tool_prompt_permission(
&json!({"tool_name": "Bash", "input": {"command": "git status"}}),
&ctx,
) )
.unwrap(); .await
.expect("always-allow must succeed");
add_permission_rule(tmp.path(), "Write").unwrap(); assert!(
ctx.services
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap(); .remembered_permissions
let settings: Value = serde_json::from_str(&content).unwrap(); .is_remembered(&session::current(), "Bash(git *)"),
let allow = settings["permissions"]["allow"].as_array().unwrap(); "AlwaysAllow must remember the rule in-memory for this session"
assert_eq!(allow.len(), 2); );
assert!(allow.contains(&json!("Edit"))); assert!(
assert!(allow.contains(&json!("Write"))); !tmp.path().join(".claude/settings.json").exists(),
"AlwaysAllow must not write to disk (story 1218: session-scoped only)"
);
} }
#[test] #[tokio::test]
fn add_rule_preserves_other_settings_fields() { async fn remembered_rule_does_not_cross_sessions() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let claude_dir = tmp.path().join(".claude"); let ctx = test_ctx(tmp.path());
fs::create_dir_all(&claude_dir).unwrap(); ctx.services
fs::write( .remembered_permissions
claude_dir.join("settings.json"), .remember("story-a", "Bash(git *)");
r#"{"permissions":{"allow":["Edit"]},"enabledMcpjsonServers":["huskies"]}"#,
// Current (default) session never remembered this rule, and no
// responder is registered, so it must fall through to auto-deny.
let result = tool_prompt_permission(
&json!({"tool_name": "Bash", "input": {"command": "git status"}}),
&ctx,
) )
.await
.unwrap(); .unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
add_permission_rule(tmp.path(), "Write").unwrap(); assert_eq!(
parsed["behavior"], "deny",
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap(); "a rule remembered for a different session must not auto-approve this one"
let settings: Value = serde_json::from_str(&content).unwrap(); );
let servers = settings["enabledMcpjsonServers"].as_array().unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0], "huskies");
} }
// ── move_story tool tests ───────────────────────────────────── // ── move_story tool tests ─────────────────────────────────────
+23 -5
View File
@@ -19,6 +19,10 @@ pub mod merge_tools;
pub mod progress; pub mod progress;
/// MCP tools for QA request, approve, and reject workflows. /// MCP tools for QA request, approve, and reject workflows.
pub mod qa_tools; pub mod qa_tools;
/// Task-local session identifier for the requesting MCP client, read from
/// the `X-Huskies-Session` header so `tool_prompt_permission` can scope
/// remembered approvals per requesting agent.
pub mod session;
/// MCP tools for running shell commands and test suites. /// MCP tools for running shell commands and test suites.
pub mod shell_tools; pub mod shell_tools;
/// MCP tools for pipeline status, story todos, and triage dump. /// MCP tools for pipeline status, story todos, and triage dump.
@@ -71,6 +75,11 @@ pub async fn mcp_get_handler() -> Response {
/// `tools/call`, and `notifications/*`. /// `tools/call`, and `notifications/*`.
#[handler] #[handler]
pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc<AppContext>>) -> Response { pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc<AppContext>>) -> Response {
let session_id = req
.header("x-huskies-session")
.unwrap_or(session::DEFAULT_SESSION)
.to_string();
let content_type = req.header("content-type").unwrap_or(""); let content_type = req.header("content-type").unwrap_or("");
if !content_type.is_empty() && !content_type.contains("application/json") { if !content_type.is_empty() && !content_type.contains("application/json") {
return json_response(JsonRpcResponse::error( return json_response(JsonRpcResponse::error(
@@ -125,7 +134,7 @@ pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc<AppConte
.and_then(|m| m.get("progressToken")) .and_then(|m| m.get("progressToken"))
.cloned(); .cloned();
if let (true, Some(token)) = (accepts_sse, progress_token) { if let (true, Some(token)) = (accepts_sse, progress_token) {
return sse_tools_call(rpc.id, rpc.params, token, Arc::clone(&ctx)).await; return sse_tools_call(rpc.id, rpc.params, token, Arc::clone(&ctx), session_id).await;
} }
} }
@@ -134,7 +143,11 @@ pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc<AppConte
"tools/list" => { "tools/list" => {
JsonRpcResponse::success(rpc.id, json!({ "tools": tools_list::list_tools() })) JsonRpcResponse::success(rpc.id, json!({ "tools": tools_list::list_tools() }))
} }
"tools/call" => handle_tools_call(rpc.id, &rpc.params, &ctx).await, "tools/call" => {
session::SESSION_ID
.scope(session_id, handle_tools_call(rpc.id, &rpc.params, &ctx))
.await
}
_ => JsonRpcResponse::error(rpc.id, -32601, format!("Unknown method: {}", rpc.method)), _ => JsonRpcResponse::error(rpc.id, -32601, format!("Unknown method: {}", rpc.method)),
}; };
@@ -152,6 +165,7 @@ async fn sse_tools_call(
params: Value, params: Value,
progress_token: Value, progress_token: Value,
ctx: Arc<AppContext>, ctx: Arc<AppContext>,
session_id: String,
) -> Response { ) -> Response {
use tokio::sync::mpsc::unbounded_channel; use tokio::sync::mpsc::unbounded_channel;
@@ -174,9 +188,13 @@ async fn sse_tools_call(
// its final state to the CRDT even on client disconnect). // its final state to the CRDT even on client disconnect).
let dispatch_ctx = Arc::clone(&ctx); let dispatch_ctx = Arc::clone(&ctx);
let dispatch_handle = tokio::spawn(async move { let dispatch_handle = tokio::spawn(async move {
progress::EMITTER session::SESSION_ID
.scope(emitter, async move { .scope(session_id, async move {
dispatch::dispatch_tool_call(&tool_name, args, &dispatch_ctx).await progress::EMITTER
.scope(emitter, async move {
dispatch::dispatch_tool_call(&tool_name, args, &dispatch_ctx).await
})
.await
}) })
.await .await
}); });
+51
View File
@@ -0,0 +1,51 @@
//! Task-local session identifier for the requesting MCP client.
//!
//! Threaded ambiently through tool dispatch (same pattern as
//! [`super::progress::EMITTER`]) so a deeply-nested handler — currently only
//! `tool_prompt_permission` — can scope decisions per requesting agent
//! without adding a parameter to `dispatch_tool_call` and every one of its
//! ~40 match arms.
//!
//! The HTTP MCP handler installs the scope before dispatching a `tools/call`
//! request, populated from the `X-Huskies-Session` header that per-story
//! worktrees embed in their `.mcp.json` (see `worktree::write_mcp_json`).
//! Callers with no header (the main interactive chat CLI, API-based runtimes
//! that invoke `dispatch_tool_call` directly) fall back to a fixed
//! `"default"` key — there is only ever one such session per server process,
//! so no cross-story leakage results from sharing that bucket.
/// Session key used when no `X-Huskies-Session` header was present.
pub const DEFAULT_SESSION: &str = "default";
tokio::task_local! {
/// Set by the MCP HTTP handler before dispatching a `tools/call` request.
/// Unset in tests and in non-HTTP dispatch paths, where [`current`] falls
/// back to [`DEFAULT_SESSION`].
pub static SESSION_ID: String;
}
/// Return the current request's session id, or [`DEFAULT_SESSION`] if no
/// scope is installed.
pub fn current() -> String {
SESSION_ID
.try_with(Clone::clone)
.unwrap_or_else(|_| DEFAULT_SESSION.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn current_falls_back_to_default_without_scope() {
assert_eq!(current(), DEFAULT_SESSION);
}
#[tokio::test]
async fn current_reads_installed_scope() {
let value = SESSION_ID
.scope("1218".to_string(), async { current() })
.await;
assert_eq!(value, "1218");
}
}
+42
View File
@@ -382,6 +382,48 @@ mod tests {
assert_eq!(depends_on[1], 200); assert_eq!(depends_on[1], 200);
} }
/// Story 1222 regression: `show` must return full content for a story
/// after it reaches Done and again after it reaches Archived — the
/// terminal-stage content purge must never evict the story body, only
/// its ephemeral bookkeeping keys.
#[tokio::test]
async fn tool_show_returns_content_after_done_and_after_archived() {
let tmp = tempdir().unwrap();
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let story_id = "1222_story_done_archived_test";
let story_content = "# Story\n\n## Acceptance Criteria\n\n- [ ] Ship it\n";
crate::db::write_item_with_content(
story_id,
"5_done",
story_content,
crate::db::ItemMeta::named("Done Archived Test"),
);
// Simulate the terminal-transition purge that fires when a story
// reaches Done (story 996 GC subscriber / sweep).
crate::db::gc::purge_ephemeral_content_keys_for_story(story_id);
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
let result = tool_show(&json!({"story_id": story_id}), &ctx)
.await
.expect("show must succeed for a Done story, not return content unavailable");
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["story_id"], story_id);
assert_eq!(parsed["front_matter"]["name"], "Done Archived Test");
// Move to Archived and purge again — content must still be readable.
crate::db::move_item_stage(story_id, "6_archived", None);
crate::db::gc::purge_ephemeral_content_keys_for_story(story_id);
let result = tool_show(&json!({"story_id": story_id}), &ctx)
.await
.expect("show must succeed for an Archived story, not return content unavailable");
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["story_id"], story_id);
}
#[tokio::test] #[tokio::test]
async fn tool_show_returns_story_data() { async fn tool_show_returns_story_data() {
let tmp = tempdir().unwrap(); let tmp = tempdir().unwrap();
+33 -1
View File
@@ -5,7 +5,7 @@ use serde_json::json;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use super::scaffold::scaffold_story_kit; use super::scaffold::{ensure_gitignore_entries, scaffold_story_kit};
const KEY_LAST_PROJECT: &str = "last_project_path"; const KEY_LAST_PROJECT: &str = "last_project_path";
const KEY_KNOWN_PROJECTS: &str = "known_projects"; const KEY_KNOWN_PROJECTS: &str = "known_projects";
@@ -36,6 +36,10 @@ pub(crate) async fn ensure_project_root_with_story_kit(
} }
if !path.join(".huskies").is_dir() { if !path.join(".huskies").is_dir() {
scaffold_story_kit(&path, port)?; scaffold_story_kit(&path, port)?;
} else {
// Already-adopted project: pick up any Story Kit gitignore entries
// added since this project was first scaffolded.
ensure_gitignore_entries(&path)?;
} }
// Always update .mcp.json with the current port so the bot connects to // Always update .mcp.json with the current port so the bot connects to
// the right endpoint even when HUSKIES_PORT changes between restarts. // the right endpoint even when HUSKIES_PORT changes between restarts.
@@ -449,6 +453,34 @@ mod tests {
assert!(project_dir.join(".huskies").is_dir()); assert!(project_dir.join(".huskies").is_dir());
} }
/// Regression test for story 1219: projects scaffolded before
/// `merge_reports/` and `session_store.json` were added to the ignore
/// list must pick up those entries the next time the project is opened,
/// without needing to be re-scaffolded from scratch.
#[tokio::test]
async fn open_project_retrofits_gitignore_entries_for_existing_project() {
let dir = tempdir().unwrap();
let project_dir = dir.path().join("myproject");
let sk_dir = project_dir.join(".huskies");
fs::create_dir_all(&sk_dir).unwrap();
fs::write(sk_dir.join(".gitignore"), "worktrees/\n").unwrap();
let store = make_store(&dir);
let state = SessionState::default();
open_project(
project_dir.to_string_lossy().to_string(),
&state,
&store,
3001,
)
.await
.unwrap();
let content = fs::read_to_string(sk_dir.join(".gitignore")).unwrap();
assert!(content.contains("merge_reports/"));
assert!(content.contains("session_store.json"));
}
#[tokio::test] #[tokio::test]
async fn open_project_does_not_overwrite_existing_story_kit() { async fn open_project_does_not_overwrite_existing_story_kit() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
+2
View File
@@ -64,6 +64,8 @@ pub(super) fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
"store.json", "store.json",
"pipeline.db", "pipeline.db",
"*.db", "*.db",
"merge_reports/",
"session_store.json",
]; ];
let gitignore_path = root.join(".huskies").join(".gitignore"); let gitignore_path = root.join(".huskies").join(".gitignore");
+10
View File
@@ -20,6 +20,16 @@ use templates::{
STORY_KIT_STACK, STORY_KIT_STACK,
}; };
/// Retrofit the Story Kit `.gitignore` entries onto a project that was
/// scaffolded before those entries existed (e.g. `merge_reports/` or
/// `session_store.json` added in a later release). Idempotent — only
/// appends lines that are missing, so it is safe to call on every project
/// open regardless of how old the project's `.huskies/` directory is.
pub(crate) fn ensure_gitignore_entries(root: &Path) -> Result<(), String> {
write_story_kit_gitignore(root)?;
append_root_gitignore_entries(root)
}
pub(crate) fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> { pub(crate) fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> {
let story_kit_root = root.join(".huskies"); let story_kit_root = root.join(".huskies");
let specs_root = story_kit_root.join("specs"); let specs_root = story_kit_root.join("specs");
+25
View File
@@ -334,6 +334,31 @@ fn scaffold_creates_story_kit_gitignore_with_relative_entries() {
// Database files must be ignored so novice users don't accidentally commit them // Database files must be ignored so novice users don't accidentally commit them
assert!(sk_content.contains("pipeline.db")); assert!(sk_content.contains("pipeline.db"));
assert!(sk_content.contains("*.db")); assert!(sk_content.contains("*.db"));
// Runtime artifacts written under .huskies/ must be ignored too (story 1219)
assert!(sk_content.contains("merge_reports/"));
assert!(sk_content.contains("session_store.json"));
}
#[test]
fn ensure_gitignore_entries_retrofits_already_adopted_project() {
// Simulate a project scaffolded before merge_reports/ and session_store.json
// were added to the ignore list: .huskies/.gitignore exists but lacks them.
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join(".huskies")).unwrap();
fs::write(
dir.path().join(".huskies/.gitignore"),
"worktrees/\ncoverage/\n",
)
.unwrap();
ensure_gitignore_entries(dir.path()).unwrap();
let sk_content = fs::read_to_string(dir.path().join(".huskies/.gitignore")).unwrap();
assert!(sk_content.contains("merge_reports/"));
assert!(sk_content.contains("session_store.json"));
// Pre-existing entries must survive untouched
assert!(sk_content.contains("worktrees/"));
assert!(sk_content.contains("coverage/"));
} }
#[test] #[test]
+1
View File
@@ -256,6 +256,7 @@ async fn main() -> Result<(), std::io::Error> {
.as_ref() .as_ref()
.map(|c| c.permission_timeout_secs) .map(|c| c.permission_timeout_secs)
.unwrap_or(120), .unwrap_or(120),
remembered_permissions: service::permission_router::RememberedPermissions::new(),
status: agents.status_broadcaster(), status: agents.status_broadcaster(),
chat_dispatcher: std::sync::Arc::new(chat::dispatcher::ChatDispatcher::new( chat_dispatcher: std::sync::Arc::new(chat::dispatcher::ChatDispatcher::new(
bot_cfg bot_cfg
+1
View File
@@ -141,6 +141,7 @@ pub(super) fn call_sync(
permission_registry: ResponderRegistry::new(), permission_registry: ResponderRegistry::new(),
pending_perm_replies: PendingPermReplies::new(), pending_perm_replies: PendingPermReplies::new(),
permission_timeout_secs: 120, permission_timeout_secs: 120,
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
status: Arc::new(crate::service::status::StatusBroadcaster::new()), status: Arc::new(crate::service::status::StatusBroadcaster::new()),
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)), chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
}); });
-72
View File
@@ -1,72 +0,0 @@
//! Diagnostics I/O — the ONLY place in `service::diagnostics/` that may perform side effects.
//!
//! Side effects here include: reading and writing `.claude/settings.json` via `std::fs`.
//! Pure permission-rule logic (pattern derivation, wildcard domination checks) lives in
//! `permission.rs`.
use serde_json::{Value, json};
use std::fs;
use std::path::Path;
/// Add a permission rule to `.claude/settings.json` in the project root.
///
/// Does nothing if the rule already exists (exact match) or is already covered
/// by a wildcard pattern in the allow list. Creates the file and any missing
/// parent directories if they do not yet exist.
///
/// # Errors
/// Returns `Err(String)` if the directory cannot be created, the file cannot be
/// read or written, or the JSON cannot be parsed or serialised.
pub fn add_permission_rule(project_root: &Path, rule: &str) -> Result<(), String> {
let claude_dir = project_root.join(".claude");
fs::create_dir_all(&claude_dir)
.map_err(|e| format!("Failed to create .claude/ directory: {e}"))?;
let settings_path = claude_dir.join("settings.json");
let mut settings: Value = if settings_path.exists() {
let content = fs::read_to_string(&settings_path)
.map_err(|e| format!("Failed to read settings.json: {e}"))?;
serde_json::from_str(&content).map_err(|e| format!("Failed to parse settings.json: {e}"))?
} else {
json!({ "permissions": { "allow": [] } })
};
let allow_arr = settings
.pointer_mut("/permissions/allow")
.and_then(|v| v.as_array_mut());
let allow = match allow_arr {
Some(arr) => arr,
None => {
settings
.as_object_mut()
.unwrap()
.entry("permissions")
.or_insert(json!({ "allow": [] }));
settings
.pointer_mut("/permissions/allow")
.unwrap()
.as_array_mut()
.unwrap()
}
};
let rule_value = Value::String(rule.to_string());
// Exact duplicate check.
if allow.contains(&rule_value) {
return Ok(());
}
// Wildcard-coverage check: if "mcp__huskies__*" exists, skip more-specific rules.
if super::permission::is_dominated_by_wildcard(rule, allow) {
return Ok(());
}
allow.push(rule_value);
let pretty =
serde_json::to_string_pretty(&settings).map_err(|e| format!("Failed to serialize: {e}"))?;
fs::write(&settings_path, pretty).map_err(|e| format!("Failed to write settings.json: {e}"))?;
Ok(())
}
+4 -7
View File
@@ -3,20 +3,17 @@
//! Extracted from `http/mcp/diagnostics.rs` following the conventions in //! Extracted from `http/mcp/diagnostics.rs` following the conventions in
//! `docs/architecture/service-modules.md`: //! `docs/architecture/service-modules.md`:
//! - `mod.rs` (this file) — public API, typed [`Error`], orchestration //! - `mod.rs` (this file) — public API, typed [`Error`], orchestration
//! - `io.rs` — the ONLY place that performs side effects (filesystem reads/writes)
//! - `permission.rs` — pure permission-rule generation and wildcard checks //! - `permission.rs` — pure permission-rule generation and wildcard checks
//!
//! Permission rules are remembered in-memory, per requesting-agent session
//! (`service::permission_router::RememberedPermissions`, story 1218) rather
//! than written to disk, so there is no side-effectful I/O submodule here.
/// Side-effectful diagnostics I/O — log reads, CRDT dumps, filesystem writes.
pub mod io;
/// Pure permission-rule generation and wildcard matching. /// Pure permission-rule generation and wildcard matching.
pub mod permission; pub mod permission;
#[allow(unused_imports)]
pub use io::add_permission_rule;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use permission::generate_permission_rule; pub use permission::generate_permission_rule;
#[allow(unused_imports)]
pub use permission::is_dominated_by_wildcard;
// ── Error type ──────────────────────────────────────────────────────────────── // ── Error type ────────────────────────────────────────────────────────────────
@@ -22,21 +22,6 @@ pub fn generate_permission_rule(tool_name: &str, tool_input: &Value) -> String {
} }
} }
/// Return `true` if `rule` is already covered by an existing wildcard in `allow_list`.
///
/// For example, if `allow_list` contains `"mcp__huskies__*"`, then the more
/// specific rule `"mcp__huskies__create_story"` is already covered.
pub fn is_dominated_by_wildcard(rule: &str, allow_list: &[Value]) -> bool {
allow_list.iter().any(|existing| {
if let Some(pat) = existing.as_str()
&& let Some(prefix) = pat.strip_suffix('*')
{
return rule.starts_with(prefix);
}
false
})
}
// ── Tests ───────────────────────────────────────────────────────────────────── // ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)] #[cfg(test)]
@@ -79,27 +64,4 @@ mod tests {
let rule = generate_permission_rule("mcp__huskies__create_story", &json!({"name": "foo"})); let rule = generate_permission_rule("mcp__huskies__create_story", &json!({"name": "foo"}));
assert_eq!(rule, "mcp__huskies__create_story"); assert_eq!(rule, "mcp__huskies__create_story");
} }
#[test]
fn is_dominated_by_exact_wildcard() {
let allow = vec![json!("mcp__huskies__*")];
assert!(is_dominated_by_wildcard(
"mcp__huskies__create_story",
&allow
));
}
#[test]
fn is_not_dominated_by_different_prefix() {
let allow = vec![json!("mcp__other__*")];
assert!(!is_dominated_by_wildcard(
"mcp__huskies__create_story",
&allow
));
}
#[test]
fn is_not_dominated_when_list_is_empty() {
assert!(!is_dominated_by_wildcard("Edit", &[]));
}
} }
+1
View File
@@ -741,6 +741,7 @@ pub fn spawn_gateway_bot(
.as_ref() .as_ref()
.map(|c| c.permission_timeout_secs) .map(|c| c.permission_timeout_secs)
.unwrap_or(120), .unwrap_or(120),
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
chat_dispatcher: std::sync::Arc::new(crate::chat::dispatcher::ChatDispatcher::new( chat_dispatcher: std::sync::Arc::new(crate::chat::dispatcher::ChatDispatcher::new(
bot_cfg bot_cfg
.as_ref() .as_ref()
+84 -1
View File
@@ -20,7 +20,7 @@
//! part of the server. //! part of the server.
use crate::http::context::{PermissionDecision, PermissionForward}; use crate::http::context::{PermissionDecision, PermissionForward};
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex as StdMutex; use std::sync::Mutex as StdMutex;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
@@ -241,6 +241,57 @@ impl PendingPermReplies {
} }
} }
// ── Remembered permissions ───────────────────────────────────────────────
/// In-memory "don't ask again for this tool + target-pattern" store, scoped
/// per requesting agent session (story 1218).
///
/// Keyed by session id (see `http::mcp::session`) — for a per-story coding
/// agent this is the story id, stable across a `--resume` of the same story
/// since the worktree (and its `.mcp.json` header) doesn't change. Rules
/// remembered here are visible only within the session that recorded them,
/// so one agent's "don't ask again" never widens what another agent or
/// story is auto-approved for.
///
/// Deliberately **not** persisted to disk: this store lives only as long as
/// the server process does. Restarting the server clears every remembered
/// rule (agents will be prompted again once), which is an explicit tradeoff
/// — durability across restarts would require writing into each worktree's
/// own `.claude/settings.json`, which adds a second persistence path for a
/// case (server restarts mid-story) rare enough not to justify the added
/// complexity.
pub struct RememberedPermissions {
inner: StdMutex<HashMap<String, HashSet<String>>>,
}
impl RememberedPermissions {
/// Create an empty store.
pub fn new() -> Arc<Self> {
Arc::new(Self {
inner: StdMutex::new(HashMap::new()),
})
}
/// Record that `rule` is approved for `session_id` going forward.
pub fn remember(&self, session_id: &str, rule: &str) {
self.inner
.lock()
.unwrap()
.entry(session_id.to_string())
.or_default()
.insert(rule.to_string());
}
/// `true` if `rule` was previously remembered for `session_id`.
pub fn is_remembered(&self, session_id: &str, rule: &str) -> bool {
self.inner
.lock()
.unwrap()
.get(session_id)
.is_some_and(|rules| rules.contains(rule))
}
}
// ── Tests ───────────────────────────────────────────────────────────────── // ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)] #[cfg(test)]
@@ -394,4 +445,36 @@ mod tests {
let pending = PendingPermReplies::new(); let pending = PendingPermReplies::new();
assert!(pending.resolve_oldest("no-such-room").await.is_none()); assert!(pending.resolve_oldest("no-such-room").await.is_none());
} }
// ── RememberedPermissions ───────────────────────────────────────
#[test]
fn remembered_permissions_starts_empty() {
let store = RememberedPermissions::new();
assert!(!store.is_remembered("1218", "Bash(git *)"));
}
#[test]
fn remembered_permissions_recalls_after_remember() {
let store = RememberedPermissions::new();
store.remember("1218", "Bash(git *)");
assert!(store.is_remembered("1218", "Bash(git *)"));
}
#[test]
fn remembered_permissions_scoped_per_session() {
let store = RememberedPermissions::new();
store.remember("1218", "Bash(git *)");
assert!(
!store.is_remembered("1216", "Bash(git *)"),
"a rule remembered for one story must not apply to another"
);
}
#[test]
fn remembered_permissions_scoped_per_rule() {
let store = RememberedPermissions::new();
store.remember("1218", "Bash(git *)");
assert!(!store.is_remembered("1218", "Write"));
}
} }
+9 -1
View File
@@ -7,7 +7,9 @@
use crate::agents::AgentPool; use crate::agents::AgentPool;
use crate::chat::dispatcher::ChatDispatcher; use crate::chat::dispatcher::ChatDispatcher;
use crate::service::permission_router::{PendingPermReplies, ResponderRegistry}; use crate::service::permission_router::{
PendingPermReplies, RememberedPermissions, ResponderRegistry,
};
use crate::service::status::StatusBroadcaster; use crate::service::status::StatusBroadcaster;
use std::collections::HashSet; use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
@@ -41,6 +43,11 @@ pub struct Services {
/// Seconds to wait for a user to respond to a permission prompt before /// Seconds to wait for a user to respond to a permission prompt before
/// auto-denying (fail-closed). /// auto-denying (fail-closed).
pub permission_timeout_secs: u64, pub permission_timeout_secs: u64,
/// In-memory, per-session "don't ask again" permission rules (story
/// 1218). Checked by `tool_prompt_permission` before forwarding a
/// request to chat; never persisted to disk and never affects a
/// different session's agent.
pub remembered_permissions: Arc<RememberedPermissions>,
/// Project-scoped status broadcaster. /// Project-scoped status broadcaster.
/// ///
/// Consumers (chat transports, Web UI, agent context) call /// Consumers (chat transports, Web UI, agent context) call
@@ -73,6 +80,7 @@ impl Services {
permission_registry: ResponderRegistry::new(), permission_registry: ResponderRegistry::new(),
pending_perm_replies: PendingPermReplies::new(), pending_perm_replies: PendingPermReplies::new(),
permission_timeout_secs: 120, permission_timeout_secs: 120,
remembered_permissions: RememberedPermissions::new(),
chat_dispatcher: std::sync::Arc::new(ChatDispatcher::new(1_500)), chat_dispatcher: std::sync::Arc::new(ChatDispatcher::new(1_500)),
}) })
} }
+1
View File
@@ -483,6 +483,7 @@ mod tests {
permission_registry: ResponderRegistry::new(), permission_registry: ResponderRegistry::new(),
pending_perm_replies: PendingPermReplies::new(), pending_perm_replies: PendingPermReplies::new(),
permission_timeout_secs: 120, permission_timeout_secs: 120,
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)), chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
}) })
} }
+11 -3
View File
@@ -588,6 +588,10 @@ pub(crate) async fn run_reconcile_pass(
// Content-GC: purge content-store entries for terminal/tombstoned stories. // Content-GC: purge content-store entries for terminal/tombstoned stories.
crate::db::gc::sweep_zombie_content_on_startup(); crate::db::gc::sweep_zombie_content_on_startup();
// Content backfill: restore story bodies wiped by the pre-1222 buggy
// terminal-transition purge from the durable SQLite shadow column.
crate::db::gc::backfill_evicted_story_content().await;
// Worktree create: ensure every Coding story has a worktree. // Worktree create: ensure every Coding story has a worktree.
crate::agents::pool::worktree_lifecycle::reconcile_worktree_create(root, agents.port()).await; crate::agents::pool::worktree_lifecycle::reconcile_worktree_create(root, agents.port()).await;
@@ -749,12 +753,16 @@ mod tests {
"run_reconcile_pass must not broadcast through the transition channel (no Lagged)" "run_reconcile_pass must not broadcast through the transition channel (no Lagged)"
); );
// ── Assert: zombie content purged for all 200 Abandoned stories ──── // ── Assert: story content is RETAINED for all 200 Abandoned stories ──
// Story 1222: content must never be evicted on a pipeline transition
// (Done/Archived/Abandoned/etc.) — only genuinely deleted/tombstoned
// stories get their body purged. These stories are still live in the
// CRDT (merely Abandoned), so their content-store entry must survive.
for i in 0..200u32 { for i in 0..200u32 {
let id = format!("1066_abandoned_{i:04}"); let id = format!("1066_abandoned_{i:04}");
assert!( assert!(
crate::db::read_content(ContentKey::Story(&id)).is_none(), crate::db::read_content(ContentKey::Story(&id)).is_some(),
"zombie content must be purged for abandoned story {id}" "story content must be retained for abandoned story {id} (story 1222)"
); );
} }
} }
+2 -2
View File
@@ -53,7 +53,7 @@ pub async fn create_worktree(
tokio::task::spawn_blocking(move || configure_sparse_checkout(&wt_clone)) tokio::task::spawn_blocking(move || configure_sparse_checkout(&wt_clone))
.await .await
.map_err(|e| format!("spawn_blocking: {e}"))??; .map_err(|e| format!("spawn_blocking: {e}"))??;
write_mcp_json(&wt_path, port)?; write_mcp_json(&wt_path, port, story_id)?;
return Ok(WorktreeInfo { return Ok(WorktreeInfo {
path: wt_path, path: wt_path,
branch, branch,
@@ -68,7 +68,7 @@ pub async fn create_worktree(
.await .await
.map_err(|e| format!("spawn_blocking: {e}"))??; .map_err(|e| format!("spawn_blocking: {e}"))??;
write_mcp_json(&wt_path, port)?; write_mcp_json(&wt_path, port, story_id)?;
run_setup_commands(&wt_path, config).await; run_setup_commands(&wt_path, config).await;
Ok(WorktreeInfo { Ok(WorktreeInfo {
+31 -6
View File
@@ -40,10 +40,23 @@ pub fn worktree_path(project_root: &Path, story_id: &str) -> PathBuf {
/// Write a `.mcp.json` file in the given directory pointing to the huskies /// Write a `.mcp.json` file in the given directory pointing to the huskies
/// HTTP MCP endpoint at the given port. /// HTTP MCP endpoint at the given port.
pub fn write_mcp_json(dir: &Path, port: u16) -> Result<(), String> { ///
let content = format!( /// Embeds an `X-Huskies-Session` header set to `story_id` so the server can
"{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n" /// scope remembered permission approvals to this worktree's agent session
); /// (see `http::mcp::session`) without affecting other stories' agents.
pub fn write_mcp_json(dir: &Path, port: u16, story_id: &str) -> Result<(), String> {
let value = serde_json::json!({
"mcpServers": {
"huskies": {
"type": "http",
"url": format!("http://localhost:{port}/mcp"),
"headers": { "X-Huskies-Session": story_id }
}
}
});
let content = serde_json::to_string_pretty(&value)
.map_err(|e| format!("Serialize .mcp.json: {e}"))?
+ "\n";
std::fs::write(dir.join(".mcp.json"), content).map_err(|e| format!("Write .mcp.json: {e}")) std::fs::write(dir.join(".mcp.json"), content).map_err(|e| format!("Write .mcp.json: {e}"))
} }
@@ -91,7 +104,7 @@ mod tests {
#[test] #[test]
fn write_mcp_json_uses_given_port() { fn write_mcp_json_uses_given_port() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
write_mcp_json(tmp.path(), 4242).unwrap(); write_mcp_json(tmp.path(), 4242, "1218").unwrap();
let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap(); let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
assert!(content.contains("http://localhost:4242/mcp")); assert!(content.contains("http://localhost:4242/mcp"));
} }
@@ -99,11 +112,23 @@ mod tests {
#[test] #[test]
fn write_mcp_json_default_port() { fn write_mcp_json_default_port() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
write_mcp_json(tmp.path(), 3001).unwrap(); write_mcp_json(tmp.path(), 3001, "1218").unwrap();
let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap(); let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
assert!(content.contains("http://localhost:3001/mcp")); assert!(content.contains("http://localhost:3001/mcp"));
} }
#[test]
fn write_mcp_json_embeds_session_header_with_story_id() {
let tmp = TempDir::new().unwrap();
write_mcp_json(tmp.path(), 3001, "42_story_test").unwrap();
let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
parsed["mcpServers"]["huskies"]["headers"]["X-Huskies-Session"],
"42_story_test"
);
}
#[test] #[test]
fn worktree_path_is_inside_project() { fn worktree_path_is_inside_project() {
let project_root = Path::new("/home/user/my-project"); let project_root = Path::new("/home/user/my-project");