Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69cf867969 | ||
|
|
b43def12f7 | ||
|
|
c3cad6b3d8 | ||
|
|
c58e204e8b | ||
|
|
8b2dd21e22 | ||
|
|
326b2b4a32 | ||
|
|
10dd239c92 | ||
|
|
1583ade9fc | ||
|
|
5c3b433e7d | ||
|
|
14331127b8 | ||
|
|
7de8fa1924 | ||
|
|
82d9e3c460 | ||
|
|
013a5da60b | ||
|
|
2ea633a2f1 | ||
|
|
b9e0a16bf8 | ||
|
|
78b2e7a9a2 | ||
|
|
d06f5b5410 |
@@ -767,6 +767,25 @@
|
|||||||
"server/src/agents/pty/types.rs": [
|
"server/src/agents/pty/types.rs": [
|
||||||
"struct PtyResult"
|
"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": [
|
"server/src/agents/runtime/claude_code.rs": [
|
||||||
"struct ClaudeCodeRuntime",
|
"struct ClaudeCodeRuntime",
|
||||||
"fn new"
|
"fn new"
|
||||||
@@ -950,6 +969,12 @@
|
|||||||
"fn load_chat_history",
|
"fn load_chat_history",
|
||||||
"fn save_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": [
|
"server/src/chat/lookup.rs": [
|
||||||
"fn find_story_by_number"
|
"fn find_story_by_number"
|
||||||
],
|
],
|
||||||
@@ -958,6 +983,7 @@
|
|||||||
"mod compact",
|
"mod compact",
|
||||||
"mod dispatcher",
|
"mod dispatcher",
|
||||||
"mod history",
|
"mod history",
|
||||||
|
"mod llm_turn",
|
||||||
"mod lookup",
|
"mod lookup",
|
||||||
"mod test_helpers",
|
"mod test_helpers",
|
||||||
"mod timer",
|
"mod timer",
|
||||||
@@ -1352,6 +1378,7 @@
|
|||||||
"mod agent_name",
|
"mod agent_name",
|
||||||
"struct ProjectConfig",
|
"struct ProjectConfig",
|
||||||
"struct WatcherConfig",
|
"struct WatcherConfig",
|
||||||
|
"fn default_snapshot_interval_ops",
|
||||||
"struct DiskWatchConfig",
|
"struct DiskWatchConfig",
|
||||||
"struct ComponentConfig",
|
"struct ComponentConfig",
|
||||||
"struct AgentConfig",
|
"struct AgentConfig",
|
||||||
@@ -1512,7 +1539,8 @@
|
|||||||
"server/src/crdt_state/state/init.rs": [
|
"server/src/crdt_state/state/init.rs": [
|
||||||
"enum PersistMsg",
|
"enum PersistMsg",
|
||||||
"fn init",
|
"fn init",
|
||||||
"fn flush_persistence"
|
"fn flush_persistence",
|
||||||
|
"fn checkpoint_on_shutdown"
|
||||||
],
|
],
|
||||||
"server/src/crdt_state/state/mod.rs": [
|
"server/src/crdt_state/state/mod.rs": [
|
||||||
"fn subscribe",
|
"fn subscribe",
|
||||||
@@ -1528,6 +1556,10 @@
|
|||||||
"static SYNC_TX",
|
"static SYNC_TX",
|
||||||
"static ALL_OPS",
|
"static ALL_OPS",
|
||||||
"static VECTOR_CLOCK",
|
"static VECTOR_CLOCK",
|
||||||
|
"static CRDT_POOL",
|
||||||
|
"static SNAPSHOT_INTERVAL_OPS",
|
||||||
|
"static OPS_SINCE_SNAPSHOT",
|
||||||
|
"static LAST_SEQ",
|
||||||
"static ALL_OPS_TL",
|
"static ALL_OPS_TL",
|
||||||
"static VECTOR_CLOCK_TL",
|
"static VECTOR_CLOCK_TL",
|
||||||
"fn all_ops_lock",
|
"fn all_ops_lock",
|
||||||
@@ -2072,6 +2104,7 @@
|
|||||||
],
|
],
|
||||||
"server/src/http/workflow/bug_ops/bug.rs": [
|
"server/src/http/workflow/bug_ops/bug.rs": [
|
||||||
"fn create_bug_file",
|
"fn create_bug_file",
|
||||||
|
"fn update_bug_fields_in_file",
|
||||||
"fn is_bug_item",
|
"fn is_bug_item",
|
||||||
"fn extract_bug_name_from_content",
|
"fn extract_bug_name_from_content",
|
||||||
"fn list_bug_files"
|
"fn list_bug_files"
|
||||||
@@ -2890,7 +2923,10 @@
|
|||||||
"const SUMMARY_TAIL_LINES",
|
"const SUMMARY_TAIL_LINES",
|
||||||
"fn extract_failing_test_names",
|
"fn extract_failing_test_names",
|
||||||
"fn summarize_merge_result",
|
"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": [
|
"server/src/service/mod.rs": [
|
||||||
"mod agents",
|
"mod agents",
|
||||||
@@ -3513,6 +3549,7 @@
|
|||||||
"fn migrate_slug_paths"
|
"fn migrate_slug_paths"
|
||||||
],
|
],
|
||||||
"server/src/worktree/mod.rs": [
|
"server/src/worktree/mod.rs": [
|
||||||
|
"mod test_support",
|
||||||
"struct WorktreeInfo",
|
"struct WorktreeInfo",
|
||||||
"struct WorktreeListEntry",
|
"struct WorktreeListEntry",
|
||||||
"fn worktree_path",
|
"fn worktree_path",
|
||||||
@@ -3527,5 +3564,10 @@
|
|||||||
"server/src/worktree/sweep.rs": [
|
"server/src/worktree/sweep.rs": [
|
||||||
"fn worktree_should_be_swept",
|
"fn worktree_should_be_swept",
|
||||||
"fn sweep_with_lookup"
|
"fn sweep_with_lookup"
|
||||||
|
],
|
||||||
|
"server/src/worktree/test_support.rs": [
|
||||||
|
"fn init_git_repo",
|
||||||
|
"fn empty_config",
|
||||||
|
"fn done_stage"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"threshold": 10,
|
||||||
|
"minLines": 10,
|
||||||
|
"minTokens": 50,
|
||||||
|
"ignore": [
|
||||||
|
"**/target/**",
|
||||||
|
"**/node_modules/**",
|
||||||
|
"**/dist/**",
|
||||||
|
"**/*.svg",
|
||||||
|
"**/flamegraphs/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+19
-19
@@ -67,9 +67,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ammonia"
|
name = "ammonia"
|
||||||
version = "4.1.3"
|
version = "4.1.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68b9d3370580a12f4b7a10fdcc18b28942c083ba570e3d954fe59d10951b85a2"
|
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cssparser",
|
"cssparser",
|
||||||
"html5ever",
|
"html5ever",
|
||||||
@@ -227,7 +227,7 @@ checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.2",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -456,9 +456,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
|
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
"jobserver",
|
||||||
@@ -583,9 +583,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.3"
|
version = "4.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fb99565819980999fb7b4a1796046a5c949e6d4ff132cf5fadf5a641e20d776"
|
checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
]
|
]
|
||||||
@@ -1925,7 +1925,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "huskies"
|
name = "huskies"
|
||||||
version = "0.14.4"
|
version = "0.14.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -2492,9 +2492,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.188"
|
version = "0.2.189"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b"
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libsqlite3-sys"
|
name = "libsqlite3-sys"
|
||||||
@@ -3738,7 +3738,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.2",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4080,9 +4080,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.15.0"
|
version = "1.15.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"web-time",
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -4275,7 +4275,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.2",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4845,9 +4845,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.2"
|
version = "3.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
|
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -4954,7 +4954,7 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.2",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5071,9 +5071,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.18"
|
version = "0.1.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -88,6 +88,11 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
|||||||
# Claude Code CLI in runtime
|
# Claude Code CLI in runtime
|
||||||
RUN npm install -g @anthropic-ai/claude-code
|
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:
|
# Cargo and Rust toolchain needed at runtime for:
|
||||||
# - rebuild_and_restart (cargo build inside the container)
|
# - rebuild_and_restart (cargo build inside the container)
|
||||||
# - Agent-driven cargo commands (cargo clippy, cargo test, etc.)
|
# - Agent-driven cargo commands (cargo clippy, cargo test, etc.)
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "huskies",
|
"name": "huskies",
|
||||||
"version": "0.14.4",
|
"version": "0.14.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "huskies",
|
"name": "huskies",
|
||||||
"version": "0.14.4",
|
"version": "0.14.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "huskies",
|
"name": "huskies",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.14.4",
|
"version": "0.14.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -4,19 +4,10 @@ set -euo pipefail
|
|||||||
# Build all project images in dependency order:
|
# Build all project images in dependency order:
|
||||||
# huskies → huskies-project-base → huskies-project-<stack> (one per stack fragment)
|
# huskies → huskies-project-base → huskies-project-<stack> (one per stack fragment)
|
||||||
#
|
#
|
||||||
# Run this after `script/docker_rebuild` or whenever you add a new stack.
|
# Called automatically by `script/release` (story 1242) so the huskies-project-*
|
||||||
# Safe to re-run: each step re-tags the image with the latest layers.
|
# images never drift from the version being published. Also safe to run
|
||||||
#
|
# standalone after `script/docker_rebuild` or whenever you add a new stack —
|
||||||
# IMPORTANT (story 1231): this script is NOT part of `script/release`. The
|
# each step re-tags the image with the latest layers.
|
||||||
# huskies-project-* images bake whatever `huskies` binary happened to be built
|
|
||||||
# locally the last time this ran — as of this comment that's 0.13.0, several
|
|
||||||
# releases behind the current fleet artifact. `project-rebuild` self-heals a
|
|
||||||
# sled that comes back on a stale baked binary (it re-upgrades it in place
|
|
||||||
# from the gateway's published artifact), but that costs an extra
|
|
||||||
# download+restart cycle every time. Run `script/build-project-images` after
|
|
||||||
# every `script/release` — ideally as a step in the release flow itself — so
|
|
||||||
# freshly rebuilt sleds start on a current binary instead of relying on the
|
|
||||||
# self-heal.
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
|||||||
+18
-4
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Pre-commit quality gate: fmt-check, clippy, cargo check, and doc-coverage.
|
# Pre-commit quality gate: fmt-check, clippy, duplication, cargo check, and
|
||||||
# Run this before committing to catch fmt drift, clippy warnings, compile
|
# doc-coverage. Run this before committing to catch fmt drift, clippy
|
||||||
# errors, and missing doc comments without waiting for the full test suite.
|
# warnings, duplicate code, compile errors, and missing doc comments without
|
||||||
|
# waiting for the full test suite.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
@@ -11,7 +12,20 @@ echo "=== Checking Rust formatting ==="
|
|||||||
cargo fmt --manifest-path "$PROJECT_ROOT/Cargo.toml" --all --check
|
cargo fmt --manifest-path "$PROJECT_ROOT/Cargo.toml" --all --check
|
||||||
|
|
||||||
echo "=== Running cargo clippy ==="
|
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 ==="
|
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
|
cargo run --manifest-path "$PROJECT_ROOT/Cargo.toml" -p source-map-gen --bin source-map-check --quiet -- --worktree "$PROJECT_ROOT" --base master
|
||||||
|
|||||||
+13
-10
@@ -1,16 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# NOTE (story 1231): this script publishes the `huskies` binary artifact
|
|
||||||
# (consumed by the `upgrade`/`upgrade all` chat commands) but does NOT
|
|
||||||
# rebuild the huskies-project-* Docker images that `project-rebuild` uses.
|
|
||||||
# Those images bake whatever binary was locally built the last time
|
|
||||||
# `script/build-project-images` ran, so they silently drift stale otherwise
|
|
||||||
# (currently baking 0.13.0). Run `script/build-project-images` after this
|
|
||||||
# script — or better, fold it into this release flow — so a `project-rebuild`
|
|
||||||
# right after a release doesn't need to self-heal a stale sled back up to the
|
|
||||||
# version just published here.
|
|
||||||
|
|
||||||
# ── Configuration ──────────────────────────────────────────────
|
# ── Configuration ──────────────────────────────────────────────
|
||||||
GITEA_URL="https://code.crashlabs.io"
|
GITEA_URL="https://code.crashlabs.io"
|
||||||
REPO="crashlabs/huskies"
|
REPO="crashlabs/huskies"
|
||||||
@@ -97,6 +87,19 @@ cross build --release --target x86_64-unknown-linux-musl
|
|||||||
echo "==> Building Linux arm64 (static musl via cross)..."
|
echo "==> Building Linux arm64 (static musl via cross)..."
|
||||||
cross build --release --target aarch64-unknown-linux-musl
|
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 ────────────────────────────────────────────────────
|
# ── Package ────────────────────────────────────────────────────
|
||||||
DIST="target/dist"
|
DIST="target/dist"
|
||||||
rm -rf "$DIST"
|
rm -rf "$DIST"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "huskies"
|
name = "huskies"
|
||||||
version = "0.14.4"
|
version = "0.14.5"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@@ -299,10 +299,17 @@ impl AgentPool {
|
|||||||
crate::db::write_content(crate::db::ContentKey::MergeSuccess(&sid), "1");
|
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 {
|
match &report {
|
||||||
Ok(r) => {
|
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(
|
crate::crdt_state::write_merge_job(
|
||||||
&sid,
|
&sid,
|
||||||
"completed",
|
"completed",
|
||||||
@@ -312,12 +319,15 @@ impl AgentPool {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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(
|
crate::crdt_state::write_merge_job(
|
||||||
&sid,
|
&sid,
|
||||||
"failed",
|
"failed",
|
||||||
started_at,
|
started_at,
|
||||||
Some(finished_at),
|
Some(finished_at),
|
||||||
Some(e),
|
Some(&bounded),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,39 +184,11 @@ pub(crate) async fn on_terminal_transition(project_root: &Path, story_id: &str)
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::worktree::test_support::init_git_repo;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn init_git_repo(dir: &Path) {
|
|
||||||
use crate::git_test_support::git_ok;
|
|
||||||
git_ok(
|
|
||||||
Command::new("git").args(["init"]).current_dir(dir).output(),
|
|
||||||
"git init",
|
|
||||||
);
|
|
||||||
git_ok(
|
|
||||||
Command::new("git")
|
|
||||||
.args(["config", "user.email", "test@test.com"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output(),
|
|
||||||
"git config user.email",
|
|
||||||
);
|
|
||||||
git_ok(
|
|
||||||
Command::new("git")
|
|
||||||
.args(["config", "user.name", "Test"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output(),
|
|
||||||
"git config user.name",
|
|
||||||
);
|
|
||||||
git_ok(
|
|
||||||
Command::new("git")
|
|
||||||
.args(["commit", "--allow-empty", "-m", "init"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output(),
|
|
||||||
"git commit",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setup_project(tmp: &TempDir) -> PathBuf {
|
fn setup_project(tmp: &TempDir) -> PathBuf {
|
||||||
let root = tmp.path().join("project");
|
let root = tmp.path().join("project");
|
||||||
fs::create_dir_all(root.join(".huskies")).unwrap();
|
fs::create_dir_all(root.join(".huskies")).unwrap();
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ use serde_json::{Value, json};
|
|||||||
|
|
||||||
use super::super::super::TokenUsage;
|
use super::super::super::TokenUsage;
|
||||||
use super::super::RuntimeContext;
|
use super::super::RuntimeContext;
|
||||||
|
use super::super::api_common::build_system_text;
|
||||||
|
|
||||||
// ── Gemini API types ─────────────────────────────────────────────────
|
// ── Gemini API types ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -19,26 +20,8 @@ pub(super) struct GeminiFunctionDeclaration {
|
|||||||
|
|
||||||
/// Build the system instruction content from the RuntimeContext.
|
/// Build the system instruction content from the RuntimeContext.
|
||||||
pub(super) fn build_system_instruction(ctx: &RuntimeContext) -> Value {
|
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!({
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::super::super::api_common::test_runtime_context;
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn build_system_instruction_uses_args() {
|
fn build_system_instruction_uses_args() {
|
||||||
let ctx = RuntimeContext {
|
let ctx = test_runtime_context(
|
||||||
story_id: "42_story_test".to_string(),
|
"gemini-2.5-pro",
|
||||||
agent_name: "coder-1".to_string(),
|
vec![
|
||||||
command: "gemini-2.5-pro".to_string(),
|
|
||||||
args: vec![
|
|
||||||
"--append-system-prompt".to_string(),
|
"--append-system-prompt".to_string(),
|
||||||
"Custom 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);
|
let instruction = build_system_instruction(&ctx);
|
||||||
assert_eq!(instruction["parts"][0]["text"], "Custom system prompt");
|
assert_eq!(instruction["parts"][0]["text"], "Custom system prompt");
|
||||||
@@ -127,20 +94,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_system_instruction_default() {
|
fn build_system_instruction_default() {
|
||||||
let ctx = RuntimeContext {
|
let ctx = test_runtime_context("gemini-2.5-pro", vec![]);
|
||||||
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 instruction = build_system_instruction(&ctx);
|
let instruction = build_system_instruction(&ctx);
|
||||||
let text = instruction["parts"][0]["text"].as_str().unwrap();
|
let text = instruction["parts"][0]["text"].as_str().unwrap();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::slog;
|
|||||||
|
|
||||||
use crate::http::mcp::tools_list::list_tools;
|
use crate::http::mcp::tools_list::list_tools;
|
||||||
|
|
||||||
|
use super::super::api_common::clean_schema_properties;
|
||||||
use super::api::GeminiFunctionDeclaration;
|
use super::api::GeminiFunctionDeclaration;
|
||||||
|
|
||||||
// ── MCP tool loading ────────────────────────────────────────────────
|
// ── MCP tool loading ────────────────────────────────────────────────
|
||||||
@@ -62,7 +63,7 @@ pub(super) fn convert_mcp_schema_to_gemini(schema: Option<&Value>) -> Option<Val
|
|||||||
|
|
||||||
let mut result = json!({
|
let mut result = json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": clean_schema_properties(properties),
|
"properties": clean_schema_properties(properties, true),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Preserve required fields if present.
|
// Preserve required fields if present.
|
||||||
@@ -73,44 +74,6 @@ pub(super) fn convert_mcp_schema_to_gemini(schema: Option<&Value>) -> Option<Val
|
|||||||
Some(result)
|
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 ────────────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -170,45 +133,4 @@ mod tests {
|
|||||||
assert!(name_prop.get("$schema").is_none());
|
assert!(name_prop.get("$schema").is_none());
|
||||||
assert_eq!(name_prop["type"], "string");
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! Gemini runtime — drives Google Gemini API sessions as agent backends.
|
//! 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 std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
@@ -10,7 +10,10 @@ use crate::agent_log::AgentLogWriter;
|
|||||||
use crate::http::mcp::dispatch::dispatch_tool_call;
|
use crate::http::mcp::dispatch::dispatch_tool_call;
|
||||||
use crate::slog;
|
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};
|
use super::{AgentRuntime, RuntimeContext, RuntimeResult, RuntimeStatus};
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
@@ -40,14 +43,14 @@ struct GeminiFunctionCall {
|
|||||||
/// 6. Tracks token usage from the API response metadata.
|
/// 6. Tracks token usage from the API response metadata.
|
||||||
pub struct GeminiRuntime {
|
pub struct GeminiRuntime {
|
||||||
/// Whether a stop has been requested.
|
/// Whether a stop has been requested.
|
||||||
cancelled: Arc<AtomicBool>,
|
cancelled: CancellationFlag,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GeminiRuntime {
|
impl GeminiRuntime {
|
||||||
/// Create a new Gemini runtime instance.
|
/// Create a new Gemini runtime instance.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
cancelled: Arc::new(AtomicBool::new(false)),
|
cancelled: CancellationFlag::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,19 +69,7 @@ impl AgentRuntime for GeminiRuntime {
|
|||||||
.to_string()
|
.to_string()
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let model = if ctx.command.starts_with("gemini") {
|
let model = extract_model(&ctx, |c| c.starts_with("gemini"), "gemini-2.5-pro");
|
||||||
// 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 app_ctx = ctx
|
let app_ctx = ctx
|
||||||
.app_ctx
|
.app_ctx
|
||||||
@@ -86,7 +77,7 @@ impl AgentRuntime for GeminiRuntime {
|
|||||||
.ok_or_else(|| "Gemini runtime requires app_ctx to be set".to_string())?;
|
.ok_or_else(|| "Gemini runtime requires app_ctx to be set".to_string())?;
|
||||||
|
|
||||||
let client = Client::new();
|
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.
|
// Step 1: Load MCP tool definitions and convert to Gemini format.
|
||||||
let gemini_tools = convert_mcp_tools_to_gemini();
|
let gemini_tools = convert_mcp_tools_to_gemini();
|
||||||
@@ -98,65 +89,14 @@ impl AgentRuntime for GeminiRuntime {
|
|||||||
"parts": [{ "text": ctx.prompt }]
|
"parts": [{ "text": ctx.prompt }]
|
||||||
})];
|
})];
|
||||||
|
|
||||||
let mut total_usage = TokenUsage {
|
let (emit, mut total_usage, mut turn) =
|
||||||
input_tokens: 0,
|
start_conversation_loop(&ctx, tx, event_log, log_writer);
|
||||||
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(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 3: Conversation loop.
|
// Step 3: Conversation loop.
|
||||||
let mut turn = 0u32;
|
|
||||||
let max_turns = 200; // Safety limit
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if cancelled.load(Ordering::Relaxed) {
|
if let Some(result) = check_loop_guard(&ctx, &cancelled, &mut turn, &total_usage, &emit)
|
||||||
emit(AgentEvent::Error {
|
{
|
||||||
story_id: ctx.story_id.clone(),
|
return Ok(result);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
slog!(
|
slog!(
|
||||||
@@ -248,19 +188,7 @@ impl AgentRuntime for GeminiRuntime {
|
|||||||
|
|
||||||
// If no function calls, the model is done.
|
// If no function calls, the model is done.
|
||||||
if function_calls.is_empty() {
|
if function_calls.is_empty() {
|
||||||
emit(AgentEvent::Done {
|
return Ok(done_result(&ctx, &emit, total_usage));
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the model's response to the conversation.
|
// Add the model's response to the conversation.
|
||||||
@@ -333,32 +261,15 @@ impl AgentRuntime for GeminiRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(AgentEvent::Done {
|
Ok(done_result(&ctx, &emit, total_usage))
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&self) {
|
fn stop(&self) {
|
||||||
self.cancelled.store(true, Ordering::Relaxed);
|
self.cancelled.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_status(&self) -> RuntimeStatus {
|
fn get_status(&self) -> RuntimeStatus {
|
||||||
if self.cancelled.load(Ordering::Relaxed) {
|
self.cancelled.status()
|
||||||
RuntimeStatus::Failed
|
|
||||||
} else {
|
|
||||||
RuntimeStatus::Idle
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,13 +277,8 @@ impl AgentRuntime for GeminiRuntime {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::super::api_common::test_runtime_context;
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn gemini_runtime_stop_sets_cancelled() {
|
fn gemini_runtime_stop_sets_cancelled() {
|
||||||
@@ -385,20 +291,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn model_extraction_from_command() {
|
fn model_extraction_from_command() {
|
||||||
// When command starts with "gemini", use it as model name
|
// When command starts with "gemini", use it as model name
|
||||||
let ctx = RuntimeContext {
|
let ctx = test_runtime_context("gemini-2.5-pro", vec![]);
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
// The model extraction logic is inside start(), but we test the
|
// The model extraction logic is inside start(), but we test the
|
||||||
// condition here.
|
// condition here.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! Agent runtimes — pluggable backends (Claude Code, Gemini, OpenAI) for running agents.
|
//! Agent runtimes — pluggable backends (Claude Code, Gemini, OpenAI) for running agents.
|
||||||
|
mod api_common;
|
||||||
mod claude_code;
|
mod claude_code;
|
||||||
mod gemini;
|
mod gemini;
|
||||||
mod openai;
|
mod openai;
|
||||||
@@ -132,12 +133,7 @@ pub trait AgentRuntime: Send + Sync {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::http::context::AppContext;
|
use api_common::test_app_ctx;
|
||||||
|
|
||||||
fn test_app_ctx() -> Arc<AppContext> {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
Arc::new(AppContext::new_test(tmp.path().to_path_buf()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_context_fields() {
|
fn runtime_context_fields() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! OpenAI Codex runtime — drives OpenAI API sessions as agent backends.
|
//! 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 std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
@@ -12,6 +12,10 @@ use crate::http::mcp::tools_list::list_tools;
|
|||||||
use crate::slog;
|
use crate::slog;
|
||||||
|
|
||||||
use super::super::{AgentEvent, TokenUsage};
|
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};
|
use super::{AgentRuntime, RuntimeContext, RuntimeResult, RuntimeStatus};
|
||||||
|
|
||||||
// ── Public runtime struct ────────────────────────────────────────────
|
// ── Public runtime struct ────────────────────────────────────────────
|
||||||
@@ -28,14 +32,14 @@ use super::{AgentRuntime, RuntimeContext, RuntimeResult, RuntimeStatus};
|
|||||||
/// 6. Tracks token usage from the API response.
|
/// 6. Tracks token usage from the API response.
|
||||||
pub struct OpenAiRuntime {
|
pub struct OpenAiRuntime {
|
||||||
/// Whether a stop has been requested.
|
/// Whether a stop has been requested.
|
||||||
cancelled: Arc<AtomicBool>,
|
cancelled: CancellationFlag,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenAiRuntime {
|
impl OpenAiRuntime {
|
||||||
/// Create a new OpenAI runtime instance.
|
/// Create a new OpenAI runtime instance.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
cancelled: Arc::new(AtomicBool::new(false)),
|
cancelled: CancellationFlag::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,18 +58,11 @@ impl AgentRuntime for OpenAiRuntime {
|
|||||||
.to_string()
|
.to_string()
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let model = if ctx.command.starts_with("gpt") || ctx.command.starts_with("o") {
|
let model = extract_model(
|
||||||
// The pool puts the model into `command` for non-CLI runtimes.
|
&ctx,
|
||||||
ctx.command.clone()
|
|c| c.starts_with("gpt") || c.starts_with("o"),
|
||||||
} else {
|
"gpt-4o",
|
||||||
// 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 app_ctx = ctx
|
let app_ctx = ctx
|
||||||
.app_ctx
|
.app_ctx
|
||||||
@@ -73,7 +70,7 @@ impl AgentRuntime for OpenAiRuntime {
|
|||||||
.ok_or_else(|| "OpenAI runtime requires app_ctx to be set".to_string())?;
|
.ok_or_else(|| "OpenAI runtime requires app_ctx to be set".to_string())?;
|
||||||
|
|
||||||
let client = Client::new();
|
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.
|
// Step 1: Fetch MCP tool definitions and convert to OpenAI format.
|
||||||
let openai_tools = convert_mcp_tools_to_openai();
|
let openai_tools = convert_mcp_tools_to_openai();
|
||||||
@@ -85,65 +82,14 @@ impl AgentRuntime for OpenAiRuntime {
|
|||||||
json!({ "role": "user", "content": ctx.prompt }),
|
json!({ "role": "user", "content": ctx.prompt }),
|
||||||
];
|
];
|
||||||
|
|
||||||
let mut total_usage = TokenUsage {
|
let (emit, mut total_usage, mut turn) =
|
||||||
input_tokens: 0,
|
start_conversation_loop(&ctx, tx, event_log, log_writer);
|
||||||
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(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 3: Conversation loop.
|
// Step 3: Conversation loop.
|
||||||
let mut turn = 0u32;
|
|
||||||
let max_turns = 200; // Safety limit
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if cancelled.load(Ordering::Relaxed) {
|
if let Some(result) = check_loop_guard(&ctx, &cancelled, &mut turn, &total_usage, &emit)
|
||||||
emit(AgentEvent::Error {
|
{
|
||||||
story_id: ctx.story_id.clone(),
|
return Ok(result);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
slog!(
|
slog!(
|
||||||
@@ -218,19 +164,7 @@ impl AgentRuntime for OpenAiRuntime {
|
|||||||
|
|
||||||
if tool_calls.is_none() || tool_calls.is_some_and(|tc| tc.is_empty()) {
|
if tool_calls.is_none() || tool_calls.is_some_and(|tc| tc.is_empty()) {
|
||||||
// No tool calls — model is done.
|
// No tool calls — model is done.
|
||||||
emit(AgentEvent::Done {
|
return Ok(done_result(&ctx, &emit, total_usage));
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let tool_calls = tool_calls.unwrap();
|
let tool_calls = tool_calls.unwrap();
|
||||||
@@ -297,38 +231,16 @@ impl AgentRuntime for OpenAiRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&self) {
|
fn stop(&self) {
|
||||||
self.cancelled.store(true, Ordering::Relaxed);
|
self.cancelled.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_status(&self) -> RuntimeStatus {
|
fn get_status(&self) -> RuntimeStatus {
|
||||||
if self.cancelled.load(Ordering::Relaxed) {
|
self.cancelled.status()
|
||||||
RuntimeStatus::Failed
|
|
||||||
} else {
|
|
||||||
RuntimeStatus::Idle
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helper functions ─────────────────────────────────────────────────
|
// ── 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.
|
/// Load MCP tool definitions directly and convert to OpenAI function-calling format.
|
||||||
fn convert_mcp_tools_to_openai() -> Vec<Value> {
|
fn convert_mcp_tools_to_openai() -> Vec<Value> {
|
||||||
let tools = list_tools();
|
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") {
|
if let Some(properties) = schema.get("properties") {
|
||||||
result["properties"] = clean_schema_properties(properties);
|
result["properties"] = clean_schema_properties(properties, false);
|
||||||
} else {
|
} else {
|
||||||
result["properties"] = json!({});
|
result["properties"] = json!({});
|
||||||
}
|
}
|
||||||
@@ -392,40 +304,6 @@ fn convert_mcp_schema_to_openai(schema: Option<&Value>) -> Option<Value> {
|
|||||||
Some(result)
|
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.
|
/// Parse token usage from an OpenAI API response.
|
||||||
fn parse_usage(response: &Value) -> Option<TokenUsage> {
|
fn parse_usage(response: &Value) -> Option<TokenUsage> {
|
||||||
let usage = response.get("usage")?;
|
let usage = response.get("usage")?;
|
||||||
@@ -449,13 +327,8 @@ fn parse_usage(response: &Value) -> Option<TokenUsage> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::super::api_common::test_runtime_context;
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn convert_mcp_schema_simple_object() {
|
fn convert_mcp_schema_simple_object() {
|
||||||
@@ -512,92 +385,6 @@ mod tests {
|
|||||||
assert_eq!(name_prop["type"], "string");
|
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]
|
#[test]
|
||||||
fn parse_usage_valid() {
|
fn parse_usage_valid() {
|
||||||
let response = json!({
|
let response = json!({
|
||||||
@@ -631,39 +418,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn model_extraction_from_command_gpt() {
|
fn model_extraction_from_command_gpt() {
|
||||||
let ctx = RuntimeContext {
|
let ctx = test_runtime_context("gpt-4o", vec![]);
|
||||||
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,
|
|
||||||
};
|
|
||||||
assert!(ctx.command.starts_with("gpt"));
|
assert!(ctx.command.starts_with("gpt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn model_extraction_from_command_o3() {
|
fn model_extraction_from_command_o3() {
|
||||||
let ctx = RuntimeContext {
|
let ctx = test_runtime_context("o3", vec![]);
|
||||||
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,
|
|
||||||
};
|
|
||||||
assert!(ctx.command.starts_with("o"));
|
assert!(ctx.command.starts_with("o"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ pub mod compact;
|
|||||||
pub mod dispatcher;
|
pub mod dispatcher;
|
||||||
/// Chat history utilities — loading and serialising conversation history.
|
/// Chat history utilities — loading and serialising conversation history.
|
||||||
pub mod history;
|
pub mod history;
|
||||||
|
/// Transport-agnostic LLM turn driver shared by Discord, WhatsApp, etc.
|
||||||
|
pub mod llm_turn;
|
||||||
pub(crate) mod lookup;
|
pub(crate) mod lookup;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) mod test_helpers;
|
pub(crate) mod test_helpers;
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ use std::collections::HashSet;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::chat::ChatTransport;
|
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::chat::util::is_permission_approval;
|
||||||
use crate::http::context::PermissionDecision;
|
use crate::http::context::PermissionDecision;
|
||||||
use crate::services::Services;
|
use crate::services::Services;
|
||||||
@@ -304,196 +306,43 @@ pub(super) async fn handle_incoming_message(
|
|||||||
handle_llm_message(ctx, channel, user, message).await;
|
handle_llm_message(ctx, channel, user, message).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the prompt for a Discord LLM turn, prepending any pending
|
/// [`crate::chat::llm_turn::TransportFormat`] implementation for Discord:
|
||||||
/// CRDT pipeline-transition events as a `<system-reminder>` block.
|
/// single-message replies (no chunking) and no OAuth-link error handling.
|
||||||
fn build_discord_llm_prompt(
|
#[derive(Clone, Copy)]
|
||||||
persona: &str,
|
struct DiscordFormat;
|
||||||
bot_name: &str,
|
|
||||||
user: &str,
|
impl crate::chat::llm_turn::TransportFormat for DiscordFormat {
|
||||||
user_message: &str,
|
fn format_and_chunk(&self, markdown: &str) -> Vec<String> {
|
||||||
) -> String {
|
vec![markdown_to_discord(markdown)]
|
||||||
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}"
|
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.
|
/// 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) {
|
async fn handle_llm_message(ctx: &DiscordContext, channel: &str, user: &str, user_message: &str) {
|
||||||
use crate::chat::util::drain_complete_paragraphs;
|
use crate::chat::llm_turn::{LlmTurnContext, run_llm_turn};
|
||||||
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use tokio::sync::watch;
|
|
||||||
|
|
||||||
// Look up existing session ID for this channel.
|
run_llm_turn(
|
||||||
let resume_session_id: Option<String> = {
|
LlmTurnContext {
|
||||||
let guard = ctx.history.lock().await;
|
transport: Arc::clone(&ctx.transport) as Arc<dyn ChatTransport>,
|
||||||
guard.get(channel).and_then(|conv| conv.session_id.clone())
|
key: channel,
|
||||||
};
|
user,
|
||||||
|
services: &ctx.services,
|
||||||
let bot_name = &ctx.services.bot_name;
|
history: &ctx.history,
|
||||||
let persona = bot_name.to_lowercase();
|
history_size: ctx.history_size,
|
||||||
let prompt = build_discord_llm_prompt(&persona, bot_name, user, user_message);
|
save_history: save_discord_history,
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|_thinking| {},
|
DiscordFormat,
|
||||||
|_activity| {},
|
user_message,
|
||||||
);
|
)
|
||||||
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;
|
.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tests ───────────────────────────────────────────────────────────────
|
// ── Tests ───────────────────────────────────────────────────────────────
|
||||||
@@ -635,8 +484,12 @@ mod tests {
|
|||||||
at: chrono::Utc::now(),
|
at: chrono::Utc::now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let prompt =
|
let prompt = crate::chat::llm_turn::build_llm_prompt(
|
||||||
build_discord_llm_prompt("discord-ch-test", "Timmy", "@alice", "what is the status?");
|
"discord-ch-test",
|
||||||
|
"Timmy",
|
||||||
|
"@alice",
|
||||||
|
"what is the status?",
|
||||||
|
);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
prompt.contains("<system-reminder>"),
|
prompt.contains("<system-reminder>"),
|
||||||
|
|||||||
@@ -924,7 +924,6 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
|||||||
rebuild_cmd.drain_timeout_secs,
|
rebuild_cmd.drain_timeout_secs,
|
||||||
rebuild_cmd.force,
|
rebuild_cmd.force,
|
||||||
store,
|
store,
|
||||||
ctx.gateway_port,
|
|
||||||
&ctx.services.project_root,
|
&ctx.services.project_root,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ use crate::service::gateway::io::save_config;
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
/// Default seconds to wait for in-flight work to drain before refusing.
|
/// Default seconds to wait for in-flight work to drain before refusing.
|
||||||
@@ -102,7 +101,6 @@ pub async fn handle_project_rebuild(
|
|||||||
drain_timeout_secs: u64,
|
drain_timeout_secs: u64,
|
||||||
force: bool,
|
force: bool,
|
||||||
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
||||||
gateway_port: Option<u16>,
|
|
||||||
config_dir: &Path,
|
config_dir: &Path,
|
||||||
) -> String {
|
) -> String {
|
||||||
// ── 1. Validate project ──────────────────────────────────────────────────
|
// ── 1. Validate project ──────────────────────────────────────────────────
|
||||||
@@ -142,21 +140,6 @@ pub async fn handle_project_rebuild(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 1b. Capture the running binary's git_hash before we tear it down ──────
|
|
||||||
// Best-effort: an unreachable or pre-version-endpoint sled just yields `None`,
|
|
||||||
// in which case the post-rebuild reconciliation below can't compare old vs
|
|
||||||
// new and falls back to whatever the published fleet artifact says.
|
|
||||||
let http_client = reqwest::Client::builder()
|
|
||||||
.timeout(Duration::from_secs(15))
|
|
||||||
.build()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let old_git_hash = match project_url.as_deref() {
|
|
||||||
Some(url) => super::sled_upgrade::fetch_sled_version(&http_client, url)
|
|
||||||
.await
|
|
||||||
.map(|(_version, hash)| hash),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 2. Drain check ───────────────────────────────────────────────────────
|
// ── 2. Drain check ───────────────────────────────────────────────────────
|
||||||
let container_name = format!("huskies-{name}");
|
let container_name = format!("huskies-{name}");
|
||||||
if !force
|
if !force
|
||||||
@@ -317,20 +300,6 @@ pub async fn handle_project_rebuild(
|
|||||||
|
|
||||||
crate::slog!("[project-rebuild] Rebuilt '{name}': image={image_hash} container={container_id}");
|
crate::slog!("[project-rebuild] Rebuilt '{name}': image={image_hash} container={container_id}");
|
||||||
|
|
||||||
// ── 7. Make sure the rebuilt sled isn't running an older binary than before ──
|
|
||||||
let artifact_source = super::sled_upgrade::resolve_artifact_source(gateway_port);
|
|
||||||
let version_note = reconcile_post_rebuild_version(
|
|
||||||
name,
|
|
||||||
old_git_hash.as_deref(),
|
|
||||||
&container_url,
|
|
||||||
artifact_source,
|
|
||||||
projects_store,
|
|
||||||
config_dir,
|
|
||||||
&http_client,
|
|
||||||
60,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
"Project **{name}** rebuilt.\n\
|
"Project **{name}** rebuilt.\n\
|
||||||
- New image: `{image}` (`{image_short}…`)\n\
|
- New image: `{image}` (`{image_short}…`)\n\
|
||||||
@@ -338,103 +307,10 @@ pub async fn handle_project_rebuild(
|
|||||||
- State: `pipeline.db` and CRDT preserved (same volume bind-mount)\n\
|
- State: `pipeline.db` and CRDT preserved (same volume bind-mount)\n\
|
||||||
- Port: {port} (unchanged)\n\
|
- Port: {port} (unchanged)\n\
|
||||||
\n\
|
\n\
|
||||||
Use `switch {name}` then `status` to verify the pipeline.{version_note}"
|
Use `switch {name}` then `status` to verify the pipeline."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure the freshly-rebuilt sled at `container_url` isn't running an older
|
|
||||||
/// binary than the one it had before the rebuild (AC 1–3, story 1231).
|
|
||||||
///
|
|
||||||
/// Base images are only refreshed by a manual `script/build-project-images`
|
|
||||||
/// run, so `project-rebuild` can otherwise silently downgrade a sled that was
|
|
||||||
/// live-upgraded past whatever binary is baked into the image. This waits for
|
|
||||||
/// the new container to come up, compares its `git_hash` against the
|
|
||||||
/// gateway's published fleet artifact, and — when they differ — self-heals by
|
|
||||||
/// running the same in-place upgrade `upgrade <name>` uses. When there's no
|
|
||||||
/// published artifact to converge to, it falls back to comparing against the
|
|
||||||
/// pre-rebuild hash so a real downgrade is at least surfaced as a warning
|
|
||||||
/// instead of passing silently.
|
|
||||||
///
|
|
||||||
/// `artifact_source` is the already-resolved published-artifact lookup (see
|
|
||||||
/// [`super::sled_upgrade::resolve_artifact_source`]) — `Err` means no fleet
|
|
||||||
/// artifact has been published yet.
|
|
||||||
///
|
|
||||||
/// Returns a Markdown snippet (starting with `\n\n`) to append to the rebuild
|
|
||||||
/// reply, or an empty string when the sled already matches the current build.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
async fn reconcile_post_rebuild_version(
|
|
||||||
name: &str,
|
|
||||||
old_git_hash: Option<&str>,
|
|
||||||
container_url: &str,
|
|
||||||
artifact_source: Result<(String, Option<String>), String>,
|
|
||||||
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
|
||||||
config_dir: &Path,
|
|
||||||
client: &reqwest::Client,
|
|
||||||
health_timeout_secs: u64,
|
|
||||||
) -> String {
|
|
||||||
let health_url = format!("{}/health", container_url.trim_end_matches('/'));
|
|
||||||
if !super::sled_upgrade::wait_for_health(client, &health_url, health_timeout_secs).await {
|
|
||||||
return format!(
|
|
||||||
"\n\n⚠️ **Warning:** the rebuilt sled did not respond to `/health` within 60s — \
|
|
||||||
cannot verify its binary version. Check `docker logs huskies-{name}` and run \
|
|
||||||
`upgrade {name}` once it's reachable."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some((_new_version, new_hash)) =
|
|
||||||
super::sled_upgrade::fetch_sled_version(client, container_url).await
|
|
||||||
else {
|
|
||||||
return "\n\n⚠️ **Warning:** the rebuilt sled is healthy but `/api/version` is unavailable — \
|
|
||||||
cannot verify it isn't running a stale binary baked into the base image."
|
|
||||||
.to_string();
|
|
||||||
};
|
|
||||||
|
|
||||||
let (source_url, expected_hash) = match artifact_source {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(_) => {
|
|
||||||
// No published fleet artifact to converge to — the best we can do is
|
|
||||||
// flag an actual change from what was running before the rebuild.
|
|
||||||
return match old_git_hash {
|
|
||||||
Some(old) if old != new_hash => format!(
|
|
||||||
"\n\n⚠️ **Warning:** the rebuilt sled is now on `{new_hash}` (was `{old}`) and \
|
|
||||||
no fleet artifact is published to verify or fix this. Run `release` then \
|
|
||||||
`upgrade {name}`."
|
|
||||||
),
|
|
||||||
_ => String::new(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if expected_hash.as_deref() == Some(new_hash.as_str()) {
|
|
||||||
return String::new(); // base image already bakes the current fleet build.
|
|
||||||
}
|
|
||||||
|
|
||||||
// The base image baked an older (or merely different) binary than the fleet
|
|
||||||
// is currently on — self-heal the same way `upgrade <name>` would.
|
|
||||||
let outcome = super::sled_upgrade::run_sled_upgrade(
|
|
||||||
name,
|
|
||||||
container_url,
|
|
||||||
&source_url,
|
|
||||||
expected_hash,
|
|
||||||
projects_store,
|
|
||||||
config_dir,
|
|
||||||
|_| async {},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if outcome.contains("matches published artifact") || outcome.starts_with("upgraded to v") {
|
|
||||||
format!(
|
|
||||||
"\n\n♻️ The base image baked an older binary (`{new_hash}`); self-healed in place — \
|
|
||||||
{outcome}"
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"\n\n⚠️ **Warning:** the base image baked an older binary (`{new_hash}`) and the \
|
|
||||||
automatic self-heal failed: {outcome}\nRun `upgrade {name}` manually."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wait for active Claude agent processes in the container to exit.
|
/// Wait for active Claude agent processes in the container to exit.
|
||||||
///
|
///
|
||||||
/// Polls every 5 seconds until no `claude` processes remain or `timeout_secs` elapses.
|
/// Polls every 5 seconds until no `claude` processes remain or `timeout_secs` elapses.
|
||||||
@@ -634,7 +510,7 @@ mod tests {
|
|||||||
async fn rebuild_unknown_project_returns_error() {
|
async fn rebuild_unknown_project_returns_error() {
|
||||||
let store = make_store(vec![]);
|
let store = make_store(vec![]);
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let result = handle_project_rebuild("nonexistent", 0, true, &store, None, dir.path()).await;
|
let result = handle_project_rebuild("nonexistent", 0, true, &store, dir.path()).await;
|
||||||
assert!(
|
assert!(
|
||||||
result.contains("not found"),
|
result.contains("not found"),
|
||||||
"expected 'not found': {result}"
|
"expected 'not found': {result}"
|
||||||
@@ -654,7 +530,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
)]);
|
)]);
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let result = handle_project_rebuild("myapp", 0, true, &store, None, dir.path()).await;
|
let result = handle_project_rebuild("myapp", 0, true, &store, dir.path()).await;
|
||||||
assert!(
|
assert!(
|
||||||
result.contains("host_path"),
|
result.contains("host_path"),
|
||||||
"expected 'host_path' mention: {result}"
|
"expected 'host_path' mention: {result}"
|
||||||
@@ -674,7 +550,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
)]);
|
)]);
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let result = handle_project_rebuild("myapp", 0, true, &store, None, dir.path()).await;
|
let result = handle_project_rebuild("myapp", 0, true, &store, dir.path()).await;
|
||||||
assert!(
|
assert!(
|
||||||
result.contains("does not exist"),
|
result.contains("does not exist"),
|
||||||
"expected 'does not exist': {result}"
|
"expected 'does not exist': {result}"
|
||||||
@@ -710,8 +586,7 @@ mod tests {
|
|||||||
)]);
|
)]);
|
||||||
let config_dir = tempfile::tempdir().unwrap();
|
let config_dir = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
let result =
|
let result = handle_project_rebuild("myapp", 0, true, &store, config_dir.path()).await;
|
||||||
handle_project_rebuild("myapp", 0, true, &store, None, config_dir.path()).await;
|
|
||||||
|
|
||||||
// (a) Step naming: one of several possible failure steps depending on what Docker
|
// (a) Step naming: one of several possible failure steps depending on what Docker
|
||||||
// binaries are available in the test environment, or a success reply.
|
// binaries are available in the test environment, or a success reply.
|
||||||
@@ -732,181 +607,4 @@ mod tests {
|
|||||||
"project 'myapp' must remain registered after failed rebuild: {result}"
|
"project 'myapp' must remain registered after failed rebuild: {result}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── reconcile_post_rebuild_version (AC 1–3) ────────────────────────────
|
|
||||||
|
|
||||||
/// Spawn a minimal HTTP server that answers every `/health` request with
|
|
||||||
/// 200 and every `/api/version` request with `git_hash`. Serves
|
|
||||||
/// connections in a loop so both `wait_for_health` and
|
|
||||||
/// `fetch_sled_version` can hit it independently within one test.
|
|
||||||
fn spawn_version_server(git_hash: &str) -> (String, tokio::task::JoinHandle<()>) {
|
|
||||||
let git_hash = git_hash.to_string();
|
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
||||||
listener.set_nonblocking(true).unwrap();
|
|
||||||
let listener = tokio::net::TcpListener::from_std(listener).unwrap();
|
|
||||||
let port = listener.local_addr().unwrap().port();
|
|
||||||
let handle = tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
let Ok((mut stream, _)) = listener.accept().await else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
let git_hash = git_hash.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
let mut buf = [0u8; 4096];
|
|
||||||
let n = stream.read(&mut buf).await.unwrap_or(0);
|
|
||||||
let req = String::from_utf8_lossy(&buf[..n]);
|
|
||||||
let path = req.lines().next().unwrap_or("").to_string();
|
|
||||||
let body = if path.contains("/api/version") {
|
|
||||||
serde_json::json!({"version": "0.14.2", "git_hash": git_hash}).to_string()
|
|
||||||
} else {
|
|
||||||
"ok".to_string()
|
|
||||||
};
|
|
||||||
let resp = format!(
|
|
||||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
|
||||||
body.len(),
|
|
||||||
body
|
|
||||||
);
|
|
||||||
let _ = stream.write_all(resp.as_bytes()).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
(format!("http://127.0.0.1:{port}"), handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reconcile_empty_when_new_hash_matches_published_artifact() {
|
|
||||||
let (url, srv) = spawn_version_server("current-hash");
|
|
||||||
let store = make_store(vec![]);
|
|
||||||
let config_dir = tempfile::tempdir().unwrap();
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
let note = reconcile_post_rebuild_version(
|
|
||||||
"myapp",
|
|
||||||
Some("old-hash"),
|
|
||||||
&url,
|
|
||||||
Ok((
|
|
||||||
"http://unused".to_string(),
|
|
||||||
Some("current-hash".to_string()),
|
|
||||||
)),
|
|
||||||
&store,
|
|
||||||
config_dir.path(),
|
|
||||||
&client,
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert_eq!(note, "", "already-current build must produce no note");
|
|
||||||
srv.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reconcile_empty_when_no_artifact_and_hash_unchanged() {
|
|
||||||
let (url, srv) = spawn_version_server("same-hash");
|
|
||||||
let store = make_store(vec![]);
|
|
||||||
let config_dir = tempfile::tempdir().unwrap();
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
let note = reconcile_post_rebuild_version(
|
|
||||||
"myapp",
|
|
||||||
Some("same-hash"),
|
|
||||||
&url,
|
|
||||||
Err("no published artifact".to_string()),
|
|
||||||
&store,
|
|
||||||
config_dir.path(),
|
|
||||||
&client,
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert_eq!(note, "", "unchanged hash must produce no note");
|
|
||||||
srv.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reconcile_warns_when_no_artifact_and_hash_changed() {
|
|
||||||
let (url, srv) = spawn_version_server("stale-baked-hash");
|
|
||||||
let store = make_store(vec![]);
|
|
||||||
let config_dir = tempfile::tempdir().unwrap();
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
let note = reconcile_post_rebuild_version(
|
|
||||||
"myapp",
|
|
||||||
Some("newer-hash-that-was-running"),
|
|
||||||
&url,
|
|
||||||
Err("no published artifact".to_string()),
|
|
||||||
&store,
|
|
||||||
config_dir.path(),
|
|
||||||
&client,
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(note.contains("Warning"), "expected a warning: {note}");
|
|
||||||
assert!(
|
|
||||||
note.contains("stale-baked-hash") && note.contains("newer-hash-that-was-running"),
|
|
||||||
"warning should name both hashes: {note}"
|
|
||||||
);
|
|
||||||
srv.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reconcile_warns_when_health_probe_fails() {
|
|
||||||
let store = make_store(vec![]);
|
|
||||||
let config_dir = tempfile::tempdir().unwrap();
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_millis(200))
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let note = reconcile_post_rebuild_version(
|
|
||||||
"myapp",
|
|
||||||
Some("old-hash"),
|
|
||||||
"http://127.0.0.1:1", // nothing listens on port 1
|
|
||||||
Err("no published artifact".to_string()),
|
|
||||||
&store,
|
|
||||||
config_dir.path(),
|
|
||||||
&client,
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
note.contains("did not respond to `/health`"),
|
|
||||||
"expected a health-probe warning: {note}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reconcile_attempts_self_heal_and_reports_failure_when_artifact_unreachable() {
|
|
||||||
let (url, srv) = spawn_version_server("stale-baked-hash");
|
|
||||||
let store = make_store(vec![]);
|
|
||||||
let config_dir = tempfile::tempdir().unwrap();
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
let note = reconcile_post_rebuild_version(
|
|
||||||
"myapp",
|
|
||||||
Some("stale-baked-hash"),
|
|
||||||
&url,
|
|
||||||
Ok((
|
|
||||||
"http://127.0.0.1:1/api/artifacts/huskies-linux-arm64".to_string(),
|
|
||||||
Some("current-fleet-hash".to_string()),
|
|
||||||
)),
|
|
||||||
&store,
|
|
||||||
config_dir.path(),
|
|
||||||
&client,
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
note.contains("self-heal failed"),
|
|
||||||
"mismatch should trigger a self-heal attempt that reports failure: {note}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
note.contains("stale-baked-hash"),
|
|
||||||
"note should name the stale hash: {note}"
|
|
||||||
);
|
|
||||||
srv.abort();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,204 +2,56 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
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::WhatsAppWebhookContext;
|
||||||
use super::super::format::{chunk_for_whatsapp, markdown_to_whatsapp};
|
use super::super::format::{chunk_for_whatsapp, markdown_to_whatsapp};
|
||||||
use super::super::history::save_whatsapp_history;
|
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(
|
pub(super) async fn handle_llm_message(
|
||||||
ctx: &WhatsAppWebhookContext,
|
ctx: &WhatsAppWebhookContext,
|
||||||
sender: &str,
|
sender: &str,
|
||||||
user_message: &str,
|
user_message: &str,
|
||||||
) {
|
) {
|
||||||
use crate::chat::util::drain_complete_paragraphs;
|
use crate::chat::llm_turn::{LlmTurnContext, run_llm_turn};
|
||||||
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use tokio::sync::watch;
|
|
||||||
|
|
||||||
// Look up existing session ID for this sender.
|
run_llm_turn(
|
||||||
let resume_session_id: Option<String> = {
|
LlmTurnContext {
|
||||||
let guard = ctx.history.lock().await;
|
transport: Arc::clone(&ctx.transport),
|
||||||
guard.get(sender).and_then(|conv| conv.session_id.clone())
|
key: sender,
|
||||||
};
|
user: sender,
|
||||||
|
services: &ctx.services,
|
||||||
let bot_name = &ctx.services.bot_name;
|
history: &ctx.history,
|
||||||
let persona = bot_name.to_lowercase();
|
history_size: ctx.history_size,
|
||||||
let event_ctx = crate::llm_session::assemble_prompt_context(&persona);
|
save_history: save_whatsapp_history,
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|_thinking| {},
|
WhatsAppFormat,
|
||||||
|_activity| {},
|
user_message,
|
||||||
);
|
)
|
||||||
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;
|
.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tests ───────────────────────────────────────────────────────────────
|
// ── Tests ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -155,6 +155,12 @@ pub struct ProjectConfig {
|
|||||||
/// (disabled) — the on-demand `gc` MCP tool remains available regardless.
|
/// (disabled) — the on-demand `gc` MCP tool remains available regardless.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gc_min_free_gb: u64,
|
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.
|
/// Configuration for the filesystem watcher's sweep behaviour.
|
||||||
@@ -255,6 +261,11 @@ fn default_max_mesh_peers() -> usize {
|
|||||||
3
|
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.
|
/// Configuration for the low-disk-space watchdog's free-space thresholds.
|
||||||
///
|
///
|
||||||
/// Sleds check free space on the `/workspace` filesystem each tick and
|
/// 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(),
|
status_push_enabled: default_status_push_enabled(),
|
||||||
merge_failure_block_threshold: default_merge_failure_block_threshold(),
|
merge_failure_block_threshold: default_merge_failure_block_threshold(),
|
||||||
gc_min_free_gb: 0,
|
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(),
|
status_push_enabled: default_status_push_enabled(),
|
||||||
merge_failure_block_threshold: default_merge_failure_block_threshold(),
|
merge_failure_block_threshold: default_merge_failure_block_threshold(),
|
||||||
gc_min_free_gb: 0,
|
gc_min_free_gb: 0,
|
||||||
|
snapshot_interval_ops: default_snapshot_interval_ops(),
|
||||||
};
|
};
|
||||||
validate_agents(&config.agent)?;
|
validate_agents(&config.agent)?;
|
||||||
return Ok(config);
|
return Ok(config);
|
||||||
@@ -607,6 +620,7 @@ impl ProjectConfig {
|
|||||||
status_push_enabled: default_status_push_enabled(),
|
status_push_enabled: default_status_push_enabled(),
|
||||||
merge_failure_block_threshold: default_merge_failure_block_threshold(),
|
merge_failure_block_threshold: default_merge_failure_block_threshold(),
|
||||||
gc_min_free_gb: 0,
|
gc_min_free_gb: 0,
|
||||||
|
snapshot_interval_ops: default_snapshot_interval_ops(),
|
||||||
};
|
};
|
||||||
validate_agents(&config.agent)?;
|
validate_agents(&config.agent)?;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
@@ -638,6 +652,7 @@ impl ProjectConfig {
|
|||||||
status_push_enabled: default_status_push_enabled(),
|
status_push_enabled: default_status_push_enabled(),
|
||||||
merge_failure_block_threshold: default_merge_failure_block_threshold(),
|
merge_failure_block_threshold: default_merge_failure_block_threshold(),
|
||||||
gc_min_free_gb: 0,
|
gc_min_free_gb: 0,
|
||||||
|
snapshot_interval_ops: default_snapshot_interval_ops(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,21 @@ fn default_config_when_missing() {
|
|||||||
assert!(config.component.is_empty());
|
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]
|
#[test]
|
||||||
fn parse_multi_agent_toml() {
|
fn parse_multi_agent_toml() {
|
||||||
let toml_str = r#"
|
let toml_str = r#"
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ pub use read::{
|
|||||||
read_all_items, read_item, tombstoned_ids,
|
read_all_items, read_item, tombstoned_ids,
|
||||||
};
|
};
|
||||||
pub(crate) use state::flush_persistence;
|
pub(crate) use state::flush_persistence;
|
||||||
pub use state::{init, subscribe};
|
pub use state::{checkpoint_on_shutdown, init, subscribe};
|
||||||
pub use types::{
|
pub use types::{
|
||||||
ActiveAgentCrdt, ActiveAgentView, AgentThrottleCrdt, AgentThrottleView, CrdtEvent, EpicId,
|
ActiveAgentCrdt, ActiveAgentView, AgentThrottleCrdt, AgentThrottleView, CrdtEvent, EpicId,
|
||||||
EventLogEntryCrdt, GatewayConfigCrdt, GatewayProjectCrdt, GatewayProjectView, LlmSessionCrdt,
|
EventLogEntryCrdt, GatewayConfigCrdt, GatewayProjectCrdt, GatewayProjectView, LlmSessionCrdt,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use super::indices::{
|
|||||||
rebuild_index, rebuild_llm_session_index, rebuild_merge_job_index, rebuild_node_index,
|
rebuild_index, rebuild_llm_session_index, rebuild_merge_job_index, rebuild_node_index,
|
||||||
rebuild_test_job_index, rebuild_token_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 super::{CRDT_STATE, CrdtState};
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
|
|
||||||
@@ -41,8 +41,11 @@ pub(crate) enum PersistMsg {
|
|||||||
/// Opens the SQLite database, loads or creates a node keypair, replays any
|
/// Opens the SQLite database, loads or creates a node keypair, replays any
|
||||||
/// persisted ops to reconstruct state, and spawns a background persistence
|
/// persisted ops to reconstruct state, and spawns a background persistence
|
||||||
/// task. Safe to call only once; subsequent calls are no-ops.
|
/// 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
|
#[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() {
|
if CRDT_STATE.get().is_some() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -52,6 +55,8 @@ pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> {
|
|||||||
.create_if_missing(true);
|
.create_if_missing(true);
|
||||||
let pool = SqlitePool::connect_with(options).await?;
|
let pool = SqlitePool::connect_with(options).await?;
|
||||||
sqlx::migrate!("./migrations").run(&pool).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.
|
// Load or create the node keypair.
|
||||||
let keypair = load_or_create_keypair(&pool).await?;
|
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 kp = keypair.clone();
|
||||||
let restore_result = tokio::task::spawn_blocking(move || {
|
let restore_result = tokio::task::spawn_blocking(move || {
|
||||||
let doc: PipelineDoc = serde_json::from_str(&state_json)
|
let doc: PipelineDoc = match serde_json::from_str(&state_json) {
|
||||||
.map_err(|e| format!("snapshot deserialize failed: {e}"))?;
|
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);
|
let mut crdt = BaseCrdt::<PipelineDoc>::new(&kp);
|
||||||
crdt.doc = doc;
|
crdt.doc = doc;
|
||||||
Ok::<_, String>(crdt)
|
crdt
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| sqlx::Error::Protocol(format!("snapshot restore panicked: {e}")))?
|
.map_err(|e| sqlx::Error::Protocol(format!("snapshot restore panicked: {e}")))?;
|
||||||
.map_err(sqlx::Error::Protocol)?;
|
|
||||||
|
|
||||||
// Replay only ops that arrived after the snapshot.
|
// Replay only ops that arrived after the snapshot.
|
||||||
let tail_rows: Vec<(String,)> =
|
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]);
|
slog!("[crdt] Failed to persist op {}: {e}", &op_id[..12]);
|
||||||
}
|
}
|
||||||
PERSIST_PENDING.fetch_sub(1, Ordering::Relaxed);
|
PERSIST_PENDING.fetch_sub(1, Ordering::Relaxed);
|
||||||
|
|
||||||
|
maybe_periodic_checkpoint().await;
|
||||||
}
|
}
|
||||||
PersistMsg::Flush(reply) => {
|
PersistMsg::Flush(reply) => {
|
||||||
// All ops queued before this message have already been processed.
|
// 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 —
|
/// 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.
|
/// 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) {
|
async fn save_snapshot(pool: &SqlitePool, crdt: &BaseCrdt<PipelineDoc>, lamport_floor: u64) {
|
||||||
// Find the highest rowid currently in crdt_ops — ops with rowid <= this
|
let json = match serde_json::to_string(&crdt.doc) {
|
||||||
// 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) {
|
|
||||||
Ok(j) => j,
|
Ok(j) => j,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
slog!("[crdt] Failed to serialize snapshot: {e}");
|
slog!("[crdt] Failed to serialize snapshot: {e}");
|
||||||
return;
|
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 json_len = json.len();
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
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) \
|
"INSERT OR REPLACE INTO crdt_snapshot (id, at_seq, max_rowid, state_json, created_at) \
|
||||||
VALUES (1, ?1, ?2, ?3, ?4)",
|
VALUES (1, ?1, ?2, ?3, ?4)",
|
||||||
)
|
)
|
||||||
.bind(lamport_floor as i64)
|
.bind(at_seq as i64)
|
||||||
.bind(max_rowid)
|
.bind(max_rowid)
|
||||||
.bind(&json)
|
.bind(json)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await;
|
.await;
|
||||||
@@ -418,7 +435,7 @@ async fn save_snapshot(pool: &SqlitePool, crdt: &BaseCrdt<PipelineDoc>, lamport_
|
|||||||
match result {
|
match result {
|
||||||
Ok(_) => slog!(
|
Ok(_) => slog!(
|
||||||
"[crdt] Snapshot saved: at_seq={}, max_rowid={}, json={}B",
|
"[crdt] Snapshot saved: at_seq={}, max_rowid={}, json={}B",
|
||||||
lamport_floor,
|
at_seq,
|
||||||
max_rowid,
|
max_rowid,
|
||||||
json_len
|
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.
|
/// Extract the filesystem path from a SqlitePool's connect options.
|
||||||
fn pool_path(pool: &SqlitePool) -> Option<String> {
|
fn pool_path(pool: &SqlitePool) -> Option<String> {
|
||||||
use sqlx::ConnectOptions;
|
use sqlx::ConnectOptions;
|
||||||
@@ -433,3 +530,52 @@ fn pool_path(pool: &SqlitePool) -> Option<String> {
|
|||||||
let filename = opts.get_filename();
|
let filename = opts.get_filename();
|
||||||
filename.to_str().map(|s| s.to_string())
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ mod tests;
|
|||||||
|
|
||||||
// ── Re-exports for crdt_state siblings ──────────────────────────────
|
// ── Re-exports for crdt_state siblings ──────────────────────────────
|
||||||
|
|
||||||
pub use init::init;
|
|
||||||
pub(crate) use init::{PersistMsg, flush_persistence};
|
pub(crate) use init::{PersistMsg, flush_persistence};
|
||||||
|
pub use init::{checkpoint_on_shutdown, init};
|
||||||
|
|
||||||
/// Subscribe to CRDT state-transition events.
|
/// Subscribe to CRDT state-transition events.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -10,10 +10,11 @@
|
|||||||
//! tests do not share `ALL_OPS` — preventing one test's `apply_compaction`
|
//! tests do not share `ALL_OPS` — preventing one test's `apply_compaction`
|
||||||
//! from pruning another test's freshly-written ops.
|
//! 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 std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
use bft_json_crdt::json_crdt::SignedOp;
|
use bft_json_crdt::json_crdt::SignedOp;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use super::super::VectorClock;
|
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()`.
|
/// re-parsing all ops when a peer requests `our_vector_clock()`.
|
||||||
pub(crate) static VECTOR_CLOCK: OnceLock<Mutex<VectorClock>> = OnceLock::new();
|
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)]
|
#[cfg(test)]
|
||||||
thread_local! {
|
thread_local! {
|
||||||
/// Per-thread op journal for test isolation. Each test thread sees its
|
/// 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());
|
let author_hex = hex::encode(&signed.author());
|
||||||
*clock.entry(author_hex).or_insert(0) += 1;
|
*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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// Initialise the CRDT so gateway_config.active_project is persisted across restarts.
|
||||||
let crdt_db = config_dir.join("gateway.db");
|
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!(
|
crate::slog!(
|
||||||
"[gateway] Warning: CRDT init failed ({e}); active-project selection will not persist"
|
"[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)
|
.run(route)
|
||||||
.await;
|
.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
|
// Best-effort shutdown notification: signal the Matrix bot so it can post
|
||||||
// "going offline" before the process exits. Mirror of main.rs:346.
|
// "going offline" before the process exits. Mirror of main.rs:346.
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1211,7 +1211,6 @@ async fn handle_project_rebuild_tool(
|
|||||||
drain_timeout_secs,
|
drain_timeout_secs,
|
||||||
force,
|
force,
|
||||||
&state.projects,
|
&state.projects,
|
||||||
Some(state.port),
|
|
||||||
&state.config_dir,
|
&state.config_dir,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Story field update and unblock tools.
|
//! Story field update and unblock tools.
|
||||||
|
|
||||||
use crate::http::context::AppContext;
|
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::slog_warn;
|
||||||
use crate::validation::UpdateStoryRequest;
|
use crate::validation::UpdateStoryRequest;
|
||||||
use serde_json::Value;
|
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)?;
|
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:
|
// Explicit top-level args map onto typed CRDT registers directly (story 929:
|
||||||
// no YAML front-matter writes). The `front_matter` object is the legacy
|
// no YAML front-matter writes). The `front_matter` object is the legacy
|
||||||
// escape hatch; every known key is recognised and routed below, and any
|
// 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.
|
// 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).
|
// Story 929: reads from the CRDT (was a FS-yaml scan).
|
||||||
let archived_deps = crate::crdt_state::check_archived_deps_crdt(story_id);
|
let archived_deps = crate::crdt_state::check_archived_deps_crdt(story_id);
|
||||||
|
|||||||
@@ -264,6 +264,18 @@ pub(super) fn story_tools() -> Vec<Value> {
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "New description text to replace the '## Description' section content"
|
"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": {
|
"agent": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Set or change the 'agent' YAML front matter field"
|
"description": "Set or change the 'agent' YAML front matter field"
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
use std::path::Path;
|
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.
|
/// 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.
|
/// Returns true if the item stem is a bug item.
|
||||||
///
|
///
|
||||||
/// Checks the slug-based ID format first (e.g. `"4_bug_login_crash"`), then
|
/// 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).
|
/// 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());
|
let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit());
|
||||||
if after_num.starts_with("_bug_") {
|
if after_num.starts_with("_bug_") {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ mod spike;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
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 epic::create_epic_file;
|
||||||
pub use refactor::{create_refactor_file, list_refactor_files};
|
pub use refactor::{create_refactor_file, list_refactor_files};
|
||||||
pub use spike::create_spike_file;
|
pub use spike::create_spike_file;
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ mod test_results;
|
|||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
pub use bug_ops::{
|
pub use bug_ops::{
|
||||||
create_bug_file, create_epic_file, create_refactor_file, create_spike_file, list_bug_files,
|
create_bug_file, create_epic_file, create_refactor_file, create_spike_file, is_bug_item,
|
||||||
list_refactor_files,
|
list_bug_files, list_refactor_files, update_bug_fields_in_file,
|
||||||
};
|
};
|
||||||
pub use pipeline::{
|
pub use pipeline::{
|
||||||
PipelineState, UpcomingStory, load_pipeline_state, load_upcoming_stories, validate_story_dirs,
|
PipelineState, UpcomingStory, load_pipeline_state, load_upcoming_stories, validate_story_dirs,
|
||||||
|
|||||||
@@ -491,6 +491,11 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
|
|
||||||
let result = Server::new(TcpListener::bind(&addr)).run(app).await;
|
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) ──────────────────────────────────
|
// ── Shutdown notifications (best-effort) ──────────────────────────────────
|
||||||
startup::bots::notify_shutdown(&bot_ctxs).await;
|
startup::bots::notify_shutdown(&bot_ctxs).await;
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ pub mod summary;
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use status::format_merge_status_message;
|
pub use status::format_merge_status_message;
|
||||||
#[allow(unused_imports)]
|
#[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 ────────────────────────────────────────────────────────────────
|
// ── Error type ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,78 @@ pub fn summarize_merge_failure_kind(kind: &MergeFailureKind) -> String {
|
|||||||
summarize(failing_gate_label_for_kind(kind), &kind.to_gate_output())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -236,4 +308,118 @@ mod tests {
|
|||||||
let summary = summarize_merge_failure_kind(&kind);
|
let summary = summarize_merge_failure_kind(&kind);
|
||||||
assert!(!summary.contains("Quality gates failed:"));
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -338,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);
|
let huskies_dir = db_path.parent().unwrap_or(db_path);
|
||||||
migrate_json_stores_to_sqlite(huskies_dir).await;
|
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}");
|
crate::slog!("[crdt] Failed to initialise CRDT state layer: {e}");
|
||||||
} else {
|
} else {
|
||||||
crdt_state::migrate_names_from_slugs();
|
crdt_state::migrate_names_from_slugs();
|
||||||
|
|||||||
+337
-730
File diff suppressed because it is too large
Load Diff
@@ -169,80 +169,11 @@ pub fn format_report(report: &CleanupReport, confirm: bool) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::WatcherConfig;
|
use crate::worktree::test_support::{done_stage, empty_config, init_git_repo};
|
||||||
use chrono::Utc;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn init_git_repo(dir: &std::path::Path) {
|
|
||||||
use crate::git_test_support::git_ok;
|
|
||||||
git_ok(
|
|
||||||
Command::new("git").args(["init"]).current_dir(dir).output(),
|
|
||||||
"git init",
|
|
||||||
);
|
|
||||||
git_ok(
|
|
||||||
Command::new("git")
|
|
||||||
.args(["config", "user.email", "test@test.com"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output(),
|
|
||||||
"git config user.email",
|
|
||||||
);
|
|
||||||
git_ok(
|
|
||||||
Command::new("git")
|
|
||||||
.args(["config", "user.name", "Test"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output(),
|
|
||||||
"git config user.name",
|
|
||||||
);
|
|
||||||
git_ok(
|
|
||||||
Command::new("git")
|
|
||||||
.args(["commit", "--allow-empty", "-m", "init"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output(),
|
|
||||||
"git commit",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn empty_config() -> ProjectConfig {
|
|
||||||
ProjectConfig {
|
|
||||||
component: vec![],
|
|
||||||
agent: vec![],
|
|
||||||
watcher: WatcherConfig::default(),
|
|
||||||
disk_watch: Default::default(),
|
|
||||||
default_qa: "server".to_string(),
|
|
||||||
default_coder_model: None,
|
|
||||||
max_coders: None,
|
|
||||||
max_retries: 2,
|
|
||||||
base_branch: None,
|
|
||||||
rate_limit_notifications: true,
|
|
||||||
web_ui_status_consumer: true,
|
|
||||||
matrix_status_consumer: true,
|
|
||||||
slack_status_consumer: true,
|
|
||||||
discord_status_consumer: true,
|
|
||||||
whatsapp_status_consumer: true,
|
|
||||||
timezone: None,
|
|
||||||
rendezvous: None,
|
|
||||||
trusted_keys: Vec::new(),
|
|
||||||
crdt_require_token: false,
|
|
||||||
crdt_tokens: Vec::new(),
|
|
||||||
max_mesh_peers: 3,
|
|
||||||
gateway_url: None,
|
|
||||||
gateway_project: None,
|
|
||||||
status_push_enabled: true,
|
|
||||||
merge_failure_block_threshold: 3,
|
|
||||||
gc_min_free_gb: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn done_stage() -> Stage {
|
|
||||||
Stage::Done {
|
|
||||||
merged_at: Utc::now(),
|
|
||||||
merge_commit: crate::pipeline_state::GitSha("abc123".to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn setup_project_with_real_worktree(story_id: &str) -> (TempDir, PathBuf) {
|
async fn setup_project_with_real_worktree(story_id: &str) -> (TempDir, PathBuf) {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let project_root = tmp.path().join("project");
|
let project_root = tmp.path().join("project");
|
||||||
|
|||||||
@@ -210,71 +210,12 @@ pub(crate) async fn run_shell_command(cmd: &str, cwd: &Path) -> Result<(), Strin
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::{ComponentConfig, WatcherConfig};
|
use crate::config::ComponentConfig;
|
||||||
|
use crate::worktree::test_support::{empty_config, init_git_repo};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn init_git_repo(dir: &Path) {
|
|
||||||
use crate::git_test_support::git_ok;
|
|
||||||
git_ok(
|
|
||||||
Command::new("git").args(["init"]).current_dir(dir).output(),
|
|
||||||
"git init",
|
|
||||||
);
|
|
||||||
git_ok(
|
|
||||||
Command::new("git")
|
|
||||||
.args(["config", "user.email", "test@test.com"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output(),
|
|
||||||
"git config user.email",
|
|
||||||
);
|
|
||||||
git_ok(
|
|
||||||
Command::new("git")
|
|
||||||
.args(["config", "user.name", "Test"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output(),
|
|
||||||
"git config user.name",
|
|
||||||
);
|
|
||||||
git_ok(
|
|
||||||
Command::new("git")
|
|
||||||
.args(["commit", "--allow-empty", "-m", "init"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output(),
|
|
||||||
"git commit",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn empty_config() -> ProjectConfig {
|
|
||||||
ProjectConfig {
|
|
||||||
component: vec![],
|
|
||||||
agent: vec![],
|
|
||||||
watcher: WatcherConfig::default(),
|
|
||||||
disk_watch: Default::default(),
|
|
||||||
default_qa: "server".to_string(),
|
|
||||||
default_coder_model: None,
|
|
||||||
max_coders: None,
|
|
||||||
max_retries: 2,
|
|
||||||
base_branch: None,
|
|
||||||
rate_limit_notifications: true,
|
|
||||||
web_ui_status_consumer: true,
|
|
||||||
matrix_status_consumer: true,
|
|
||||||
slack_status_consumer: true,
|
|
||||||
discord_status_consumer: true,
|
|
||||||
whatsapp_status_consumer: true,
|
|
||||||
timezone: None,
|
|
||||||
rendezvous: None,
|
|
||||||
trusted_keys: Vec::new(),
|
|
||||||
crdt_require_token: false,
|
|
||||||
crdt_tokens: Vec::new(),
|
|
||||||
max_mesh_peers: 3,
|
|
||||||
gateway_url: None,
|
|
||||||
gateway_project: None,
|
|
||||||
status_push_enabled: true,
|
|
||||||
merge_failure_block_threshold: 3,
|
|
||||||
gc_min_free_gb: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn failing_setup_config() -> ProjectConfig {
|
fn failing_setup_config() -> ProjectConfig {
|
||||||
ProjectConfig {
|
ProjectConfig {
|
||||||
component: vec![ComponentConfig {
|
component: vec![ComponentConfig {
|
||||||
|
|||||||
@@ -232,38 +232,10 @@ pub fn migrate_slug_paths(project_root: &Path, migrations: &[(String, String)])
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::worktree::test_support::init_git_repo;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn init_git_repo(dir: &Path) {
|
|
||||||
use crate::git_test_support::git_ok;
|
|
||||||
git_ok(
|
|
||||||
Command::new("git").args(["init"]).current_dir(dir).output(),
|
|
||||||
"git init",
|
|
||||||
);
|
|
||||||
git_ok(
|
|
||||||
Command::new("git")
|
|
||||||
.args(["config", "user.email", "test@test.com"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output(),
|
|
||||||
"git config user.email",
|
|
||||||
);
|
|
||||||
git_ok(
|
|
||||||
Command::new("git")
|
|
||||||
.args(["config", "user.name", "Test"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output(),
|
|
||||||
"git config user.name",
|
|
||||||
);
|
|
||||||
git_ok(
|
|
||||||
Command::new("git")
|
|
||||||
.args(["commit", "--allow-empty", "-m", "init"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output(),
|
|
||||||
"git commit",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn branch_name_format() {
|
fn branch_name_format() {
|
||||||
assert_eq!(branch_name("42_my_story"), "feature/story-42_my_story");
|
assert_eq!(branch_name("42_my_story"), "feature/story-42_my_story");
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ mod create;
|
|||||||
mod git;
|
mod git;
|
||||||
mod remove;
|
mod remove;
|
||||||
mod sweep;
|
mod sweep;
|
||||||
|
/// Shared test fixtures (`init_git_repo`, `empty_config`) reused across the
|
||||||
|
/// worktree module's test suites and the pool worktree-lifecycle tests.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod test_support;
|
||||||
|
|
||||||
pub use cleanup::{format_report, run_cleanup};
|
pub use cleanup::{format_report, run_cleanup};
|
||||||
pub use create::create_worktree;
|
pub use create::create_worktree;
|
||||||
|
|||||||
@@ -68,71 +68,10 @@ pub async fn remove_worktree_by_story_id(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::WatcherConfig;
|
use crate::worktree::test_support::{empty_config, init_git_repo};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::process::Command;
|
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn init_git_repo(dir: &std::path::Path) {
|
|
||||||
use crate::git_test_support::git_ok;
|
|
||||||
git_ok(
|
|
||||||
Command::new("git").args(["init"]).current_dir(dir).output(),
|
|
||||||
"git init",
|
|
||||||
);
|
|
||||||
git_ok(
|
|
||||||
Command::new("git")
|
|
||||||
.args(["config", "user.email", "test@test.com"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output(),
|
|
||||||
"git config user.email",
|
|
||||||
);
|
|
||||||
git_ok(
|
|
||||||
Command::new("git")
|
|
||||||
.args(["config", "user.name", "Test"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output(),
|
|
||||||
"git config user.name",
|
|
||||||
);
|
|
||||||
git_ok(
|
|
||||||
Command::new("git")
|
|
||||||
.args(["commit", "--allow-empty", "-m", "init"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output(),
|
|
||||||
"git commit",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn empty_config() -> ProjectConfig {
|
|
||||||
ProjectConfig {
|
|
||||||
component: vec![],
|
|
||||||
agent: vec![],
|
|
||||||
watcher: WatcherConfig::default(),
|
|
||||||
disk_watch: Default::default(),
|
|
||||||
default_qa: "server".to_string(),
|
|
||||||
default_coder_model: None,
|
|
||||||
max_coders: None,
|
|
||||||
max_retries: 2,
|
|
||||||
base_branch: None,
|
|
||||||
rate_limit_notifications: true,
|
|
||||||
web_ui_status_consumer: true,
|
|
||||||
matrix_status_consumer: true,
|
|
||||||
slack_status_consumer: true,
|
|
||||||
discord_status_consumer: true,
|
|
||||||
whatsapp_status_consumer: true,
|
|
||||||
timezone: None,
|
|
||||||
rendezvous: None,
|
|
||||||
trusted_keys: Vec::new(),
|
|
||||||
crdt_require_token: false,
|
|
||||||
crdt_tokens: Vec::new(),
|
|
||||||
max_mesh_peers: 3,
|
|
||||||
gateway_url: None,
|
|
||||||
gateway_project: None,
|
|
||||||
status_push_enabled: true,
|
|
||||||
merge_failure_block_threshold: 3,
|
|
||||||
gc_min_free_gb: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn remove_worktree_by_story_id_returns_err_when_not_found() {
|
async fn remove_worktree_by_story_id_returns_err_when_not_found() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -89,81 +89,13 @@ where
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::WatcherConfig;
|
use crate::worktree::test_support::{done_stage, empty_config, init_git_repo};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::num::NonZeroU32;
|
use std::num::NonZeroU32;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn init_git_repo(dir: &Path) {
|
|
||||||
use crate::git_test_support::git_ok;
|
|
||||||
git_ok(
|
|
||||||
Command::new("git").args(["init"]).current_dir(dir).output(),
|
|
||||||
"git init",
|
|
||||||
);
|
|
||||||
git_ok(
|
|
||||||
Command::new("git")
|
|
||||||
.args(["config", "user.email", "test@test.com"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output(),
|
|
||||||
"git config user.email",
|
|
||||||
);
|
|
||||||
git_ok(
|
|
||||||
Command::new("git")
|
|
||||||
.args(["config", "user.name", "Test"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output(),
|
|
||||||
"git config user.name",
|
|
||||||
);
|
|
||||||
git_ok(
|
|
||||||
Command::new("git")
|
|
||||||
.args(["commit", "--allow-empty", "-m", "init"])
|
|
||||||
.current_dir(dir)
|
|
||||||
.output(),
|
|
||||||
"git commit",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn empty_config() -> ProjectConfig {
|
|
||||||
ProjectConfig {
|
|
||||||
component: vec![],
|
|
||||||
agent: vec![],
|
|
||||||
watcher: WatcherConfig::default(),
|
|
||||||
disk_watch: Default::default(),
|
|
||||||
default_qa: "server".to_string(),
|
|
||||||
default_coder_model: None,
|
|
||||||
max_coders: None,
|
|
||||||
max_retries: 2,
|
|
||||||
base_branch: None,
|
|
||||||
rate_limit_notifications: true,
|
|
||||||
web_ui_status_consumer: true,
|
|
||||||
matrix_status_consumer: true,
|
|
||||||
slack_status_consumer: true,
|
|
||||||
discord_status_consumer: true,
|
|
||||||
whatsapp_status_consumer: true,
|
|
||||||
timezone: None,
|
|
||||||
rendezvous: None,
|
|
||||||
trusted_keys: Vec::new(),
|
|
||||||
crdt_require_token: false,
|
|
||||||
crdt_tokens: Vec::new(),
|
|
||||||
max_mesh_peers: 3,
|
|
||||||
gateway_url: None,
|
|
||||||
gateway_project: None,
|
|
||||||
status_push_enabled: true,
|
|
||||||
merge_failure_block_threshold: 3,
|
|
||||||
gc_min_free_gb: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn done_stage() -> Stage {
|
|
||||||
Stage::Done {
|
|
||||||
merged_at: Utc::now(),
|
|
||||||
merge_commit: crate::pipeline_state::GitSha("abc123".to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn archived_stage() -> Stage {
|
fn archived_stage() -> Stage {
|
||||||
Stage::Archived {
|
Stage::Archived {
|
||||||
archived_at: Utc::now(),
|
archived_at: Utc::now(),
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! Shared test fixtures for worktree module tests: a throwaway git repo and a
|
||||||
|
//! minimal [`ProjectConfig`] with no components, used by the worktree
|
||||||
|
//! create/remove/cleanup/sweep tests and the pool worktree-lifecycle
|
||||||
|
//! subscriber tests so each doesn't hand-roll its own copy.
|
||||||
|
|
||||||
|
use crate::config::{ProjectConfig, WatcherConfig};
|
||||||
|
use crate::git_test_support::git_ok;
|
||||||
|
use crate::pipeline_state::{GitSha, Stage};
|
||||||
|
use chrono::Utc;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// Initialize a git repo at `dir` with test identity configured and a
|
||||||
|
/// committed empty initial commit, so worktree creation has a valid base
|
||||||
|
/// branch to fork from.
|
||||||
|
pub(crate) fn init_git_repo(dir: &Path) {
|
||||||
|
git_ok(
|
||||||
|
Command::new("git").args(["init"]).current_dir(dir).output(),
|
||||||
|
"git init",
|
||||||
|
);
|
||||||
|
git_ok(
|
||||||
|
Command::new("git")
|
||||||
|
.args(["config", "user.email", "test@test.com"])
|
||||||
|
.current_dir(dir)
|
||||||
|
.output(),
|
||||||
|
"git config user.email",
|
||||||
|
);
|
||||||
|
git_ok(
|
||||||
|
Command::new("git")
|
||||||
|
.args(["config", "user.name", "Test"])
|
||||||
|
.current_dir(dir)
|
||||||
|
.output(),
|
||||||
|
"git config user.name",
|
||||||
|
);
|
||||||
|
git_ok(
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "--allow-empty", "-m", "init"])
|
||||||
|
.current_dir(dir)
|
||||||
|
.output(),
|
||||||
|
"git commit",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal [`ProjectConfig`] with no components, suitable for worktree
|
||||||
|
/// tests that don't exercise setup/teardown commands.
|
||||||
|
pub(crate) fn empty_config() -> ProjectConfig {
|
||||||
|
ProjectConfig {
|
||||||
|
component: vec![],
|
||||||
|
agent: vec![],
|
||||||
|
watcher: WatcherConfig::default(),
|
||||||
|
disk_watch: Default::default(),
|
||||||
|
default_qa: "server".to_string(),
|
||||||
|
default_coder_model: None,
|
||||||
|
max_coders: None,
|
||||||
|
max_retries: 2,
|
||||||
|
base_branch: None,
|
||||||
|
rate_limit_notifications: true,
|
||||||
|
web_ui_status_consumer: true,
|
||||||
|
matrix_status_consumer: true,
|
||||||
|
slack_status_consumer: true,
|
||||||
|
discord_status_consumer: true,
|
||||||
|
whatsapp_status_consumer: true,
|
||||||
|
timezone: None,
|
||||||
|
rendezvous: None,
|
||||||
|
trusted_keys: Vec::new(),
|
||||||
|
crdt_require_token: false,
|
||||||
|
crdt_tokens: Vec::new(),
|
||||||
|
max_mesh_peers: 3,
|
||||||
|
gateway_url: None,
|
||||||
|
gateway_project: None,
|
||||||
|
status_push_enabled: true,
|
||||||
|
merge_failure_block_threshold: 3,
|
||||||
|
gc_min_free_gb: 0,
|
||||||
|
snapshot_interval_ops: 5000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `Stage::Done` fixture for tests that need a terminal-stage story.
|
||||||
|
pub(crate) fn done_stage() -> Stage {
|
||||||
|
Stage::Done {
|
||||||
|
merged_at: Utc::now(),
|
||||||
|
merge_commit: GitSha("abc123".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user