Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1de05b480b | ||
|
|
c906707a4f | ||
|
|
afde373676 | ||
|
|
8a32c1ffd8 | ||
|
|
22710571df | ||
|
|
6fbd755846 | ||
|
|
df1e339b47 | ||
|
|
b55816d0a5 | ||
|
|
eac57c6757 | ||
|
|
c9804ecfdf | ||
|
|
30ca3ad463 | ||
|
|
f7b7f21e88 | ||
|
|
7ac5bd196f | ||
|
|
30b0d31500 | ||
|
|
2b9fcf2878 | ||
|
|
7bbd34bc3a | ||
|
|
3f05648d25 | ||
|
|
f4f0981f17 | ||
|
|
933fb5a54b | ||
|
|
7b3990430e | ||
|
|
caf9953634 | ||
|
|
c7cb3172f1 | ||
|
|
3198309db5 | ||
|
|
59481515b5 | ||
|
|
a6cce683f5 | ||
|
|
2b0e8e6f10 | ||
|
|
34fe84fdd9 | ||
|
|
a4e70af157 | ||
|
|
15e9aa6b34 | ||
|
|
7d3de2bb44 | ||
|
|
fbaf5bf959 | ||
|
|
9c61dfa595 | ||
|
|
90cb005019 | ||
|
|
12580ae9ce |
@@ -35,3 +35,10 @@ double_timmy_log.md
|
||||
pipeline.db
|
||||
pipeline.db.bak*
|
||||
session_store.json
|
||||
|
||||
# Full untruncated merge attempt reports (one file per attempt, pruned automatically)
|
||||
merge_reports/
|
||||
logs/
|
||||
wizard_state.json
|
||||
store.json
|
||||
*.db
|
||||
|
||||
+99
-11
@@ -931,7 +931,11 @@
|
||||
"fn frame_seed_for_prompt"
|
||||
],
|
||||
"server/src/chat/compact/transcript.rs": [
|
||||
"fn transcript_path"
|
||||
"fn transcript_path",
|
||||
"mod test_home",
|
||||
"fn get",
|
||||
"struct HomeGuard",
|
||||
"fn set"
|
||||
],
|
||||
"server/src/chat/dispatcher.rs": [
|
||||
"type SpawnFn",
|
||||
@@ -1037,6 +1041,7 @@
|
||||
"fn is_addressed_to_other"
|
||||
],
|
||||
"server/src/chat/transport/matrix/bot/messages/handle_message.rs": [
|
||||
"const DIGGING_IN_MESSAGE",
|
||||
"fn spawn_digging_in_watcher",
|
||||
"fn handle_message"
|
||||
],
|
||||
@@ -1054,12 +1059,17 @@
|
||||
"mod mentions",
|
||||
"mod messages",
|
||||
"mod permission_listener",
|
||||
"mod question_listener",
|
||||
"mod run",
|
||||
"mod verification"
|
||||
],
|
||||
"server/src/chat/transport/matrix/bot/permission_listener.rs": [
|
||||
"fn spawn_permission_listener"
|
||||
],
|
||||
"server/src/chat/transport/matrix/bot/question_listener.rs": [
|
||||
"fn format_question_as_text",
|
||||
"fn spawn_question_listener"
|
||||
],
|
||||
"server/src/chat/transport/matrix/bot/run.rs": [
|
||||
"fn run_bot"
|
||||
],
|
||||
@@ -1188,8 +1198,12 @@
|
||||
"enum UpgradeCommand",
|
||||
"fn extract_upgrade_command",
|
||||
"fn handle_upgrade_list_projects",
|
||||
"fn resolve_artifact_source",
|
||||
"fn handle_upgrade_all",
|
||||
"fn handle_sled_upgrade"
|
||||
"fn handle_sled_upgrade",
|
||||
"fn run_sled_upgrade",
|
||||
"fn wait_for_health",
|
||||
"fn fetch_sled_version"
|
||||
],
|
||||
"server/src/chat/transport/matrix/start.rs": [
|
||||
"enum StartCommand",
|
||||
@@ -1310,6 +1324,7 @@
|
||||
"fn verify_twilio_signature"
|
||||
],
|
||||
"server/src/chat/util.rs": [
|
||||
"const QUESTION_FORMAT_INSTRUCTION",
|
||||
"fn truncate_at_char_boundary",
|
||||
"fn is_permission_approval",
|
||||
"fn strip_prefix_ci",
|
||||
@@ -1317,6 +1332,12 @@
|
||||
"fn drain_complete_paragraphs",
|
||||
"fn normalize_line_breaks"
|
||||
],
|
||||
"server/src/chatbot_log.rs": [
|
||||
"struct ChatBotLog",
|
||||
"fn set_log_dir",
|
||||
"fn push_line",
|
||||
"fn global"
|
||||
],
|
||||
"server/src/ci_publish_artifact.rs": [],
|
||||
"server/src/cli.rs": [
|
||||
"struct CliArgs",
|
||||
@@ -1401,6 +1422,11 @@
|
||||
"fn delete_gateway_project",
|
||||
"fn extract_gateway_project_view"
|
||||
],
|
||||
"server/src/crdt_state/lww_maps/history_log.rs": [
|
||||
"struct HistoryEntryRaw",
|
||||
"fn append_history_entry",
|
||||
"fn read_all_history_entries"
|
||||
],
|
||||
"server/src/crdt_state/lww_maps/llm_sessions.rs": [
|
||||
"fn write_llm_session",
|
||||
"fn read_llm_session",
|
||||
@@ -1516,6 +1542,7 @@
|
||||
"struct GatewayConfigCrdt",
|
||||
"struct PipelineDoc",
|
||||
"struct EventLogEntryCrdt",
|
||||
"struct HistoryEntryCrdt",
|
||||
"struct LlmSessionCrdt",
|
||||
"enum ScopeFilter",
|
||||
"fn from_scope_str",
|
||||
@@ -1671,16 +1698,21 @@
|
||||
"fn init_content_store"
|
||||
],
|
||||
"server/src/db/gc.rs": [
|
||||
"fn purge_ephemeral_content_keys_for_story",
|
||||
"fn purge_content_keys_for_story",
|
||||
"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": [
|
||||
"mod content_store",
|
||||
"mod gc",
|
||||
"mod ops",
|
||||
"mod recover",
|
||||
"mod shadow_write"
|
||||
"mod shadow_write",
|
||||
"mod tests",
|
||||
"static SHADOW_RT",
|
||||
"fn ensure_shadow_db"
|
||||
],
|
||||
"server/src/db/ops.rs": [
|
||||
"struct ItemMeta",
|
||||
@@ -1731,12 +1763,29 @@
|
||||
"server/src/gateway_relay.rs": [
|
||||
"fn spawn_relay_task"
|
||||
],
|
||||
"server/src/git_test_support.rs": [
|
||||
"fn git_ok"
|
||||
],
|
||||
"server/src/history/mod.rs": [
|
||||
"struct HistoryEntry",
|
||||
"struct HistoryPage",
|
||||
"fn get_history",
|
||||
"fn get_history_entry",
|
||||
"fn record_pipeline_transition",
|
||||
"fn record_chat_turn",
|
||||
"fn record_agent_run",
|
||||
"fn spawn_history_subscriber"
|
||||
],
|
||||
"server/src/http/agents_sse.rs": [
|
||||
"fn agent_stream"
|
||||
],
|
||||
"server/src/http/context.rs": [
|
||||
"enum PermissionDecision",
|
||||
"struct PermissionForward",
|
||||
"struct QuestionOption",
|
||||
"struct QuestionSpec",
|
||||
"enum QuestionAnswer",
|
||||
"struct QuestionForward",
|
||||
"struct AppContext",
|
||||
"fn new_test"
|
||||
],
|
||||
@@ -1818,6 +1867,9 @@
|
||||
"server/src/http/mcp/diagnostics/permission.rs": [
|
||||
"fn tool_prompt_permission"
|
||||
],
|
||||
"server/src/http/mcp/diagnostics/question.rs": [
|
||||
"fn tool_ask_question"
|
||||
],
|
||||
"server/src/http/mcp/diagnostics/usage.rs": [
|
||||
"fn tool_get_token_usage"
|
||||
],
|
||||
@@ -1832,6 +1884,10 @@
|
||||
"fn tool_git_commit",
|
||||
"fn tool_git_log"
|
||||
],
|
||||
"server/src/http/mcp/history_tools.rs": [
|
||||
"fn tool_get_history",
|
||||
"fn tool_get_history_entry"
|
||||
],
|
||||
"server/src/http/mcp/merge_tools.rs": [
|
||||
"fn tool_merge_agent_work",
|
||||
"fn tool_get_merge_status",
|
||||
@@ -1843,9 +1899,11 @@
|
||||
"mod diagnostics",
|
||||
"mod dispatch",
|
||||
"mod git_tools",
|
||||
"mod history_tools",
|
||||
"mod merge_tools",
|
||||
"mod progress",
|
||||
"mod qa_tools",
|
||||
"mod session",
|
||||
"mod shell_tools",
|
||||
"mod status_tools",
|
||||
"mod story_tools",
|
||||
@@ -1867,6 +1925,11 @@
|
||||
"fn tool_reject_qa",
|
||||
"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": [
|
||||
"fn validate_working_dir",
|
||||
"fn tool_run_command"
|
||||
@@ -2129,6 +2192,7 @@
|
||||
"fn append_root_gitignore_entries"
|
||||
],
|
||||
"server/src/io/fs/scaffold/mod.rs": [
|
||||
"fn ensure_gitignore_entries",
|
||||
"fn scaffold_story_kit"
|
||||
],
|
||||
"server/src/io/fs/scaffold/templates.rs": [
|
||||
@@ -2334,12 +2398,15 @@
|
||||
"fn global"
|
||||
],
|
||||
"server/src/main.rs": [
|
||||
"mod chatbot_log",
|
||||
"mod crdt_snapshot",
|
||||
"mod crdt_state",
|
||||
"mod crdt_sync",
|
||||
"mod crdt_wire",
|
||||
"mod event_log",
|
||||
"mod gateway",
|
||||
"mod git_test_support",
|
||||
"mod history",
|
||||
"mod llm_session",
|
||||
"mod log_buffer",
|
||||
"mod mesh",
|
||||
@@ -2576,17 +2643,12 @@
|
||||
"server/src/service/common/mod.rs": [
|
||||
"mod item_id"
|
||||
],
|
||||
"server/src/service/diagnostics/io.rs": [
|
||||
"fn add_permission_rule"
|
||||
],
|
||||
"server/src/service/diagnostics/mod.rs": [
|
||||
"mod io",
|
||||
"mod permission",
|
||||
"enum Error"
|
||||
],
|
||||
"server/src/service/diagnostics/permission.rs": [
|
||||
"fn generate_permission_rule",
|
||||
"fn is_dominated_by_wildcard"
|
||||
"fn generate_permission_rule"
|
||||
],
|
||||
"server/src/service/disk_watch/io.rs": [
|
||||
"fn free_space_bytes",
|
||||
@@ -2731,6 +2793,7 @@
|
||||
"struct GatewayState",
|
||||
"fn new",
|
||||
"fn active_url",
|
||||
"fn url_for_project",
|
||||
"fn register_sled_connection",
|
||||
"fn deregister_sled_connection",
|
||||
"fn sled_connection_for",
|
||||
@@ -2852,6 +2915,7 @@
|
||||
"mod pipeline",
|
||||
"mod project",
|
||||
"mod qa",
|
||||
"mod question_router",
|
||||
"mod settings",
|
||||
"mod shell",
|
||||
"mod status",
|
||||
@@ -2976,7 +3040,11 @@
|
||||
"fn new",
|
||||
"fn insert",
|
||||
"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": [
|
||||
"fn aggregate_pipeline_counts"
|
||||
@@ -3018,6 +3086,22 @@
|
||||
"mod lifecycle",
|
||||
"enum Error"
|
||||
],
|
||||
"server/src/service/question_router.rs": [
|
||||
"const QUESTION_RESPONDER_CHANNEL_CAPACITY",
|
||||
"struct QuestionResponderRegistry",
|
||||
"fn new",
|
||||
"fn is_empty",
|
||||
"fn register",
|
||||
"fn dispatch",
|
||||
"struct QuestionResponderGuard",
|
||||
"fn spawn_question_router",
|
||||
"struct PendingQuestionReplies",
|
||||
"fn new",
|
||||
"fn insert",
|
||||
"fn peek_oldest_meta",
|
||||
"fn resolve_oldest",
|
||||
"fn remove_by_request_id"
|
||||
],
|
||||
"server/src/service/settings/io.rs": [
|
||||
"fn read_config_toml",
|
||||
"fn write_config_toml",
|
||||
@@ -3285,6 +3369,7 @@
|
||||
"server/src/startup/mod.rs": [
|
||||
"mod bots",
|
||||
"mod project",
|
||||
"mod self_upgrade",
|
||||
"mod tick_loop"
|
||||
],
|
||||
"server/src/startup/project.rs": [
|
||||
@@ -3292,6 +3377,9 @@
|
||||
"fn init_subsystems",
|
||||
"fn configure_crdt_sync"
|
||||
],
|
||||
"server/src/startup/self_upgrade.rs": [
|
||||
"fn maybe_self_upgrade"
|
||||
],
|
||||
"server/src/startup/tick_loop.rs": [
|
||||
"fn spawn_event_bridges",
|
||||
"fn spawn_tick_loop",
|
||||
|
||||
Generated
+111
-99
@@ -100,9 +100,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.103"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "anymap2"
|
||||
@@ -221,13 +221,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
version = "0.1.91"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -456,9 +456,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.67"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
|
||||
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -583,9 +583,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.2"
|
||||
version = "4.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
|
||||
checksum = "0fb99565819980999fb7b4a1796046a5c949e6d4ff132cf5fadf5a641e20d776"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
]
|
||||
@@ -1359,9 +1359,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
@@ -1467,9 +1467,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -1482,9 +1482,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
@@ -1492,15 +1492,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
@@ -1520,15 +1520,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1537,21 +1537,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -1925,7 +1925,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "huskies"
|
||||
version = "0.14.0"
|
||||
version = "0.14.3"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"async-stream",
|
||||
@@ -1984,9 +1984,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -2179,9 +2179,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ignore"
|
||||
version = "0.4.30"
|
||||
version = "0.4.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b009b6744c1445efd7244084e25e498636412effb6760b55067553baa925cc7"
|
||||
checksum = "7f8a7b8211e695a1d0cd91cace480d4d0bd57667ab10277cc412c5f7f4884f83"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"globset",
|
||||
@@ -2351,7 +2351,7 @@ dependencies = [
|
||||
"jni-sys",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"walkdir",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
@@ -2492,9 +2492,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
version = "0.2.188"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b"
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
@@ -2612,7 +2612,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3d65d46b7379dd0afa4a42f9b2269821d31afdee0111b5e0d74e3bee03553a0"
|
||||
dependencies = [
|
||||
"matrix-pickle-derive",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2676,7 +2676,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
@@ -2709,7 +2709,7 @@ dependencies = [
|
||||
"ruma",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"unicode-normalization",
|
||||
@@ -2730,7 +2730,7 @@ dependencies = [
|
||||
"ruma",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -2769,7 +2769,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -2803,7 +2803,7 @@ dependencies = [
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
@@ -2833,7 +2833,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"vodozemac",
|
||||
@@ -2858,7 +2858,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -2880,7 +2880,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
@@ -3333,7 +3333,7 @@ dependencies = [
|
||||
"smallvec",
|
||||
"sse-codec",
|
||||
"sync_wrapper",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-tungstenite 0.27.0",
|
||||
@@ -3448,9 +3448,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -3510,7 +3510,7 @@ dependencies = [
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
@@ -3533,7 +3533,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
@@ -3555,9 +3555,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
@@ -3723,22 +3723,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast"
|
||||
version = "1.0.25"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
|
||||
checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
|
||||
dependencies = [
|
||||
"ref-cast-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast-impl"
|
||||
version = "1.0.25"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
|
||||
checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3910,7 +3910,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_html_form",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"url",
|
||||
"web-time",
|
||||
]
|
||||
@@ -3939,7 +3939,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_html_form",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -3963,7 +3963,7 @@ dependencies = [
|
||||
"ruma-macros",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tracing",
|
||||
"web-time",
|
||||
"wildmatch",
|
||||
@@ -3989,7 +3989,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d6cff00317675f487c4e7ccfb18875a14c5a14867b51d13f2a826053f03c432"
|
||||
dependencies = [
|
||||
"js_int",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4229,9 +4229,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
@@ -4260,22 +4260,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4293,9 +4293,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"itoa",
|
||||
@@ -4622,7 +4622,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
@@ -4661,7 +4661,7 @@ dependencies = [
|
||||
"sqlx-core",
|
||||
"sqlx-sqlite",
|
||||
"syn 2.0.119",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
@@ -4688,7 +4688,7 @@ dependencies = [
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"sqlx-core",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -4722,7 +4722,7 @@ dependencies = [
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tracing",
|
||||
"whoami",
|
||||
]
|
||||
@@ -4746,7 +4746,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"sqlx-core",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
@@ -4843,6 +4843,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
@@ -4917,11 +4928,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
version = "2.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.18",
|
||||
"thiserror-impl 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4937,13 +4948,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.18"
|
||||
version = "2.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4957,9 +4968,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.53"
|
||||
version = "0.3.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
|
||||
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
@@ -4977,9 +4988,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.31"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
|
||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
@@ -5022,9 +5033,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.0"
|
||||
version = "1.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -5100,14 +5111,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
version = "0.7.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
@@ -5277,7 +5289,7 @@ dependencies = [
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
"sha1 0.10.7",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
@@ -5296,7 +5308,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1 0.11.0",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5477,7 +5489,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"x25519-dalek",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -6070,9 +6082,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "xxhash-rust"
|
||||
version = "0.8.17"
|
||||
version = "0.8.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985eec839aaf2a1270af8f4ebcf63cf9401cfd90f0902f97c28d9f104ffbde72"
|
||||
checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
@@ -6099,18 +6111,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.54"
|
||||
version = "0.8.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
|
||||
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.54"
|
||||
version = "0.8.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
|
||||
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "huskies",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "huskies",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.3",
|
||||
"dependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "huskies",
|
||||
"private": true,
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -135,7 +135,7 @@ export function PermissionDialog({
|
||||
fontSize: "0.9em",
|
||||
}}
|
||||
>
|
||||
Always Allow
|
||||
Don't ask again this session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -453,6 +453,67 @@ describe("StagePanel", () => {
|
||||
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", () => {
|
||||
const items: PipelineStageItem[] = [
|
||||
{
|
||||
|
||||
@@ -6,6 +6,17 @@ set -euo pipefail
|
||||
#
|
||||
# Run this after `script/docker_rebuild` or whenever you add a new stack.
|
||||
# Safe to re-run: each step re-tags the image with the latest layers.
|
||||
#
|
||||
# IMPORTANT (story 1231): this script is NOT part of `script/release`. The
|
||||
# huskies-project-* images bake whatever `huskies` binary happened to be built
|
||||
# locally the last time this ran — as of this comment that's 0.13.0, several
|
||||
# releases behind the current fleet artifact. `project-rebuild` self-heals a
|
||||
# sled that comes back on a stale baked binary (it re-upgrades it in place
|
||||
# from the gateway's published artifact), but that costs an extra
|
||||
# download+restart cycle every time. Run `script/build-project-images` after
|
||||
# every `script/release` — ideally as a step in the release flow itself — so
|
||||
# freshly rebuilt sleds start on a current binary instead of relying on the
|
||||
# self-heal.
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# NOTE (story 1231): this script publishes the `huskies` binary artifact
|
||||
# (consumed by the `upgrade`/`upgrade all` chat commands) but does NOT
|
||||
# rebuild the huskies-project-* Docker images that `project-rebuild` uses.
|
||||
# Those images bake whatever binary was locally built the last time
|
||||
# `script/build-project-images` ran, so they silently drift stale otherwise
|
||||
# (currently baking 0.13.0). Run `script/build-project-images` after this
|
||||
# script — or better, fold it into this release flow — so a `project-rebuild`
|
||||
# right after a release doesn't need to self-heal a stale sled back up to the
|
||||
# version just published here.
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────────
|
||||
GITEA_URL="https://code.crashlabs.io"
|
||||
REPO="crashlabs/huskies"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "huskies"
|
||||
version = "0.14.0"
|
||||
version = "0.14.3"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@@ -66,6 +66,12 @@ pub(super) fn build_agent_app_context(
|
||||
perm_rx,
|
||||
Arc::clone(&permission_registry),
|
||||
);
|
||||
let (question_tx, question_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let question_registry = crate::service::question_router::QuestionResponderRegistry::new();
|
||||
crate::service::question_router::spawn_question_router(
|
||||
question_rx,
|
||||
Arc::clone(&question_registry),
|
||||
);
|
||||
let timer_store = Arc::new(crate::service::timer::TimerStore::load(
|
||||
project_root.join(".huskies").join("timers.json"),
|
||||
));
|
||||
@@ -82,6 +88,10 @@ pub(super) fn build_agent_app_context(
|
||||
permission_registry,
|
||||
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
|
||||
permission_timeout_secs: 120,
|
||||
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||
question_registry,
|
||||
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(),
|
||||
question_timeout_secs: 120,
|
||||
status: agents.status_broadcaster(),
|
||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||
});
|
||||
@@ -95,6 +105,7 @@ pub(super) fn build_agent_app_context(
|
||||
watcher_tx,
|
||||
reconciliation_tx,
|
||||
perm_tx,
|
||||
question_tx,
|
||||
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
|
||||
bot_shutdown: None,
|
||||
matrix_shutdown_tx: None,
|
||||
|
||||
+29
-20
@@ -447,26 +447,35 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn init_git_repo(repo: &std::path::Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
use crate::git_test_support::git_ok;
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git init",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git config user.email",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git config user.name",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git commit",
|
||||
);
|
||||
}
|
||||
|
||||
// ── find_duplicate_module_files tests ────────────────────────
|
||||
|
||||
@@ -821,26 +821,35 @@ mod tests {
|
||||
// ── feature_branch_has_unmerged_changes tests ────────────────────────────
|
||||
|
||||
fn init_git_repo(repo: &std::path::Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
use crate::git_test_support::git_ok;
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git init",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git config user.email",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git config user.name",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git commit",
|
||||
);
|
||||
}
|
||||
|
||||
/// Bug 226: feature_branch_has_unmerged_changes returns true when the
|
||||
|
||||
@@ -3,26 +3,35 @@ use super::*;
|
||||
use std::process::Command;
|
||||
|
||||
fn init_git_repo(repo: &std::path::Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
use crate::git_test_support::git_ok;
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git init",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git config user.email",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git config user.name",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git commit",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -3,26 +3,35 @@ use super::*;
|
||||
use std::process::Command;
|
||||
|
||||
fn init_git_repo(repo: &std::path::Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
use crate::git_test_support::git_ok;
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git init",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git config user.email",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git config user.name",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git commit",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -170,6 +170,14 @@ pub(in crate::agents::pool) async fn run_server_owned_completion(
|
||||
"[agents] Server-owned completion for '{story_id}:{agent_name}': gates_passed={gates_passed}"
|
||||
);
|
||||
|
||||
crate::history::record_agent_run(
|
||||
story_id,
|
||||
agent_name,
|
||||
session_id.as_deref(),
|
||||
gates_passed,
|
||||
&gate_output,
|
||||
);
|
||||
|
||||
// Notify chat transports of the agent completion result.
|
||||
let _ = watcher_tx.send(WatcherEvent::AgentCompleted {
|
||||
story_id: story_id.to_string(),
|
||||
|
||||
@@ -6,26 +6,35 @@ use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
fn init_git_repo(repo: &std::path::Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
use crate::git_test_support::git_ok;
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git init",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git config user.email",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git config user.name",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git commit",
|
||||
);
|
||||
}
|
||||
|
||||
// ── report_completion tests ────────────────────────────────────
|
||||
|
||||
@@ -34,26 +34,35 @@ fn serial_test_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
}
|
||||
|
||||
fn init_git_repo(repo: &std::path::Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
use crate::git_test_support::git_ok;
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git init",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git config user.email",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git config user.name",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(repo)
|
||||
.output(),
|
||||
"git commit",
|
||||
);
|
||||
}
|
||||
|
||||
// ── bug 498: stale Running job blocks retry ───────────────────────────────
|
||||
|
||||
@@ -189,16 +189,32 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn init_git_repo(dir: &Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git init");
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git commit");
|
||||
use crate::git_test_support::git_ok;
|
||||
git_ok(
|
||||
Command::new("git").args(["init"]).current_dir(dir).output(),
|
||||
"git init",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git config user.email",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git config user.name",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git commit",
|
||||
);
|
||||
}
|
||||
|
||||
fn setup_project(tmp: &TempDir) -> PathBuf {
|
||||
|
||||
@@ -102,10 +102,14 @@ pub(super) fn handle_show(ctx: &CommandContext) -> Option<String> {
|
||||
}
|
||||
};
|
||||
|
||||
// `content` comes from the CRDT / content store. If unavailable, report
|
||||
// it rather than silently reading a stale on-disk copy.
|
||||
// `content` must be present for any story the shared lookup returns —
|
||||
// story 1222 made the content store retain a story's body indefinitely,
|
||||
// so a missing body here is a content-store bug, not an expected state.
|
||||
// Fail loudly instead of masking it with a placeholder message.
|
||||
let text = content.unwrap_or_else(|| {
|
||||
format!("Story {story_id} found in pipeline but its content is unavailable.")
|
||||
panic!(
|
||||
"story {story_id} found by find_story_by_number but has no content in the content store"
|
||||
)
|
||||
});
|
||||
|
||||
// Strip front matter block from the displayed body; source the metadata
|
||||
|
||||
@@ -1161,34 +1161,45 @@ fn display_section_returns_closed_for_new_terminal_variants() {
|
||||
|
||||
/// Initialise a bare-minimum git repo in `dir` with one commit.
|
||||
fn init_git_repo(dir: &std::path::Path) {
|
||||
use crate::git_test_support::git_ok;
|
||||
use std::process::Command;
|
||||
Command::new("git")
|
||||
.args(["init", "-b", "main"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["init", "-b", "main"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git init",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git config user.email",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git config user.name",
|
||||
);
|
||||
// Create an initial commit so the repo has a HEAD.
|
||||
std::fs::write(dir.join("README.md"), "# test").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "README.md"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["add", "README.md"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git add",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git commit",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,46 +19,34 @@ use std::process::Command;
|
||||
const MAX_DIRTY_FILES_SHOWN: usize = 20;
|
||||
|
||||
/// Handle `{bot_name} status {number}`.
|
||||
///
|
||||
/// Resolves the numeric prefix via the shared [`crate::chat::lookup::find_story_by_number`]
|
||||
/// lookup against the room's active project — the same lookup `show` uses —
|
||||
/// rather than a separate pipeline-item scan, so `status <n>` and `show <n>`
|
||||
/// always agree on which story a bare number refers to (story 1237).
|
||||
pub(super) fn handle_triage(ctx: &CommandContext) -> Option<String> {
|
||||
let num_str = ctx.args.trim();
|
||||
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
match find_story_by_number(num_str) {
|
||||
Some((story_id, item)) => Some(build_triage_dump(ctx, &story_id, &item, num_str)),
|
||||
match crate::chat::lookup::find_story_by_number(ctx.effective_root(), num_str) {
|
||||
Some((story_id, stage_dir, _path, content)) => Some(build_triage_dump(
|
||||
ctx, &story_id, &stage_dir, content, num_str,
|
||||
)),
|
||||
None => Some(format!("Story **{num_str}** not found in the pipeline.")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a pipeline item whose numeric prefix matches `num_str` by querying the
|
||||
/// CRDT state. Returns `(story_id, PipelineItem)` for the first match.
|
||||
fn find_story_by_number(num_str: &str) -> Option<(String, crate::pipeline_state::PipelineItem)> {
|
||||
let items = crate::pipeline_state::read_all_typed();
|
||||
for item in items {
|
||||
let file_num = item
|
||||
.story_id
|
||||
.0
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("");
|
||||
if file_num == num_str {
|
||||
let story_id = item.story_id.0.clone();
|
||||
return Some((story_id, item));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build the full triage dump for a story.
|
||||
fn build_triage_dump(
|
||||
ctx: &CommandContext,
|
||||
story_id: &str,
|
||||
item: &crate::pipeline_state::PipelineItem,
|
||||
fallback_stage_dir: &str,
|
||||
content: Option<String>,
|
||||
num_str: &str,
|
||||
) -> String {
|
||||
let contents = match crate::db::read_content(crate::db::ContentKey::Story(story_id)) {
|
||||
let contents = match content {
|
||||
Some(c) => c,
|
||||
None => return format!("Story {num_str}: content not found in content store."),
|
||||
};
|
||||
@@ -72,8 +60,18 @@ fn build_triage_dump(
|
||||
|
||||
// ---- Header ----
|
||||
out.push_str(&format!("## Story {num_str} — {name}\n"));
|
||||
let stage_name = crate::pipeline_state::stage_label(&item.stage);
|
||||
let dir_name = crate::pipeline_state::stage_dir_name(&item.stage);
|
||||
// `fallback_stage_dir` covers the rare case where the shared lookup found
|
||||
// the story via the content store but it hasn't synced into the CRDT yet.
|
||||
let (stage_name, dir_name) = match crdt_item.as_ref() {
|
||||
Some(w) => (
|
||||
crate::pipeline_state::stage_label(w.stage()).to_string(),
|
||||
crate::pipeline_state::stage_dir_name(w.stage()).to_string(),
|
||||
),
|
||||
None => (
|
||||
fallback_stage_dir.to_string(),
|
||||
fallback_stage_dir.to_string(),
|
||||
),
|
||||
};
|
||||
out.push_str(&format!("**Stage:** {stage_name} (`{dir_name}`)\n\n"));
|
||||
|
||||
// ---- CRDT metadata ----
|
||||
@@ -356,6 +354,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Story 1237, AC1 + AC3: `status <n>` and `show <n>` must resolve a bare
|
||||
/// story number to the same story in the same room — both now go through
|
||||
/// the shared `chat::lookup::find_story_by_number` lookup instead of
|
||||
/// `status`/`triage` running its own separate pipeline-item scan.
|
||||
#[test]
|
||||
fn status_and_show_resolve_to_same_story_in_same_room() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"9910_story_shared_lookup.md",
|
||||
"---\nname: Shared Lookup Test\n---\n\n# Story\n\nBody text for consistency check.",
|
||||
Some("Shared Lookup Test"),
|
||||
);
|
||||
|
||||
let status_output = status_triage_cmd(tmp.path(), "9910").unwrap();
|
||||
|
||||
let services =
|
||||
crate::services::Services::new_test(tmp.path().to_path_buf(), "Timmy".to_string());
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
services: &services,
|
||||
project_root: &services.project_root,
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
room_id: &room_id,
|
||||
};
|
||||
let show_output = try_handle_command(&dispatch, "@timmy show 9910").unwrap();
|
||||
|
||||
assert!(
|
||||
status_output.contains("Shared Lookup Test"),
|
||||
"status should resolve story 9910: {status_output}"
|
||||
);
|
||||
assert!(
|
||||
show_output.contains("Body text for consistency check."),
|
||||
"show should resolve story 9910: {show_output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsup_works_for_story_in_backlog() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
@@ -530,33 +566,44 @@ mod tests {
|
||||
|
||||
/// Initialise a bare-minimum git repo in `dir` with one commit.
|
||||
fn init_git_repo(dir: &std::path::Path) {
|
||||
use crate::git_test_support::git_ok;
|
||||
use std::process::Command as Cmd;
|
||||
Cmd::new("git")
|
||||
.args(["init", "-b", "main"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
Cmd::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
Cmd::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_ok(
|
||||
Cmd::new("git")
|
||||
.args(["init", "-b", "main"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git init",
|
||||
);
|
||||
git_ok(
|
||||
Cmd::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git config user.email",
|
||||
);
|
||||
git_ok(
|
||||
Cmd::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git config user.name",
|
||||
);
|
||||
std::fs::write(dir.join("README.md"), "# test").unwrap();
|
||||
Cmd::new("git")
|
||||
.args(["add", "README.md"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
Cmd::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_ok(
|
||||
Cmd::new("git")
|
||||
.args(["add", "README.md"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git add",
|
||||
);
|
||||
git_ok(
|
||||
Cmd::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git commit",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,10 +10,55 @@ use std::path::{Path, PathBuf};
|
||||
/// replaced by `-` (e.g. `/workspace/.huskies/worktrees/1186` becomes
|
||||
/// `-workspace--huskies-worktrees-1186`).
|
||||
pub fn transcript_path(cwd: &Path, session_id: &str) -> PathBuf {
|
||||
#[cfg(test)]
|
||||
if let Some(home) = test_home::get() {
|
||||
return transcript_path_under_home(&home, cwd, session_id);
|
||||
}
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/huskies".to_string());
|
||||
transcript_path_under_home(Path::new(&home), cwd, session_id)
|
||||
}
|
||||
|
||||
/// Per-thread `$HOME` override for tests, so a test can sandbox where
|
||||
/// [`transcript_path`] looks without mutating the process-global `$HOME`
|
||||
/// env var (which every thread shares, including unrelated `git`
|
||||
/// subprocesses spawned by other tests reading `$HOME` for
|
||||
/// `~/.gitconfig`). Thread-local storage gives each test's thread its own
|
||||
/// independent value — no lock, no serialization, no race, since
|
||||
/// `#[tokio::test]` (current-thread flavor, used throughout this crate)
|
||||
/// pins a test's whole async call graph to the one thread that set it.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_home {
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
thread_local! {
|
||||
static HOME: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Return the current thread's `$HOME` override, if one is set.
|
||||
pub(crate) fn get() -> Option<PathBuf> {
|
||||
HOME.with(|h| h.borrow().clone())
|
||||
}
|
||||
|
||||
/// RAII guard: while held, this thread's [`super::transcript_path`]
|
||||
/// calls resolve under the overridden home instead of the real
|
||||
/// `$HOME`. Clears the override on drop.
|
||||
pub(crate) struct HomeGuard;
|
||||
|
||||
impl Drop for HomeGuard {
|
||||
fn drop(&mut self) {
|
||||
HOME.with(|h| *h.borrow_mut() = None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Override `$HOME` resolution for [`super::transcript_path`] calls
|
||||
/// made on the current thread for the lifetime of the returned guard.
|
||||
pub(crate) fn set(home: &Path) -> HomeGuard {
|
||||
HOME.with(|h| *h.borrow_mut() = Some(home.to_path_buf()));
|
||||
HomeGuard
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as [`transcript_path`] but takes an explicit `$HOME` directory,
|
||||
/// keeping the path-joining logic testable without mutating process env vars.
|
||||
fn transcript_path_under_home(home: &Path, cwd: &Path, session_id: &str) -> PathBuf {
|
||||
|
||||
@@ -673,11 +673,7 @@ mod tests {
|
||||
let project_root = tmp.path().join("project");
|
||||
std::fs::create_dir_all(&project_root).unwrap();
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
// SAFETY: this test owns HOME for its duration; no other test in this
|
||||
// process reads HOME concurrently with this call.
|
||||
unsafe {
|
||||
std::env::set_var("HOME", home.path());
|
||||
}
|
||||
let _home_guard = crate::chat::compact::transcript::test_home::set(home.path());
|
||||
let transcript_dir =
|
||||
crate::chat::compact::transcript::transcript_path(&project_root, session_id)
|
||||
.parent()
|
||||
|
||||
@@ -317,6 +317,11 @@ mod tests {
|
||||
permission_registry: ResponderRegistry::new(),
|
||||
pending_perm_replies: PendingPermReplies::new(),
|
||||
permission_timeout_secs: 120,
|
||||
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||
question_registry: crate::service::question_router::QuestionResponderRegistry::new(),
|
||||
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(
|
||||
),
|
||||
question_timeout_secs: 120,
|
||||
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||
})
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
//! Matrix handle_message — runs the LLM turn for a verified incoming message and
|
||||
//! streams the assistant reply back to the room.
|
||||
|
||||
use crate::chat::ChatTransport;
|
||||
use crate::chat::util::drain_complete_paragraphs;
|
||||
use crate::chat::{ChatTransport, MessageId};
|
||||
use crate::llm::providers::claude_code::{CANCELLED, ClaudeCodeProvider, ClaudeCodeResult};
|
||||
use crate::slog;
|
||||
use matrix_sdk::ruma::OwnedRoomId;
|
||||
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use super::super::context::BotContext;
|
||||
@@ -18,8 +20,9 @@ use super::super::history::{ConversationEntry, ConversationRole, save_history};
|
||||
use super::format_user_prompt;
|
||||
|
||||
/// Text posted to the room by [`spawn_digging_in_watcher`] when a turn runs
|
||||
/// long without emitting any user-facing text.
|
||||
const DIGGING_IN_MESSAGE: &str = "Still digging in — this turn is taking a bit longer than usual.";
|
||||
/// long without emitting any user-facing text, and by `on_room_message` as
|
||||
/// an immediate acknowledgement when a message is first received (story 1239).
|
||||
pub(super) const DIGGING_IN_MESSAGE: &str = "Working...";
|
||||
|
||||
/// Spawns a background watcher that posts a single "digging in" notice to
|
||||
/// `room_id` if `threshold` elapses before `sent_any_text` becomes `true`.
|
||||
@@ -44,6 +47,85 @@ pub(in crate::chat::transport::matrix::bot) fn spawn_digging_in_watcher(
|
||||
})
|
||||
}
|
||||
|
||||
/// One live-progress update to apply to the room's placeholder message
|
||||
/// while a turn runs (story 1240).
|
||||
enum ProgressUpdate {
|
||||
/// A tool is about to run — shown to the user as e.g. "Using Read...".
|
||||
Activity(String),
|
||||
/// The model is thinking, with no tool call yet.
|
||||
Thinking,
|
||||
/// A committed chunk of assistant text — finalizes the current placeholder.
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// The provisional state currently displayed in the open placeholder, used
|
||||
/// to debounce repeated identical [`ProgressUpdate`]s into a single edit.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
enum DisplayState {
|
||||
Thinking,
|
||||
Tool(String),
|
||||
}
|
||||
|
||||
/// Drain `updates`, applying each to `room_id` via `transport`.
|
||||
///
|
||||
/// `current_id` starts as the id of the "Working..." placeholder already
|
||||
/// posted by `post_working_notice` (`None` if that send failed). Provisional
|
||||
/// updates (`Activity`/`Thinking`) edit the open placeholder in place —
|
||||
/// skipped entirely when the state is unchanged from the last one shown, so
|
||||
/// a long run of identical signals (e.g. many thinking-token deltas, or the
|
||||
/// same tool_use signalled twice via both the real-time and fallback paths)
|
||||
/// produces at most one edit (AC2/AC5). `Text` always finalizes: it edits
|
||||
/// (or, if none is open, sends) the placeholder with real content and then
|
||||
/// clears `current_id`, so the *next* provisional update lazily opens a
|
||||
/// fresh placeholder below it (AC3) — and if no further update ever arrives,
|
||||
/// nothing extra is ever created, satisfying AC4 without needing a delete
|
||||
/// capability the transport doesn't have.
|
||||
async fn run_progress_updates(
|
||||
transport: Arc<dyn ChatTransport>,
|
||||
room_id: String,
|
||||
bot_sent_event_ids: Arc<TokioMutex<HashSet<OwnedEventId>>>,
|
||||
mut current_id: Option<MessageId>,
|
||||
mut updates: tokio::sync::mpsc::UnboundedReceiver<ProgressUpdate>,
|
||||
) {
|
||||
let mut display_state: Option<DisplayState> = None;
|
||||
while let Some(update) = updates.recv().await {
|
||||
let (text, new_state, finalizes) = match update {
|
||||
ProgressUpdate::Activity(name) => {
|
||||
let state = DisplayState::Tool(name.clone());
|
||||
if display_state.as_ref() == Some(&state) {
|
||||
continue;
|
||||
}
|
||||
(format!("Using {name}..."), Some(state), false)
|
||||
}
|
||||
ProgressUpdate::Thinking => {
|
||||
if display_state.as_ref() == Some(&DisplayState::Thinking) {
|
||||
continue;
|
||||
}
|
||||
(
|
||||
"Thinking...".to_string(),
|
||||
Some(DisplayState::Thinking),
|
||||
false,
|
||||
)
|
||||
}
|
||||
ProgressUpdate::Text(text) => (text, None, true),
|
||||
};
|
||||
|
||||
let html = markdown_to_html(&text);
|
||||
if let Some(id) = ¤t_id {
|
||||
let _ = transport.edit_message(&room_id, id, &text, &html).await;
|
||||
} else if let Ok(msg_id) = transport.send_message(&room_id, &text, &html).await {
|
||||
if let Ok(event_id) = msg_id.parse() {
|
||||
bot_sent_event_ids.lock().await.insert(event_id);
|
||||
}
|
||||
current_id = Some(msg_id);
|
||||
}
|
||||
display_state = new_state;
|
||||
if finalizes {
|
||||
current_id = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
||||
room_id_str: String,
|
||||
room_id: OwnedRoomId,
|
||||
@@ -51,6 +133,7 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
||||
sender: String,
|
||||
user_message: String,
|
||||
mut cancel_rx: watch::Receiver<bool>,
|
||||
placeholder_id: Option<MessageId>,
|
||||
) {
|
||||
// Look up the room's existing Claude Code session ID (if any) so we can
|
||||
// resume the conversation with structured API messages instead of
|
||||
@@ -95,34 +178,31 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
||||
String::new()
|
||||
};
|
||||
let prompt = format!(
|
||||
"{event_log_ctx}{seed_prefix}[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n{active_project_ctx}\n{}",
|
||||
"{event_log_ctx}{seed_prefix}[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n[{}]\n{active_project_ctx}\n{}",
|
||||
crate::chat::util::QUESTION_FORMAT_INSTRUCTION,
|
||||
format_user_prompt(&sender, &user_message)
|
||||
);
|
||||
|
||||
let provider = ClaudeCodeProvider::new();
|
||||
|
||||
// Channel for sending complete paragraphs to the Matrix posting task.
|
||||
let (msg_tx, mut msg_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
let msg_tx_for_callback = msg_tx.clone();
|
||||
// Channel for sending live-progress updates to the Matrix posting task.
|
||||
let (progress_tx, progress_rx) = tokio::sync::mpsc::unbounded_channel::<ProgressUpdate>();
|
||||
let progress_tx_for_callback = progress_tx.clone();
|
||||
let progress_tx_for_thinking = progress_tx.clone();
|
||||
let progress_tx_for_activity = progress_tx.clone();
|
||||
|
||||
// Spawn a task to post messages via the transport as they arrive so we
|
||||
// don't block the LLM stream while waiting for send round-trips.
|
||||
// Spawn a task to apply progress updates via the transport as they arrive
|
||||
// so we don't block the LLM stream while waiting for send/edit round-trips.
|
||||
let post_transport = Arc::clone(&ctx.transport);
|
||||
let post_room_id = room_id_str.clone();
|
||||
let sent_ids = Arc::clone(&ctx.bot_sent_event_ids);
|
||||
let sent_ids_for_post = Arc::clone(&sent_ids);
|
||||
let post_task = tokio::spawn(async move {
|
||||
while let Some(chunk) = msg_rx.recv().await {
|
||||
let html = markdown_to_html(&chunk);
|
||||
if let Ok(msg_id) = post_transport
|
||||
.send_message(&post_room_id, &chunk, &html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
sent_ids_for_post.lock().await.insert(event_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
let post_task = tokio::spawn(run_progress_updates(
|
||||
post_transport,
|
||||
post_room_id,
|
||||
sent_ids,
|
||||
placeholder_id,
|
||||
progress_rx,
|
||||
));
|
||||
|
||||
// Shared state between the sync token callback and the async outer scope.
|
||||
let buffer = Arc::new(std::sync::Mutex::new(String::new()));
|
||||
@@ -156,11 +236,15 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
||||
let paragraphs = drain_complete_paragraphs(&mut buf);
|
||||
for chunk in paragraphs {
|
||||
sent_any_chunk_for_callback.store(true, Ordering::Relaxed);
|
||||
let _ = msg_tx_for_callback.send(chunk);
|
||||
let _ = progress_tx_for_callback.send(ProgressUpdate::Text(chunk));
|
||||
}
|
||||
},
|
||||
|_thinking| {}, // Discard thinking tokens
|
||||
|_activity| {}, // Discard activity signals
|
||||
move |_thinking| {
|
||||
let _ = progress_tx_for_thinking.send(ProgressUpdate::Thinking);
|
||||
},
|
||||
move |activity| {
|
||||
let _ = progress_tx_for_activity.send(ProgressUpdate::Activity(activity.to_string()));
|
||||
},
|
||||
);
|
||||
tokio::pin!(chat_fut);
|
||||
|
||||
@@ -192,7 +276,7 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
||||
usage,
|
||||
}) => {
|
||||
let reply = if !remaining.is_empty() {
|
||||
let _ = msg_tx.send(remaining.clone());
|
||||
let _ = progress_tx.send(ProgressUpdate::Text(remaining.clone()));
|
||||
remaining
|
||||
} else if !did_send_any {
|
||||
// Nothing was streamed at all (e.g. only tool calls with no
|
||||
@@ -205,7 +289,7 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
||||
.map(|m| m.content.clone())
|
||||
.unwrap_or_default();
|
||||
if !last_text.is_empty() {
|
||||
let _ = msg_tx.send(last_text.clone());
|
||||
let _ = progress_tx.send(ProgressUpdate::Text(last_text.clone()));
|
||||
}
|
||||
last_text
|
||||
} else {
|
||||
@@ -230,14 +314,14 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
||||
} else {
|
||||
format!("Error processing your request: {e}")
|
||||
};
|
||||
let _ = msg_tx.send(err_msg.clone());
|
||||
let _ = progress_tx.send(ProgressUpdate::Text(err_msg.clone()));
|
||||
(err_msg, None, None)
|
||||
}
|
||||
};
|
||||
|
||||
// Drop the sender to signal the posting task that no more messages will
|
||||
// arrive, then wait for all pending Matrix sends to complete.
|
||||
drop(msg_tx);
|
||||
// Drop the sender to signal the posting task that no more updates will
|
||||
// arrive, then wait for all pending Matrix sends/edits to complete.
|
||||
drop(progress_tx);
|
||||
let _ = post_task.await;
|
||||
|
||||
if was_cancelled {
|
||||
@@ -333,18 +417,24 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chat::MessageId;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Captures every message sent through it, for assertion in tests.
|
||||
/// Captures every message sent and edit applied through it, for
|
||||
/// assertion in tests. Each `send_message` call returns a fresh,
|
||||
/// distinguishable id (`msg-0`, `msg-1`, ...) so tests can tell which
|
||||
/// placeholder a subsequent edit landed on.
|
||||
struct CapturingTransport {
|
||||
sent: std::sync::Mutex<Vec<(String, String)>>,
|
||||
edits: std::sync::Mutex<Vec<(String, String, String)>>,
|
||||
next_id: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl CapturingTransport {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
sent: std::sync::Mutex::new(Vec::new()),
|
||||
edits: std::sync::Mutex::new(Vec::new()),
|
||||
next_id: std::sync::atomic::AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +449,20 @@ mod tests {
|
||||
.last()
|
||||
.map(|(_, plain)| plain.clone())
|
||||
}
|
||||
|
||||
fn edit_count(&self) -> usize {
|
||||
self.edits.lock().unwrap().len()
|
||||
}
|
||||
|
||||
/// `(message_id, plain_text)` for every edit, in call order.
|
||||
fn edits(&self) -> Vec<(String, String)> {
|
||||
self.edits
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(id, plain, _html)| (id.clone(), plain.clone()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -373,16 +477,24 @@ mod tests {
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((room_id.to_string(), plain.to_string()));
|
||||
Ok("msg-id".to_string())
|
||||
let n = self
|
||||
.next_id
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
Ok(format!("msg-{n}"))
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
_room_id: &str,
|
||||
_original_message_id: &str,
|
||||
_plain: &str,
|
||||
_html: &str,
|
||||
original_message_id: &str,
|
||||
plain: &str,
|
||||
html: &str,
|
||||
) -> Result<(), String> {
|
||||
self.edits.lock().unwrap().push((
|
||||
original_message_id.to_string(),
|
||||
plain.to_string(),
|
||||
html.to_string(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -392,7 +504,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// 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]
|
||||
async fn digging_in_fires_after_threshold_when_no_text_sent() {
|
||||
let transport = Arc::new(CapturingTransport::new());
|
||||
@@ -405,14 +517,7 @@ mod tests {
|
||||
);
|
||||
handle.await.unwrap();
|
||||
assert_eq!(transport.sent_count(), 1);
|
||||
assert!(
|
||||
transport
|
||||
.last_message()
|
||||
.unwrap()
|
||||
.to_lowercase()
|
||||
.contains("digging in"),
|
||||
"notice should mention 'digging in'"
|
||||
);
|
||||
assert_eq!(transport.last_message().unwrap(), "Working...");
|
||||
}
|
||||
|
||||
/// AC 2: if text is sent before the threshold elapses, the watcher must
|
||||
@@ -498,4 +603,164 @@ mod tests {
|
||||
second_handle.await.unwrap();
|
||||
assert_eq!(transport.sent_count(), 1);
|
||||
}
|
||||
|
||||
// ── run_progress_updates (story 1240) ─────────────────────────────────
|
||||
|
||||
fn spawn_progress(
|
||||
transport: Arc<CapturingTransport>,
|
||||
placeholder_id: Option<MessageId>,
|
||||
) -> (
|
||||
tokio::sync::mpsc::UnboundedSender<ProgressUpdate>,
|
||||
tokio::task::JoinHandle<()>,
|
||||
) {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<ProgressUpdate>();
|
||||
let bot_sent_event_ids = Arc::new(TokioMutex::new(HashSet::new()));
|
||||
let handle = tokio::spawn(run_progress_updates(
|
||||
transport as Arc<dyn ChatTransport>,
|
||||
"!room:example.com".to_string(),
|
||||
bot_sent_event_ids,
|
||||
placeholder_id,
|
||||
rx,
|
||||
));
|
||||
(tx, handle)
|
||||
}
|
||||
|
||||
/// AC 1: an activity signal edits the existing placeholder to name the
|
||||
/// tool, and a thinking signal edits it to a "Thinking..." state.
|
||||
#[tokio::test]
|
||||
async fn activity_and_thinking_edit_the_open_placeholder() {
|
||||
let transport = Arc::new(CapturingTransport::new());
|
||||
let (tx, handle) = spawn_progress(transport.clone(), Some("placeholder-0".to_string()));
|
||||
|
||||
tx.send(ProgressUpdate::Activity("Read".to_string()))
|
||||
.unwrap();
|
||||
tx.send(ProgressUpdate::Thinking).unwrap();
|
||||
drop(tx);
|
||||
handle.await.unwrap();
|
||||
|
||||
assert_eq!(transport.sent_count(), 0, "no new message should be sent");
|
||||
let edits = transport.edits();
|
||||
assert_eq!(edits.len(), 2);
|
||||
assert_eq!(
|
||||
edits[0],
|
||||
("placeholder-0".to_string(), "Using Read...".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
edits[1],
|
||||
("placeholder-0".to_string(), "Thinking...".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
/// AC 2: repeating the same tool-call signal (e.g. the real-time and
|
||||
/// fallback activity paths both firing for one tool_use block) produces
|
||||
/// exactly one edit, not two.
|
||||
#[tokio::test]
|
||||
async fn repeated_identical_activity_produces_one_edit() {
|
||||
let transport = Arc::new(CapturingTransport::new());
|
||||
let (tx, handle) = spawn_progress(transport.clone(), Some("placeholder-0".to_string()));
|
||||
|
||||
tx.send(ProgressUpdate::Activity("Bash".to_string()))
|
||||
.unwrap();
|
||||
tx.send(ProgressUpdate::Activity("Bash".to_string()))
|
||||
.unwrap();
|
||||
tx.send(ProgressUpdate::Activity("Bash".to_string()))
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
handle.await.unwrap();
|
||||
|
||||
assert_eq!(transport.edit_count(), 1, "duplicate signals must debounce");
|
||||
}
|
||||
|
||||
/// AC 3: an intermediate text block finalizes the current placeholder
|
||||
/// (edits it with the real text) and the next provisional update opens a
|
||||
/// fresh placeholder below it rather than reusing the finalized one.
|
||||
#[tokio::test]
|
||||
async fn intermediate_text_finalizes_then_next_update_opens_fresh_placeholder() {
|
||||
let transport = Arc::new(CapturingTransport::new());
|
||||
let (tx, handle) = spawn_progress(transport.clone(), Some("placeholder-0".to_string()));
|
||||
|
||||
tx.send(ProgressUpdate::Text("Here's part one.".to_string()))
|
||||
.unwrap();
|
||||
tx.send(ProgressUpdate::Activity("Edit".to_string()))
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
handle.await.unwrap();
|
||||
|
||||
let edits = transport.edits();
|
||||
assert_eq!(
|
||||
edits[0],
|
||||
("placeholder-0".to_string(), "Here's part one.".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
transport.sent_count(),
|
||||
1,
|
||||
"a fresh placeholder must be sent"
|
||||
);
|
||||
assert_eq!(transport.last_message().unwrap(), "Using Edit...");
|
||||
// The subsequent edit (from the AC5-style test below) would need to
|
||||
// land on this newly sent placeholder, not the already-finalized one
|
||||
// — confirmed here since this update was sent, not edited.
|
||||
assert_eq!(edits.len(), 1, "the second update was sent, not edited");
|
||||
}
|
||||
|
||||
/// AC 4: when a text update is the last thing to arrive, it finalizes the
|
||||
/// open placeholder in place and nothing further is ever sent — no
|
||||
/// trailing placeholder is left behind.
|
||||
#[tokio::test]
|
||||
async fn final_text_finalizes_with_no_trailing_placeholder() {
|
||||
let transport = Arc::new(CapturingTransport::new());
|
||||
let (tx, handle) = spawn_progress(transport.clone(), Some("placeholder-0".to_string()));
|
||||
|
||||
tx.send(ProgressUpdate::Activity("Read".to_string()))
|
||||
.unwrap();
|
||||
tx.send(ProgressUpdate::Text("All done.".to_string()))
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
handle.await.unwrap();
|
||||
|
||||
assert_eq!(transport.sent_count(), 0, "no trailing placeholder");
|
||||
let edits = transport.edits();
|
||||
assert_eq!(edits.last().unwrap().1, "All done.");
|
||||
}
|
||||
|
||||
/// AC 5: a representative turn (tool call, duplicate tool signal,
|
||||
/// thinking burst, another tool call, an intermediate text block, one
|
||||
/// more tool call, final text) produces roughly five edits — not
|
||||
/// hundreds — even though several of those updates arrive many times.
|
||||
#[tokio::test]
|
||||
async fn representative_turn_produces_roughly_five_edits() {
|
||||
let transport = Arc::new(CapturingTransport::new());
|
||||
let (tx, handle) = spawn_progress(transport.clone(), Some("placeholder-0".to_string()));
|
||||
|
||||
tx.send(ProgressUpdate::Activity("Read".to_string()))
|
||||
.unwrap();
|
||||
tx.send(ProgressUpdate::Activity("Read".to_string()))
|
||||
.unwrap(); // fallback duplicate of the same tool_use — must debounce
|
||||
for _ in 0..50 {
|
||||
tx.send(ProgressUpdate::Thinking).unwrap(); // a burst of thinking-token deltas
|
||||
}
|
||||
tx.send(ProgressUpdate::Activity("Bash".to_string()))
|
||||
.unwrap();
|
||||
tx.send(ProgressUpdate::Text(
|
||||
"Here's what I found so far.".to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
tx.send(ProgressUpdate::Activity("Edit".to_string()))
|
||||
.unwrap();
|
||||
tx.send(ProgressUpdate::Text("Done.".to_string())).unwrap();
|
||||
drop(tx);
|
||||
handle.await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
transport.edit_count(),
|
||||
5,
|
||||
"one edit per distinct state change, regardless of how many \
|
||||
identical updates arrived in between"
|
||||
);
|
||||
assert_eq!(
|
||||
transport.sent_count(),
|
||||
1,
|
||||
"one fresh placeholder for the second half"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,6 +336,84 @@ async fn try_handle_stop_command(
|
||||
true
|
||||
}
|
||||
|
||||
/// Parse a chat reply to a pending [`QuestionSpec`](crate::http::context::QuestionSpec)
|
||||
/// into the answer it represents (story 1228, AC2/AC4/AC5).
|
||||
///
|
||||
/// Tries, in order: a comma-separated list of 1-based option numbers (e.g.
|
||||
/// `"2"` or `"1,3"`, validated against `num_options` and `multi_select`); a
|
||||
/// case-insensitive exact match against one of `labels`; and finally free
|
||||
/// text (the always-available "Other" path). Returns `None` only when the
|
||||
/// reply looks like an attempted numeric selection but is out of range or
|
||||
/// violates `multi_select` — the caller must re-prompt in that case rather
|
||||
/// than guessing or picking a default (AC4).
|
||||
fn parse_question_reply(
|
||||
body: &str,
|
||||
num_options: usize,
|
||||
multi_select: bool,
|
||||
labels: &[String],
|
||||
) -> Option<crate::http::context::QuestionAnswer> {
|
||||
let body_trimmed = body.trim();
|
||||
// Strip a leading "@mention " prefix (e.g. "@timmy 1") entirely, not just
|
||||
// the '@' character, so a mention-prefixed reply still parses as a
|
||||
// number/label rather than falling through to free text.
|
||||
let trimmed = if body_trimmed.starts_with('@') {
|
||||
body_trimmed
|
||||
.split_once(char::is_whitespace)
|
||||
.map(|(_, rest)| rest.trim_start())
|
||||
.unwrap_or(body_trimmed)
|
||||
} else {
|
||||
body_trimmed
|
||||
};
|
||||
let tokens: Vec<&str> = trimmed.split(',').map(str::trim).collect();
|
||||
let parsed_indices: Option<Vec<usize>> =
|
||||
tokens.iter().map(|t| t.parse::<usize>().ok()).collect();
|
||||
match parsed_indices {
|
||||
Some(indices) if !trimmed.is_empty() => {
|
||||
if indices.iter().any(|&i| i == 0 || i > num_options) {
|
||||
None
|
||||
} else {
|
||||
let mut zero_based: Vec<usize> = indices.into_iter().map(|i| i - 1).collect();
|
||||
zero_based.dedup();
|
||||
if zero_based.len() > 1 && !multi_select {
|
||||
None
|
||||
} else {
|
||||
Some(crate::http::context::QuestionAnswer::Selected(zero_based))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => match labels.iter().position(|l| l.eq_ignore_ascii_case(trimmed)) {
|
||||
Some(idx) => Some(crate::http::context::QuestionAnswer::Selected(vec![idx])),
|
||||
None => Some(crate::http::context::QuestionAnswer::FreeText(
|
||||
trimmed.to_string(),
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Post an immediate "Working..." acknowledgement for a newly received room
|
||||
/// message, before it is handed to the dispatcher (which may coalesce it
|
||||
/// with other messages and/or queue it behind an in-flight run).
|
||||
///
|
||||
/// Called once per incoming room message that reaches the LLM dispatch path
|
||||
/// — never once per resulting agent turn — so a burst of messages the
|
||||
/// dispatcher later coalesces into a single turn still yields one notice per
|
||||
/// message the user actually sent (story 1239).
|
||||
async fn post_working_notice(
|
||||
ctx: &BotContext,
|
||||
room_id_str: &str,
|
||||
) -> Option<crate::chat::MessageId> {
|
||||
let html = markdown_to_html(handle_message::DIGGING_IN_MESSAGE);
|
||||
let msg_id = ctx
|
||||
.transport
|
||||
.send_message(room_id_str, handle_message::DIGGING_IN_MESSAGE, &html)
|
||||
.await
|
||||
.ok()?;
|
||||
if let Ok(event_id) = msg_id.parse() {
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
}
|
||||
Some(msg_id)
|
||||
}
|
||||
|
||||
pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||
ev: OriginalSyncRoomMessageEvent,
|
||||
room: Room,
|
||||
@@ -471,6 +549,59 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||
}
|
||||
}
|
||||
|
||||
// If there is a pending question for this room, interpret the message as
|
||||
// an answer instead of starting a new chat (story 1228). Checked before
|
||||
// the pending-permission block below so the two never conflate (AC4):
|
||||
// each is a fully independent store, and this only fires when a question
|
||||
// is actually pending for this room.
|
||||
if let Some((num_options, multi_select, labels)) = ctx
|
||||
.services
|
||||
.pending_question_replies
|
||||
.peek_oldest_meta(incoming_room_id.as_str())
|
||||
.await
|
||||
{
|
||||
let outcome = parse_question_reply(&body, num_options, multi_select, &labels);
|
||||
|
||||
match outcome {
|
||||
None => {
|
||||
// Invalid attempted selection — re-prompt without consuming
|
||||
// the pending question (AC4: never strand the agent or
|
||||
// silently pick a default).
|
||||
let msg = "I didn't understand that reply. Please reply with a number \
|
||||
(or numbers separated by commas), an option's label, or your \
|
||||
own answer.";
|
||||
let html = markdown_to_html(msg);
|
||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, msg, &html).await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Some(answer) => {
|
||||
if let Some(tx) = ctx
|
||||
.services
|
||||
.pending_question_replies
|
||||
.resolve_oldest(incoming_room_id.as_str())
|
||||
.await
|
||||
{
|
||||
let _ = tx.send(Ok(answer));
|
||||
}
|
||||
let confirmation = "Got it — thanks for answering.";
|
||||
let html = markdown_to_html(confirmation);
|
||||
if let Ok(msg_id) = ctx
|
||||
.transport
|
||||
.send_message(&room_id_str, confirmation, &html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there is a pending permission prompt for this room, interpret the
|
||||
// message as a yes/no response instead of starting a new chat.
|
||||
{
|
||||
@@ -793,6 +924,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||
rebuild_cmd.drain_timeout_secs,
|
||||
rebuild_cmd.force,
|
||||
store,
|
||||
ctx.gateway_port,
|
||||
&ctx.services.project_root,
|
||||
)
|
||||
.await
|
||||
@@ -1457,6 +1589,11 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||
return;
|
||||
}
|
||||
|
||||
// Acknowledge receipt immediately, before the message is handed to the
|
||||
// dispatcher below (story 1239). Its message id seeds the live-progress
|
||||
// placeholder that handle_message edits in place as the turn runs (story 1240).
|
||||
let placeholder_id = post_working_notice(&ctx, &room_id_str).await;
|
||||
|
||||
// Hand the message to the protocol-agnostic dispatcher instead of spawning
|
||||
// directly. The dispatcher applies a coalesce window and a per-session
|
||||
// serial lock, preventing duplicate concurrent Timmy spawns.
|
||||
@@ -1469,6 +1606,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||
let incoming_room_id = incoming_room_id.clone();
|
||||
let ctx = ctx_for_factory.clone();
|
||||
let sender = sender.clone();
|
||||
let placeholder_id = placeholder_id.clone();
|
||||
Box::pin(async move {
|
||||
handle_message(
|
||||
room_id_str,
|
||||
@@ -1477,6 +1615,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||
sender,
|
||||
coalesced,
|
||||
cancel_rx,
|
||||
placeholder_id,
|
||||
)
|
||||
.await;
|
||||
})
|
||||
@@ -1496,14 +1635,75 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||
mod tests {
|
||||
use super::{
|
||||
eval_gateway_overview_command, eval_gateway_status_command, eval_switch_command,
|
||||
try_handle_compact_command, try_handle_stop_command,
|
||||
parse_question_reply, post_working_notice, try_handle_compact_command,
|
||||
try_handle_stop_command,
|
||||
};
|
||||
use crate::chat::{ChatTransport, MessageId};
|
||||
use crate::http::context::QuestionAnswer;
|
||||
use crate::service::gateway::config::ProjectEntry;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::BTreeMap;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
// -- parse_question_reply (story 1228) -----------------------------------
|
||||
|
||||
fn sample_labels() -> Vec<String> {
|
||||
vec!["Fast".to_string(), "Safe".to_string()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_question_reply_single_number_selects_option() {
|
||||
let outcome = parse_question_reply("2", 2, false, &sample_labels());
|
||||
assert_eq!(outcome, Some(QuestionAnswer::Selected(vec![1])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_question_reply_multi_select_comma_list() {
|
||||
let outcome = parse_question_reply("1,2", 2, true, &sample_labels());
|
||||
assert_eq!(outcome, Some(QuestionAnswer::Selected(vec![0, 1])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_question_reply_multi_numbers_rejected_when_not_multi_select() {
|
||||
let outcome = parse_question_reply("1,2", 2, false, &sample_labels());
|
||||
assert_eq!(outcome, None, "must re-prompt, not silently pick one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_question_reply_out_of_range_number_is_invalid() {
|
||||
let outcome = parse_question_reply("5", 2, false, &sample_labels());
|
||||
assert_eq!(outcome, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_question_reply_zero_is_invalid() {
|
||||
let outcome = parse_question_reply("0", 2, false, &sample_labels());
|
||||
assert_eq!(outcome, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_question_reply_matches_option_label_case_insensitively() {
|
||||
let outcome = parse_question_reply("fast", 2, false, &sample_labels());
|
||||
assert_eq!(outcome, Some(QuestionAnswer::Selected(vec![0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_question_reply_unmatched_text_is_free_text() {
|
||||
let outcome = parse_question_reply("Let's do something else", 2, false, &sample_labels());
|
||||
assert_eq!(
|
||||
outcome,
|
||||
Some(QuestionAnswer::FreeText(
|
||||
"Let's do something else".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_question_reply_strips_leading_mention() {
|
||||
let outcome = parse_question_reply("@timmy 1", 2, false, &sample_labels());
|
||||
assert_eq!(outcome, Some(QuestionAnswer::Selected(vec![0])));
|
||||
}
|
||||
|
||||
/// Regression test: `switch` reads from the live store, not a snapshot Vec.
|
||||
///
|
||||
/// Seeds an empty store, inserts a project at runtime, then asserts the
|
||||
@@ -1915,11 +2115,7 @@ mod tests {
|
||||
let project_root_dir = tempfile::tempdir().unwrap();
|
||||
let project_root = project_root_dir.path().to_path_buf();
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
// SAFETY: this test owns HOME for its duration; no other test in this
|
||||
// process reads HOME concurrently with this call.
|
||||
unsafe {
|
||||
std::env::set_var("HOME", home.path());
|
||||
}
|
||||
let _home_guard = crate::chat::compact::transcript::test_home::set(home.path());
|
||||
let transcript_dir =
|
||||
crate::chat::compact::transcript::transcript_path(&project_root, session_id)
|
||||
.parent()
|
||||
@@ -2111,4 +2307,57 @@ mod tests {
|
||||
"no reply should be sent for a message that isn't a bare stop"
|
||||
);
|
||||
}
|
||||
|
||||
// ── post_working_notice (story 1239) ─────────────────────────────────
|
||||
|
||||
/// AC1/AC2: receiving a message posts "Working..." to the room
|
||||
/// immediately — before any agent/dispatcher work happens — via a
|
||||
/// single, synchronously-awaited call.
|
||||
#[tokio::test]
|
||||
async fn post_working_notice_sends_immediately_to_the_room() {
|
||||
use std::sync::Arc;
|
||||
|
||||
let project_root_dir = tempfile::tempdir().unwrap();
|
||||
let services = crate::services::Services::new_test(
|
||||
project_root_dir.path().to_path_buf(),
|
||||
"Huskies".to_string(),
|
||||
);
|
||||
let transport = Arc::new(CapturingTransport::new());
|
||||
let ctx = make_test_ctx(services, transport.clone());
|
||||
|
||||
post_working_notice(&ctx, "!room:example.com").await;
|
||||
|
||||
let sent = transport.sent.lock().unwrap().clone();
|
||||
assert_eq!(sent.len(), 1, "exactly one notice must be sent");
|
||||
assert_eq!(sent[0].0, "!room:example.com");
|
||||
assert_eq!(sent[0].1, "Working...");
|
||||
}
|
||||
|
||||
/// AC3: the notice is emitted once per call — i.e. once per incoming
|
||||
/// user message — not coalesced into a single notice the way the
|
||||
/// dispatcher coalesces messages into one agent turn. Two messages
|
||||
/// (two calls) must yield two notices.
|
||||
#[tokio::test]
|
||||
async fn post_working_notice_fires_once_per_message_not_once_per_turn() {
|
||||
use std::sync::Arc;
|
||||
|
||||
let project_root_dir = tempfile::tempdir().unwrap();
|
||||
let services = crate::services::Services::new_test(
|
||||
project_root_dir.path().to_path_buf(),
|
||||
"Huskies".to_string(),
|
||||
);
|
||||
let transport = Arc::new(CapturingTransport::new());
|
||||
let ctx = make_test_ctx(services, transport.clone());
|
||||
|
||||
post_working_notice(&ctx, "!room:example.com").await;
|
||||
post_working_notice(&ctx, "!room:example.com").await;
|
||||
|
||||
let sent = transport.sent.lock().unwrap().clone();
|
||||
assert_eq!(
|
||||
sent.len(),
|
||||
2,
|
||||
"two incoming messages must produce two notices, even though the \
|
||||
dispatcher would coalesce them into a single agent turn"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ pub mod messages;
|
||||
/// Permission listener — registers as a permission responder for the bot's
|
||||
/// lifetime and forwards permission requests to the configured Matrix room.
|
||||
pub mod permission_listener;
|
||||
/// Question listener — registers as a question responder for the bot's
|
||||
/// lifetime and forwards `ask_question` requests to the configured Matrix
|
||||
/// room, rendered as numbered text (story 1228).
|
||||
pub mod question_listener;
|
||||
/// Bot run loop — the main async task that drives the Matrix sync loop.
|
||||
pub mod run;
|
||||
/// Device verification — handles Matrix cross-signing and emoji verification flows.
|
||||
|
||||
@@ -173,6 +173,11 @@ mod tests {
|
||||
permission_registry: crate::service::permission_router::ResponderRegistry::new(),
|
||||
pending_perm_replies: PendingPermReplies::new(),
|
||||
permission_timeout_secs: 120,
|
||||
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||
question_registry: crate::service::question_router::QuestionResponderRegistry::new(),
|
||||
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(
|
||||
),
|
||||
question_timeout_secs: 120,
|
||||
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
//! Background task that registers as a question responder for the bot's
|
||||
//! lifetime and forwards `ask_question` requests to the configured Matrix
|
||||
//! room, rendering them as numbered text (story 1228).
|
||||
//!
|
||||
//! Mirrors `permission_listener.rs`'s always-on registration pattern so an
|
||||
//! agent's question reaches chat even when the bot isn't mid-turn.
|
||||
|
||||
use crate::chat::ChatTransport;
|
||||
use crate::http::context::QuestionSpec;
|
||||
use crate::services::Services;
|
||||
use crate::slog;
|
||||
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
use super::format::markdown_to_html;
|
||||
|
||||
/// Render a [`QuestionSpec`] as readable, numbered chat text — the question,
|
||||
/// then each option numbered with its label and description — never raw
|
||||
/// JSON (story 1228, AC1).
|
||||
pub fn format_question_as_text(question: &QuestionSpec) -> String {
|
||||
let mut out = format!("**{}**\n\n{}\n\n", question.header, question.question);
|
||||
for (i, opt) in question.options.iter().enumerate() {
|
||||
out.push_str(&format!("{}. {} — {}\n", i + 1, opt.label, opt.description));
|
||||
}
|
||||
out.push('\n');
|
||||
if question.multi_select {
|
||||
out.push_str(
|
||||
"Reply with a number, or multiple numbers separated by commas (e.g. \"1,3\"), \
|
||||
or type your own answer.",
|
||||
);
|
||||
} else {
|
||||
out.push_str("Reply with a number, or type your own answer.");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Spawn a background task that registers with `services.question_registry`
|
||||
/// for the bot's lifetime and forwards each incoming question request to
|
||||
/// `target_room` as a chat message. Replies are resolved by `on_room_message`
|
||||
/// via `pending_question_replies`.
|
||||
pub fn spawn_question_listener(
|
||||
services: Arc<Services>,
|
||||
transport: Arc<dyn ChatTransport>,
|
||||
target_room: OwnedRoomId,
|
||||
bot_sent_event_ids: Arc<TokioMutex<HashSet<OwnedEventId>>>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let (_responder_guard, mut question_rx) = services.question_registry.register();
|
||||
let target_room_str = target_room.as_str().to_string();
|
||||
slog!("[matrix-bot] question listener started; forwarding requests to {target_room_str}");
|
||||
|
||||
while let Some(q_fwd) = question_rx.recv().await {
|
||||
let prompt_msg = format_question_as_text(&q_fwd.question);
|
||||
let html = markdown_to_html(&prompt_msg);
|
||||
if let Ok(msg_id) = transport
|
||||
.send_message(&target_room_str, &prompt_msg, &html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse::<OwnedEventId>()
|
||||
{
|
||||
bot_sent_event_ids.lock().await.insert(event_id);
|
||||
}
|
||||
|
||||
let num_options = q_fwd.question.options.len();
|
||||
let multi_select = q_fwd.question.multi_select;
|
||||
let labels: Vec<String> = q_fwd
|
||||
.question
|
||||
.options
|
||||
.iter()
|
||||
.map(|o| o.label.clone())
|
||||
.collect();
|
||||
services
|
||||
.pending_question_replies
|
||||
.insert(
|
||||
target_room.to_string(),
|
||||
q_fwd.request_id.clone(),
|
||||
num_options,
|
||||
multi_select,
|
||||
labels,
|
||||
q_fwd.response_tx,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Spawn a per-request timeout: give up (fail-closed) if the user
|
||||
// does not respond within `question_timeout_secs`.
|
||||
let pending = Arc::clone(&services.pending_question_replies);
|
||||
let timeout_room_key = target_room.to_string();
|
||||
let timeout_request_id = q_fwd.request_id.clone();
|
||||
let timeout_transport = Arc::clone(&transport);
|
||||
let timeout_room_str = target_room_str.clone();
|
||||
let timeout_sent_ids = Arc::clone(&bot_sent_event_ids);
|
||||
let timeout_secs = services.question_timeout_secs;
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(timeout_secs)).await;
|
||||
if let Some(tx) = pending
|
||||
.remove_by_request_id(&timeout_room_key, &timeout_request_id)
|
||||
.await
|
||||
{
|
||||
let _ = tx.send(Err("Question timed out waiting for a reply.".to_string()));
|
||||
let msg = "Question timed out — no answer received.";
|
||||
let html = markdown_to_html(msg);
|
||||
if let Ok(msg_id) = timeout_transport
|
||||
.send_message(&timeout_room_str, msg, &html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse::<OwnedEventId>()
|
||||
{
|
||||
timeout_sent_ids.lock().await.insert(event_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
slog!("[matrix-bot] question listener exiting (channel closed)");
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::context::{QuestionAnswer, QuestionForward, QuestionOption};
|
||||
use async_trait::async_trait;
|
||||
|
||||
struct RecordingTransport {
|
||||
sent: Arc<std::sync::Mutex<Vec<(String, String)>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl crate::chat::ChatTransport for RecordingTransport {
|
||||
async fn send_message(
|
||||
&self,
|
||||
room_id: &str,
|
||||
plain: &str,
|
||||
_html: &str,
|
||||
) -> Result<crate::chat::MessageId, String> {
|
||||
self.sent
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((room_id.to_string(), plain.to_string()));
|
||||
Ok("$test_event_id:example.com".to_string())
|
||||
}
|
||||
|
||||
async fn edit_message(&self, _: &str, _: &str, _: &str, _: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_typing(&self, _: &str, _: bool) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_until(mut cond: impl FnMut() -> bool, timeout: std::time::Duration) -> bool {
|
||||
let start = tokio::time::Instant::now();
|
||||
loop {
|
||||
if cond() {
|
||||
return true;
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
return false;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn test_services() -> Arc<Services> {
|
||||
Arc::new(Services {
|
||||
project_root: std::path::PathBuf::from("/tmp/test"),
|
||||
agents: Arc::new(crate::agents::AgentPool::new_test(3000)),
|
||||
bot_name: "Assistant".to_string(),
|
||||
bot_user_id: "@bot:example.com".to_string(),
|
||||
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
||||
permission_registry: crate::service::permission_router::ResponderRegistry::new(),
|
||||
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
|
||||
permission_timeout_secs: 120,
|
||||
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||
question_registry: crate::service::question_router::QuestionResponderRegistry::new(),
|
||||
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(
|
||||
),
|
||||
question_timeout_secs: 120,
|
||||
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_question(multi_select: bool) -> QuestionSpec {
|
||||
QuestionSpec {
|
||||
header: "Approach".to_string(),
|
||||
question: "Which approach should we use?".to_string(),
|
||||
options: vec![
|
||||
QuestionOption {
|
||||
label: "Fast".to_string(),
|
||||
description: "Ship quickly".to_string(),
|
||||
},
|
||||
QuestionOption {
|
||||
label: "Safe".to_string(),
|
||||
description: "Take more time".to_string(),
|
||||
},
|
||||
],
|
||||
multi_select,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_question_as_text_renders_numbered_options_never_json() {
|
||||
let text = format_question_as_text(&sample_question(false));
|
||||
assert!(text.contains("**Approach**"));
|
||||
assert!(text.contains("Which approach should we use?"));
|
||||
assert!(text.contains("1. Fast — Ship quickly"));
|
||||
assert!(text.contains("2. Safe — Take more time"));
|
||||
assert!(!text.contains('{'), "must never render raw JSON: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_question_as_text_mentions_multi_select_syntax_when_enabled() {
|
||||
let text = format_question_as_text(&sample_question(true));
|
||||
assert!(text.contains("1,3"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listener_forwards_question_to_target_room_and_registers_pending_reply() {
|
||||
let services = test_services();
|
||||
let sent: Arc<std::sync::Mutex<Vec<(String, String)>>> =
|
||||
Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let transport: Arc<dyn crate::chat::ChatTransport> = Arc::new(RecordingTransport {
|
||||
sent: Arc::clone(&sent),
|
||||
});
|
||||
let target_room: OwnedRoomId = "!question:example.com".parse().unwrap();
|
||||
let bot_sent_event_ids = Arc::new(TokioMutex::new(HashSet::new()));
|
||||
|
||||
spawn_question_listener(
|
||||
Arc::clone(&services),
|
||||
Arc::clone(&transport),
|
||||
target_room.clone(),
|
||||
Arc::clone(&bot_sent_event_ids),
|
||||
);
|
||||
|
||||
assert!(
|
||||
wait_until(
|
||||
|| !services.question_registry.is_empty(),
|
||||
std::time::Duration::from_secs(2)
|
||||
)
|
||||
.await,
|
||||
"listener never registered as a responder"
|
||||
);
|
||||
|
||||
let (response_tx, _response_rx) = tokio::sync::oneshot::channel();
|
||||
services.question_registry.dispatch(QuestionForward {
|
||||
request_id: "req-1".to_string(),
|
||||
question: sample_question(false),
|
||||
response_tx,
|
||||
});
|
||||
|
||||
assert!(
|
||||
wait_until(
|
||||
|| !sent.lock().unwrap().is_empty(),
|
||||
std::time::Duration::from_secs(2)
|
||||
)
|
||||
.await,
|
||||
"listener never sent the question prompt"
|
||||
);
|
||||
|
||||
let recorded = sent.lock().unwrap().clone();
|
||||
assert_eq!(recorded[0].0, target_room.as_str());
|
||||
assert!(recorded[0].1.contains("1. Fast — Ship quickly"));
|
||||
|
||||
let mut resolved = None;
|
||||
for _ in 0..50 {
|
||||
resolved = services
|
||||
.pending_question_replies
|
||||
.peek_oldest_meta(target_room.as_str())
|
||||
.await;
|
||||
if resolved.is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
assert_eq!(
|
||||
resolved,
|
||||
Some((2, false, vec!["Fast".to_string(), "Safe".to_string()])),
|
||||
"pending_question_replies missing entry with correct option count"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolved_reply_delivers_answer_through_response_channel() {
|
||||
let services = test_services();
|
||||
let sent: Arc<std::sync::Mutex<Vec<(String, String)>>> =
|
||||
Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let transport: Arc<dyn crate::chat::ChatTransport> = Arc::new(RecordingTransport {
|
||||
sent: Arc::clone(&sent),
|
||||
});
|
||||
let target_room: OwnedRoomId = "!question2:example.com".parse().unwrap();
|
||||
let bot_sent_event_ids = Arc::new(TokioMutex::new(HashSet::new()));
|
||||
|
||||
spawn_question_listener(
|
||||
Arc::clone(&services),
|
||||
Arc::clone(&transport),
|
||||
target_room.clone(),
|
||||
Arc::clone(&bot_sent_event_ids),
|
||||
);
|
||||
assert!(
|
||||
wait_until(
|
||||
|| !services.question_registry.is_empty(),
|
||||
std::time::Duration::from_secs(2)
|
||||
)
|
||||
.await
|
||||
);
|
||||
|
||||
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
|
||||
services.question_registry.dispatch(QuestionForward {
|
||||
request_id: "req-2".to_string(),
|
||||
question: sample_question(false),
|
||||
response_tx,
|
||||
});
|
||||
|
||||
let mut tx = None;
|
||||
for _ in 0..50 {
|
||||
tx = services
|
||||
.pending_question_replies
|
||||
.resolve_oldest(target_room.as_str())
|
||||
.await;
|
||||
if tx.is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
let tx = tx.expect("pending reply must be present");
|
||||
let _ = tx.send(Ok(QuestionAnswer::Selected(vec![1])));
|
||||
assert_eq!(
|
||||
response_rx.await.unwrap().unwrap(),
|
||||
QuestionAnswer::Selected(vec![1])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -306,6 +306,12 @@ pub async fn run_bot(
|
||||
target_room.clone(),
|
||||
Arc::clone(&bot_sent_event_ids),
|
||||
);
|
||||
super::question_listener::spawn_question_listener(
|
||||
Arc::clone(&services),
|
||||
Arc::clone(&transport),
|
||||
target_room.clone(),
|
||||
Arc::clone(&bot_sent_event_ids),
|
||||
);
|
||||
}
|
||||
|
||||
// The forwarder only needs live (future) events — resubscribe is fine.
|
||||
@@ -355,8 +361,23 @@ pub async fn run_bot(
|
||||
let notif_room_id_strings: Vec<String> = notif_room_ids.iter().map(|r| r.to_string()).collect();
|
||||
crate::service::notifications::spawn_notification_listener(
|
||||
Arc::clone(&transport),
|
||||
move || notif_room_id_strings.clone(),
|
||||
{
|
||||
let notif_room_id_strings = notif_room_id_strings.clone();
|
||||
move || notif_room_id_strings.clone()
|
||||
},
|
||||
watcher_rx,
|
||||
notif_project_root.clone(),
|
||||
);
|
||||
|
||||
// Spawn the TransitionFired-driven stage-notification subscriber (story
|
||||
// 995) here too — this was previously only wired for WhatsApp/Slack/Discord
|
||||
// (`startup::bots::spawn_notification_listeners`), leaving Matrix
|
||||
// deployments with no stage-transition chat notifications at all (story
|
||||
// 1235). Spawned once here, before the sync loop below, so the task runs
|
||||
// independently of Matrix sync reconnects/re-logins.
|
||||
crate::service::notifications::spawn_stage_notification_subscriber(
|
||||
Arc::clone(&transport),
|
||||
move || notif_room_id_strings.clone(),
|
||||
notif_project_root,
|
||||
);
|
||||
|
||||
|
||||
@@ -139,11 +139,7 @@ mod tests {
|
||||
let session_id = "sess-compact-1";
|
||||
let project_root = tempfile::tempdir().unwrap();
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
// SAFETY: this test owns HOME for its duration; no other test in this
|
||||
// process reads HOME concurrently with this call.
|
||||
unsafe {
|
||||
std::env::set_var("HOME", home.path());
|
||||
}
|
||||
let _home_guard = crate::chat::compact::transcript::test_home::set(home.path());
|
||||
let transcript_dir = compact::transcript::transcript_path(project_root.path(), session_id)
|
||||
.parent()
|
||||
.unwrap()
|
||||
|
||||
@@ -624,6 +624,11 @@ mod tests {
|
||||
permission_registry: registry,
|
||||
pending_perm_replies: PendingPermReplies::new(),
|
||||
permission_timeout_secs: 120,
|
||||
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||
question_registry: crate::service::question_router::QuestionResponderRegistry::new(),
|
||||
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(
|
||||
),
|
||||
question_timeout_secs: 120,
|
||||
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||
});
|
||||
@@ -655,6 +660,11 @@ mod tests {
|
||||
permission_registry: ResponderRegistry::new(),
|
||||
pending_perm_replies: PendingPermReplies::new(),
|
||||
permission_timeout_secs: 120,
|
||||
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||
question_registry: crate::service::question_router::QuestionResponderRegistry::new(),
|
||||
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(
|
||||
),
|
||||
question_timeout_secs: 120,
|
||||
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||
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.
|
||||
///
|
||||
/// 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> {
|
||||
(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())
|
||||
}
|
||||
|
||||
@@ -1720,6 +1726,21 @@ mod tests {
|
||||
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]
|
||||
fn detect_stack_go_mod() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::service::gateway::io::save_config;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Default seconds to wait for in-flight work to drain before refusing.
|
||||
@@ -101,6 +102,7 @@ pub async fn handle_project_rebuild(
|
||||
drain_timeout_secs: u64,
|
||||
force: bool,
|
||||
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
||||
gateway_port: Option<u16>,
|
||||
config_dir: &Path,
|
||||
) -> String {
|
||||
// ── 1. Validate project ──────────────────────────────────────────────────
|
||||
@@ -140,6 +142,21 @@ pub async fn handle_project_rebuild(
|
||||
);
|
||||
}
|
||||
|
||||
// ── 1b. Capture the running binary's git_hash before we tear it down ──────
|
||||
// Best-effort: an unreachable or pre-version-endpoint sled just yields `None`,
|
||||
// in which case the post-rebuild reconciliation below can't compare old vs
|
||||
// new and falls back to whatever the published fleet artifact says.
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
let old_git_hash = match project_url.as_deref() {
|
||||
Some(url) => super::sled_upgrade::fetch_sled_version(&http_client, url)
|
||||
.await
|
||||
.map(|(_version, hash)| hash),
|
||||
None => None,
|
||||
};
|
||||
|
||||
// ── 2. Drain check ───────────────────────────────────────────────────────
|
||||
let container_name = format!("huskies-{name}");
|
||||
if !force
|
||||
@@ -288,8 +305,8 @@ pub async fn handle_project_rebuild(
|
||||
let container_short: String = container_id.chars().take(12).collect();
|
||||
|
||||
// ── 6. Persist updated config (URL is unchanged; project already registered) ────
|
||||
let container_url = format!("http://127.0.0.1:{port}");
|
||||
{
|
||||
let container_url = format!("http://127.0.0.1:{port}");
|
||||
let mut projects = projects_store.write().await;
|
||||
if let Some(entry) = projects.get_mut(name) {
|
||||
entry.url = Some(container_url.clone());
|
||||
@@ -300,6 +317,20 @@ pub async fn handle_project_rebuild(
|
||||
|
||||
crate::slog!("[project-rebuild] Rebuilt '{name}': image={image_hash} container={container_id}");
|
||||
|
||||
// ── 7. Make sure the rebuilt sled isn't running an older binary than before ──
|
||||
let artifact_source = super::sled_upgrade::resolve_artifact_source(gateway_port);
|
||||
let version_note = reconcile_post_rebuild_version(
|
||||
name,
|
||||
old_git_hash.as_deref(),
|
||||
&container_url,
|
||||
artifact_source,
|
||||
projects_store,
|
||||
config_dir,
|
||||
&http_client,
|
||||
60,
|
||||
)
|
||||
.await;
|
||||
|
||||
format!(
|
||||
"Project **{name}** rebuilt.\n\
|
||||
- New image: `{image}` (`{image_short}…`)\n\
|
||||
@@ -307,10 +338,103 @@ pub async fn handle_project_rebuild(
|
||||
- State: `pipeline.db` and CRDT preserved (same volume bind-mount)\n\
|
||||
- Port: {port} (unchanged)\n\
|
||||
\n\
|
||||
Use `switch {name}` then `status` to verify the pipeline."
|
||||
Use `switch {name}` then `status` to verify the pipeline.{version_note}"
|
||||
)
|
||||
}
|
||||
|
||||
/// Ensure the freshly-rebuilt sled at `container_url` isn't running an older
|
||||
/// binary than the one it had before the rebuild (AC 1–3, story 1231).
|
||||
///
|
||||
/// Base images are only refreshed by a manual `script/build-project-images`
|
||||
/// run, so `project-rebuild` can otherwise silently downgrade a sled that was
|
||||
/// live-upgraded past whatever binary is baked into the image. This waits for
|
||||
/// the new container to come up, compares its `git_hash` against the
|
||||
/// gateway's published fleet artifact, and — when they differ — self-heals by
|
||||
/// running the same in-place upgrade `upgrade <name>` uses. When there's no
|
||||
/// published artifact to converge to, it falls back to comparing against the
|
||||
/// pre-rebuild hash so a real downgrade is at least surfaced as a warning
|
||||
/// instead of passing silently.
|
||||
///
|
||||
/// `artifact_source` is the already-resolved published-artifact lookup (see
|
||||
/// [`super::sled_upgrade::resolve_artifact_source`]) — `Err` means no fleet
|
||||
/// artifact has been published yet.
|
||||
///
|
||||
/// Returns a Markdown snippet (starting with `\n\n`) to append to the rebuild
|
||||
/// reply, or an empty string when the sled already matches the current build.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn reconcile_post_rebuild_version(
|
||||
name: &str,
|
||||
old_git_hash: Option<&str>,
|
||||
container_url: &str,
|
||||
artifact_source: Result<(String, Option<String>), String>,
|
||||
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
||||
config_dir: &Path,
|
||||
client: &reqwest::Client,
|
||||
health_timeout_secs: u64,
|
||||
) -> String {
|
||||
let health_url = format!("{}/health", container_url.trim_end_matches('/'));
|
||||
if !super::sled_upgrade::wait_for_health(client, &health_url, health_timeout_secs).await {
|
||||
return format!(
|
||||
"\n\n⚠️ **Warning:** the rebuilt sled did not respond to `/health` within 60s — \
|
||||
cannot verify its binary version. Check `docker logs huskies-{name}` and run \
|
||||
`upgrade {name}` once it's reachable."
|
||||
);
|
||||
}
|
||||
|
||||
let Some((_new_version, new_hash)) =
|
||||
super::sled_upgrade::fetch_sled_version(client, container_url).await
|
||||
else {
|
||||
return "\n\n⚠️ **Warning:** the rebuilt sled is healthy but `/api/version` is unavailable — \
|
||||
cannot verify it isn't running a stale binary baked into the base image."
|
||||
.to_string();
|
||||
};
|
||||
|
||||
let (source_url, expected_hash) = match artifact_source {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
// No published fleet artifact to converge to — the best we can do is
|
||||
// flag an actual change from what was running before the rebuild.
|
||||
return match old_git_hash {
|
||||
Some(old) if old != new_hash => format!(
|
||||
"\n\n⚠️ **Warning:** the rebuilt sled is now on `{new_hash}` (was `{old}`) and \
|
||||
no fleet artifact is published to verify or fix this. Run `release` then \
|
||||
`upgrade {name}`."
|
||||
),
|
||||
_ => String::new(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if expected_hash.as_deref() == Some(new_hash.as_str()) {
|
||||
return String::new(); // base image already bakes the current fleet build.
|
||||
}
|
||||
|
||||
// The base image baked an older (or merely different) binary than the fleet
|
||||
// is currently on — self-heal the same way `upgrade <name>` would.
|
||||
let outcome = super::sled_upgrade::run_sled_upgrade(
|
||||
name,
|
||||
container_url,
|
||||
&source_url,
|
||||
expected_hash,
|
||||
projects_store,
|
||||
config_dir,
|
||||
|_| async {},
|
||||
)
|
||||
.await;
|
||||
|
||||
if outcome.contains("matches published artifact") || outcome.starts_with("upgraded to v") {
|
||||
format!(
|
||||
"\n\n♻️ The base image baked an older binary (`{new_hash}`); self-healed in place — \
|
||||
{outcome}"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"\n\n⚠️ **Warning:** the base image baked an older binary (`{new_hash}`) and the \
|
||||
automatic self-heal failed: {outcome}\nRun `upgrade {name}` manually."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for active Claude agent processes in the container to exit.
|
||||
///
|
||||
/// Polls every 5 seconds until no `claude` processes remain or `timeout_secs` elapses.
|
||||
@@ -510,7 +634,7 @@ mod tests {
|
||||
async fn rebuild_unknown_project_returns_error() {
|
||||
let store = make_store(vec![]);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = handle_project_rebuild("nonexistent", 0, true, &store, dir.path()).await;
|
||||
let result = handle_project_rebuild("nonexistent", 0, true, &store, None, dir.path()).await;
|
||||
assert!(
|
||||
result.contains("not found"),
|
||||
"expected 'not found': {result}"
|
||||
@@ -530,7 +654,7 @@ mod tests {
|
||||
},
|
||||
)]);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = handle_project_rebuild("myapp", 0, true, &store, dir.path()).await;
|
||||
let result = handle_project_rebuild("myapp", 0, true, &store, None, dir.path()).await;
|
||||
assert!(
|
||||
result.contains("host_path"),
|
||||
"expected 'host_path' mention: {result}"
|
||||
@@ -550,7 +674,7 @@ mod tests {
|
||||
},
|
||||
)]);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = handle_project_rebuild("myapp", 0, true, &store, dir.path()).await;
|
||||
let result = handle_project_rebuild("myapp", 0, true, &store, None, dir.path()).await;
|
||||
assert!(
|
||||
result.contains("does not exist"),
|
||||
"expected 'does not exist': {result}"
|
||||
@@ -586,7 +710,8 @@ mod tests {
|
||||
)]);
|
||||
let config_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let result = handle_project_rebuild("myapp", 0, true, &store, config_dir.path()).await;
|
||||
let result =
|
||||
handle_project_rebuild("myapp", 0, true, &store, None, config_dir.path()).await;
|
||||
|
||||
// (a) Step naming: one of several possible failure steps depending on what Docker
|
||||
// binaries are available in the test environment, or a success reply.
|
||||
@@ -607,4 +732,181 @@ mod tests {
|
||||
"project 'myapp' must remain registered after failed rebuild: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── reconcile_post_rebuild_version (AC 1–3) ────────────────────────────
|
||||
|
||||
/// Spawn a minimal HTTP server that answers every `/health` request with
|
||||
/// 200 and every `/api/version` request with `git_hash`. Serves
|
||||
/// connections in a loop so both `wait_for_health` and
|
||||
/// `fetch_sled_version` can hit it independently within one test.
|
||||
fn spawn_version_server(git_hash: &str) -> (String, tokio::task::JoinHandle<()>) {
|
||||
let git_hash = git_hash.to_string();
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let listener = tokio::net::TcpListener::from_std(listener).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut stream, _)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
let git_hash = git_hash.clone();
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = stream.read(&mut buf).await.unwrap_or(0);
|
||||
let req = String::from_utf8_lossy(&buf[..n]);
|
||||
let path = req.lines().next().unwrap_or("").to_string();
|
||||
let body = if path.contains("/api/version") {
|
||||
serde_json::json!({"version": "0.14.2", "git_hash": git_hash}).to_string()
|
||||
} else {
|
||||
"ok".to_string()
|
||||
};
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let _ = stream.write_all(resp.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
(format!("http://127.0.0.1:{port}"), handle)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_empty_when_new_hash_matches_published_artifact() {
|
||||
let (url, srv) = spawn_version_server("current-hash");
|
||||
let store = make_store(vec![]);
|
||||
let config_dir = tempfile::tempdir().unwrap();
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let note = reconcile_post_rebuild_version(
|
||||
"myapp",
|
||||
Some("old-hash"),
|
||||
&url,
|
||||
Ok((
|
||||
"http://unused".to_string(),
|
||||
Some("current-hash".to_string()),
|
||||
)),
|
||||
&store,
|
||||
config_dir.path(),
|
||||
&client,
|
||||
2,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(note, "", "already-current build must produce no note");
|
||||
srv.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_empty_when_no_artifact_and_hash_unchanged() {
|
||||
let (url, srv) = spawn_version_server("same-hash");
|
||||
let store = make_store(vec![]);
|
||||
let config_dir = tempfile::tempdir().unwrap();
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let note = reconcile_post_rebuild_version(
|
||||
"myapp",
|
||||
Some("same-hash"),
|
||||
&url,
|
||||
Err("no published artifact".to_string()),
|
||||
&store,
|
||||
config_dir.path(),
|
||||
&client,
|
||||
2,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(note, "", "unchanged hash must produce no note");
|
||||
srv.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_warns_when_no_artifact_and_hash_changed() {
|
||||
let (url, srv) = spawn_version_server("stale-baked-hash");
|
||||
let store = make_store(vec![]);
|
||||
let config_dir = tempfile::tempdir().unwrap();
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let note = reconcile_post_rebuild_version(
|
||||
"myapp",
|
||||
Some("newer-hash-that-was-running"),
|
||||
&url,
|
||||
Err("no published artifact".to_string()),
|
||||
&store,
|
||||
config_dir.path(),
|
||||
&client,
|
||||
2,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(note.contains("Warning"), "expected a warning: {note}");
|
||||
assert!(
|
||||
note.contains("stale-baked-hash") && note.contains("newer-hash-that-was-running"),
|
||||
"warning should name both hashes: {note}"
|
||||
);
|
||||
srv.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_warns_when_health_probe_fails() {
|
||||
let store = make_store(vec![]);
|
||||
let config_dir = tempfile::tempdir().unwrap();
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_millis(200))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let note = reconcile_post_rebuild_version(
|
||||
"myapp",
|
||||
Some("old-hash"),
|
||||
"http://127.0.0.1:1", // nothing listens on port 1
|
||||
Err("no published artifact".to_string()),
|
||||
&store,
|
||||
config_dir.path(),
|
||||
&client,
|
||||
2,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
note.contains("did not respond to `/health`"),
|
||||
"expected a health-probe warning: {note}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_attempts_self_heal_and_reports_failure_when_artifact_unreachable() {
|
||||
let (url, srv) = spawn_version_server("stale-baked-hash");
|
||||
let store = make_store(vec![]);
|
||||
let config_dir = tempfile::tempdir().unwrap();
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let note = reconcile_post_rebuild_version(
|
||||
"myapp",
|
||||
Some("stale-baked-hash"),
|
||||
&url,
|
||||
Ok((
|
||||
"http://127.0.0.1:1/api/artifacts/huskies-linux-arm64".to_string(),
|
||||
Some("current-fleet-hash".to_string()),
|
||||
)),
|
||||
&store,
|
||||
config_dir.path(),
|
||||
&client,
|
||||
2,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
note.contains("self-heal failed"),
|
||||
"mismatch should trigger a self-heal attempt that reports failure: {note}"
|
||||
);
|
||||
assert!(
|
||||
note.contains("stale-baked-hash"),
|
||||
"note should name the stale hash: {note}"
|
||||
);
|
||||
srv.abort();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,9 @@ pub async fn handle_upgrade_list_projects(
|
||||
///
|
||||
/// Returns `Err` with a user-facing message when no artifact has been
|
||||
/// published yet.
|
||||
fn resolve_artifact_source(gateway_port: Option<u16>) -> Result<(String, Option<String>), String> {
|
||||
pub(crate) fn resolve_artifact_source(
|
||||
gateway_port: Option<u16>,
|
||||
) -> Result<(String, Option<String>), String> {
|
||||
if let Ok(url) = std::env::var("HUSKIES_GATEWAY_BINARY_URL") {
|
||||
return Ok((url, None));
|
||||
}
|
||||
@@ -311,7 +313,7 @@ where
|
||||
/// resolved. Split from [`handle_sled_upgrade`] so tests can drive the wire
|
||||
/// behaviour without a published artifact on the host.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_sled_upgrade<F, Fut>(
|
||||
pub(crate) async fn run_sled_upgrade<F, Fut>(
|
||||
project: &str,
|
||||
sled_url: &str,
|
||||
source_url: &str,
|
||||
@@ -427,7 +429,11 @@ where
|
||||
/// Poll `GET {health_url}` every 3 seconds until it returns 200 or `timeout_secs` elapses.
|
||||
///
|
||||
/// Returns `true` when the probe succeeds, `false` on timeout.
|
||||
async fn wait_for_health(client: &reqwest::Client, health_url: &str, timeout_secs: u64) -> bool {
|
||||
pub(crate) async fn wait_for_health(
|
||||
client: &reqwest::Client,
|
||||
health_url: &str,
|
||||
timeout_secs: u64,
|
||||
) -> bool {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs);
|
||||
let poll = Duration::from_secs(3);
|
||||
loop {
|
||||
@@ -446,7 +452,10 @@ async fn wait_for_health(client: &reqwest::Client, health_url: &str, timeout_sec
|
||||
///
|
||||
/// Returns `None` when the endpoint is unreachable or malformed — e.g. a sled
|
||||
/// still running a binary that predates the endpoint.
|
||||
async fn fetch_sled_version(client: &reqwest::Client, sled_url: &str) -> Option<(String, String)> {
|
||||
pub(crate) async fn fetch_sled_version(
|
||||
client: &reqwest::Client,
|
||||
sled_url: &str,
|
||||
) -> Option<(String, String)> {
|
||||
let url = format!("{}/api/version", sled_url.trim_end_matches('/'));
|
||||
let val: serde_json::Value = client.get(&url).send().await.ok()?.json().await.ok()?;
|
||||
let version = val.get("version").and_then(|v| v.as_str())?.to_string();
|
||||
|
||||
@@ -314,6 +314,11 @@ mod tests {
|
||||
permission_registry: crate::service::permission_router::ResponderRegistry::new(),
|
||||
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
|
||||
permission_timeout_secs: 120,
|
||||
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||
question_registry: crate::service::question_router::QuestionResponderRegistry::new(),
|
||||
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(
|
||||
),
|
||||
question_timeout_secs: 120,
|
||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||
});
|
||||
Arc::new(WhatsAppWebhookContext {
|
||||
|
||||
@@ -3,6 +3,23 @@
|
||||
//! These functions are transport-agnostic helpers for processing chat messages:
|
||||
//! prefix stripping, bot-mention handling, and paragraph buffering.
|
||||
|
||||
/// Instruction prepended to chat-bot prompts telling the model how to ask a
|
||||
/// clarifying multiple-choice question over a chat protocol that has no
|
||||
/// interactive question UI (story 1228).
|
||||
///
|
||||
/// Claude Code's built-in `AskUserQuestion` tool requires an interactive
|
||||
/// terminal/IDE to render and answer; in the headless `claude -p` sessions
|
||||
/// this server spawns for chat transports it either isn't offered to the
|
||||
/// model at all, or (if forced into scope) degrades into the model emitting
|
||||
/// its attempted call as raw tool-call syntax in plain text. Disallowing it
|
||||
/// (`--disallowedTools AskUserQuestion` on the CLI invocation) closes that
|
||||
/// leak; this instruction points the model at the huskies-owned MCP
|
||||
/// `ask_question` tool as the replacement, which renders the numbered
|
||||
/// options in chat, blocks until a reply resolves it, and returns the
|
||||
/// answer directly to this tool call — the model does not need to format
|
||||
/// the question itself or wait for a future turn to see the reply.
|
||||
pub const QUESTION_FORMAT_INSTRUCTION: &str = "This chat has no interactive question UI, and the built-in AskUserQuestion tool is unavailable here. When you need to ask the user a clarifying multiple-choice question, call the `ask_question` MCP tool instead — never emit JSON or tool-call syntax as plain text. Pass a `question`, an optional `header`, and at least two `options` (each with a `label` and `description`); set `multi_select: true` if the user may choose more than one. The tool renders the options as a numbered list in chat and blocks until the user replies, then returns the selected option(s) or free-text answer for you to continue with.";
|
||||
|
||||
/// Truncate `s` to at most `max_bytes` bytes without splitting a UTF-8 codepoint.
|
||||
///
|
||||
/// If `s.len() <= max_bytes` the original slice is returned unchanged.
|
||||
@@ -318,6 +335,29 @@ mod tests {
|
||||
assert_eq!(truncate_at_char_boundary("hi", 100), "hi");
|
||||
}
|
||||
|
||||
// -- QUESTION_FORMAT_INSTRUCTION (story 1228) ---------------------------
|
||||
|
||||
#[test]
|
||||
fn question_format_instruction_directs_model_to_ask_question_tool() {
|
||||
assert!(QUESTION_FORMAT_INSTRUCTION.contains("ask_question"));
|
||||
assert!(QUESTION_FORMAT_INSTRUCTION.contains("AskUserQuestion"));
|
||||
assert!(QUESTION_FORMAT_INSTRUCTION.contains("never emit JSON or tool-call syntax"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_format_instruction_describes_required_arguments() {
|
||||
assert!(QUESTION_FORMAT_INSTRUCTION.contains("question"));
|
||||
assert!(QUESTION_FORMAT_INSTRUCTION.contains("options"));
|
||||
assert!(QUESTION_FORMAT_INSTRUCTION.contains("label"));
|
||||
assert!(QUESTION_FORMAT_INSTRUCTION.contains("multi_select"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_format_instruction_explains_blocking_reply_behavior() {
|
||||
assert!(QUESTION_FORMAT_INSTRUCTION.contains("blocks until the user replies"));
|
||||
assert!(QUESTION_FORMAT_INSTRUCTION.contains("free-text"));
|
||||
}
|
||||
|
||||
// -- is_permission_approval ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Rotated log sink for the chat bot's PTY output.
|
||||
//!
|
||||
//! The chat bot runs Claude Code CLI in a PTY (see
|
||||
//! [`crate::llm::providers::claude_code`]) and previously logged every raw
|
||||
//! PTY line — spawn commands, reader-thread lifecycle, and truncated
|
||||
//! passthrough of each NDJSON line — via [`crate::slog!`], which meant this
|
||||
//! high-volume, low-signal output shared the bounded operational ring buffer
|
||||
//! and `server.log` with everything else, displacing genuinely operational
|
||||
//! lines. This sink gives that PTY output its own daily-rotated file
|
||||
//! (`chatbot-YYYY-MM-DD.log`) instead.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// Number of daily log files to keep on disk before pruning older ones.
|
||||
const KEEP_DAYS: u64 = 7;
|
||||
|
||||
/// Internal state for the on-disk log: directory and last-written date.
|
||||
struct ChatBotLogState {
|
||||
dir: Option<PathBuf>,
|
||||
/// `YYYY-MM-DD` of the last written entry — used to detect day rollover.
|
||||
last_date: String,
|
||||
}
|
||||
|
||||
/// Daily-rotated on-disk sink for the chat bot's PTY output.
|
||||
pub struct ChatBotLog {
|
||||
state: Mutex<ChatBotLogState>,
|
||||
}
|
||||
|
||||
impl ChatBotLog {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
state: Mutex::new(ChatBotLogState {
|
||||
dir: None,
|
||||
last_date: String::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the directory for daily-rotated chat bot log files.
|
||||
///
|
||||
/// Files are written as `chatbot-YYYY-MM-DD.log` inside `dir`. Files
|
||||
/// older than [`KEEP_DAYS`] are pruned immediately and again on each day
|
||||
/// rollover. Call once at startup after the project root is known.
|
||||
pub fn set_log_dir(&self, dir: PathBuf) {
|
||||
prune_old_logs(&dir, KEEP_DAYS);
|
||||
if let Ok(mut state) = self.state.lock() {
|
||||
state.dir = Some(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a line to today's chat bot log file, prefixed with an ISO 8601
|
||||
/// UTC timestamp. No-ops silently until [`set_log_dir`] has been called.
|
||||
pub fn push_line(&self, message: &str) {
|
||||
let (log_path, prune_dir) = match self.state.lock() {
|
||||
Ok(mut state) => {
|
||||
if let Some(dir) = state.dir.clone() {
|
||||
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||
let path = dir.join(format!("chatbot-{today}.log"));
|
||||
let maybe_prune = if state.last_date != today {
|
||||
state.last_date = today;
|
||||
Some(dir)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(Some(path), maybe_prune)
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
}
|
||||
Err(_) => (None, None),
|
||||
};
|
||||
|
||||
if let Some(ref dir) = prune_dir {
|
||||
prune_old_logs(dir, KEEP_DAYS);
|
||||
}
|
||||
|
||||
if let Some(ref path) = log_path {
|
||||
let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(file, "{timestamp} [pty-debug] {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static GLOBAL: OnceLock<ChatBotLog> = OnceLock::new();
|
||||
|
||||
/// Access the process-wide chat bot PTY log sink.
|
||||
pub fn global() -> &'static ChatBotLog {
|
||||
GLOBAL.get_or_init(ChatBotLog::new)
|
||||
}
|
||||
|
||||
/// Delete daily `chatbot-*.log` files older than `keep_days` from `dir`.
|
||||
fn prune_old_logs(dir: &Path, keep_days: u64) {
|
||||
let cutoff = chrono::Utc::now()
|
||||
.checked_sub_signed(chrono::Duration::days(keep_days as i64))
|
||||
.map(|t| t.format("%Y-%m-%d").to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
// Match "chatbot-YYYY-MM-DD.log"
|
||||
if name.starts_with("chatbot-") && name.ends_with(".log") && name.len() == 22 {
|
||||
// SAFETY: "chatbot-" is 8 ASCII bytes, ".log" is 4, total 22 chars
|
||||
// means the middle 10 bytes are the date "YYYY-MM-DD" — all
|
||||
// ASCII, safe to slice.
|
||||
if let Some(date_part) = name.get(8..18)
|
||||
&& date_part < cutoff.as_str()
|
||||
{
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fresh_sink() -> ChatBotLog {
|
||||
ChatBotLog::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_line_before_set_log_dir_is_a_noop() {
|
||||
let sink = fresh_sink();
|
||||
// Must not panic when no directory has been configured yet.
|
||||
sink.push_line("hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_line_writes_to_rotated_file() {
|
||||
let tmp = std::env::temp_dir().join(format!(
|
||||
"huskies_chatbot_log_test_{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
|
||||
let sink = fresh_sink();
|
||||
sink.set_log_dir(tmp.clone());
|
||||
sink.push_line("raw line: {\"type\":\"assistant\"}");
|
||||
|
||||
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||
let path = tmp.join(format!("chatbot-{today}.log"));
|
||||
let contents = std::fs::read_to_string(&path).unwrap();
|
||||
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
|
||||
assert!(contents.contains("[pty-debug]"));
|
||||
assert!(contents.contains("raw line: {\"type\":\"assistant\"}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_write_into_shared_ring_buffer() {
|
||||
let tmp = std::env::temp_dir().join(format!(
|
||||
"huskies_chatbot_log_isolation_test_{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
|
||||
let sink = fresh_sink();
|
||||
sink.set_log_dir(tmp.clone());
|
||||
let marker = "chatbot_isolation_marker_9f31a";
|
||||
sink.push_line(marker);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
|
||||
let ring_hits = crate::log_buffer::global().get_recent(1000, Some(marker), None);
|
||||
assert!(
|
||||
ring_hits.is_empty(),
|
||||
"chat bot PTY output must not land in the shared server log ring buffer"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Read/write helpers for the `history_log` append-only list in the CRDT document.
|
||||
//!
|
||||
//! Every chat turn, agent run, and pipeline transition is appended as a
|
||||
//! [`HistoryEntryCrdt`][super::super::types::HistoryEntryCrdt] entry, scoped
|
||||
//! to a subject (story, sled, or project). Entries are never updated or
|
||||
//! tombstoned — the list is strictly grow-only, mirroring `event_log.rs`.
|
||||
|
||||
use bft_json_crdt::json_crdt::{JsonValue, *};
|
||||
use bft_json_crdt::op::ROOT_ID;
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::state::{apply_and_persist, get_crdt};
|
||||
use super::super::types::HistoryEntryCrdt;
|
||||
|
||||
/// Raw history entry extracted from the CRDT document.
|
||||
pub struct HistoryEntryRaw {
|
||||
/// Monotonic sequence number for the recording sled (0-based).
|
||||
pub event_seq: u64,
|
||||
/// Hex-encoded Ed25519 public key of the sled that wrote this entry.
|
||||
pub sled_id: String,
|
||||
/// Unix timestamp (seconds) when the entry was recorded.
|
||||
pub timestamp: f64,
|
||||
/// Subject kind: `"story"`, `"sled"`, or `"project"`.
|
||||
pub subject_type: String,
|
||||
/// Subject identifier.
|
||||
pub subject_id: String,
|
||||
/// Entry kind: `"pipeline_transition"`, `"chat_turn"`, or `"agent_run"`.
|
||||
pub kind: String,
|
||||
/// Short human-readable summary.
|
||||
pub summary: String,
|
||||
/// JSON-encoded full payload.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// Append a new history entry to the CRDT, computing the monotonic `event_seq`
|
||||
/// atomically while the CRDT lock is held. No-ops silently when the CRDT is
|
||||
/// not yet initialised.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn append_history_entry(
|
||||
sled_id: &str,
|
||||
timestamp: f64,
|
||||
subject_type: &str,
|
||||
subject_id: &str,
|
||||
kind: &str,
|
||||
summary: &str,
|
||||
detail: &str,
|
||||
) {
|
||||
let Some(state_mutex) = get_crdt() else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut state) = state_mutex.lock() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Count existing entries for this sled while holding the lock so the seq
|
||||
// is computed and used in the same critical section — no TOCTOU gap.
|
||||
let event_seq = state
|
||||
.crdt
|
||||
.doc
|
||||
.history_log
|
||||
.iter()
|
||||
.filter(|e| matches!(e.sled_id.view(), JsonValue::String(s) if s == sled_id))
|
||||
.count() as f64;
|
||||
|
||||
// Append after the last existing entry so the list stays in insertion order.
|
||||
let total_len = state.crdt.doc.history_log.view().len();
|
||||
let after = if total_len > 0 {
|
||||
super::list_id_at(&state.crdt.doc.history_log, total_len - 1).unwrap_or(ROOT_ID)
|
||||
} else {
|
||||
ROOT_ID
|
||||
};
|
||||
|
||||
let entry: JsonValue = json!({
|
||||
"event_seq": event_seq,
|
||||
"sled_id": sled_id,
|
||||
"timestamp": timestamp,
|
||||
"subject_type": subject_type,
|
||||
"subject_id": subject_id,
|
||||
"kind": kind,
|
||||
"summary": summary,
|
||||
"detail": detail,
|
||||
})
|
||||
.into();
|
||||
|
||||
apply_and_persist(&mut state, |s| s.crdt.doc.history_log.insert(after, entry));
|
||||
}
|
||||
|
||||
/// Read all history entries from the CRDT document.
|
||||
///
|
||||
/// Entries with a missing or empty `sled_id` are silently skipped. Order
|
||||
/// reflects CRDT insertion order (RGA list semantics) — callers that need a
|
||||
/// deterministic global order should sort by `(timestamp, sled_id, event_seq)`.
|
||||
pub fn read_all_history_entries() -> Vec<HistoryEntryRaw> {
|
||||
let Some(state_mutex) = get_crdt() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(state) = state_mutex.lock() else {
|
||||
return Vec::new();
|
||||
};
|
||||
state
|
||||
.crdt
|
||||
.doc
|
||||
.history_log
|
||||
.iter()
|
||||
.filter_map(extract_entry)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Convert a CRDT history entry to its read-side representation.
|
||||
fn extract_entry(e: &HistoryEntryCrdt) -> Option<HistoryEntryRaw> {
|
||||
let event_seq = match e.event_seq.view() {
|
||||
JsonValue::Number(n) => n as u64,
|
||||
_ => return None,
|
||||
};
|
||||
let sled_id = match e.sled_id.view() {
|
||||
JsonValue::String(s) if !s.is_empty() => s,
|
||||
_ => return None,
|
||||
};
|
||||
let timestamp = match e.timestamp.view() {
|
||||
JsonValue::Number(n) => n,
|
||||
_ => 0.0,
|
||||
};
|
||||
let subject_type = match e.subject_type.view() {
|
||||
JsonValue::String(s) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
let subject_id = match e.subject_id.view() {
|
||||
JsonValue::String(s) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
let kind = match e.kind.view() {
|
||||
JsonValue::String(s) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
let summary = match e.summary.view() {
|
||||
JsonValue::String(s) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
let detail = match e.detail.view() {
|
||||
JsonValue::String(s) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
Some(HistoryEntryRaw {
|
||||
event_seq,
|
||||
sled_id,
|
||||
timestamp,
|
||||
subject_type,
|
||||
subject_id,
|
||||
kind,
|
||||
summary,
|
||||
detail,
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod active_agents;
|
||||
mod agent_throttle;
|
||||
mod event_log;
|
||||
mod gateway_projects;
|
||||
mod history_log;
|
||||
mod llm_sessions;
|
||||
mod merge_jobs;
|
||||
mod test_jobs;
|
||||
@@ -37,6 +38,7 @@ pub use event_log::{
|
||||
pub use gateway_projects::{
|
||||
delete_gateway_project, read_all_gateway_projects, read_gateway_project, write_gateway_project,
|
||||
};
|
||||
pub use history_log::{HistoryEntryRaw, append_history_entry, read_all_history_entries};
|
||||
pub use llm_sessions::{assemble_and_advance_session, read_llm_session, write_llm_session};
|
||||
pub use merge_jobs::{delete_merge_job, read_all_merge_jobs, read_merge_job, write_merge_job};
|
||||
pub use test_jobs::{delete_test_job, read_all_test_jobs, read_test_job, write_test_job};
|
||||
|
||||
@@ -28,14 +28,15 @@ mod write;
|
||||
|
||||
pub use gateway_config::{read_gateway_active_project, write_gateway_active_project};
|
||||
pub use lww_maps::{
|
||||
EventLogEntryRaw, GAP_PIPELINE_EVENT, append_event_log_entry, append_gap_log_entry,
|
||||
assemble_and_advance_session, delete_active_agent, delete_agent_throttle,
|
||||
delete_gateway_project, delete_merge_job, delete_test_job, delete_token_usage,
|
||||
read_active_agent, read_agent_throttle, read_all_active_agents, read_all_agent_throttles,
|
||||
read_all_event_log_entries, read_all_gateway_projects, read_all_merge_jobs, read_all_test_jobs,
|
||||
read_all_token_usage, read_gateway_project, read_llm_session, read_merge_job, read_test_job,
|
||||
read_token_usage, write_active_agent, write_agent_throttle, write_gateway_project,
|
||||
write_llm_session, write_merge_job, write_test_job, write_token_usage,
|
||||
EventLogEntryRaw, GAP_PIPELINE_EVENT, HistoryEntryRaw, append_event_log_entry,
|
||||
append_gap_log_entry, append_history_entry, assemble_and_advance_session, delete_active_agent,
|
||||
delete_agent_throttle, delete_gateway_project, delete_merge_job, delete_test_job,
|
||||
delete_token_usage, read_active_agent, read_agent_throttle, read_all_active_agents,
|
||||
read_all_agent_throttles, read_all_event_log_entries, read_all_gateway_projects,
|
||||
read_all_history_entries, read_all_merge_jobs, read_all_test_jobs, read_all_token_usage,
|
||||
read_gateway_project, read_llm_session, read_merge_job, read_test_job, read_token_usage,
|
||||
write_active_agent, write_agent_throttle, write_gateway_project, write_llm_session,
|
||||
write_merge_job, write_test_job, write_token_usage,
|
||||
};
|
||||
pub use ops::{all_ops_json, apply_remote_op, ops_since, our_vector_clock, subscribe_ops};
|
||||
pub use presence::{
|
||||
|
||||
@@ -51,6 +51,9 @@ pub struct PipelineDoc {
|
||||
pub event_log: ListCrdt<EventLogEntryCrdt>,
|
||||
/// Per-session LLM context state (high-water marks for event log injection).
|
||||
pub llm_sessions: ListCrdt<LlmSessionCrdt>,
|
||||
/// Append-only, subject-scoped log of chat turns, agent runs, and
|
||||
/// pipeline transitions, persisted as CRDT ops (story 1236).
|
||||
pub history_log: ListCrdt<HistoryEntryCrdt>,
|
||||
}
|
||||
|
||||
/// CRDT entry representing a single persisted pipeline stage-transition event.
|
||||
@@ -79,6 +82,34 @@ pub struct EventLogEntryCrdt {
|
||||
pub pipeline_event: LwwRegisterCrdt<String>,
|
||||
}
|
||||
|
||||
/// CRDT entry representing a single persisted history entry — a chat turn,
|
||||
/// agent run, or pipeline transition — scoped to a subject (story, sled, or
|
||||
/// project) for the `get_history` / `get_history_entry` MCP tools (story 1236).
|
||||
///
|
||||
/// Entries are append-only, mirroring [`EventLogEntryCrdt`]'s per-sled
|
||||
/// monotonic `event_seq` scheme so `"{sled_id}:{event_seq}"` is a stable,
|
||||
/// re-readable pagination cursor and payload ref.
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug, Serialize, Deserialize)]
|
||||
pub struct HistoryEntryCrdt {
|
||||
/// Monotonic sequence number for this sled (0, 1, 2, …).
|
||||
pub event_seq: LwwRegisterCrdt<f64>,
|
||||
/// Hex-encoded Ed25519 public key of the sled that recorded this entry.
|
||||
pub sled_id: LwwRegisterCrdt<String>,
|
||||
/// Unix timestamp (seconds) when the entry was recorded.
|
||||
pub timestamp: LwwRegisterCrdt<f64>,
|
||||
/// Subject kind: `"story"`, `"sled"`, or `"project"`.
|
||||
pub subject_type: LwwRegisterCrdt<String>,
|
||||
/// Subject identifier (story ID, sled hex ID, or project name/persona).
|
||||
pub subject_id: LwwRegisterCrdt<String>,
|
||||
/// Entry kind: `"pipeline_transition"`, `"chat_turn"`, or `"agent_run"`.
|
||||
pub kind: LwwRegisterCrdt<String>,
|
||||
/// Short human-readable summary shown in a paged listing.
|
||||
pub summary: LwwRegisterCrdt<String>,
|
||||
/// JSON-encoded full payload returned by `get_history_entry`.
|
||||
pub detail: LwwRegisterCrdt<String>,
|
||||
}
|
||||
|
||||
/// CRDT entry tracking an LLM session's event-log injection state.
|
||||
///
|
||||
/// Each session (keyed by `session_id`, typically a Matrix room ID) records the
|
||||
|
||||
+309
-34
@@ -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,
|
||||
//! Superseded, Rejected) every `ContentKey::*` entry for that story is purged
|
||||
//! from the in-memory content store. There are two purge paths:
|
||||
//! Two purge paths clear the *ephemeral* `ContentKey::*` working-state keys
|
||||
//! (gate output, respawn counters, merge-fixup flags, etc.) when a pipeline
|
||||
//! item reaches a terminal stage (Done, Archived, Abandoned, Superseded,
|
||||
//! Rejected):
|
||||
//!
|
||||
//! 1. **Subscriber** ([`spawn_content_gc_subscriber`]) — reacts to
|
||||
//! [`crate::pipeline_state::TransitionFired`] events and runs for new
|
||||
@@ -10,19 +12,35 @@
|
||||
//!
|
||||
//! 2. **Startup sweep** ([`sweep_zombie_content_on_startup`]) — cleans up
|
||||
//! 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::slog;
|
||||
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
|
||||
/// keys are no-ops. Call this when a work item reaches a terminal stage to
|
||||
/// prevent long-lived zombie entries from accumulating in the process heap.
|
||||
pub(crate) fn purge_content_keys_for_story(story_id: &str) {
|
||||
delete_content(ContentKey::Story(story_id));
|
||||
/// Call this when a work item reaches a terminal stage: agent working-state
|
||||
/// (gate output, respawn counters, merge-fixup flags) is no longer needed,
|
||||
/// but the story's markdown body must be retained indefinitely so `show()`
|
||||
/// keeps working (story 1222). Deletes for absent keys are no-ops.
|
||||
pub(crate) fn purge_ephemeral_content_keys_for_story(story_id: &str) {
|
||||
delete_content(ContentKey::GateOutput(story_id));
|
||||
delete_content(ContentKey::AbortRespawnCount(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));
|
||||
}
|
||||
|
||||
/// 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`,
|
||||
/// `Archived`, `Abandoned`, `Superseded`, or `Rejected`, all `ContentKey::*`
|
||||
/// entries for that story are purged. Lag events are logged as warnings —
|
||||
/// a missed event leaves zombie entries that the next startup sweep will remove.
|
||||
/// `Archived`, `Abandoned`, `Superseded`, or `Rejected`, the eight ephemeral
|
||||
/// `ContentKey::*` entries for that story are purged — the markdown body
|
||||
/// (`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() {
|
||||
let mut rx = crate::pipeline_state::subscribe_transitions();
|
||||
tokio::spawn(async move {
|
||||
@@ -50,9 +83,9 @@ pub(crate) fn spawn_content_gc_subscriber() {
|
||||
let story_id = &fired.story_id.0;
|
||||
slog!(
|
||||
"[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)) => {
|
||||
@@ -70,7 +103,12 @@ pub(crate) fn spawn_content_gc_subscriber() {
|
||||
/// One-shot startup sweep: purge content-store entries for stories that have
|
||||
/// 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.
|
||||
pub(crate) fn sweep_zombie_content_on_startup() {
|
||||
let raw_keys = all_content_ids();
|
||||
@@ -92,25 +130,94 @@ pub(crate) fn sweep_zombie_content_on_startup() {
|
||||
|
||||
let mut swept = 0usize;
|
||||
for story_id in &story_ids {
|
||||
let should_purge = match crate::crdt_state::read_item(story_id) {
|
||||
// Tombstoned or absent from the live CRDT index — purge.
|
||||
None => true,
|
||||
Some(item) => is_terminal_stage(item.stage()),
|
||||
};
|
||||
if should_purge {
|
||||
purge_content_keys_for_story(story_id);
|
||||
swept += 1;
|
||||
match crate::crdt_state::read_item(story_id) {
|
||||
// Tombstoned or absent from the live CRDT index — genuinely
|
||||
// gone, safe to purge everything including the story body.
|
||||
None => {
|
||||
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 {
|
||||
slog!(
|
||||
"[content-gc] Startup sweep purged content-store entries for \
|
||||
"[content-gc] Startup sweep purged ephemeral content-store entries for \
|
||||
{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.
|
||||
///
|
||||
/// 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.
|
||||
#[test]
|
||||
fn purge_clears_all_eight_content_key_namespaces() {
|
||||
@@ -216,10 +361,11 @@ mod tests {
|
||||
assert_all_keys_absent(id);
|
||||
}
|
||||
|
||||
/// AC1 + AC4: the GC subscriber reacts to an Abandoned terminal transition and
|
||||
/// purges all content-store entries for the story.
|
||||
/// Story 1222, AC1: the GC subscriber reacts to an Abandoned terminal
|
||||
/// transition and purges ephemeral content-store entries for the story,
|
||||
/// but RETAINS the story body so `show()` keeps working.
|
||||
#[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();
|
||||
ensure_content_store();
|
||||
|
||||
@@ -242,7 +388,7 @@ mod tests {
|
||||
// Give the subscriber task time to run.
|
||||
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
|
||||
@@ -272,8 +418,9 @@ mod tests {
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
|
||||
// Terminal story's content must be gone.
|
||||
assert_all_keys_absent(terminal_id);
|
||||
// Terminal story's ephemeral keys must be gone, but body retained
|
||||
// (story 1222).
|
||||
assert_ephemeral_keys_absent_but_story_present(terminal_id);
|
||||
|
||||
// Active story's main content key must still be present.
|
||||
assert!(
|
||||
@@ -309,6 +456,49 @@ mod tests {
|
||||
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.
|
||||
#[test]
|
||||
fn startup_sweep_preserves_active_story_content() {
|
||||
@@ -342,4 +532,89 @@ mod tests {
|
||||
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
@@ -37,8 +37,10 @@ pub use shadow_write::{check_schema_drift, get_shared_pool, init};
|
||||
#[cfg(test)]
|
||||
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)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
@@ -597,10 +599,16 @@ mod tests {
|
||||
/// 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`.
|
||||
#[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)]
|
||||
async fn ensure_shadow_db() {
|
||||
pub(crate) async fn ensure_shadow_db() {
|
||||
static INIT: std::sync::OnceLock<()> = std::sync::OnceLock::new();
|
||||
if INIT.get().is_some() {
|
||||
return;
|
||||
|
||||
+202
-3
@@ -848,6 +848,196 @@ async fn gateway_mcp_sse_proxy_streams_progress_and_final_response() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── SSE path honors explicit `project` and the create-guard (story 1229) ──
|
||||
//
|
||||
// Story 1225 added explicit-project routing and the create-without-project
|
||||
// guard, but only on the buffered `tools/call` path — the SSE branch above
|
||||
// (Accept: text/event-stream + `_meta.progressToken`) still always proxied
|
||||
// to `state.active_url()` and never ran the guard. These tests exercise the
|
||||
// SSE branch specifically so a regression here fails a test, unlike 1225's
|
||||
// tests at `create_tool_with_explicit_project_lands_in_non_active_project` /
|
||||
// `read_tool_with_explicit_project_reads_from_non_active_project` in
|
||||
// `http/gateway/mcp.rs`, which bypass SSE entirely.
|
||||
|
||||
fn sse_body_for(id: i64, text: &str) -> String {
|
||||
let final_resp = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": { "content": [{ "type": "text", "text": text }] }
|
||||
});
|
||||
format!("data: {final_resp}\n\n")
|
||||
}
|
||||
|
||||
async fn make_two_http_project_state() -> (
|
||||
Arc<GatewayState>,
|
||||
mockito::ServerGuard,
|
||||
mockito::ServerGuard,
|
||||
) {
|
||||
let mut mock_alpha = mockito::Server::new_async().await;
|
||||
let mut mock_beta = mockito::Server::new_async().await;
|
||||
mock_alpha
|
||||
.mock("POST", "/mcp")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "text/event-stream")
|
||||
.with_body(sse_body_for(1, "handled_by:alpha"))
|
||||
.create_async()
|
||||
.await;
|
||||
mock_beta
|
||||
.mock("POST", "/mcp")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "text/event-stream")
|
||||
.with_body(sse_body_for(1, "handled_by:beta"))
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert(
|
||||
"alpha".to_string(),
|
||||
ProjectEntry::with_url(mock_alpha.url()),
|
||||
);
|
||||
projects.insert("beta".to_string(), ProjectEntry::with_url(mock_beta.url()));
|
||||
let config = GatewayConfig {
|
||||
projects,
|
||||
sled_tokens: BTreeMap::new(),
|
||||
release_channels: BTreeMap::new(),
|
||||
};
|
||||
let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap());
|
||||
assert_eq!(*state.active_project.read().await, "alpha");
|
||||
(state, mock_alpha, mock_beta)
|
||||
}
|
||||
|
||||
fn sse_data_lines(body: &str) -> Vec<&str> {
|
||||
body.lines()
|
||||
.filter_map(|l| l.strip_prefix("data: "))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_create_tool_with_explicit_project_lands_in_non_active_project() {
|
||||
let (state, _mock_alpha, _mock_beta) = make_two_http_project_state().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(&serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "create_bug",
|
||||
"arguments": { "project": "beta", "title": "x", "description": "y" },
|
||||
"_meta": { "progressToken": "tok1" }
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = cli
|
||||
.post("/mcp")
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
.body(rpc_body)
|
||||
.send()
|
||||
.await;
|
||||
let body = resp.0.into_body().into_string().await.unwrap();
|
||||
let lines = sse_data_lines(&body);
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
1,
|
||||
"expected exactly one SSE data event: {body}"
|
||||
);
|
||||
let ev: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
|
||||
assert_eq!(
|
||||
ev["result"]["content"][0]["text"], "handled_by:beta",
|
||||
"explicit project=beta on the SSE path must land in beta even though \
|
||||
alpha is active: {ev}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_read_tool_with_explicit_project_reads_from_non_active_project() {
|
||||
let (state, _mock_alpha, _mock_beta) = make_two_http_project_state().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(&serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "list_upcoming",
|
||||
"arguments": { "project": "beta" },
|
||||
"_meta": { "progressToken": "tok1" }
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = cli
|
||||
.post("/mcp")
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
.body(rpc_body)
|
||||
.send()
|
||||
.await;
|
||||
let body = resp.0.into_body().into_string().await.unwrap();
|
||||
let lines = sse_data_lines(&body);
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
1,
|
||||
"expected exactly one SSE data event: {body}"
|
||||
);
|
||||
let ev: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
|
||||
assert_eq!(
|
||||
ev["result"]["content"][0]["text"], "handled_by:beta",
|
||||
"explicit project=beta on the SSE path must read from beta (audited \
|
||||
generically, not via a hand-listed subset — story 1229 AC 4): {ev}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_create_tool_without_project_errors_when_multiple_projects_registered() {
|
||||
let (state, _mock_alpha, _mock_beta) = make_two_http_project_state().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(&serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "create_bug",
|
||||
"arguments": { "title": "x", "description": "y" },
|
||||
"_meta": { "progressToken": "tok1" }
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = cli
|
||||
.post("/mcp")
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
.body(rpc_body)
|
||||
.send()
|
||||
.await;
|
||||
let body = resp.0.into_body().into_string().await.unwrap();
|
||||
let lines = sse_data_lines(&body);
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
1,
|
||||
"expected exactly one SSE error event: {body}"
|
||||
);
|
||||
let ev: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
|
||||
assert!(
|
||||
ev["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("requires an explicit `project`"),
|
||||
"SSE create call omitting `project` with >1 project registered must \
|
||||
error, not silently proxy to the active project: {ev}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Non-SSE `tools/call` requests must be routed over the live sled-uplink WS
|
||||
/// (story 1180: the gateway no longer falls back to HTTP for MCP proxying)
|
||||
/// and return a plain `application/json` body.
|
||||
@@ -1023,11 +1213,20 @@ async fn gateway_mcp_post_against_disconnected_sled_returns_error_response_fast(
|
||||
|
||||
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||
assert_eq!(body["id"], 3);
|
||||
// Story 1232: a disconnected sled is a tool-call-time failure, not a
|
||||
// protocol fault — it must come back as a non-fatal MCP tool_result
|
||||
// (isError: true) so the calling `claude` CLI's MCP client treats it as
|
||||
// a normal tool error instead of exiting non-zero, not a top-level
|
||||
// JSON-RPC `error` object.
|
||||
assert!(
|
||||
body.get("error").is_some(),
|
||||
"Expected a JSON-RPC error for a disconnected sled; got: {body}"
|
||||
body.get("error").is_none(),
|
||||
"a disconnected sled must not produce a top-level JSON-RPC error; got: {body}"
|
||||
);
|
||||
let msg = body["error"]["message"].as_str().unwrap_or("");
|
||||
assert_eq!(
|
||||
body["result"]["isError"], true,
|
||||
"expected a non-fatal tool_result for a disconnected sled; got: {body}"
|
||||
);
|
||||
let msg = body["result"]["content"][0]["text"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
msg.contains("offline-sled"),
|
||||
"error message must name the sled; got: {msg}"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Shared test helper for driving `git` as a subprocess in unit tests.
|
||||
//!
|
||||
//! `Command::output()` resolves to `Ok` even when the spawned process exits
|
||||
//! non-zero — a bare `.expect(...)`/`.unwrap()` on that `Output` only
|
||||
//! checks that the process could be spawned, not that git itself
|
||||
//! succeeded. A failed `git commit` (e.g. missing `user.name`/`user.email`
|
||||
//! identity) then silently leaves the repo without the commit the rest of
|
||||
//! the test assumes exists, surfacing later as a confusing, unrelated
|
||||
//! assertion failure instead of the real git error.
|
||||
|
||||
use std::io;
|
||||
use std::process::Output;
|
||||
|
||||
/// Unwrap a `git` subprocess result, panicking with `context` and git's
|
||||
/// stderr if the process failed to spawn or exited non-zero.
|
||||
pub(crate) fn git_ok(output: io::Result<Output>, context: &str) -> Output {
|
||||
let output = output.unwrap_or_else(|e| panic!("{context}: failed to run git: {e}"));
|
||||
if !output.status.success() {
|
||||
panic!("{context}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
output
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! History — subject-scoped, cursor-paged timeline over chat turns, agent
|
||||
//! runs, and pipeline transitions (story 1236).
|
||||
//!
|
||||
//! Every entry is persisted to the CRDT `history_log` list (see
|
||||
//! [`crate::crdt_state`]) so it survives restarts and replicates across
|
||||
//! sleds. Callers query via [`get_history`], which returns short typed
|
||||
//! summaries plus an opaque `ref` string; the full payload for a single
|
||||
//! entry is fetched on demand via [`get_history_entry`].
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
/// One entry in a paged history listing: a short typed summary plus a `ref`
|
||||
/// that [`get_history_entry`] can resolve to the full payload.
|
||||
pub struct HistoryEntry {
|
||||
pub kind: String,
|
||||
pub subject_type: String,
|
||||
pub subject_id: String,
|
||||
pub at: chrono::DateTime<Utc>,
|
||||
pub summary: String,
|
||||
/// Opaque cursor-safe reference: `"{sled_id}:{event_seq}"`.
|
||||
pub entry_ref: String,
|
||||
}
|
||||
|
||||
/// A page of history entries plus an opaque cursor for the next page.
|
||||
pub struct HistoryPage {
|
||||
pub entries: Vec<HistoryEntry>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// Sort key used both for global ordering and for the pagination cursor.
|
||||
fn sort_key(e: &crate::crdt_state::HistoryEntryRaw) -> (i64, String, u64) {
|
||||
(e.timestamp as i64, e.sled_id.clone(), e.event_seq)
|
||||
}
|
||||
|
||||
fn encode_cursor(key: &(i64, String, u64)) -> String {
|
||||
format!("{}:{}:{}", key.0, key.1, key.2)
|
||||
}
|
||||
|
||||
fn decode_cursor(cursor: &str) -> Option<(i64, String, u64)> {
|
||||
let mut parts = cursor.splitn(3, ':');
|
||||
let ts: i64 = parts.next()?.parse().ok()?;
|
||||
let sled_id = parts.next()?.to_string();
|
||||
let seq: u64 = parts.next()?.parse().ok()?;
|
||||
Some((ts, sled_id, seq))
|
||||
}
|
||||
|
||||
/// Query a time-ordered, paged history for a subject.
|
||||
///
|
||||
/// `since`/`until` are inclusive Unix-second bounds (`None` = unbounded).
|
||||
/// `cursor` resumes after the last entry returned by a previous call;
|
||||
/// `limit` is clamped to `[1, 500]`.
|
||||
pub fn get_history(
|
||||
subject_type: &str,
|
||||
subject_id: &str,
|
||||
since: Option<i64>,
|
||||
until: Option<i64>,
|
||||
cursor: Option<&str>,
|
||||
limit: usize,
|
||||
) -> HistoryPage {
|
||||
let limit = limit.clamp(1, 500);
|
||||
let after_key = cursor.and_then(decode_cursor);
|
||||
|
||||
let mut matching: Vec<crate::crdt_state::HistoryEntryRaw> =
|
||||
crate::crdt_state::read_all_history_entries()
|
||||
.into_iter()
|
||||
.filter(|e| subject_matches(e, subject_type, subject_id))
|
||||
.filter(|e| since.is_none_or(|s| e.timestamp as i64 >= s))
|
||||
.filter(|e| until.is_none_or(|u| e.timestamp as i64 <= u))
|
||||
.collect();
|
||||
|
||||
matching.sort_by_key(sort_key);
|
||||
|
||||
let start = match after_key {
|
||||
Some(after) => matching
|
||||
.iter()
|
||||
.position(|e| sort_key(e) > after)
|
||||
.unwrap_or(matching.len()),
|
||||
None => 0,
|
||||
};
|
||||
|
||||
let page: Vec<_> = matching[start..].iter().take(limit).collect();
|
||||
let next_cursor = if start + page.len() < matching.len() {
|
||||
page.last().map(|e| encode_cursor(&sort_key(e)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let entries = page
|
||||
.into_iter()
|
||||
.map(|e| HistoryEntry {
|
||||
kind: e.kind.clone(),
|
||||
subject_type: e.subject_type.clone(),
|
||||
subject_id: e.subject_id.clone(),
|
||||
at: chrono::DateTime::from_timestamp(e.timestamp as i64, 0).unwrap_or_default(),
|
||||
summary: e.summary.clone(),
|
||||
entry_ref: format!("{}:{}", e.sled_id, e.event_seq),
|
||||
})
|
||||
.collect();
|
||||
|
||||
HistoryPage {
|
||||
entries,
|
||||
next_cursor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return true when `entry` belongs to the requested subject.
|
||||
///
|
||||
/// `"sled"` subjects match on the recording sled's own ID (who did it);
|
||||
/// `"story"` subjects match on the entry's declared subject dimension (what
|
||||
/// it happened to). A `"project"` query returns every entry recorded by this
|
||||
/// server instance, since each huskies server is scoped to a single project.
|
||||
fn subject_matches(
|
||||
e: &crate::crdt_state::HistoryEntryRaw,
|
||||
subject_type: &str,
|
||||
subject_id: &str,
|
||||
) -> bool {
|
||||
match subject_type {
|
||||
"sled" | "robot" => e.sled_id == subject_id,
|
||||
"project" => true,
|
||||
_ => e.subject_type == subject_type && e.subject_id == subject_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a `ref` string returned by [`get_history`] into the full payload
|
||||
/// for that entry. Returns `None` when the ref does not resolve to any known
|
||||
/// entry.
|
||||
pub fn get_history_entry(entry_ref: &str) -> Option<String> {
|
||||
let (sled_id, seq_str) = entry_ref.split_once(':')?;
|
||||
let seq: u64 = seq_str.parse().ok()?;
|
||||
crate::crdt_state::read_all_history_entries()
|
||||
.into_iter()
|
||||
.find(|e| e.sled_id == sled_id && e.event_seq == seq)
|
||||
.map(|e| e.detail)
|
||||
}
|
||||
|
||||
/// Record a pipeline stage transition into the unified history log.
|
||||
///
|
||||
/// Called from a dedicated broadcast subscriber (see
|
||||
/// [`spawn_history_subscriber`]) so it runs independently of
|
||||
/// [`crate::event_log`]'s own transition log.
|
||||
pub(crate) fn record_pipeline_transition(fired: &crate::pipeline_state::TransitionFired) {
|
||||
let sled_id = crate::crdt_state::our_node_id().unwrap_or_default();
|
||||
let timestamp = fired.at.timestamp() as f64;
|
||||
let from_stage = crate::pipeline_state::stage_label(&fired.before);
|
||||
let to_stage = crate::pipeline_state::stage_label(&fired.after);
|
||||
let pipeline_event = crate::pipeline_state::event_label(&fired.event);
|
||||
let summary = format!(
|
||||
"{} moved {from_stage} -> {to_stage} ({pipeline_event})",
|
||||
fired.story_id.0
|
||||
);
|
||||
let detail = serde_json::json!({
|
||||
"story_id": fired.story_id.0,
|
||||
"from_stage": from_stage,
|
||||
"to_stage": to_stage,
|
||||
"pipeline_event": pipeline_event,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
crate::crdt_state::append_history_entry(
|
||||
&sled_id,
|
||||
timestamp,
|
||||
"story",
|
||||
&fired.story_id.0,
|
||||
"pipeline_transition",
|
||||
&summary,
|
||||
&detail,
|
||||
);
|
||||
}
|
||||
|
||||
/// Record a completed chat turn into the unified history log.
|
||||
///
|
||||
/// `subject_id` is the persona/session the turn belongs to (e.g. `"timmy"`).
|
||||
pub(crate) fn record_chat_turn(subject_id: &str, user_message: &str, assistant_reply: &str) {
|
||||
let sled_id = crate::crdt_state::our_node_id().unwrap_or_default();
|
||||
let timestamp = Utc::now().timestamp() as f64;
|
||||
let summary = truncate(user_message, 120);
|
||||
let detail = serde_json::json!({
|
||||
"user": user_message,
|
||||
"assistant": assistant_reply,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
crate::crdt_state::append_history_entry(
|
||||
&sled_id,
|
||||
timestamp,
|
||||
"project",
|
||||
subject_id,
|
||||
"chat_turn",
|
||||
&summary,
|
||||
&detail,
|
||||
);
|
||||
}
|
||||
|
||||
/// Record a completed agent run into the unified history log.
|
||||
///
|
||||
/// The gate output is truncated to keep the CRDT entry bounded; the full
|
||||
/// transcript remains on disk under `.huskies/logs/{story_id}/` for deeper
|
||||
/// inspection via `get_agent_output`.
|
||||
pub(crate) fn record_agent_run(
|
||||
story_id: &str,
|
||||
agent_name: &str,
|
||||
session_id: Option<&str>,
|
||||
gates_passed: bool,
|
||||
gate_output: &str,
|
||||
) {
|
||||
let sled_id = crate::crdt_state::our_node_id().unwrap_or_default();
|
||||
let timestamp = Utc::now().timestamp() as f64;
|
||||
let outcome = if gates_passed { "passed" } else { "failed" };
|
||||
let summary = format!(
|
||||
"{agent_name} run on {story_id} {outcome}: {}",
|
||||
truncate(gate_output, 100)
|
||||
);
|
||||
let detail = serde_json::json!({
|
||||
"story_id": story_id,
|
||||
"agent_name": agent_name,
|
||||
"session_id": session_id,
|
||||
"gates_passed": gates_passed,
|
||||
"gate_output": truncate(gate_output, 4000),
|
||||
})
|
||||
.to_string();
|
||||
|
||||
crate::crdt_state::append_history_entry(
|
||||
&sled_id,
|
||||
timestamp,
|
||||
"story",
|
||||
story_id,
|
||||
"agent_run",
|
||||
&summary,
|
||||
&detail,
|
||||
);
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max_chars: usize) -> String {
|
||||
if s.chars().count() <= max_chars {
|
||||
s.to_string()
|
||||
} else {
|
||||
let truncated: String = s.chars().take(max_chars).collect();
|
||||
format!("{truncated}…")
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a background task that persists every `TransitionFired` event to the
|
||||
/// unified history log, independently of [`crate::event_log`]'s subscriber.
|
||||
pub fn spawn_history_subscriber() {
|
||||
let mut rx = crate::pipeline_state::subscribe_transitions();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(fired) => record_pipeline_transition(&fired),
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn record_and_query_pipeline_transition() {
|
||||
crate::crdt_state::init_for_test();
|
||||
let fired = crate::pipeline_state::TransitionFired {
|
||||
story_id: crate::pipeline_state::StoryId("42_story_test".to_string()),
|
||||
before: crate::pipeline_state::Stage::Backlog,
|
||||
after: crate::pipeline_state::Stage::Coding {
|
||||
claim: None,
|
||||
plan: crate::pipeline_state::PlanState::Missing,
|
||||
retries: 0,
|
||||
},
|
||||
event: crate::pipeline_state::PipelineEvent::DepsMet,
|
||||
at: chrono::Utc::now(),
|
||||
};
|
||||
record_pipeline_transition(&fired);
|
||||
|
||||
let page = get_history("story", "42_story_test", None, None, None, 50);
|
||||
assert_eq!(page.entries.len(), 1);
|
||||
assert_eq!(page.entries[0].kind, "pipeline_transition");
|
||||
assert!(page.entries[0].summary.contains("42_story_test"));
|
||||
|
||||
let full = get_history_entry(&page.entries[0].entry_ref).unwrap();
|
||||
assert!(full.contains("DepsMet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_and_query_chat_turn() {
|
||||
crate::crdt_state::init_for_test();
|
||||
record_chat_turn("timmy", "what happened with 42?", "here's the history");
|
||||
|
||||
let page = get_history("project", "timmy", None, None, None, 50);
|
||||
assert_eq!(page.entries.len(), 1);
|
||||
assert_eq!(page.entries[0].kind, "chat_turn");
|
||||
|
||||
let full = get_history_entry(&page.entries[0].entry_ref).unwrap();
|
||||
assert!(full.contains("here's the history"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_and_query_agent_run() {
|
||||
crate::crdt_state::init_for_test();
|
||||
record_agent_run(
|
||||
"42_story_test",
|
||||
"coder-1",
|
||||
Some("sess-1"),
|
||||
true,
|
||||
"all gates passed",
|
||||
);
|
||||
|
||||
let page = get_history("story", "42_story_test", None, None, None, 50);
|
||||
assert_eq!(page.entries.len(), 1);
|
||||
assert_eq!(page.entries[0].kind, "agent_run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagination_cursor_advances() {
|
||||
crate::crdt_state::init_for_test();
|
||||
for i in 0..5 {
|
||||
record_chat_turn("timmy", &format!("msg {i}"), &format!("reply {i}"));
|
||||
}
|
||||
|
||||
let page1 = get_history("project", "timmy", None, None, None, 2);
|
||||
assert_eq!(page1.entries.len(), 2);
|
||||
assert!(page1.next_cursor.is_some());
|
||||
|
||||
let page2 = get_history(
|
||||
"project",
|
||||
"timmy",
|
||||
None,
|
||||
None,
|
||||
page1.next_cursor.as_deref(),
|
||||
2,
|
||||
);
|
||||
assert_eq!(page2.entries.len(), 2);
|
||||
|
||||
let page3 = get_history(
|
||||
"project",
|
||||
"timmy",
|
||||
None,
|
||||
None,
|
||||
page2.next_cursor.as_deref(),
|
||||
2,
|
||||
);
|
||||
assert_eq!(page3.entries.len(), 1);
|
||||
assert!(page3.next_cursor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sled_subject_matches_recording_sled() {
|
||||
crate::crdt_state::init_for_test();
|
||||
record_chat_turn("timmy", "hi", "hello");
|
||||
let sled_id = crate::crdt_state::our_node_id().unwrap_or_default();
|
||||
|
||||
let page = get_history("sled", &sled_id, None, None, None, 50);
|
||||
assert_eq!(page.entries.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,12 @@ pub enum PermissionDecision {
|
||||
Deny,
|
||||
/// One-time approval.
|
||||
Approve,
|
||||
/// Approve and persist the rule to `.claude/settings.json` so Claude Code's
|
||||
/// built-in permission system handles future checks without prompting.
|
||||
/// Approve, and remember `(tool, target-pattern)` for the rest of the
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -33,6 +37,46 @@ pub struct PermissionForward {
|
||||
pub response_tx: oneshot::Sender<PermissionDecision>,
|
||||
}
|
||||
|
||||
/// A single selectable choice within a [`QuestionSpec`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QuestionOption {
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// A multiple-choice question forwarded from the MCP `ask_question` tool to a
|
||||
/// chat transport for rendering as numbered text (story 1228).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QuestionSpec {
|
||||
pub header: String,
|
||||
pub question: String,
|
||||
pub options: Vec<QuestionOption>,
|
||||
pub multi_select: bool,
|
||||
}
|
||||
|
||||
/// The user's reply to a forwarded [`QuestionSpec`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum QuestionAnswer {
|
||||
/// 0-based indices into `QuestionSpec::options` the user selected.
|
||||
Selected(Vec<usize>),
|
||||
/// Freeform text the user typed instead of selecting a listed option
|
||||
/// (the always-available "Other" path, AC5).
|
||||
FreeText(String),
|
||||
}
|
||||
|
||||
/// A question request forwarded from the MCP `ask_question` tool to the
|
||||
/// active chat transport. The MCP handler blocks on `response_tx` until a
|
||||
/// chat reply resolves it (or it times out).
|
||||
///
|
||||
/// Kept structurally separate from `PermissionForward` / permission-router
|
||||
/// plumbing (see `service::question_router`) so a reply answering one is
|
||||
/// never misinterpreted as answering the other (story 1228, AC4).
|
||||
pub struct QuestionForward {
|
||||
pub request_id: String,
|
||||
pub question: QuestionSpec,
|
||||
pub response_tx: oneshot::Sender<Result<QuestionAnswer, String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
/// Shared application state threaded through all HTTP handlers via Poem's `Data` extractor.
|
||||
pub struct AppContext {
|
||||
@@ -52,6 +96,10 @@ pub struct AppContext {
|
||||
/// `prompt_permission` tool. The MCP handler sends a [`PermissionForward`]
|
||||
/// and awaits the oneshot response.
|
||||
pub perm_tx: mpsc::UnboundedSender<PermissionForward>,
|
||||
/// Sender for questions originating from the MCP `ask_question` tool.
|
||||
/// The MCP handler sends a [`QuestionForward`] and awaits the oneshot
|
||||
/// response (story 1228).
|
||||
pub question_tx: mpsc::UnboundedSender<QuestionForward>,
|
||||
/// Child process of the QA app launched for manual testing.
|
||||
/// Only one instance runs at a time.
|
||||
pub qa_app_process: Arc<std::sync::Mutex<Option<std::process::Child>>>,
|
||||
@@ -97,6 +145,8 @@ impl AppContext {
|
||||
let (reconciliation_tx, _) = broadcast::channel(64);
|
||||
let (perm_tx, perm_rx) = mpsc::unbounded_channel();
|
||||
let permission_registry = crate::service::permission_router::ResponderRegistry::new();
|
||||
let (question_tx, question_rx) = mpsc::unbounded_channel();
|
||||
let question_registry = crate::service::question_router::QuestionResponderRegistry::new();
|
||||
// Plain `#[test]` fns (no tokio runtime) construct `AppContext` too;
|
||||
// skip spawning when there's no reactor to spawn onto since those
|
||||
// tests never exercise the permission plumbing.
|
||||
@@ -105,6 +155,10 @@ impl AppContext {
|
||||
perm_rx,
|
||||
Arc::clone(&permission_registry),
|
||||
);
|
||||
crate::service::question_router::spawn_question_router(
|
||||
question_rx,
|
||||
Arc::clone(&question_registry),
|
||||
);
|
||||
}
|
||||
let timer_store = Arc::new(TimerStore::load(
|
||||
project_root.join(".huskies").join("timers.json"),
|
||||
@@ -125,6 +179,11 @@ impl AppContext {
|
||||
permission_registry,
|
||||
pending_perm_replies: crate::service::permission_router::PendingPermReplies::new(),
|
||||
permission_timeout_secs: 120,
|
||||
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||
question_registry,
|
||||
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(
|
||||
),
|
||||
question_timeout_secs: 120,
|
||||
status: agents.status_broadcaster(),
|
||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||
});
|
||||
@@ -136,6 +195,7 @@ impl AppContext {
|
||||
watcher_tx,
|
||||
reconciliation_tx,
|
||||
perm_tx,
|
||||
question_tx,
|
||||
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
|
||||
bot_shutdown: None,
|
||||
matrix_shutdown_tx: None,
|
||||
|
||||
+540
-23
@@ -295,7 +295,10 @@ pub async fn gateway_mcp_post_handler(
|
||||
|
||||
// SSE proxy: tools/call with Accept: text/event-stream + progressToken for
|
||||
// non-gateway tools is forwarded to the sled's SSE endpoint so progress
|
||||
// notifications flow through to the gateway client unchanged.
|
||||
// notifications flow through to the gateway client unchanged. This must
|
||||
// apply the same explicit-project resolution and create-guard as the
|
||||
// buffered `tools/call` path below (story 1229: they had drifted apart,
|
||||
// silently bypassing both on the SSE path).
|
||||
if rpc.method == "tools/call" {
|
||||
let accepts_sse = req
|
||||
.header("accept")
|
||||
@@ -313,7 +316,15 @@ pub async fn gateway_mcp_post_handler(
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if !GATEWAY_TOOLS.contains(&tool_name) {
|
||||
return proxy_and_respond_sse(&state, &bytes, rpc.id).await;
|
||||
return match extract_explicit_project(&rpc.params) {
|
||||
Some(project) => {
|
||||
proxy_and_respond_sse_for_project(&state, project, &bytes, rpc.id).await
|
||||
}
|
||||
None => match create_guard_error(&state, tool_name).await {
|
||||
Some(msg) => sse_error_response(rpc.id, -32602, msg),
|
||||
None => proxy_and_respond_sse(&state, &bytes, rpc.id).await,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -340,17 +351,20 @@ pub async fn gateway_mcp_post_handler(
|
||||
// Story 1208 AC 1: an explicit `project` argument on any
|
||||
// proxied tool call targets that project directly, without
|
||||
// requiring a prior `switch_project`.
|
||||
let explicit_project = rpc
|
||||
.params
|
||||
.get("arguments")
|
||||
.and_then(|a| a.get("project"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|p| !p.is_empty());
|
||||
match explicit_project {
|
||||
match extract_explicit_project(&rpc.params) {
|
||||
Some(project) => {
|
||||
proxy_and_respond_for_project(&state, project, &bytes, rpc.id).await
|
||||
}
|
||||
None => proxy_and_respond(&state, &bytes, rpc.id).await,
|
||||
None => match create_guard_error(&state, tool_name).await {
|
||||
// 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.
|
||||
Some(msg) => to_json_response(JsonRpcResponse::error(rpc.id, -32602, msg)),
|
||||
None => {
|
||||
proxy_and_respond_with_resolved_project(&state, &bytes, rpc.id).await
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,6 +372,36 @@ pub async fn gateway_mcp_post_handler(
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the optional per-call `project` argument out of a `tools/call`
|
||||
/// request's params (story 1208 AC 1), shared by the SSE and buffered
|
||||
/// `tools/call` paths so both resolve routing identically (story 1229).
|
||||
fn extract_explicit_project(params: &Value) -> Option<&str> {
|
||||
params
|
||||
.get("arguments")
|
||||
.and_then(|a| a.get("project"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|p| !p.is_empty())
|
||||
}
|
||||
|
||||
/// Returns an error message when `tool_name` is a create-tool called without
|
||||
/// an explicit `project` while more than one project is registered (story
|
||||
/// 1225 AC 3), or `None` when the call may proceed against the active
|
||||
/// project. Shared by the SSE and buffered `tools/call` paths so a fix to one
|
||||
/// can't silently miss the other, as happened in story 1229.
|
||||
async fn create_guard_error(state: &GatewayState, tool_name: &str) -> Option<String> {
|
||||
if !is_create_tool(tool_name) {
|
||||
return None;
|
||||
}
|
||||
let project_count = state.projects.read().await.len();
|
||||
if project_count > 1 {
|
||||
Some(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 {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Proxy a request to the active project and format the response.
|
||||
///
|
||||
/// Prefers the live sled-uplink WebSocket when one is attached (story 899
|
||||
@@ -376,6 +420,46 @@ 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) => tool_error_response(id, format!("Sled '{active}' is unavailable: {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
|
||||
/// than whatever project is currently active, so a single ops/LLM session
|
||||
/// can address any registered project per-call without a prior
|
||||
@@ -398,29 +482,66 @@ async fn proxy_and_respond_for_project(
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(resp_body)),
|
||||
Err(e) => to_json_response(JsonRpcResponse::error(
|
||||
id,
|
||||
-32603,
|
||||
format!("proxy error: {e}"),
|
||||
)),
|
||||
Err(e) => tool_error_response(id, format!("Sled '{project}' is unavailable: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a JSON-RPC **success** response shaped as an MCP tool_result error
|
||||
/// (`isError: true`) rather than a top-level JSON-RPC protocol error.
|
||||
///
|
||||
/// A down or version-mismatched sled is a tool-call-time failure, not a
|
||||
/// protocol violation — the downstream `claude` CLI's MCP client must see a
|
||||
/// normal tool_result so the model can report the failure and the turn
|
||||
/// completes, instead of a top-level error that the CLI may treat as fatal
|
||||
/// and exit non-zero (story 1232).
|
||||
fn tool_error_response(id: Option<Value>, message: String) -> Response {
|
||||
to_json_response(tool_error_json(id, message))
|
||||
}
|
||||
|
||||
/// Stream an MCP tool call to the active sled as SSE, re-emitting each `data:`
|
||||
/// event from the sled to the originating gateway client without buffering.
|
||||
///
|
||||
/// On sled disconnect mid-stream a JSON-RPC error event is emitted so the
|
||||
/// client does not hang forever.
|
||||
#[allow(clippy::string_slice)] // pos from buf.find('\n'); '\n' is ASCII so pos and pos+1 are valid boundaries
|
||||
async fn proxy_and_respond_sse(state: &GatewayState, bytes: &[u8], id: Option<Value>) -> Response {
|
||||
let url = match state.active_url().await {
|
||||
Ok(u) => u,
|
||||
Err(e) => return sse_error_response(id, -32603, e.to_string()),
|
||||
Err(e) => return sse_tool_error_response(id, e.to_string()),
|
||||
};
|
||||
stream_mcp_call_sse(state, &url, bytes, id).await
|
||||
}
|
||||
|
||||
let resp = match gateway::io::proxy_mcp_call_sse(&state.client, &url, bytes).await {
|
||||
/// Stream an MCP tool call via SSE to an explicitly named project (story
|
||||
/// 1229), rather than always targeting the active project — the SSE
|
||||
/// counterpart of `proxy_and_respond_for_project`.
|
||||
async fn proxy_and_respond_sse_for_project(
|
||||
state: &GatewayState,
|
||||
project: &str,
|
||||
bytes: &[u8],
|
||||
id: Option<Value>,
|
||||
) -> Response {
|
||||
let url = match state.url_for_project(project).await {
|
||||
Ok(u) => u,
|
||||
Err(e) => return sse_error_response(id, -32602, e.to_string()),
|
||||
};
|
||||
stream_mcp_call_sse(state, &url, bytes, id).await
|
||||
}
|
||||
|
||||
/// Shared SSE streaming body for `proxy_and_respond_sse` and
|
||||
/// `proxy_and_respond_sse_for_project` — proxies to `url` and re-emits each
|
||||
/// `data:` event from the sled to the originating gateway client without
|
||||
/// buffering. On sled disconnect mid-stream a JSON-RPC error event is emitted
|
||||
/// so the client does not hang forever.
|
||||
#[allow(clippy::string_slice)] // pos from buf.find('\n'); '\n' is ASCII so pos and pos+1 are valid boundaries
|
||||
async fn stream_mcp_call_sse(
|
||||
state: &GatewayState,
|
||||
url: &str,
|
||||
bytes: &[u8],
|
||||
id: Option<Value>,
|
||||
) -> Response {
|
||||
let resp = match gateway::io::proxy_mcp_call_sse(&state.client, url, bytes).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return sse_error_response(id, -32603, format!("proxy error: {e}")),
|
||||
Err(e) => return sse_tool_error_response(id, format!("proxy error: {e}")),
|
||||
};
|
||||
|
||||
let id_for_error = id;
|
||||
@@ -446,9 +567,8 @@ async fn proxy_and_respond_sse(state: &GatewayState, bytes: &[u8], id: Option<Va
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let err = JsonRpcResponse::error(
|
||||
let err = tool_error_json(
|
||||
id_for_error.clone(),
|
||||
-32603,
|
||||
format!("upstream disconnected: {e}"),
|
||||
);
|
||||
let data = serde_json::to_string(&err).unwrap_or_default();
|
||||
@@ -464,6 +584,32 @@ async fn proxy_and_respond_sse(state: &GatewayState, bytes: &[u8], id: Option<Va
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Build the JSON-RPC value used by [`tool_error_response`] and
|
||||
/// [`sse_tool_error_response`] — extracted so the SSE mid-stream-disconnect
|
||||
/// branch above can reuse the same non-fatal `isError` shape.
|
||||
fn tool_error_json(id: Option<Value>, message: String) -> JsonRpcResponse {
|
||||
JsonRpcResponse::success(
|
||||
id,
|
||||
json!({
|
||||
"content": [{ "type": "text", "text": message }],
|
||||
"isError": true
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a minimal SSE response containing a single non-fatal MCP tool_result
|
||||
/// error event (`isError: true`) — the SSE counterpart of
|
||||
/// [`tool_error_response`], used when the initial proxy connection to the
|
||||
/// sled fails (story 1232).
|
||||
fn sse_tool_error_response(id: Option<Value>, message: String) -> Response {
|
||||
let err = tool_error_json(id, message);
|
||||
let data = serde_json::to_string(&err).unwrap_or_default();
|
||||
let stream = async_stream::stream! {
|
||||
yield Event::message(data);
|
||||
};
|
||||
SSE::new(stream).into_response()
|
||||
}
|
||||
|
||||
/// Build a minimal SSE response containing a single JSON-RPC error event.
|
||||
fn sse_error_response(id: Option<Value>, code: i64, msg: String) -> Response {
|
||||
let err = JsonRpcResponse::error(id, code, msg);
|
||||
@@ -1065,6 +1211,7 @@ async fn handle_project_rebuild_tool(
|
||||
drain_timeout_secs,
|
||||
force,
|
||||
&state.projects,
|
||||
Some(state.port),
|
||||
&state.config_dir,
|
||||
)
|
||||
.await;
|
||||
@@ -1310,6 +1457,7 @@ async fn handle_pipeline_get(state: &GatewayState, id: Option<Value>) -> JsonRpc
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::service::gateway::config::{GatewayConfig, ProjectEntry};
|
||||
use poem::EndpointExt as _;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -1642,9 +1790,14 @@ mod tests {
|
||||
proxy_and_respond_for_project(&state, "test-project", b"{}", Some(json!(1))).await;
|
||||
let bytes = resp.into_body().into_bytes().await.unwrap();
|
||||
let parsed: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(parsed["error"]["code"], -32603);
|
||||
// Story 1232: a known project with no live sled connection is a
|
||||
// tool-call-time failure, not a protocol fault — it must come back as
|
||||
// a non-fatal tool_result (isError: true), not a top-level JSON-RPC
|
||||
// error, so the calling `claude` CLI treats it as a normal tool error.
|
||||
assert!(parsed["error"].is_null(), "got: {parsed}");
|
||||
assert_eq!(parsed["result"]["isError"], true, "got: {parsed}");
|
||||
assert!(
|
||||
parsed["error"]["message"]
|
||||
parsed["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("no live WS uplink"),
|
||||
@@ -1688,4 +1841,368 @@ mod tests {
|
||||
inject_project_arg_schema(&mut tools);
|
||||
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}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── story 1232: down/mismatched sled must not surface as a fatal
|
||||
// top-level JSON-RPC error, which is what makes the calling `claude` CLI
|
||||
// exit non-zero instead of completing the turn ────────────────────────
|
||||
|
||||
/// AC 1/2/3: a `tools/call` against a project with no live sled-uplink
|
||||
/// connection at all (the "unreachable sled" case) must come back as a
|
||||
/// normal, non-fatal MCP tool_result (`isError: true`), not a top-level
|
||||
/// JSON-RPC `error` object — the CLI's MCP client treats the latter as a
|
||||
/// protocol fault rather than something the model can react to.
|
||||
#[tokio::test]
|
||||
async fn unreachable_sled_returns_non_fatal_tool_result_not_top_level_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// No sled connection registered for "test-project" — proxy_active_mcp
|
||||
// must fail with "no live WS uplink connection".
|
||||
let state = make_test_state(dir.path());
|
||||
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!(
|
||||
parsed["error"].is_null(),
|
||||
"an unreachable sled must not produce a top-level JSON-RPC error: {parsed}"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["result"]["isError"], true,
|
||||
"an unreachable sled must produce a tool_result with isError: true: {parsed}"
|
||||
);
|
||||
let text = parsed["result"]["content"][0]["text"].as_str().unwrap();
|
||||
assert!(
|
||||
text.contains("unavailable"),
|
||||
"tool_result text should explain the sled is unavailable, got: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// AC 1/2/3: a sled that IS connected but replies with a malformed or
|
||||
/// version-mismatched MCP response (not a well-formed JSON-RPC object)
|
||||
/// must also surface as a non-fatal tool_result, not garbage forwarded
|
||||
/// verbatim to the calling `claude` CLI's MCP parser.
|
||||
#[tokio::test]
|
||||
async fn malformed_sled_response_returns_non_fatal_tool_result() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut projects = BTreeMap::new();
|
||||
projects.insert(
|
||||
"test-project".to_string(),
|
||||
ProjectEntry::with_url("http://127.0.0.1:3001"),
|
||||
);
|
||||
let config = GatewayConfig {
|
||||
projects,
|
||||
sled_tokens: BTreeMap::new(),
|
||||
release_channels: BTreeMap::new(),
|
||||
};
|
||||
let state = Arc::new(GatewayState::new(config, dir.path().to_path_buf(), 3000).unwrap());
|
||||
|
||||
// Fake sled that answers every mcp_request with a payload that is
|
||||
// valid JSON but not a well-formed JSON-RPC response — simulating a
|
||||
// version-mismatched sled speaking an incompatible protocol shape.
|
||||
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);
|
||||
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!({ "unexpected": "shape", "no_jsonrpc_field": true }));
|
||||
}
|
||||
}
|
||||
});
|
||||
state
|
||||
.register_sled_connection(
|
||||
"test-project".to_string(),
|
||||
gateway::SledConnection {
|
||||
tx,
|
||||
last_heartbeat_ms: Arc::new(std::sync::atomic::AtomicI64::new(
|
||||
chrono::Utc::now().timestamp_millis(),
|
||||
)),
|
||||
in_flight,
|
||||
},
|
||||
)
|
||||
.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!(
|
||||
parsed["error"].is_null(),
|
||||
"a malformed sled response must not produce a top-level JSON-RPC error: {parsed}"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["result"]["isError"], true,
|
||||
"a malformed sled response must produce a tool_result with isError: true: {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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ use serde_json::{Value, json};
|
||||
|
||||
mod chat_telemetry;
|
||||
mod permission;
|
||||
mod question;
|
||||
mod usage;
|
||||
|
||||
pub(crate) use chat_telemetry::tool_chat_telemetry;
|
||||
pub(crate) use permission::tool_prompt_permission;
|
||||
pub(crate) use question::tool_ask_question;
|
||||
pub(crate) use usage::tool_get_token_usage;
|
||||
|
||||
pub(crate) fn tool_get_server_logs(args: &Value) -> Result<String, String> {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
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_warn;
|
||||
|
||||
@@ -29,6 +30,23 @@ pub(crate) async fn tool_prompt_permission(
|
||||
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
|
||||
// receive forwarded permission requests. The Matrix bot's
|
||||
// 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())?;
|
||||
|
||||
if decision == PermissionDecision::AlwaysAllow {
|
||||
// Persist the rule so Claude Code won't prompt again for this tool.
|
||||
if let Some(root) = ctx.state.project_root.lock().unwrap().clone() {
|
||||
let rule = generate_permission_rule(&tool_name, &tool_input);
|
||||
if let Err(e) = add_permission_rule(&root, &rule) {
|
||||
slog_warn!("[permission] Failed to write always-allow rule: {e}");
|
||||
} else {
|
||||
slog!("[permission] Added always-allow rule: {rule}");
|
||||
}
|
||||
}
|
||||
// Remember for the rest of this agent's session (story 1218) — never
|
||||
// written to disk, never visible to another session's requests.
|
||||
ctx.services
|
||||
.remembered_permissions
|
||||
.remember(&session_id, &rule);
|
||||
slog!(
|
||||
"[permission] Remembered rule '{rule}' for session '{session_id}' — future matches auto-approve without prompting"
|
||||
);
|
||||
}
|
||||
|
||||
if decision == PermissionDecision::Approve || decision == PermissionDecision::AlwaysAllow {
|
||||
@@ -238,91 +255,86 @@ mod tests {
|
||||
assert_eq!(rule, "mcp__huskies__create_story");
|
||||
}
|
||||
|
||||
// ── Settings.json writing tests ──────────────────────────────
|
||||
// ── Remembered ("don't ask again this session") tests (story 1218) ──
|
||||
|
||||
#[test]
|
||||
fn add_rule_creates_settings_file_when_missing() {
|
||||
#[tokio::test]
|
||||
async fn remembered_rule_auto_approves_without_forwarding() {
|
||||
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();
|
||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
||||
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
||||
assert!(allow.contains(&json!("Edit")));
|
||||
}
|
||||
// Pre-remember the rule for the default session (no X-Huskies-Session
|
||||
// header scope installed in this test) — no responder is registered,
|
||||
// so if the request were forwarded it would auto-deny instead.
|
||||
ctx.services
|
||||
.remembered_permissions
|
||||
.remember(&session::current(), "Bash(git *)");
|
||||
|
||||
#[test]
|
||||
fn add_rule_does_not_duplicate_existing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
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__*"]}}"#,
|
||||
let result = tool_prompt_permission(
|
||||
&json!({"tool_name": "Bash", "input": {"command": "git status"}}),
|
||||
&ctx,
|
||||
)
|
||||
.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 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__*");
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["behavior"], "allow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rule_appends_to_existing_rules() {
|
||||
#[tokio::test]
|
||||
async fn always_allow_decision_remembers_rule_for_session_not_disk() {
|
||||
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":["Edit"]}}"#,
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
let (guard, mut rx) = ctx.services.permission_registry.register();
|
||||
tokio::spawn(async move {
|
||||
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();
|
||||
|
||||
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(), 2);
|
||||
assert!(allow.contains(&json!("Edit")));
|
||||
assert!(allow.contains(&json!("Write")));
|
||||
assert!(
|
||||
ctx.services
|
||||
.remembered_permissions
|
||||
.is_remembered(&session::current(), "Bash(git *)"),
|
||||
"AlwaysAllow must remember the rule in-memory for this session"
|
||||
);
|
||||
assert!(
|
||||
!tmp.path().join(".claude/settings.json").exists(),
|
||||
"AlwaysAllow must not write to disk (story 1218: session-scoped only)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rule_preserves_other_settings_fields() {
|
||||
#[tokio::test]
|
||||
async fn remembered_rule_does_not_cross_sessions() {
|
||||
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":["Edit"]},"enabledMcpjsonServers":["huskies"]}"#,
|
||||
let ctx = test_ctx(tmp.path());
|
||||
ctx.services
|
||||
.remembered_permissions
|
||||
.remember("story-a", "Bash(git *)");
|
||||
|
||||
// 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();
|
||||
|
||||
add_permission_rule(tmp.path(), "Write").unwrap();
|
||||
|
||||
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
|
||||
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");
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(
|
||||
parsed["behavior"], "deny",
|
||||
"a rule remembered for a different session must not auto-approve this one"
|
||||
);
|
||||
}
|
||||
|
||||
// ── move_story tool tests ─────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! MCP `ask_question` tool — presents a multiple-choice question to the user
|
||||
//! via chat transports that lack a native question UI (story 1228).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::http::context::{
|
||||
AppContext, QuestionAnswer, QuestionForward, QuestionOption, QuestionSpec,
|
||||
};
|
||||
|
||||
/// MCP tool called by an agent to ask the user a multiple-choice question.
|
||||
///
|
||||
/// Forwards the question through the shared channel to the active chat
|
||||
/// transport (currently Matrix), which renders it as numbered text. Blocks
|
||||
/// until a reply resolves it or the question times out.
|
||||
pub(crate) async fn tool_ask_question(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let header = args
|
||||
.get("header")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Question")
|
||||
.to_string();
|
||||
let question_text = args
|
||||
.get("question")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing required argument: question".to_string())?
|
||||
.to_string();
|
||||
let multi_select = args
|
||||
.get("multi_select")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let options: Vec<QuestionOption> = args
|
||||
.get("options")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| "Missing required argument: options".to_string())?
|
||||
.iter()
|
||||
.map(|o| QuestionOption {
|
||||
label: o
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
description: o
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
if options.len() < 2 {
|
||||
return Err("options must contain at least 2 entries".to_string());
|
||||
}
|
||||
|
||||
let question = QuestionSpec {
|
||||
header,
|
||||
question: question_text,
|
||||
options,
|
||||
multi_select,
|
||||
};
|
||||
|
||||
if ctx.services.question_registry.is_empty() {
|
||||
crate::slog!("[question] No interactive session active — cannot ask question");
|
||||
return serde_json::to_string_pretty(&json!({
|
||||
"answered": false,
|
||||
"message": "No interactive session active. Nobody is available to answer this question."
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"));
|
||||
}
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
|
||||
let option_labels: Vec<String> = question.options.iter().map(|o| o.label.clone()).collect();
|
||||
|
||||
if ctx
|
||||
.question_tx
|
||||
.send(QuestionForward {
|
||||
request_id: request_id.clone(),
|
||||
question,
|
||||
response_tx,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return serde_json::to_string_pretty(&json!({
|
||||
"answered": false,
|
||||
"message": "Failed to forward question — no active session."
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"));
|
||||
}
|
||||
|
||||
let timeout_secs = ctx.services.question_timeout_secs;
|
||||
let outcome = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), response_rx)
|
||||
.await
|
||||
.map_err(|_| format!("Question timed out after {timeout_secs} seconds with no reply"))?
|
||||
.map_err(|_| "Question response channel closed unexpectedly".to_string())?;
|
||||
|
||||
match outcome {
|
||||
Ok(QuestionAnswer::Selected(indices)) => {
|
||||
let labels: Vec<&String> = indices
|
||||
.iter()
|
||||
.filter_map(|&i| option_labels.get(i))
|
||||
.collect();
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"answered": true,
|
||||
"selected_indices": indices,
|
||||
"selected_labels": labels,
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
Ok(QuestionAnswer::FreeText(text)) => serde_json::to_string_pretty(&json!({
|
||||
"answered": true,
|
||||
"free_text": text,
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}")),
|
||||
Err(message) => serde_json::to_string_pretty(&json!({
|
||||
"answered": false,
|
||||
"message": message,
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::test_helpers::test_ctx;
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_ask_question_no_interactive_session_returns_not_answered() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
let result = tool_ask_question(
|
||||
&json!({
|
||||
"question": "Which approach?",
|
||||
"options": [
|
||||
{"label": "A", "description": "First"},
|
||||
{"label": "B", "description": "Second"}
|
||||
]
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.expect("must return Ok even when no session is active");
|
||||
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["answered"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_ask_question_requires_at_least_two_options() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
let result = tool_ask_question(
|
||||
&json!({
|
||||
"question": "Pick one?",
|
||||
"options": [{"label": "Only", "description": "one"}]
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_ask_question_selected_answer_returns_labels() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
let (guard, mut rx) = ctx.services.question_registry.register();
|
||||
tokio::spawn(async move {
|
||||
if let Some(forward) = rx.recv().await {
|
||||
let _ = forward
|
||||
.response_tx
|
||||
.send(Ok(QuestionAnswer::Selected(vec![1])));
|
||||
}
|
||||
drop(guard);
|
||||
});
|
||||
|
||||
let result = tool_ask_question(
|
||||
&json!({
|
||||
"question": "Which approach?",
|
||||
"options": [
|
||||
{"label": "A", "description": "First"},
|
||||
{"label": "B", "description": "Second"}
|
||||
]
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.expect("should succeed");
|
||||
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["answered"], true);
|
||||
assert_eq!(parsed["selected_labels"][0], "B");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_ask_question_free_text_answer() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
let (guard, mut rx) = ctx.services.question_registry.register();
|
||||
tokio::spawn(async move {
|
||||
if let Some(forward) = rx.recv().await {
|
||||
let _ = forward
|
||||
.response_tx
|
||||
.send(Ok(QuestionAnswer::FreeText("Something else".to_string())));
|
||||
}
|
||||
drop(guard);
|
||||
});
|
||||
|
||||
let result = tool_ask_question(
|
||||
&json!({
|
||||
"question": "Which approach?",
|
||||
"options": [
|
||||
{"label": "A", "description": "First"},
|
||||
{"label": "B", "description": "Second"}
|
||||
]
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.expect("should succeed");
|
||||
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["answered"], true);
|
||||
assert_eq!(parsed["free_text"], "Something else");
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{
|
||||
agent_tools, diagnostics, git_tools, merge_tools, qa_tools, shell_tools, status_tools,
|
||||
story_tools, timer_tools, trigger_tools, wizard_tools,
|
||||
agent_tools, diagnostics, git_tools, history_tools, merge_tools, qa_tools, shell_tools,
|
||||
status_tools, story_tools, timer_tools, trigger_tools, wizard_tools,
|
||||
};
|
||||
use crate::http::context::AppContext;
|
||||
|
||||
@@ -86,10 +86,14 @@ pub async fn dispatch_tool_call(
|
||||
"get_version" => diagnostics::tool_get_version(ctx),
|
||||
// Permission bridge (Claude Code → frontend dialog)
|
||||
"prompt_permission" => diagnostics::tool_prompt_permission(&args, ctx).await,
|
||||
"ask_question" => diagnostics::tool_ask_question(&args, ctx).await,
|
||||
// Token usage
|
||||
"get_token_usage" => diagnostics::tool_get_token_usage(&args, ctx),
|
||||
// Chat turn telemetry (story 1209)
|
||||
"chat_telemetry" => diagnostics::tool_chat_telemetry(&args),
|
||||
// Subject-scoped history (story 1236)
|
||||
"get_history" => history_tools::tool_get_history(&args),
|
||||
"get_history_entry" => history_tools::tool_get_history_entry(&args),
|
||||
// Delete story
|
||||
"delete_story" => story_tools::tool_delete_story(&args, ctx).await,
|
||||
// Purge story (CRDT tombstone — story 521)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! MCP tools for subject-scoped history queries (story 1236).
|
||||
//!
|
||||
//! `get_history` returns a time-ordered, cursor-paged list of short typed
|
||||
//! summaries for a subject (story, sled, or project); `get_history_entry`
|
||||
//! resolves the `ref` from one of those summaries into its full payload.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// MCP tool: return a paged, subject-scoped history listing.
|
||||
pub(crate) fn tool_get_history(args: &Value) -> Result<String, String> {
|
||||
let subject_type = args
|
||||
.get("subject_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("subject_type is required (one of: story, robot, project)")?;
|
||||
let subject_id = args
|
||||
.get("subject_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("subject_id is required")?;
|
||||
if !matches!(subject_type, "story" | "sled" | "robot" | "project") {
|
||||
return Err(format!(
|
||||
"subject_type must be one of: story, robot, project (got '{subject_type}')"
|
||||
));
|
||||
}
|
||||
let since = args.get("since").and_then(|v| v.as_i64());
|
||||
let until = args.get("until").and_then(|v| v.as_i64());
|
||||
let cursor = args.get("cursor").and_then(|v| v.as_str());
|
||||
let limit = args
|
||||
.get("limit")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as usize)
|
||||
.unwrap_or(50);
|
||||
|
||||
let page = crate::history::get_history(subject_type, subject_id, since, until, cursor, limit);
|
||||
|
||||
let entries: Vec<Value> = page
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
json!({
|
||||
"ref": e.entry_ref,
|
||||
"kind": e.kind,
|
||||
"subject_type": e.subject_type,
|
||||
"subject_id": e.subject_id,
|
||||
"at": e.at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
"summary": e.summary,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"entries": entries,
|
||||
"next_cursor": page.next_cursor,
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
/// MCP tool: resolve a `ref` from `get_history` into its full payload.
|
||||
pub(crate) fn tool_get_history_entry(args: &Value) -> Result<String, String> {
|
||||
let entry_ref = args
|
||||
.get("ref")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("ref is required (from a get_history entry)")?;
|
||||
|
||||
crate::history::get_history_entry(entry_ref)
|
||||
.ok_or_else(|| format!("No history entry found for ref '{entry_ref}'"))
|
||||
}
|
||||
@@ -12,6 +12,8 @@ pub mod diagnostics;
|
||||
pub mod dispatch;
|
||||
/// MCP tools for git operations scoped to agent worktrees.
|
||||
pub mod git_tools;
|
||||
/// MCP tools for subject-scoped history queries (`get_history`, `get_history_entry`).
|
||||
pub mod history_tools;
|
||||
/// MCP tools for merge status and merge-to-master operations.
|
||||
pub mod merge_tools;
|
||||
/// Task-local progress emitter used to deliver `notifications/progress`
|
||||
@@ -19,6 +21,10 @@ pub mod merge_tools;
|
||||
pub mod progress;
|
||||
/// MCP tools for QA request, approve, and reject workflows.
|
||||
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.
|
||||
pub mod shell_tools;
|
||||
/// MCP tools for pipeline status, story todos, and triage dump.
|
||||
@@ -71,6 +77,11 @@ pub async fn mcp_get_handler() -> Response {
|
||||
/// `tools/call`, and `notifications/*`.
|
||||
#[handler]
|
||||
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("");
|
||||
if !content_type.is_empty() && !content_type.contains("application/json") {
|
||||
return json_response(JsonRpcResponse::error(
|
||||
@@ -125,7 +136,7 @@ pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc<AppConte
|
||||
.and_then(|m| m.get("progressToken"))
|
||||
.cloned();
|
||||
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 +145,11 @@ pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc<AppConte
|
||||
"tools/list" => {
|
||||
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)),
|
||||
};
|
||||
|
||||
@@ -152,6 +167,7 @@ async fn sse_tools_call(
|
||||
params: Value,
|
||||
progress_token: Value,
|
||||
ctx: Arc<AppContext>,
|
||||
session_id: String,
|
||||
) -> Response {
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
@@ -174,9 +190,13 @@ async fn sse_tools_call(
|
||||
// its final state to the CRDT even on client disconnect).
|
||||
let dispatch_ctx = Arc::clone(&ctx);
|
||||
let dispatch_handle = tokio::spawn(async move {
|
||||
progress::EMITTER
|
||||
.scope(emitter, async move {
|
||||
dispatch::dispatch_tool_call(&tool_name, args, &dispatch_ctx).await
|
||||
session::SESSION_ID
|
||||
.scope(session_id, async move {
|
||||
progress::EMITTER
|
||||
.scope(emitter, async move {
|
||||
dispatch::dispatch_tool_call(&tool_name, args, &dispatch_ctx).await
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -382,6 +382,48 @@ mod tests {
|
||||
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]
|
||||
async fn tool_show_returns_story_data() {
|
||||
let tmp = tempdir().unwrap();
|
||||
|
||||
@@ -120,7 +120,10 @@ mod tests {
|
||||
assert!(names.contains(&"write"));
|
||||
assert!(names.contains(&"gc"));
|
||||
assert!(names.contains(&"chat_telemetry"));
|
||||
assert_eq!(tools.len(), 88);
|
||||
assert!(names.contains(&"ask_question"));
|
||||
assert!(names.contains(&"get_history"));
|
||||
assert!(names.contains(&"get_history_entry"));
|
||||
assert_eq!(tools.len(), 91);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -60,6 +60,40 @@ pub(super) fn system_tools() -> Vec<Value> {
|
||||
"required": ["tool_name", "input"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "ask_question",
|
||||
"description": "Ask the user a multiple-choice question via the active chat transport (e.g. Matrix). Renders as numbered options in chat protocols without a native question UI; the reply is parsed back (a number, a comma-separated list for multi_select, an option label, or free text) and returned here. Blocks until answered or timed out.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question": {
|
||||
"type": "string",
|
||||
"description": "The question to ask, e.g. 'Which approach should we use?'"
|
||||
},
|
||||
"header": {
|
||||
"type": "string",
|
||||
"description": "Short label for the question (e.g. 'Approach'). Defaults to 'Question'."
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {"type": "string"},
|
||||
"description": {"type": "string"}
|
||||
},
|
||||
"required": ["label", "description"]
|
||||
},
|
||||
"description": "At least 2 selectable options, each with a label and description."
|
||||
},
|
||||
"multi_select": {
|
||||
"type": "boolean",
|
||||
"description": "If true, the user may select multiple options (e.g. reply '1,3'). Default false."
|
||||
}
|
||||
},
|
||||
"required": ["question", "options"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "get_token_usage",
|
||||
"description": "Return per-agent token usage records from the persistent log. Shows input tokens, output tokens, cache tokens, and cost in USD for each agent session. Optionally filter by story_id.",
|
||||
@@ -86,6 +120,54 @@ pub(super) fn system_tools() -> Vec<Value> {
|
||||
}
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "get_history",
|
||||
"description": "Return a time-ordered, cursor-paged history for a subject (story, robot, or project) over an optional time range. Each entry is a short typed summary (chat_turn, agent_run, or pipeline_transition) plus a 'ref' string; fetch the full payload for one entry with get_history_entry.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subject_type": {
|
||||
"type": "string",
|
||||
"description": "One of: story, robot, project"
|
||||
},
|
||||
"subject_id": {
|
||||
"type": "string",
|
||||
"description": "Story ID, robot (sled) hex ID, or project/persona name"
|
||||
},
|
||||
"since": {
|
||||
"type": "integer",
|
||||
"description": "Optional Unix-second lower bound (inclusive)"
|
||||
},
|
||||
"until": {
|
||||
"type": "integer",
|
||||
"description": "Optional Unix-second upper bound (inclusive)"
|
||||
},
|
||||
"cursor": {
|
||||
"type": "string",
|
||||
"description": "Opaque cursor from a previous page's next_cursor to resume from"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of entries to return (default 50, max 500)"
|
||||
}
|
||||
},
|
||||
"required": ["subject_type", "subject_id"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "get_history_entry",
|
||||
"description": "Resolve a 'ref' string returned by get_history into its full payload.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"description": "The 'ref' value from a get_history entry"
|
||||
}
|
||||
},
|
||||
"required": ["ref"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "run_command",
|
||||
"description": "Execute a shell command in an agent's worktree directory. The working_dir must be inside .huskies/worktrees/. Returns stdout, stderr, exit_code, and timed_out. Supports SSE streaming (send Accept: text/event-stream) for long-running commands. Dangerous commands (rm -rf /, sudo, etc.) are blocked.",
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde_json::json;
|
||||
use std::fs;
|
||||
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_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() {
|
||||
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
|
||||
// the right endpoint even when HUSKIES_PORT changes between restarts.
|
||||
@@ -449,6 +453,34 @@ mod tests {
|
||||
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]
|
||||
async fn open_project_does_not_overwrite_existing_story_kit() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
@@ -64,6 +64,8 @@ pub(super) fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
|
||||
"store.json",
|
||||
"pipeline.db",
|
||||
"*.db",
|
||||
"merge_reports/",
|
||||
"session_store.json",
|
||||
];
|
||||
|
||||
let gitignore_path = root.join(".huskies").join(".gitignore");
|
||||
|
||||
@@ -20,6 +20,16 @@ use templates::{
|
||||
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> {
|
||||
let story_kit_root = root.join(".huskies");
|
||||
let specs_root = story_kit_root.join("specs");
|
||||
|
||||
@@ -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
|
||||
assert!(sk_content.contains("pipeline.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]
|
||||
|
||||
@@ -257,6 +257,15 @@ where
|
||||
} else {
|
||||
result.extend(cc_messages);
|
||||
}
|
||||
|
||||
let assistant_reply = result
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.role == Role::Assistant)
|
||||
.map(|m| m.content.as_str())
|
||||
.unwrap_or_default();
|
||||
crate::history::record_chat_turn(persona, &user_message, assistant_reply);
|
||||
|
||||
on_update(&result);
|
||||
return Ok(ChatResult {
|
||||
messages: result,
|
||||
|
||||
@@ -8,7 +8,6 @@ mod tests;
|
||||
use super::parse::{parse_assistant_message, parse_tool_results};
|
||||
use crate::agents::TokenUsage;
|
||||
use crate::llm::types::Message;
|
||||
use crate::slog;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use stream::handle_stream_event;
|
||||
|
||||
@@ -36,7 +35,7 @@ pub(super) fn process_json_event(
|
||||
// Capture session_id from the first event that carries it
|
||||
if let Some(tx) = sid_tx.take() {
|
||||
if let Some(sid) = json.get("session_id").and_then(|s| s.as_str()) {
|
||||
slog!("[pty-debug] CAPTURED session_id: {}", sid);
|
||||
crate::chatbot_log::global().push_line(&format!("CAPTURED session_id: {sid}"));
|
||||
let _ = tx.send(sid.to_string());
|
||||
} else {
|
||||
*sid_tx = Some(tx);
|
||||
@@ -45,7 +44,7 @@ pub(super) fn process_json_event(
|
||||
|
||||
// Detect authentication_failed at the top level of any event.
|
||||
if json.get("error").and_then(|e| e.as_str()) == Some("authentication_failed") {
|
||||
slog!("[pty-debug] Detected authentication_failed error");
|
||||
crate::chatbot_log::global().push_line("Detected authentication_failed error");
|
||||
auth_failed.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
|
||||
@@ -180,7 +180,8 @@ impl ClaudeCodeProvider {
|
||||
}
|
||||
|
||||
let captured_session_id = sid_rx.await.ok();
|
||||
slog!("[pty-debug] RECEIVED session_id: {:?}", captured_session_id);
|
||||
crate::chatbot_log::global()
|
||||
.push_line(&format!("RECEIVED session_id: {captured_session_id:?}"));
|
||||
let usage = usage_rx.await.ok();
|
||||
let structured_messages: Vec<Message> = msg_rx.try_iter().collect();
|
||||
|
||||
@@ -292,6 +293,14 @@ fn run_pty_session(
|
||||
// a tool requires user approval, instead of using PTY stdin/stdout.
|
||||
cmd.arg("--permission-prompt-tool");
|
||||
cmd.arg("mcp__huskies__prompt_permission");
|
||||
// AskUserQuestion requires an interactive terminal/IDE to render and
|
||||
// answer; every session this provider spawns is headless (stdin is
|
||||
// dropped below), so the tool is either unavailable or, if the model
|
||||
// attempts it anyway, degrades into raw tool-call syntax leaking into
|
||||
// the output text. Disallowing it forces the model to ask clarifying
|
||||
// questions as plain text instead (story 1228).
|
||||
cmd.arg("--disallowedTools");
|
||||
cmd.arg("AskUserQuestion");
|
||||
// Note: --system is not a valid Claude Code CLI flag. System-level
|
||||
// instructions (like bot name) are prepended to the user prompt instead.
|
||||
cmd.cwd(cwd);
|
||||
@@ -300,21 +309,22 @@ fn run_pty_session(
|
||||
// Allow nested spawning when the server itself runs inside Claude Code
|
||||
cmd.env("CLAUDECODE", "");
|
||||
|
||||
slog!(
|
||||
"[pty-debug] Spawning: claude -p \"{}\" {} {} --output-format stream-json --verbose --include-partial-messages --permission-prompt-tool mcp__huskies__prompt_permission",
|
||||
crate::chatbot_log::global().push_line(&format!(
|
||||
"Spawning: claude -p \"{}\" {} {} --output-format stream-json --verbose --include-partial-messages --permission-prompt-tool mcp__huskies__prompt_permission",
|
||||
user_message,
|
||||
resume_session_id
|
||||
.map(|s| format!("--resume {s}"))
|
||||
.unwrap_or_default(),
|
||||
model.map(|m| format!("--model {m}")).unwrap_or_default()
|
||||
);
|
||||
));
|
||||
|
||||
let mut child = pair
|
||||
.slave
|
||||
.spawn_command(cmd)
|
||||
.map_err(|e| format!("Failed to spawn claude: {e}"))?;
|
||||
|
||||
slog!("[pty-debug] Process spawned, pid: {:?}", child.process_id());
|
||||
crate::chatbot_log::global()
|
||||
.push_line(&format!("Process spawned, pid: {:?}", child.process_id()));
|
||||
drop(pair.slave);
|
||||
|
||||
let reader = pair
|
||||
@@ -331,23 +341,23 @@ fn run_pty_session(
|
||||
|
||||
let reader_handle = std::thread::spawn(move || {
|
||||
let buf_reader = BufReader::new(reader);
|
||||
slog!("[pty-debug] Reader thread started");
|
||||
crate::chatbot_log::global().push_line("Reader thread started");
|
||||
for line in buf_reader.lines() {
|
||||
match line {
|
||||
Ok(l) => {
|
||||
slog!("[pty-debug] raw line: {}", l);
|
||||
crate::chatbot_log::global().push_line(&format!("raw line: {l}"));
|
||||
if line_tx.send(Some(l)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
slog!("[pty-debug] read error: {e}");
|
||||
crate::chatbot_log::global().push_line(&format!("read error: {e}"));
|
||||
let _ = line_tx.send(None);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
slog!("[pty-debug] Reader thread done");
|
||||
crate::chatbot_log::global().push_line("Reader thread done");
|
||||
let _ = line_tx.send(None);
|
||||
});
|
||||
|
||||
@@ -374,7 +384,8 @@ fn run_pty_session(
|
||||
while !trimmed.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
slog!("[pty-debug] processing: {}...", &trimmed[..end]);
|
||||
crate::chatbot_log::global()
|
||||
.push_line(&format!("processing: {}...", &trimmed[..end]));
|
||||
|
||||
// Try to parse as JSON
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed)
|
||||
@@ -469,7 +480,9 @@ fn run_pty_session(
|
||||
&& let Some(ref status) = exit_status
|
||||
&& !status.success()
|
||||
{
|
||||
slog!("[pty-debug] Claude Code exited with non-zero status: {status}");
|
||||
crate::chatbot_log::global().push_line(&format!(
|
||||
"Claude Code exited with non-zero status: {status}"
|
||||
));
|
||||
return Err(format!("Claude Code crashed (exit status: {status})"));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ mod agent_log;
|
||||
mod agent_mode;
|
||||
mod agents;
|
||||
mod chat;
|
||||
/// Chat bot log — daily-rotated sink for the chat bot's PTY output, kept
|
||||
/// separate from the shared operational ring buffer and `server.log`.
|
||||
pub mod chatbot_log;
|
||||
#[cfg(test)]
|
||||
mod ci_publish_artifact;
|
||||
mod config;
|
||||
@@ -27,6 +30,13 @@ pub(crate) mod event_log;
|
||||
/// Gateway mode — multi-project reverse proxy that fronts multiple project containers.
|
||||
pub mod gateway;
|
||||
mod gateway_relay;
|
||||
/// Shared test helper for driving `git` as a subprocess and checking its
|
||||
/// exit status, used by `init_git_repo`-style helpers across the crate.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod git_test_support;
|
||||
/// History — subject-scoped, cursor-paged timeline over chat turns, agent
|
||||
/// runs, and pipeline transitions.
|
||||
pub(crate) mod history;
|
||||
mod http;
|
||||
mod io;
|
||||
mod llm;
|
||||
@@ -199,6 +209,15 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
return agent_mode::run(agent_root, rendezvous, port, join_token, agent_gateway_url).await;
|
||||
}
|
||||
|
||||
// Sled startup self-upgrade (story 1230): if the gateway has published a
|
||||
// newer artifact than this baked binary, swap to it and restart before
|
||||
// this process ever serves a request. Never blocks startup on failure.
|
||||
let self_upgrade_root = app_state.project_root.lock().unwrap().clone();
|
||||
if let Some(root) = self_upgrade_root {
|
||||
let self_upgrade_gateway_url = std::env::var("HUSKIES_GATEWAY_URL").ok();
|
||||
startup::self_upgrade::maybe_self_upgrade(&root, self_upgrade_gateway_url).await;
|
||||
}
|
||||
|
||||
// Event bus: broadcast channel for pipeline lifecycle events.
|
||||
let (watcher_tx, _) = broadcast::channel::<io::watcher::WatcherEvent>(1024);
|
||||
let agents = Arc::new(AgentPool::new(port, watcher_tx.clone()));
|
||||
@@ -219,6 +238,7 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
// Reconciliation progress channel and permission channel.
|
||||
let (reconciliation_tx, _) = broadcast::channel::<agents::ReconciliationEvent>(64);
|
||||
let (perm_tx, perm_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (question_tx, question_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
||||
let watcher_tx_for_bot = watcher_tx.clone();
|
||||
let watcher_rx_for_whatsapp = watcher_tx.subscribe();
|
||||
@@ -227,6 +247,8 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
|
||||
let permission_registry = service::permission_router::ResponderRegistry::new();
|
||||
service::permission_router::spawn_permission_router(perm_rx, Arc::clone(&permission_registry));
|
||||
let question_registry = service::question_router::QuestionResponderRegistry::new();
|
||||
service::question_router::spawn_question_router(question_rx, Arc::clone(&question_registry));
|
||||
let startup_root: Option<PathBuf> = app_state.project_root.lock().unwrap().clone();
|
||||
let startup_agents = Arc::clone(&agents);
|
||||
let startup_reconciliation_tx = reconciliation_tx.clone();
|
||||
@@ -256,6 +278,13 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
.as_ref()
|
||||
.map(|c| c.permission_timeout_secs)
|
||||
.unwrap_or(120),
|
||||
remembered_permissions: service::permission_router::RememberedPermissions::new(),
|
||||
question_registry: Arc::clone(&question_registry),
|
||||
pending_question_replies: service::question_router::PendingQuestionReplies::new(),
|
||||
question_timeout_secs: bot_cfg
|
||||
.as_ref()
|
||||
.map(|c| c.permission_timeout_secs)
|
||||
.unwrap_or(120),
|
||||
status: agents.status_broadcaster(),
|
||||
chat_dispatcher: std::sync::Arc::new(chat::dispatcher::ChatDispatcher::new(
|
||||
bot_cfg
|
||||
@@ -358,6 +387,7 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
watcher_tx,
|
||||
reconciliation_tx,
|
||||
perm_tx,
|
||||
question_tx,
|
||||
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
|
||||
bot_shutdown: bot_ctxs.shutdown_notifier.clone(),
|
||||
matrix_shutdown_tx: Some(Arc::clone(&bot_ctxs.matrix_shutdown_tx)),
|
||||
|
||||
@@ -141,6 +141,10 @@ pub(super) fn call_sync(
|
||||
permission_registry: ResponderRegistry::new(),
|
||||
pending_perm_replies: PendingPermReplies::new(),
|
||||
permission_timeout_secs: 120,
|
||||
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||
question_registry: crate::service::question_router::QuestionResponderRegistry::new(),
|
||||
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(),
|
||||
question_timeout_secs: 120,
|
||||
status: Arc::new(crate::service::status::StatusBroadcaster::new()),
|
||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||
});
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -3,20 +3,17 @@
|
||||
//! Extracted from `http/mcp/diagnostics.rs` following the conventions in
|
||||
//! `docs/architecture/service-modules.md`:
|
||||
//! - `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 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.
|
||||
pub mod permission;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use io::add_permission_rule;
|
||||
#[allow(unused_imports)]
|
||||
pub use permission::generate_permission_rule;
|
||||
#[allow(unused_imports)]
|
||||
pub use permission::is_dominated_by_wildcard;
|
||||
|
||||
// ── 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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -79,27 +64,4 @@ mod tests {
|
||||
let rule = generate_permission_rule("mcp__huskies__create_story", &json!({"name": "foo"}));
|
||||
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", &[]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,6 +741,10 @@ pub fn spawn_gateway_bot(
|
||||
.as_ref()
|
||||
.map(|c| c.permission_timeout_secs)
|
||||
.unwrap_or(120),
|
||||
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||
question_registry: crate::service::question_router::QuestionResponderRegistry::new(),
|
||||
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(),
|
||||
question_timeout_secs: 120,
|
||||
chat_dispatcher: std::sync::Arc::new(crate::chat::dispatcher::ChatDispatcher::new(
|
||||
bot_cfg
|
||||
.as_ref()
|
||||
|
||||
@@ -160,6 +160,12 @@ pub async fn proxy_mcp_via_ws(
|
||||
let timeout = std::time::Duration::from_millis(MCP_VIA_WS_TIMEOUT_MS);
|
||||
match tokio::time::timeout(timeout, rx).await {
|
||||
Ok(Ok(response_value)) => {
|
||||
if !is_valid_jsonrpc_response(&response_value) {
|
||||
return Err(
|
||||
"sled returned a malformed/incompatible MCP response (possible version mismatch)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
serde_json::to_vec(&response_value).map_err(|e| format!("serialise mcp_response: {e}"))
|
||||
}
|
||||
Ok(Err(_)) => Err("sled response channel dropped".to_string()),
|
||||
@@ -172,6 +178,21 @@ pub async fn proxy_mcp_via_ws(
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when `value` looks like a well-formed JSON-RPC 2.0 response
|
||||
/// (`jsonrpc: "2.0"` plus exactly one of `result`/`error`) — the shape the
|
||||
/// downstream `claude` CLI's MCP client expects on every `mcp_response`. A
|
||||
/// down or version-mismatched sled can send something else entirely (an
|
||||
/// empty object, a request instead of a response, ...); forwarding that
|
||||
/// verbatim risks the CLI's MCP parser crashing instead of surfacing a
|
||||
/// normal tool error (story 1232).
|
||||
fn is_valid_jsonrpc_response(value: &serde_json::Value) -> bool {
|
||||
let Some(obj) = value.as_object() else {
|
||||
return false;
|
||||
};
|
||||
obj.get("jsonrpc").and_then(|v| v.as_str()) == Some("2.0")
|
||||
&& (obj.contains_key("result") ^ obj.contains_key("error"))
|
||||
}
|
||||
|
||||
// ── Error type ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Typed errors returned by `service::gateway` functions.
|
||||
@@ -361,6 +382,28 @@ impl GatewayState {
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the URL of an explicitly named project (story 1229), without
|
||||
/// reading or mutating `active_project`.
|
||||
///
|
||||
/// Returns `Err` when the project is unknown or has no URL configured
|
||||
/// (WS-uplink only) — mirrors [`GatewayState::active_url`]'s error shape
|
||||
/// so callers can format both the same way.
|
||||
pub async fn url_for_project(&self, project: &str) -> Result<String, Error> {
|
||||
self.projects
|
||||
.read()
|
||||
.await
|
||||
.get(project)
|
||||
.ok_or_else(|| Error::ProjectNotFound(format!("unknown project '{project}'")))?
|
||||
.url
|
||||
.clone()
|
||||
.ok_or_else(|| {
|
||||
Error::ProjectNotFound(format!(
|
||||
"project '{project}' has no URL configured \
|
||||
(use sled-uplink WS or add url to projects.toml)"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Register a live sled connection for the given project.
|
||||
pub async fn register_sled_connection(&self, project_name: String, conn: SledConnection) {
|
||||
self.sled_connections
|
||||
@@ -858,6 +901,83 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── is_valid_jsonrpc_response / proxy_mcp_via_ws malformed handling
|
||||
// (story 1232) ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn is_valid_jsonrpc_response_accepts_well_formed_result() {
|
||||
assert!(is_valid_jsonrpc_response(&serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": { "content": [] }
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_jsonrpc_response_accepts_well_formed_error() {
|
||||
assert!(is_valid_jsonrpc_response(&serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": { "code": -32603, "message": "boom" }
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_jsonrpc_response_rejects_missing_jsonrpc_field() {
|
||||
assert!(!is_valid_jsonrpc_response(&serde_json::json!({
|
||||
"id": 1,
|
||||
"result": {}
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_jsonrpc_response_rejects_missing_result_and_error() {
|
||||
assert!(!is_valid_jsonrpc_response(&serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_jsonrpc_response_rejects_non_object() {
|
||||
assert!(!is_valid_jsonrpc_response(&serde_json::json!(
|
||||
"not an object"
|
||||
)));
|
||||
assert!(!is_valid_jsonrpc_response(&serde_json::json!(null)));
|
||||
}
|
||||
|
||||
/// End-to-end: a sled that responds with a payload that is valid JSON but
|
||||
/// not a well-formed JSON-RPC response (simulating a version-mismatched
|
||||
/// sled) must make `proxy_mcp_via_ws` return `Err`, not `Ok` with garbage
|
||||
/// bytes forwarded to the caller.
|
||||
#[tokio::test]
|
||||
async fn proxy_mcp_via_ws_errors_on_malformed_sled_response() {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<crate::sled_uplink::UplinkEnvelope>();
|
||||
let in_flight: Arc<
|
||||
TokioMutex<HashMap<String, tokio::sync::oneshot::Sender<serde_json::Value>>>,
|
||||
> = Arc::new(TokioMutex::new(HashMap::new()));
|
||||
let in_flight_task = Arc::clone(&in_flight);
|
||||
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(serde_json::json!({ "not": "jsonrpc" }));
|
||||
}
|
||||
}
|
||||
});
|
||||
let conn = SledConnection {
|
||||
tx,
|
||||
last_heartbeat_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())),
|
||||
in_flight,
|
||||
};
|
||||
|
||||
let result = proxy_mcp_via_ws(&conn, b"{}").await;
|
||||
let err = result.expect_err("malformed sled response must be surfaced as an error");
|
||||
assert!(
|
||||
err.contains("malformed") || err.contains("incompatible"),
|
||||
"error should explain the response was malformed/incompatible, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_uptime_secs_is_zero_or_positive_immediately_after_start() {
|
||||
// Just ensure it doesn't panic and returns a sane (small) value —
|
||||
|
||||
@@ -48,6 +48,10 @@ pub mod pipeline;
|
||||
pub mod project;
|
||||
/// QA — request, approve, and reject code reviews.
|
||||
pub mod qa;
|
||||
/// Question router — responder registry and pending-reply tracking for the
|
||||
/// MCP `ask_question` tool, kept separate from `permission_router` so a
|
||||
/// question answer is never conflated with a permission decision (story 1228).
|
||||
pub mod question_router;
|
||||
/// Project settings read/write and validation.
|
||||
pub mod settings;
|
||||
/// Shell command safety, sandboxing, and output helpers.
|
||||
|
||||
@@ -50,9 +50,9 @@ pub enum EventAction {
|
||||
/// Classify a [`WatcherEvent`] into the action the notification listener should take.
|
||||
pub fn classify(event: &WatcherEvent) -> EventAction {
|
||||
match event {
|
||||
// Stage-change notifications are now handled by the TransitionFired subscriber
|
||||
// (story 995). WorkItem events are skipped regardless of from_stage.
|
||||
WatcherEvent::WorkItem { .. } => EventAction::Skip,
|
||||
// Stage-change notifications are handled by the TransitionFired
|
||||
// subscriber (story 995/1235), not this watcher-event path — WorkItem
|
||||
// falls through to the `_` catch-all below.
|
||||
WatcherEvent::MergeFailure { .. } => EventAction::MergeFailure,
|
||||
WatcherEvent::RateLimitWarning { .. } => EventAction::RateLimitWarning,
|
||||
WatcherEvent::StoryBlocked { .. } => EventAction::StoryBlocked,
|
||||
@@ -79,30 +79,6 @@ pub fn classify(event: &WatcherEvent) -> EventAction {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn work_item(from_stage: Option<&str>) -> WatcherEvent {
|
||||
WatcherEvent::WorkItem {
|
||||
stage: "3_qa".to_string(),
|
||||
item_id: "1_story_foo".to_string(),
|
||||
action: "qa".to_string(),
|
||||
commit_msg: String::new(),
|
||||
from_stage: from_stage.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
// Stage-change notifications moved to TransitionFired subscriber (story 995).
|
||||
// All WorkItem events are now classified as Skip regardless of from_stage.
|
||||
#[test]
|
||||
fn work_item_with_from_stage_is_skip() {
|
||||
let event = work_item(Some("2_current"));
|
||||
assert_eq!(classify(&event), EventAction::Skip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_item_without_from_stage_is_skip() {
|
||||
let event = work_item(None);
|
||||
assert_eq!(classify(&event), EventAction::Skip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_failure_is_classified_correctly() {
|
||||
let event = WatcherEvent::MergeFailure {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
//! part of the server.
|
||||
|
||||
use crate::http::context::{PermissionDecision, PermissionForward};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -394,4 +445,36 @@ mod tests {
|
||||
let pending = PendingPermReplies::new();
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
//! Question router — mirrors `permission_router.rs`'s `ResponderRegistry` /
|
||||
//! pending-reply pattern for the MCP `ask_question` tool (story 1228).
|
||||
//!
|
||||
//! Kept as a fully separate registry and pending-reply store from
|
||||
//! `permission_router.rs` rather than reusing those types with a flag: AC4 of
|
||||
//! story 1228 requires that a chat reply answering a permission prompt is
|
||||
//! never treated as answering a pending question (and vice versa). Two
|
||||
//! independent stores make that conflation structurally impossible instead of
|
||||
//! relying on careful conditionals over a shared one.
|
||||
|
||||
use crate::http::context::{QuestionAnswer, QuestionForward};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use tokio::sync::{Mutex as TokioMutex, mpsc, oneshot};
|
||||
|
||||
/// Capacity of a single responder's private inbox channel.
|
||||
pub const QUESTION_RESPONDER_CHANNEL_CAPACITY: usize = 16;
|
||||
|
||||
struct ResponderSlot {
|
||||
id: u64,
|
||||
tx: mpsc::Sender<QuestionForward>,
|
||||
}
|
||||
|
||||
/// Tracks which tasks are currently registered to receive forwarded question
|
||||
/// requests. Mirrors `permission_router::ResponderRegistry` exactly, but for
|
||||
/// `ask_question` forwards instead of `prompt_permission` ones.
|
||||
pub struct QuestionResponderRegistry {
|
||||
next_id: AtomicU64,
|
||||
slots: StdMutex<Vec<ResponderSlot>>,
|
||||
}
|
||||
|
||||
impl QuestionResponderRegistry {
|
||||
/// Create an empty registry.
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
next_id: AtomicU64::new(0),
|
||||
slots: StdMutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// `true` when no responder is currently registered.
|
||||
///
|
||||
/// `tool_ask_question` uses this to fail closed immediately (returning a
|
||||
/// "no interactive session" result to the agent) instead of forwarding a
|
||||
/// question nobody is listening for.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.slots.lock().unwrap().is_empty()
|
||||
}
|
||||
|
||||
/// Register a new responder. Returns a private receiver for forwarded
|
||||
/// requests and an RAII guard — dropping the guard unregisters the
|
||||
/// responder.
|
||||
pub fn register(self: &Arc<Self>) -> (QuestionResponderGuard, mpsc::Receiver<QuestionForward>) {
|
||||
let (tx, rx) = mpsc::channel(QUESTION_RESPONDER_CHANNEL_CAPACITY);
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
self.slots.lock().unwrap().push(ResponderSlot { id, tx });
|
||||
(
|
||||
QuestionResponderGuard {
|
||||
id,
|
||||
registry: Arc::clone(self),
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
|
||||
/// Dispatch one forwarded request to the first responder that accepts it.
|
||||
///
|
||||
/// Never blocks: uses `try_send` against each registered responder in
|
||||
/// turn. If every responder's channel is full (or none are registered),
|
||||
/// the request is fail-closed with an error result rather than dropped
|
||||
/// silently.
|
||||
pub fn dispatch(&self, forward: QuestionForward) {
|
||||
let slots = self.slots.lock().unwrap().clone_senders();
|
||||
let mut remaining = forward;
|
||||
for tx in &slots {
|
||||
match tx.try_send(remaining) {
|
||||
Ok(()) => return,
|
||||
Err(mpsc::error::TrySendError::Full(fwd))
|
||||
| Err(mpsc::error::TrySendError::Closed(fwd)) => {
|
||||
remaining = fwd;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = remaining.response_tx.send(Err(
|
||||
"No interactive session is available to answer this question.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
fn unregister(&self, id: u64) {
|
||||
self.slots.lock().unwrap().retain(|s| s.id != id);
|
||||
}
|
||||
}
|
||||
|
||||
trait CloneSenders {
|
||||
fn clone_senders(&self) -> Vec<mpsc::Sender<QuestionForward>>;
|
||||
}
|
||||
|
||||
impl CloneSenders for Vec<ResponderSlot> {
|
||||
fn clone_senders(&self) -> Vec<mpsc::Sender<QuestionForward>> {
|
||||
self.iter().map(|s| s.tx.clone()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII guard returned by [`QuestionResponderRegistry::register`].
|
||||
pub struct QuestionResponderGuard {
|
||||
id: u64,
|
||||
registry: Arc<QuestionResponderRegistry>,
|
||||
}
|
||||
|
||||
impl Drop for QuestionResponderGuard {
|
||||
fn drop(&mut self) {
|
||||
self.registry.unregister(self.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the router task: the sole, permanent owner of the MCP-side receiver.
|
||||
/// Never awaits responder I/O — only [`QuestionResponderRegistry::dispatch`],
|
||||
/// which is itself non-blocking. Exits when `question_rx` closes (server
|
||||
/// shutdown).
|
||||
pub fn spawn_question_router(
|
||||
mut question_rx: mpsc::UnboundedReceiver<QuestionForward>,
|
||||
registry: Arc<QuestionResponderRegistry>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
while let Some(forward) = question_rx.recv().await {
|
||||
registry.dispatch(forward);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Pending replies ──────────────────────────────────────────────────────
|
||||
|
||||
/// A pending question awaiting a chat reply, together with the metadata a
|
||||
/// transport needs to parse that reply (option count, single/multi-select).
|
||||
struct PendingQuestion {
|
||||
tx: oneshot::Sender<Result<QuestionAnswer, String>>,
|
||||
num_options: usize,
|
||||
multi_select: bool,
|
||||
labels: Vec<String>,
|
||||
}
|
||||
|
||||
/// Tracks questions awaiting a chat reply, keyed by `request_id` with a
|
||||
/// per-location FIFO index — mirrors `permission_router::PendingPermReplies`.
|
||||
pub struct PendingQuestionReplies {
|
||||
inner: TokioMutex<PendingInner>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PendingInner {
|
||||
by_request_id: HashMap<String, PendingQuestion>,
|
||||
by_location: HashMap<String, VecDeque<String>>,
|
||||
}
|
||||
|
||||
impl PendingQuestionReplies {
|
||||
/// Create an empty tracker.
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
inner: TokioMutex::new(PendingInner::default()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Register a pending reply for `request_id`, queued under `location`.
|
||||
pub async fn insert(
|
||||
&self,
|
||||
location: impl Into<String>,
|
||||
request_id: impl Into<String>,
|
||||
num_options: usize,
|
||||
multi_select: bool,
|
||||
labels: Vec<String>,
|
||||
tx: oneshot::Sender<Result<QuestionAnswer, String>>,
|
||||
) {
|
||||
let request_id = request_id.into();
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner
|
||||
.by_location
|
||||
.entry(location.into())
|
||||
.or_default()
|
||||
.push_back(request_id.clone());
|
||||
inner.by_request_id.insert(
|
||||
request_id,
|
||||
PendingQuestion {
|
||||
tx,
|
||||
num_options,
|
||||
multi_select,
|
||||
labels,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Peek the metadata (option count, multi-select, option labels) of the
|
||||
/// oldest pending question queued for `location`, without consuming it.
|
||||
/// Used so an invalid reply can trigger a re-prompt without losing the
|
||||
/// pending request (AC4), and so a plain-text reply can be matched
|
||||
/// against option labels (AC2).
|
||||
pub async fn peek_oldest_meta(&self, location: &str) -> Option<(usize, bool, Vec<String>)> {
|
||||
let inner = self.inner.lock().await;
|
||||
let request_id = inner.by_location.get(location)?.front()?;
|
||||
inner
|
||||
.by_request_id
|
||||
.get(request_id)
|
||||
.map(|p| (p.num_options, p.multi_select, p.labels.clone()))
|
||||
}
|
||||
|
||||
/// Resolve the oldest pending question queued for `location`, removing it
|
||||
/// from both the location queue and the request_id map.
|
||||
pub async fn resolve_oldest(
|
||||
&self,
|
||||
location: &str,
|
||||
) -> Option<oneshot::Sender<Result<QuestionAnswer, String>>> {
|
||||
let mut inner = self.inner.lock().await;
|
||||
loop {
|
||||
let queue = inner.by_location.get_mut(location)?;
|
||||
let request_id = queue.pop_front()?;
|
||||
if queue.is_empty() {
|
||||
inner.by_location.remove(location);
|
||||
}
|
||||
if let Some(pending) = inner.by_request_id.remove(&request_id) {
|
||||
return Some(pending.tx);
|
||||
}
|
||||
// request_id was already removed (e.g. by a timeout) — try the
|
||||
// next queued entry for this location instead of returning None.
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a specific pending reply by `request_id`, also dequeuing it
|
||||
/// from `location`'s FIFO. Used by responder timeout tasks.
|
||||
pub async fn remove_by_request_id(
|
||||
&self,
|
||||
location: &str,
|
||||
request_id: &str,
|
||||
) -> Option<oneshot::Sender<Result<QuestionAnswer, String>>> {
|
||||
let mut inner = self.inner.lock().await;
|
||||
if let Some(queue) = inner.by_location.get_mut(location) {
|
||||
queue.retain(|id| id != request_id);
|
||||
if queue.is_empty() {
|
||||
inner.by_location.remove(location);
|
||||
}
|
||||
}
|
||||
inner.by_request_id.remove(request_id).map(|p| p.tx)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::context::QuestionSpec;
|
||||
|
||||
fn make_forward(
|
||||
request_id: &str,
|
||||
) -> (
|
||||
QuestionForward,
|
||||
oneshot::Receiver<Result<QuestionAnswer, String>>,
|
||||
) {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
(
|
||||
QuestionForward {
|
||||
request_id: request_id.to_string(),
|
||||
question: QuestionSpec {
|
||||
header: "Test".to_string(),
|
||||
question: "Pick one?".to_string(),
|
||||
options: vec![],
|
||||
multi_select: false,
|
||||
},
|
||||
response_tx: tx,
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registry_starts_empty() {
|
||||
let registry = QuestionResponderRegistry::new();
|
||||
assert!(registry.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_makes_registry_non_empty_until_guard_dropped() {
|
||||
let registry = QuestionResponderRegistry::new();
|
||||
let (guard, _rx) = registry.register();
|
||||
assert!(!registry.is_empty());
|
||||
drop(guard);
|
||||
assert!(registry.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_delivers_to_registered_responder() {
|
||||
let registry = QuestionResponderRegistry::new();
|
||||
let (_guard, mut rx) = registry.register();
|
||||
let (fwd, _response_rx) = make_forward("req-1");
|
||||
registry.dispatch(fwd);
|
||||
let received = rx.recv().await.expect("responder must receive forward");
|
||||
assert_eq!(received.request_id, "req-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_fails_closed_when_no_responder_registered() {
|
||||
let registry = QuestionResponderRegistry::new();
|
||||
let (fwd, response_rx) = make_forward("req-2");
|
||||
registry.dispatch(fwd);
|
||||
let result = response_rx.await.expect("must receive a result");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"no registered responder must fail-closed with an error"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_replies_two_concurrent_requests_same_location_both_resolve() {
|
||||
let pending = PendingQuestionReplies::new();
|
||||
let (tx1, rx1) = oneshot::channel();
|
||||
let (tx2, rx2) = oneshot::channel();
|
||||
pending
|
||||
.insert(
|
||||
"room-1",
|
||||
"req-a",
|
||||
3,
|
||||
false,
|
||||
vec!["A".to_string(), "B".to_string(), "C".to_string()],
|
||||
tx1,
|
||||
)
|
||||
.await;
|
||||
pending
|
||||
.insert("room-1", "req-b", 2, true, vec![], tx2)
|
||||
.await;
|
||||
|
||||
let first = pending
|
||||
.resolve_oldest("room-1")
|
||||
.await
|
||||
.expect("first pending reply must still be present");
|
||||
let _ = first.send(Ok(QuestionAnswer::Selected(vec![0])));
|
||||
assert_eq!(
|
||||
rx1.await.unwrap().unwrap(),
|
||||
QuestionAnswer::Selected(vec![0])
|
||||
);
|
||||
|
||||
let second = pending
|
||||
.resolve_oldest("room-1")
|
||||
.await
|
||||
.expect("second pending reply must still be present");
|
||||
let _ = second.send(Ok(QuestionAnswer::FreeText("other".to_string())));
|
||||
assert_eq!(
|
||||
rx2.await.unwrap().unwrap(),
|
||||
QuestionAnswer::FreeText("other".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn peek_oldest_meta_does_not_consume() {
|
||||
let pending = PendingQuestionReplies::new();
|
||||
let (tx, _rx) = oneshot::channel();
|
||||
pending
|
||||
.insert(
|
||||
"room-1",
|
||||
"req-a",
|
||||
4,
|
||||
true,
|
||||
vec!["X".to_string(), "Y".to_string()],
|
||||
tx,
|
||||
)
|
||||
.await;
|
||||
|
||||
let meta = pending.peek_oldest_meta("room-1").await;
|
||||
assert_eq!(
|
||||
meta,
|
||||
Some((4, true, vec!["X".to_string(), "Y".to_string()]))
|
||||
);
|
||||
|
||||
// Peeking again must return the same entry — it was not consumed.
|
||||
let meta_again = pending.peek_oldest_meta("room-1").await;
|
||||
assert_eq!(
|
||||
meta_again,
|
||||
Some((4, true, vec!["X".to_string(), "Y".to_string()]))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_by_request_id_prevents_later_resolution() {
|
||||
let pending = PendingQuestionReplies::new();
|
||||
let (tx, _rx) = oneshot::channel();
|
||||
pending
|
||||
.insert("room-1", "req-timeout", 2, false, vec![], tx)
|
||||
.await;
|
||||
|
||||
let removed = pending.remove_by_request_id("room-1", "req-timeout").await;
|
||||
assert!(removed.is_some());
|
||||
|
||||
let resolved = pending.resolve_oldest("room-1").await;
|
||||
assert!(resolved.is_none());
|
||||
assert!(pending.peek_oldest_meta("room-1").await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_oldest_returns_none_for_unknown_location() {
|
||||
let pending = PendingQuestionReplies::new();
|
||||
assert!(pending.resolve_oldest("no-such-room").await.is_none());
|
||||
}
|
||||
}
|
||||
+25
-1
@@ -7,7 +7,10 @@
|
||||
|
||||
use crate::agents::AgentPool;
|
||||
use crate::chat::dispatcher::ChatDispatcher;
|
||||
use crate::service::permission_router::{PendingPermReplies, ResponderRegistry};
|
||||
use crate::service::permission_router::{
|
||||
PendingPermReplies, RememberedPermissions, ResponderRegistry,
|
||||
};
|
||||
use crate::service::question_router::{PendingQuestionReplies, QuestionResponderRegistry};
|
||||
use crate::service::status::StatusBroadcaster;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
@@ -41,6 +44,23 @@ pub struct Services {
|
||||
/// Seconds to wait for a user to respond to a permission prompt before
|
||||
/// auto-denying (fail-closed).
|
||||
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>,
|
||||
/// Registry of tasks currently registered to receive forwarded MCP
|
||||
/// `ask_question` requests (story 1228). Kept fully separate from
|
||||
/// `permission_registry` so a question answer is never conflated with a
|
||||
/// permission decision.
|
||||
pub question_registry: Arc<QuestionResponderRegistry>,
|
||||
/// Pending question replies awaiting a chat reply, keyed by `request_id`
|
||||
/// with a per-location FIFO index, mirroring `pending_perm_replies` but
|
||||
/// for `ask_question` (story 1228).
|
||||
pub pending_question_replies: Arc<PendingQuestionReplies>,
|
||||
/// Seconds to wait for a user to answer a question before giving up
|
||||
/// (fail-closed): the MCP tool returns an error to the asking agent.
|
||||
pub question_timeout_secs: u64,
|
||||
/// Project-scoped status broadcaster.
|
||||
///
|
||||
/// Consumers (chat transports, Web UI, agent context) call
|
||||
@@ -73,6 +93,10 @@ impl Services {
|
||||
permission_registry: ResponderRegistry::new(),
|
||||
pending_perm_replies: PendingPermReplies::new(),
|
||||
permission_timeout_secs: 120,
|
||||
remembered_permissions: RememberedPermissions::new(),
|
||||
question_registry: QuestionResponderRegistry::new(),
|
||||
pending_question_replies: PendingQuestionReplies::new(),
|
||||
question_timeout_secs: 120,
|
||||
chat_dispatcher: std::sync::Arc::new(ChatDispatcher::new(1_500)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -483,6 +483,11 @@ mod tests {
|
||||
permission_registry: ResponderRegistry::new(),
|
||||
pending_perm_replies: PendingPermReplies::new(),
|
||||
permission_timeout_secs: 120,
|
||||
remembered_permissions: crate::service::permission_router::RememberedPermissions::new(),
|
||||
question_registry: crate::service::question_router::QuestionResponderRegistry::new(),
|
||||
pending_question_replies: crate::service::question_router::PendingQuestionReplies::new(
|
||||
),
|
||||
question_timeout_secs: 120,
|
||||
chat_dispatcher: Arc::new(crate::chat::dispatcher::ChatDispatcher::new(1_500)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,4 +2,7 @@
|
||||
|
||||
pub(crate) mod bots;
|
||||
pub(crate) mod project;
|
||||
/// Sled startup self-upgrade — check the gateway's published artifact hash
|
||||
/// and swap in place before serving if the baked binary is behind.
|
||||
pub(crate) mod self_upgrade;
|
||||
pub(crate) mod tick_loop;
|
||||
|
||||
@@ -228,6 +228,7 @@ pub(crate) async fn init_subsystems(app_state: &Arc<SessionState>, cwd: &Path, i
|
||||
if let Some(ref root) = *app_state.project_root.lock().unwrap() {
|
||||
let log_dir = root.join(".huskies").join("logs");
|
||||
let _ = std::fs::create_dir_all(&log_dir);
|
||||
crate::chatbot_log::global().set_log_dir(log_dir.clone());
|
||||
log_buffer::global().set_log_dir(log_dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
//! Sled startup self-upgrade — check the gateway's published artifact hash
|
||||
//! against this binary's own build hash and swap in place before serving.
|
||||
|
||||
use crate::slog;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Name of the on-disk marker recording the target hash of the last upgrade
|
||||
/// attempt, used to avoid retrying forever if the artifact never converges.
|
||||
const ATTEMPT_MARKER: &str = ".huskies_self_upgrade_attempt";
|
||||
|
||||
/// What startup should do after comparing the gateway's published artifact
|
||||
/// hash against this binary's own build hash.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum SelfUpgradeDecision {
|
||||
/// Gateway not configured, unreachable, or hash missing/invalid.
|
||||
Skip(String),
|
||||
/// Already on the published build.
|
||||
UpToDate,
|
||||
/// Hash differs and no prior attempt targeted this exact hash — upgrade.
|
||||
Upgrade { expected_hash: String },
|
||||
/// Hash still differs after a prior attempt already targeted this exact
|
||||
/// hash — stop instead of looping.
|
||||
AlreadyAttempted { expected_hash: String },
|
||||
}
|
||||
|
||||
/// Check the gateway's published artifact hash and self-upgrade in place if
|
||||
/// the baked binary is behind.
|
||||
///
|
||||
/// Called once from `main()` for standard "sled" mode, before the HTTP
|
||||
/// server starts serving. `gateway_url` is the caller's already-resolved
|
||||
/// `HUSKIES_GATEWAY_URL` (plain HTTP base, e.g.
|
||||
/// `http://host.docker.internal:3000`) — taken as a parameter rather than
|
||||
/// read from the env directly so tests can drive this deterministically
|
||||
/// without mutating shared process-global env state (env vars aren't
|
||||
/// per-test-isolated; this function is exercised concurrently with the rest
|
||||
/// of the suite). Any failure to reach the gateway or resolve a valid hash
|
||||
/// is logged as a warning and startup continues on the baked binary — this
|
||||
/// check must never hard-fail startup. On a genuine mismatch it fetches and
|
||||
/// swaps the binary, then exits so Docker restarts the container into the
|
||||
/// new build (mirrors `/api/upgrade`, `server/src/upgrade.rs`).
|
||||
pub async fn maybe_self_upgrade(project_root: &Path, gateway_url: Option<String>) {
|
||||
let Some(gateway_url) = gateway_url else {
|
||||
slog!("[self-upgrade] HUSKIES_GATEWAY_URL not set; skipping startup self-upgrade check.");
|
||||
return;
|
||||
};
|
||||
|
||||
let current_hash = option_env!("BUILD_GIT_HASH").unwrap_or("unknown");
|
||||
let base = gateway_url.trim_end_matches('/');
|
||||
let hash_url = format!(
|
||||
"{base}/api/artifacts/{}.hash",
|
||||
crate::http::SLED_ARTIFACT_NAME
|
||||
);
|
||||
let marker_path = project_root.join(ATTEMPT_MARKER);
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
slog!("[self-upgrade] Failed to build HTTP client: {e}; booting on baked binary.");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match resolve_decision(&client, &hash_url, current_hash, &marker_path).await {
|
||||
SelfUpgradeDecision::Skip(reason) => {
|
||||
slog!("[self-upgrade] {reason}; booting on baked binary ({current_hash}).");
|
||||
}
|
||||
SelfUpgradeDecision::UpToDate => {
|
||||
let _ = std::fs::remove_file(&marker_path);
|
||||
slog!("[self-upgrade] Already on published build ({current_hash}); no upgrade needed.");
|
||||
}
|
||||
SelfUpgradeDecision::AlreadyAttempted { expected_hash } => {
|
||||
slog!(
|
||||
"[self-upgrade] Already attempted upgrade to {expected_hash} and it did not \
|
||||
converge (still on {current_hash}); not retrying. Booting on baked binary."
|
||||
);
|
||||
}
|
||||
SelfUpgradeDecision::Upgrade { expected_hash } => {
|
||||
slog!(
|
||||
"[self-upgrade] Baked binary ({current_hash}) is behind published artifact \
|
||||
({expected_hash}); upgrading before serving."
|
||||
);
|
||||
let _ = std::fs::write(&marker_path, &expected_hash);
|
||||
|
||||
let artifact_url = format!("{base}/api/artifacts/{}", crate::http::SLED_ARTIFACT_NAME);
|
||||
let target = crate::upgrade::resolve_target_path();
|
||||
if let Err(e) = crate::upgrade::fetch_and_replace_binary(&artifact_url, &target).await {
|
||||
slog!(
|
||||
"[self-upgrade] Failed to fetch/replace binary: {e}; booting on baked binary."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
crate::rebuild::drain_and_exit(project_root, "self-upgrade").await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide what to do given the current state, without any process-exiting
|
||||
/// side effects — kept separate from [`maybe_self_upgrade`] so the decision
|
||||
/// logic is unit-testable.
|
||||
async fn resolve_decision(
|
||||
client: &reqwest::Client,
|
||||
hash_url: &str,
|
||||
current_hash: &str,
|
||||
marker_path: &Path,
|
||||
) -> SelfUpgradeDecision {
|
||||
let expected_hash = match fetch_expected_hash(client, hash_url).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => return SelfUpgradeDecision::Skip(e),
|
||||
};
|
||||
|
||||
if expected_hash == current_hash {
|
||||
return SelfUpgradeDecision::UpToDate;
|
||||
}
|
||||
|
||||
if let Ok(prev) = std::fs::read_to_string(marker_path)
|
||||
&& prev.trim() == expected_hash
|
||||
{
|
||||
return SelfUpgradeDecision::AlreadyAttempted { expected_hash };
|
||||
}
|
||||
|
||||
SelfUpgradeDecision::Upgrade { expected_hash }
|
||||
}
|
||||
|
||||
/// Fetch and validate the `.hash` sidecar from `hash_url`.
|
||||
///
|
||||
/// Returns `Err` with a human-readable reason for any of: connect failure,
|
||||
/// non-2xx response, empty body, or content that doesn't look like a git
|
||||
/// short hash (hex digits only).
|
||||
async fn fetch_expected_hash(client: &reqwest::Client, hash_url: &str) -> Result<String, String> {
|
||||
let resp = client
|
||||
.get(hash_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("gateway unreachable at {hash_url}: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!(
|
||||
"hash fetch returned HTTP {} from {hash_url}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("failed to read hash response from {hash_url}: {e}"))?;
|
||||
let hash = text.trim().to_string();
|
||||
|
||||
if hash.is_empty() {
|
||||
return Err(format!("hash sidecar at {hash_url} was empty"));
|
||||
}
|
||||
if !hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(format!(
|
||||
"hash sidecar at {hash_url} did not look like a git hash: {hash:?}"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Start a tiny HTTP server that serves `body` with `status` at `/`.
|
||||
async fn serve_text(status: u16, body: &'static str) -> (u16, tokio::task::JoinHandle<()>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut stream, _)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let mut buf = [0u8; 4096];
|
||||
let _ = stream.read(&mut buf).await;
|
||||
let status_line = match status {
|
||||
200 => "200 OK",
|
||||
404 => "404 Not Found",
|
||||
_ => "500 Internal Server Error",
|
||||
};
|
||||
let header = format!(
|
||||
"HTTP/1.1 {status_line}\r\nContent-Length: {}\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(header.as_bytes()).await;
|
||||
let _ = stream.write_all(body.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
(port, handle)
|
||||
}
|
||||
|
||||
fn test_client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ── resolve_decision ─────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn up_to_date_when_hash_matches() {
|
||||
let (port, _srv) = serve_text(200, "abc1234").await;
|
||||
let url = format!("http://127.0.0.1:{port}/hash");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let marker = dir.path().join(ATTEMPT_MARKER);
|
||||
|
||||
let decision = resolve_decision(&test_client(), &url, "abc1234", &marker).await;
|
||||
assert_eq!(decision, SelfUpgradeDecision::UpToDate);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upgrade_when_hash_differs_and_no_prior_attempt() {
|
||||
let (port, _srv) = serve_text(200, "def5678").await;
|
||||
let url = format!("http://127.0.0.1:{port}/hash");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let marker = dir.path().join(ATTEMPT_MARKER);
|
||||
|
||||
let decision = resolve_decision(&test_client(), &url, "abc1234", &marker).await;
|
||||
assert_eq!(
|
||||
decision,
|
||||
SelfUpgradeDecision::Upgrade {
|
||||
expected_hash: "def5678".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn already_attempted_when_marker_matches_expected_hash() {
|
||||
let (port, _srv) = serve_text(200, "def5678").await;
|
||||
let url = format!("http://127.0.0.1:{port}/hash");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let marker = dir.path().join(ATTEMPT_MARKER);
|
||||
std::fs::write(&marker, "def5678").unwrap();
|
||||
|
||||
let decision = resolve_decision(&test_client(), &url, "abc1234", &marker).await;
|
||||
assert_eq!(
|
||||
decision,
|
||||
SelfUpgradeDecision::AlreadyAttempted {
|
||||
expected_hash: "def5678".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upgrade_retried_when_marker_names_a_different_hash() {
|
||||
// A stale marker from a previous, different target hash must not
|
||||
// block upgrading to a newly-published one.
|
||||
let (port, _srv) = serve_text(200, "cafef00d").await;
|
||||
let url = format!("http://127.0.0.1:{port}/hash");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let marker = dir.path().join(ATTEMPT_MARKER);
|
||||
std::fs::write(&marker, "deadbeef").unwrap();
|
||||
|
||||
let decision = resolve_decision(&test_client(), &url, "abc1234", &marker).await;
|
||||
assert_eq!(
|
||||
decision,
|
||||
SelfUpgradeDecision::Upgrade {
|
||||
expected_hash: "cafef00d".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skip_when_gateway_unreachable() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let marker = dir.path().join(ATTEMPT_MARKER);
|
||||
let decision = resolve_decision(
|
||||
&test_client(),
|
||||
"http://127.0.0.1:1/hash",
|
||||
"abc1234",
|
||||
&marker,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(decision, SelfUpgradeDecision::Skip(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skip_when_hash_endpoint_404s() {
|
||||
let (port, _srv) = serve_text(404, "not found").await;
|
||||
let url = format!("http://127.0.0.1:{port}/hash");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let marker = dir.path().join(ATTEMPT_MARKER);
|
||||
|
||||
let decision = resolve_decision(&test_client(), &url, "abc1234", &marker).await;
|
||||
assert!(matches!(decision, SelfUpgradeDecision::Skip(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skip_when_hash_body_is_empty() {
|
||||
let (port, _srv) = serve_text(200, "").await;
|
||||
let url = format!("http://127.0.0.1:{port}/hash");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let marker = dir.path().join(ATTEMPT_MARKER);
|
||||
|
||||
let decision = resolve_decision(&test_client(), &url, "abc1234", &marker).await;
|
||||
assert!(matches!(decision, SelfUpgradeDecision::Skip(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skip_when_hash_body_is_not_hex() {
|
||||
let (port, _srv) = serve_text(200, "not-a-hash!!").await;
|
||||
let url = format!("http://127.0.0.1:{port}/hash");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let marker = dir.path().join(ATTEMPT_MARKER);
|
||||
|
||||
let decision = resolve_decision(&test_client(), &url, "abc1234", &marker).await;
|
||||
assert!(matches!(decision, SelfUpgradeDecision::Skip(_)));
|
||||
}
|
||||
|
||||
// ── maybe_self_upgrade (non-exiting paths only) ─────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn maybe_self_upgrade_skips_when_gateway_url_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Must return (not hang, not exit) when the gateway isn't configured.
|
||||
maybe_self_upgrade(dir.path(), None).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn maybe_self_upgrade_no_ops_when_already_up_to_date() {
|
||||
let current_hash = option_env!("BUILD_GIT_HASH").unwrap_or("unknown");
|
||||
let (port, _srv) = serve_text(200, current_hash).await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
maybe_self_upgrade(dir.path(), Some(format!("http://127.0.0.1:{port}"))).await;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,10 @@ pub(crate) fn spawn_event_bridges(
|
||||
// the history survives rebuild_and_restart and replicates across nodes.
|
||||
crate::event_log::spawn_event_log_subscriber();
|
||||
|
||||
// Unified history subscriber: persist every transition into the
|
||||
// subject-scoped history log alongside chat turns and agent runs (story 1236).
|
||||
crate::history::spawn_history_subscriber();
|
||||
|
||||
// CRDT → watcher bridge: translate CRDT stage-transition events into
|
||||
// WatcherEvent::WorkItem so downstream consumers (WebSocket, auto-assign)
|
||||
// see a uniform stream regardless of whether the event originated from the
|
||||
@@ -588,6 +592,10 @@ pub(crate) async fn run_reconcile_pass(
|
||||
// Content-GC: purge content-store entries for terminal/tombstoned stories.
|
||||
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.
|
||||
crate::agents::pool::worktree_lifecycle::reconcile_worktree_create(root, agents.port()).await;
|
||||
|
||||
@@ -749,12 +757,16 @@ mod tests {
|
||||
"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 {
|
||||
let id = format!("1066_abandoned_{i:04}");
|
||||
assert!(
|
||||
crate::db::read_content(ContentKey::Story(&id)).is_none(),
|
||||
"zombie content must be purged for abandoned story {id}"
|
||||
crate::db::read_content(ContentKey::Story(&id)).is_some(),
|
||||
"story content must be retained for abandoned story {id} (story 1222)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,16 +177,32 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn init_git_repo(dir: &std::path::Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git init");
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git commit");
|
||||
use crate::git_test_support::git_ok;
|
||||
git_ok(
|
||||
Command::new("git").args(["init"]).current_dir(dir).output(),
|
||||
"git init",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git config user.email",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git config user.name",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git commit",
|
||||
);
|
||||
}
|
||||
|
||||
fn empty_config() -> ProjectConfig {
|
||||
|
||||
@@ -53,7 +53,7 @@ pub async fn create_worktree(
|
||||
tokio::task::spawn_blocking(move || configure_sparse_checkout(&wt_clone))
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking: {e}"))??;
|
||||
write_mcp_json(&wt_path, port)?;
|
||||
write_mcp_json(&wt_path, port, story_id)?;
|
||||
return Ok(WorktreeInfo {
|
||||
path: wt_path,
|
||||
branch,
|
||||
@@ -68,7 +68,7 @@ pub async fn create_worktree(
|
||||
.await
|
||||
.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;
|
||||
|
||||
Ok(WorktreeInfo {
|
||||
@@ -216,16 +216,32 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn init_git_repo(dir: &Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git init");
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git commit");
|
||||
use crate::git_test_support::git_ok;
|
||||
git_ok(
|
||||
Command::new("git").args(["init"]).current_dir(dir).output(),
|
||||
"git init",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git config user.email",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git config user.name",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git commit",
|
||||
);
|
||||
}
|
||||
|
||||
fn empty_config() -> ProjectConfig {
|
||||
|
||||
+38
-13
@@ -64,11 +64,20 @@ pub(crate) fn create_worktree_sync(
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
|
||||
// Try to create branch. If it already exists that's fine.
|
||||
let _ = Command::new("git")
|
||||
// Try to create branch. If it already exists that's fine; any other
|
||||
// failure (e.g. unborn HEAD, invalid branch name) must surface here
|
||||
// rather than resurface later as a confusing "git worktree add" error.
|
||||
let output = Command::new("git")
|
||||
.args(["branch", branch])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
.output()
|
||||
.map_err(|e| format!("git branch: {e}"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !stderr.contains("already exists") {
|
||||
return Err(format!("git branch failed: {stderr}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Create worktree
|
||||
let output = Command::new("git")
|
||||
@@ -227,16 +236,32 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn init_git_repo(dir: &Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git init");
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git commit");
|
||||
use crate::git_test_support::git_ok;
|
||||
git_ok(
|
||||
Command::new("git").args(["init"]).current_dir(dir).output(),
|
||||
"git init",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git config user.email",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git config user.name",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git commit",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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
|
||||
/// HTTP MCP endpoint at the given port.
|
||||
pub fn write_mcp_json(dir: &Path, port: u16) -> Result<(), String> {
|
||||
let content = format!(
|
||||
"{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n"
|
||||
);
|
||||
///
|
||||
/// Embeds an `X-Huskies-Session` header set to `story_id` so the server can
|
||||
/// 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}"))
|
||||
}
|
||||
|
||||
@@ -91,7 +104,7 @@ mod tests {
|
||||
#[test]
|
||||
fn write_mcp_json_uses_given_port() {
|
||||
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();
|
||||
assert!(content.contains("http://localhost:4242/mcp"));
|
||||
}
|
||||
@@ -99,11 +112,23 @@ mod tests {
|
||||
#[test]
|
||||
fn write_mcp_json_default_port() {
|
||||
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();
|
||||
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]
|
||||
fn worktree_path_is_inside_project() {
|
||||
let project_root = Path::new("/home/user/my-project");
|
||||
|
||||
@@ -74,16 +74,32 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn init_git_repo(dir: &std::path::Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git init");
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git commit");
|
||||
use crate::git_test_support::git_ok;
|
||||
git_ok(
|
||||
Command::new("git").args(["init"]).current_dir(dir).output(),
|
||||
"git init",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git config user.email",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git config user.name",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git commit",
|
||||
);
|
||||
}
|
||||
|
||||
fn empty_config() -> ProjectConfig {
|
||||
|
||||
@@ -98,16 +98,32 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn init_git_repo(dir: &Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git init");
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git commit");
|
||||
use crate::git_test_support::git_ok;
|
||||
git_ok(
|
||||
Command::new("git").args(["init"]).current_dir(dir).output(),
|
||||
"git init",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git config user.email",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git config user.name",
|
||||
);
|
||||
git_ok(
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output(),
|
||||
"git commit",
|
||||
);
|
||||
}
|
||||
|
||||
fn empty_config() -> ProjectConfig {
|
||||
|
||||
Reference in New Issue
Block a user