Compare commits

...
46 Commits
Author SHA1 Message Date
Timmy 69cf867969 Bump version to 0.14.5
Publish sled artifact / publish-dev-artifact (push) Canceled after 0s
2026-07-24 17:41:05 +01:00
Huskies Agent b43def12f7 huskies: regen source-map.json 2026-07-21 20:19:29 +00:00
Huskies Agent c3cad6b3d8 huskies: merge 1244 refactor Deduplicate the worktree module cluster (cleanup/remove/sweep/create/lifecycle share ~60-line clones) 2026-07-21 20:19:29 +00:00
Huskies Agent c58e204e8b huskies: regen source-map.json 2026-07-21 17:51:35 +00:00
Huskies Agent 8b2dd21e22 huskies: merge 1247 refactor Deduplicate chat-transport LLM command handling (discord <-> whatsapp 45-line clone) 2026-07-21 17:51:35 +00:00
Huskies Agent 326b2b4a32 huskies: regen source-map.json 2026-07-21 17:29:35 +00:00
Huskies Agent 10dd239c92 huskies: merge 1246 refactor Extract shared LLM-runtime logic duplicated between gemini and openai runtimes 2026-07-21 17:29:35 +00:00
Huskies Agent 1583ade9fc huskies: merge 1245 refactor Deduplicate validation/requests.rs — five internal ~50-85 line self-clones 2026-07-21 16:55:10 +00:00
Huskies Agent 5c3b433e7d huskies: merge 1251 story script/check gates duplication and cognitive complexity 2026-07-21 16:15:10 +00:00
Huskies Agent 14331127b8 huskies: regen source-map.json 2026-07-21 16:08:03 +00:00
Huskies Agent 7de8fa1924 huskies: merge 1249 story Checkpoint the CRDT snapshot periodically, not once by accident 2026-07-21 16:08:03 +00:00
Huskies Agent 82d9e3c460 huskies: regen source-map.json 2026-07-21 15:25:35 +00:00
Huskies Agent 013a5da60b huskies: merge 1243 refactor Merge jobs stop bloating replicated CRDT state 2026-07-21 15:25:34 +00:00
Huskies Agent 2ea633a2f1 huskies: merge 1242 refactor script/release builds project images so they can't drift 2026-07-21 15:10:39 +00:00
Huskies Agent b9e0a16bf8 huskies: regen source-map.json 2026-07-21 14:55:49 +00:00
Huskies Agent 78b2e7a9a2 huskies: merge 1250 bug Bug fields steps_to_reproduce, actual_result and expected_result cannot be edited after creation 2026-07-21 14:55:49 +00:00
Huskies Agent d06f5b5410 huskies: merge 1241 bug CRDT snapshot has no schema migration; a failed load silently starts empty 2026-07-21 14:47:40 +00:00
Timmy 40fb6367c1 Bump version to 0.14.4
Publish sled artifact / publish-dev-artifact (push) Canceled after 0s
2026-07-21 14:51:39 +01:00
Huskies Agent 1859e79491 huskies: regen source-map.json 2026-07-21 13:46:43 +00:00
Huskies Agent 5eeb036875 huskies: merge 1248 bug Duplicate Working line: obsolete digging-in watcher survives alongside 1240 placeholder 2026-07-21 13:46:43 +00:00
Timmy 1de05b480b Bump version to 0.14.3
Publish sled artifact / publish-dev-artifact (push) Canceled after 0s
2026-07-21 13:34:16 +01:00
Huskies Agent c906707a4f huskies: merge 1240 story Matrix bot shows live progress by editing its placeholder message 2026-07-21 12:17:42 +00:00
Huskies Agent afde373676 huskies: regen source-map.json 2026-07-21 12:10:20 +00:00
Huskies Agent 8a32c1ffd8 huskies: merge 1238 bug worktree tests fail on macOS: tests clobber global HOME 2026-07-21 12:10:20 +00:00
Huskies Agent 22710571df huskies: regen source-map.json 2026-07-21 11:42:44 +00:00
Huskies Agent 6fbd755846 huskies: merge 1239 story Matrix bot posts \"Working...\" immediately on message receipt 2026-07-21 11:42:44 +00:00
Timmy df1e339b47 Removing hardcoded huskies session 2026-07-21 11:29:57 +01:00
Timmy b55816d0a5 Bump version to 0.15.3
Publish sled artifact / publish-dev-artifact (push) Canceled after 0s
2026-07-20 17:14:24 +01:00
Huskies Agent eac57c6757 huskies: regen source-map.json 2026-07-20 14:44:32 +00:00
Huskies Agent c9804ecfdf huskies: merge 1236 story Ask "what happened with X" and get a paged, subject-scoped history 2026-07-20 14:44:32 +00:00
Huskies Agent 30ca3ad463 huskies: merge 1235 bug Notifications fire off the filesystem watcher, not the state machine — story 995's TransitionFired subscriber was never wired into startup 2026-07-20 14:36:49 +00:00
Huskies Agent f7b7f21e88 huskies: merge 1237 bug status resolves the story number against a different project than show 2026-07-20 14:30:23 +00:00
Huskies Agent 7ac5bd196f huskies: regen source-map.json 2026-07-19 21:30:50 +00:00
Huskies Agent 30b0d31500 huskies: merge 1230 story Sleds self-upgrade on startup to the gateway's published artifact if the baked binary is behind 2026-07-19 21:30:50 +00:00
Huskies Agent 2b9fcf2878 huskies: regen source-map.json 2026-07-19 20:47:19 +00:00
Huskies Agent 7bbd34bc3a huskies: merge 1231 story project-rebuild must not silently downgrade a live sled to the stale image binary 2026-07-19 20:47:19 +00:00
Huskies Agent 3f05648d25 huskies: regen source-map.json 2026-07-19 19:23:17 +00:00
Huskies Agent f4f0981f17 huskies: merge 1228 story Render agent questions as numbered options in chat protocols without question UI 2026-07-19 19:23:16 +00:00
Huskies Agent 933fb5a54b huskies: merge 1232 bug Gateway chat bot crashes (CLI exit 1) on a sled MCP tool error instead of surfacing it 2026-07-19 18:12:33 +00:00
Huskies Agent 7b3990430e huskies: regen source-map.json 2026-07-19 15:41:49 +00:00
Huskies Agent caf9953634 huskies: merge 1229 bug Explicit project arg ignored on the SSE MCP path — 1225's routing and create-guard are bypassed 2026-07-19 15:41:49 +00:00
Timmy c7cb3172f1 Ignoring some more files 2026-07-19 15:36:30 +01:00
Timmy 3198309db5 Bump version to 0.14.2
Publish sled artifact / publish-dev-artifact (push) Canceled after 0s
2026-07-19 00:31:16 +01:00
Huskies Agent 59481515b5 huskies: regen source-map.json 2026-07-18 19:56:30 +00:00
Huskies Agent a6cce683f5 huskies: merge 1222 bug show fails with "content unavailable" on done/archived stories — content should never be evicted 2026-07-18 19:56:30 +00:00
Huskies Agent 2b0e8e6f10 huskies: merge 1225 bug Work-item tools use active project as an implicit global; project arg ignored, creates/reads misfile silently 2026-07-18 19:20:46 +00:00
109 changed files with 6739 additions and 2555 deletions
+4
View File
@@ -38,3 +38,7 @@ session_store.json
# Full untruncated merge attempt reports (one file per attempt, pruned automatically)
merge_reports/
logs/
wizard_state.json
store.json
*.db
+130 -8
View File
@@ -767,6 +767,25 @@
"server/src/agents/pty/types.rs": [
"struct PtyResult"
],
"server/src/agents/runtime/api_common.rs": [
"struct CancellationFlag",
"fn new",
"fn handle",
"fn stop",
"fn status",
"fn make_emit",
"fn zero_usage",
"fn emit_running",
"fn start_conversation_loop",
"fn api_runtime_result",
"fn done_result",
"fn check_loop_guard",
"fn extract_model",
"fn build_system_text",
"fn clean_schema_properties",
"fn test_app_ctx",
"fn test_runtime_context"
],
"server/src/agents/runtime/claude_code.rs": [
"struct ClaudeCodeRuntime",
"fn new"
@@ -931,7 +950,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",
@@ -946,6 +969,12 @@
"fn load_chat_history",
"fn save_chat_history"
],
"server/src/chat/llm_turn.rs": [
"trait TransportFormat",
"fn build_llm_prompt",
"struct LlmTurnContext",
"fn run_llm_turn"
],
"server/src/chat/lookup.rs": [
"fn find_story_by_number"
],
@@ -954,6 +983,7 @@
"mod compact",
"mod dispatcher",
"mod history",
"mod llm_turn",
"mod lookup",
"mod test_helpers",
"mod timer",
@@ -1037,7 +1067,7 @@
"fn is_addressed_to_other"
],
"server/src/chat/transport/matrix/bot/messages/handle_message.rs": [
"fn spawn_digging_in_watcher",
"const DIGGING_IN_MESSAGE",
"fn handle_message"
],
"server/src/chat/transport/matrix/bot/messages/mod.rs": [
@@ -1054,12 +1084,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"
],
@@ -1093,7 +1128,6 @@
"fn default_compact_seed_max_bytes",
"fn default_cache_read_suggest_threshold",
"fn default_compact_suggest_cooldown_secs",
"fn default_digging_in_threshold_secs",
"fn default_transport",
"fn default_whatsapp_provider",
"struct BotConfig"
@@ -1188,8 +1222,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 +1348,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 +1356,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",
@@ -1333,6 +1378,7 @@
"mod agent_name",
"struct ProjectConfig",
"struct WatcherConfig",
"fn default_snapshot_interval_ops",
"struct DiskWatchConfig",
"struct ComponentConfig",
"struct AgentConfig",
@@ -1401,6 +1447,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",
@@ -1488,7 +1539,8 @@
"server/src/crdt_state/state/init.rs": [
"enum PersistMsg",
"fn init",
"fn flush_persistence"
"fn flush_persistence",
"fn checkpoint_on_shutdown"
],
"server/src/crdt_state/state/mod.rs": [
"fn subscribe",
@@ -1504,6 +1556,10 @@
"static SYNC_TX",
"static ALL_OPS",
"static VECTOR_CLOCK",
"static CRDT_POOL",
"static SNAPSHOT_INTERVAL_OPS",
"static OPS_SINCE_SNAPSHOT",
"static LAST_SEQ",
"static ALL_OPS_TL",
"static VECTOR_CLOCK_TL",
"fn all_ops_lock",
@@ -1516,6 +1572,7 @@
"struct GatewayConfigCrdt",
"struct PipelineDoc",
"struct EventLogEntryCrdt",
"struct HistoryEntryCrdt",
"struct LlmSessionCrdt",
"enum ScopeFilter",
"fn from_scope_str",
@@ -1671,16 +1728,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 +1793,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 +1897,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 +1914,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,6 +1929,7 @@
"mod diagnostics",
"mod dispatch",
"mod git_tools",
"mod history_tools",
"mod merge_tools",
"mod progress",
"mod qa_tools",
@@ -2017,6 +2104,7 @@
],
"server/src/http/workflow/bug_ops/bug.rs": [
"fn create_bug_file",
"fn update_bug_fields_in_file",
"fn is_bug_item",
"fn extract_bug_name_from_content",
"fn list_bug_files"
@@ -2341,12 +2429,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",
@@ -2733,6 +2824,7 @@
"struct GatewayState",
"fn new",
"fn active_url",
"fn url_for_project",
"fn register_sled_connection",
"fn deregister_sled_connection",
"fn sled_connection_for",
@@ -2831,7 +2923,10 @@
"const SUMMARY_TAIL_LINES",
"fn extract_failing_test_names",
"fn summarize_merge_result",
"fn summarize_merge_failure_kind"
"fn summarize_merge_failure_kind",
"fn bound_result_for_storage",
"fn bound_report_for_storage",
"fn bound_plain_error"
],
"server/src/service/mod.rs": [
"mod agents",
@@ -2854,6 +2949,7 @@
"mod pipeline",
"mod project",
"mod qa",
"mod question_router",
"mod settings",
"mod shell",
"mod status",
@@ -3024,6 +3120,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",
@@ -3291,6 +3403,7 @@
"server/src/startup/mod.rs": [
"mod bots",
"mod project",
"mod self_upgrade",
"mod tick_loop"
],
"server/src/startup/project.rs": [
@@ -3298,6 +3411,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",
@@ -3433,6 +3549,7 @@
"fn migrate_slug_paths"
],
"server/src/worktree/mod.rs": [
"mod test_support",
"struct WorktreeInfo",
"struct WorktreeListEntry",
"fn worktree_path",
@@ -3447,5 +3564,10 @@
"server/src/worktree/sweep.rs": [
"fn worktree_should_be_swept",
"fn sweep_with_lookup"
],
"server/src/worktree/test_support.rs": [
"fn init_git_repo",
"fn empty_config",
"fn done_stage"
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"threshold": 10,
"minLines": 10,
"minTokens": 50,
"ignore": [
"**/target/**",
"**/node_modules/**",
"**/dist/**",
"**/*.svg",
"**/flamegraphs/**"
]
}
Generated
+99 -87
View File
@@ -67,9 +67,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ammonia"
version = "4.1.3"
version = "4.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68b9d3370580a12f4b7a10fdcc18b28942c083ba570e3d954fe59d10951b85a2"
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
dependencies = [
"cssparser",
"html5ever",
@@ -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.3",
]
[[package]]
@@ -456,9 +456,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.3.0"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -583,9 +583,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.6.2"
version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
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"
@@ -1925,7 +1925,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "huskies"
version = "0.14.1"
version = "0.14.5"
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.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[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.3",
]
[[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]]
@@ -4080,9 +4080,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.15.0"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"web-time",
"zeroize",
@@ -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.3",
]
[[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.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
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.3",
]
[[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",
@@ -5060,9 +5071,9 @@ dependencies = [
[[package]]
name = "tokio-stream"
version = "0.1.18"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
dependencies = [
"futures-core",
"pin-project-lite",
@@ -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",
+7
View File
@@ -0,0 +1,7 @@
# cognitive_complexity is allow-by-default in clippy; script/check enables it
# with `-W clippy::cognitive_complexity`. This threshold is set well above
# clippy's own default (25) to accommodate existing large dispatch functions
# (e.g. Matrix bot command routing) without requiring an unrelated refactor;
# it still gates against genuinely runaway complexity introduced going
# forward.
cognitive-complexity-threshold = 200
+5
View File
@@ -88,6 +88,11 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
# Claude Code CLI in runtime
RUN npm install -g @anthropic-ai/claude-code
# jscpd — duplication detector used by script/check. Installed in the
# runtime stage (not just the base build stage) so it's available to agents
# running script/check inside the sled, not only on a developer machine.
RUN npm install -g jscpd
# Cargo and Rust toolchain needed at runtime for:
# - rebuild_and_restart (cargo build inside the container)
# - Agent-driven cargo commands (cargo clippy, cargo test, etc.)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "huskies",
"version": "0.14.1",
"version": "0.14.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "huskies",
"version": "0.14.1",
"version": "0.14.5",
"dependencies": {
"@types/react-syntax-highlighter": "^15.5.13",
"react": "^19.1.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "huskies",
"private": true,
"version": "0.14.1",
"version": "0.14.5",
"type": "module",
"scripts": {
"dev": "vite",
+4 -2
View File
@@ -4,8 +4,10 @@ set -euo pipefail
# Build all project images in dependency order:
# huskies → huskies-project-base → huskies-project-<stack> (one per stack fragment)
#
# 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.
# Called automatically by `script/release` (story 1242) so the huskies-project-*
# images never drift from the version being published. Also safe to run
# standalone after `script/docker_rebuild` or whenever you add a new stack —
# each step re-tags the image with the latest layers.
cd "$(dirname "$0")/.."
+18 -4
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# Pre-commit quality gate: fmt-check, clippy, cargo check, and doc-coverage.
# Run this before committing to catch fmt drift, clippy warnings, compile
# errors, and missing doc comments without waiting for the full test suite.
# Pre-commit quality gate: fmt-check, clippy, duplication, cargo check, and
# doc-coverage. Run this before committing to catch fmt drift, clippy
# warnings, duplicate code, compile errors, and missing doc comments without
# waiting for the full test suite.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -11,7 +12,20 @@ echo "=== Checking Rust formatting ==="
cargo fmt --manifest-path "$PROJECT_ROOT/Cargo.toml" --all --check
echo "=== Running cargo clippy ==="
cargo clippy --manifest-path "$PROJECT_ROOT/Cargo.toml" --workspace --all-targets -- -D warnings
# clippy::cognitive_complexity is allow-by-default; -W activates it so that
# -D warnings turns violations into a hard failure. The threshold it's
# measured against lives in clippy.toml (committed, not passed ad hoc).
cargo clippy --manifest-path "$PROJECT_ROOT/Cargo.toml" --workspace --all-targets -- -W clippy::cognitive_complexity -D warnings
echo "=== Checking code duplication (jscpd) ==="
if ! command -v jscpd &>/dev/null; then
echo "FAIL: jscpd is not installed. Install it with 'npm install -g jscpd' before running script/check." >&2
exit 1
fi
# Duplication threshold lives in .jscpd.json (committed, not passed ad hoc).
# jscpd exits non-zero automatically when duplication exceeds that threshold.
jscpd --config "$PROJECT_ROOT/.jscpd.json" \
"$PROJECT_ROOT/server/src" "$PROJECT_ROOT/frontend/src" "$PROJECT_ROOT/crates"
echo "=== Checking doc coverage on changed files ==="
cargo run --manifest-path "$PROJECT_ROOT/Cargo.toml" -p source-map-gen --bin source-map-check --quiet -- --worktree "$PROJECT_ROOT" --base master
+13
View File
@@ -87,6 +87,19 @@ cross build --release --target x86_64-unknown-linux-musl
echo "==> Building Linux arm64 (static musl via cross)..."
cross build --release --target aarch64-unknown-linux-musl
# ── Build project images ─────────────────────────────────────────
# Rebuild the huskies-project-* Docker images from this exact source tree
# (the version-bump commit above already landed, so build.rs's `git
# rev-parse HEAD` embeds the matching git hash) so they never drift from
# the binary being published below. A release that can't produce these
# images fails loudly here, before anything is tagged, pushed, or published.
echo "==> Building project images..."
if ! "${SCRIPT_DIR}/script/build-project-images"; then
echo "Error: failed to build huskies-project-* images at ${VERSION}."
echo "Release aborted — nothing was tagged, pushed, or published."
exit 1
fi
# ── Package ────────────────────────────────────────────────────
DIST="target/dist"
rm -rf "$DIST"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "huskies"
version = "0.14.1"
version = "0.14.5"
edition = "2024"
build = "build.rs"
+10
View File
@@ -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"),
));
@@ -83,6 +89,9 @@ pub(super) fn build_agent_app_context(
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)),
});
@@ -96,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
View File
@@ -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 ────────────────────────
+29 -20
View File
@@ -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]
+29 -20
View File
@@ -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 ────────────────────────────────────
@@ -299,10 +299,17 @@ impl AgentPool {
crate::db::write_content(crate::db::ContentKey::MergeSuccess(&sid), "1");
}
// Update CRDT with terminal status.
// Update CRDT with terminal status. The full untruncated output is
// already on disk (write_merge_report, called from
// run_merge_pipeline for the Ok(r) case above, or below for the
// Err(e) case) — only a bounded summary plus that pointer goes
// into the replicated `merge_jobs.error` field, so a large gate
// failure doesn't bloat every node's CRDT state.
match &report {
Ok(r) => {
let report_json = serde_json::to_string(r).unwrap_or_else(|_| String::new());
let bounded = crate::service::merge::bound_report_for_storage(r);
let report_json =
serde_json::to_string(&bounded).unwrap_or_else(|_| String::new());
crate::crdt_state::write_merge_job(
&sid,
"completed",
@@ -312,12 +319,15 @@ impl AgentPool {
);
}
Err(e) => {
let report_path = crate::service::merge::io::write_merge_report(&root, &sid, e);
let bounded =
crate::service::merge::bound_plain_error(e, report_path.as_deref());
crate::crdt_state::write_merge_job(
&sid,
"failed",
started_at,
Some(finished_at),
Some(e),
Some(&bounded),
);
}
}
+29 -20
View File
@@ -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 ───────────────────────────────
+1 -13
View File
@@ -184,23 +184,11 @@ pub(crate) async fn on_terminal_transition(project_root: &Path, story_id: &str)
#[cfg(test)]
mod tests {
use super::*;
use crate::worktree::test_support::init_git_repo;
use std::fs;
use std::process::Command;
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");
}
fn setup_project(tmp: &TempDir) -> PathBuf {
let root = tmp.path().join("project");
fs::create_dir_all(root.join(".huskies")).unwrap();
+404
View File
@@ -0,0 +1,404 @@
//! Shared helpers for the API-based agent runtimes (Gemini, OpenAI), which
//! talk directly to a provider's REST API rather than spawning a CLI over a
//! PTY. Both runtimes drive an almost-identical turn loop against different
//! wire formats; this module holds the logic that doesn't vary between them.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use serde_json::Value;
use tokio::sync::broadcast;
use crate::agent_log::AgentLogWriter;
use super::super::{AgentEvent, TokenUsage};
use super::{RuntimeContext, RuntimeResult, RuntimeStatus};
/// Cancellation flag shared by the API-based runtimes' `stop()`/`get_status()`.
pub(super) struct CancellationFlag {
cancelled: Arc<AtomicBool>,
}
impl CancellationFlag {
/// Create a fresh, un-cancelled flag.
pub(super) fn new() -> Self {
Self {
cancelled: Arc::new(AtomicBool::new(false)),
}
}
/// Clone of the underlying flag, for the conversation loop to poll.
pub(super) fn handle(&self) -> Arc<AtomicBool> {
Arc::clone(&self.cancelled)
}
/// Request a stop.
pub(super) fn stop(&self) {
self.cancelled.store(true, Ordering::Relaxed);
}
/// Report `Failed` once stopped, `Idle` otherwise.
pub(super) fn status(&self) -> RuntimeStatus {
if self.cancelled.load(Ordering::Relaxed) {
RuntimeStatus::Failed
} else {
RuntimeStatus::Idle
}
}
}
/// Build the event-emitting closure shared by API-based runtimes: forwards
/// events to the broadcast channel, the in-memory event log, and (optionally)
/// the on-disk log writer.
pub(super) fn make_emit(
tx: broadcast::Sender<AgentEvent>,
event_log: Arc<Mutex<Vec<AgentEvent>>>,
log_writer: Option<Arc<Mutex<AgentLogWriter>>>,
) -> impl Fn(AgentEvent) {
move |event: AgentEvent| {
super::super::pty::emit_event(
event,
&tx,
&event_log,
log_writer.as_ref().map(|w| w.as_ref()),
);
}
}
/// Zeroed token-usage accumulator, seeded before an API runtime's
/// conversation loop starts accumulating per-turn usage.
pub(super) fn zero_usage() -> TokenUsage {
TokenUsage {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
total_cost_usd: 0.0,
}
}
/// Emit the initial "running" status event.
pub(super) fn emit_running(ctx: &RuntimeContext, emit: &impl Fn(AgentEvent)) {
emit(AgentEvent::Status {
story_id: ctx.story_id.clone(),
agent_name: ctx.agent_name.clone(),
status: "running".to_string(),
});
}
/// Set up an API-based runtime's conversation loop: builds the event
/// emitter, emits the initial "running" status, and returns it alongside a
/// zeroed usage accumulator and the turn counter (starting at 0).
pub(super) fn start_conversation_loop(
ctx: &RuntimeContext,
tx: broadcast::Sender<AgentEvent>,
event_log: Arc<Mutex<Vec<AgentEvent>>>,
log_writer: Option<Arc<Mutex<AgentLogWriter>>>,
) -> (impl Fn(AgentEvent), TokenUsage, u32) {
let emit = make_emit(tx, event_log, log_writer);
emit_running(ctx, &emit);
(emit, zero_usage(), 0u32)
}
/// Build a successful `RuntimeResult` carrying the given token usage. All
/// API-based runtimes report `exit_ok: true` and leave the CLI-only fields
/// (`aborted_signal`, `rate_limit_exit`, `rate_limit_reset_at`) at their
/// defaults, since those concepts don't apply outside the PTY runtime.
pub(super) fn api_runtime_result(total_usage: TokenUsage) -> RuntimeResult {
RuntimeResult {
session_id: None,
token_usage: Some(total_usage),
exit_ok: true,
aborted_signal: false,
rate_limit_exit: false,
rate_limit_reset_at: None,
}
}
/// Emit the `Done` event and build the final successful result once the
/// model produces a response with no further tool/function calls.
pub(super) fn done_result(
ctx: &RuntimeContext,
emit: &impl Fn(AgentEvent),
total_usage: TokenUsage,
) -> RuntimeResult {
emit(AgentEvent::Done {
story_id: ctx.story_id.clone(),
agent_name: ctx.agent_name.clone(),
session_id: None,
});
api_runtime_result(total_usage)
}
/// Safety limit on conversation turns for API-based runtimes, shared so
/// both the guard check and its error message stay in sync.
const MAX_TURNS: u32 = 200;
/// Check the per-turn cancellation/max-turns guard at the top of an API
/// runtime's conversation loop. Returns `Some(result)` when the loop should
/// stop immediately (either the user requested a stop, or the safety turn
/// limit was exceeded); otherwise increments `*turn` and returns `None`.
pub(super) fn check_loop_guard(
ctx: &RuntimeContext,
cancelled: &AtomicBool,
turn: &mut u32,
total_usage: &TokenUsage,
emit: &impl Fn(AgentEvent),
) -> Option<RuntimeResult> {
if cancelled.load(Ordering::Relaxed) {
emit(AgentEvent::Error {
story_id: ctx.story_id.clone(),
agent_name: ctx.agent_name.clone(),
message: "Agent was stopped by user".to_string(),
});
return Some(api_runtime_result(total_usage.clone()));
}
*turn += 1;
if *turn > MAX_TURNS {
emit(AgentEvent::Error {
story_id: ctx.story_id.clone(),
agent_name: ctx.agent_name.clone(),
message: format!("Exceeded maximum turns ({MAX_TURNS})"),
});
return Some(api_runtime_result(total_usage.clone()));
}
None
}
/// Extract the model name for an API-based runtime: the agent pool stashes
/// the model directly in `ctx.command` for non-CLI runtimes, detected here
/// via `is_command_a_model`; otherwise fall back to a `--model` arg, and
/// finally `default_model`.
pub(super) fn extract_model(
ctx: &RuntimeContext,
is_command_a_model: impl Fn(&str) -> bool,
default_model: &str,
) -> String {
if is_command_a_model(&ctx.command) {
ctx.command.clone()
} else {
ctx.args
.iter()
.position(|a| a == "--model")
.and_then(|i| ctx.args.get(i + 1))
.cloned()
.unwrap_or_else(|| default_model.to_string())
}
}
/// Build the default system-prompt text shared by both API-based runtimes:
/// prefers an explicit `--append-system-prompt` arg (set by the agent pool),
/// else falls back to a generic tool-calling preamble.
pub(super) fn build_system_text(ctx: &RuntimeContext) -> String {
ctx.args
.iter()
.position(|a| a == "--append-system-prompt")
.and_then(|i| ctx.args.get(i + 1))
.cloned()
.unwrap_or_else(|| {
format!(
"You are an AI coding agent working on story {}. \
You have access to tools via function calling. \
Use them to complete the task. \
Work in the directory: {}",
ctx.story_id, ctx.cwd
)
})
}
/// Recursively clean an MCP JSON-Schema `properties` object into a provider
/// function-calling schema: strips `$schema` (always) and
/// `additionalProperties` (when the provider doesn't support it, e.g.
/// Gemini) from the top level and from nested `properties`/`items`.
pub(super) fn clean_schema_properties(
properties: &Value,
strip_additional_properties: bool,
) -> Value {
let Some(obj) = properties.as_object() else {
return properties.clone();
};
let mut cleaned = serde_json::Map::new();
for (key, value) in obj {
let mut prop = value.clone();
if let Some(p) = prop.as_object_mut() {
p.remove("$schema");
if strip_additional_properties {
p.remove("additionalProperties");
}
if let Some(nested_props) = p.get("properties").cloned() {
p.insert(
"properties".to_string(),
clean_schema_properties(&nested_props, strip_additional_properties),
);
}
if let Some(items) = p.get("items").cloned()
&& let Some(items_obj) = items.as_object()
{
let mut cleaned_items = items_obj.clone();
cleaned_items.remove("$schema");
if strip_additional_properties {
cleaned_items.remove("additionalProperties");
}
p.insert("items".to_string(), Value::Object(cleaned_items));
}
}
cleaned.insert(key.clone(), prop);
}
Value::Object(cleaned)
}
// ── Test helpers ─────────────────────────────────────────────────────
/// Build a throwaway `AppContext` backed by a temp directory, for tests
/// that need a `RuntimeContext.app_ctx` but don't exercise it.
#[cfg(test)]
pub(super) fn test_app_ctx() -> Arc<crate::http::context::AppContext> {
let tmp = tempfile::tempdir().unwrap();
Arc::new(crate::http::context::AppContext::new_test(
tmp.path().to_path_buf(),
))
}
/// Build a `RuntimeContext` with sensible test defaults, overriding only
/// `command` and `args` (the fields the API-runtime tests vary).
#[cfg(test)]
pub(super) fn test_runtime_context(command: &str, args: Vec<String>) -> RuntimeContext {
RuntimeContext {
story_id: "42_story_test".to_string(),
agent_name: "coder-1".to_string(),
command: command.to_string(),
args,
prompt: "Do the thing".to_string(),
cwd: "/tmp/wt".to_string(),
inactivity_timeout_secs: 300,
app_ctx: Some(test_app_ctx()),
session_id_to_resume: None,
fresh_prompt: None,
project_root: std::path::PathBuf::from("/tmp/project"),
model: None,
}
}
// ── Tests ────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn clean_schema_strips_dollar_schema_always() {
let schema = json!({
"name": { "type": "string", "$schema": "http://json-schema.org/draft-07/schema#" }
});
let result = clean_schema_properties(&schema, false);
assert!(result["name"].get("$schema").is_none());
}
#[test]
fn clean_schema_strips_additional_properties_when_requested() {
let schema = json!({
"name": { "type": "string", "additionalProperties": false }
});
let result = clean_schema_properties(&schema, true);
assert!(result["name"].get("additionalProperties").is_none());
}
#[test]
fn clean_schema_keeps_additional_properties_when_not_requested() {
let schema = json!({
"name": { "type": "object", "additionalProperties": false }
});
let result = clean_schema_properties(&schema, false);
assert!(result["name"].get("additionalProperties").is_some());
}
#[test]
fn clean_schema_recurses_into_nested_object_properties() {
let schema = json!({
"config": {
"type": "object",
"properties": {
"key": { "type": "string", "$schema": "x" }
}
}
});
let result = clean_schema_properties(&schema, false);
assert!(result["config"]["properties"]["key"].is_object());
assert!(
result["config"]["properties"]["key"]
.get("$schema")
.is_none()
);
}
#[test]
fn clean_schema_recurses_into_array_items() {
let schema = json!({
"items": {
"type": "array",
"items": {
"type": "object",
"properties": { "name": { "type": "string" } },
"additionalProperties": false,
"$schema": "x"
}
}
});
let result = clean_schema_properties(&schema, true);
let items_schema = &result["items"]["items"];
assert!(items_schema.get("additionalProperties").is_none());
assert!(items_schema.get("$schema").is_none());
}
#[test]
fn extract_model_uses_command_when_it_matches() {
let ctx = test_runtime_context("gpt-4o", vec![]);
assert_eq!(
extract_model(&ctx, |c| c.starts_with("gpt"), "fallback"),
"gpt-4o"
);
}
#[test]
fn extract_model_falls_back_to_args() {
let ctx = test_runtime_context("claude", vec!["--model".to_string(), "custom".to_string()]);
assert_eq!(
extract_model(&ctx, |c| c.starts_with("gpt"), "fallback"),
"custom"
);
}
#[test]
fn extract_model_falls_back_to_default() {
let ctx = test_runtime_context("claude", vec![]);
assert_eq!(
extract_model(&ctx, |c| c.starts_with("gpt"), "fallback"),
"fallback"
);
}
#[test]
fn build_system_text_uses_args() {
let ctx = test_runtime_context(
"gpt-4o",
vec![
"--append-system-prompt".to_string(),
"Custom system prompt".to_string(),
],
);
assert_eq!(build_system_text(&ctx), "Custom system prompt");
}
#[test]
fn build_system_text_default() {
let ctx = test_runtime_context("gpt-4o", vec![]);
let text = build_system_text(&ctx);
assert!(text.contains("42_story_test"));
assert!(text.contains("/tmp/wt"));
}
}
+8 -54
View File
@@ -4,6 +4,7 @@ use serde_json::{Value, json};
use super::super::super::TokenUsage;
use super::super::RuntimeContext;
use super::super::api_common::build_system_text;
// ── Gemini API types ─────────────────────────────────────────────────
@@ -19,26 +20,8 @@ pub(super) struct GeminiFunctionDeclaration {
/// Build the system instruction content from the RuntimeContext.
pub(super) fn build_system_instruction(ctx: &RuntimeContext) -> Value {
// Use system_prompt from args if provided via --append-system-prompt,
// otherwise use a sensible default.
let system_text = ctx
.args
.iter()
.position(|a| a == "--append-system-prompt")
.and_then(|i| ctx.args.get(i + 1))
.cloned()
.unwrap_or_else(|| {
format!(
"You are an AI coding agent working on story {}. \
You have access to tools via function calling. \
Use them to complete the task. \
Work in the directory: {}",
ctx.story_id, ctx.cwd
)
});
json!({
"parts": [{ "text": system_text }]
"parts": [{ "text": build_system_text(ctx) }]
})
}
@@ -92,34 +75,18 @@ pub(super) fn parse_usage_metadata(response: &Value) -> Option<TokenUsage> {
#[cfg(test)]
mod tests {
use super::super::super::api_common::test_runtime_context;
use super::*;
use crate::http::context::AppContext;
use std::sync::Arc;
fn test_app_ctx() -> Arc<AppContext> {
let tmp = tempfile::tempdir().unwrap();
Arc::new(AppContext::new_test(tmp.path().to_path_buf()))
}
#[test]
fn build_system_instruction_uses_args() {
let ctx = RuntimeContext {
story_id: "42_story_test".to_string(),
agent_name: "coder-1".to_string(),
command: "gemini-2.5-pro".to_string(),
args: vec![
let ctx = test_runtime_context(
"gemini-2.5-pro",
vec![
"--append-system-prompt".to_string(),
"Custom system prompt".to_string(),
],
prompt: "Do the thing".to_string(),
cwd: "/tmp/wt".to_string(),
inactivity_timeout_secs: 300,
app_ctx: Some(test_app_ctx()),
session_id_to_resume: None,
fresh_prompt: None,
project_root: std::path::PathBuf::from("/tmp/project"),
model: None,
};
);
let instruction = build_system_instruction(&ctx);
assert_eq!(instruction["parts"][0]["text"], "Custom system prompt");
@@ -127,20 +94,7 @@ mod tests {
#[test]
fn build_system_instruction_default() {
let ctx = RuntimeContext {
story_id: "42_story_test".to_string(),
agent_name: "coder-1".to_string(),
command: "gemini-2.5-pro".to_string(),
args: vec![],
prompt: "Do the thing".to_string(),
cwd: "/tmp/wt".to_string(),
inactivity_timeout_secs: 300,
app_ctx: Some(test_app_ctx()),
session_id_to_resume: None,
fresh_prompt: None,
project_root: std::path::PathBuf::from("/tmp/project"),
model: None,
};
let ctx = test_runtime_context("gemini-2.5-pro", vec![]);
let instruction = build_system_instruction(&ctx);
let text = instruction["parts"][0]["text"].as_str().unwrap();
+2 -80
View File
@@ -8,6 +8,7 @@ use crate::slog;
use crate::http::mcp::tools_list::list_tools;
use super::super::api_common::clean_schema_properties;
use super::api::GeminiFunctionDeclaration;
// ── MCP tool loading ────────────────────────────────────────────────
@@ -62,7 +63,7 @@ pub(super) fn convert_mcp_schema_to_gemini(schema: Option<&Value>) -> Option<Val
let mut result = json!({
"type": "object",
"properties": clean_schema_properties(properties),
"properties": clean_schema_properties(properties, true),
});
// Preserve required fields if present.
@@ -73,44 +74,6 @@ pub(super) fn convert_mcp_schema_to_gemini(schema: Option<&Value>) -> Option<Val
Some(result)
}
/// Recursively clean schema properties to be Gemini-compatible.
/// Removes unsupported JSON Schema keywords.
fn clean_schema_properties(properties: &Value) -> Value {
let Some(obj) = properties.as_object() else {
return properties.clone();
};
let mut cleaned = serde_json::Map::new();
for (key, value) in obj {
let mut prop = value.clone();
// Remove JSON Schema keywords not supported by Gemini
if let Some(p) = prop.as_object_mut() {
p.remove("$schema");
p.remove("additionalProperties");
// Recursively clean nested object properties
if let Some(nested_props) = p.get("properties").cloned() {
p.insert(
"properties".to_string(),
clean_schema_properties(&nested_props),
);
}
// Clean items schema for arrays
if let Some(items) = p.get("items").cloned()
&& let Some(items_obj) = items.as_object()
{
let mut cleaned_items = items_obj.clone();
cleaned_items.remove("$schema");
cleaned_items.remove("additionalProperties");
p.insert("items".to_string(), Value::Object(cleaned_items));
}
}
cleaned.insert(key.clone(), prop);
}
Value::Object(cleaned)
}
// ── Tests ────────────────────────────────────────────────────────────
#[cfg(test)]
@@ -170,45 +133,4 @@ mod tests {
assert!(name_prop.get("$schema").is_none());
assert_eq!(name_prop["type"], "string");
}
#[test]
fn convert_mcp_schema_with_nested_objects() {
let schema = json!({
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"key": { "type": "string" }
}
}
}
});
let result = convert_mcp_schema_to_gemini(Some(&schema)).unwrap();
assert!(result["properties"]["config"]["properties"]["key"].is_object());
}
#[test]
fn convert_mcp_schema_with_array_items() {
let schema = json!({
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" }
},
"additionalProperties": false
}
}
}
});
let result = convert_mcp_schema_to_gemini(Some(&schema)).unwrap();
let items_schema = &result["properties"]["items"]["items"];
assert!(items_schema.get("additionalProperties").is_none());
}
}
+20 -127
View File
@@ -1,5 +1,5 @@
//! Gemini runtime — drives Google Gemini API sessions as agent backends.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use reqwest::Client;
@@ -10,7 +10,10 @@ use crate::agent_log::AgentLogWriter;
use crate::http::mcp::dispatch::dispatch_tool_call;
use crate::slog;
use super::super::{AgentEvent, TokenUsage};
use super::super::AgentEvent;
use super::api_common::{
CancellationFlag, check_loop_guard, done_result, extract_model, start_conversation_loop,
};
use super::{AgentRuntime, RuntimeContext, RuntimeResult, RuntimeStatus};
mod api;
@@ -40,14 +43,14 @@ struct GeminiFunctionCall {
/// 6. Tracks token usage from the API response metadata.
pub struct GeminiRuntime {
/// Whether a stop has been requested.
cancelled: Arc<AtomicBool>,
cancelled: CancellationFlag,
}
impl GeminiRuntime {
/// Create a new Gemini runtime instance.
pub fn new() -> Self {
Self {
cancelled: Arc::new(AtomicBool::new(false)),
cancelled: CancellationFlag::new(),
}
}
}
@@ -66,19 +69,7 @@ impl AgentRuntime for GeminiRuntime {
.to_string()
})?;
let model = if ctx.command.starts_with("gemini") {
// The pool puts the model into `command` for non-CLI runtimes,
// but also check args for a --model flag.
ctx.command.clone()
} else {
// Fall back to args: look for --model <value>
ctx.args
.iter()
.position(|a| a == "--model")
.and_then(|i| ctx.args.get(i + 1))
.cloned()
.unwrap_or_else(|| "gemini-2.5-pro".to_string())
};
let model = extract_model(&ctx, |c| c.starts_with("gemini"), "gemini-2.5-pro");
let app_ctx = ctx
.app_ctx
@@ -86,7 +77,7 @@ impl AgentRuntime for GeminiRuntime {
.ok_or_else(|| "Gemini runtime requires app_ctx to be set".to_string())?;
let client = Client::new();
let cancelled = Arc::clone(&self.cancelled);
let cancelled = self.cancelled.handle();
// Step 1: Load MCP tool definitions and convert to Gemini format.
let gemini_tools = convert_mcp_tools_to_gemini();
@@ -98,65 +89,14 @@ impl AgentRuntime for GeminiRuntime {
"parts": [{ "text": ctx.prompt }]
})];
let mut total_usage = TokenUsage {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
total_cost_usd: 0.0,
};
let emit = |event: AgentEvent| {
super::super::pty::emit_event(
event,
&tx,
&event_log,
log_writer.as_ref().map(|w| w.as_ref()),
);
};
emit(AgentEvent::Status {
story_id: ctx.story_id.clone(),
agent_name: ctx.agent_name.clone(),
status: "running".to_string(),
});
let (emit, mut total_usage, mut turn) =
start_conversation_loop(&ctx, tx, event_log, log_writer);
// Step 3: Conversation loop.
let mut turn = 0u32;
let max_turns = 200; // Safety limit
loop {
if cancelled.load(Ordering::Relaxed) {
emit(AgentEvent::Error {
story_id: ctx.story_id.clone(),
agent_name: ctx.agent_name.clone(),
message: "Agent was stopped by user".to_string(),
});
return Ok(RuntimeResult {
session_id: None,
token_usage: Some(total_usage),
exit_ok: true,
aborted_signal: false,
rate_limit_exit: false,
rate_limit_reset_at: None,
});
}
turn += 1;
if turn > max_turns {
emit(AgentEvent::Error {
story_id: ctx.story_id.clone(),
agent_name: ctx.agent_name.clone(),
message: format!("Exceeded maximum turns ({max_turns})"),
});
return Ok(RuntimeResult {
session_id: None,
token_usage: Some(total_usage),
exit_ok: true,
aborted_signal: false,
rate_limit_exit: false,
rate_limit_reset_at: None,
});
if let Some(result) = check_loop_guard(&ctx, &cancelled, &mut turn, &total_usage, &emit)
{
return Ok(result);
}
slog!(
@@ -248,19 +188,7 @@ impl AgentRuntime for GeminiRuntime {
// If no function calls, the model is done.
if function_calls.is_empty() {
emit(AgentEvent::Done {
story_id: ctx.story_id.clone(),
agent_name: ctx.agent_name.clone(),
session_id: None,
});
return Ok(RuntimeResult {
session_id: None,
token_usage: Some(total_usage),
exit_ok: true,
aborted_signal: false,
rate_limit_exit: false,
rate_limit_reset_at: None,
});
return Ok(done_result(&ctx, &emit, total_usage));
}
// Add the model's response to the conversation.
@@ -333,32 +261,15 @@ impl AgentRuntime for GeminiRuntime {
}
}
emit(AgentEvent::Done {
story_id: ctx.story_id.clone(),
agent_name: ctx.agent_name.clone(),
session_id: None,
});
Ok(RuntimeResult {
session_id: None,
token_usage: Some(total_usage),
exit_ok: true,
aborted_signal: false,
rate_limit_exit: false,
rate_limit_reset_at: None,
})
Ok(done_result(&ctx, &emit, total_usage))
}
fn stop(&self) {
self.cancelled.store(true, Ordering::Relaxed);
self.cancelled.stop();
}
fn get_status(&self) -> RuntimeStatus {
if self.cancelled.load(Ordering::Relaxed) {
RuntimeStatus::Failed
} else {
RuntimeStatus::Idle
}
self.cancelled.status()
}
}
@@ -366,13 +277,8 @@ impl AgentRuntime for GeminiRuntime {
#[cfg(test)]
mod tests {
use super::super::api_common::test_runtime_context;
use super::*;
use crate::http::context::AppContext;
fn test_app_ctx() -> Arc<AppContext> {
let tmp = tempfile::tempdir().unwrap();
Arc::new(AppContext::new_test(tmp.path().to_path_buf()))
}
#[test]
fn gemini_runtime_stop_sets_cancelled() {
@@ -385,20 +291,7 @@ mod tests {
#[test]
fn model_extraction_from_command() {
// When command starts with "gemini", use it as model name
let ctx = RuntimeContext {
story_id: "1".to_string(),
agent_name: "coder".to_string(),
command: "gemini-2.5-pro".to_string(),
args: vec![],
prompt: "test".to_string(),
cwd: "/tmp".to_string(),
inactivity_timeout_secs: 300,
app_ctx: Some(test_app_ctx()),
session_id_to_resume: None,
fresh_prompt: None,
project_root: std::path::PathBuf::from("/tmp/project"),
model: None,
};
let ctx = test_runtime_context("gemini-2.5-pro", vec![]);
// The model extraction logic is inside start(), but we test the
// condition here.
+2 -6
View File
@@ -1,4 +1,5 @@
//! Agent runtimes — pluggable backends (Claude Code, Gemini, OpenAI) for running agents.
mod api_common;
mod claude_code;
mod gemini;
mod openai;
@@ -132,12 +133,7 @@ pub trait AgentRuntime: Send + Sync {
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::AppContext;
fn test_app_ctx() -> Arc<AppContext> {
let tmp = tempfile::tempdir().unwrap();
Arc::new(AppContext::new_test(tmp.path().to_path_buf()))
}
use api_common::test_app_ctx;
#[test]
fn runtime_context_fields() {
+25 -264
View File
@@ -1,5 +1,5 @@
//! OpenAI Codex runtime — drives OpenAI API sessions as agent backends.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use reqwest::Client;
@@ -12,6 +12,10 @@ use crate::http::mcp::tools_list::list_tools;
use crate::slog;
use super::super::{AgentEvent, TokenUsage};
use super::api_common::{
CancellationFlag, build_system_text, check_loop_guard, clean_schema_properties, done_result,
extract_model, start_conversation_loop,
};
use super::{AgentRuntime, RuntimeContext, RuntimeResult, RuntimeStatus};
// ── Public runtime struct ────────────────────────────────────────────
@@ -28,14 +32,14 @@ use super::{AgentRuntime, RuntimeContext, RuntimeResult, RuntimeStatus};
/// 6. Tracks token usage from the API response.
pub struct OpenAiRuntime {
/// Whether a stop has been requested.
cancelled: Arc<AtomicBool>,
cancelled: CancellationFlag,
}
impl OpenAiRuntime {
/// Create a new OpenAI runtime instance.
pub fn new() -> Self {
Self {
cancelled: Arc::new(AtomicBool::new(false)),
cancelled: CancellationFlag::new(),
}
}
}
@@ -54,18 +58,11 @@ impl AgentRuntime for OpenAiRuntime {
.to_string()
})?;
let model = if ctx.command.starts_with("gpt") || ctx.command.starts_with("o") {
// The pool puts the model into `command` for non-CLI runtimes.
ctx.command.clone()
} else {
// Fall back to args: look for --model <value>
ctx.args
.iter()
.position(|a| a == "--model")
.and_then(|i| ctx.args.get(i + 1))
.cloned()
.unwrap_or_else(|| "gpt-4o".to_string())
};
let model = extract_model(
&ctx,
|c| c.starts_with("gpt") || c.starts_with("o"),
"gpt-4o",
);
let app_ctx = ctx
.app_ctx
@@ -73,7 +70,7 @@ impl AgentRuntime for OpenAiRuntime {
.ok_or_else(|| "OpenAI runtime requires app_ctx to be set".to_string())?;
let client = Client::new();
let cancelled = Arc::clone(&self.cancelled);
let cancelled = self.cancelled.handle();
// Step 1: Fetch MCP tool definitions and convert to OpenAI format.
let openai_tools = convert_mcp_tools_to_openai();
@@ -85,65 +82,14 @@ impl AgentRuntime for OpenAiRuntime {
json!({ "role": "user", "content": ctx.prompt }),
];
let mut total_usage = TokenUsage {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
total_cost_usd: 0.0,
};
let emit = |event: AgentEvent| {
super::super::pty::emit_event(
event,
&tx,
&event_log,
log_writer.as_ref().map(|w| w.as_ref()),
);
};
emit(AgentEvent::Status {
story_id: ctx.story_id.clone(),
agent_name: ctx.agent_name.clone(),
status: "running".to_string(),
});
let (emit, mut total_usage, mut turn) =
start_conversation_loop(&ctx, tx, event_log, log_writer);
// Step 3: Conversation loop.
let mut turn = 0u32;
let max_turns = 200; // Safety limit
loop {
if cancelled.load(Ordering::Relaxed) {
emit(AgentEvent::Error {
story_id: ctx.story_id.clone(),
agent_name: ctx.agent_name.clone(),
message: "Agent was stopped by user".to_string(),
});
return Ok(RuntimeResult {
session_id: None,
token_usage: Some(total_usage),
exit_ok: true,
aborted_signal: false,
rate_limit_exit: false,
rate_limit_reset_at: None,
});
}
turn += 1;
if turn > max_turns {
emit(AgentEvent::Error {
story_id: ctx.story_id.clone(),
agent_name: ctx.agent_name.clone(),
message: format!("Exceeded maximum turns ({max_turns})"),
});
return Ok(RuntimeResult {
session_id: None,
token_usage: Some(total_usage),
exit_ok: true,
aborted_signal: false,
rate_limit_exit: false,
rate_limit_reset_at: None,
});
if let Some(result) = check_loop_guard(&ctx, &cancelled, &mut turn, &total_usage, &emit)
{
return Ok(result);
}
slog!(
@@ -218,19 +164,7 @@ impl AgentRuntime for OpenAiRuntime {
if tool_calls.is_none() || tool_calls.is_some_and(|tc| tc.is_empty()) {
// No tool calls — model is done.
emit(AgentEvent::Done {
story_id: ctx.story_id.clone(),
agent_name: ctx.agent_name.clone(),
session_id: None,
});
return Ok(RuntimeResult {
session_id: None,
token_usage: Some(total_usage),
exit_ok: true,
aborted_signal: false,
rate_limit_exit: false,
rate_limit_reset_at: None,
});
return Ok(done_result(&ctx, &emit, total_usage));
}
let tool_calls = tool_calls.unwrap();
@@ -297,38 +231,16 @@ impl AgentRuntime for OpenAiRuntime {
}
fn stop(&self) {
self.cancelled.store(true, Ordering::Relaxed);
self.cancelled.stop();
}
fn get_status(&self) -> RuntimeStatus {
if self.cancelled.load(Ordering::Relaxed) {
RuntimeStatus::Failed
} else {
RuntimeStatus::Idle
}
self.cancelled.status()
}
}
// ── Helper functions ─────────────────────────────────────────────────
/// Build the system message text from the RuntimeContext.
fn build_system_text(ctx: &RuntimeContext) -> String {
ctx.args
.iter()
.position(|a| a == "--append-system-prompt")
.and_then(|i| ctx.args.get(i + 1))
.cloned()
.unwrap_or_else(|| {
format!(
"You are an AI coding agent working on story {}. \
You have access to tools via function calling. \
Use them to complete the task. \
Work in the directory: {}",
ctx.story_id, ctx.cwd
)
})
}
/// Load MCP tool definitions directly and convert to OpenAI function-calling format.
fn convert_mcp_tools_to_openai() -> Vec<Value> {
let tools = list_tools();
@@ -377,7 +289,7 @@ fn convert_mcp_schema_to_openai(schema: Option<&Value>) -> Option<Value> {
});
if let Some(properties) = schema.get("properties") {
result["properties"] = clean_schema_properties(properties);
result["properties"] = clean_schema_properties(properties, false);
} else {
result["properties"] = json!({});
}
@@ -392,40 +304,6 @@ fn convert_mcp_schema_to_openai(schema: Option<&Value>) -> Option<Value> {
Some(result)
}
/// Recursively clean schema properties, removing unsupported keywords.
fn clean_schema_properties(properties: &Value) -> Value {
let Some(obj) = properties.as_object() else {
return properties.clone();
};
let mut cleaned = serde_json::Map::new();
for (key, value) in obj {
let mut prop = value.clone();
if let Some(p) = prop.as_object_mut() {
p.remove("$schema");
// Recursively clean nested object properties.
if let Some(nested_props) = p.get("properties").cloned() {
p.insert(
"properties".to_string(),
clean_schema_properties(&nested_props),
);
}
// Clean items schema for arrays.
if let Some(items) = p.get("items").cloned()
&& let Some(items_obj) = items.as_object()
{
let mut cleaned_items = items_obj.clone();
cleaned_items.remove("$schema");
p.insert("items".to_string(), Value::Object(cleaned_items));
}
}
cleaned.insert(key.clone(), prop);
}
Value::Object(cleaned)
}
/// Parse token usage from an OpenAI API response.
fn parse_usage(response: &Value) -> Option<TokenUsage> {
let usage = response.get("usage")?;
@@ -449,13 +327,8 @@ fn parse_usage(response: &Value) -> Option<TokenUsage> {
#[cfg(test)]
mod tests {
use super::super::api_common::test_runtime_context;
use super::*;
use crate::http::context::AppContext;
fn test_app_ctx() -> Arc<AppContext> {
let tmp = tempfile::tempdir().unwrap();
Arc::new(AppContext::new_test(tmp.path().to_path_buf()))
}
#[test]
fn convert_mcp_schema_simple_object() {
@@ -512,92 +385,6 @@ mod tests {
assert_eq!(name_prop["type"], "string");
}
#[test]
fn convert_mcp_schema_with_nested_objects() {
let schema = json!({
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"key": { "type": "string" }
}
}
}
});
let result = convert_mcp_schema_to_openai(Some(&schema)).unwrap();
assert!(result["properties"]["config"]["properties"]["key"].is_object());
}
#[test]
fn convert_mcp_schema_with_array_items() {
let schema = json!({
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" }
},
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
}
});
let result = convert_mcp_schema_to_openai(Some(&schema)).unwrap();
let items_schema = &result["properties"]["items"]["items"];
assert!(items_schema.get("$schema").is_none());
}
#[test]
fn build_system_text_uses_args() {
let ctx = RuntimeContext {
story_id: "42_story_test".to_string(),
agent_name: "coder-1".to_string(),
command: "gpt-4o".to_string(),
args: vec![
"--append-system-prompt".to_string(),
"Custom system prompt".to_string(),
],
prompt: "Do the thing".to_string(),
cwd: "/tmp/wt".to_string(),
inactivity_timeout_secs: 300,
app_ctx: Some(test_app_ctx()),
session_id_to_resume: None,
fresh_prompt: None,
project_root: std::path::PathBuf::from("/tmp/project"),
model: None,
};
assert_eq!(build_system_text(&ctx), "Custom system prompt");
}
#[test]
fn build_system_text_default() {
let ctx = RuntimeContext {
story_id: "42_story_test".to_string(),
agent_name: "coder-1".to_string(),
command: "gpt-4o".to_string(),
args: vec![],
prompt: "Do the thing".to_string(),
cwd: "/tmp/wt".to_string(),
inactivity_timeout_secs: 300,
app_ctx: Some(test_app_ctx()),
session_id_to_resume: None,
fresh_prompt: None,
project_root: std::path::PathBuf::from("/tmp/project"),
model: None,
};
let text = build_system_text(&ctx);
assert!(text.contains("42_story_test"));
assert!(text.contains("/tmp/wt"));
}
#[test]
fn parse_usage_valid() {
let response = json!({
@@ -631,39 +418,13 @@ mod tests {
#[test]
fn model_extraction_from_command_gpt() {
let ctx = RuntimeContext {
story_id: "1".to_string(),
agent_name: "coder".to_string(),
command: "gpt-4o".to_string(),
args: vec![],
prompt: "test".to_string(),
cwd: "/tmp".to_string(),
inactivity_timeout_secs: 300,
app_ctx: Some(test_app_ctx()),
session_id_to_resume: None,
fresh_prompt: None,
project_root: std::path::PathBuf::from("/tmp/project"),
model: None,
};
let ctx = test_runtime_context("gpt-4o", vec![]);
assert!(ctx.command.starts_with("gpt"));
}
#[test]
fn model_extraction_from_command_o3() {
let ctx = RuntimeContext {
story_id: "1".to_string(),
agent_name: "coder".to_string(),
command: "o3".to_string(),
args: vec![],
prompt: "test".to_string(),
cwd: "/tmp".to_string(),
inactivity_timeout_secs: 300,
app_ctx: Some(test_app_ctx()),
session_id_to_resume: None,
fresh_prompt: None,
project_root: std::path::PathBuf::from("/tmp/project"),
model: None,
};
let ctx = test_runtime_context("o3", vec![]);
assert!(ctx.command.starts_with("o"));
}
}
+7 -3
View File
@@ -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
+36 -25
View File
@@ -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]
+98 -51
View File
@@ -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]
+45
View File
@@ -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 {
+250
View File
@@ -0,0 +1,250 @@
//! Transport-agnostic LLM turn driver shared by every chat transport that
//! forwards a non-command message to Claude Code (currently Discord and
//! WhatsApp). Formatting, chunking, and error-message differences between
//! transports are captured by the small [`TransportFormat`] trait; the
//! streaming/session/history plumbing lives once in [`run_llm_turn`].
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex as TokioMutex;
use crate::chat::ChatTransport;
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
use crate::http::context::PermissionDecision;
use crate::services::Services;
use crate::slog;
/// Per-transport formatting hooks needed to run a shared LLM turn.
pub trait TransportFormat: Send + Sync {
/// Convert markdown into the transport's native formatting and split it
/// into messages that respect the transport's size limit. Transports
/// without a size limit (or that don't chunk) return a single-element
/// vector.
fn format_and_chunk(&self, markdown: &str) -> Vec<String>;
/// Prefix used in `slog!` calls for this transport, e.g. `"[discord]"`.
fn log_prefix(&self) -> &'static str;
/// Convert an LLM error into a user-facing message.
fn format_error(&self, err: &str) -> String;
}
/// Build the prompt for an LLM turn, prepending any pending CRDT
/// pipeline-transition events as a `<system-reminder>` block.
pub fn build_llm_prompt(persona: &str, bot_name: &str, user: &str, user_message: &str) -> String {
let event_ctx = crate::llm_session::assemble_prompt_context(persona);
format!(
"{event_ctx}[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n\n{user}: {user_message}"
)
}
/// Everything a shared LLM turn needs beyond formatting: transport handle,
/// routing/history key, display name, and shared services/history state.
pub struct LlmTurnContext<'a> {
/// Chat transport used to send/post messages for this turn.
pub transport: Arc<dyn ChatTransport>,
/// Routing and history key (Discord channel id, WhatsApp phone number).
pub key: &'a str,
/// Display name stored in conversation history and used in the prompt.
pub user: &'a str,
/// Shared services bundle (project root, permissions, agents).
pub services: &'a Arc<Services>,
/// Per-key conversation history.
pub history: &'a Arc<TokioMutex<HashMap<String, RoomConversation>>>,
/// Maximum number of conversation entries to keep per key.
pub history_size: usize,
/// Persists conversation history to disk.
pub save_history: fn(&std::path::Path, &HashMap<String, RoomConversation>),
}
/// Forward a message to Claude Code and stream the response back through the
/// transport, handling permission prompts and conversation history.
///
/// `F` is taken by value (and cloned into the posting task) rather than by
/// reference because the posting task is a `tokio::spawn`ed future, which
/// requires `'static` — the formatter types are zero-sized markers, so
/// cloning is free.
pub async fn run_llm_turn<F: TransportFormat + Clone + 'static>(
ctx: LlmTurnContext<'_>,
format: F,
user_message: &str,
) {
use crate::chat::util::drain_complete_paragraphs;
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::watch;
let prefix = format.log_prefix();
// Look up existing session ID for this key.
let resume_session_id: Option<String> = {
let guard = ctx.history.lock().await;
guard.get(ctx.key).and_then(|conv| conv.session_id.clone())
};
let bot_name = &ctx.services.bot_name;
let persona = bot_name.to_lowercase();
let prompt = build_llm_prompt(&persona, bot_name, ctx.user, user_message);
let provider = ClaudeCodeProvider::new();
let (_cancel_tx, mut cancel_rx) = watch::channel(false);
// Channel for sending complete chunks to the posting task.
let (msg_tx, mut msg_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let msg_tx_for_callback = msg_tx.clone();
// Spawn a task to post messages as they arrive.
let post_transport = Arc::clone(&ctx.transport);
let post_key = ctx.key.to_string();
let post_format = format.clone();
let post_task = tokio::spawn(async move {
while let Some(chunk) = msg_rx.recv().await {
for part in post_format.format_and_chunk(&chunk) {
let _ = post_transport.send_message(&post_key, &part, "").await;
}
}
});
// Shared buffer between the sync token callback and the async scope.
let buffer = Arc::new(std::sync::Mutex::new(String::new()));
let buffer_for_callback = Arc::clone(&buffer);
let sent_any_chunk = Arc::new(AtomicBool::new(false));
let sent_any_chunk_for_callback = Arc::clone(&sent_any_chunk);
let project_root_str = ctx.services.project_root.to_string_lossy().to_string();
let chat_fut = provider.chat_stream(
&prompt,
&project_root_str,
resume_session_id.as_deref(),
None,
None,
&mut cancel_rx,
move |token| {
let mut buf = buffer_for_callback.lock().unwrap();
buf.push_str(token);
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);
}
},
|_thinking| {},
|_activity| {},
);
tokio::pin!(chat_fut);
// Register as a permission responder for the duration of this chat turn.
let (_perm_guard, mut perm_rx) = ctx.services.permission_registry.register();
let result = loop {
tokio::select! {
r = &mut chat_fut => break r,
Some(perm_fwd) = perm_rx.recv() => {
let prompt_msg = format!(
"**Permission Request**\n\nTool: `{}`\n```json\n{}\n```\n\nReply **yes** to approve or **no** to deny.",
perm_fwd.tool_name,
serde_json::to_string_pretty(&perm_fwd.tool_input)
.unwrap_or_else(|_| perm_fwd.tool_input.to_string()),
);
for part in format.format_and_chunk(&prompt_msg) {
let _ = ctx.transport.send_message(ctx.key, &part, "").await;
}
// Keyed by request_id (not just key) so a second
// concurrent request doesn't drop the first's sender.
ctx.services
.pending_perm_replies
.insert(ctx.key.to_string(), perm_fwd.request_id.clone(), perm_fwd.response_tx)
.await;
// Spawn a timeout task: auto-deny if the user does not respond.
let pending = Arc::clone(&ctx.services.pending_perm_replies);
let timeout_key = ctx.key.to_string();
let timeout_request_id = perm_fwd.request_id.clone();
let timeout_transport = Arc::clone(&ctx.transport);
let timeout_secs = ctx.services.permission_timeout_secs;
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(timeout_secs)).await;
if let Some(tx) = pending.remove_by_request_id(&timeout_key, &timeout_request_id).await {
let _ = tx.send(PermissionDecision::Deny);
let msg = "Permission request timed out — denied (fail-closed).";
let _ = timeout_transport.send_message(&timeout_key, msg, "").await;
}
});
}
}
};
// Flush remaining text.
let remaining = buffer.lock().unwrap().trim().to_string();
let did_send_any = sent_any_chunk.load(Ordering::Relaxed);
let (assistant_reply, new_session_id) = match result {
Ok(ClaudeCodeResult {
messages,
session_id,
..
}) => {
let reply = if !remaining.is_empty() {
let _ = msg_tx.send(remaining.clone());
remaining
} else if !did_send_any {
let last_text = messages
.iter()
.rev()
.find(|m| m.role == crate::llm::types::Role::Assistant && !m.content.is_empty())
.map(|m| m.content.clone())
.unwrap_or_default();
if !last_text.is_empty() {
let _ = msg_tx.send(last_text.clone());
}
last_text
} else {
remaining
};
slog!("{prefix} session_id from chat_stream: {:?}", session_id);
(reply, session_id)
}
Err(e) => {
slog!("{prefix} LLM error: {e}");
let err_msg = format.format_error(&e);
let _ = msg_tx.send(err_msg.clone());
(err_msg, None)
}
};
// Signal the posting task to finish and wait for it.
drop(msg_tx);
let _ = post_task.await;
// Record this exchange in conversation history.
if !assistant_reply.starts_with("Error processing") {
let mut guard = ctx.history.lock().await;
let conv = guard.entry(ctx.key.to_string()).or_default();
if new_session_id.is_some() {
conv.session_id = new_session_id;
}
conv.entries.push(ConversationEntry {
role: ConversationRole::User,
sender: ctx.user.to_string(),
content: user_message.to_string(),
});
conv.entries.push(ConversationEntry {
role: ConversationRole::Assistant,
sender: String::new(),
content: assistant_reply,
});
// Trim to configured maximum.
if conv.entries.len() > ctx.history_size {
let excess = conv.entries.len() - ctx.history_size;
conv.entries.drain(..excess);
}
(ctx.save_history)(&ctx.services.project_root, &guard);
}
}
+2
View File
@@ -12,6 +12,8 @@ pub mod compact;
pub mod dispatcher;
/// Chat history utilities — loading and serialising conversation history.
pub mod history;
/// Transport-agnostic LLM turn driver shared by Discord, WhatsApp, etc.
pub mod llm_turn;
pub(crate) mod lookup;
#[cfg(test)]
pub(crate) mod test_helpers;
+41 -192
View File
@@ -4,7 +4,9 @@ use std::collections::HashSet;
use std::sync::Arc;
use crate::chat::ChatTransport;
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
use crate::chat::transport::matrix::RoomConversation;
#[cfg(test)]
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole};
use crate::chat::util::is_permission_approval;
use crate::http::context::PermissionDecision;
use crate::services::Services;
@@ -304,196 +306,43 @@ pub(super) async fn handle_incoming_message(
handle_llm_message(ctx, channel, user, message).await;
}
/// Build the prompt for a Discord LLM turn, prepending any pending
/// CRDT pipeline-transition events as a `<system-reminder>` block.
fn build_discord_llm_prompt(
persona: &str,
bot_name: &str,
user: &str,
user_message: &str,
) -> String {
let event_ctx = crate::llm_session::assemble_prompt_context(persona);
format!(
"{event_ctx}[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n\n{user}: {user_message}"
)
/// [`crate::chat::llm_turn::TransportFormat`] implementation for Discord:
/// single-message replies (no chunking) and no OAuth-link error handling.
#[derive(Clone, Copy)]
struct DiscordFormat;
impl crate::chat::llm_turn::TransportFormat for DiscordFormat {
fn format_and_chunk(&self, markdown: &str) -> Vec<String> {
vec![markdown_to_discord(markdown)]
}
fn log_prefix(&self) -> &'static str {
"[discord]"
}
fn format_error(&self, err: &str) -> String {
format!("Error processing your request: {err}")
}
}
/// Forward a message to Claude Code and send the response back via Discord.
async fn handle_llm_message(ctx: &DiscordContext, channel: &str, user: &str, user_message: &str) {
use crate::chat::util::drain_complete_paragraphs;
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::watch;
use crate::chat::llm_turn::{LlmTurnContext, run_llm_turn};
// Look up existing session ID for this channel.
let resume_session_id: Option<String> = {
let guard = ctx.history.lock().await;
guard.get(channel).and_then(|conv| conv.session_id.clone())
};
let bot_name = &ctx.services.bot_name;
let persona = bot_name.to_lowercase();
let prompt = build_discord_llm_prompt(&persona, bot_name, user, user_message);
let provider = ClaudeCodeProvider::new();
let (_cancel_tx, mut cancel_rx) = watch::channel(false);
// Channel for sending complete chunks to the Discord posting task.
let (msg_tx, mut msg_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let msg_tx_for_callback = msg_tx.clone();
// Spawn a task to post messages as they arrive.
let post_transport = Arc::clone(&ctx.transport) as Arc<dyn ChatTransport>;
let post_channel = channel.to_string();
let post_task = tokio::spawn(async move {
while let Some(chunk) = msg_rx.recv().await {
let formatted = markdown_to_discord(&chunk);
let _ = post_transport
.send_message(&post_channel, &formatted, "")
.await;
}
});
// Shared buffer between the sync token callback and the async scope.
let buffer = Arc::new(std::sync::Mutex::new(String::new()));
let buffer_for_callback = Arc::clone(&buffer);
let sent_any_chunk = Arc::new(AtomicBool::new(false));
let sent_any_chunk_for_callback = Arc::clone(&sent_any_chunk);
let project_root_str = ctx.services.project_root.to_string_lossy().to_string();
let chat_fut = provider.chat_stream(
&prompt,
&project_root_str,
resume_session_id.as_deref(),
None,
None,
&mut cancel_rx,
move |token| {
let mut buf = buffer_for_callback.lock().unwrap();
buf.push_str(token);
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);
}
run_llm_turn(
LlmTurnContext {
transport: Arc::clone(&ctx.transport) as Arc<dyn ChatTransport>,
key: channel,
user,
services: &ctx.services,
history: &ctx.history,
history_size: ctx.history_size,
save_history: save_discord_history,
},
|_thinking| {},
|_activity| {},
);
tokio::pin!(chat_fut);
// Register as a permission responder for the duration of this chat turn.
let (_perm_guard, mut perm_rx) = ctx.services.permission_registry.register();
let result = loop {
tokio::select! {
r = &mut chat_fut => break r,
Some(perm_fwd) = perm_rx.recv() => {
let prompt_msg = format!(
"**Permission Request**\n\nTool: `{}`\n```json\n{}\n```\n\nReply **yes** to approve or **no** to deny.",
perm_fwd.tool_name,
serde_json::to_string_pretty(&perm_fwd.tool_input)
.unwrap_or_else(|_| perm_fwd.tool_input.to_string()),
);
let formatted = markdown_to_discord(&prompt_msg);
let _ = ctx.transport.send_message(channel, &formatted, "").await;
// Keyed by request_id (not just channel) so a second
// concurrent request doesn't drop the first's sender.
ctx.services
.pending_perm_replies
.insert(channel.to_string(), perm_fwd.request_id.clone(), perm_fwd.response_tx)
.await;
// Spawn a timeout task: auto-deny if the user does not respond.
let pending = Arc::clone(&ctx.services.pending_perm_replies);
let timeout_channel = channel.to_string();
let timeout_request_id = perm_fwd.request_id.clone();
let timeout_transport = Arc::clone(&ctx.transport) as Arc<dyn ChatTransport>;
let timeout_secs = ctx.services.permission_timeout_secs;
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(timeout_secs)).await;
if let Some(tx) = pending.remove_by_request_id(&timeout_channel, &timeout_request_id).await {
let _ = tx.send(PermissionDecision::Deny);
let msg = "Permission request timed out — denied (fail-closed).";
let _ = timeout_transport.send_message(&timeout_channel, msg, "").await;
}
});
}
}
};
// Flush remaining text.
let remaining = buffer.lock().unwrap().trim().to_string();
let did_send_any = sent_any_chunk.load(Ordering::Relaxed);
let (assistant_reply, new_session_id) = match result {
Ok(ClaudeCodeResult {
messages,
session_id,
..
}) => {
let reply = if !remaining.is_empty() {
let _ = msg_tx.send(remaining.clone());
remaining
} else if !did_send_any {
let last_text = messages
.iter()
.rev()
.find(|m| m.role == crate::llm::types::Role::Assistant && !m.content.is_empty())
.map(|m| m.content.clone())
.unwrap_or_default();
if !last_text.is_empty() {
let _ = msg_tx.send(last_text.clone());
}
last_text
} else {
remaining
};
slog!("[discord] session_id from chat_stream: {:?}", session_id);
(reply, session_id)
}
Err(e) => {
slog!("[discord] LLM error: {e}");
let err_msg = format!("Error processing your request: {e}");
let _ = msg_tx.send(err_msg.clone());
(err_msg, None)
}
};
// Signal the posting task to finish and wait for it.
drop(msg_tx);
let _ = post_task.await;
// Record this exchange in conversation history.
if !assistant_reply.starts_with("Error processing") {
let mut guard = ctx.history.lock().await;
let conv = guard.entry(channel.to_string()).or_default();
if new_session_id.is_some() {
conv.session_id = new_session_id;
}
conv.entries.push(ConversationEntry {
role: ConversationRole::User,
sender: user.to_string(),
content: user_message.to_string(),
});
conv.entries.push(ConversationEntry {
role: ConversationRole::Assistant,
sender: String::new(),
content: assistant_reply,
});
// Trim to configured maximum.
if conv.entries.len() > ctx.history_size {
let excess = conv.entries.len() - ctx.history_size;
conv.entries.drain(..excess);
}
save_discord_history(&ctx.services.project_root, &guard);
}
DiscordFormat,
user_message,
)
.await;
}
// ── Tests ───────────────────────────────────────────────────────────────
@@ -635,8 +484,12 @@ mod tests {
at: chrono::Utc::now(),
});
let prompt =
build_discord_llm_prompt("discord-ch-test", "Timmy", "@alice", "what is the status?");
let prompt = crate::chat::llm_turn::build_llm_prompt(
"discord-ch-test",
"Timmy",
"@alice",
"what is the status?",
);
assert!(
prompt.contains("<system-reminder>"),
@@ -673,11 +526,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()
@@ -130,10 +130,6 @@ pub struct BotContext {
/// Minimum seconds between repeated `compact` suggestions for the same
/// room. From `bot.toml`'s `compact_suggest_cooldown_secs`.
pub compact_suggest_cooldown_secs: i64,
/// Seconds a turn may spend on tool calls before emitting any
/// user-facing text before the bot posts a "digging in" notice. From
/// `bot.toml`'s `digging_in_threshold_secs`.
pub digging_in_threshold_secs: u64,
}
impl BotContext {
@@ -318,6 +314,10 @@ mod tests {
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)),
})
@@ -361,7 +361,6 @@ mod tests {
compact_seed_max_bytes: 8_000,
cache_read_suggest_threshold: 50_000,
compact_suggest_cooldown_secs: 3_600,
digging_in_threshold_secs: 15,
}
}
@@ -1,14 +1,15 @@
//! 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;
@@ -17,31 +18,87 @@ 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 = "Working...";
/// Text posted 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`.
/// 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`.
///
/// Callers must abort the returned [`tokio::task::JoinHandle`] once the turn
/// completes so a turn that finishes under the threshold — with or without
/// text — never triggers the notice after the fact.
pub(in crate::chat::transport::matrix::bot) fn spawn_digging_in_watcher(
/// `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,
sent_any_text: Arc<AtomicBool>,
threshold: Duration,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
tokio::time::sleep(threshold).await;
if !sent_any_text.load(Ordering::Relaxed) {
let html = markdown_to_html(DIGGING_IN_MESSAGE);
let _ = transport
.send_message(&room_id, DIGGING_IN_MESSAGE, &html)
.await;
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) = &current_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(
@@ -51,6 +108,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 +153,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,29 +211,22 @@ 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);
// Notify the room if this turn spends longer than the configured
// threshold on tool calls before emitting any user-facing text, so a
// long silent turn doesn't look hung. Aborted below once the turn ends.
let digging_in_task = spawn_digging_in_watcher(
Arc::clone(&ctx.transport),
room_id_str.clone(),
Arc::clone(&sent_any_chunk),
Duration::from_secs(ctx.digging_in_threshold_secs),
);
// Permission requests are handled by the persistent permission_listener
// task spawned at bot startup (story 884) — they no longer route through
// per-message handlers. Just await chat_fut.
let result = (&mut chat_fut).await;
digging_in_task.abort();
// Flush any remaining text that didn't end with a paragraph boundary.
let remaining = buffer.lock().unwrap().trim().to_string();
@@ -192,7 +240,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 +253,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 +278,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 +381,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 +413,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 +441,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(())
}
@@ -391,104 +467,163 @@ mod tests {
}
}
/// AC 1: a turn that runs longer than the threshold without sending any
/// 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());
let sent_any_text = Arc::new(AtomicBool::new(false));
let handle = spawn_digging_in_watcher(
transport.clone() as Arc<dyn ChatTransport>,
// ── 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(),
Arc::clone(&sent_any_text),
Duration::from_millis(30),
);
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(), 1);
assert_eq!(transport.last_message().unwrap(), "Working...");
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: if text is sent before the threshold elapses, the watcher must
/// not post anything.
/// 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 digging_in_does_not_fire_when_text_sent_before_threshold() {
async fn repeated_identical_activity_produces_one_edit() {
let transport = Arc::new(CapturingTransport::new());
let sent_any_text = Arc::new(AtomicBool::new(false));
let handle = spawn_digging_in_watcher(
transport.clone() as Arc<dyn ChatTransport>,
"!room:example.com".to_string(),
Arc::clone(&sent_any_text),
Duration::from_millis(30),
);
sent_any_text.store(true, Ordering::Relaxed);
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.sent_count(), 0);
assert_eq!(transport.edit_count(), 1, "duplicate signals must debounce");
}
/// AC 2: a fast turn that completes (and is aborted by its caller)
/// before the threshold elapses must not post anything, even if it
/// never sent any text either.
/// 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 digging_in_does_not_fire_when_aborted_before_threshold() {
async fn intermediate_text_finalizes_then_next_update_opens_fresh_placeholder() {
let transport = Arc::new(CapturingTransport::new());
let sent_any_text = Arc::new(AtomicBool::new(false));
let handle = spawn_digging_in_watcher(
transport.clone() as Arc<dyn ChatTransport>,
"!room:example.com".to_string(),
Arc::clone(&sent_any_text),
Duration::from_millis(200),
);
handle.abort();
tokio::time::sleep(Duration::from_millis(250)).await;
assert_eq!(transport.sent_count(), 0);
}
let (tx, handle) = spawn_progress(transport.clone(), Some("placeholder-0".to_string()));
/// AC 3: the notice fires at most once per turn — even waiting well past
/// the threshold never produces a second message.
#[tokio::test]
async fn digging_in_fires_at_most_once_even_after_extra_wait() {
let transport = Arc::new(CapturingTransport::new());
let sent_any_text = Arc::new(AtomicBool::new(false));
let handle = spawn_digging_in_watcher(
transport.clone() as Arc<dyn ChatTransport>,
"!room:example.com".to_string(),
Arc::clone(&sent_any_text),
Duration::from_millis(20),
);
tx.send(ProgressUpdate::Text("Here's part one.".to_string()))
.unwrap();
tx.send(ProgressUpdate::Activity("Edit".to_string()))
.unwrap();
drop(tx);
handle.await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(transport.sent_count(), 1);
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 5: state is fresh per call — spawning a second, independent
/// watcher (as a new turn would) does not inherit any state from a
/// prior watcher's `sent_any_text` flag.
/// 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 digging_in_state_does_not_leak_across_turns() {
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()));
// First "turn": text sent before threshold, so no notice.
let first_sent_any_text = Arc::new(AtomicBool::new(false));
let first_handle = spawn_digging_in_watcher(
transport.clone() as Arc<dyn ChatTransport>,
"!room:example.com".to_string(),
Arc::clone(&first_sent_any_text),
Duration::from_millis(30),
);
first_sent_any_text.store(true, Ordering::Relaxed);
first_handle.await.unwrap();
assert_eq!(transport.sent_count(), 0);
tx.send(ProgressUpdate::Activity("Read".to_string()))
.unwrap();
tx.send(ProgressUpdate::Text("All done.".to_string()))
.unwrap();
drop(tx);
handle.await.unwrap();
// Second "turn": fresh flag, no text sent — must fire independently
// of the first turn's outcome.
let second_sent_any_text = Arc::new(AtomicBool::new(false));
let second_handle = spawn_digging_in_watcher(
transport.clone() as Arc<dyn ChatTransport>,
"!room:example.com".to_string(),
Arc::clone(&second_sent_any_text),
Duration::from_millis(30),
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"
);
second_handle.await.unwrap();
assert_eq!(transport.sent_count(), 1);
}
}
@@ -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.
{
@@ -1457,6 +1588,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 +1605,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 +1614,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
sender,
coalesced,
cancel_rx,
placeholder_id,
)
.await;
})
@@ -1496,14 +1634,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
@@ -1892,7 +2091,6 @@ mod tests {
compact_seed_max_bytes: 8_000,
cache_read_suggest_threshold: 50_000,
compact_suggest_cooldown_secs: 3_600,
digging_in_threshold_secs: 15,
}
}
@@ -1915,11 +2113,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 +2305,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.
@@ -174,6 +174,10 @@ mod tests {
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])
);
}
}
+22 -2
View File
@@ -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.
@@ -337,7 +343,6 @@ pub async fn run_bot(
compact_seed_max_bytes: config.compact_seed_max_bytes,
cache_read_suggest_threshold: config.cache_read_suggest_threshold,
compact_suggest_cooldown_secs: config.compact_suggest_cooldown_secs,
digging_in_threshold_secs: config.digging_in_threshold_secs,
};
slog!(
@@ -355,8 +360,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,
);
+1 -5
View File
@@ -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()
@@ -172,49 +172,6 @@ history_size = 50
assert_eq!(config.history_size, 50);
}
/// AC4: `digging_in_threshold_secs` defaults to 15 when unset in bot.toml.
#[test]
fn load_uses_default_digging_in_threshold_secs() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".huskies");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = true
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.digging_in_threshold_secs, 15);
}
/// AC4: `digging_in_threshold_secs` can be overridden in bot.toml.
#[test]
fn load_respects_custom_digging_in_threshold_secs() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".huskies");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("bot.toml"),
r#"
homeserver = "https://matrix.example.com"
username = "@bot:example.com"
password = "secret"
room_ids = ["!abc:example.com"]
enabled = true
digging_in_threshold_secs = 30
"#,
)
.unwrap();
let config = BotConfig::load(tmp.path()).unwrap();
assert_eq!(config.digging_in_threshold_secs, 30);
}
#[test]
fn load_reads_display_name() {
let tmp = tempfile::tempdir().unwrap();
@@ -31,12 +31,6 @@ pub(super) fn default_compact_suggest_cooldown_secs() -> i64 {
3_600
}
/// Default threshold (seconds) a turn may spend on tool calls before
/// emitting any user-facing text before the bot posts a "digging in" notice.
pub(super) fn default_digging_in_threshold_secs() -> u64 {
15
}
pub(super) fn default_transport() -> String {
"matrix".to_string()
}
@@ -229,10 +223,4 @@ pub struct BotConfig {
/// room, so a busy room isn't spammed every turn. Defaults to 3600 (1h).
#[serde(default = "default_compact_suggest_cooldown_secs")]
pub compact_suggest_cooldown_secs: i64,
/// Seconds a turn may spend on tool calls before emitting any
/// user-facing text before the bot posts a "digging in" notice to the
/// room, so a silent long-running turn doesn't look hung. Defaults to 15.
#[serde(default = "default_digging_in_threshold_secs")]
pub digging_in_threshold_secs: u64,
}
+8 -1
View File
@@ -625,6 +625,10 @@ mod tests {
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)),
});
@@ -657,6 +661,10 @@ mod tests {
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)),
});
@@ -951,7 +959,6 @@ mod tests {
compact_seed_max_bytes: 8_000,
cache_read_suggest_threshold: 50_000,
compact_suggest_cooldown_secs: 3_600,
digging_in_threshold_secs: 15,
}
}
}
@@ -288,8 +288,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());
@@ -102,7 +102,6 @@ mod tests {
compact_seed_max_bytes: 8_000,
cache_read_suggest_threshold: 50_000,
compact_suggest_cooldown_secs: 3_600,
digging_in_threshold_secs: 15,
};
run_projects_list(&ctx).await
}
@@ -225,7 +224,6 @@ mod tests {
compact_seed_max_bytes: 8_000,
cache_read_suggest_threshold: 50_000,
compact_suggest_cooldown_secs: 3_600,
digging_in_threshold_secs: 15,
};
let response = run_projects_list(&ctx).await;
assert!(
@@ -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();
@@ -2,204 +2,56 @@
use std::sync::Arc;
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole};
use crate::http::context::PermissionDecision;
use crate::slog;
use super::super::WhatsAppWebhookContext;
use super::super::format::{chunk_for_whatsapp, markdown_to_whatsapp};
use super::super::history::save_whatsapp_history;
/// [`crate::chat::llm_turn::TransportFormat`] implementation for WhatsApp:
/// messages are markdown-converted then split to fit the platform's size
/// limit, and LLM errors are checked for an OAuth login link.
#[derive(Clone, Copy)]
struct WhatsAppFormat;
impl crate::chat::llm_turn::TransportFormat for WhatsAppFormat {
fn format_and_chunk(&self, markdown: &str) -> Vec<String> {
chunk_for_whatsapp(&markdown_to_whatsapp(markdown))
}
fn log_prefix(&self) -> &'static str {
"[whatsapp]"
}
fn format_error(&self, err: &str) -> String {
if let Some(url) = crate::llm::oauth::extract_login_url_from_error(err) {
format!("Authentication required. Log in to Claude here: {url}")
} else {
format!("Error processing your request: {err}")
}
}
}
/// Forward a message to Claude Code and send the response back via WhatsApp.
pub(super) async fn handle_llm_message(
ctx: &WhatsAppWebhookContext,
sender: &str,
user_message: &str,
) {
use crate::chat::util::drain_complete_paragraphs;
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::watch;
use crate::chat::llm_turn::{LlmTurnContext, run_llm_turn};
// Look up existing session ID for this sender.
let resume_session_id: Option<String> = {
let guard = ctx.history.lock().await;
guard.get(sender).and_then(|conv| conv.session_id.clone())
};
let bot_name = &ctx.services.bot_name;
let persona = bot_name.to_lowercase();
let event_ctx = crate::llm_session::assemble_prompt_context(&persona);
let prompt = format!(
"{event_ctx}[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n\n{sender}: {user_message}"
);
let provider = ClaudeCodeProvider::new();
let (_cancel_tx, mut cancel_rx) = watch::channel(false);
// Channel for sending complete chunks to the WhatsApp posting task.
let (msg_tx, mut msg_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let msg_tx_for_callback = msg_tx.clone();
// Spawn a task to post messages as they arrive.
let post_transport = Arc::clone(&ctx.transport);
let post_sender = sender.to_string();
let post_task = tokio::spawn(async move {
while let Some(chunk) = msg_rx.recv().await {
// Convert Markdown to WhatsApp formatting, then split into sized chunks.
let formatted = markdown_to_whatsapp(&chunk);
for part in chunk_for_whatsapp(&formatted) {
let _ = post_transport.send_message(&post_sender, &part, "").await;
}
}
});
// Shared buffer between the sync token callback and the async scope.
let buffer = Arc::new(std::sync::Mutex::new(String::new()));
let buffer_for_callback = Arc::clone(&buffer);
let sent_any_chunk = Arc::new(AtomicBool::new(false));
let sent_any_chunk_for_callback = Arc::clone(&sent_any_chunk);
let project_root_str = ctx.services.project_root.to_string_lossy().to_string();
let chat_fut = provider.chat_stream(
&prompt,
&project_root_str,
resume_session_id.as_deref(),
None,
None,
&mut cancel_rx,
move |token| {
let mut buf = buffer_for_callback.lock().unwrap();
buf.push_str(token);
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);
}
run_llm_turn(
LlmTurnContext {
transport: Arc::clone(&ctx.transport),
key: sender,
user: sender,
services: &ctx.services,
history: &ctx.history,
history_size: ctx.history_size,
save_history: save_whatsapp_history,
},
|_thinking| {},
|_activity| {},
);
tokio::pin!(chat_fut);
// Register as a permission responder for the duration of this chat turn.
let (_perm_guard, mut perm_rx) = ctx.services.permission_registry.register();
let result = loop {
tokio::select! {
r = &mut chat_fut => break r,
Some(perm_fwd) = perm_rx.recv() => {
let prompt_msg = format!(
"*Permission Request*\n\nTool: `{}`\n```json\n{}\n```\n\nReply *yes* to approve or *no* to deny.",
perm_fwd.tool_name,
serde_json::to_string_pretty(&perm_fwd.tool_input)
.unwrap_or_else(|_| perm_fwd.tool_input.to_string()),
);
let formatted = markdown_to_whatsapp(&prompt_msg);
for part in chunk_for_whatsapp(&formatted) {
let _ = ctx.transport.send_message(sender, &part, "").await;
}
// Store the response sender so the incoming message handler
// can resolve it when the user replies yes/no. Keyed by
// request_id (not just sender) so a second concurrent
// request doesn't drop the first's sender.
ctx.services.pending_perm_replies
.insert(sender.to_string(), perm_fwd.request_id.clone(), perm_fwd.response_tx)
.await;
// Spawn a timeout task: auto-deny if the user does not respond.
let pending = Arc::clone(&ctx.services.pending_perm_replies);
let timeout_sender = sender.to_string();
let timeout_request_id = perm_fwd.request_id.clone();
let timeout_transport = Arc::clone(&ctx.transport);
let timeout_secs = ctx.services.permission_timeout_secs;
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(timeout_secs)).await;
if let Some(tx) = pending.remove_by_request_id(&timeout_sender, &timeout_request_id).await {
let _ = tx.send(PermissionDecision::Deny);
let msg = "Permission request timed out — denied (fail-closed).";
let _ = timeout_transport.send_message(&timeout_sender, msg, "").await;
}
});
}
}
};
// Flush remaining text.
let remaining = buffer.lock().unwrap().trim().to_string();
let did_send_any = sent_any_chunk.load(Ordering::Relaxed);
let (assistant_reply, new_session_id) = match result {
Ok(ClaudeCodeResult {
messages,
session_id,
..
}) => {
let reply = if !remaining.is_empty() {
let _ = msg_tx.send(remaining.clone());
remaining
} else if !did_send_any {
let last_text = messages
.iter()
.rev()
.find(|m| m.role == crate::llm::types::Role::Assistant && !m.content.is_empty())
.map(|m| m.content.clone())
.unwrap_or_default();
if !last_text.is_empty() {
let _ = msg_tx.send(last_text.clone());
}
last_text
} else {
remaining
};
slog!("[whatsapp] session_id from chat_stream: {:?}", session_id);
(reply, session_id)
}
Err(e) => {
slog!("[whatsapp] LLM error: {e}");
let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
format!("Authentication required. Log in to Claude here: {url}")
} else {
format!("Error processing your request: {e}")
};
let _ = msg_tx.send(err_msg.clone());
(err_msg, None)
}
};
// Signal the posting task to finish and wait for it.
drop(msg_tx);
let _ = post_task.await;
// Record this exchange in conversation history.
if !assistant_reply.starts_with("Error processing") {
let mut guard = ctx.history.lock().await;
let conv = guard.entry(sender.to_string()).or_default();
if new_session_id.is_some() {
conv.session_id = new_session_id;
}
conv.entries.push(ConversationEntry {
role: ConversationRole::User,
sender: sender.to_string(),
content: user_message.to_string(),
});
conv.entries.push(ConversationEntry {
role: ConversationRole::Assistant,
sender: String::new(),
content: assistant_reply,
});
// Trim to configured maximum.
if conv.entries.len() > ctx.history_size {
let excess = conv.entries.len() - ctx.history_size;
conv.entries.drain(..excess);
}
save_whatsapp_history(&ctx.services.project_root, &guard);
}
WhatsAppFormat,
user_message,
)
.await;
}
// ── Tests ───────────────────────────────────────────────────────────────
@@ -315,6 +315,10 @@ mod tests {
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 {
+40
View File
@@ -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]
+189
View File
@@ -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"
);
}
}
+15
View File
@@ -155,6 +155,12 @@ pub struct ProjectConfig {
/// (disabled) — the on-demand `gc` MCP tool remains available regardless.
#[serde(default)]
pub gc_min_free_gb: u64,
/// Number of CRDT ops applied (locally or received from sync peers)
/// between periodic snapshot checkpoints (story 1249). Keeps the
/// replayed tail on startup bounded instead of growing forever between
/// the one-time post-replay snapshot and the next restart. Default: 5000.
#[serde(default = "default_snapshot_interval_ops")]
pub snapshot_interval_ops: usize,
}
/// Configuration for the filesystem watcher's sweep behaviour.
@@ -255,6 +261,11 @@ fn default_max_mesh_peers() -> usize {
3
}
/// Default number of ops between periodic CRDT snapshot checkpoints (story 1249).
pub fn default_snapshot_interval_ops() -> usize {
5000
}
/// Configuration for the low-disk-space watchdog's free-space thresholds.
///
/// Sleds check free space on the `/workspace` filesystem each tick and
@@ -472,6 +483,7 @@ impl Default for ProjectConfig {
status_push_enabled: default_status_push_enabled(),
merge_failure_block_threshold: default_merge_failure_block_threshold(),
gc_min_free_gb: 0,
snapshot_interval_ops: default_snapshot_interval_ops(),
}
}
}
@@ -564,6 +576,7 @@ impl ProjectConfig {
status_push_enabled: default_status_push_enabled(),
merge_failure_block_threshold: default_merge_failure_block_threshold(),
gc_min_free_gb: 0,
snapshot_interval_ops: default_snapshot_interval_ops(),
};
validate_agents(&config.agent)?;
return Ok(config);
@@ -607,6 +620,7 @@ impl ProjectConfig {
status_push_enabled: default_status_push_enabled(),
merge_failure_block_threshold: default_merge_failure_block_threshold(),
gc_min_free_gb: 0,
snapshot_interval_ops: default_snapshot_interval_ops(),
};
validate_agents(&config.agent)?;
Ok(config)
@@ -638,6 +652,7 @@ impl ProjectConfig {
status_push_enabled: default_status_push_enabled(),
merge_failure_block_threshold: default_merge_failure_block_threshold(),
gc_min_free_gb: 0,
snapshot_interval_ops: default_snapshot_interval_ops(),
})
}
}
+15
View File
@@ -11,6 +11,21 @@ fn default_config_when_missing() {
assert!(config.component.is_empty());
}
#[test]
fn snapshot_interval_ops_defaults_to_5000() {
let config = ProjectConfig::default();
assert_eq!(config.snapshot_interval_ops, 5000);
}
#[test]
fn snapshot_interval_ops_overridable() {
let toml_str = r#"
snapshot_interval_ops = 250
"#;
let config = ProjectConfig::parse(toml_str).unwrap();
assert_eq!(config.snapshot_interval_ops, 250);
}
#[test]
fn parse_multi_agent_toml() {
let toml_str = r#"
@@ -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,
})
}
+2
View File
@@ -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};
+10 -9
View File
@@ -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::{
@@ -48,7 +49,7 @@ pub use read::{
read_all_items, read_item, tombstoned_ids,
};
pub(crate) use state::flush_persistence;
pub use state::{init, subscribe};
pub use state::{checkpoint_on_shutdown, init, subscribe};
pub use types::{
ActiveAgentCrdt, ActiveAgentView, AgentThrottleCrdt, AgentThrottleView, CrdtEvent, EpicId,
EventLogEntryCrdt, GatewayConfigCrdt, GatewayProjectCrdt, GatewayProjectView, LlmSessionCrdt,
+165 -19
View File
@@ -24,7 +24,7 @@ use super::indices::{
rebuild_index, rebuild_llm_session_index, rebuild_merge_job_index, rebuild_node_index,
rebuild_test_job_index, rebuild_token_index,
};
use super::statics::{ALL_OPS, CRDT_EVENT_TX, PERSIST_PENDING, SYNC_TX, VECTOR_CLOCK};
use super::statics::{self, ALL_OPS, CRDT_EVENT_TX, PERSIST_PENDING, SYNC_TX, VECTOR_CLOCK};
use super::{CRDT_STATE, CrdtState};
use crate::slog;
@@ -41,8 +41,11 @@ pub(crate) enum PersistMsg {
/// Opens the SQLite database, loads or creates a node keypair, replays any
/// persisted ops to reconstruct state, and spawns a background persistence
/// task. Safe to call only once; subsequent calls are no-ops.
///
/// `snapshot_interval_ops` (story 1249) is the number of ops applied between
/// periodic snapshot checkpoints — see `ProjectConfig::snapshot_interval_ops`.
#[allow(clippy::string_slice)] // op_id is hex::encode output (ASCII-only), &op_id[..12] is always valid
pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> {
pub async fn init(db_path: &Path, snapshot_interval_ops: usize) -> Result<(), sqlx::Error> {
if CRDT_STATE.get().is_some() {
return Ok(());
}
@@ -52,6 +55,8 @@ pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> {
.create_if_missing(true);
let pool = SqlitePool::connect_with(options).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
let _ = statics::CRDT_POOL.set(pool.clone());
let _ = statics::SNAPSHOT_INTERVAL_OPS.set(snapshot_interval_ops);
// Load or create the node keypair.
let keypair = load_or_create_keypair(&pool).await?;
@@ -73,15 +78,19 @@ pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> {
);
let kp = keypair.clone();
let restore_result = tokio::task::spawn_blocking(move || {
let doc: PipelineDoc = serde_json::from_str(&state_json)
.map_err(|e| format!("snapshot deserialize failed: {e}"))?;
let doc: PipelineDoc = match serde_json::from_str(&state_json) {
Ok(doc) => doc,
Err(e) => {
eprintln!("\n{}\n", snapshot_load_abort_message(&e.to_string()));
std::process::exit(1);
}
};
let mut crdt = BaseCrdt::<PipelineDoc>::new(&kp);
crdt.doc = doc;
Ok::<_, String>(crdt)
crdt
})
.await
.map_err(|e| sqlx::Error::Protocol(format!("snapshot restore panicked: {e}")))?
.map_err(sqlx::Error::Protocol)?;
.map_err(|e| sqlx::Error::Protocol(format!("snapshot restore panicked: {e}")))?;
// Replay only ops that arrived after the snapshot.
let tail_rows: Vec<(String,)> =
@@ -270,6 +279,8 @@ pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> {
slog!("[crdt] Failed to persist op {}: {e}", &op_id[..12]);
}
PERSIST_PENDING.fetch_sub(1, Ordering::Relaxed);
maybe_periodic_checkpoint().await;
}
PersistMsg::Flush(reply) => {
// All ops queued before this message have already been processed.
@@ -375,21 +386,27 @@ async fn load_or_create_keypair(pool: &SqlitePool) -> Result<Ed25519KeyPair, sql
/// and back up the database file. Errors are logged but not propagated —
/// a failed snapshot just means the next restart will do a full replay.
async fn save_snapshot(pool: &SqlitePool, crdt: &BaseCrdt<PipelineDoc>, lamport_floor: u64) {
// Find the highest rowid currently in crdt_ops — ops with rowid <= this
// value are already captured in the snapshot.
let max_rowid: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(rowid), 0) FROM crdt_ops")
.fetch_one(pool)
.await
.unwrap_or(0);
let doc_ref = &crdt.doc;
let json = match serde_json::to_string(doc_ref) {
let json = match serde_json::to_string(&crdt.doc) {
Ok(j) => j,
Err(e) => {
slog!("[crdt] Failed to serialize snapshot: {e}");
return;
}
};
save_snapshot_json(pool, &json, lamport_floor).await;
}
/// Shared snapshot-writing body: back up the DB file and write `json` (an
/// already-serialized `PipelineDoc`) into the `crdt_snapshot` table. Used by
/// both the one-time post-replay snapshot ([`save_snapshot`]) and periodic /
/// shutdown checkpoints ([`checkpoint_now`]) added by story 1249.
async fn save_snapshot_json(pool: &SqlitePool, json: &str, at_seq: u64) {
// Find the highest rowid currently in crdt_ops — ops with rowid <= this
// value are already captured in the snapshot.
let max_rowid: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(rowid), 0) FROM crdt_ops")
.fetch_one(pool)
.await
.unwrap_or(0);
let json_len = json.len();
let now = chrono::Utc::now().to_rfc3339();
@@ -408,9 +425,9 @@ async fn save_snapshot(pool: &SqlitePool, crdt: &BaseCrdt<PipelineDoc>, lamport_
"INSERT OR REPLACE INTO crdt_snapshot (id, at_seq, max_rowid, state_json, created_at) \
VALUES (1, ?1, ?2, ?3, ?4)",
)
.bind(lamport_floor as i64)
.bind(at_seq as i64)
.bind(max_rowid)
.bind(&json)
.bind(json)
.bind(&now)
.execute(pool)
.await;
@@ -418,7 +435,7 @@ async fn save_snapshot(pool: &SqlitePool, crdt: &BaseCrdt<PipelineDoc>, lamport_
match result {
Ok(_) => slog!(
"[crdt] Snapshot saved: at_seq={}, max_rowid={}, json={}B",
lamport_floor,
at_seq,
max_rowid,
json_len
),
@@ -426,6 +443,86 @@ async fn save_snapshot(pool: &SqlitePool, crdt: &BaseCrdt<PipelineDoc>, lamport_
}
}
/// Pure threshold decision for periodic checkpoints (story 1249 AC2/AC3).
///
/// Returns `true` only when at least one op has been applied since the last
/// checkpoint AND that count has reached `threshold`. The `ops_since_last >
/// 0` guard is what makes AC3 (skip when nothing changed) hold even for a
/// `threshold` of 0 or 1.
fn should_checkpoint(ops_since_last: usize, threshold: usize) -> bool {
ops_since_last > 0 && ops_since_last >= threshold
}
/// Called after every op the persistence task writes. Triggers a checkpoint
/// once `OPS_SINCE_SNAPSHOT` reaches the configured `SNAPSHOT_INTERVAL_OPS`
/// (story 1249 AC1/AC2).
async fn maybe_periodic_checkpoint() {
let threshold = statics::SNAPSHOT_INTERVAL_OPS
.get()
.copied()
.unwrap_or(crate::config::default_snapshot_interval_ops());
let ops_since_last = statics::OPS_SINCE_SNAPSHOT.load(Ordering::Relaxed);
if should_checkpoint(ops_since_last, threshold) {
checkpoint_now("periodic").await;
}
}
/// Take a snapshot of the current CRDT state, unless no ops have been
/// applied since the last checkpoint (story 1249 AC3). Resets
/// `OPS_SINCE_SNAPSHOT` back to zero afterwards. `reason` is only used for
/// the log line (e.g. `"periodic"` or `"shutdown"`).
async fn checkpoint_now(reason: &str) {
if statics::OPS_SINCE_SNAPSHOT.load(Ordering::Relaxed) == 0 {
return;
}
let Some(pool) = statics::CRDT_POOL.get() else {
return;
};
let Some(state_mutex) = super::get_crdt() else {
return;
};
let json = {
let Ok(state) = state_mutex.lock() else {
return;
};
match serde_json::to_string(&state.crdt.doc) {
Ok(j) => j,
Err(e) => {
slog!("[crdt] Failed to serialize {reason} checkpoint: {e}");
return;
}
}
};
let at_seq = statics::LAST_SEQ.load(Ordering::Relaxed);
save_snapshot_json(pool, &json, at_seq).await;
statics::OPS_SINCE_SNAPSHOT.store(0, Ordering::Relaxed);
slog!("[crdt] Checkpoint ({reason}) complete");
}
/// Take a final checkpoint on clean shutdown (story 1249 AC1), if any ops
/// have been applied since the last one. Safe to call even if the CRDT
/// layer was never initialised — it's a no-op in that case.
pub async fn checkpoint_on_shutdown() {
checkpoint_now("shutdown").await;
}
/// Build the abort message printed when a CRDT snapshot fails to deserialize
/// into the current schema. `deserialize_error` is the `Display` text of the
/// `serde_json::Error`, which names the missing/mismatched field; it is
/// included verbatim so the operator can see exactly what changed.
fn snapshot_load_abort_message(deserialize_error: &str) -> String {
format!(
"error: failed to load the CRDT snapshot (crdt_snapshot row):\n \
{deserialize_error}\n\n\
This snapshot was written by a binary with a different schema and is \
missing a field the current binary expects.\n\
No data has been lost: the ops log (crdt_ops) is intact and is the \
durable source of truth the snapshot is only a fast-path replay cache.\n\
To recover: delete the crdt_snapshot row (e.g. `DELETE FROM crdt_snapshot \
WHERE id = 1;`) so init.rs rebuilds state from crdt_ops on the next start."
)
}
/// Extract the filesystem path from a SqlitePool's connect options.
fn pool_path(pool: &SqlitePool) -> Option<String> {
use sqlx::ConnectOptions;
@@ -433,3 +530,52 @@ fn pool_path(pool: &SqlitePool) -> Option<String> {
let filename = opts.get_filename();
filename.to_str().map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::{should_checkpoint, snapshot_load_abort_message};
/// AC3: a checkpoint must never fire when nothing has changed, no matter
/// how low the configured threshold is.
#[test]
fn should_checkpoint_false_when_no_ops_applied() {
assert!(!should_checkpoint(0, 5000));
assert!(!should_checkpoint(0, 1));
assert!(!should_checkpoint(0, 0));
}
/// AC2: the threshold is whatever N is configured to, not a hardcoded value.
#[test]
fn should_checkpoint_respects_configured_threshold() {
assert!(!should_checkpoint(4999, 5000));
assert!(should_checkpoint(5000, 5000));
assert!(should_checkpoint(5001, 5000));
assert!(should_checkpoint(1, 1));
}
/// The abort message must name the failing field (via the serde error
/// text), explain that the ops log is intact, and give the exact
/// recovery step — this is what the operator reads at 3am.
#[test]
fn snapshot_load_abort_message_names_field_and_explains_recovery() {
let err = "missing field `gateway_config` at line 1 column 4821";
let msg = snapshot_load_abort_message(err);
assert!(
msg.contains("missing field `gateway_config`"),
"message must name the failing field verbatim: {msg}"
);
assert!(
msg.contains("different schema"),
"message must explain the schema-mismatch cause: {msg}"
);
assert!(
msg.contains("crdt_ops") && msg.contains("intact"),
"message must state the ops log is intact and no data is lost: {msg}"
);
assert!(
msg.contains("DELETE FROM crdt_snapshot"),
"message must give the exact recovery step: {msg}"
);
}
}
+1 -1
View File
@@ -26,8 +26,8 @@ mod tests;
// ── Re-exports for crdt_state siblings ──────────────────────────────
pub use init::init;
pub(crate) use init::{PersistMsg, flush_persistence};
pub use init::{checkpoint_on_shutdown, init};
/// Subscribe to CRDT state-transition events.
///
+29 -1
View File
@@ -10,10 +10,11 @@
//! tests do not share `ALL_OPS` — preventing one test's `apply_compaction`
//! from pruning another test's freshly-written ops.
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::{AtomicU64, AtomicUsize};
use std::sync::{Mutex, OnceLock};
use bft_json_crdt::json_crdt::SignedOp;
use sqlx::SqlitePool;
use tokio::sync::broadcast;
use super::super::VectorClock;
@@ -46,6 +47,31 @@ pub(crate) static ALL_OPS: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
/// re-parsing all ops when a peer requests `our_vector_clock()`.
pub(crate) static VECTOR_CLOCK: OnceLock<Mutex<VectorClock>> = OnceLock::new();
/// The CRDT SQLite pool, stashed here so periodic and shutdown checkpoints
/// (story 1249) can reuse it without threading it through every call site
/// that might trigger one. Set once, at the end of `init::init()`.
pub(crate) static CRDT_POOL: OnceLock<SqlitePool> = OnceLock::new();
/// Number of ops between periodic snapshot checkpoints (story 1249).
/// Set once, from `ProjectConfig::snapshot_interval_ops`, at the end of
/// `init::init()`. Falls back to `config::default_snapshot_interval_ops()`
/// (5000) if `init()` hasn't set it yet.
pub(crate) static SNAPSHOT_INTERVAL_OPS: OnceLock<usize> = OnceLock::new();
/// Count of ops applied (locally created or received from sync peers) since
/// the last snapshot checkpoint. Incremented in [`track_op`]; reset to zero
/// after a checkpoint is taken. A checkpoint is skipped when this is zero
/// (story 1249 AC3) so restarting the server or an idle period never writes
/// a redundant snapshot.
pub(crate) static OPS_SINCE_SNAPSHOT: AtomicUsize = AtomicUsize::new(0);
/// Highest op sequence number seen since startup, updated in [`track_op`].
/// Used as the informational `at_seq` value for periodic/shutdown
/// checkpoints (the replay-boundary correctness depends only on
/// `max_rowid`, computed fresh from SQLite at snapshot time — this is just
/// for accurate logging/debugging).
pub(crate) static LAST_SEQ: AtomicU64 = AtomicU64::new(0);
#[cfg(test)]
thread_local! {
/// Per-thread op journal for test isolation. Each test thread sees its
@@ -107,4 +133,6 @@ pub(in crate::crdt_state) fn track_op(signed: &SignedOp, json: String) {
let author_hex = hex::encode(&signed.author());
*clock.entry(author_hex).or_insert(0) += 1;
}
OPS_SINCE_SNAPSHOT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
LAST_SEQ.fetch_max(signed.inner.seq, std::sync::atomic::Ordering::Relaxed);
}
+31
View File
@@ -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
View File
@@ -1,8 +1,10 @@
//! Content-store garbage collection: TransitionFired subscriber and startup sweep.
//! Content-store garbage collection: TransitionFired subscriber, startup
//! sweep, and story-content backfill.
//!
//! When a pipeline item reaches a terminal stage (Done, Archived, Abandoned,
//! 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
View File
@@ -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;
+10 -1
View File
@@ -94,7 +94,9 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
// Initialise the CRDT so gateway_config.active_project is persisted across restarts.
let crdt_db = config_dir.join("gateway.db");
if let Err(e) = crate::crdt_state::init(&crdt_db).await {
if let Err(e) =
crate::crdt_state::init(&crdt_db, crate::config::default_snapshot_interval_ops()).await
{
crate::slog!(
"[gateway] Warning: CRDT init failed ({e}); active-project selection will not persist"
);
@@ -146,6 +148,13 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
.run(route)
.await;
// Story 1249: take a final CRDT snapshot on clean shutdown (mirrors main.rs).
crate::crdt_state::checkpoint_on_shutdown().await;
// Story 1249: take a final CRDT snapshot on clean shutdown so the next
// restart's replayed tail stays bounded.
crate::crdt_state::checkpoint_on_shutdown().await;
// Best-effort shutdown notification: signal the Matrix bot so it can post
// "going offline" before the process exits. Mirror of main.rs:346.
{
+202 -3
View File
@@ -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}"
+22
View File
@@ -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
}
+357
View File
@@ -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);
}
}
+55
View File
@@ -37,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 {
@@ -56,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>>>,
@@ -101,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.
@@ -109,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"),
@@ -130,6 +180,10 @@ impl AppContext {
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)),
});
@@ -141,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,
+539 -23
View File
@@ -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);
@@ -1310,6 +1456,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 +1789,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 +1840,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"));
}
}
+2
View File
@@ -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> {
+229
View File
@@ -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");
}
}
+6 -2
View File
@@ -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)
+66
View File
@@ -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}'"))
}
+2
View File
@@ -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`
+42
View File
@@ -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();
@@ -1,7 +1,7 @@
//! Story field update and unblock tools.
use crate::http::context::AppContext;
use crate::http::workflow::update_story_in_file;
use crate::http::workflow::{is_bug_item, update_bug_fields_in_file, update_story_in_file};
use crate::slog_warn;
use crate::validation::UpdateStoryRequest;
use serde_json::Value;
@@ -14,6 +14,26 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
let req = UpdateStoryRequest::from_json(args)?;
// Story 1250: steps_to_reproduce/actual_result/expected_result only exist on
// bug items. Reject up front rather than silently creating the section on a
// story/spike/refactor that has no such field.
if (req.steps_to_reproduce.is_some()
|| req.actual_result.is_some()
|| req.expected_result.is_some())
&& !is_bug_item(story_id)
{
let field = if req.steps_to_reproduce.is_some() {
"steps_to_reproduce"
} else if req.actual_result.is_some() {
"actual_result"
} else {
"expected_result"
};
return Err(format!(
"Field '{field}' is only valid on bug items. '{story_id}' is not a bug."
));
}
// Explicit top-level args map onto typed CRDT registers directly (story 929:
// no YAML front-matter writes). The `front_matter` object is the legacy
// escape hatch; every known key is recognised and routed below, and any
@@ -194,6 +214,19 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
)?;
}
if req.steps_to_reproduce.is_some()
|| req.actual_result.is_some()
|| req.expected_result.is_some()
{
update_bug_fields_in_file(
&root,
story_id,
req.steps_to_reproduce.as_ref().map(|d| d.as_str()),
req.actual_result.as_ref().map(|d| d.as_str()),
req.expected_result.as_ref().map(|d| d.as_str()),
)?;
}
// Bug 503: warn if any depends_on in the (now updated) story points at an archived story.
// Story 929: reads from the CRDT (was a FS-yaml scan).
let archived_deps = crate::crdt_state::check_archived_deps_crdt(story_id);
+4 -1
View File
@@ -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]
@@ -264,6 +264,18 @@ pub(super) fn story_tools() -> Vec<Value> {
"type": "string",
"description": "New description text to replace the '## Description' section content"
},
"steps_to_reproduce": {
"type": "string",
"description": "Bug items only: replace the '## How to Reproduce' section content. Errors if story_id is not a bug."
},
"actual_result": {
"type": "string",
"description": "Bug items only: replace the '## Actual Result' section content. Errors if story_id is not a bug."
},
"expected_result": {
"type": "string",
"description": "Bug items only: replace the '## Expected Result' section content. Errors if story_id is not a bug."
},
"agent": {
"type": "string",
"description": "Set or change the 'agent' YAML front matter field"
@@ -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.",
+63 -2
View File
@@ -2,7 +2,10 @@
use std::path::Path;
use super::super::create_item_in_backlog;
use super::super::{
create_item_in_backlog, create_section_content, read_story_content, replace_section_content,
story_stage, write_story_content,
};
/// Create a bug file and store it in the database.
///
@@ -66,11 +69,69 @@ pub fn create_bug_file(
)
}
/// Update the `## How to Reproduce`, `## Actual Result` and/or `## Expected
/// Result` sections of an existing bug (story 1250).
///
/// At least one of the three must be provided. Callers must confirm the
/// target item is a bug (via [`is_bug_item`]) before calling this — it does
/// not itself check item type, so calling it against a non-bug item will
/// silently create these sections.
pub fn update_bug_fields_in_file(
project_root: &Path,
bug_id: &str,
steps_to_reproduce: Option<&str>,
actual_result: Option<&str>,
expected_result: Option<&str>,
) -> Result<(), String> {
if steps_to_reproduce.is_none() && actual_result.is_none() && expected_result.is_none() {
return Err(
"At least one of 'steps_to_reproduce', 'actual_result' or 'expected_result' \
must be provided."
.to_string(),
);
}
let mut contents = read_story_content(project_root, bug_id)?;
if let Some(steps) = steps_to_reproduce {
contents = match replace_section_content(&contents, "How to Reproduce", steps) {
Ok(updated) => updated,
Err(_) => {
create_section_content(&contents, "How to Reproduce", steps, Some("Actual Result"))
}
};
}
if let Some(actual) = actual_result {
contents = match replace_section_content(&contents, "Actual Result", actual) {
Ok(updated) => updated,
Err(_) => {
create_section_content(&contents, "Actual Result", actual, Some("Expected Result"))
}
};
}
if let Some(expected) = expected_result {
contents = match replace_section_content(&contents, "Expected Result", expected) {
Ok(updated) => updated,
Err(_) => create_section_content(
&contents,
"Expected Result",
expected,
Some("Acceptance Criteria"),
),
};
}
let stage = story_stage(bug_id).unwrap_or_else(|| "1_backlog".to_string());
write_story_content(project_root, bug_id, &stage, &contents, None);
Ok(())
}
/// Returns true if the item stem is a bug item.
///
/// Checks the slug-based ID format first (e.g. `"4_bug_login_crash"`), then
/// consults the typed CRDT `item_type` register for numeric-only IDs (story 933).
pub(super) fn is_bug_item(stem: &str) -> bool {
pub fn is_bug_item(stem: &str) -> bool {
let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit());
if after_num.starts_with("_bug_") {
return true;
+1 -1
View File
@@ -8,7 +8,7 @@ mod spike;
#[cfg(test)]
mod tests;
pub use bug::{create_bug_file, list_bug_files};
pub use bug::{create_bug_file, is_bug_item, list_bug_files, update_bug_fields_in_file};
pub use epic::create_epic_file;
pub use refactor::{create_refactor_file, list_refactor_files};
pub use spike::create_spike_file;
+2 -2
View File
@@ -6,8 +6,8 @@ mod test_results;
mod utils;
pub use bug_ops::{
create_bug_file, create_epic_file, create_refactor_file, create_spike_file, list_bug_files,
list_refactor_files,
create_bug_file, create_epic_file, create_refactor_file, create_spike_file, is_bug_item,
list_bug_files, list_refactor_files, update_bug_fields_in_file,
};
pub use pipeline::{
PipelineState, UpcomingStory, load_pipeline_state, load_upcoming_stories, validate_story_dirs,
+9
View File
@@ -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);
}
+24 -11
View File
@@ -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})"));
}
+34
View File
@@ -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();
@@ -257,6 +279,12 @@ async fn main() -> Result<(), std::io::Error> {
.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
@@ -359,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)),
@@ -462,6 +491,11 @@ async fn main() -> Result<(), std::io::Error> {
let result = Server::new(TcpListener::bind(&addr)).run(app).await;
// Story 1249: take a final CRDT snapshot on clean shutdown so the next
// restart's replayed tail stays bounded, even if fewer than
// `snapshot_interval_ops` ops have accumulated since the last periodic one.
crate::crdt_state::checkpoint_on_shutdown().await;
// ── Shutdown notifications (best-effort) ──────────────────────────────────
startup::bots::notify_shutdown(&bot_ctxs).await;
+3
View File
@@ -142,6 +142,9 @@ pub(super) fn call_sync(
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)),
});
+3
View File
@@ -742,6 +742,9 @@ pub fn spawn_gateway_bot(
.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()
+120
View File
@@ -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 —
+4 -1
View File
@@ -16,7 +16,10 @@ pub mod summary;
#[allow(unused_imports)]
pub use status::format_merge_status_message;
#[allow(unused_imports)]
pub use summary::{summarize_merge_failure_kind, summarize_merge_result};
pub use summary::{
bound_plain_error, bound_report_for_storage, summarize_merge_failure_kind,
summarize_merge_result,
};
// ── Error type ────────────────────────────────────────────────────────────────
+186
View File
@@ -113,6 +113,78 @@ pub fn summarize_merge_failure_kind(kind: &MergeFailureKind) -> String {
summarize(failing_gate_label_for_kind(kind), &kind.to_gate_output())
}
/// Return a copy of `result` with its embedded output string (`gate_output`
/// on [`MergeResult::Success`], `output` on every other variant) bounded to
/// [`SUMMARY_TAIL_LINES`].
///
/// Used before storing a [`MergeResult`] in the replicated CRDT `merge_jobs`
/// collection: the full untruncated text already lives on disk (see
/// `service::merge::io::write_merge_report` and `MergeReport::report_path`),
/// so only a bounded tail needs to travel through CRDT sync.
pub fn bound_result_for_storage(result: &MergeResult) -> MergeResult {
match result {
MergeResult::Success {
conflicts_resolved,
conflict_details,
gate_output,
} => MergeResult::Success {
conflicts_resolved: *conflicts_resolved,
conflict_details: conflict_details.clone(),
gate_output: truncate_gate_output(gate_output, SUMMARY_TAIL_LINES),
},
MergeResult::Conflict { details, output } => MergeResult::Conflict {
details: details.clone(),
output: truncate_gate_output(output, SUMMARY_TAIL_LINES),
},
MergeResult::GateFailure {
output,
failure_kind,
} => MergeResult::GateFailure {
output: truncate_gate_output(output, SUMMARY_TAIL_LINES),
failure_kind: failure_kind.clone(),
},
MergeResult::NoCommits { output } => MergeResult::NoCommits {
output: truncate_gate_output(output, SUMMARY_TAIL_LINES),
},
MergeResult::Other {
output,
conflict_details,
} => MergeResult::Other {
output: truncate_gate_output(output, SUMMARY_TAIL_LINES),
conflict_details: conflict_details.clone(),
},
}
}
/// Return a copy of `report` whose embedded [`MergeResult`] output is bounded
/// via [`bound_result_for_storage`]; `report_path` (the pointer to the full
/// untruncated text on disk) and every other field are carried over as-is.
pub fn bound_report_for_storage(
report: &crate::agents::merge::MergeReport,
) -> crate::agents::merge::MergeReport {
crate::agents::merge::MergeReport {
story_id: report.story_id.clone(),
result: bound_result_for_storage(&report.result),
worktree_cleaned_up: report.worktree_cleaned_up,
story_archived: report.story_archived,
report_path: report.report_path.clone(),
}
}
/// Bound a plain (non-[`MergeResult`]) error string for CRDT storage,
/// appending a pointer to the full text on disk when `report_path` is given.
///
/// Used for the hard-error path (git/process failures that short-circuit
/// before a [`MergeResult`] is ever constructed), so the CRDT `merge_jobs`
/// error field stays bounded the same way completed-job entries do.
pub fn bound_plain_error(error: &str, report_path: Option<&str>) -> String {
let truncated = truncate_gate_output(error, SUMMARY_TAIL_LINES);
match report_path {
Some(path) => format!("{truncated}\n\nFull output: {path}"),
None => truncated,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -236,4 +308,118 @@ mod tests {
let summary = summarize_merge_failure_kind(&kind);
assert!(!summary.contains("Quality gates failed:"));
}
// ── bound_result_for_storage ────────────────────────────────────────────────
fn long_output(n: usize) -> String {
(1..=n)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn bound_result_for_storage_truncates_gate_failure_output() {
let result = MergeResult::GateFailure {
output: long_output(100),
failure_kind: Some(GateFailureKind::Test),
};
let bounded = bound_result_for_storage(&result);
assert!(bounded.output().len() < result.output().len());
assert!(bounded.output().contains("line100"));
assert!(matches!(
bounded,
MergeResult::GateFailure {
failure_kind: Some(GateFailureKind::Test),
..
}
));
}
#[test]
fn bound_result_for_storage_truncates_success_gate_output() {
let result = MergeResult::Success {
conflicts_resolved: true,
conflict_details: Some("resolved automatically".to_string()),
gate_output: long_output(100),
};
let bounded = bound_result_for_storage(&result);
assert!(bounded.output().len() < result.output().len());
assert!(bounded.output().contains("line100"));
match bounded {
MergeResult::Success {
conflicts_resolved,
conflict_details,
..
} => {
assert!(conflicts_resolved);
assert_eq!(conflict_details.as_deref(), Some("resolved automatically"));
}
other => panic!("expected Success, got {other:?}"),
}
}
#[test]
fn bound_result_for_storage_leaves_short_output_unchanged() {
let result = MergeResult::NoCommits {
output: "no commits to merge".to_string(),
};
let bounded = bound_result_for_storage(&result);
assert_eq!(bounded.output(), "no commits to merge");
}
// ── bound_report_for_storage ────────────────────────────────────────────────
#[test]
fn bound_report_for_storage_truncates_output_and_keeps_report_path() {
let report = crate::agents::merge::MergeReport {
story_id: "42_story".to_string(),
result: MergeResult::GateFailure {
output: long_output(100),
failure_kind: Some(GateFailureKind::Test),
},
worktree_cleaned_up: false,
story_archived: false,
report_path: Some(".huskies/merge_reports/42_story-123.log".to_string()),
};
let bounded = bound_report_for_storage(&report);
assert!(bounded.result.output().len() < report.result.output().len());
assert_eq!(
bounded.report_path.as_deref(),
report.report_path.as_deref()
);
assert_eq!(bounded.story_id, report.story_id);
// Round-trips through the same JSON shape `get_merge_status` expects.
let json = serde_json::to_string(&bounded).unwrap();
let decoded: crate::agents::merge::MergeReport = serde_json::from_str(&json).unwrap();
assert!(decoded.result.output().contains("line100"));
assert_eq!(
decoded.report_path.as_deref(),
Some(".huskies/merge_reports/42_story-123.log")
);
}
// ── bound_plain_error ────────────────────────────────────────────────────────
#[test]
fn bound_plain_error_appends_pointer_when_path_given() {
let bounded = bound_plain_error("boom", Some(".huskies/merge_reports/1_story-1.log"));
assert!(bounded.contains("boom"));
assert!(bounded.contains("Full output: .huskies/merge_reports/1_story-1.log"));
}
#[test]
fn bound_plain_error_no_pointer_when_no_path() {
let bounded = bound_plain_error("boom", None);
assert_eq!(bounded, "boom");
}
#[test]
fn bound_plain_error_truncates_long_error() {
let error = long_output(100);
let bounded = bound_plain_error(&error, None);
assert!(bounded.len() < error.len());
assert!(bounded.contains("line100"));
}
}
+4
View File
@@ -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.
+3 -27
View File
@@ -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 {
+401
View File
@@ -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());
}
}
+16
View File
@@ -10,6 +10,7 @@ use crate::chat::dispatcher::ChatDispatcher;
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;
@@ -48,6 +49,18 @@ pub struct Services {
/// 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
@@ -81,6 +94,9 @@ impl Services {
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)),
})
}
+4
View File
@@ -484,6 +484,10 @@ mod tests {
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)),
})
}
+3
View File
@@ -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;
+10 -1
View File
@@ -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);
}
@@ -337,7 +338,15 @@ pub(crate) async fn init_subsystems(app_state: &Arc<SessionState>, cwd: &Path, i
let huskies_dir = db_path.parent().unwrap_or(db_path);
migrate_json_stores_to_sqlite(huskies_dir).await;
}
if let Err(e) = crdt_state::init(db_path).await {
let snapshot_interval_ops = app_state
.project_root
.lock()
.unwrap()
.as_ref()
.and_then(|root| config::ProjectConfig::load(root).ok())
.map(|cfg| cfg.snapshot_interval_ops)
.unwrap_or_else(config::default_snapshot_interval_ops);
if let Err(e) = crdt_state::init(db_path, snapshot_interval_ops).await {
crate::slog!("[crdt] Failed to initialise CRDT state layer: {e}");
} else {
crdt_state::migrate_names_from_slugs();
+339
View File
@@ -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;
}
}

Some files were not shown because too many files have changed in this diff Show More