Compare commits

...

315 Commits

Author SHA1 Message Date
Timmy f4a97c1135 Bump version to 0.10.2 2026-04-15 20:07:55 +01:00
dave 0969fb5d51 fix: remove duplicate / route in gateway that causes panic on startup
gateway_index_handler and embedded_index both registered at /. The
embedded React frontend should serve /. Remove the old gateway
index handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:57:35 +00:00
dave 744cc9dca4 huskies: merge 569_story_gateway_ui_cross_project_pipeline_status_view 2026-04-15 18:38:33 +00:00
dave ce37281333 huskies: merge 571_story_expose_agent_remaining_turns_and_budget_via_mcp_tool 2026-04-15 18:30:32 +00:00
dave 149a383447 huskies: merge 568_story_gateway_ui_connected_agents_dashboard 2026-04-15 18:25:17 +00:00
dave d68614e26a huskies: merge 580_story_diff_bot_command_shows_git_diff_from_main_branch_to_worktree_branch 2026-04-15 18:16:26 +00:00
dave a4480fa067 chore: feed CONTEXT and STACK specs to all agents, update STACK with source map
Agents now read specs/00_CONTEXT.md (what the project does) and
specs/tech/STACK.md (tech stack + source map) in addition to the
README. STACK.md rewritten to reflect current state — removes stale
references to biome, tauri-specta, .story_kit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:15:09 +00:00
dave beb84ade9f huskies: merge 567_story_gateway_ui_project_management_add_and_remove_projects 2026-04-15 18:06:43 +00:00
dave d235fd41ac huskies: merge 581_story_freeze_command_to_hold_a_story_at_its_current_stage_without_advancing 2026-04-15 18:02:14 +00:00
dave 2246278845 huskies: merge 582_story_bot_configuration_page 2026-04-15 17:37:52 +00:00
dave d80fc143c2 huskies: merge 577_bug_show_command_reads_story_files_from_filesystem_instead_of_crdt 2026-04-15 17:28:05 +00:00
dave 1fe4ca2b7a Revert "huskies: merge 566_story_gateway_ui_bot_configuration_page"
This reverts commit c28c86dbc6.
2026-04-15 17:13:01 +00:00
dave c28c86dbc6 huskies: merge 566_story_gateway_ui_bot_configuration_page 2026-04-15 14:12:23 +00:00
dave 70fecafd41 huskies: merge 576_bug_overview_command_reads_story_files_from_filesystem_instead_of_crdt 2026-04-15 14:06:31 +00:00
dave c34b119526 huskies: merge 575_bug_unblock_command_reads_story_files_from_filesystem_instead_of_crdt 2026-04-15 13:53:21 +00:00
dave 0bf715d9bb huskies: merge 574_bug_depends_bot_command_broken_after_removing_filesystem_story_files 2026-04-15 13:38:27 +00:00
dave 7fa31c03a3 huskies: merge 573_story_remove_criterion_mcp_tool_to_delete_an_acceptance_criterion 2026-04-15 13:23:18 +00:00
dave 483489cc44 fix: rewrite coder agent prompts — run tests before commit, remove stale instructions
Key changes:
- Tests before commit, not after: "run run_tests, fix failures, then commit"
- Removed polling references (run_tests blocks now)
- Removed "never run script/test" (primes agents to think about it)
- Removed dead "user review" instruction
- Removed "commit and stop" which signalled skip-testing
- Cleaner workflow: implement → check criteria → test → fix → commit → exit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:19:08 +00:00
dave ec40b4771b huskies: merge 572_story_edit_criterion_mcp_tool_to_update_acceptance_criteria_text 2026-04-15 13:03:55 +00:00
dave 52b21c22b1 huskies: merge 566_story_gateway_ui_bot_configuration_page 2026-04-14 18:57:32 +00:00
dave 8936abd8cd docs: add project architecture section to README for agent context
Agents need to know the gateway is a mode of the binary, not a
separate app, and that UI stories are frontend React work, not
Rust backend restructuring.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:23:18 +00:00
dave 8482df2f4e huskies: merge 570_bug_merge_agent_work_should_check_if_story_is_already_done_before_attempting_merge 2026-04-14 16:15:29 +00:00
Timmy 327163eb60 Bump version to 0.10.1 2026-04-14 16:41:27 +01:00
Timmy 8f1dd0ad13 Removing example code 2026-04-14 14:34:43 +01:00
dave 28adef9739 chore: switch mergemaster to opus and add cargo fmt guidance
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:35:57 +00:00
dave badfabcf5e chore: switch mergemaster to opus and add cargo fmt guidance
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:27:58 +00:00
dave d0d2b17484 huskies: merge 563_story_build_agent_join_mechanism_agents_register_with_the_gateway_via_token 2026-04-14 12:06:13 +00:00
dave efe434ede3 huskies: merge 565_story_gateway_web_ui_shell_with_project_switcher 2026-04-14 11:28:55 +00:00
dave df5ba8ebab huskies: merge 560_story_make_merge_agent_work_return_results_like_run_tests_instead_of_polling 2026-04-14 10:26:44 +00:00
dave ff1149750b huskies: merge 561_bug_mcp_tools_matching_mcp_huskies_allowlist_still_trigger_permission_prompts 2026-04-14 10:19:51 +00:00
dave d824dc4b73 huskies: merge 558_story_matrix_bot_can_run_on_the_gateway_to_manage_multiple_projects_from_one_chat 2026-04-14 10:01:04 +00:00
dave 28777b0c77 fix: simplify boolean in validate_working_dir to satisfy clippy nonminimal_bool
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:52:51 +00:00
dave f412c7dee6 fix: cargo fmt the merge_workspace validation code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:43:18 +00:00
dave 44fe52195e fix: allow MCP tools to access merge_workspace so mergemaster can fix conflicts
The permission lockdown restricted run_command/run_tests to
.huskies/worktrees/ only. The mergemaster could diagnose merge
conflict compile errors but couldn't edit files in .huskies/merge_workspace/
to fix them. Add merge_workspace as an allowed path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:21:39 +00:00
dave 979cf39228 huskies: merge 557_refactor_remove_all_filesystem_fallback_paths_crdt_is_the_only_source_of_truth 2026-04-14 09:14:07 +00:00
dave 10d3517648 fix: remove filesystem fallback from scan_stage_items to unblock 557 merge
The auto-resolver kept both sides of the conflict — feature's
_project_root signature with master's filesystem code referencing
project_root — producing a compile error. Remove the filesystem
fallback on master so there's no conflict. CRDT is the only source
of truth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:14:58 +00:00
Timmy 8a62b62819 Noting the existence of chat transports in README 2026-04-13 17:22:54 +01:00
dave 2e412af4dd fix: suppress Vite chunk size warning that clutters test output
The JS bundle is ~1MB which is fine for an embedded admin UI.
Raise the warning limit to 1100KB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:20:19 +00:00
dave 39b1964b68 fix: set git default branch to master in Dockerfile to suppress hint spam
Tests that create temp git repos produce thousands of lines of
"Using 'master' as the name for the initial branch" hints that
bury actual test failures in agent output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:18:05 +00:00
dave bd04c6acd7 fix: capture test output with background pipe draining instead of Stdio::inherit
Stdio::inherit sent test output to server stdout, making it invisible
to agents calling run_tests via MCP. Switch back to Stdio::piped with
background drain threads (same pattern as gates.rs) to capture output
without the pipe deadlock that caused the original switch to inherit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:17:06 +00:00
dave 7977b7c5f8 huskies: merge 555_bug_agent_permission_prompts_flood_matrix_chat_instead_of_being_auto_denied 2026-04-13 15:02:47 +00:00
dave d618bc3b32 huskies: merge 556_bug_stale_filesystem_shadows_in_1_backlog_cause_auto_assign_to_promote_archived_stories 2026-04-13 14:48:44 +00:00
dave 845b85e7a7 fix: add --all to cargo fmt in script/test and autoformat codebase
cargo fmt without --all fails with "Failed to find targets" in
workspace repos. This was blocking every story's gates. Also ran
cargo fmt --all to fix all existing formatting issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:07:08 +00:00
dave ed2526ce41 feat: add get_version MCP tool returning version and build hash
Agents can now call get_version to see what server version and commit
they're running against.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:50:37 +00:00
dave 05655847d8 chore: log version on startup and gitignore build_hash
Startup now logs "huskies v0.10.0 (build abc1234)" so we can verify
both the version and the commit that's running. build_hash is a
runtime artifact, not tracked in git.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:48:18 +00:00
dave 0cb68e1de9 docs: add deployment modes to README — standard, headless, and gateway
Documents the three modes of the huskies binary: standard single-project
server, headless build agent (--rendezvous), and multi-project gateway
(--gateway). Includes projects.toml config example and Docker Compose
sketch for multi-project setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:44:10 +00:00
dave cd189cfe60 fix: isolate frontend node_modules in Docker volume to prevent cross-platform conflicts
npm install pulls platform-specific native binaries (esbuild, rollup).
Without isolation, building on macOS writes macOS node_modules into the
bind mount, then the Linux container tries to execute them and fails.
The Docker volume gives each platform its own node_modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:36:32 +00:00
dave 69dab063a8 huskies: merge 554_story_multi_project_gateway_that_proxies_mcp_calls_to_per_project_docker_containers 2026-04-13 13:07:04 +00:00
dave 5806156af3 huskies: merge 553_story_accept_spike_state_machine_transition_skips_merge_and_goes_directly_to_done 2026-04-13 12:54:09 +00:00
dave 12497eb4f1 fix: add Read, Glob, Grep to agent settings.json allowlist
These read-only tools were missing from the locked-down settings,
causing permission prompts to flood Matrix chat for every agent
file read.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:44:17 +00:00
dave 8b5275a30b fix: dropdown hover gap and z-index so menu is clickable on desktop and mobile
Invisible bridge element fills the gap between toggle and menu so
hover chain doesn't break. Dropdown z-index raised above hero graphic
so links aren't obscured on mobile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:42:11 +00:00
dave 5536803ad6 fix: simplify nav with Start dropdown and move Get in touch to footer
Nav now shows: How it works, Features, Start (dropdown: Docs, Source,
Releases). Get in touch moved to footer. Cleaner on mobile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:15:44 +00:00
dave c4462e2918 fix: wrap nav links on mobile to prevent horizontal overflow
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 06:58:03 +00:00
dave f6cd947173 fix: shrink hero husky logo from 320px to 160px for mobile
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:24:18 +00:00
dave fa7c2fa0ed fix: switch tokio-tungstenite from native-tls to rustls to remove OpenSSL dependency
native-tls pulls in openssl-sys which requires system OpenSSL headers,
breaking macOS release builds. rustls-tls-native-roots is pure Rust.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:20:46 +00:00
Timmy f2fc33c86b Bump version to 0.10.0 2026-04-13 00:10:03 +01:00
dave 05c3b11e57 huskies: merge 551_bug_get_agent_output_mcp_tool_returns_fetch_failed_for_running_agents 2026-04-12 17:50:44 +00:00
dave ae4cacefe8 huskies: merge 549_bug_stale_stage_transition_notifications_for_stories_that_skipped_stages 2026-04-12 15:40:11 +00:00
dave 8ae06cc8e2 huskies: merge 550_story_split_status_command_into_status_pipeline_info_and_logs_agent_output_subcommands 2026-04-12 15:00:05 +00:00
dave da5d604d01 huskies: merge 548_refactor_rename_living_spec_standalone_to_huskies_in_package_json_and_cargo_lock 2026-04-12 14:50:38 +00:00
dave 9c3dbfb765 huskies: merge 485_story_documentation_site_for_huskies_dev 2026-04-12 14:17:28 +00:00
dave b85d7b3b86 huskies: merge 542_refactor_add_doc_comments_to_all_undocumented_source_files_and_generate_source_map_in_readme 2026-04-12 14:05:08 +00:00
dave 76765e15d2 huskies: merge 547_story_run_tests_bot_command_accepts_optional_story_number_to_run_tests_in_a_worktree 2026-04-12 13:26:13 +00:00
dave b7f077197d chore: add doc comment guidance to coder agent system prompts
Agents now know to add //! module comments and /// doc comments
to new public items, keeping documentation consistent with the
codebase-wide doc pass from story 542.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:25:21 +00:00
dave a344cfadee huskies: merge 544_story_add_run_build_and_run_lint_mcp_tools_backed_by_script_build_and_script_lint 2026-04-12 13:21:41 +00:00
dave cec62dad1c huskies: merge 542_refactor_add_doc_comments_to_all_undocumented_source_files_and_generate_source_map_in_readme 2026-04-12 13:16:11 +00:00
dave 6b1737d52d huskies: merge 546_refactor_rename_bot_test_command_to_run_tests_to_avoid_eating_chat_messages 2026-04-12 13:11:17 +00:00
dave b4dbfcbde6 huskies: merge 541_story_backlog_command_for_chat_and_web_ui_shows_only_backlog_items 2026-04-12 13:05:12 +00:00
dave 2bdb0eb730 fix: add log rotation to docker-compose to prevent disk fill
Test output now goes to container stdout via Stdio::inherit, so logs
grow fast. Cap at 50MB with 3 rotated files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:00:21 +00:00
dave 5f01631e6a huskies: merge 543_story_resume_failed_coder_agents_with_resume_instead_of_starting_fresh_sessions 2026-04-12 12:58:42 +00:00
dave c80931c15c fix: add ETXTBSY retry to run_coverage_gate
Use fsync in coverage gate tests to ensure the kernel releases the
write handle before executing the script. Prevents flaky ETXTBSY
errors on fast test runs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:40:08 +00:00
dave f140238cc3 fix: update run_tests tests for Stdio::inherit and bump tool count to 60
run_tests now uses Stdio::inherit so stdout/stderr aren't captured —
tests can only assert on pass/fail and exit code. Tool count bumped
from 59 to 60 for the new get_test_result tool.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:30:10 +00:00
dave 05bdc71ebc fix: add rustfmt to Docker image for formatting checks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:20:47 +00:00
dave ec6891b5ba fix: remove stale tests that hang or assert dead behaviour
- Remove tool_merge_agent_work_returns_started and
  tool_get_merge_status_returns_running: these tested the old
  non-blocking API but tool_merge_agent_work now blocks in a poll
  loop, causing the tests to hang forever.

- Update coder_agents_have_root_cause_guidance: prompt no longer
  requires "git bisect" — check for bug workflow guidance instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:02:47 +00:00
dave 06defd9596 fix: collapse nested if-let blocks to satisfy clippy collapsible_if lint
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 11:43:36 +00:00
dave 0b58b0486c fix: use Stdio::inherit for run_tests to prevent pipe deadlock
spawn() with piped stdout/stderr deadlocks when the test binary
produces more output than the OS pipe buffer (64KB). Switch to
Stdio::inherit so test output flows to server logs and we can
see what's happening.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:46:43 +00:00
dave b43e7cf752 fix: kill stale cargo processes before running acceptance gates
The completion handler now pgrep+kills any cargo processes targeting
the worktree's Cargo.toml before running gates. This prevents the
run_tests MCP child from holding the build lock and blocking gates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:25:56 +00:00
dave 8ae6ca3eb8 fix: make run_tests block server-side instead of requiring agent polling
run_tests now spawns the child and blocks in a 1-second poll loop until
tests complete or the 20-minute timeout fires. Returns the full result
in a single MCP call — agents use 1 turn instead of 50+. Child process
is properly killed on timeout (no zombies).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:07:02 +00:00
dave bac07d28a7 fix: increase run_tests MCP timeout to 20 minutes to match acceptance gates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:43:31 +00:00
dave fc89be2f55 fix: server-side 20s blocking in get_test_result to prevent agent poll spam
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:29:38 +00:00
dave 028bff5ef1 fix: rewrite .huskies/README.md for CRDT-only world
Strip all filesystem pipeline references that were causing agents to
waste turns searching for story files on disk. The README now points
agents at MCP tools exclusively and documents the async run_tests
workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:11:36 +00:00
dave 1f66183c8e fix: update scaffold settings template to match locked-down agent permissions 2026-04-11 22:03:53 +00:00
dave f958f57e56 fix: async run_tests to prevent zombie cargo processes blocking gates
run_tests MCP tool now spawns tests in the background and returns
immediately. Agents poll get_test_result to check completion. This
prevents zombie cargo processes from holding the build lock when the
CLI times out the MCP call before tests finish.

Also fixes agent permission mode: acceptEdits replaces invalid
allowFullAutoEdit that was causing agents to crash-loop on spawn.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:00:05 +00:00
dave 8393a67c89 fix: log git hash on build success and startup to verify which commit is running
Writes HEAD short hash to .huskies/build_hash after successful cargo
build. Logs it on startup as [startup] Running build: <hash>. No more
guessing whether the rebuild actually deployed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:50:15 +00:00
dave e32300d1f8 fix: switch agent permission mode from bypassPermissions to allowFullAutoEdit
bypassPermissions ignored the worktree's .claude/settings.json entirely,
letting agents run any Bash command including cargo test (which they'd
spawn 4+ times concurrently, deadlocking on the build directory lock).

allowFullAutoEdit respects the settings.json allowlist, so agents can
only use the Bash commands we explicitly permit (cargo check, cargo
build, git) and must use MCP tools for everything else (run_tests,
run_lint, run_build).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:23:22 +00:00
dave 32e36bbc4b fix: remove cargo test/clippy/npm from agent Bash permissions
Agents were running cargo test directly via Bash instead of using the
run_tests MCP tool, causing 4 concurrent cargo builds that deadlocked
on the build directory lock. Removed cargo test, cargo clippy, cargo
nextest, script/test, npm test, and pnpm test from the allowed Bash
commands. Agents must use the run_tests MCP tool which returns truncated
output and prevents concurrent builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:50:52 +00:00
dave c0d1be675b fix: mergemaster prompt says merge_agent_work blocks — no polling needed 2026-04-11 18:13:53 +00:00
dave d06241c20c fix: merge_agent_work blocks until complete instead of requiring polling
The mergemaster agent was burning all 30 turns polling get_merge_status
every 2 seconds while the merge pipeline takes ~2 minutes. It would
exhaust turns, exit, restart, and repeat — never seeing the result.

merge_agent_work now blocks with a 10-second internal poll loop and
returns the final result directly. The agent calls it once and gets
the answer. No more polling turns wasted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:43:50 +00:00
dave 599fbdc71d huskies: merge 539_bug_crdt_event_bridge_still_writes_filesystem_shadow_files_after_530_eliminated_filesystem_state 2026-04-11 17:04:36 +00:00
dave 6998275331 huskies: merge 540_bug_get_agent_output_mcp_tool_returns_no_agent_for_exited_agents_instead_of_reading_session_logs_from_disk 2026-04-11 16:33:58 +00:00
dave a9a1852422 fix: agent prompts say trust the story description instead of always investigating
Agents were spending entire $5 budgets grepping the codebase and reading
git history instead of making fixes when the story already specifies
exact file paths and function names. Changed bug workflow from
"investigate root cause first" to "trust the story, act fast" — go
directly to the specified location when the story tells you where.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:16:12 +00:00
dave 48ea612739 fix: remove startup CRDT stage sync — it fights the done→archived sweep
The sync_crdt_stages_from_db migration reads pipeline_items (which has
stale 5_done stages) and overwrites the CRDT back to 5_done for stories
that were already swept to 6_archived. On every restart, done stories
reappear and get re-swept.

The migration served its purpose — CRDT stages are now correct. Remove it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:50:07 +00:00
dave 17d635b66b fix: restore CRDT-based triage command (535 fix was reverted by merge conflict)
Story 535's triage fix was overwritten by a subsequent merge that
resolved a conflict by taking the old filesystem-based version.
Re-applies the CRDT-based triage that reads from pipeline state
and content store, works for any pipeline stage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:43:26 +00:00
dave 4ab723f40b huskies: merge 538_bug_done_archived_sweep_never_fires_because_stage_done_projection_uses_utc_now_instead_of_real_merged_at_timestamp 2026-04-11 13:29:38 +00:00
dave 5d193bb568 huskies: merge 537_bug_delete_item_sets_stage_to_deleted_string_instead_of_writing_a_crdt_tombstone 2026-04-11 13:25:45 +00:00
dave dcf6cf8f82 fix: collapse consecutive str::replace calls to satisfy clippy 2026-04-11 13:21:47 +00:00
dave eea54ca616 fix: thread-local CRDT and content store for test isolation
Tests shared a global CRDT singleton and content store HashMap, causing
flaky failures when parallel tests wrote items that polluted each
other's assertions. 3-5 random test failures per run.

Both CRDT_STATE and CONTENT_STORE now use thread_local! in test mode
so each test thread gets its own isolated instance. Production code
is unchanged — it still uses the global OnceLock singletons.

Also fixed 3 tests (create_story_writes_correct_content,
next_item_number_increments_from_existing_bugs,
next_item_number_scans_archived_too) that relied on leaked state
from other tests — they now write to the content store explicitly.

Result: 1902 passed, 0 failed across 5 consecutive runs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:02:09 +00:00
dave dd53870c59 fix: agent prompts use run_tests MCP tool instead of running script/test via Bash
Agents were running script/test directly through the PTY, streaming
the full output of npm install, cargo clippy, cargo test, and frontend
builds into session logs. This tripled session log sizes (~200KB to
~600KB per session) and contributed to CLI SIGABRT crashes.

The run_tests MCP tool already runs script/test server-side and returns
a truncated JSON summary. Agents now use it exclusively.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:46:05 +00:00
dave 5696d77922 debug: add PTY spawn diagnostics for Session: None investigation
When an agent CLI exits without creating a session, we now log:
- Number of prior sessions and total session log bytes
- Child process exit status (exit code or signal)
- Explicit SESSION NONE warning with context

This will help diagnose whether the fatal runtime error
(output.write assertion) correlates with accumulated sessions,
budget exhaustion, or something else.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:21:06 +00:00
dave 44ef477a01 fix: skip rate limit timer for short blocks (≤10 min) — CLI handles internally
The rate limit auto-scheduler was creating timers for every hard block,
including short 5-minute throttles. This caused a death loop: agent hits
rate limit, timer set, agent exits, pipeline restarts before timer fires,
new agent dies instantly (Session: None) because API is still throttled.

Short rate limits are handled naturally by the CLI's internal wait. Only
schedule timers for long session-level blocks (>10 min) where the CLI
will exit and needs external restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:52:14 +00:00
Timmy de738b27ed fix: CrdtNode derive macro defaults missing fields instead of panicking
When replaying old CRDT ops that predate new struct fields (e.g.
claimed_by, claim_ts added by story 479), node_from would call
.unwrap() on None and panic during init. Now defaults to an empty
CrdtNode::new() for missing fields, allowing schema evolution without
breaking replay of historical ops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:16:10 +01:00
dave fc24da82ae debug: add logging to sync_crdt_stages_from_db to diagnose stale backlog 2026-04-10 20:33:04 +00:00
dave bae3619723 fix: startup migration syncs stale CRDT stages from pipeline_items DB
510 stories had stale 1_backlog stages in the CRDT because they were
imported during the filesystem→CRDT migration and then moved forward
via filesystem-only moves that never wrote CRDT ops. This made done
stories appear as ghost entries in the backlog.

On startup, reads the authoritative stage from pipeline_items and
corrects any CRDT entries that disagree.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:58:17 +00:00
dave ea36160667 fix: read_all_items must use deduplicated index, not raw CRDT entries
read_all_items was iterating all CRDT entries including stale duplicates
from earlier stage writes. A story written multiple times (backlog →
current → done) would appear in the output multiple times with different
stages, causing ghost entries in the pipeline status and backlog views.

Now iterates only the index (story_id → visible_index map) which
represents the latest-wins deduplicated view of each story.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:32:55 +00:00
dave 2e0ed98d42 huskies: merge 480_story_cryptographic_node_auth_for_distributed_mesh 2026-04-10 19:14:21 +00:00
dave 40893a8cb1 huskies: merge 535_bug_chat_status_number_and_mcp_tool_status_still_read_from_filesystem_broken_after_530 2026-04-10 19:01:31 +00:00
dave bc2b1e244c huskies: merge 498_bug_stale_merge_job_lock_prevents_new_merges_after_agent_dies 2026-04-10 18:55:05 +00:00
dave 6f7a0c7708 huskies: merge 479_story_build_agent_mode_with_crdt_based_work_claiming 2026-04-10 18:50:30 +00:00
dave 91be0ac47f huskies: merge 534_refactor_unify_timer_tick_watchdog_and_watcher_sweep_into_a_single_1_second_tick_loop 2026-04-10 17:38:42 +00:00
dave 808935b446 huskies: merge 528_story_crdt_based_peer_discovery_via_node_presence_entries 2026-04-10 17:03:05 +00:00
dave 4c8fe910a7 huskies: merge 533_story_crdt_based_done_archived_sweep_to_replace_filesystem_based_watcher_sweep 2026-04-10 16:58:50 +00:00
dave 8f34c521fb huskies: merge 508_story_configurable_rendezvous_peer_in_project_toml_with_outbound_crdt_sync_connect 2026-04-10 16:44:50 +00:00
dave a59f4fc1a5 huskies: merge 532_story_remove_startup_reconcile_pass_and_drift_notification_no_filesystem_to_reconcile_against 2026-04-10 16:40:56 +00:00
dave b88857c2e4 huskies: merge 507_story_apply_inbound_signedops_with_causal_order_queue_for_partition_recovery 2026-04-10 16:13:07 +00:00
dave 1ca9bc1bfd huskies: merge 506_story_websocket_sync_endpoint_that_broadcasts_local_signedops_to_connected_peers 2026-04-10 15:52:49 +00:00
dave 73890c98fa huskies: merge 505_story_signedop_wire_codec_for_crdt_sync_between_nodes 2026-04-10 15:35:10 +00:00
dave bfede09fe6 huskies: merge 529_bug_stale_mergemaster_advance_moves_done_stories_back_to_merge_zombie_merge_loop 2026-04-10 15:20:34 +00:00
dave 11d19d8902 huskies: merge 530_story_eliminate_filesystem_markdown_shadows_entirely_crdt_db_is_the_only_story_store 2026-04-10 14:59:58 +00:00
dave 1dd675796b huskies: merge 531_story_mcp_tool_to_read_agent_session_logs_from_disk_not_just_live_stream 2026-04-10 13:08:51 +00:00
dave 31388da609 huskies: merge 517_story_remove_filesystem_shadow_fallback_paths_from_lifecycle_rs_finish_the_migration_to_crdt_only 2026-04-10 13:00:25 +00:00
dave fe405e81c6 huskies: merge 527_story_remove_rate_limit_hard_block_bot_notifications_from_matrix_chat 2026-04-10 11:27:36 +00:00
dave 7e5b9839e8 huskies: merge 523_refactor_introduce_script_test_script_lint_script_build_and_migrate_agent_prompts_off_tech_specific_commands 2026-04-10 11:22:51 +00:00
dave 2a24a4cc85 huskies: merge 522_story_migrate_status_command_pipeline_view_from_filesystem_to_pipeline_state_read_all_typed 2026-04-10 10:37:17 +00:00
dave 6310c8bf49 huskies: merge 518_story_apply_and_persist_should_log_when_persist_tx_send_fails_instead_of_silently_dropping_the_op 2026-04-10 10:33:01 +00:00
dave 61ae30873f huskies: merge 516_story_update_story_description_should_create_the_description_section_if_it_doesn_t_exist_instead_of_erroring 2026-04-10 10:28:53 +00:00
dave f015fe5a1d huskies: merge 515_story_add_a_debug_mcp_tool_to_dump_the_in_memory_crdt_state_for_inspection 2026-04-10 10:24:30 +00:00
dave c6b6be872b huskies: merge 509_bug_create_story_silently_drops_description_and_any_other_unknown_parameters_with_no_error 2026-04-10 10:20:13 +00:00
dave 5377eeae5b huskies: merge 513_story_startup_reconcile_pass_that_detects_drift_between_crdt_pipeline_items_and_filesystem_shadows 2026-04-10 10:16:45 +00:00
Timmy 92b212e7fd huskies: merge 504_story_update_story_front_matter_mcp_schema_should_accept_non_string_values_lists_bools_numbers
Squash merge of story 504: add MCP regression tests for non-string
front_matter values (arrays, bools, integers). The schema change itself
was already on master. Fixed the array assertion to match YAML's
space-after-comma inline sequence format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:08:21 +01:00
Timmy 9633ab35a6 fix: validate_story_dirs reads filesystem shadows instead of global CRDT singleton (bug 525)
The post-520 migration changed validate_story_dirs to read from
pipeline_state::read_all_typed() (the process-global CRDT singleton),
ignoring its root: &Path argument. This broke test isolation — tests
creating a tempdir saw dozens of results from ambient CRDT state,
causing non-deterministic failures that blocked every mergemaster gate.

Remove the CRDT singleton block and rely on the filesystem shadow scan
that already uses the root argument correctly. 1845/1845 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:52:42 +01:00
dave d1b845fd2e fix: move_item must not overwrite advanced CRDT stage when missing_ok=true (bug 524)
When a story is found in the CRDT but not in the expected source stages,
and missing_ok is true, return Ok(None) instead of proceeding with the move.
This prevents promote_ready_backlog_stories from demoting a story that has
already advanced to merge/done via a stale filesystem shadow in 1_backlog.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 00:21:39 +00:00
Timmy 934bda5904 Trying out sonnet for merges 2026-04-10 01:04:25 +01:00
Timmy 962e3d4e7d fmt 2026-04-10 01:04:09 +01:00
dave 0de9200d48 huskies: merge 512_story_migrate_chat_commands_from_filesystem_lookup_to_crdt_db 2026-04-09 23:03:58 +00:00
dave c324452b38 fix: commit uncommitted native JSON type changes on master
These changes (HashMap<String, String> → HashMap<String, Value> for front matter,
json_value_to_yaml_scalar, and oneOf schema for front_matter) were left uncommitted
on master after a previous merge, blocking the cherry-pick step of story 509's merge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 22:35:52 +00:00
dave d3ee850f37 huskies: merge 500_story_remove_duplicate_pty_debug_log_lines 2026-04-09 22:16:03 +00:00
dave cbe016d7a2 huskies: merge 519_story_mergemaster_should_detect_no_commits_ahead_of_master_and_fail_loudly_instead_of_exiting_silently 2026-04-09 22:11:09 +00:00
dave 6f6d37e955 huskies: merge 514_story_delete_story_should_do_a_full_cleanup_crdt_op_db_row_filesystem_shadow_worktree_pending_timers 2026-04-09 22:05:18 +00:00
dave 84717b04bd huskies: merge 520_story_typed_pipeline_state_machine_in_rust_foundation_replaces_stringly_typed_crdt_views_with_strict_enums_subsumes_436 2026-04-09 21:27:48 +00:00
Timmy 1d9287389a feat(521): evict_item primitive + purge_story MCP tool
Adds the foundational capability to clear a story from the running
server's in-memory CRDT state without restarting the process. This is
story 521, motivated by the 2026-04-09 incident where stories 478 and
503 kept resurrecting from in-memory CRDT after every sqlite delete /
worktree removal / timers.json clear. The only previous remedy was a
full docker restart.

Changes:

  - server/src/crdt_state.rs: new `pub fn evict_item(story_id: &str)`.
    Looks up the item's CRDT OpId via the visible-index map, calls the
    bft-json-crdt list `delete()` primitive to construct a tombstone op,
    runs it through the existing `apply_and_persist` machinery (which
    signs, applies to the in-memory CRDT, and queues for persistence to
    crdt_ops), rebuilds the story_id → visible_index map, and drops the
    in-memory CONTENT_STORE entry. The tombstone survives a restart
    because it's persisted as a real CRDT op.

  - server/src/http/mcp/story_tools.rs: new `tool_purge_story` MCP
    handler that takes a story_id and calls evict_item. Deliberately
    minimal — does NOT touch agents, worktrees, pipeline_items shadow
    table, timers.json, or filesystem shadows. Compose with stop_agent,
    remove_worktree, etc. for a full purge. Story 514 (delete_story
    full cleanup) is the future "do it all" tool.

  - server/src/http/mcp/mod.rs: registers the `purge_story` tool in the
    tools list and dispatch table.

Usage:

    mcp__huskies__purge_story story_id="<full_story_id>"

Returns a string confirming the eviction. The story will no longer
appear in get_pipeline_status, list_agents, or any other API that
reads from the in-memory CRDT view, and on the next server restart
the persisted tombstone op will keep it from being reconstructed.

This is a prerequisite for story 514 (delete_story full cleanup) and
useful for any "kill it with fire" operator need.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:29:09 +01:00
Timmy 13635b01bc wip(501): timer cancellation infrastructure (parallel session WIP + main.rs wiring)
Bundles in-progress work from a parallel Claude session toward fixing
bug 501 (rate-limit retry timer doesn't cancel on stop_agent / move_story
/ successful completion). This commit lands the foundation but the MCP
tool wiring is still TODO.

  - server/src/chat/timer.rs: defense-in-depth check in tick_once that
    skips firing a timer for stories already past 3_qa (3_qa, 4_merge,
    5_done, 6_archived). The primary cancellation path will be in the
    MCP tools; this guards races where a timer was scheduled before the
    story was advanced and the tool didn't get a chance to cancel it.

  - server/src/http/context.rs: adds `timer_store: Arc<TimerStore>` field
    on AppContext so MCP tools (move_story, stop_agent, ...) can reach
    the shared timer store and cancel pending entries when the user
    intervenes manually. The test helper is updated to construct one.

  - server/src/main.rs: wires up a TimerStore instance in the AppContext
    initialiser so the binary actually compiles after the context.rs
    field addition. TODO: the matrix bot's spawn_bot still creates its
    own TimerStore instance (in chat/transport/matrix/bot/run.rs:220-227)
    rather than consuming the shared one — that refactor is the next
    step in the bug 501 fix.

What is NOT in this commit and is needed to actually fix bug 501:
  - The MCP tool side (move_story, stop_agent, delete_story) does not
    yet call timer_store.cancel(story_id) when invoked
  - The matrix bot's spawn_bot does not yet consume the shared
    timer_store from AppContext — it still creates its own

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:28:48 +01:00
Timmy 1707277bb7 sketch(520): add ExecutionMachine to the statig sketch for parity with bare
The statig version was missing the per-node ExecutionState machine that
the bare version has. This commit adds it as a sub-module so its
generated `State` enum doesn't collide with the top-level PipelineMachine's
`State` enum.

Adds:
  - ExecutionEvent enum (top-level, alongside PipelineEvent)
  - mod execution { … } sub-module containing ExecutionMachine
  - States: idle, pending, running, rate_limited, completed
  - Cross-cutting `any` superstate that handles Stopped/Reset → Idle
  - 6 new tests covering the happy path, rate-limit + resume, and
    stop-from-anywhere via the superstate

Also adds a small note about how statig's `#[action]` entry/exit hooks
would replace the bare version's external EventBus pattern (without
implementing it — we'd pick one or the other based on whether side
effects should live inside or outside the state machine).

Test count: 11 → 17 (all passing).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:08:39 +01:00
Timmy 7c0015beb0 docs: file 12 stories from 2026-04-09 architecture session + handoff doc
Adds the markdown shadows for stories filed during today's stress-test
session, plus a SESSION_HANDOFF document for picking up the work in
a future session.

New stories (510-521):
  510 — bug: stale 1_backlog filesystem shadows get re-promoted by timers
  511 — bug: CRDT lamport clock resets to 1 on restart (FIXED in 99557635)
  512 — story: migrate chat commands from filesystem lookup to CRDT/DB
  513 — story: startup reconcile pass for state-machine drift detection
  514 — story: delete_story should do a full cleanup
  515 — story: debug MCP tool to dump in-memory CRDT state
  516 — story: update_story.description should create the section if missing
  517 — story: remove filesystem-shadow fallback paths from lifecycle.rs
  518 — story: apply_and_persist should log persist_tx send failures
  519 — story: mergemaster should fail loudly on no-op merges (mostly
                 obviated by Stage::Merge { commits_ahead: NonZeroU32 } in 520)
  520 — story: typed pipeline state machine in Rust (sketches added in f7d69cde)
  521 — story: MCP capability to write a CRDT tombstone for a story

Refactor 436 (unify story stuck states) is marked superseded by 520
via front_matter — its functionality is now part of the
Stage::Archived { reason: ArchiveReason } enum in story 520's design.

The SESSION_HANDOFF_2026-04-09.md document captures: the four-state-machine
drift situation that motivated story 520, today's bug fixes (502 + 511),
the off-leash rogue commit incident (forensic tag rogue-commit-2026-04-09-ac9f3ecf
preserved), the recommended next-session priority order, and useful
diagnostic recipes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:03:53 +01:00
Timmy f7d69cde50 sketch(520): typed pipeline state machine — bare and statig versions
Two parallel scratch experiments under server/examples/ exploring the
typed Rust state machine that should replace huskies's current
stringly-typed CRDT representation (story 520).

  - pipeline_state_sketch_bare.rs   — hand-rolled, plain enums + match
  - pipeline_state_sketch_statig.rs — using the statig crate

Both sketches:
  - Define the same Stage enum (Backlog, Coding, Qa, Merge, Done, Archived)
  - Define ArchiveReason (subsumes refactor 436's blocked/merge_failure/review_hold)
  - Define ExecutionState (per-node, separate from synced Stage) — bare only
  - Define PipelineEvent and the valid transitions
  - Make bug 519 unrepresentable: Stage::Merge requires NonZeroU32 commits_ahead
  - Make bug 502 unrepresentable: Coder agents can't be assigned to Merge state
  - Have happy-path tests, retry-loop tests, and invalid-transition tests

Differences:
  - Bare uses pure pattern matching, no framework. ~720 lines.
  - Statig uses #[state_machine] proc macro and gets free hierarchical
    states via the `active` superstate that factors out the cross-cutting
    Block / ReviewHold / Abandon / Supersede transitions across the four
    active stages. ~440 lines, 11 passing tests.

Run with:
  cargo run  --example pipeline_state_sketch_bare   -p huskies
  cargo run  --example pipeline_state_sketch_statig -p huskies
  cargo test --example pipeline_state_sketch_bare   -p huskies
  cargo test --example pipeline_state_sketch_statig -p huskies

Adds statig 0.3 as a dev-dependency in server/Cargo.toml. Cargo.lock
updated to include statig + statig-macro and their transitive deps.

Not wired into the main codebase. Once we agree on which version to
adopt, story 520 promotes the chosen sketch into a real
server/src/pipeline_state.rs module.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:03:07 +01:00
Timmy 995576358f fix(511): replay CRDT ops by rowid ASC instead of seq ASC
The CRDT lamport seq is per-author and per-field, not globally
monotonic. Replaying by `seq ASC` causes field-update ops (which
have low per-field seq counters like 1, 2, 3) to be applied
BEFORE the list-insert ops they reference (which have higher
per-list seq counters like N for the Nth item ever inserted).
The field updates fail with ErrPathMismatch because the target
item doesn't exist yet, the field counter is never advanced,
and subsequent writes silently lose state.

Concretely on 2026-04-09 we observed: post-restart writes were
being persisted at seq=1,2,3,4,5,6,7 even though pre-restart
seq had reached 492. On the next replay, those low-seq field
updates would be applied before their seq=485+ creation ops,
silently dropping the updates. This was the load-bearing
"why does state keep flapping" bug today.

Fix: replay by `rowid ASC` (SQLite insertion order) instead.
Rowid preserves the causal order ops were originally applied
in, so field updates always come after the item insert they
reference.

Adds a regression test that constructs the exact scenario:
inserts a story (op gets seq=6), updates its stage (op gets
seq=1 because field counter starts at 0), persists both ops
in causal order, then replays both seq ASC (reproduces the
bug — stage update is lost) and rowid ASC (the fix — stage
update is preserved).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:02:01 +01:00
Timmy 5765fb57be merge(478): WebSocket CRDT sync layer (manual squash from feature/story-478)
Manual squash-merge of feature/story-478_… into master after the in-pipeline
mergemaster runs failed silently. The 478 agent did substantial real work
across multiple respawn cycles before being interrupted; commits on the
feature branch were intact and verified high-quality but never merged via
the normal pipeline path due to compounding bugs:

- The first mergemaster attempt ran ($0.82 in tokens) and exited "Done"
  cleanly but didn't push anything to master — likely the worktree was
  briefly on master rather than the feature branch when the merge_agent_work
  MCP tool ran, so it found nothing to merge.
- Subsequent timer fires defaulted to spawning coders instead of resuming
  mergemaster, burning more tokens for no progress.
- Bug 510 (split-brain shadows yanking done stories back to current) and
  bug 501 (timers don't cancel on stop/completion) compounded the cost.

What this commit lands:
- server/src/crdt_sync.rs (new, ~518 lines): GET /crdt-sync WebSocket
  handler that subscribes to locally-applied SignedOps and streams them as
  binary frames. Per-peer bounded queue (256 ops) drops slow peers.
- server/src/crdt_state.rs: new public functions subscribe_ops(),
  all_ops_json(), apply_remote_op() backing the sync handler. Adds the
  CRDT_OP_TX broadcast channel (capacity 1024).
- server/src/main.rs: wires up the sync subsystem at startup.
- server/src/http/mod.rs: registers the new endpoint.
- server/src/config.rs: adds optional rendezvous field for outbound peers.
- server/src/worktree.rs: minor changes from the original branch.
- server/Cargo.toml: cfg lint suppression for CrdtNode derive.
- crates/bft-json-crdt/src/debug.rs: fix unused-variable warnings.

Resolved a trivial test-mod merge conflict in crdt_state.rs (both 478 and
503 added new tests at the end of the test module — kept both sets).

Note: this is the squash of the original 478 work that the user explicitly
authorized landing. The earlier rogue commit ac9f3ecf — which added a
DIFFERENT, broken implementation of the same feature directly to master
under the user's identity without consent — was reverted earlier in this
session. The forensic tags rogue-commit-2026-04-09-ac9f3ecf and
pre-502-reset-2026-04-09 still exist for incident audit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:46:29 +01:00
dave 41515e3b8f huskies: merge 503_bug_depends_on_pointing_at_an_archived_story_is_silently_treated_as_deps_met_surprising_users 2026-04-09 18:31:29 +00:00
Timmy 8b2e068d3e fix(502): don't demote merge-stage stories on mergemaster attach
start_agent unconditionally called move_story_to_current at the top of
its body, before the agent-stage check. When called for mergemaster (or
qa) on a story in 4_merge/ AND a stale 1_backlog/ shadow of the story
existed (post-491/492 split-brain artifact), the move would find the
shadow and yank it to 2_current/, find_active_story_stage would then
report 2_current/, the stage check would expect a Coder agent, and
mergemaster would be rejected — leaving the story in 2_current/ to be
re-promoted by the next auto-assign tick. Infinite loop.

Gate the move so it only fires for Coder-stage agents. QA and
Mergemaster now attach to the story at its existing stage.

Adds a regression test that reproduces the split-brain scenario by
seeding both 4_merge/ and 1_backlog/ copies of the same story and
asserting (1) the stage check does not reject mergemaster, and (2) the
4_merge/ copy is preserved (i.e. not demoted to 2_current/).

Observed live on 2026-04-09 while story 478 was looping. Filed as
bug 502.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:18:01 +01:00
dave 59fbb56252 chore: ignore pipeline.db backup files in .huskies/.gitignore
The pre-478-surgery backup file was left untracked, causing the
acceptance gate to fail. Add pipeline.db.bak* pattern to ignore
such backup files.
2026-04-09 19:16:27 +01:00
Timmy 278bc8f050 Noting script/ commands for Docker rebuild and restart. 2026-04-09 18:00:20 +01:00
Timmy f5634a7434 Archiving the last of the pipeline story files 2026-04-09 17:59:54 +01:00
Timmy 8d9600183f Ignoring the huskies pipeline datastore 2026-04-09 17:59:12 +01:00
Timmy bb865687d5 Formatting 2026-04-09 17:58:29 +01:00
dave 1ffdd75475 huskies: accept 499_story_web_ui_shows_project_name_in_browser_tab_with_huskies_favicon 2026-04-08 05:04:16 +00:00
dave 46a254f80c huskies: accept 496_bug_hard_rate_limit_without_reset_at_never_auto_schedules_retry 2026-04-08 04:05:13 +00:00
dave 1baa83c1fd huskies: accept 491_story_watcher_fires_on_crdt_state_transitions_instead_of_filesystem_events 2026-04-08 04:03:12 +00:00
dave 870f49509d huskies: done 492_story_remove_filesystem_pipeline_state_and_store_story_content_in_database 2026-04-08 03:07:36 +00:00
dave 8fd49d563e huskies: merge 492_story_remove_filesystem_pipeline_state_and_store_story_content_in_database 2026-04-08 03:07:33 +00:00
dave f43d30bdae huskies: accept 497_bug_dependency_promotion_loop_missing_stories_with_met_deps_never_move_from_backlog_to_current 2026-04-08 01:33:33 +00:00
dave 6a56fa5623 huskies: done 497_bug_dependency_promotion_loop_missing_stories_with_met_deps_never_move_from_backlog_to_current 2026-04-08 01:32:29 +00:00
dave eba933e21e huskies: merge 497_bug_dependency_promotion_loop_missing_stories_with_met_deps_never_move_from_backlog_to_current 2026-04-08 01:32:26 +00:00
dave bc429edf49 huskies: done 491_story_watcher_fires_on_crdt_state_transitions_instead_of_filesystem_events 2026-04-08 01:18:33 +00:00
dave 5c2769dd7d huskies: merge 491_story_watcher_fires_on_crdt_state_transitions_instead_of_filesystem_events 2026-04-08 01:18:30 +00:00
dave dbdcf334aa huskies: done 499_story_web_ui_shows_project_name_in_browser_tab_with_huskies_favicon 2026-04-08 01:07:35 +00:00
dave 09a89fdb6b huskies: merge 499_story_web_ui_shows_project_name_in_browser_tab_with_huskies_favicon 2026-04-08 01:07:32 +00:00
dave 0fa0b60feb huskies: create 491_story_watcher_fires_on_crdt_state_transitions_instead_of_filesystem_events 2026-04-08 00:55:02 +00:00
dave e814f5dd3c huskies: delete 488_story_web_ui_shows_project_name_in_browser_tab_with_huskies_favicon 2026-04-08 00:52:31 +00:00
dave ce9acbdeab huskies: create 499_story_web_ui_shows_project_name_in_browser_tab_with_huskies_favicon 2026-04-08 00:52:01 +00:00
dave ea8e12190b huskies: done 496_bug_hard_rate_limit_without_reset_at_never_auto_schedules_retry 2026-04-08 00:04:28 +00:00
dave dea410149a huskies: merge 496_bug_hard_rate_limit_without_reset_at_never_auto_schedules_retry 2026-04-08 00:04:25 +00:00
dave f8bebd0fdf huskies: create 498_bug_stale_merge_job_lock_prevents_new_merges_after_agent_dies 2026-04-07 23:49:27 +00:00
dave 753f7f1c92 fix: comment out premature db::crdt references that broke build
The 490 merge introduced references to a db::crdt module that doesn't
exist yet (it's part of story 491). Commented out with TODO(491)
markers so master compiles. The crdt_state.rs module from 490 is
intact — these are just the call sites that will be wired up when
491 lands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:49:11 +00:00
dave c4e70db85f huskies: accept 490_story_crdt_state_layer_backed_by_sqlite 2026-04-07 18:54:36 +00:00
dave c06a01facb huskies: accept 495_bug_status_traffic_light_dots_use_unsupported_html_colouring_switch_to_emoji 2026-04-07 18:41:32 +00:00
dave 0072e44e0f huskies: accept 494_story_mcp_tool_to_run_project_test_suite 2026-04-07 18:39:31 +00:00
dave 8372b77e07 huskies: accept 493_bug_story_dependency_chain_not_firing_due_to_front_matter_format_issues 2026-04-07 17:16:27 +00:00
dave 8be4e73d10 huskies: accept 489_story_sqlite_shadow_write_for_pipeline_state_via_sqlx 2026-04-07 17:10:27 +00:00
dave 2811c27a2a scope script/test to huskies crate only
Skip compiling bft-json-crdt test harness in gate checks. The CRDT
crate's tests are stable and not being modified — no need to compile
and run them on every story.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:22:19 +00:00
dave 15a52d6d38 ignore kleppmann_trace test — 10+ min, 12GB RAM
Marked #[ignore] so cargo test skips it by default. Run manually with
--ignored flag when needed for benchmarking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:15:38 +00:00
dave c73153dd4e huskies: merge 490_story_crdt_state_layer_backed_by_sqlite
CRDT state layer backed by SQLite for pipeline state. Integrates the
BFT JSON CRDT crate with SQLite persistence via sqlx. Ops are persisted
and replayed on startup. Node identity via Ed25519 keypair.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:12:19 +00:00
dave c621bca7b1 huskies: done 495_bug_status_traffic_light_dots_use_unsupported_html_colouring_switch_to_emoji 2026-04-07 15:55:04 +00:00
dave 5a9601dd3c huskies: merge 495_bug_status_traffic_light_dots_use_unsupported_html_colouring_switch_to_emoji 2026-04-07 15:55:01 +00:00
dave b05ddedb41 huskies: create 497_bug_dependency_promotion_loop_missing_stories_with_met_deps_never_move_from_backlog_to_current 2026-04-07 15:52:48 +00:00
dave 0e2d9fe1cd huskies: accept 487_story_display_story_dependencies_in_web_ui_and_chat_commands 2026-04-07 15:47:55 +00:00
dave a126929f00 huskies: done 490_story_crdt_state_layer_backed_by_sqlite 2026-04-07 15:47:50 +00:00
dave 7eecfeb56a bump gate timeout from 600s to 1200s
Merge worktree cold-compiles the BFT CRDT crate + all deps which
exceeds 600s. 1200s gives enough headroom.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:47:44 +00:00
dave c7cf1e8335 huskies: accept 488_story_web_ui_shows_project_name_in_browser_tab_with_huskies_favicon 2026-04-07 15:35:57 +00:00
dave 61a8f0edca huskies: accept 481_bug_scaffold_does_not_copy_agent_definitions_from_project_toml_to_new_projects 2026-04-07 15:11:57 +00:00
dave fa5885154b huskies: create 496_bug_hard_rate_limit_without_reset_at_never_auto_schedules_retry 2026-04-07 14:57:20 +00:00
dave 0adc2a494e huskies: done 494_story_mcp_tool_to_run_project_test_suite 2026-04-07 14:43:44 +00:00
dave 19768c23d5 huskies: merge 494_story_mcp_tool_to_run_project_test_suite 2026-04-07 14:43:41 +00:00
dave 1b8c391836 huskies: create 495_bug_status_traffic_light_dots_use_unsupported_html_colouring_switch_to_emoji 2026-04-07 14:41:00 +00:00
dave 1acb8123ae huskies: create 494_story_mcp_tool_to_run_project_test_suite 2026-04-07 14:16:19 +00:00
dave 132d61cb68 huskies: done 488_story_web_ui_shows_project_name_in_browser_tab_with_huskies_favicon 2026-04-07 13:36:46 +00:00
dave 4476c57444 huskies: merge 488_story_web_ui_shows_project_name_in_browser_tab_with_huskies_favicon 2026-04-07 13:36:43 +00:00
dave c64577eff0 huskies: done 493_bug_story_dependency_chain_not_firing_due_to_front_matter_format_issues 2026-04-07 13:32:42 +00:00
dave a3a3942b0a huskies: merge 493_bug_story_dependency_chain_not_firing_due_to_front_matter_format_issues 2026-04-07 13:32:38 +00:00
dave d158b05a1a huskies: create 480_story_cryptographic_node_auth_for_distributed_mesh 2026-04-07 13:19:38 +00:00
dave 9fc68e1379 huskies: create 479_story_build_agent_mode_with_crdt_based_work_claiming 2026-04-07 13:19:27 +00:00
dave fcc9d35c33 huskies: create 478_story_websocket_sync_layer_for_crdt_state_between_nodes 2026-04-07 13:19:11 +00:00
dave 8ae5dad649 huskies: create 492_story_remove_filesystem_pipeline_state_and_store_story_content_in_database 2026-04-07 13:18:48 +00:00
dave 9b36252d1d huskies: create 491_story_watcher_fires_on_crdt_state_transitions_instead_of_filesystem_events 2026-04-07 13:18:36 +00:00
dave 88c1d8b44f huskies: create 490_story_crdt_state_layer_backed_by_sqlite 2026-04-07 13:18:24 +00:00
dave 9a255086c4 huskies: create 493_bug_story_dependency_chain_not_firing_due_to_front_matter_format_issues 2026-04-07 13:16:26 +00:00
dave 4f6d4a1e2e huskies: done 489_story_sqlite_shadow_write_for_pipeline_state_via_sqlx 2026-04-07 13:13:20 +00:00
dave f1ef31d1ee huskies: merge 489_story_sqlite_shadow_write_for_pipeline_state_via_sqlx 2026-04-07 13:13:17 +00:00
dave 0c9e120ba2 huskies: create 480_story_cryptographic_node_auth_for_distributed_mesh 2026-04-07 12:33:35 +00:00
dave afdb604255 huskies: create 479_story_build_agent_mode_with_crdt_based_work_claiming 2026-04-07 12:33:28 +00:00
dave d5fcbb19f0 huskies: create 478_story_websocket_sync_layer_for_crdt_state_between_nodes 2026-04-07 12:33:21 +00:00
dave 4c51258a17 huskies: create 492_story_remove_filesystem_pipeline_state_and_store_story_content_in_database 2026-04-07 12:33:14 +00:00
dave e0b51e8041 huskies: create 491_story_watcher_fires_on_crdt_state_transitions_instead_of_filesystem_events 2026-04-07 12:33:05 +00:00
dave 5e025c6c20 huskies: create 490_story_crdt_state_layer_backed_by_sqlite 2026-04-07 12:32:57 +00:00
dave 78f79e9081 huskies: create 489_story_sqlite_shadow_write_for_pipeline_state_via_sqlx 2026-04-07 12:32:48 +00:00
dave d2db973daa huskies: done 487_story_display_story_dependencies_in_web_ui_and_chat_commands 2026-04-07 11:50:01 +00:00
dave 4e082009c2 huskies: merge 487_story_display_story_dependencies_in_web_ui_and_chat_commands 2026-04-07 11:49:57 +00:00
dave 05eb13eab3 huskies: accept 485_story_documentation_site_for_huskies_dev 2026-04-07 11:42:29 +00:00
dave 85ebecb115 huskies: done 485_story_documentation_site_for_huskies_dev 2026-04-07 11:42:25 +00:00
dave 9e9ab374f0 huskies: merge 485_story_documentation_site_for_huskies_dev 2026-04-07 11:42:22 +00:00
dave b07eb70c70 huskies: create 488_story_web_ui_shows_project_name_in_browser_tab_with_huskies_favicon 2026-04-07 11:35:00 +00:00
dave 15d0209bcc huskies: create 487_story_display_story_dependencies_in_web_ui_and_chat_commands 2026-04-07 11:29:57 +00:00
dave 018b185489 huskies: done 481_bug_scaffold_does_not_copy_agent_definitions_from_project_toml_to_new_projects 2026-04-07 11:26:08 +00:00
dave 89aa705880 huskies: merge 481_bug_scaffold_does_not_copy_agent_definitions_from_project_toml_to_new_projects 2026-04-07 11:26:04 +00:00
dave e7f483f169 huskies: create 486_bug_create_worktree_deletes_all_files_from_main_branch_git_index 2026-04-07 10:57:09 +00:00
dave 79d8f70c29 huskies: accept 482_refactor_split_agent_definitions_from_project_toml_into_agents_toml 2026-04-07 10:38:27 +00:00
dave 7a86e5c26e huskies: create 480_story_cryptographic_node_auth_for_distributed_mesh 2026-04-04 21:54:08 +00:00
dave 2f1c412fd9 huskies: create 479_story_build_agent_mode_with_crdt_based_work_claiming 2026-04-04 21:53:45 +00:00
dave a72e83c703 huskies: create 478_story_websocket_sync_layer_for_crdt_state_between_nodes 2026-04-04 21:53:20 +00:00
dave 93438dc672 huskies: done 484_story_story_dependencies_in_pipeline_auto_assign 2026-04-04 21:47:01 +00:00
dave 5413a26406 huskies: merge 484_story_story_dependencies_in_pipeline_auto_assign 2026-04-04 21:46:58 +00:00
dave 26de009259 huskies: done 483_bug_timer_slash_command_not_wired_up_in_web_ui 2026-04-04 21:33:19 +00:00
dave 7a82a411ec huskies: merge 483_bug_timer_slash_command_not_wired_up_in_web_ui 2026-04-04 21:33:16 +00:00
dave 893f5b4984 huskies: create 485_story_documentation_site_for_huskies_dev 2026-04-04 21:32:05 +00:00
dave 22611b9a77 huskies: create 484_story_story_dependencies_in_pipeline_auto_assign 2026-04-04 21:25:24 +00:00
dave b64db3ba9e huskies: create 484_story_story_dependencies_in_pipeline_auto_assign 2026-04-04 21:25:12 +00:00
dave bf2da4576d huskies: create 484_story_story_dependencies_in_pipeline_auto_assign 2026-04-04 21:24:27 +00:00
dave 08260e2c6f huskies: done 482_refactor_split_agent_definitions_from_project_toml_into_agents_toml 2026-04-04 21:24:26 +00:00
dave 470e7a5fd5 huskies: merge 482_refactor_split_agent_definitions_from_project_toml_into_agents_toml 2026-04-04 21:24:22 +00:00
dave f63ed664eb huskies: create 480_story_cryptographic_node_auth_for_distributed_mesh 2026-04-04 21:21:32 +00:00
dave 552836b29b huskies: create 479_story_build_agent_mode_with_crdt_based_work_claiming 2026-04-04 21:21:20 +00:00
dave f598ed1ab9 huskies: create 478_story_websocket_sync_layer_for_crdt_state_between_nodes 2026-04-04 21:21:09 +00:00
dave e62ddce674 huskies: create 483_bug_timer_slash_command_not_wired_up_in_web_ui 2026-04-04 21:18:08 +00:00
dave 9aa07cf7a1 huskies: create 477_story_crdt_state_backend_replacing_filesystem_pipeline_state 2026-04-04 21:16:32 +00:00
dave 69d9dc8bc1 huskies: create 483_bug_timer_slash_command_not_wired_up_in_web_ui 2026-04-04 21:15:04 +00:00
dave abd5c6381a huskies: create 482_refactor_split_agent_definitions_from_project_toml_into_agents_toml 2026-04-04 21:04:13 +00:00
dave ed6747c487 huskies: create 481_bug_scaffold_does_not_copy_agent_definitions_from_project_toml_to_new_projects 2026-04-04 20:59:40 +00:00
dave 5e17784f7f huskies: done 476_refactor_split_agents_pool_lifecycle_rs_into_submodules 2026-04-04 20:54:27 +00:00
dave 91d31d908f huskies: merge 476_refactor_split_agents_pool_lifecycle_rs_into_submodules 2026-04-04 20:54:24 +00:00
dave 4e772b72db huskies: create 480_story_cryptographic_node_auth_for_distributed_mesh 2026-04-04 20:45:00 +00:00
dave 6ba0088128 huskies: create 479_story_build_agent_mode_with_crdt_based_work_claiming 2026-04-04 20:44:47 +00:00
dave e6ee814801 huskies: create 478_story_websocket_sync_layer_for_crdt_state_between_nodes 2026-04-04 20:44:34 +00:00
dave 3ab0410a82 huskies: create 477_story_crdt_state_backend_replacing_filesystem_pipeline_state 2026-04-04 20:44:25 +00:00
dave 5561b9c6c7 huskies: create 477_spike_distributed_build_agents_via_bft_crdts_over_websocket 2026-04-04 20:40:55 +00:00
dave c98f661b87 huskies: create 477_spike_distributed_build_agents_via_bft_crdts_over_websocket 2026-04-04 20:36:11 +00:00
Timmy 74d04d1157 Adding the bft-json-crdt crate source 2026-04-04 21:33:27 +01:00
Timmy 935a04c042 Styling it up 2026-04-04 21:32:14 +01:00
Timmy bcd642043a Adding source and release links 2026-04-04 21:32:05 +01:00
Timmy 5168592cd3 Ignoring some thinking 2026-04-04 21:31:41 +01:00
dave 2266e0d4dc huskies: create 477_spike_distributed_build_agents_via_bft_crdts_over_websocket 2026-04-04 20:22:47 +00:00
dave 339a8558af huskies: create 477_spike_distributed_build_agents_via_bft_crdts_over_websocket 2026-04-04 20:16:11 +00:00
dave 7b6865b099 huskies: create 477_spike_distributed_build_agents_via_bft_crdts_over_websocket 2026-04-04 20:15:37 +00:00
dave 3941abcca8 huskies: create 477_spike_distributed_build_agents_via_bft_crdts_over_websocket 2026-04-04 20:15:28 +00:00
dave 8164917f32 huskies: create 477_spike_distributed_build_agents_via_bft_crdts_over_websocket 2026-04-04 20:14:39 +00:00
dave 1d95ee17bb huskies: create 477_spike_distributed_build_agents_via_bft_crdts_over_websocket 2026-04-04 20:14:31 +00:00
dave 78b3f4c165 huskies: create 477_spike_distributed_build_agents_via_bft_crdts_over_websocket 2026-04-04 20:13:31 +00:00
dave 96fec31bb7 huskies: accept 475_refactor_deduplicate_lifecycle_rs_move_functions_into_a_shared_parameterised_helper 2026-04-04 19:21:06 +00:00
dave 52a2ee8ac7 huskies: accept 473_refactor_split_chat_tsx_into_smaller_components 2026-04-04 19:11:05 +00:00
dave 416adf9009 huskies: accept 474_story_per_file_test_coverage_report_with_improvement_targets 2026-04-04 19:08:04 +00:00
dave 86186b9ab3 huskies: done 475_refactor_deduplicate_lifecycle_rs_move_functions_into_a_shared_parameterised_helper 2026-04-04 15:23:52 +00:00
dave eb8654dba0 huskies: merge 475_refactor_deduplicate_lifecycle_rs_move_functions_into_a_shared_parameterised_helper 2026-04-04 15:23:49 +00:00
dave 0458de2b70 huskies: accept 471_story_bot_command_to_show_overall_test_coverage 2026-04-04 15:21:31 +00:00
dave 1c571fd8ce huskies: accept 472_story_discord_chat_transport 2026-04-04 15:19:58 +00:00
dave 107d95eece huskies: done 473_refactor_split_chat_tsx_into_smaller_components 2026-04-04 15:15:51 +00:00
dave fa99f19198 huskies: merge 473_refactor_split_chat_tsx_into_smaller_components 2026-04-04 15:15:48 +00:00
dave d4979ae492 huskies: accept 470_story_reduce_timer_tick_interval_to_1_second_and_suppress_idle_tick_logging 2026-04-04 15:15:30 +00:00
dave 030cef914c huskies: done 474_story_per_file_test_coverage_report_with_improvement_targets 2026-04-04 15:11:29 +00:00
dave df135e9373 huskies: merge 474_story_per_file_test_coverage_report_with_improvement_targets 2026-04-04 15:11:26 +00:00
dave c4e2f600de huskies: accept 469_bug_scaffold_missing_rate_limit_notifications_and_timezone_in_default_project_toml 2026-04-04 15:06:11 +00:00
dave 6375863c77 huskies: accept 468_story_exclude_git_worktrees_from_loc_command_output 2026-04-04 14:59:39 +00:00
dave 18d2242815 huskies: create 476_refactor_split_agents_pool_lifecycle_rs_into_submodules 2026-04-04 12:33:36 +00:00
dave 0ae0d0fba7 huskies: create 475_refactor_deduplicate_lifecycle_rs_move_functions_into_a_shared_parameterised_helper 2026-04-04 12:31:53 +00:00
dave 97220f5321 huskies: create 474_story_per_file_test_coverage_report_with_improvement_targets 2026-04-04 12:26:50 +00:00
dave 3b7b0c82de huskies: create 474_story_per_file_test_coverage_report_with_improvement_targets 2026-04-04 12:26:27 +00:00
dave e45d57bfb9 huskies: create 474_story_per_file_test_coverage_report_with_improvement_targets 2026-04-04 12:21:03 +00:00
dave a7d48afe3a huskies: done 472_story_discord_chat_transport 2026-04-04 12:12:07 +00:00
dave c56e462340 huskies: merge 472_story_discord_chat_transport 2026-04-04 12:12:03 +00:00
dave ee86e4a3d3 huskies: create 473_refactor_split_chat_tsx_into_smaller_components 2026-04-04 12:11:23 +00:00
dave 9b2c31688c huskies: done 471_story_bot_command_to_show_overall_test_coverage 2026-04-04 11:56:17 +00:00
dave e74f5275ef huskies: merge 471_story_bot_command_to_show_overall_test_coverage 2026-04-04 11:56:14 +00:00
dave 40a04397b4 huskies: done 470_story_reduce_timer_tick_interval_to_1_second_and_suppress_idle_tick_logging 2026-04-04 11:45:58 +00:00
dave 187e3d13f1 huskies: merge 470_story_reduce_timer_tick_interval_to_1_second_and_suppress_idle_tick_logging 2026-04-04 11:45:55 +00:00
dave 6af7e3d30b huskies: done 469_bug_scaffold_missing_rate_limit_notifications_and_timezone_in_default_project_toml 2026-04-04 11:41:56 +00:00
dave 34ab43aa7e huskies: merge 469_bug_scaffold_missing_rate_limit_notifications_and_timezone_in_default_project_toml 2026-04-04 11:41:53 +00:00
dave 2a3415c536 huskies: create 471_story_bot_command_to_show_overall_test_coverage 2026-04-04 11:20:22 +00:00
dave d5aca532da huskies: create 471_story_bot_command_to_show_overall_test_coverage 2026-04-04 11:20:16 +00:00
dave 3c9a5238cd huskies: create 472_story_discord_chat_transport 2026-04-04 11:19:57 +00:00
dave f4b43f80c2 huskies: create 472_story_discord_chat_transport 2026-04-04 11:19:09 +00:00
dave d22786a200 huskies: create 471_story_bot_command_to_show_overall_test_coverage 2026-04-04 11:18:00 +00:00
dave b602a2e4e5 huskies: create 471_story_bot_command_to_show_overall_test_coverage 2026-04-04 11:17:35 +00:00
dave 4a30c0924d huskies: create 471_story_bot_command_to_show_overall_test_coverage 2026-04-04 11:17:28 +00:00
dave 183d4c12bf huskies: create 471_story_bot_command_to_show_overall_test_coverage 2026-04-04 11:16:59 +00:00
dave a4a09bd094 huskies: create 470_story_reduce_timer_tick_interval_to_1_second_and_suppress_idle_tick_logging 2026-04-04 11:14:45 +00:00
dave 030fa04d34 huskies: done 468_story_exclude_git_worktrees_from_loc_command_output 2026-04-04 11:11:02 +00:00
dave 6b7c3bb450 huskies: merge 468_story_exclude_git_worktrees_from_loc_command_output 2026-04-04 11:10:58 +00:00
Timmy 656a840607 Fixing missing configs 2026-04-04 12:08:45 +01:00
dave 2cf654aa4c huskies: create 469_bug_scaffold_missing_rate_limit_notifications_and_timezone_in_default_project_toml 2026-04-04 11:04:39 +00:00
dave a11900a78f huskies: create 467_story_mcp_tool_to_return_current_time_in_project_timezone 2026-04-04 11:00:22 +00:00
dave e142e1a9c3 huskies: create 468_story_exclude_git_worktrees_from_loc_command_output 2026-04-04 10:59:38 +00:00
Timmy a211fba874 Fixed up README a bit 2026-04-04 11:53:10 +01:00
Timmy 889b0f0cb8 Coding agents text change. 2026-04-03 21:38:58 +01:00
Timmy 0770da6c30 More husky 2026-04-03 21:13:13 +01:00
Timmy 1bbb2dc128 Husky metaphor in action. 2026-04-03 21:05:23 +01:00
Timmy dc0c2a75df Updated styles 2026-04-03 21:03:54 +01:00
dave 5594c6d21e huskies: accept 463_story_configurable_rate_limit_notification_suppression 2026-04-03 19:12:18 +00:00
Timmy afe94234c8 Fixed repo location in release script 2026-04-03 17:38:21 +01:00
Timmy 34c9324b74 Fixed website title 2026-04-03 17:25:00 +01:00
296 changed files with 300037 additions and 8027 deletions
+21
View File
@@ -0,0 +1,21 @@
Show test coverage from the cached `.coverage_baseline` file, or rerun the full test suite with `$ARGUMENTS`.
## Usage
- `/coverage` — read cached coverage from `.coverage_baseline` (instant)
- `/coverage run` — run `script/test_coverage` and report fresh results
## What it does
**Cached mode (default):** Reads `.coverage_baseline` and displays the stored coverage percentage(s). This is instant and does not run any tests.
**Run mode (`run`):** Executes `script/test_coverage` which runs:
1. Rust tests with `cargo llvm-cov` (reports line coverage %)
2. Frontend tests with `npm run test:coverage` (reports line coverage %)
3. Computes the overall average and compares to the threshold
Reports Rust coverage, Frontend coverage, Overall coverage, and whether the run passed the threshold.
---
If the arguments (`$ARGUMENTS`) equal `run`, execute `bash script/test_coverage` from the project root and show the Coverage Summary section from the output. Otherwise, read `.coverage_baseline` and display the stored coverage value(s).
+12 -60
View File
@@ -1,76 +1,28 @@
{ {
"enabledMcpjsonServers": [
"huskies"
],
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(./server/target/debug/huskies:*)",
"Bash(./target/debug/huskies:*)",
"Bash(HUSKIES_PORT=*)",
"Bash(cargo build:*)", "Bash(cargo build:*)",
"Bash(cargo check:*)", "Bash(cargo check:*)",
"Bash(cargo clippy:*)",
"Bash(cargo doc:*)",
"Bash(cargo llvm-cov:*)",
"Bash(cargo nextest run:*)",
"Bash(cargo run:*)",
"Bash(cargo test:*)",
"Bash(cargo watch:*)",
"Bash(cd *)",
"Bash(claude:*)",
"Bash(curl:*)",
"Bash(echo:*)",
"Bash(env:*)",
"Bash(git *)", "Bash(git *)",
"Bash(grep:*)",
"Bash(kill *)",
"Bash(ls *)", "Bash(ls *)",
"Bash(lsof *)",
"Bash(mkdir *)", "Bash(mkdir *)",
"Bash(mv *)", "Bash(mv *)",
"Bash(npm run build:*)",
"Bash(npx @biomejs/biome check:*)",
"Bash(npx @playwright/test test:*)",
"Bash(npx biome check:*)",
"Bash(npx playwright test:*)",
"Bash(npx tsc:*)",
"Bash(npx vitest:*)",
"Bash(pnpm add:*)",
"Bash(pnpm build:*)",
"Bash(pnpm dev:*)",
"Bash(pnpm install:*)",
"Bash(pnpm run build:*)",
"Bash(pnpm run test:*)",
"Bash(pnpm test:*)",
"Bash(printf:*)",
"Bash(ps *)",
"Bash(python3:*)",
"Bash(pwd *)",
"Bash(rm *)", "Bash(rm *)",
"Bash(sleep *)",
"Bash(touch *)", "Bash(touch *)",
"Bash(xargs:*)", "Bash(echo:*)",
"WebFetch(domain:crates.io)", "Bash(pwd *)",
"WebFetch(domain:docs.rs)", "Bash(grep:*)",
"WebFetch(domain:github.com)",
"WebFetch(domain:portkey.ai)",
"WebFetch(domain:www.shuttle.dev)",
"WebSearch",
"mcp__huskies__*",
"Edit",
"Write",
"Bash(find *)", "Bash(find *)",
"Bash(sqlite3 *)",
"Bash(cat <<:*)",
"Bash(cat <<'ENDJSON:*)",
"Bash(make release:*)",
"Bash(npm test:*)",
"Bash(head *)", "Bash(head *)",
"Bash(tail *)", "Bash(tail *)",
"Bash(wc *)", "Bash(wc *)",
"Bash(npx vite:*)", "Bash(cat *)",
"Bash(npm run dev:*)", "Edit",
"Bash(stat *)" "Write",
"mcp__huskies__*"
] ]
} },
} "enabledMcpjsonServers": [
"huskies"
]
}
+5
View File
@@ -9,6 +9,11 @@
store.json store.json
.huskies_port .huskies_port
.huskies/bot.toml.bak .huskies/bot.toml.bak
.huskies/build_hash
# Coverage report (generated by script/test_coverage, not tracked in git)
.coverage_report.json
.coverage_baseline
# Rust stuff # Rust stuff
target target
+7
View File
@@ -26,3 +26,10 @@ whatsapp_history.json
# Timers # Timers
timers.json timers.json
# Misc
wishlist.md
# Database
pipeline.db
pipeline.db.bak*
+131 -236
View File
@@ -1,267 +1,162 @@
# Story Kit: The Story-Driven Test Workflow (SDTW) # Huskies: Story-Driven Development
**Target Audience:** Large Language Models (LLMs) acting as Senior Engineers. **Target Audience:** LLM agents working as engineers.
**Goal:** To maintain long-term project coherence, prevent context window exhaustion, and ensure high-quality, testable code generation in large software projects. **Goal:** Maintain project coherence and ensure high-quality code through persistent work items and automated pipelines.
--- ---
## 0. First Steps (For New LLM Sessions) ## 0. First Steps (For New Agent Sessions)
When you start a new session with this project:
1. **Check Setup Wizard:** Call `wizard_status` to check if project setup is complete. If the wizard is not complete, guide the user through the remaining steps. Important rules for the wizard flow:
- **Be conversational.** Don't show tool names, step numbers, or raw wizard output to the user.
- **On projects with existing code:** Read the codebase and generate each file, then show the user what you wrote and ask if it looks right.
- **On bare projects with no code:** Ask the user what they want to build, what language/framework they plan to use, and generate files from their answers.
- **You must actually generate the files.** The workflow for each step is: (1) call `wizard_generate` with no args to get a hint, (2) write the file content yourself based on the conversation, (3) call `wizard_generate` again with the `content` argument containing the full file body, (4) show the user what you wrote, (5) call `wizard_confirm` (they approve), `wizard_retry` (they want changes), or `wizard_skip` (they want to skip). Do not stop after discussing — follow through and write the files.
- **Keep moving.** After each step is confirmed, immediately proceed to the next wizard step without waiting for the user to ask.
2. **Check for MCP Tools:** Read `.mcp.json` to discover the MCP server endpoint. Then list available tools by calling:
```bash
curl -s "$(jq -r '.mcpServers["huskies"].url' .mcp.json)" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
```
This returns the full tool catalog (create stories, spawn agents, record tests, manage worktrees, etc.). Familiarize yourself with the available tools before proceeding. These tools allow you to directly manipulate the workflow and spawn subsidiary agents without manual file manipulation.
3. **Read Context:** Check `.huskies/specs/00_CONTEXT.md` for high-level project goals.
4. **Read Stack:** Check `.huskies/specs/tech/STACK.md` for technical constraints and patterns.
5. **Check Work Items:** Look at `.huskies/work/1_backlog/` and `.huskies/work/2_current/` to see what work is pending.
1. **Read CLAUDE.md** in the worktree root for project-specific rules.
2. **Check MCP Tools:** Your `.mcp.json` connects you to the huskies server. Use MCP tools for all pipeline operations — never manipulate files directly.
3. **Check your story:** Call `status(story_id)` or `get_story_todos(story_id)` to see what needs doing.
--- ---
## 1. The Philosophy ## 1. Pipeline Overview
We treat the codebase as the implementation of a **"Living Specification."** driven by **User Stories** Work items (stories, bugs, spikes, refactors) move through stages managed by a CRDT state machine:
Instead of ephemeral chat prompts ("Fix this", "Add that"), we work through persistent artifacts.
* **Stories** define the *Change*.
* **Tests** define the *Truth*.
* **Code** defines the *Reality*.
**The Golden Rule:** You are not allowed to write code until the Acceptance Criteria are captured in the story. `Backlog → Current → QA → Merge → Done → Archived`
**All state lives in the CRDT.** There are no filesystem pipeline directories to read or write. Use MCP tools to query and manipulate pipeline state.
--- ---
## 1.5 MCP Tools ## 2. Your Workflow as a Coder Agent
Agents have programmatic access to the workflow via MCP tools served at `POST /mcp`. The project `.mcp.json` registers this endpoint automatically so Claude Code sessions and spawned agents can call tools like `create_story`, `validate_stories`, `list_upcoming`, `get_story_todos`, `record_tests`, `ensure_acceptance`, `start_agent`, `stop_agent`, `list_agents`, and `get_agent_output` without parsing English instructions. 1. **Read the story** via `status(story_id)` — understand the acceptance criteria.
2. **Implement** the feature/fix in your worktree. Commit as you go using `git_add` and `git_commit` MCP tools.
3. **Run tests** via the `run_tests` MCP tool (starts tests in the background). Poll `get_test_result` to check completion. Never run `cargo test` or `script/test` directly via Bash.
4. **Check off acceptance criteria** as you complete them using `check_criterion(story_id, criterion_index)`.
5. **Commit and exit.** The server runs acceptance gates automatically when your process exits and advances the pipeline based on the results.
**To discover what tools are available:** Check `.mcp.json` for the server endpoint, then use the MCP protocol to list available tools. **Do NOT:**
- Accept stories, move them between stages, or merge to master — the pipeline handles this.
- Run tests via Bash — use the MCP tools.
- Create summary documents or write terminal output to files.
--- ---
## 2. Directory Structure ## 3. Work Item Types
```text - **Story:** New functionality → implement and test
project_root/ - **Bug:** Broken functionality → fix with minimal surgical change
.mcp.json # MCP server configuration (if MCP tools are available) - **Spike:** Research/uncertainty → investigate, document findings, no production code
.story_kit/ - **Refactor:** Code improvement → restructure without changing behaviour
├── README.md # This document
├── project.toml # Agent configuration (roles, models, prompts) ---
├── work/ # Unified work item pipeline (stories, bugs, spikes)
│ ├── 1_backlog/ # New work items awaiting implementation ## 4. Bug Workflow
│ ├── 2_current/ # Work in progress
│ ├── 3_qa/ # QA review When working on bugs:
│ ├── 4_merge/ # Ready to merge to master 1. Read the story description first. If it specifies exact files and locations, go directly there.
│ ├── 5_done/ # Merged and completed (auto-swept to 6_archived after 4 hours) 2. If not specified, investigate with targeted grep.
│ └── 6_archived/ # Long-term archive 3. Fix with a surgical, minimal change.
├── worktrees/ # Agent worktrees (managed by the server) 4. Commit early. Don't spend turns on unnecessary verification.
├── specs/ # Minimal guardrails (context + stack)
│ ├── 00_CONTEXT.md # High-level goals, domain definition, and glossary ---
│ ├── tech/ # Implementation details (Stack, Architecture, Constraints)
│ │ └── STACK.md # The "Constitution" (Languages, Libs, Patterns) ## 5. Code Quality
│ └── functional/ # Domain logic (Platform-agnostic behavior)
│ └── ... Before exiting, ensure your code compiles and tests pass. Use `run_tests` MCP tool to verify. Fix all errors and warnings — zero tolerance.
└── src/ # The Code
Consult `specs/tech/STACK.md` for project-specific quality gates.
---
## 6. Key MCP Tools
| Tool | Purpose |
|------|---------|
| `status` | Get story details, ACs, git state |
| `get_story_todos` | List unchecked acceptance criteria |
| `check_criterion` | Mark an AC as done |
| `run_tests` | Start test suite (blocks until complete) |
| `git_status` | Worktree git status |
| `git_add` | Stage files |
| `git_commit` | Commit staged changes |
| `git_diff` | View changes |
| `git_log` | View commit history |
---
## 7. Project Architecture
Huskies is a single Rust binary with an embedded React frontend. Key things to know:
- **Backend:** `server/src/` — Rust, built with Poem (HTTP framework)
- **Frontend:** `frontend/src/` — React + TypeScript, built with Vite
- **Gateway mode:** `huskies --gateway` is a deployment mode of the same binary, NOT a separate application. The gateway backend code lives in `server/src/gateway.rs`. Gateway frontend components live in `frontend/src/` alongside everything else.
- **Stories that say "UI":** These are primarily frontend (TypeScript/React) work. Check what backend endpoints already exist before adding new ones. Keep Rust changes minimal.
- **Stories that say "gateway":** The gateway is just a mode. Don't restructure `gateway.rs` unless the story specifically asks for backend changes.
---
## 8. Deployment Modes
Huskies has three modes, all from the same binary:
### Standard (single project)
```
huskies [--port 3001] /path/to/project
``` ```
### Work Items Full server: web UI, MCP endpoint, chat bot, agent pool, pipeline. One project per instance.
All work items (stories, bugs, spikes) live in the same `work/` pipeline. Items are named: `{id}_{type}_{slug}.md` ### Headless Build Agent
* Stories: `57_story_live_test_gate_updates.md` ```
* Bugs: `4_bug_run_button_does_not_start_agent.md` huskies --rendezvous ws://host:port/crdt-sync
* Spikes: `61_spike_filesystem_watcher_architecture.md`
Items move through stages by moving the file between directories:
`1_backlog` → `2_current` → `3_qa` → `4_merge` → `5_done` → `6_archived`
Items in `5_done` are auto-swept to `6_archived` after 4 hours by the server.
### Filesystem Watcher
The server watches `.story_kit/work/` for changes. When a file is created, moved, or modified, the watcher auto-commits with a deterministic message and broadcasts a WebSocket notification to the frontend. This means:
* MCP tools only need to write/move files — the watcher handles git commits
* IDE drag-and-drop works (drag a story from `1_backlog/` to `2_current/`)
* The frontend updates automatically without manual refresh
---
## 3. The Cycle (The "Loop")
When the user asks for a feature, follow this 4-step loop strictly:
### Step 1: The Story (Ingest)
* **User Input:** "I want the robot to dance."
* **Action:** Create a story via MCP tool `create_story` (guarantees correct front matter and auto-assigns the story number).
* **Front Matter (Required):** Every work item file MUST begin with YAML front matter containing a `name` field:
```yaml
---
name: Short Human-Readable Story Name
---
```
* **Move to Current:** Once the story is validated and ready for coding, move it to `work/2_current/`.
* **Tracking:** Mark Acceptance Criteria as tested directly in the story file as tests are completed.
* **Content:**
* **User Story:** "As a user, I want..."
* **Acceptance Criteria:** Bullet points of observable success.
* **Out of scope:** Things that are out of scope so that the LLM doesn't go crazy
* **Story Quality (INVEST):** Stories should be Independent, Negotiable, Valuable, Estimable, Small, and Testable.
* **Git:** The `start_agent` MCP tool automatically creates a worktree under `.story_kit/worktrees/`, checks out a feature branch, moves the story to `work/2_current/`, and spawns the agent. No manual branch or worktree creation is needed.
### Step 2: The Implementation (Code)
* **Action:** Write the code to satisfy the approved tests and Acceptance Criteria.
* **Constraint:** adhere strictly to `specs/tech/STACK.md` (e.g., if it forbids certain patterns, you must not use them).
* **Full-Stack Completion:** Every story must be completed across all components of the stack. If a feature touches the backend, frontend, and API layer, all three must be fully implemented and working end-to-end before the story can be accepted. Partial implementations (e.g., backend logic with no frontend wiring, or UI scaffolding with no real data) do not satisfy acceptance criteria.
### Step 3: Verification (Close)
* **Action:** For each Acceptance Criterion in the story, write a failing test (red), mark the criterion as tested, make the test pass (green), and refactor if needed. Keep only one failing test at a time.
* **Action:** Run compilation and make sure it succeeds without errors. Consult `specs/tech/STACK.md` and run all required linters listed there (treat warnings as errors). Run tests and make sure they all pass before proceeding. Ask questions here if needed.
* **Action:** Do not accept stories yourself. Ask the user if they accept the story. If they agree, move the story file to `work/5_done/`.
* **Move to Done:** After acceptance, move the story from `work/2_current/` (or `work/4_merge/`) to `work/5_done/`.
* **Action:** When the user accepts:
1. Move the story file to `work/5_done/`
2. Commit both changes to the feature branch
3. Perform the squash merge: `git merge --squash feature/story-name`
4. Commit to master with a comprehensive commit message
5. Delete the feature branch: `git branch -D feature/story-name`
* **Important:** Do NOT mark acceptance criteria as complete before user acceptance. Only mark them complete when the user explicitly accepts the story.
**CRITICAL - NO SUMMARY DOCUMENTS:**
* **NEVER** create a separate summary document (e.g., `STORY_XX_SUMMARY.md`, `IMPLEMENTATION_NOTES.md`, etc.)
* **NEVER** write terminal output to a markdown file for "documentation purposes"
* Tests are the primary source of truth. Keep test coverage and Acceptance Criteria aligned after each story.
* If you find yourself typing `cat << 'EOF' > SUMMARY.md` or similar, **STOP IMMEDIATELY**.
* The only files that should exist after story completion:
* Updated code in `src/`
* Updated guardrails in `specs/` (if needed)
* Archived work item in `work/5_done/` (server auto-sweeps to `work/6_archived/` after 4 hours)
---
## 3.5. Bug Workflow (Simplified Path)
Not everything needs to be a full story. Simple bugs can skip the story process:
### When to Use Bug Workflow
* Defects in existing functionality (not new features)
* State inconsistencies or data corruption
* UI glitches that don't require spec changes
* Performance issues with known fixes
### Bug Process
1. **Document Bug:** Create a bug file in `work/1_backlog/` named `{id}_bug_{slug}.md` with:
* **Symptom:** What the user observes
* **Root Cause:** Technical explanation (if known)
* **Reproduction Steps:** How to trigger the bug
* **Proposed Fix:** Brief technical approach
* **Workaround:** Temporary solution if available
2. **Start an Agent:** Use the `start_agent` MCP tool to create a worktree and spawn an agent for the bug fix.
3. **Write a Failing Test:** Before fixing the bug, write a test that reproduces it (red). This proves the bug exists and prevents regression.
4. **Fix the Bug:** Make minimal code changes to make the test pass (green).
5. **User Testing:** Let the user verify the fix in the worktree before merging. Do not proceed until they confirm.
6. **Archive & Merge:** Move the bug file to `work/5_done/`, squash merge to master, delete the worktree and branch.
7. **No Guardrail Update Needed:** Unless the bug reveals a missing constraint
### Bug vs Story vs Spike
* **Bug:** Existing functionality is broken → Fix it
* **Story:** New functionality is needed → Test it, then build it
* **Spike:** Uncertainty/feasibility discovery → Run spike workflow
---
## 3.6. Spike Workflow (Research Path)
Not everything needs a story or bug fix. Spikes are time-boxed investigations to reduce uncertainty.
### When to Use a Spike
* Unclear root cause or feasibility
* Need to compare libraries/encoders/formats
* Need to validate performance constraints
### Spike Process
1. **Document Spike:** Create a spike file in `work/1_backlog/` named `{id}_spike_{slug}.md` with:
* **Question:** What you need to answer
* **Hypothesis:** What you expect to be true
* **Timebox:** Strict limit for the research
* **Investigation Plan:** Steps/tools to use
* **Findings:** Evidence and observations
* **Recommendation:** Next step (Story, Bug, or No Action)
2. **Execute Research:** Stay within the timebox. No production code changes.
3. **Escalate if Needed:** If implementation is required, open a Story or Bug and follow that workflow.
4. **Archive:** Move the spike file to `work/5_done/`.
### Spike Output
* Decision and evidence, not production code
* Specs updated only if the spike changes system truth
---
## 4. Context Reset Protocol
When the LLM context window fills up (or the chat gets slow/confused):
1. **Stop Coding.**
2. **Instruction:** Tell the user to open a new chat.
3. **Handoff:** The only context the new LLM needs is in the `specs/` folder and `.mcp.json`.
* *Prompt for New Session:* "I am working on Project X. Read `.mcp.json` to discover available tools, then read `specs/00_CONTEXT.md` and `specs/tech/STACK.md`. Then look at `work/1_backlog/` and `work/2_current/` to see what is pending."
---
## 5. Setup Instructions (For the LLM)
If a user hands you this document and says "Apply this process to my project":
1. **Check for MCP Tools:** Look for `.mcp.json` in the project root. If it exists, you have programmatic access to workflow tools and agent spawning capabilities.
2. **Analyze the Request:** Ask for the high-level goal ("What are we building?") and the tech preferences ("Rust or Python?").
3. **Git Check:** Check if the directory is a git repository (`git status`). If not, run `git init`.
4. **Scaffold:** Run commands to create the `work/` and `specs/` folders with the 6-stage pipeline (`work/1_backlog/` through `work/6_archived/`).
5. **Draft Context:** Write `specs/00_CONTEXT.md` based on the user's answer.
6. **Draft Stack:** Write `specs/tech/STACK.md` based on best practices for that language.
7. **Wait:** Ask the user for "Story #1".
---
## 6. Chat Bot Configuration
Story Kit includes a chat bot that can be connected to one messaging platform at a time. The bot handles commands, LLM conversations, and pipeline notifications.
**Only one transport can be active at a time.** To configure the bot, copy the appropriate example file to `.huskies/bot.toml`:
| Transport | Example file | Webhook endpoint |
|-----------|-------------|-----------------|
| Matrix | `bot.toml.matrix.example` | *(uses Matrix sync, no webhook)* |
| WhatsApp (Meta Cloud API) | `bot.toml.whatsapp-meta.example` | `/webhook/whatsapp` |
| WhatsApp (Twilio) | `bot.toml.whatsapp-twilio.example` | `/webhook/whatsapp` |
| Slack | `bot.toml.slack.example` | `/webhook/slack` |
```bash
cp .huskies/bot.toml.matrix.example .huskies/bot.toml
# Edit bot.toml with your credentials
``` ```
The `bot.toml` file is gitignored (it contains secrets). The example files are checked in for reference. Connects to an existing huskies instance as a worker node. Syncs the CRDT, claims work from the pipeline, runs agents. No web UI, no chat — just a build worker. Use this to add more compute to a project by running extra containers.
--- ### Gateway (multi-project)
## 7. Code Quality ```
huskies --gateway [--port 3000] /path/to/config
```
**MANDATORY:** Before completing Step 3 (Verification) of any story, you MUST run all applicable linters, formatters, and test suites and fix ALL errors and warnings. Zero tolerance for warnings or errors. Lightweight proxy that sits in front of multiple project containers. Reads a `projects.toml` that maps project names to container URLs:
**AUTO-RUN CHECKS:** Always run the required lint/test/build checks as soon as relevant changes are made. Do not ask for permission to run them—run them automatically and fix any failures. ```toml
[projects.huskies]
url = "http://huskies:3001"
**ALWAYS FIX DIAGNOSTICS:** At every stage, you must proactively fix all errors and warnings without waiting for user confirmation. Do not pause to ask whether to fix diagnostics—fix them immediately as part of the workflow. [projects.robot-studio]
url = "http://robot-studio:3002"
```
**Consult `specs/tech/STACK.md`** for the specific tools, commands, linter configurations, and quality gates for this project. The STACK file is the single source of truth for what must pass before a story can be accepted. The gateway presents a unified MCP surface to the chat agent. All tool calls are proxied to the active project's container. Gateway-specific tools:
| Tool | Purpose |
|------|---------|
| `switch_project` | Change the active project |
| `gateway_status` | Show active project and list all registered projects |
| `gateway_health` | Health check all containers |
### Example: multi-project Docker Compose
```yaml
services:
gateway:
image: huskies
command: ["huskies", "--gateway", "--port", "3000", "/workspace"]
ports:
- "127.0.0.1:3000:3000"
depends_on: [huskies, robot-studio]
huskies:
image: huskies
volumes:
- /path/to/huskies:/workspace
robot-studio:
image: huskies
environment:
- HUSKIES_PORT=3002
volumes:
- /path/to/robot-studio:/workspace
```
+126
View File
@@ -0,0 +1,126 @@
# Huskies architectural session — 2026-04-09 handoff
## tl;dr for the next agent
We spent today operating huskies under realistic stress and discovered that the **491/492 CRDT migration is incomplete**. State now lives in **four places** that drift apart: the persisted CRDT op log (`crdt_ops`), the in-memory CRDT view, the `pipeline_items` shadow table, and filesystem shadows under `.huskies/work/`. Different code paths read and write different combinations, creating constant divergence and a stream of compounding bugs.
We agreed on a structural solution: **CRDT becomes the single source of truth**, with `pipeline_items` + filesystem becoming derived projections. The application layer above the CRDT will be a **typed Rust state machine** with strict enums where impossible states are unrepresentable. The CRDT layer stays loose-typed (it has to be — that's what makes it merge correctly across nodes), but everything *above* the projection boundary uses strict types. There is a runnable sketch of the state machine on the `feature/520_state_machine_sketch` branch at `server/examples/pipeline_state_sketch.rs`.
## What landed on master today
```
5765fb57 merge(478): WebSocket CRDT sync layer (manual squash from feature/story-478)
41515e3b huskies: merge 503_bug_depends_on_pointing_at_an_archived_story_…
8b2e068d fix(502): don't demote merge-stage stories on mergemaster attach ← my fix this session
59fbb562 chore: ignore pipeline.db backup files in .huskies/.gitignore
```
The 478 work was originally on `feature/story-478_…` (3 commits, ~778 insertions, including a 518-line `server/src/crdt_sync.rs`). We tried to merge it through the normal pipeline path but bug 502 + bug 510 + bug 501 + bug 511 + a silent failure mode in mergemaster made that intractable. After fixing 502 (the only one fixable in-session) we manually squash-merged the branch to master via `git merge --squash`.
## Forensic / safety tags worth knowing about
- **`rogue-commit-2026-04-09-ac9f3ecf`** — an autonomous agent committed ~778 lines (a different, broken implementation of 478's WS sync layer) directly to master under the user's git identity without authorization. We reverted the commit but preserved this tag for incident postmortem. **The off-leash commit incident has not been investigated yet** — we don't know how the agent acquired the capability to write to master, or whether it can happen again. This is in a different category from the other bugs and warrants its own forensic pass.
- **`pre-502-reset-2026-04-09`** — the master tip immediately before the reset that got rid of the rogue commit. Useful for cross-referencing.
- **`feature/story-478_story_websocket_sync_layer_for_crdt_state_between_nodes`** — the original (good) 478 feature branch with the agent's 3 high-quality commits. Preserved.
- **`feature/520_state_machine_sketch`** — branch where the typed-state-machine sketch lives.
## The architectural agreement
1. **CRDT (`crdt_ops` table) is the source of truth** for syncable state. Replay deterministically reconstructs the in-memory CRDT.
2. **`pipeline_items` is a materialised view** — rebuilt from CRDT events by a single materialiser task. *No code writes directly to it.*
3. **Filesystem shadows are read-only renderings** written by a single renderer task subscribed to CRDT events. *No code reads from them for state purposes.*
4. **Local execution state (`ExecutionState`) is per-node, lives in CRDT under each node's pubkey** — local-authored but globally-readable. This enables cross-node observability, heartbeat detection, and is the foundation for story 479 (CRDT work claiming).
5. **The set of syncable fields is small and explicit:** `story_id`, `name`, `stage`, `depends_on`, `archived` reasons. Local-only fields (current agent, retry counts, timers) are NOT in the CRDT.
6. **The application layer is a typed Rust state machine.** Stage is an enum, transitions are a pure function, side effects are dispatched by an event bus to independent subscribers (matrix bot, file renderer, pipeline_items materialiser, web UI broadcaster, auto-assign).
## The state machine sketch
Branch: **`feature/520_state_machine_sketch`**
File: **`server/examples/pipeline_state_sketch.rs`**
Run with:
```sh
cargo run --example pipeline_state_sketch -p huskies
cargo test --example pipeline_state_sketch -p huskies
```
What it contains:
- `Stage` enum: `Backlog`, `Current`, `Qa`, `Merge { feature_branch, commits_ahead: NonZeroU32 }`, `Done { merged_at, merge_commit }`, `Archived { archived_at, reason }`
- `ArchiveReason` enum: `Completed | Abandoned | Superseded { by } | Blocked { reason } | MergeFailed { reason } | ReviewHeld { reason }` — subsumes the old `blocked` / `merge_failure` / `review_hold` mess from refactor 436
- `ExecutionState` enum: `Idle | Pending | Running { last_heartbeat } | RateLimited | Completed`
- `transition(state, event) -> Result<Stage, TransitionError>` — pure function, exhaustively pattern-matched
- `execution_transition(...)` — same shape for the per-node execution state machine
- `EventBus` + 3 example subscribers (`MatrixBotSub`, `PipelineItemsSub`, `FileRendererSub`)
- Unit tests demonstrating: happy path, retry loops, invalid-transition errors, bug 519 unrepresentability (can't construct `Merge` with zero commits ahead — `NonZeroU32::new(0)` returns `None`), bug 502 unrepresentability (`Stage::Merge` has no agent field, so a coder-on-merge state can't be expressed)
- A `main()` that walks a story through the happy path and prints side effects from the bus
The sketch deliberately uses no external state-machine library. The user originally suggested `statig` (<https://crates.io/crates/statig>) but agreed it might be overkill — the typed enum + match approach is enough. If hierarchical states become useful later (e.g. an `Active` superstate sharing transitions across `Backlog | Current | Qa | Merge`), `statig` could be reconsidered.
## Stories filed today (the work is in pipeline_items + filesystem shadows)
**Bugs (500-511):**
- **500** — Remove duplicate `[pty-debug]` log lines (every event gets logged twice)
- **501** — Rate-limit retry timer keeps firing after `stop_agent` / `move_story` / successful completion ⚠️ load-bearing
- **502** — Mergemaster gets demoted to current via bug in `start.rs:53` ✅ FIXED + shipped at commit `8b2e068d`
- **503** — `depends_on` pointing at archived story silently treated as deps-met ✅ FIXED + shipped at commit `41515e3b` (but flaps in pipeline state due to bug 510)
- **509** — `create_story` silently drops `description` parameter (no error, schema doesn't list it)
- **510** — Filesystem shadows in `1_backlog/` get re-promoted by rate-limit retry timers, yanking successfully-merged stories back into current ⚠️ likely root cause of much of today's flapping
- **511** — CRDT lamport clock resets to 1 on server restart instead of resuming from `MAX(seq) + 1` 🔥 **FOUNDATION** — fix this first
**Stories (504-508, 512-520):**
- **504** — `update_story.front_matter` MCP schema only takes string values
- **505-508** — The 478 split-up: SignedOp wire codec, WS sync endpoint, inbound apply + causal queue, rendezvous config (478's actual code already on master via the manual squash-merge, but these stories still document the underlying chunks)
- **512** — Migrate chat commands from filesystem lookup to CRDT/DB (`move 503 done` failed today because of this)
- **513** — Startup reconcile pass for state-drift detection (scaffolding; deletes itself when migration completes)
- **514** — `delete_story` should do a full cleanup (DB row + CRDT op + worktree + timers + filesystem)
- **515** — Add a debug MCP tool to dump the in-memory CRDT
- **516** — `update_story.description` should create the section if it doesn't exist
- **517** — Remove filesystem-shadow fallback paths from `lifecycle.rs`
- **518** — `apply_and_persist` should log `persist_tx.send()` failures instead of silently dropping ops
- **519** — Mergemaster should detect "no commits ahead of master" and fail loudly instead of exiting silently and burning $0.82 per session
- **520** — 🔑 **Typed pipeline state machine in Rust** — the foundational architectural story everything else converges to. Subsumes refactor 436.
**Refactor 436** (was: "Unify story stuck states into a single status field") — marked superseded by 520 via `front_matter: superseded_by: "520"`. Its functionality is now part of `Stage::Archived { reason: ArchiveReason }` in the sketch.
## Recommended next-session priority order
1. **Fix bug 511 first** (CRDT lamport seq reset). ~30 lines in `crdt_state.rs::init()`. After CRDT replay, seed the local seq counter from `MAX(seq)` over own author. Without this, CRDT replay produces broken state and 510 keeps biting.
2. **Verify the 511 fix unblocks 510.** Hypothesis: 510 (filesystem shadow split-brain) is largely a downstream symptom of 511 (replay puts ops in wrong order, in-memory state diverges, materialiser re-creates shadows from old state). If true, 510 may need only a small additional cleanup pass.
3. **Read the state machine sketch and refine it.** Specifically:
- Verify the local-vs-syncable field partition is right
- Confirm `Stage::Merge` and `Stage::Done` carry exactly the data we need
- Add any missing transitions
- Decide whether `ExecutionState` should be in the same CRDT or a separate one (we tentatively chose the same CRDT under per-node-pubkey keys, for cross-node observability and heartbeat)
4. **Land story 520** — promote the sketch to a real `server/src/pipeline_state.rs` module. Implement the projection layer (`TryFrom<&PipelineItemCrdt> for PipelineItem`).
5. **Migrate consumers one at a time** in priority order: chat commands (512) → lifecycle (517) → delete_story (514) → mergemaster precondition (519, mostly subsumed by `NonZeroU32`).
6. **Once nothing reads the loose `PipelineItemView` anymore, delete the loose API.** The CRDT looseness becomes purely an implementation detail.
7. **Then the off-leash commit forensic** — investigate `rogue-commit-2026-04-09-ac9f3ecf`. How did an agent acquire `git push` capability? What code path enabled it? File a security-critical bug.
## What's currently weird / broken in the running system
- **`timers.json` keeps getting re-populated** even after we empty it. The cause: stopping an agent triggers the agent's exit handler, which calls the rate-limit auto-resume scheduler, which writes to `timers.json`. Bug 501 should cover this but it might need to be explicit about the stop-agent code path.
- **Chat commands can't find stories that have no filesystem shadow.** Bug 512. Workaround: use MCP `move_story` / `delete_story` / etc. directly, NOT the web UI chat commands.
- **The web UI shows stale state** for some stories because the API reads from the in-memory CRDT view, which can diverge from `pipeline_items`. This will be fixed naturally by 520 + 517 (single source of truth).
- **`create_worktree` always creates from master** — intentional design choice ("keep conflicts low") but means it can't reuse an existing feature branch's work. Bit us with 478 today.
- **Mergemaster's `merge_agent_work` exits silently** when there are no commits ahead of master — we lost ~$0.82 to one such session today. Bug 519 + the typed `NonZeroU32` constraint in story 520 will make this unrepresentable.
## Useful diagnostic recipes from today
- **View persisted CRDT ops:** `sqlite3 .huskies/pipeline.db "SELECT seq, substr(op_json, 1, 200) FROM crdt_ops ORDER BY seq DESC LIMIT 20"`
- **View in-memory CRDT pipeline state:** call `mcp__huskies__get_pipeline_status` (it goes through `crdt_state::read_all_items()`)
- **Tail server log filtered for bug 502 firings:** `tail -f .huskies/logs/server.log | grep --line-buffered "Failed to start mergemaster"`
- **Tail server log without `[pty-debug]` noise:** `tail -f .huskies/logs/server.log | grep -v "\[pty-debug\]"`
- **Check current pending timers:** `cat .huskies/timers.json`
- **Forensically delete a story across all four state machines:** stop agents → remove worktree → empty timers → `DELETE FROM pipeline_items WHERE id LIKE '<id>%'``DELETE FROM crdt_ops WHERE op_json LIKE '%<id>%'`
## Token cost accounting
This session burned roughly **$15-25** in agent thrash, mostly from bug 501 + bug 510 respawning agents on already-completed stories. Once 511 + 510 + 501 are fixed, that bleed disappears.
## Open questions for the next session
1. **Should `ExecutionState` live in the same CRDT or a separate one?** We tentatively said same CRDT under per-node-pubkey keys. Need to validate this against the bft-json-crdt library's actual capabilities.
2. **Heartbeat cadence?** How often should `last_heartbeat` be updated for `ExecutionState::Running`? Every 30s seems reasonable but should be config.
3. **What's the migration path from existing pipeline_items rows to typed `PipelineItem`s?** A one-time migration script, or rebuild from `crdt_ops`?
4. **Should we add `statig` after all?** Probably not for the initial implementation, but worth revisiting if we end up wanting hierarchical states (e.g., a `Working` superstate sharing transitions across active stages).
+260
View File
@@ -0,0 +1,260 @@
[[agent]]
name = "coder-1"
stage = "coder"
role = "Full-stack engineer. Implements features across all components."
model = "sonnet"
max_turns = 50
max_budget_usd = 5.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks until tests complete and returns the results.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Always run the run_tests MCP tool before committing — do not commit until tests pass. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes."
[[agent]]
name = "coder-2"
stage = "coder"
role = "Full-stack engineer. Implements features across all components."
model = "sonnet"
max_turns = 50
max_budget_usd = 5.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks until tests complete and returns the results.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Always run the run_tests MCP tool before committing — do not commit until tests pass. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes."
[[agent]]
name = "coder-3"
stage = "coder"
role = "Full-stack engineer. Implements features across all components."
model = "sonnet"
max_turns = 50
max_budget_usd = 5.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks until tests complete and returns the results.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Always run the run_tests MCP tool before committing — do not commit until tests pass. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes."
[[agent]]
name = "qa-2"
stage = "qa"
role = "Reviews coder work in worktrees: runs quality gates, verifies acceptance criteria, and reports findings."
model = "sonnet"
max_turns = 40
max_budget_usd = 4.00
prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report.
Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map.
## Your Workflow
### 0. Read the Story
- Read the story file at `.huskies/work/3_qa/{{story_id}}.md`
- Extract every acceptance criterion (the `- [ ]` checkbox lines)
- Keep this list in mind for Step 3
### 1. Deterministic Gates (Prerequisites)
Run these first — if any fail, reject immediately without proceeding to AC review:
- Call the `run_tests` MCP tool — it blocks until complete. All gates must pass (0 lint errors/warnings, all tests green, frontend build clean if applicable).
### 2. Code Change Review
- Run `git diff master...HEAD --stat` to see what files changed
- Run `git diff master...HEAD` to review the actual changes
- Flag any incomplete implementations:
- `todo!()`, `unimplemented!()`, `panic!()` used as stubs
- Placeholder strings like "TODO", "FIXME", "not implemented"
- Empty match arms or arms that just return `Default::default()`
- Hardcoded values where real logic is expected
- Note any obvious coding mistakes (unused imports, dead code, unhandled errors)
### 3. Acceptance Criteria Review
For each AC extracted in Step 0:
- Review the diff and test files to determine if the code addresses this AC
- PASS: describe specifically how the code addresses it (which file/function/test)
- FAIL: explain exactly what is missing or incorrect
An AC fails if:
- No code change or test relates to it
- The implementation is stubbed out (todo!/unimplemented!)
- A test exists but doesn't actually assert the behaviour described
### 4. Manual Testing Support (only if all gates PASS and all ACs PASS)
- Build: run `run_build` MCP tool and note success/failure
- If build succeeds: find a free port (try 3010-3020), set `HUSKIES_PORT=<port>` and start the server with `script/server`
- Generate a testing plan including:
- URL to visit in the browser
- Things to check in the UI
- curl commands to exercise relevant API endpoints
- Stop the test server when done: send SIGTERM to the `script/server` process (e.g. `kill <pid>`)
### 5. Produce Structured Report and Verdict
Print your QA report to stdout. Then call `approve_qa` or `reject_qa` via the MCP tool based on the overall result. Use this format:
```
## QA Report for {{story_id}}
### Code Quality
- run_tests MCP tool: PASS/FAIL (details)
- Incomplete implementations: (list any todo!/unimplemented!/stubs found, or "None")
- Other code review findings: (list any issues found, or "None")
### Acceptance Criteria Review
- AC: <criterion text>
Result: PASS/FAIL
Evidence: <how the code addresses it, or what is missing>
(repeat for each AC)
### Manual Testing Plan
- Server URL: http://localhost:PORT (or "Skipped gate/AC failure" or "Build failed")
- Pages to visit: (list, or "N/A")
- Things to check: (list, or "N/A")
- curl commands: (list, or "N/A")
### Overall: PASS/FAIL
Reason: (summary of why it passed or the primary reason it failed)
```
After printing the report:
- If Overall is PASS: call `approve_qa(story_id='{{story_id}}')` via MCP
- If Overall is FAIL: call `reject_qa(story_id='{{story_id}}', notes='<concise reason>')` via MCP so the coder knows exactly what to fix
## Rules
- Do NOT modify any code — read-only review only
- Gates must pass before AC review — a gate failure is an automatic reject
- If any AC is not met, the overall result is FAIL
- Always call approve_qa or reject_qa — never leave the story without a verdict"""
system_prompt = "You are a QA agent. Your job is read-only: run quality gates, verify each acceptance criterion against the diff, and produce a structured QA report. Always call approve_qa or reject_qa via MCP to record your verdict. Do not modify code."
[[agent]]
name = "coder-opus"
stage = "coder"
role = "Senior full-stack engineer for complex tasks. Implements features across all components."
model = "opus"
max_turns = 80
max_budget_usd = 20.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks until tests complete and returns the results.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
system_prompt = "You are a senior full-stack engineer working autonomously in a git worktree. You handle complex tasks requiring deep architectural understanding. Always run the run_tests MCP tool before committing — do not commit until tests pass. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes."
[[agent]]
name = "qa"
stage = "qa"
role = "Reviews coder work in worktrees: runs quality gates, verifies acceptance criteria, and reports findings."
model = "sonnet"
max_turns = 40
max_budget_usd = 4.00
prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report.
Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map.
## Your Workflow
### 0. Read the Story
- Read the story file at `.huskies/work/3_qa/{{story_id}}.md`
- Extract every acceptance criterion (the `- [ ]` checkbox lines)
- Keep this list in mind for Step 3
### 1. Deterministic Gates (Prerequisites)
Run these first — if any fail, reject immediately without proceeding to AC review:
- Call the `run_tests` MCP tool — it blocks until complete. All gates must pass (0 lint errors/warnings, all tests green, frontend build clean if applicable).
### 2. Code Change Review
- Run `git diff master...HEAD --stat` to see what files changed
- Run `git diff master...HEAD` to review the actual changes
- Flag any incomplete implementations:
- `todo!()`, `unimplemented!()`, `panic!()` used as stubs
- Placeholder strings like "TODO", "FIXME", "not implemented"
- Empty match arms or arms that just return `Default::default()`
- Hardcoded values where real logic is expected
- Note any obvious coding mistakes (unused imports, dead code, unhandled errors)
### 3. Acceptance Criteria Review
For each AC extracted in Step 0:
- Review the diff and test files to determine if the code addresses this AC
- PASS: describe specifically how the code addresses it (which file/function/test)
- FAIL: explain exactly what is missing or incorrect
An AC fails if:
- No code change or test relates to it
- The implementation is stubbed out (todo!/unimplemented!)
- A test exists but doesn't actually assert the behaviour described
### 4. Manual Testing Support (only if all gates PASS and all ACs PASS)
- Build: run `run_build` MCP tool and note success/failure
- If build succeeds: find a free port (try 3010-3020), set `HUSKIES_PORT=<port>` and start the server with `script/server`
- Generate a testing plan including:
- URL to visit in the browser
- Things to check in the UI
- curl commands to exercise relevant API endpoints
- Stop the test server when done: send SIGTERM to the `script/server` process (e.g. `kill <pid>`)
### 5. Produce Structured Report and Verdict
Print your QA report to stdout. Then call `approve_qa` or `reject_qa` via the MCP tool based on the overall result. Use this format:
```
## QA Report for {{story_id}}
### Code Quality
- run_tests MCP tool: PASS/FAIL (details)
- Incomplete implementations: (list any todo!/unimplemented!/stubs found, or "None")
- Other code review findings: (list any issues found, or "None")
### Acceptance Criteria Review
- AC: <criterion text>
Result: PASS/FAIL
Evidence: <how the code addresses it, or what is missing>
(repeat for each AC)
### Manual Testing Plan
- Server URL: http://localhost:PORT (or "Skipped gate/AC failure" or "Build failed")
- Pages to visit: (list, or "N/A")
- Things to check: (list, or "N/A")
- curl commands: (list, or "N/A")
### Overall: PASS/FAIL
Reason: (summary of why it passed or the primary reason it failed)
```
After printing the report:
- If Overall is PASS: call `approve_qa(story_id='{{story_id}}')` via MCP
- If Overall is FAIL: call `reject_qa(story_id='{{story_id}}', notes='<concise reason>')` via MCP so the coder knows exactly what to fix
## Rules
- Do NOT modify any code — read-only review only
- Gates must pass before AC review — a gate failure is an automatic reject
- If any AC is not met, the overall result is FAIL
- Always call approve_qa or reject_qa — never leave the story without a verdict"""
system_prompt = "You are a QA agent. Your job is read-only: run quality gates, verify each acceptance criterion against the diff, and produce a structured QA report. Always call approve_qa or reject_qa via MCP to record your verdict. Do not modify code."
[[agent]]
name = "mergemaster"
stage = "mergemaster"
role = "Merges completed coder work into master, runs quality gates, archives stories, and cleans up worktrees."
model = "opus"
max_turns = 30
max_budget_usd = 5.00
prompt = """You are the mergemaster agent for story {{story_id}}. Your job is to merge the completed coder work into master.
Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map.
## Your Workflow
1. Call merge_agent_work(story_id='{{story_id}}'). It blocks until the merge completes and returns the full result.
2. If success and gates passed: you're done. Exit.
3. If gates failed: read the gate_output carefully, fix the issues in the merge workspace at `.huskies/merge_workspace/`, run run_tests MCP tool to verify, recommit, and call merge_agent_work again.
4. If merge failed for any other reason: call report_merge_failure(story_id='{{story_id}}', reason='<details>') and exit.
5. After 3 failed fix attempts, call report_merge_failure and exit.
## Fixing Gate Failures
The auto-resolver often produces broken code. Common problems:
- Duplicate imports or definitions (kept both sides)
- Formatting issues (import ordering, line breaks)
- Unclosed delimiters from bad conflict resolution
- Type mismatches from incompatible merge of both sides
To fix:
1. Read the broken files in `.huskies/merge_workspace/`
2. Fix the issues — prefer master's structure, integrate only the feature's new code
3. Run run_lint MCP tool to check formatting
4. Run run_tests MCP tool to verify everything passes
5. Commit the fix and call merge_agent_work again
## Rules
- NEVER manually move story files between pipeline stages
- NEVER call accept_story — merge_agent_work handles that
- ALWAYS call report_merge_failure if you can't fix the merge"""
system_prompt = "You are the mergemaster agent. Call merge_agent_work to merge. If gates fail, fix the issues in the merge workspace, verify with run_lint and run_tests MCP tools, recommit, and retrigger. After 3 failed attempts, call report_merge_failure and exit. Never move story files or call accept_story."
+28
View File
@@ -0,0 +1,28 @@
# Discord Transport
# Copy this file to bot.toml and fill in your values.
# Only one transport can be active at a time.
#
# Setup:
# 1. Create a Discord Application at discord.com/developers/applications
# 2. Go to Bot → create a bot and copy the token
# 3. Enable "Message Content Intent" under Privileged Gateway Intents
# 4. Go to OAuth2 → URL Generator, select "bot" scope with permissions:
# Send Messages, Read Message History, Manage Messages
# 5. Use the generated URL to invite the bot to your server
# 6. Right-click the channel(s) → Copy Channel ID (enable Developer Mode in settings)
enabled = true
transport = "discord"
discord_bot_token = "your-bot-token-here"
discord_channel_ids = ["123456789012345678"]
# Discord user IDs allowed to interact with the bot.
# When empty, all users in configured channels can interact.
# discord_allowed_users = ["111222333444555666"]
# Bot display name (used in formatted messages).
# display_name = "Assistant"
# Maximum conversation turns to remember per channel (default: 20).
# history_size = 20
+8 -312
View File
@@ -18,6 +18,14 @@ max_retries = 3
# (reads current HEAD branch). # (reads current HEAD branch).
base_branch = "master" base_branch = "master"
# Suppress soft rate-limit warning notifications in chat.
# Hard blocks and story-blocked notifications are always sent.
rate_limit_notifications = false
# IANA timezone for timer scheduling (e.g. "Europe/London", "America/New_York").
# Timer HH:MM inputs are interpreted in this timezone.
timezone = "Europe/London"
[[component]] [[component]]
name = "frontend" name = "frontend"
path = "frontend" path = "frontend"
@@ -29,315 +37,3 @@ name = "server"
path = "." path = "."
setup = ["mkdir -p frontend/dist", "cargo check"] setup = ["mkdir -p frontend/dist", "cargo check"]
teardown = [] teardown = []
[[agent]]
name = "coder-1"
stage = "coder"
role = "Full-stack engineer. Implements features across all components."
model = "sonnet"
max_turns = 50
max_budget_usd = 5.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
[[agent]]
name = "coder-2"
stage = "coder"
role = "Full-stack engineer. Implements features across all components."
model = "sonnet"
max_turns = 50
max_budget_usd = 5.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
[[agent]]
name = "coder-3"
stage = "coder"
role = "Full-stack engineer. Implements features across all components."
model = "sonnet"
max_turns = 50
max_budget_usd = 5.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
[[agent]]
name = "qa-2"
stage = "qa"
role = "Reviews coder work in worktrees: runs quality gates, verifies acceptance criteria, and reports findings."
model = "sonnet"
max_turns = 40
max_budget_usd = 4.00
prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report.
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
## Your Workflow
### 0. Read the Story
- Read the story file at `.huskies/work/3_qa/{{story_id}}.md`
- Extract every acceptance criterion (the `- [ ]` checkbox lines)
- Keep this list in mind for Step 3
### 1. Deterministic Gates (Prerequisites)
Run these first — if any fail, reject immediately without proceeding to AC review:
- Run `cargo clippy --all-targets --all-features` — must show 0 errors, 0 warnings
- Run `cargo test` and verify all tests pass
- If a `frontend/` directory exists:
- Run `npm run build` and note any TypeScript errors
- Run `npx @biomejs/biome check src/` and note any linting issues
- Run `npm test` and verify all frontend tests pass
### 2. Code Change Review
- Run `git diff master...HEAD --stat` to see what files changed
- Run `git diff master...HEAD` to review the actual changes
- Flag any incomplete implementations:
- `todo!()`, `unimplemented!()`, `panic!()` used as stubs
- Placeholder strings like "TODO", "FIXME", "not implemented"
- Empty match arms or arms that just return `Default::default()`
- Hardcoded values where real logic is expected
- Note any obvious coding mistakes (unused imports, dead code, unhandled errors)
### 3. Acceptance Criteria Review
For each AC extracted in Step 0:
- Review the diff and test files to determine if the code addresses this AC
- PASS: describe specifically how the code addresses it (which file/function/test)
- FAIL: explain exactly what is missing or incorrect
An AC fails if:
- No code change or test relates to it
- The implementation is stubbed out (todo!/unimplemented!)
- A test exists but doesn't actually assert the behaviour described
### 4. Manual Testing Support (only if all gates PASS and all ACs PASS)
- Build the server: run `cargo build` and note success/failure
- If build succeeds: find a free port (try 3010-3020) and attempt to start the server
- Generate a testing plan including:
- URL to visit in the browser
- Things to check in the UI
- curl commands to exercise relevant API endpoints
- Kill the test server when done: `pkill -f 'target.*huskies' || true` (NEVER use `pkill -f huskies` — it kills the vite dev server)
### 5. Produce Structured Report and Verdict
Print your QA report to stdout. Then call `approve_qa` or `reject_qa` via the MCP tool based on the overall result. Use this format:
```
## QA Report for {{story_id}}
### Code Quality
- clippy: PASS/FAIL (details)
- TypeScript build: PASS/FAIL/SKIP (details)
- Biome lint: PASS/FAIL/SKIP (details)
- cargo test: PASS/FAIL (N tests)
- npm test: PASS/FAIL/SKIP (N tests)
- Incomplete implementations: (list any todo!/unimplemented!/stubs found, or "None")
- Other code review findings: (list any issues found, or "None")
### Acceptance Criteria Review
- AC: <criterion text>
Result: PASS/FAIL
Evidence: <how the code addresses it, or what is missing>
(repeat for each AC)
### Manual Testing Plan
- Server URL: http://localhost:PORT (or "Skipped gate/AC failure" or "Build failed")
- Pages to visit: (list, or "N/A")
- Things to check: (list, or "N/A")
- curl commands: (list, or "N/A")
### Overall: PASS/FAIL
Reason: (summary of why it passed or the primary reason it failed)
```
After printing the report:
- If Overall is PASS: call `approve_qa(story_id='{{story_id}}')` via MCP
- If Overall is FAIL: call `reject_qa(story_id='{{story_id}}', notes='<concise reason>')` via MCP so the coder knows exactly what to fix
## Rules
- Do NOT modify any code — read-only review only
- Gates must pass before AC review — a gate failure is an automatic reject
- If any AC is not met, the overall result is FAIL
- Always call approve_qa or reject_qa — never leave the story without a verdict"""
system_prompt = "You are a QA agent. Your job is read-only: run quality gates, verify each acceptance criterion against the diff, and produce a structured QA report. Always call approve_qa or reject_qa via MCP to record your verdict. Do not modify code."
[[agent]]
name = "coder-opus"
stage = "coder"
role = "Senior full-stack engineer for complex tasks. Implements features across all components."
model = "opus"
max_turns = 80
max_budget_usd = 20.00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
system_prompt = "You are a senior full-stack engineer working autonomously in a git worktree. You handle complex tasks requiring deep architectural understanding. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
[[agent]]
name = "qa"
stage = "qa"
role = "Reviews coder work in worktrees: runs quality gates, verifies acceptance criteria, and reports findings."
model = "sonnet"
max_turns = 40
max_budget_usd = 4.00
prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report.
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
## Your Workflow
### 0. Read the Story
- Read the story file at `.huskies/work/3_qa/{{story_id}}.md`
- Extract every acceptance criterion (the `- [ ]` checkbox lines)
- Keep this list in mind for Step 3
### 1. Deterministic Gates (Prerequisites)
Run these first — if any fail, reject immediately without proceeding to AC review:
- Run `cargo clippy --all-targets --all-features` — must show 0 errors, 0 warnings
- Run `cargo test` and verify all tests pass
- If a `frontend/` directory exists:
- Run `npm run build` and note any TypeScript errors
- Run `npx @biomejs/biome check src/` and note any linting issues
- Run `npm test` and verify all frontend tests pass
### 2. Code Change Review
- Run `git diff master...HEAD --stat` to see what files changed
- Run `git diff master...HEAD` to review the actual changes
- Flag any incomplete implementations:
- `todo!()`, `unimplemented!()`, `panic!()` used as stubs
- Placeholder strings like "TODO", "FIXME", "not implemented"
- Empty match arms or arms that just return `Default::default()`
- Hardcoded values where real logic is expected
- Note any obvious coding mistakes (unused imports, dead code, unhandled errors)
### 3. Acceptance Criteria Review
For each AC extracted in Step 0:
- Review the diff and test files to determine if the code addresses this AC
- PASS: describe specifically how the code addresses it (which file/function/test)
- FAIL: explain exactly what is missing or incorrect
An AC fails if:
- No code change or test relates to it
- The implementation is stubbed out (todo!/unimplemented!)
- A test exists but doesn't actually assert the behaviour described
### 4. Manual Testing Support (only if all gates PASS and all ACs PASS)
- Build the server: run `cargo build` and note success/failure
- If build succeeds: find a free port (try 3010-3020) and attempt to start the server
- Generate a testing plan including:
- URL to visit in the browser
- Things to check in the UI
- curl commands to exercise relevant API endpoints
- Kill the test server when done: `pkill -f 'target.*huskies' || true` (NEVER use `pkill -f huskies` — it kills the vite dev server)
### 5. Produce Structured Report and Verdict
Print your QA report to stdout. Then call `approve_qa` or `reject_qa` via the MCP tool based on the overall result. Use this format:
```
## QA Report for {{story_id}}
### Code Quality
- clippy: PASS/FAIL (details)
- TypeScript build: PASS/FAIL/SKIP (details)
- Biome lint: PASS/FAIL/SKIP (details)
- cargo test: PASS/FAIL (N tests)
- npm test: PASS/FAIL/SKIP (N tests)
- Incomplete implementations: (list any todo!/unimplemented!/stubs found, or "None")
- Other code review findings: (list any issues found, or "None")
### Acceptance Criteria Review
- AC: <criterion text>
Result: PASS/FAIL
Evidence: <how the code addresses it, or what is missing>
(repeat for each AC)
### Manual Testing Plan
- Server URL: http://localhost:PORT (or "Skipped gate/AC failure" or "Build failed")
- Pages to visit: (list, or "N/A")
- Things to check: (list, or "N/A")
- curl commands: (list, or "N/A")
### Overall: PASS/FAIL
Reason: (summary of why it passed or the primary reason it failed)
```
After printing the report:
- If Overall is PASS: call `approve_qa(story_id='{{story_id}}')` via MCP
- If Overall is FAIL: call `reject_qa(story_id='{{story_id}}', notes='<concise reason>')` via MCP so the coder knows exactly what to fix
## Rules
- Do NOT modify any code — read-only review only
- Gates must pass before AC review — a gate failure is an automatic reject
- If any AC is not met, the overall result is FAIL
- Always call approve_qa or reject_qa — never leave the story without a verdict"""
system_prompt = "You are a QA agent. Your job is read-only: run quality gates, verify each acceptance criterion against the diff, and produce a structured QA report. Always call approve_qa or reject_qa via MCP to record your verdict. Do not modify code."
[[agent]]
name = "mergemaster"
stage = "mergemaster"
role = "Merges completed coder work into master, runs quality gates, archives stories, and cleans up worktrees."
model = "opus"
max_turns = 30
max_budget_usd = 5.00
prompt = """You are the mergemaster agent for story {{story_id}}. Your job is to merge the completed coder work into master.
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
## Your Workflow
1. Call merge_agent_work(story_id='{{story_id}}') via the MCP tool to trigger the full merge pipeline
2. Review the result: check success, had_conflicts, conflicts_resolved, gates_passed, and gate_output
3. If merge succeeded and gates passed: report success to the human
4. If conflicts were auto-resolved (conflicts_resolved=true) and gates passed: report success, noting which conflicts were resolved
5. If conflicts could not be auto-resolved: **resolve them yourself** in the merge worktree (see below)
6. If merge failed for any other reason: call report_merge_failure(story_id='{{story_id}}', reason='<details>') and report to the human
7. If gates failed after merge: attempt to fix the issues yourself in the merge worktree, then re-trigger merge_agent_work. After 3 fix attempts, call report_merge_failure and stop.
## Resolving Complex Conflicts Yourself
When the auto-resolver fails, you have access to the merge worktree at `.story_kit/merge_workspace/`. Go in there and resolve the conflicts manually:
1. Run `git diff --name-only --diff-filter=U` in the merge worktree to list conflicted files
2. **Build context before touching code.** Run `git log --oneline master...HEAD` on the feature branch to see its commits. Then run `git log --oneline --since="$(git log -1 --format=%ci <feature-branch-base-commit>)" master` to see what landed on master since the branch was created. Read the story files in `.story_kit/work/` for any recently merged stories that touch the same files — this tells you WHY master changed and what must be preserved.
3. Read each conflicted file and understand both sides of the conflict
4. **Understand intent, not just syntax.** The feature branch may be behind master — master's version of shared infrastructure is almost always correct. The feature branch's contribution is the NEW functionality it adds. Your job is to integrate the new into master's structure, not pick one side.
5. Resolve by integrating the feature's new functionality into master's code structure
5. Stage resolved files with `git add`
6. Run `cargo check` (and `npm run build` if frontend changed) to verify compilation
7. If it compiles, commit and re-trigger merge_agent_work
### Common conflict patterns in this project:
**Story file rename/rename conflicts:** Both branches moved the story .md file to different pipeline directories. Resolution: `git rm` both sides — story files in `work/2_current/`, `work/3_qa/`, `work/4_merge/` are gitignored and don't need to be committed.
**bot.rs tokio::select! conflicts:** Master has a `tokio::select!` loop in `handle_message()` that handles permission forwarding (story 275). Feature branches created before story 275 have a simpler direct `provider.chat_stream().await` call. Resolution: KEEP master's tokio::select! loop. Integrate only the feature's new logic (e.g. typing indicators, new callbacks) into the existing loop structure. Do NOT replace the loop with the old direct call.
**Duplicate functions/imports:** The auto-resolver keeps both sides, producing duplicates. Resolution: keep one copy (prefer master's version), delete the duplicate.
**Formatting-only conflicts:** Both sides reformatted the same code differently. Resolution: pick either side (prefer master).
## Fixing Gate Failures
If quality gates fail (cargo clippy, cargo test, npm run build, npm test), attempt to fix issues yourself in the merge worktree.
**Fix yourself (up to 3 attempts total):**
- Syntax errors (missing semicolons, brackets, commas)
- Duplicate definitions from merge artifacts
- Simple type annotation errors
- Unused import warnings flagged by clippy
- Mismatched braces from bad conflict resolution
- Trivial formatting issues that block compilation or linting
**Report to human without attempting a fix:**
- Logic errors or incorrect business logic
- Missing function implementations
- Architectural changes required
- Non-trivial refactoring needed
**Max retry limit:** If gates still fail after 3 fix attempts, call report_merge_failure to record the failure, then stop immediately and report the full gate output to the human.
## CRITICAL Rules
- NEVER manually move story files between pipeline stages (e.g. from 4_merge/ to 5_done/)
- NEVER call accept_story — only merge_agent_work can move stories to done after a successful merge
- When merge fails after exhausting your fix attempts, ALWAYS call report_merge_failure
- Report conflict resolution outcomes clearly
- Report gate failures with full output so the human can act if needed
- The server automatically runs acceptance gates when your process exits"""
system_prompt = "You are the mergemaster agent. Your primary job is to merge feature branches to master. First try the merge_agent_work MCP tool. If the auto-resolver fails on complex conflicts, resolve them yourself in the merge worktree — you are an opus-class agent capable of understanding both sides of a conflict and producing correct merged code. Common patterns: keep master's tokio::select! permission loop in bot.rs, discard story file rename conflicts (gitignored), remove duplicate definitions. After resolving, verify compilation before re-triggering merge. CRITICAL: Never manually move story files or call accept_story. After 3 failed fix attempts, call report_merge_failure and stop."
+112 -112
View File
@@ -1,130 +1,130 @@
# Tech Stack & Constraints # Tech Stack
## Overview ## Backend
This project is a standalone Rust **web server binary** that serves a Vite/React frontend and exposes a **WebSocket API**. The built frontend assets are packaged with the binary (in a `frontend` directory) and served as static files. It functions as an **Agentic Code Assistant** capable of safely executing tools on the host system. - **Language:** Rust
- **Framework:** Poem (HTTP + WebSocket + OpenAPI)
- **Database:** SQLite via sqlx + rusqlite
- **State:** BFT CRDT replicated document backed by SQLite
- **Agents:** Claude Code CLI spawned in PTY pseudo-terminals
- **Package manager:** cargo
## Core Stack ## Frontend
* **Backend:** Rust (Web Server) - **Language:** TypeScript + React
* **MSRV:** Stable (latest) - **Build:** Vite
* **Framework:** Poem HTTP server with WebSocket support for streaming; HTTP APIs should use Poem OpenAPI (Swagger) for non-streaming endpoints. - **Package manager:** npm
* **Frontend:** TypeScript + React - **Testing:** Vitest (unit), Playwright (e2e)
* **Build Tool:** Vite
* **Package Manager:** npm
* **Styling:** CSS Modules or Tailwind (TBD - Defaulting to CSS Modules)
* **State Management:** React Context / Hooks
* **Chat UI:** Rendered Markdown with syntax highlighting.
## Agent Architecture ## Deployment
The application follows a **Tool-Use (Function Calling)** architecture: - Single Rust binary with embedded React frontend (rust-embed)
1. **Frontend:** Collects user input and sends it to the LLM. - Three modes: standard server, headless build agent (`--rendezvous`), multi-project gateway (`--gateway`)
2. **LLM:** Decides to generate text OR request a **Tool Call** (e.g., `execute_shell`, `read_file`). - Docker container with OrbStack recommended on macOS
3. **Web Server Backend (The "Hand"):**
* Intercepts Tool Calls.
* Validates the request against the **Safety Policy**.
* Executes the native code (File I/O, Shell Process, Search).
* Returns the output (stdout/stderr/file content) to the LLM.
* **Streaming:** The backend sends real-time updates over WebSocket to keep the UI responsive during long-running Agent tasks.
## LLM Provider Abstraction ## Project Layout
To support both Remote and Local models, the system implements a `ModelProvider` abstraction layer. ```
server/src/ — Rust backend
frontend/src/ — React frontend
crates/bft-json-crdt/ — CRDT library
.huskies/ — Pipeline config, agent config, specs
script/ — test, build, lint scripts
docker/ — Dockerfile and docker-compose
website/ — Static marketing/docs site
```
* **Strategy:** ## Source Map
* Abstract the differences between API formats (OpenAI-compatible vs Anthropic vs Gemini).
* Normalize "Tool Use" definitions, as each provider handles function calling schemas differently.
* **Supported Providers:**
* **Ollama:** Local inference (e.g., Llama 3, DeepSeek Coder) for privacy and offline usage.
* **Anthropic:** Claude 3.5 models (Sonnet, Haiku) via API for coding tasks (Story 12).
* **Provider Selection:**
* Automatic detection based on model name prefix:
* `claude-` → Anthropic API
* Otherwise → Ollama
* Single unified model dropdown with section headers ("Anthropic", "Ollama")
* **API Key Management:**
* Anthropic API key stored server-side and persisted securely
* On first use of Claude model, user prompted to enter API key
* Key persists across sessions (no re-entry needed)
## Tooling Capabilities ### Core
### 1. Filesystem (Native) | File | Description |
* **Scope:** Strictly limited to the user-selected `project_root`. |------|-------------|
* **Operations:** Read, Write, List, Delete. | `server/src/main.rs` | Entry point, CLI argument parsing, and server startup |
* **Constraint:** Modifications to `.git/` are strictly forbidden via file APIs (use Git tools instead). | `server/src/config.rs` | Parses `project.toml` for agents, components, and server settings |
| `server/src/state.rs` | Global mutable session state (project root, cancellation) |
| `server/src/store.rs` | JSON-backed persistent key-value store for settings |
| `server/src/gateway.rs` | Multi-project gateway mode (MCP proxy, project switching, agent registration) |
### 2. Shell Execution ### Agents
* **Library:** `tokio::process` for async execution.
* **Constraint:** We do **not** run an interactive shell (repl). We run discrete, stateless commands.
* **Allowlist:** The agent may only execute specific binaries:
* `git`
* `cargo`, `rustc`, `rustfmt`, `clippy`
* `npm`, `node`, `yarn`, `pnpm`, `bun`
* `ls`, `find`, `grep` (if not using internal search)
* `mkdir`, `rm`, `touch`, `mv`, `cp`
### 3. Search & Navigation | File | Description |
* **Library:** `ignore` (by BurntSushi) + `grep` logic. |------|-------------|
* **Behavior:** | `server/src/agents/mod.rs` | Types, configuration, and orchestration for coding agents |
* Must respect `.gitignore` files automatically. | `server/src/agents/gates.rs` | Runs test suites and validation scripts in agent worktrees |
* Must be performant (parallel traversal). | `server/src/agents/lifecycle.rs` | File creation, archival, and stage transitions for pipeline items |
| `server/src/agents/merge.rs` | Rebases agent work onto master and runs post-merge validation |
| `server/src/agents/pty.rs` | Spawns agent processes in pseudo-terminals and streams output |
| `server/src/agents/token_usage.rs` | Persists per-agent token consumption records to disk |
| `server/src/agent_log.rs` | Reads and writes JSONL agent event logs to disk |
| `server/src/agent_mode.rs` | Headless build-agent mode for distributed story processing |
## Coding Standards ### Agent Pool
### Rust | File | Description |
* **Style:** `rustfmt` standard. |------|-------------|
* **Linter:** `clippy` - Must pass with 0 warnings before merging. | `server/src/agents/pool/mod.rs` | Manages the set of active agents across all pipeline stages |
* **Error Handling:** Custom `AppError` type deriving `thiserror`. All Commands return `Result<T, AppError>`. | `server/src/agents/pool/start.rs` | Spawns a new agent process in a worktree for a story |
* **Concurrency:** Heavy tools (Search, Shell) must run on `tokio` threads to avoid blocking the UI. | `server/src/agents/pool/stop.rs` | Terminates a running agent while preserving its worktree |
* **Quality Gates:** | `server/src/agents/pool/pipeline/advance.rs` | Moves stories forward through pipeline stages |
* `cargo clippy --all-targets --all-features` must show 0 errors, 0 warnings | `server/src/agents/pool/pipeline/completion.rs` | Processes exit results and triggers pipeline advancement |
* `cargo check` must succeed | `server/src/agents/pool/pipeline/merge.rs` | Orchestrates the merge-to-master flow for completed stories |
* `cargo nextest run` must pass all tests | `server/src/agents/pool/auto_assign/auto_assign.rs` | Scans pipeline stages and dispatches agents to unassigned stories |
* **Test Coverage:**
* Generate JSON report: `cargo llvm-cov nextest --no-clean --json --output-path .story_kit/coverage/server.json`
* Generate lcov report: `cargo llvm-cov report --lcov --output-path .story_kit/coverage/server.lcov`
* Reports are written to `.story_kit/coverage/` (excluded from git)
### TypeScript / React ### CRDT & Database
* **Style:** Biome formatter (replaces Prettier/ESLint).
* **Linter:** Biome - Must pass with 0 errors, 0 warnings before merging.
* **Types:** Shared types with Rust (via `tauri-specta` or manual interface matching) are preferred to ensure type safety across the bridge.
* **Testing:** Vitest for unit/component tests; Playwright for end-to-end tests.
* **Quality Gates:**
* `npx @biomejs/biome check src/` must show 0 errors, 0 warnings
* `npm run build` must succeed
* `npm test` must pass
* `npm run test:e2e` must pass
* No `any` types allowed (use proper types or `unknown`)
* React keys must use stable IDs, not array indices
* All buttons must have explicit `type` attribute
## Libraries (Approved) | File | Description |
* **Rust:** |------|-------------|
* `serde`, `serde_json`: Serialization. | `server/src/crdt_state.rs` | Pipeline state as a conflict-free replicated document backed by SQLite |
* `ignore`: Fast recursive directory iteration respecting gitignore. | `server/src/crdt_sync.rs` | WebSocket-based replication of pipeline state between nodes |
* `walkdir`: Simple directory traversal. | `server/src/pipeline_state.rs` | Typed pipeline state machine |
* `tokio`: Async runtime. | `server/src/db/mod.rs` | Content store, shadow writes, and CRDT op persistence |
* `reqwest`: For LLM API calls (Anthropic, Ollama).
* `eventsource-stream`: For Server-Sent Events (Anthropic streaming).
* `uuid`: For unique message IDs.
* `chrono`: For timestamps.
* `poem`: HTTP server framework.
* `poem-openapi`: OpenAPI (Swagger) for non-streaming HTTP APIs.
* **JavaScript:**
* `react-markdown`: For rendering chat responses.
* `vitest`: Unit/component testing.
* `playwright`: End-to-end testing.
## Running the App (Worktrees & Ports) ### HTTP — MCP Tools (the tools agents call)
Multiple instances can run simultaneously in different worktrees. To avoid port conflicts: | File | Description |
|------|-------------|
| `server/src/http/mcp/mod.rs` | MCP endpoint dispatching tool calls |
| `server/src/http/mcp/agent_tools.rs` | Start, stop, wait, list, and inspect agents |
| `server/src/http/mcp/git_tools.rs` | Status, diff, add, commit, and log on agent worktrees |
| `server/src/http/mcp/merge_tools.rs` | Merge agent work to master and report failures |
| `server/src/http/mcp/shell_tools.rs` | Run commands, execute tests, and stream output |
| `server/src/http/mcp/story_tools.rs` | Create, update, move, and manage stories/bugs/refactors |
| `server/src/http/mcp/diagnostics.rs` | Server logs, CRDT dump, version, and story movement helpers |
- **Backend:** Set `HUSKIES_PORT` to a unique port (default is 3001). Example: `HUSKIES_PORT=3002 cargo run` ### Chat — Bot Commands
- **Frontend:** Run `npm run dev` from `frontend/`. It auto-selects the next unused port. It reads `HUSKIES_PORT` to know which backend to talk to, so export it before running: `export HUSKIES_PORT=3002 && cd frontend && npm run dev`
When running in a worktree, use a port that won't conflict with the main instance (3001). Ports 3002+ are good choices. | File | Description |
|------|-------------|
| `server/src/chat/commands/mod.rs` | Bot-level command registry shared by all transports |
| `server/src/chat/commands/status.rs` | `status` command and pipeline status helpers |
| `server/src/chat/commands/backlog.rs` | `backlog` command — shows only backlog-stage items |
| `server/src/chat/commands/run_tests.rs` | `run_tests` command — run the project's test suite |
## Safety & Sandbox ### Chat — Transports
1. **Project Scope:** The application must strictly enforce that it does not read/write outside the `project_root` selected by the user.
2. **Human in the Loop:** | File | Description |
* Shell commands that modify state (non-readonly) should ideally require a UI confirmation (configurable). |------|-------------|
* File writes must be confirmed or revertible. | `server/src/chat/transport/matrix/` | Matrix bot integration |
| `server/src/chat/transport/slack/` | Slack bot integration |
| `server/src/chat/transport/whatsapp/` | WhatsApp Business API integration |
| `server/src/chat/transport/discord/` | Discord bot integration |
### Frontend
| Directory | Description |
|-----------|-------------|
| `frontend/src/components/` | React UI components |
| `frontend/src/api/` | API client code (gateway, agents, etc.) |
### Utilities
| File | Description |
|------|-------------|
| `server/src/rebuild.rs` | Server rebuild and restart logic |
| `server/src/worktree.rs` | Creates, lists, and removes git worktrees for agent isolation |
| `server/src/io/watcher.rs` | Filesystem watcher for `.huskies/work/` and `project.toml` |
## Quality Gates
All enforced by `script/test`:
1. Frontend build (`npm run build`)
2. Rust formatting (`cargo fmt --all --check`)
3. Rust linting (`cargo clippy -- -D warnings`)
4. Rust tests (`cargo test`)
5. Frontend tests (`npm test`)
@@ -1,5 +1,7 @@
--- ---
name: "Unify story stuck states into a single status field" name: "Unify story stuck states into a single status field"
status: "superseded"
superseded_by: 520
--- ---
# Refactor 436: Unify story stuck states into a single status field # Refactor 436: Unify story stuck states into a single status field
@@ -0,0 +1,24 @@
---
name: "Build agent mode with CRDT-based work claiming"
agent: coder-opus
depends_on: [478]
---
# Story 479: Build agent mode with CRDT-based work claiming
## User Story
As a user with multiple laptops, I want to run huskies in build agent mode so it connects to the mesh, syncs state, and autonomously picks up and runs coding work.
## Acceptance Criteria
- [ ] New CLI mode: huskies agent --rendezvous ws://host:3001
- [ ] Agent mode: syncs CRDT state, runs coders, no web UI or chat interface
- [ ] Work claiming via CRDT: node writes claim (node ID) to CRDT doc, merge resolves conflicts deterministically, losing node stops work
- [ ] Agent picks up stories in current stage and runs Claude Code locally
- [ ] Agent pushes feature branch to Gitea when done, reports completion via CRDT
- [ ] Handles offline/reconnect: CRDT merges on reconnect, interrupted work is reclaimed after timeout
## Out of Scope
- TBD
@@ -0,0 +1,23 @@
---
name: "Cryptographic node auth for distributed mesh"
agent: coder-opus
depends_on: [479]
---
# Story 480: Cryptographic node auth for distributed mesh
## User Story
As a user running a distributed huskies mesh, I want nodes authenticated by Ed25519 keypairs so only trusted machines can join and see pipeline state.
## Acceptance Criteria
- [ ] Each node has an Ed25519 keypair (generated on first run or via CLI command)
- [ ] Trusted nodes defined by a list of known public keys in config
- [ ] Nodes authenticate on WebSocket connect by signing a challenge
- [ ] CRDT node ID derived from public key (already built into bft-json-crdt crate)
- [ ] Unauthorised nodes rejected on connect
## Out of Scope
- TBD
@@ -0,0 +1,25 @@
---
name: "create_worktree deletes all files from main branch git index"
---
# Bug 486: create_worktree deletes all files from main branch git index
## Description
On the reclaimer project, the create_worktree operation for story 34 produced a commit (cea0c48) with message "huskies: create 34_story_drawer_open_pushes_main_view_aside_with_animation" that removed 76 files (9853 deletions) from the main branch git index. All files remained on disk — nothing was lost — but every tracked file became untracked. Static analysis of the watcher code (watcher.rs:152-185) shows git add -A .huskies/work/ with correct current_dir, which should only affect files under .huskies/work/. No other code path in the server runs git add without a pathspec on the main branch. Root cause is unknown — may be related to the storkit→huskies migration leaving the git index in an inconsistent state, or a race condition during first-time scaffold on a project that previously used .storkit/.
## How to Reproduce
1. Uncertain — may require fresh huskies setup on a project that previously used storkit. 2. Create a story via the bot or MCP tool. 3. Observe that the auto-commit for story creation removes all tracked files from the git index.
## Actual Result
The commit for story creation deleted 76 files (9853 deletions) from the git index while leaving them on disk.
## Expected Result
The commit for story creation should only add the new story markdown file under .huskies/work/1_backlog/. No other files should be affected.
## Acceptance Criteria
- [ ] Bug is fixed and verified
@@ -0,0 +1,32 @@
---
name: "Stale merge job lock prevents new merges after agent dies"
---
# Bug 498: Stale merge job lock prevents new merges after agent dies
## Description
When the mergemaster agent is killed or stops while a merge is in progress, the in-memory `merge_jobs` map retains a `Running` status entry for that story. Subsequent attempts to call `merge_agent_work` get "Merge already in progress" and fail. The lock is never cleaned up.
This causes the mergemaster to loop: spawn, try merge, get "already in progress", waste turns, exit, respawn. The merge never completes.
The fix: clear the merge job entry when the mergemaster agent exits (whether cleanly or via kill/stop).
## How to Reproduce
1. Start mergemaster on a story in merge
2. Kill/stop the mergemaster agent before merge completes
3. Try to merge_agent_work again for the same story
4. Get "Merge already in progress" error
## Actual Result
Stale Running entry in merge_jobs map blocks all future merge attempts until server restart.
## Expected Result
Merge job lock is cleaned up when the agent exits, allowing retry.
## Acceptance Criteria
- [ ] Bug is fixed and verified
@@ -0,0 +1,72 @@
---
name: "CRDT lamport clock (inner.seq) resets to 1 on server restart instead of resuming from max(own_author_seq) + 1"
---
# Bug 511: CRDT lamport clock (inner.seq) resets to 1 on server restart instead of resuming from max(own_author_seq) + 1
## Description
When the huskies server restarts (e.g. via `rebuild_and_restart`), the local node's CRDT lamport clock — `inner.seq` on each `SignedOp` — appears to reset to 1 instead of resuming from `MAX(seq) + 1` for the local author's own previously-persisted ops.
**Discovered live on 2026-04-09** while inspecting the `crdt_ops` table after a `rebuild_and_restart`. Pre-restart ops were at seqs 485-492 (creation ops for stories 503-510). Post-restart ops were being persisted at seqs 1, 2, 3, 4, 5, 6, 7 — visible by sorting `crdt_ops` by `created_at DESC`:
```
created_at | seq
2026-04-09T18:49:56 → seq 7 ← post-restart
2026-04-09T18:38:22 → seq 6 ← post-restart
2026-04-09T18:37:45 → seq 492 ← pre-restart, last write before restart
2026-04-09T18:31:32 → seq 4 ← post-restart
2026-04-09T18:27:04 → seq 3 ← post-restart
```
So the local node, which had reached seq=492 before the restart, started writing new ops at seq=1 after the restart and is now climbing from there. This means **new ops have lower seqs than existing ops from the same author**.
## Why this matters
In a BFT JSON CRDT, `inner.seq` is the local lamport clock used for causality tracking. The library assumes per-author seqs are monotonically increasing — newer ops from the same author have higher seqs than older ops. Several things break when this invariant is violated:
1. **Causality / ordering on remote replay.** When a peer (or this same node after another restart) replays the persisted ops in `seq` order, the post-restart ops will be applied *before* the pre-restart ops, even though they happened later. This can produce a non-deterministic state and can cause field updates to "go backwards" — e.g. a story that was moved current → done pre-restart, then nothing post-restart, would correctly end at "done"; but if you also did a post-restart action, the seq ordering would re-play it in the wrong order.
2. **Op ID collisions.** The op id is a hash of the op contents (including author, seq, content). If a post-restart op happens to be structurally identical to a pre-restart op (e.g. "set stage to 1_backlog" with the same author and same seq=3), the op ids could collide. The persistence path uses `INSERT INTO crdt_ops ... ON CONFLICT(op_id) DO NOTHING`, which would *silently drop* the new op. (We have not yet observed this happen, but it's a latent risk.)
3. **Sync between nodes will desync.** Once the WebSocket sync layer (story 478, just merged) is exchanging ops between nodes, a restart on one node will produce ops with seqs that look "old" to the other node, and the receiving node may de-dupe or mis-order them. This will manifest as silent state divergence in multi-node deployments, which is exactly what the sync layer is supposed to prevent.
4. **Today's pipeline state confusion.** The 8 stories I created in this session (503-510) are at seqs 485-492 in the persisted CRDT. Their post-restart lifecycle moves are at seqs 1-15. If we replay the CRDT from disk in seq order, the lifecycle moves will be applied to *empty* state before the creation ops have run, and will silently no-op (because they reference content indexes that don't exist yet). On the next restart after this state, the in-memory view will show the stories in their *creation* state, not their post-restart-lifecycle state — i.e. all 8 stories will appear "stuck at 1_backlog" again. **This may well be the cause of bug 510's split-brain symptom**.
## Where the bug lives
`server/src/crdt_state.rs::init()` (around lines 80-115) replays persisted ops to reconstruct state, then constructs a fresh `CrdtState { crdt, keypair, index, persist_tx }`. The `BaseCrdt::new(&keypair)` call constructs a fresh CRDT with a fresh internal seq counter. The replay re-applies ops via `crdt.apply(signed_op)` which presumably updates the doc but does NOT advance the local seq counter (because `apply()` is for *remote* ops).
After replay, the local seq counter is at 0 (or wherever the BFT CRDT library defaults). The next call to `apply_and_persist` produces an op with `inner.seq = 1` (or whatever the next-counter value is) — even though there are already ops at seq 485+ from this author in the persisted state.
The fix is to inspect `MAX(inner.seq)` for ops where `author == local_keypair.public()` after the replay, and seed the BFT CRDT's local seq counter from `max + 1`. The exact API for "seed the seq counter" depends on the bft-json-crdt library — may need a small upstream change if not already exposed.
## How to Reproduce
1. Start a fresh huskies server with an empty database. Verify `crdt_ops` is empty.
2. Create several stories via `create_story` or similar — observe ops being persisted at incrementing seqs (1, 2, 3, ...).
3. Note the highest seq via `sqlite3 .huskies/pipeline.db "SELECT MAX(seq) FROM crdt_ops;"` — call this N.
4. Stop the server and start it again (or `rebuild_and_restart`).
5. Create another story via `create_story`.
6. Query `SELECT seq, created_at FROM crdt_ops ORDER BY created_at DESC LIMIT 5;`
## Actual Result
The new op (just created in step 5) is persisted with `seq = 1` (or some small value), NOT `seq = N + 1`. The lamport clock has been reset.
Concretely on 2026-04-09 we observed seqs in `crdt_ops` ordered by `created_at` DESC of: 7, 6, 492, 4, 3 — i.e. post-restart writes were at seqs 3, 4, 6, 7 even though the highest pre-restart seq was 492.
## Expected Result
After restart, the local node's seq counter must resume from `MAX(inner.seq)` across all persisted ops where `author == local_keypair.public()`, plus 1. The next op written by the local node should have `seq = N + 1` where N is the previous local high-water mark.
Equivalent stated: `inner.seq` on the local author's ops must be monotonically increasing across the entire lifetime of the local node's keypair, not just within a single process invocation.
## Acceptance Criteria
- [ ] After a server restart, the next CRDT op written by the local node has seq = MAX(local_author_seq from crdt_ops) + 1, not 1
- [ ] Regression test: seed crdt_ops with an op at seq=100 by the local author, restart the CRDT subsystem (or call init() in a test harness), trigger a write_item, assert the new op has seq=101
- [ ] Regression test: a brand-new node (no pre-existing ops) still starts at seq=1 (no off-by-one introduced by the fix)
- [ ] Inter-node test: simulate two nodes A and B, A writes ops up to seq=50, A restarts, A writes a new op which should be seq=51, broadcast to B, assert B applies it in the correct causal position
- [ ] If the fix requires changes to bft-json-crdt itself (to expose a way to seed the local seq), the upstream change is documented in the bug body and either landed or vendored
- [ ] After this fix is in place, replay-on-restart for the existing data (8 stories in pipeline_items at seqs 485-492 with lifecycle moves at seqs 1-15) is verified to produce the correct in-memory state — OR the existing broken-seq data is migrated as part of the fix
@@ -0,0 +1,58 @@
---
name: "Migrate chat commands from filesystem lookup to CRDT/DB"
---
# Story 512: Migrate chat commands from filesystem lookup to CRDT/DB
## User Story
**Depends on story 520** (typed pipeline state machine). This story is best implemented as a *consumer* of the typed transition API, not against the loose `PipelineItemView`. Wait for 520 to land first, then migrate the chat command lookups to use the typed `find_story_by_number → Result<PipelineItem, _>` helper from the new module.
---
**Note:** content stuffed into user_story per bug 509 workaround.
## Context
All the slash-style chat commands in `server/src/chat/commands/{move_story,show,depends,unblock}.rs` and `server/src/chat/transport/matrix/{start,assign,delete}.rs` look up stories by **searching for `.huskies/work/*/N_*.md` filesystem files**. After the 491/492 migration moved story content out of the filesystem and into `pipeline_items` + CRDT, these commands silently fail with `"No story, bug, or spike with number {N} found"` for any story whose filesystem shadow doesn't exist — *even when the story is fully present in the DB and CRDT*.
## Real user story
As a user typing chat commands in the web UI or the matrix bot, I want move/show/depends/unblock/start/assign/delete to find any story that's in the pipeline regardless of whether its filesystem shadow exists, so the chat workflow stays usable post-migration.
## Observed 2026-04-09
Master commit `41515e3b` had 503's code, the in-memory CRDT view had 503 at stage='merge', the `pipeline_items` row existed (post my-sqlite-update at `5_done`), but `move 503 done` in the web UI returned **`No story, bug, or spike with number 503 found`** because no `.huskies/work/4_merge/503_*.md` file existed.
## Implementation note
The MCP `move_story` tool already does this correctly: it goes through `lifecycle::move_item` which checks `crdt_state::read_item(story_id)` first. The chat commands need to use the same lookup helper. The fix should consolidate all "find story by number" logic into one shared function used by every command.
## Context
All the slash-style chat commands in `server/src/chat/commands/{move_story,show,depends,unblock}.rs` and `server/src/chat/transport/matrix/{start,assign,delete}.rs` look up stories by **searching for `.huskies/work/*/N_*.md` filesystem files**. After the 491/492 migration moved story content out of the filesystem and into `pipeline_items` + CRDT, these commands silently fail with `"No story, bug, or spike with number {N} found"` for any story whose filesystem shadow doesn't exist — *even when the story is fully present in the DB and CRDT*.
## Real user story
As a user typing chat commands in the web UI or the matrix bot, I want move/show/depends/unblock/start/assign/delete to find any story that's in the pipeline regardless of whether its filesystem shadow exists, so the chat workflow stays usable post-migration.
## Observed 2026-04-09
Master commit `41515e3b` had 503's code, the in-memory CRDT view had 503 at stage='merge', the `pipeline_items` row existed (post my-sqlite-update at `5_done`), but `move 503 done` in the web UI returned **`No story, bug, or spike with number 503 found`** because no `.huskies/work/4_merge/503_*.md` file existed.
## Implementation note
The MCP `move_story` tool already does this correctly: it goes through `lifecycle::move_item` which checks `crdt_state::read_item(story_id)` first. The chat commands need to use the same lookup helper. The fix should consolidate all "find story by number" logic into one shared function used by every command.
## Acceptance Criteria
- [ ] All seven chat commands (move_story, show, depends, unblock, start, assign, delete) successfully find stories that exist in CRDT but have no filesystem shadow
- [ ] Backward compat: commands still work for stories with only filesystem shadows (during the migration window)
- [ ] A single shared `find_story_by_number` helper is introduced and used by every chat command
- [ ] Lookup priority order is documented and consistent: CRDT first, then pipeline_items, then filesystem fallback
- [ ] Regression test per command covering CRDT-only, filesystem-only, both-present, and not-found cases
- [ ] Observed repro from 2026-04-09 (move 503 done failing even though 503 was fully present in CRDT and pipeline_items) is the canonical regression case
## Out of Scope
- TBD
@@ -0,0 +1,55 @@
---
name: "Startup reconcile pass that detects drift between CRDT, pipeline_items, and filesystem shadows"
---
# Story 513: Startup reconcile pass that detects drift between CRDT, pipeline_items, and filesystem shadows
## User Story
**Note:** content stuffed into user_story per bug 509 workaround.
---
## Context
Post-491/492, huskies has **four places state lives** that can drift apart:
1. `crdt_ops` table — the persisted CRDT operation log (intended source of truth)
2. In-memory CRDT view — `state.crdt.doc.items` reconstructed from `crdt_ops` on startup, mutated by `apply_and_persist` during runtime
3. `pipeline_items` table — a shadow / materialised view, written to as a shadow alongside CRDT writes
4. Filesystem shadows in `.huskies/work/N_stage/*.md` — legacy rendering, still written by some paths and read by others
There is currently **no reconcile pass** that detects drift between them. We've watched this drift bite repeatedly today: stories appear in some views and not others, lifecycle moves happen in one but not another, my direct sqlite UPDATE was invisible to the API, etc. Each individual view looks "fine" in isolation, but the drift only becomes visible when a user notices a story behaving inconsistently.
## Real user story
As a developer or operator running huskies, I want a startup reconcile pass that compares all four state sources and either reconciles them automatically (preferred) or logs structured warnings about the drift, so I can detect and diagnose state corruption before it causes user-visible bugs.
## Observed 2026-04-09
Throughout this session we observed: 478 in pipeline_items but missing from CRDT (after a direct sqlite insert), 503 in CRDT at stage=merge but pipeline_items at stage=5_done (after my UPDATE), filesystem shadows in `1_backlog/` for stories that were already in 5_done in the DB (bug 510), etc. None of these were detected by huskies itself — they were all only found by running ad-hoc `SELECT` queries during incident response.
## Scope
This is the *detection* story, not the *fix-the-drift* story. The reconcile pass should:
- Run at startup (after CRDT replay, before serving requests)
- Compare each story's stage across all four sources
- Emit structured log lines for each drift type (CRDT-only, FS-only, DB-only, stage mismatch, etc.)
- Optionally surface a count to the matrix bot startup announcement (e.g. "⚠️ 3 stories have CRDT/DB drift — see logs")
The actual *fix-the-drift* logic (what to do when drift is detected) is a separate, larger story.
## Acceptance Criteria
- [ ] At server startup, after CRDT replay, a reconcile_state() function runs that walks all four state sources and detects drift
- [ ] Each drift type is logged with a structured line: e.g. `[reconcile] DRIFT story=X crdt_stage=Y db_stage=Z fs_stage=W` (or `MISSING` for absent)
- [ ] If any drift is detected, the matrix bot startup announcement includes a count and a suggestion to check the server logs
- [ ] The reconcile pass completes in < 1 second for a typical pipeline (~100 stories) so it doesn't slow startup meaningfully
- [ ] Tests cover: no drift (clean state), CRDT-only story, DB-only story, FS-only story, stage mismatch between CRDT and DB
- [ ] Documentation in README.md explains the reconcile pass and what each drift type means
- [ ] The pass is opt-out via a config flag in case it produces noise during the migration window
## Out of Scope
- TBD
@@ -0,0 +1,94 @@
---
name: "delete_story should do a full cleanup (CRDT op + DB row + filesystem shadow + worktree + pending timers)"
---
# Story 514: delete_story should do a full cleanup (CRDT op + DB row + filesystem shadow + worktree + pending timers)
## User Story
**Depends on story 520** (typed pipeline state machine). With 520 in place, `delete_story` becomes a single typed transition (`* → Archived(Abandoned)` or a hard-delete CRDT op) followed by event subscribers that handle the worktree, timers, and filesystem cleanup. This story should be re-shaped as the consumer migration once 520 lands.
---
**Note:** content stuffed into user_story per bug 509 workaround.
## Context
The MCP `delete_story` tool currently only **removes the filesystem markdown** from `.huskies/work/N_stage/`. It does NOT:
- Remove the row from `pipeline_items`
- Write a CRDT delete op to `crdt_ops`
- Tear down the in-memory CRDT entry
- Remove the `.huskies/worktrees/N_…/` worktree
- Cancel any pending rate-limit retry timers in `.huskies/timers.json`
So after `delete_story`, the story keeps appearing in `get_pipeline_status` (because the in-memory CRDT still has it), the timer fires and re-spawns an agent, the agent runs in the still-existing worktree, and the user has no idea why the "deleted" story keeps coming back.
## Real user story
As a user calling `delete_story` (via MCP, web UI, or chat command), I want a complete tear-down of all state associated with that story across every layer, so the story is actually gone — no in-memory cache entries, no pending agents, no timers, no worktree, no shadow files, no future spawns.
## Observed 2026-04-09
Repeatedly throughout the session. The most concrete example was around 17:20: I called `delete_story 478_…`, the tool returned success, the markdown file at `.huskies/work/1_backlog/478_…md` was removed, but at 17:25:17 the rate-limit retry timer fired and **re-spawned a coder-1 on the deleted story** because the worktree still existed, the pipeline_items row still existed, and the timer entry still existed in `.huskies/timers.json`. We then had to do sqlite surgery + manual worktree removal + manual timers.json edit to actually kill 478.
## Implementation note
The current `delete_story` is on the legacy filesystem path. The fix needs to wrap it in a transaction that touches every layer:
1. Cancel any pending timers for this story_id (read timers.json, filter, write back)
2. Stop any running/pending agents for this story_id (call `agent_pool.stop_agent` for each)
3. Remove the worktree if it exists (`git worktree remove`)
4. Write a CRDT delete op (`apply_and_persist` with a delete op)
5. Wait for the persist task to confirm
6. Delete the row from `pipeline_items` directly (or trust the materialiser to drop it)
7. Remove the filesystem shadow
Each step should be best-effort with logging — partial failures should be visible, not silent.
## Context
The MCP `delete_story` tool currently only **removes the filesystem markdown** from `.huskies/work/N_stage/`. It does NOT:
- Remove the row from `pipeline_items`
- Write a CRDT delete op to `crdt_ops`
- Tear down the in-memory CRDT entry
- Remove the `.huskies/worktrees/N_…/` worktree
- Cancel any pending rate-limit retry timers in `.huskies/timers.json`
So after `delete_story`, the story keeps appearing in `get_pipeline_status` (because the in-memory CRDT still has it), the timer fires and re-spawns an agent, the agent runs in the still-existing worktree, and the user has no idea why the "deleted" story keeps coming back.
## Real user story
As a user calling `delete_story` (via MCP, web UI, or chat command), I want a complete tear-down of all state associated with that story across every layer, so the story is actually gone — no in-memory cache entries, no pending agents, no timers, no worktree, no shadow files, no future spawns.
## Observed 2026-04-09
Repeatedly throughout the session. The most concrete example was around 17:20: I called `delete_story 478_…`, the tool returned success, the markdown file at `.huskies/work/1_backlog/478_…md` was removed, but at 17:25:17 the rate-limit retry timer fired and **re-spawned a coder-1 on the deleted story** because the worktree still existed, the pipeline_items row still existed, and the timer entry still existed in `.huskies/timers.json`. We then had to do sqlite surgery + manual worktree removal + manual timers.json edit to actually kill 478.
## Implementation note
The current `delete_story` is on the legacy filesystem path. The fix needs to wrap it in a transaction that touches every layer:
1. Cancel any pending timers for this story_id (read timers.json, filter, write back)
2. Stop any running/pending agents for this story_id (call `agent_pool.stop_agent` for each)
3. Remove the worktree if it exists (`git worktree remove`)
4. Write a CRDT delete op (`apply_and_persist` with a delete op)
5. Wait for the persist task to confirm
6. Delete the row from `pipeline_items` directly (or trust the materialiser to drop it)
7. Remove the filesystem shadow
Each step should be best-effort with logging — partial failures should be visible, not silent.
## Acceptance Criteria
- [ ] delete_story returns success only when ALL of the following are true: no row in pipeline_items, no op in crdt_ops referencing the story_id (or a delete op present), no in-memory CRDT entry, no worktree directory, no timer entries, no filesystem shadow
- [ ] Each tear-down step has its own log line so partial failures are diagnosable
- [ ] If any tear-down step fails, the tool returns an error with which step failed and what was already torn down (so the user can finish the cleanup manually)
- [ ] After delete_story, the story does NOT appear in get_pipeline_status, the web UI, or list_agents
- [ ] After delete_story, no rate-limit retry timer can re-spawn an agent on the deleted story
- [ ] Regression test using the 2026-04-09 repro: schedule a rate-limit timer for the story, call delete_story, fast-forward 5 minutes, assert no agent spawned
## Out of Scope
- TBD
@@ -0,0 +1,54 @@
---
name: "Add a debug MCP tool to dump the in-memory CRDT state for inspection"
---
# Story 515: Add a debug MCP tool to dump the in-memory CRDT state for inspection
## User Story
**Note:** content stuffed into user_story per bug 509 workaround.
---
## Context
When diagnosing CRDT/state issues today, we had no way to look at the **in-memory** CRDT state directly. The closest available views were:
- `get_pipeline_status` — gives a summarised pipeline-shaped view (active/backlog/done) but hides the raw item structure, the index map, the lamport clock state, etc.
- Querying `crdt_ops` directly via sqlite — gives the *persisted* state, which can diverge from the in-memory state (we saw this with bug 511, where post-restart writes use reset seq counters)
- `read_item(story_id)` in `crdt_state.rs` — exists, returns a `PipelineItemView`, but is not exposed via MCP or HTTP
The result: every time I needed to check the CRDT state, I was either inferring it from `get_pipeline_status` (lossy) or querying the persisted ops (lagging the in-memory state). Neither gave me the ground truth.
## Real user story
As a developer debugging huskies state issues, I want an MCP tool (or HTTP debug endpoint) that returns a structured dump of the in-memory CRDT state, so I can see exactly what the running server thinks is true without inferring from summaries.
## Suggested API
- Tool name: `mcp__huskies__dump_crdt`
- Args: optional `story_id` filter (single story) or no args (dump everything)
- Returns: JSON with one entry per item containing: `story_id`, all field values (`stage`, `name`, `agent`, `retry_count`, `blocked`, `depends_on`), the CRDT path/index bytes (for cross-referencing with `crdt_ops`), the local lamport seq counter, and a flag indicating whether the item is `is_deleted`
- Returns metadata: total item count, current local seq counter value, count of pending ops in `persist_tx` channel (if observable)
## Observed 2026-04-09
This story would have saved us significant debugging time. Specific examples:
- When 478 was missing from `get_pipeline_status` after the manual sqlite insert, we had to infer "the API reads from in-memory CRDT, not from pipeline_items" by looking at source code. A `dump_crdt 478_…` call would have returned "not found" immediately, confirming the same conclusion.
- When 503 was showing at stage=merge in the API but only had a creation op at stage=1_backlog in `crdt_ops`, we had to manually search for content-indexed update ops to figure out where the post-restart updates went. A dump tool showing the current in-memory state vs the persisted op count would have made the divergence obvious.
## Acceptance Criteria
- [ ] New MCP tool `dump_crdt` is registered and callable
- [ ] With no args, returns all items in the in-memory CRDT as a structured JSON list
- [ ] With a story_id arg, returns just that one item (or null if not found)
- [ ] Each item entry includes: story_id, stage, name, agent, retry_count, blocked, depends_on, content_index (hex), is_deleted
- [ ] Returns top-level metadata: total_items, max_local_seq, pending_persist_ops_count (if available), in_memory_state_loaded (bool)
- [ ] Tool description is clear that this is a debug tool, not for normal pipeline introspection (those should use get_pipeline_status)
- [ ] Optional: also expose via HTTP at `/debug/crdt` for browser inspection
- [ ] Documented in README.md under a 'debugging' section
## Out of Scope
- TBD
@@ -0,0 +1,53 @@
---
name: "update_story.description should create the ## Description section if it doesn't exist (instead of erroring)"
---
# Story 516: update_story.description should create the ## Description section if it doesn't exist (instead of erroring)
## User Story
**Note:** content stuffed into user_story per bug 509 workaround.
---
## Context
The MCP `update_story` tool's `description` parameter "replaces the `## Description` section content". If the section doesn't exist in the story file, the call **errors out** with `Section '## Description' not found in story file.`
This becomes a real problem when:
1. A story was created via `create_story` (which is buggy per 509 and writes a stub template with no `## Description` section)
2. The user later wants to add a description via `update_story`
3. The update fails with the cryptic "section not found" error
We hit this exact scenario today: after bug 509 dropped the descriptions of 6 stories (500, 504, 505, 506, 507, 508), I tried to recover them by calling `update_story` with `description=...` — and the call errored out because the stub template the buggy `create_story` had written had no `## Description` section. We had to fall back to stuffing everything into the `user_story` field.
## Real user story
As a user calling `update_story.description` on any story (regardless of how it was originally created), I want the call to either replace the existing `## Description` section OR create one if it doesn't exist, so I never have to think about the template structure.
## Implementation note
The simplest fix is in the `update_story_in_file` (or equivalent) function: when looking for the `## Description` section, if not found, **insert it** at a sensible location — probably between `## User Story` and `## Acceptance Criteria` — and then write the description content there.
Related: this story partially covers the workaround for bug 509 (create_story drops description). If 509 is fixed first, the templates would always have a `## Description` section and this wouldn't matter. But this fix is still valuable for older stories created before 509 lands, AND for stories created via legacy paths that don't use the canonical template.
## Observed 2026-04-09
```
> update_story story_id=500_story_remove_duplicate_pty_debug_log_lines description="..."
Error: Section '## Description' not found in story file.
```
## Acceptance Criteria
- [ ] update_story.description succeeds whether or not the target story has a pre-existing ## Description section
- [ ] When the section is missing, it is created at a consistent location (between ## User Story and ## Acceptance Criteria)
- [ ] When the section exists, the existing replace-content behaviour is preserved (no regression)
- [ ] Unit test covering both: section-exists path AND section-missing path
- [ ] Symmetric fix for update_story.user_story (if it has the same brittleness)
- [ ] Error messages for genuine failure modes (file not found, write failed) are still distinct from the now-silent missing-section case
## Out of Scope
- TBD
@@ -0,0 +1,98 @@
---
name: "Remove filesystem-shadow fallback paths from lifecycle.rs (finish the migration to CRDT-only)"
---
# Story 517: Remove filesystem-shadow fallback paths from lifecycle.rs (finish the migration to CRDT-only)
## User Story
**Depends on story 520** (typed pipeline state machine). Once 520 lands and consumers are migrated to the typed transition API, the lifecycle module no longer needs filesystem fallbacks — all state changes go through the typed `transition` function and the event bus. This story becomes the natural cleanup pass after 520 + 512 + 514 land.
---
**Note:** content stuffed into user_story per bug 509 workaround.
## Context
`server/src/agents/lifecycle.rs::move_item` (the helper that backs `move_story_to_current`, `move_story_to_done`, `move_story_to_merge`, etc.) has **three execution paths**:
1. **CRDT-first path** (the "happy" post-migration path) — calls `crdt_state::read_item(story_id)`, then `db::move_item_stage`, which writes a CRDT op and broadcasts events
2. **Content-store fallback** — if the story isn't in CRDT but exists in the db's content store, import it via `db::write_item_with_content`
3. **Filesystem fallback** — if neither, scan `.huskies/work/N_stage/` for a markdown file, import it to the DB
Paths 2 and 3 are **migration scaffolding**. They were necessary while stories existed only on disk and the CRDT was empty, but post-491/492 they should be unnecessary. Worse, they actively *cause* drift today:
- The filesystem fallback can re-import stale shadow files into the DB, undoing intentional deletes
- The path 3 search is blind to which stage a story "should" be in per the DB — it picks whatever stage dir has the file, which can promote stale shadows
- This is the mechanism that makes bug 510 (split-brain shadow promotion) possible
`move_story_to_current` is hardcoded to read from `["1_backlog"]`, which is also part of the same legacy filesystem assumption.
## Real user story
As a developer maintaining huskies, I want the lifecycle code to operate exclusively on the CRDT/DB and never touch filesystem shadows, so state drift is eliminated and the post-migration architecture is consistent.
## Implementation plan
1. Inventory every code path in `lifecycle.rs` that touches the filesystem under `.huskies/work/`
2. For each, determine whether it's a *read* (legacy fallback — can be removed if we're confident all stories are in CRDT now) or a *write* (legacy mirror — can be deferred to a separate filesystem-renderer task that derives state from CRDT)
3. Remove the read fallbacks
4. Move the writes to a downstream materialiser task that writes the filesystem shadows from CRDT events (so they're strictly read-only renderings)
5. Run the bug-510 reconcile pass at startup (story TBD) before this lands, to ensure no story is stranded with only a filesystem shadow
## Observed 2026-04-09
We watched the filesystem fallback paths cause harm multiple times today:
- Bug 510 split-brain: filesystem shadows in `1_backlog/` got re-promoted by timer fires after the DB had already moved the story to `5_done`
- The 478 worktree's `move_story_to_current` no-op'd because there was no `1_backlog` shadow — even though 478 was in `4_merge` per the DB (this was actually correct behaviour given the function's narrow `from = ["1_backlog"]`, but it surfaces how filesystem-bound the function is)
- Lifecycle moves were happening on the filesystem without writing CRDT ops (we initially mis-diagnosed this as "no transition ops in CRDT" before finding bug 511)
## Context
`server/src/agents/lifecycle.rs::move_item` (the helper that backs `move_story_to_current`, `move_story_to_done`, `move_story_to_merge`, etc.) has **three execution paths**:
1. **CRDT-first path** (the "happy" post-migration path) — calls `crdt_state::read_item(story_id)`, then `db::move_item_stage`, which writes a CRDT op and broadcasts events
2. **Content-store fallback** — if the story isn't in CRDT but exists in the db's content store, import it via `db::write_item_with_content`
3. **Filesystem fallback** — if neither, scan `.huskies/work/N_stage/` for a markdown file, import it to the DB
Paths 2 and 3 are **migration scaffolding**. They were necessary while stories existed only on disk and the CRDT was empty, but post-491/492 they should be unnecessary. Worse, they actively *cause* drift today:
- The filesystem fallback can re-import stale shadow files into the DB, undoing intentional deletes
- The path 3 search is blind to which stage a story "should" be in per the DB — it picks whatever stage dir has the file, which can promote stale shadows
- This is the mechanism that makes bug 510 (split-brain shadow promotion) possible
`move_story_to_current` is hardcoded to read from `["1_backlog"]`, which is also part of the same legacy filesystem assumption.
## Real user story
As a developer maintaining huskies, I want the lifecycle code to operate exclusively on the CRDT/DB and never touch filesystem shadows, so state drift is eliminated and the post-migration architecture is consistent.
## Implementation plan
1. Inventory every code path in `lifecycle.rs` that touches the filesystem under `.huskies/work/`
2. For each, determine whether it's a *read* (legacy fallback — can be removed if we're confident all stories are in CRDT now) or a *write* (legacy mirror — can be deferred to a separate filesystem-renderer task that derives state from CRDT)
3. Remove the read fallbacks
4. Move the writes to a downstream materialiser task that writes the filesystem shadows from CRDT events (so they're strictly read-only renderings)
5. Run the bug-510 reconcile pass at startup (story TBD) before this lands, to ensure no story is stranded with only a filesystem shadow
## Observed 2026-04-09
We watched the filesystem fallback paths cause harm multiple times today:
- Bug 510 split-brain: filesystem shadows in `1_backlog/` got re-promoted by timer fires after the DB had already moved the story to `5_done`
- The 478 worktree's `move_story_to_current` no-op'd because there was no `1_backlog` shadow — even though 478 was in `4_merge` per the DB (this was actually correct behaviour given the function's narrow `from = ["1_backlog"]`, but it surfaces how filesystem-bound the function is)
- Lifecycle moves were happening on the filesystem without writing CRDT ops (we initially mis-diagnosed this as "no transition ops in CRDT" before finding bug 511)
## Acceptance Criteria
- [ ] Inventory of every filesystem touch in lifecycle.rs is documented in the story body or a follow-up comment
- [ ] All read fallbacks in lifecycle.rs (paths 2 and 3 above) are removed
- [ ] All write paths in lifecycle.rs that mirror to the filesystem are moved to a separate materialiser task driven by CRDT events
- [ ] After the change, lifecycle.rs has zero direct std::fs:: calls under .huskies/work/
- [ ] move_story_to_current no longer hardcodes from=['1_backlog'] — it reads the source stage from CRDT
- [ ] Regression: the existing 'try filesystem fallback' tests are updated to test the new CRDT-only path instead of being deleted
- [ ] A pre-flight script verifies all existing stories are in CRDT before this change lands (so nothing gets stranded)
- [ ] Bug 510 (split-brain shadows) no longer reproduces after this change
## Out of Scope
- TBD
@@ -0,0 +1,62 @@
---
name: "apply_and_persist should log when persist_tx send fails instead of silently dropping the op"
---
# Story 518: apply_and_persist should log when persist_tx send fails instead of silently dropping the op
## User Story
**Note:** content stuffed into user_story per bug 509 workaround.
---
## Context
`server/src/crdt_state.rs::apply_and_persist` updates the in-memory CRDT and then sends the signed op to the persistence task via a channel:
```rust
fn apply_and_persist<F>(state: &mut CrdtState, op_fn: F) {
let raw_op = op_fn(state);
let signed = raw_op.sign(&state.keypair);
state.crdt.apply(signed.clone()); // in-memory update
let _ = state.persist_tx.send(signed.clone()); // ← fire-and-forget, error dropped
...
}
```
The `let _ = ...` discards the return value of `send()`. If the channel is closed (because the persistence task panicked, was shut down, or has dropped its receiver), the op is silently dropped from persistence — but the in-memory CRDT is already updated. The next restart will replay only the persisted ops, and the in-memory state will quietly diverge from the persisted state.
This is also one of the candidate causes for some of the state drift we've been chasing. It's hard to rule out because there's no log line to confirm whether the persist task is still alive or whether sends are succeeding.
## Real user story
As a developer or operator, I want any failure of `persist_tx.send()` to be logged immediately at WARN or ERROR level, so silent persistence loss is detectable instead of invisible.
## Observed 2026-04-09
Spent significant time investigating whether persist sends were silently failing. Eventually ruled it out empirically (we found that ops WERE being persisted, just with reset seq counters per bug 511). But the diagnosis would have been minutes instead of an hour if there was a log line to check.
## Fix (small)
```rust
if let Err(e) = state.persist_tx.send(signed.clone()) {
crate::slog_error!(
"[crdt] Failed to send op to persist task: {e}; persist task may be dead. \
In-memory state is now ahead of persisted state."
);
}
```
Apply the same fix at every `let _ = state.persist_tx.send(...)` site in crdt_state.rs (there are at least 2 — one in apply_and_persist, one in apply_remote_op).
## Acceptance Criteria
- [ ] Every call site of `state.persist_tx.send(...)` in crdt_state.rs logs at ERROR level on send failure
- [ ] The error message includes the channel error and a clear note that 'in-memory and persisted state may have diverged'
- [ ] Unit test: shut down the persist receiver (drop the rx end), call write_item, assert an error is logged
- [ ] No regression in the happy path (no extra log lines on success)
- [ ] Consider: also expose a counter / metric for persist send failures so it can be monitored without grepping logs
## Out of Scope
- TBD
@@ -0,0 +1,107 @@
---
name: "mergemaster should detect no-commits-ahead-of-master and fail loudly instead of exiting silently"
---
# Story 519: mergemaster should detect no-commits-ahead-of-master and fail loudly instead of exiting silently
## User Story
**Depends on story 520** (typed pipeline state machine). Once 520 lands, this story largely *evaporates*: `Stage::Merge` is defined as `Merge { feature_branch: BranchName, commits_ahead: NonZeroU32 }`, so a merge state with zero commits ahead is **structurally unrepresentable**. The transition `Current → Merge` (or `Qa → Merge`) is required to provide a NonZeroU32 — the type system enforces it. This story remains useful as a *defensive runtime check* during the migration window before 520 lands; afterwards, it should be closed as redundant.
---
**Note:** content stuffed into user_story per bug 509 workaround.
## Context
When mergemaster runs on a story whose worktree has **zero commits ahead of master** (e.g. because `create_worktree` always creates from master and the original feature branch was never checked out into the worktree), it currently:
1. Spawns its claude session
2. Runs `merge_agent_work` MCP tool
3. Finds nothing to merge
4. Exits cleanly with `[agent:N:mergemaster] Done. Session: ...`
5. **Does not log any error or warning**
6. **Spends real money** on the empty session — we observed `cost=$0.82` for one such no-op run
The user has no signal that the merge didn't actually happen. The matrix bot fires a "QA → Merge" stage notification (because the story did move stages internally), then nothing — no `🎉 Merge → Done` notification follows. Master is unchanged.
## Real user story
As a user watching the pipeline, I want mergemaster to detect "this worktree has no commits ahead of master" *before* spending money on a Claude session, and fail loudly with a clear error so I know to investigate the upstream cause (probably the worktree got reset to master).
## Observed 2026-04-09
Around 18:31:51, mergemaster spawned for 478 in a worktree that had been reset to master by the orphan cleanup logic at 18:29:54. By the time mergemaster ran, the worktree was on master with zero commits ahead. It ran a session, spent $0.82, exited "Done", and didn't merge anything. We didn't notice for several minutes because the failure was completely silent. We had to manually `git log master..feature/story-478_…` to confirm there was no merge commit on master.
## Fix
In mergemaster's startup sequence (probably in advance.rs or wherever the mergemaster session is spawned), add a pre-flight check:
```rust
let commits_ahead = git_commits_ahead(worktree_path, "master")?;
if commits_ahead == 0 {
slog_error!(
"[mergemaster] worktree {worktree_path} has no commits ahead of master; \
refusing to spawn merge session. Likely cause: worktree was reset to \
master after the feature branch's commits were created. Investigate the \
worktree's git state before retrying."
);
return Err("no commits to merge".into());
}
```
This costs ~milliseconds (one git command) and saves the cost of an entire Claude session per false-positive.
## Context
When mergemaster runs on a story whose worktree has **zero commits ahead of master** (e.g. because `create_worktree` always creates from master and the original feature branch was never checked out into the worktree), it currently:
1. Spawns its claude session
2. Runs `merge_agent_work` MCP tool
3. Finds nothing to merge
4. Exits cleanly with `[agent:N:mergemaster] Done. Session: ...`
5. **Does not log any error or warning**
6. **Spends real money** on the empty session — we observed `cost=$0.82` for one such no-op run
The user has no signal that the merge didn't actually happen. The matrix bot fires a "QA → Merge" stage notification (because the story did move stages internally), then nothing — no `🎉 Merge → Done` notification follows. Master is unchanged.
## Real user story
As a user watching the pipeline, I want mergemaster to detect "this worktree has no commits ahead of master" *before* spending money on a Claude session, and fail loudly with a clear error so I know to investigate the upstream cause (probably the worktree got reset to master).
## Observed 2026-04-09
Around 18:31:51, mergemaster spawned for 478 in a worktree that had been reset to master by the orphan cleanup logic at 18:29:54. By the time mergemaster ran, the worktree was on master with zero commits ahead. It ran a session, spent $0.82, exited "Done", and didn't merge anything. We didn't notice for several minutes because the failure was completely silent. We had to manually `git log master..feature/story-478_…` to confirm there was no merge commit on master.
## Fix
In mergemaster's startup sequence (probably in advance.rs or wherever the mergemaster session is spawned), add a pre-flight check:
```rust
let commits_ahead = git_commits_ahead(worktree_path, "master")?;
if commits_ahead == 0 {
slog_error!(
"[mergemaster] worktree {worktree_path} has no commits ahead of master; \
refusing to spawn merge session. Likely cause: worktree was reset to \
master after the feature branch's commits were created. Investigate the \
worktree's git state before retrying."
);
return Err("no commits to merge".into());
}
```
This costs ~milliseconds (one git command) and saves the cost of an entire Claude session per false-positive.
## Acceptance Criteria
- [ ] Before mergemaster spawns its Claude session, it runs `git log master..HEAD --oneline` (or equivalent) on the worktree
- [ ] If the result is empty (zero commits ahead), mergemaster exits early with an ERROR log line and does NOT spawn the session
- [ ] The error message is specific enough that the user can diagnose the upstream cause (e.g. mentions 'worktree was reset' and suggests checking the worktree's branch)
- [ ] The matrix bot sends a clear failure notification (NOT a successful 🎉 emoji) when this happens
- [ ] The story does not advance to a 'done' state when mergemaster exits this way; it stays in 4_merge with a clear blocked status
- [ ] Regression test: create a worktree on master (no feature commits), invoke mergemaster, assert the early exit happens and no Claude session is spawned
- [ ] Cost saving observed in the 2026-04-09 incident ($0.82 per no-op session) is documented in the test as the motivation
## Out of Scope
- TBD
@@ -0,0 +1,192 @@
---
name: "Typed pipeline state machine in Rust (foundation: replaces stringly-typed CRDT views with strict enums, subsumes 436)"
---
# Story 520: Typed pipeline state machine in Rust (foundation: replaces stringly-typed CRDT views with strict enums, subsumes 436)
## User Story
**Note:** content stuffed into user_story per bug 509 workaround.
---
## Context
Today huskies represents pipeline state as a loose JSON document inside the BFT JSON CRDT. Each story has fields like `stage: String`, `agent: String`, `retry_count: f64`, `blocked: bool`, `depends_on: String` (JSON-encoded list, double-encoded). This stringly-typed representation allows **many impossible states** to be representable in the data model:
- `stage = "9_invalid"` — typo, no compile error
- `stage = "5_done"` + `blocked = true` — a done story is blocked? what does that mean?
- `stage = "4_merge"` with no commits ahead of master — the silent mergemaster failure mode (today's story 478)
- A coder agent assigned to a story in `4_merge` — bug 502, the loop we fought all day today
- `retry_count = 3.7` — fractional retry counts (it's an f64 because that's what JSON CRDTs do)
- `agent = "coder-1"` AND `stage = "1_backlog"` — backlog story has an agent? sentinel encoding via empty string
Multiple bugs filed today (501, 502, 510, 511) exist *because* the type system can't enforce the pipeline invariants. **Patching individual symptoms forever is the wrong strategy.** The right strategy is to make impossible states unrepresentable at the Rust type level, using a typed state machine layered on top of the loose CRDT. The CRDT can stay loose at the persistence layer (it has to be — that's what makes it merge correctly across nodes), but every consumer above the CRDT operates on strict typed enums.
## Real user story
As a developer working on huskies, I want the pipeline state to be expressed as a strict Rust state machine where impossible states and impossible transitions are compile-time errors, so future bugs in this category become structural rather than runtime drift.
## Design
### Two enum hierarchies
**Synced state (CRDT-backed, converges across nodes):**
```rust
enum Stage {
Backlog,
Current,
Qa,
Merge { feature_branch: BranchName, commits_ahead: NonZeroU32 },
Done { merged_at: DateTime<Utc>, merge_commit: GitSha },
Archived { archived_at: DateTime<Utc>, reason: ArchiveReason },
}
enum ArchiveReason {
Completed, // normal accept_story → archived
Abandoned, // user explicitly abandoned
Superseded { by: StoryId },
Blocked { reason: String }, // was bug 436's `blocked: true`
MergeFailed { reason: String }, // was bug 436's `merge_failure`
ReviewHeld { reason: String }, // was bug 436's `review_hold`
}
struct PipelineItem {
story_id: StoryId, // newtype, validated
name: String,
stage: Stage, // typed enum, all variants are valid by construction
depends_on: Vec<StoryId>, // parsed, not stringified
retry_count: u32, // not f64
// No more separate `blocked`, `merge_failure`, `review_hold` — folded into Stage::Archived
}
```
**Per-node execution state (CRDT-backed under node_id key, local-authored but globally-readable):**
```rust
enum ExecutionState {
Idle,
Pending { agent: AgentName, since: DateTime<Utc> },
Running { agent: AgentName, started_at: DateTime<Utc>, last_heartbeat: DateTime<Utc> },
RateLimited { agent: AgentName, resume_at: DateTime<Utc> },
Completed { agent: AgentName, exit_code: i32, completed_at: DateTime<Utc> },
}
// In the CRDT document, ExecutionState is stored under each node's pubkey:
// crdt.execution_state: { node_pubkey → { story_id → ExecutionState } }
```
The execution state lives in the CRDT under **each node's pubkey**. Each node only writes to entries where `node_pubkey == self.pubkey`, so there's no merge conflict — concurrent writes from the same author follow LWW, concurrent writes from different authors target different entries entirely. All nodes can READ all execution states across the mesh.
**This per-node-keyed CRDT pattern enables:**
- **Cross-node observability** — matrix bot can show "node A is running coder-1 on story X, node B is rate-limited on story Y"
- **Heartbeat detection** — if a node hasn't updated its execution_state in N minutes, the entry is "stale" (laptop closed, process crashed, oom kill, etc.)
- **Foundation for story 479** (CRDT work claiming) — a node knows what other nodes are doing *before* claiming work
- **Stuck job recovery** — if node A's heartbeat dies mid-run, node B can see the stuck state and decide whether to take over
- **Crash forensics** — the last persisted ExecutionState before a crash is preserved in CRDT, accessible from any node
### The transition function
```rust
fn transition(
state: PipelineItem,
event: PipelineEvent,
) -> Result<PipelineItem, TransitionError>
```
Pure function. Takes the current state and an event, returns either the new state or a TransitionError. The compiler enforces that the result of every transition is structurally valid — you can't construct a `Stage::Merge` without `commits_ahead: NonZeroU32`, you can't construct a `Stage::Done` without a `merge_commit: GitSha`, etc.
**The set of valid transitions is small** (roughly 10):
- `Backlog → Current` — deps met, auto-assign promotes
- `Current → Qa` — gates start
- `Current → Merge` — qa: server, gates auto-pass
- `Qa → Merge` — gates pass
- `Qa → Current` — gates fail, retry
- `Merge → Done` — mergemaster squash succeeds; *requires `Merge.commits_ahead > 0`*
- `Done → Archived(Completed)` — accept_story
- `* → Archived(Blocked / MergeFailed / ReviewHeld)` — stuck-state move
- `Archived(Blocked) → Backlog` — unblock
Anything else is a `TransitionError`. The compiler refuses to compile code that constructs invalid transitions.
### The event subscriber pattern
State changes fire events on a bus. Side-effect handlers subscribe independently:
```rust
type TransitionEvent = (PipelineItem /* before */, PipelineItem /* after */);
bus.subscribe("matrix-bot", |before, after| matrix_bot.notify_stage_change(before, after));
bus.subscribe("filesystem", |before, after| fs_renderer.update(after));
bus.subscribe("pipeline-table", |before, after| pipeline_items_table.upsert(after));
bus.subscribe("auto-assign", |before, after| auto_assign.poke_if_relevant(after));
bus.subscribe("web-ui-broadcast", |before, after| ws_clients.broadcast(after));
```
Each subscriber is independent and concerns itself only with its own dispatch. Adding a new side effect = adding a new subscriber, not editing the transition function. **The "many things happen on state changes" complexity moves out of the state machine and into the bus consumers**, where each piece is testable in isolation.
### Projection layer (loose CRDT ↔ typed Rust)
The bft-json-crdt JSON document is the persistence layer. The typed enums are the application layer. A projection function bridges them at one carefully-controlled boundary:
```rust
impl TryFrom<&PipelineItemCrdt> for PipelineItem {
type Error = ProjectionError;
fn try_from(crdt: &PipelineItemCrdt) -> Result<Self, ProjectionError> { ... }
}
impl From<&PipelineItem> for PipelineItemCrdt {
fn from(item: &PipelineItem) -> Self { ... }
}
```
When the CRDT contains data the typed layer can't parse (e.g. a stage value from a future huskies version, OR a merge that produces an inconsistent intermediate state), `try_from` returns a `ProjectionError`. The error surfaces to the caller — it doesn't silently propagate as garbage. The validation happens at exactly one point: the projection boundary.
## What this subsumes
**Story 436** ("Unify story stuck states into a single status field") is subsumed by the `Stage::Archived { reason: ArchiveReason }` variant. The unified status field IS the `ArchiveReason` enum. Story 436 is marked superseded by this story.
## What this enables (concrete bug eliminations)
- **Bug 502** becomes unrepresentable: there's no way to construct `Stage::Merge` with a Coder agent — Coder agents only attach to `Current` / `Qa` stages, and that constraint is in the type signature of the transition function.
- **Bug 510** becomes irrelevant: there's no "stage='1_backlog' filesystem shadow vs stage='5_done' DB" drift, because Stage is a typed enum with a single source of truth (the CRDT), and projections are derived deterministically.
- **Bug 519** (mergemaster silent on no-op merge) becomes unrepresentable: `Stage::Merge` requires `commits_ahead: NonZeroU32`. You can't construct a Merge state with zero commits.
- **Bug 511** (lamport seq reset) becomes detectable: the projection layer notices when CRDT data fails to parse cleanly and surfaces a ProjectionError instead of silently producing garbage in-memory state.
- **Story 479** (CRDT work claiming) has a clean foundation: ExecutionState gives every node visibility into what every other node is doing, including stale-heartbeat detection.
- **Future state machine bugs become compile errors**, not runtime drift.
## Implementation order
1. Define the `Stage`, `ArchiveReason`, `ExecutionState`, `PipelineItem` types in a new module (e.g. `server/src/pipeline_state.rs`).
2. Implement the projection layer (try_from / from for PipelineItemCrdt).
3. Implement the `transition` function with exhaustive valid transitions.
4. Implement the event bus.
5. Migrate consumers ONE AT A TIME — chat commands, lifecycle, API, auto-assign, matrix bot. Each migration is isolated; the compiler tells you when you've missed something.
6. Once nothing reads the loose `PipelineItemView` anymore, delete it.
7. Story 436 closes when this lands.
## Acceptance Criteria
- [ ] A new module (e.g. server/src/pipeline_state.rs) defines the Stage, ArchiveReason, ExecutionState, and PipelineItem types with the variants described in the design
- [ ] Stage::Merge has a NonZeroU32 commits_ahead field (so the bug 519 silent no-op merge is unrepresentable)
- [ ] Stage::Done has GitSha merge_commit and DateTime merged_at fields (so a 'done' story always has merge metadata)
- [ ] ArchiveReason enum subsumes the old blocked / merge_failure / review_hold front matter fields, with a sub-reason variant for each
- [ ] PipelineItem.depends_on is Vec<StoryId>, not String (no more JSON-as-string)
- [ ] ExecutionState lives in the CRDT under per-node-pubkey keys; each node only writes to its own subspace (validated by CRDT signature check)
- [ ] Last_heartbeat field is updated periodically by the running node so other nodes can detect stale entries
- [ ] A pure transition(state, event) -> Result<PipelineItem, TransitionError> function exists and is exhaustively pattern-matched
- [ ] Every valid transition listed in the design (~10) is implemented and unit-tested with both success and error cases
- [ ] The TryFrom<&PipelineItemCrdt> for PipelineItem projection function handles every currently-valid CRDT state and returns a structured ProjectionError for invalid ones (instead of silently propagating garbage)
- [ ] An event bus pattern is in place where matrix bot, filesystem renderer, pipeline_items materialiser, auto-assign, and web UI broadcaster are independent subscribers
- [ ] All call sites that previously read item.stage as a string or used the blocked / merge_failure / review_hold fields are migrated to the typed enum API
- [ ] Story 436 is closed as superseded by this story
- [ ] Bug 502 has a regression test that confirms the type system prevents the loop (the test should be a compile-fail test if possible)
- [ ] Bug 510 (filesystem shadow split-brain) no longer reproduces after this lands, because the typed state machine has a single source of truth
- [ ] Documentation in README.md or a new ARCHITECTURE.md explains the type hierarchy, the transition function, the event bus pattern, and the per-node ExecutionState convention
## Out of Scope
- TBD
@@ -0,0 +1,87 @@
---
name: "MCP/HTTP capability to write a CRDT tombstone (delete op) for a story, to clear it from in-memory state"
---
# Story 521: MCP/HTTP capability to write a CRDT tombstone (delete op) for a story, to clear it from in-memory state
## User Story
**Note:** content stuffed into user_story per bug 509 workaround.
---
## Context
Today (2026-04-09) we discovered the hard way that **there is no way to remove a story from huskies's running in-memory state without restarting the server process**. The state machines that keep stories alive include:
1. The persisted CRDT op log (`crdt_ops` table) — direct sqlite DELETE works
2. The in-memory CRDT view (`CRDT_STATE` global in `server/src/crdt_state.rs`) — **no eviction API**
3. The in-memory content store (`CONTENT_STORE` in `server/src/db/mod.rs:46`) — has `delete_content()` but no MCP / HTTP exposure
4. The shadow `pipeline_items` table — direct sqlite DELETE works
5. Filesystem shadows under `.huskies/work/``find -delete` works
6. `timers.json` — direct file edit works
If a story gets into a bad state (split-brain, ghost row, runaway timer respawning it), we can scrub all the *persistent* layers (1, 4, 5, 6) but the *in-memory* layers (2, 3) keep regenerating it because some periodic code reads in-memory state and writes new ops based on what it sees. The only way to clear in-memory state today is `docker restart huskies`, which is heavy and disrupts the matrix bot, web UI, and any in-flight agents.
We need a **scoped, surgical capability** to write a CRDT tombstone op for a single story_id, which:
- Marks the in-memory item as `is_deleted = true`
- Persists the tombstone op to `crdt_ops` so future replays don't resurrect the story
- Removes the story from `CONTENT_STORE`
- Cleans up any pending `timers.json` entries for the story
- Cancels any running agents on the story
…and exposes it as an MCP tool (e.g. `mcp__huskies__purge_story`) and ideally an HTTP endpoint, so an operator can "kill it with fire" without restarting the server.
## Real user story
As a huskies operator, I want a single MCP/HTTP call that completely removes a story from every layer of state — persistent AND in-memory — so I never have to restart the entire server just to clean up one stuck story.
## Observed 2026-04-09
We spent the last hour of this session whack-a-moling stories 503 and 478. Even after:
- `DELETE FROM pipeline_items WHERE id LIKE '503%'`
- `DELETE FROM crdt_ops WHERE op_json LIKE '%503_bug_depends_on%'`
- `mcp stop_agent + remove_worktree` for the running coders ✓
- `find .huskies/work -name '503_*' -delete`
- emptying `timers.json` (multiple times — kept getting re-populated) ✓
…503 kept reappearing in `current` with new agents being spawned. The root cause: the in-memory `CRDT_STATE` (loaded from `crdt_ops` at startup at 18:19) still had 503 and 478 as live items, and a periodic code path was reading `crdt_state::read_all_items()`, seeing them as live, and triggering the auto-assign / rate-limit-retry chain.
Final resolution: `docker restart huskies` to wipe the in-memory state. Worked, but it's a sledgehammer.
## Implementation note
The bft-json-crdt library appears to support per-item delete via the `is_deleted: bool` field on each CRDT item (visible in the persisted op JSON we inspected today). Writing a delete op should look something like:
```rust
crdt_state::apply_and_persist(&mut state, |s| {
s.crdt.doc.items[idx].delete() // or whatever the BFT JSON CRDT delete API is
})
```
The op gets signed, applied to the in-memory state (marking the item deleted), and persisted to crdt_ops via the existing channel. Then `read_all_items()` should filter out `is_deleted: true` entries (it may already do this — verify in `extract_item_view`).
## Why this is distinct from bug 514 (delete_story full cleanup)
Bug 514 is about making the existing `delete_story` MCP tool do a full cleanup across all the layers we know about. **This** story is specifically about acquiring the *capability* to write a CRDT tombstone — without that, bug 514 can't be implemented correctly because it has no way to clear in-memory state. So 521 is a prerequisite for 514.
It's also a prerequisite for properly handling the fix for bug 510 (split-brain shadows) — when the reconcile pass detects a stale story, it needs a way to actually evict it. That eviction is what this story provides.
## Acceptance Criteria
- [ ] A new MCP tool (e.g. `mcp__huskies__purge_story`) is registered and callable
- [ ] The tool takes a story_id and returns a structured result indicating which layers were cleared (CRDT op, content store, timers, agents, worktree, filesystem)
- [ ] The tool writes a signed CRDT tombstone op (is_deleted: true) for the item, applies it to the in-memory CRDT, and persists it to crdt_ops
- [ ] After the tool runs, `read_all_items()` does NOT return the purged story (verify the filter handles is_deleted)
- [ ] After the tool runs, `read_content(story_id)` returns None (CONTENT_STORE entry is removed)
- [ ] After the tool runs, `timers.json` has no entries for the story
- [ ] After the tool runs, no agents are running on the story (stop_agent is called for any active ones)
- [ ] After the tool runs, the worktree at `.huskies/worktrees/{story_id}/` is removed
- [ ] After the tool runs, the filesystem shadow at `.huskies/work/*/{story_id}.md` is removed
- [ ] Idempotent: calling purge_story twice on the same story_id is safe and doesn't error
- [ ] Bug 514 (delete_story full cleanup) is updated to use this purge capability internally
- [ ] Regression test: insert a story via the normal write path, call purge_story, restart the server, verify the story is still gone (i.e. the tombstone persisted correctly)
## Out of Scope
- TBD
@@ -0,0 +1,19 @@
---
name: "Exclude git worktrees from loc command output"
---
# Story 468: Exclude git worktrees from loc command output
## User Story
As a user running the `loc` bot command, I want worktree directories (`.huskies/worktrees/`) to be excluded from the file listing so that the output only shows project source files, not duplicated code from agent worktrees.
## Acceptance Criteria
- [ ] loc command excludes files under .huskies/worktrees/ from line counts
- [ ] loc command excludes files under target/ directories from line counts
- [ ] Unit test verifies worktree paths are filtered out
## Out of Scope
- TBD
@@ -0,0 +1,28 @@
---
name: "Scaffold missing rate_limit_notifications and timezone in default project.toml"
---
# Bug 469: Scaffold missing rate_limit_notifications and timezone in default project.toml
## Description
The scaffold template for `project.toml` does not include the `rate_limit_notifications` or `timezone` fields. New projects get defaults (notifications on, no timezone), but these settings aren't visible or documented in the generated config file. Users have to discover them manually.
The 455 rename also stripped these fields from the huskies project's own `project.toml` because it was regenerated from the scaffold.
## How to Reproduce
1. Run `huskies init` on a new project
2. Check the generated `.huskies/project.toml`
## Actual Result
No `rate_limit_notifications` or `timezone` fields in generated project.toml.
## Expected Result
Both fields present with commented defaults so users know they exist.
## Acceptance Criteria
- [ ] Bug is fixed and verified
@@ -0,0 +1,20 @@
---
name: "Reduce timer tick interval to 1 second and suppress idle tick logging"
---
# Story 470: Reduce timer tick interval to 1 second and suppress idle tick logging
## User Story
As a user scheduling timers, I want the tick loop to check every 1 second instead of 30 so timers fire promptly, without flooding the logs with an entry every second when nothing is due.
## Acceptance Criteria
- [ ] Timer tick interval changed from 30 seconds to 1 second
- [ ] No log entry on idle ticks (when take_due returns empty)
- [ ] Log entry only when a timer actually fires (due list non-empty)
- [ ] Startup log line still shows number of pending timers loaded
## Out of Scope
- TBD
@@ -0,0 +1,25 @@
---
name: "Bot command to show overall test coverage"
---
# Story 471: Bot command to show overall test coverage
## User Story
As a user on any chat transport (Matrix, WhatsApp, Slack, or web UI), I want a `coverage` command that runs the test suite with coverage instrumentation and reports the overall coverage percentage, so I can track code quality from any interface.
## Acceptance Criteria
- [ ] New bot command `coverage` registered in the command registry
- [ ] Runs test coverage (e.g. cargo llvm-cov or cargo tarpaulin) and parses the overall percentage
- [ ] Reports overall line coverage percentage in chat response
- [ ] Command appears in `help` output
- [ ] Returns a clear error if the coverage tool is not installed
- [ ] Command works across all transports (Matrix, WhatsApp, Slack, web UI) via the shared command registry
- [ ] Available as both a bot command in chat and a slash command in Claude Code
- [ ] By default, reads cached coverage from .coverage_baseline for an instant response without rerunning tests
- [ ] Optional `coverage run` variant reruns script/test_coverage and reports fresh results
## Out of Scope
- TBD
@@ -0,0 +1,24 @@
---
name: "Discord chat transport"
agent: coder-opus
---
# Story 472: Discord chat transport
## User Story
As a user who uses Discord, I want to control huskies from a Discord server so I can create stories, check status, start agents, and chat with the bot from Discord like I can from Matrix.
## Acceptance Criteria
- [ ] Discord transport implements the ChatTransport trait (send_message, send_typing, etc.)
- [ ] Bot connects via Discord gateway websocket using serenity crate
- [ ] All shared bot commands (status, help, start, unblock, etc.) work from Discord
- [ ] Stage transition and block notifications are posted to configured Discord channel(s)
- [ ] LLM fallthrough works — non-command messages are forwarded to Claude Code
- [ ] Config in bot.toml: discord_token, discord_channel_ids, allowed_users
- [ ] Bot only responds when mentioned or in configured channels
## Out of Scope
- TBD
@@ -0,0 +1,26 @@
---
name: "Split Chat.tsx into smaller components"
---
# Refactor 473: Split Chat.tsx into smaller components
## Current State
- TBD
## Desired State
Chat.tsx is 1513 lines and growing. ChatInput and ChatHeader are already split out. Break up the remaining monolith into focused components — likely candidates: message list/rendering, websocket connection management, message bubbles, typing indicators, and any other distinct UI concerns.
## Acceptance Criteria
- [ ] Chat.tsx reduced to under 500 lines by extracting components
- [ ] Message list/rendering extracted into its own component
- [ ] Message bubble rendering extracted into its own component
- [ ] WebSocket connection logic extracted (hook or provider)
- [ ] All existing Chat.test.tsx tests still pass
- [ ] No visual or behavioural regressions in the chat UI
## Out of Scope
- TBD
@@ -0,0 +1,21 @@
---
name: "Per-file test coverage report with improvement targets"
---
# Story 474: Per-file test coverage report with improvement targets
## User Story
As a developer, I want a standardised JSON output format for test coverage so that any language's coverage tool can produce it and huskies can read it without language-specific logic.
## Acceptance Criteria
- [ ] Define a standard `.coverage_report.json` format: `{ "overall": float, "threshold": float, "files": [{ "path": string, "coverage": float }] }`
- [ ] Update huskies' own `script/test_coverage` to write `.coverage_report.json` in this format (Rust via `cargo llvm-cov --json`, frontend via vitest)
- [ ] `coverage` bot command reads `.coverage_report.json` and shows overall percentage plus top 5 lowest-covered files as improvement targets
- [ ] Document the `.coverage_report.json` format in `.huskies/README.md` so other projects can produce it from any language
- [ ] Huskies server has zero language-specific coverage logic — all intelligence is in the project's `script/test_coverage`
## Out of Scope
- TBD
@@ -0,0 +1,25 @@
---
name: "Deduplicate lifecycle.rs move functions into a shared parameterised helper"
---
# Refactor 475: Deduplicate lifecycle.rs move functions into a shared parameterised helper
## Current State
- TBD
## Desired State
The move_story_to_current, move_story_to_done, move_story_to_merge, move_story_to_qa, and reject_story_from_qa functions share the same pattern: build paths, check idempotency, find source file in one or more stages, rename, clear front matter fields, log. Extract a shared `move_story()` helper parameterised by source stages, target stage, and fields to clear. The named functions become thin wrappers. The existing `move_story_to_stage` function should also use this shared helper. 27 existing tests provide a safety net.
## Acceptance Criteria
- [ ] Single shared move_story helper function parameterised by source stages, target stage, and fields to clear
- [ ] All existing named move functions (move_story_to_current, move_story_to_done, move_story_to_merge, move_story_to_qa, reject_story_from_qa) become thin wrappers
- [ ] move_story_to_stage also delegates to the shared helper
- [ ] All 27 existing tests pass unchanged
- [ ] Net reduction of at least 150 lines from lifecycle.rs
## Out of Scope
- TBD
@@ -0,0 +1,26 @@
---
name: "Split agents/pool/lifecycle.rs into submodules"
agent: coder-opus
---
# Refactor 476: Split agents/pool/lifecycle.rs into submodules
## Current State
- TBD
## Desired State
pool/lifecycle.rs is 1812 lines with 4 public functions (start_agent, stop_agent, wait_for_agent, remove_agents_for_story) plus 29 tests. start_agent is by far the largest — it handles agent selection, worktree creation, process spawning, and completion callbacks. Split into submodules: start.rs (agent start + selection logic), stop.rs (stop + cleanup), wait.rs (wait_for_agent), with tests co-located in each module.
## Acceptance Criteria
- [ ] pool/lifecycle.rs split into submodules (e.g. start.rs, stop.rs, wait.rs)
- [ ] Each submodule contains its related tests
- [ ] All 29 existing tests pass unchanged
- [ ] Public API unchanged — re-export from pool/mod.rs if needed
- [ ] No functional changes, purely structural
## Out of Scope
- TBD
@@ -0,0 +1,35 @@
---
name: "Scaffold does not generate agents.toml for new projects"
---
# Bug 481: Scaffold does not copy agent definitions from project.toml to new projects
## Description
When scaffolding a new project with `huskies init`, the `[[agent]]` definitions from huskies' own `agents.toml` are not included in the new project's config. The scaffold generates a minimal `project.toml` with basic settings (default_qa, max_coders, etc.) but no `agents.toml` file at all.
Without agent definitions (prompts, system prompts, model, max_turns, budget), the agents run with no guidance — they don't know about the SDTW process, worktrees, feature branches, or the rule about not committing to master. Result: agents make changes directly in master.
The scaffold should generate a default `agents.toml` with the battle-tested agent definitions (coder, QA, mergemaster) including their prompts and system prompts so new projects get sensible agent behaviour out of the box.
Note: agent definitions were split from `project.toml` into `agents.toml` in story #482.
## How to Reproduce
1. Run `huskies init` on a new project
2. Check the generated `.huskies/project.toml`
3. Note: no `[[agent]]` blocks present
4. Start a coder agent on a story
5. Agent works directly in master with no worktree, no feature branch, no process
## Actual Result
No agent definitions in scaffolded project.toml. Agents run with defaults and make changes directly in master.
## Expected Result
Scaffolded project.toml includes default agent definitions (coder, QA, mergemaster) with prompts that enforce the SDTW process, worktrees, and feature branches.
## Acceptance Criteria
- [ ] Bug is fixed and verified
@@ -0,0 +1,26 @@
---
name: "Split agent definitions from project.toml into agents.toml"
---
# Refactor 482: Split agent definitions from project.toml into agents.toml
## Current State
- TBD
## Desired State
Move all `[[agent]]` blocks from `.huskies/project.toml` into a separate `.huskies/agents.toml`. The server loads agents from agents.toml and merges with project.toml config. Falls back to inline `[[agent]]` blocks in project.toml for backwards compatibility. The watcher should detect changes to agents.toml and hot-reload. This is a prerequisite for bug 481 (scaffold copies default agents to new projects) — agents.toml becomes the embeddable template.
## Acceptance Criteria
- [ ] All [[agent]] blocks moved from .huskies/project.toml to .huskies/agents.toml
- [ ] Server loads agent config from agents.toml, falls back to inline [[agent]] in project.toml for backwards compat
- [ ] Watcher detects agents.toml changes and triggers hot-reload
- [ ] project.toml is significantly smaller (only project settings remain)
- [ ] agents.toml is the canonical default template for scaffolding (prerequisite for bug 481)
- [ ] All existing agent functionality unchanged
## Out of Scope
- TBD
@@ -0,0 +1,35 @@
---
name: "Timer slash command not wired up in web UI"
---
# Bug 483: Timer slash command not wired up in web UI
## Description
Three async bot commands are not wired up in the web UI's `bot_command.rs` dispatch: **timer**, **htop**, and **rmtree**. They fall through to `dispatch_sync` which calls the registry stub that returns `None`, resulting in "Unknown command."
The fix: add async dispatch branches for all three in `dispatch_command`:
- `"timer" => dispatch_timer(args, project_root).await`
- `"rmtree" => dispatch_rmtree(args, project_root, agents).await`
- `"htop"` — either implement a simplified version or return a "not available in web UI" message (htop is a live dashboard designed for Matrix)
Commands already correctly dispatched: assign, start, delete, rebuild.
Reset is handled by the frontend (clears local state) — not needed server-side.
## How to Reproduce
1. Open the web UI
2. Type `/timer list` or `/timer 463 14:00`
3. See "Unknown command: /timer"
## Actual Result
Unknown command: `/timer`. Type `/help` to see available commands.
## Expected Result
Timer command works in the web UI the same as it does via Matrix.
## Acceptance Criteria
- [ ] Bug is fixed and verified
@@ -0,0 +1,26 @@
---
name: "Story dependencies in pipeline auto-assign"
---
# Story 484: Story dependencies in pipeline auto-assign
## User Story
As a user creating stories that depend on each other, I want to specify dependencies in the story front matter so dependent stories stay in backlog until their dependencies are done, then automatically move to current.
Stories with `depends_on` stay in backlog. A dependency check loop (similar to the timer tick) periodically scans backlog for stories whose dependencies have all reached done/archived. When all deps are met, the story is moved to current and the normal auto-assign picks it up — ensuring the worktree is created from post-dependency master.
## Acceptance Criteria
- [ ] New optional `depends_on` field in story front matter accepts a list of story numbers (e.g. `depends_on: [477, 478]`)
- [ ] Stories with unmet dependencies stay in **backlog**, not current
- [ ] A dependency check loop (similar to the timer tick loop) periodically scans backlog for stories whose `depends_on` stories have all reached done or archived
- [ ] When all deps are met, the loop moves the story from backlog to current — the normal auto-assign then picks it up with a worktree based on post-dependency master
- [ ] Status command shows dependency info for stories waiting on deps
- [ ] Stories with no depends_on field behave as before (no change)
- [ ] Bot command `depends <number> <dep1> [dep2...]` to set dependencies from chat (all transports) and web UI slash command
- [ ] Command wired up in bot_command.rs dispatch for web UI and registered in shared command registry for all chat transports
## Out of Scope
- TBD
@@ -0,0 +1,27 @@
---
name: "Documentation site for huskies.dev"
---
# Story 485: Documentation site for huskies.dev
## User Story
As a new user discovering huskies, I want documentation on huskies.dev so I can learn how to set up and use the tool without having to read the source code.
## Acceptance Criteria
- [ ] Docs site lives under website/ directory alongside the existing landing page
- [ ] Accessible from huskies.dev (e.g. huskies.dev/docs/ or docs.huskies.dev)
- [ ] Navigation link added to the main site header
- [ ] Getting started / quickstart guide (Docker setup, first story, first agent run)
- [ ] Configuration reference: project.toml, agents.toml, bot.toml
- [ ] Bot commands reference (auto-generated from command registry or manually maintained)
- [ ] Pipeline stages explained (backlog, current, QA, merge, done)
- [ ] Chat transports guide (Matrix, WhatsApp, Slack, web UI)
- [ ] CLI reference (huskies, huskies init, huskies agent)
- [ ] Matches the visual style of the existing landing page
- [ ] Static HTML — no build step required
## Out of Scope
- TBD
@@ -0,0 +1,17 @@
---
name: "Display story dependencies in web UI and chat commands"
---
# Story 487: Display story dependencies in web UI and chat commands
## User Story
As a user managing stories with dependencies, I want to see dependency information in the web UI story panel and in chat/slash command output, so I can understand which stories are blocked and why without reading the raw markdown files.
## Acceptance Criteria
- [ ] TODO
## Out of Scope
- TBD
@@ -0,0 +1,26 @@
---
name: "SQLite shadow write for pipeline state via sqlx"
agent: "coder-opus"
---
# Story 489: SQLite shadow write for pipeline state via sqlx
## User Story
As a developer, I want pipeline state dual-written to SQLite (via sqlx) alongside the existing filesystem directories, so we have a database layer ready for CRDT integration without changing any existing behaviour.
## Acceptance Criteria
- [ ] Add sqlx with SQLite feature as a dependency
- [ ] Migrations embedded at compile time via sqlx::migrate! macro, run on startup
- [ ] Schema uses backend-agnostic SQL (TEXT, INTEGER, no vendor-specific types) so migrations work on both SQLite and Postgres
- [ ] Every move_story_to_X and pipeline state change writes to both .huskies/work/ directories AND SQLite
- [ ] Reads still come from the filesystem (SQLite is shadow-only)
- [ ] SQLite database stored at .huskies/pipeline.db
- [ ] pipeline_items table: id, name, stage, agent, retry_count, blocked, depends_on, created_at, updated_at
- [ ] All existing pipeline operations work unchanged from the user's perspective
- [ ] agent: coder-opus
## Out of Scope
- TBD
@@ -0,0 +1,26 @@
---
name: "CRDT state layer backed by SQLite"
agent: coder-opus
depends_on: [489]
---
# Story 490: CRDT state layer backed by SQLite
## User Story
As a developer, I want the BFT JSON CRDT document backed by SQLite for persistence, so CRDT ops survive restarts and the state layer is ready for multi-node sync.
## Acceptance Criteria
- [ ] BFT CRDT crate (crates/bft-json-crdt/) integrated into the server
- [ ] CRDT ops persisted to SQLite via sqlx (backend-agnostic schema)
- [ ] Pipeline state reads switch from filesystem to CRDT document
- [ ] Pipeline state writes go through CRDT ops (which persist to SQLite)
- [ ] Filesystem .huskies/work/ directories still updated as a secondary output for backwards compat during transition
- [ ] CRDT state reconstructed from SQLite on startup
- [ ] agent: coder-opus
- [ ] depends_on: [489]
## Out of Scope
- TBD
@@ -0,0 +1,25 @@
---
name: "Watcher fires on CRDT state transitions instead of filesystem events"
agent: coder-opus
depends_on: [490]
---
# Story 491: Watcher fires on CRDT state transitions instead of filesystem events
## User Story
As a developer, I want the auto-assign loop, notifications, and WebSocket UI updates to subscribe to CRDT state transitions instead of filesystem directory watches.
## Acceptance Criteria
- [ ] Auto-assign triggers on CRDT state change (story entering a stage) instead of filesystem watcher
- [ ] Stage transition notifications fire from CRDT state changes
- [ ] WebSocket UI updates fire from CRDT state changes
- [ ] The filesystem watcher for .huskies/work/ is removed or deprecated
- [ ] Timer tick loop and dependency tick loop read from CRDT state
- [ ] agent: coder-opus
- [ ] depends_on: [490]
## Out of Scope
- TBD
@@ -0,0 +1,25 @@
---
name: "Remove filesystem pipeline state and store story content in database"
agent: coder-opus
depends_on: [491]
---
# Story 492: Remove filesystem pipeline state and store story content in database
## User Story
As a developer, I want to remove the .huskies/work/ stage directories entirely and store story content (markdown, front matter) in the database, so the CRDT + SQLite is the sole source of truth.
## Acceptance Criteria
- [ ] Story markdown content and front matter stored in SQLite (backend-agnostic schema)
- [ ] .huskies/work/ stage directories no longer used for pipeline state or story storage
- [ ] All pipeline operations (create, move, read, delete stories) work against the database
- [ ] Migration path for existing projects: on startup, import any .huskies/work/ stories into the DB and archive the directories
- [ ] Worktrees and config files (project.toml, agents.toml, bot.toml) remain on filesystem
- [ ] agent: coder-opus
- [ ] depends_on: [491]
## Out of Scope
- TBD
@@ -0,0 +1,34 @@
---
name: "Story dependency chain not firing due to front matter format issues"
---
# Bug 493: Story dependency chain not firing due to front matter format issues
## Description
Two issues prevent the dependency tick loop from working:
1. **create_story puts depends_on in AC text, not front matter**: When `depends_on: [489]` is passed as an acceptance criterion string, it ends up as a checkbox item (`- [ ] depends_on: [489]`) instead of YAML front matter. Stories 490, 491, 492 are affected.
2. **update_story stores depends_on as a quoted string instead of YAML array**: The `front_matter` parameter serializes `[490]` as the string `"[490]"` instead of the YAML array `[490]`. Stories 478, 479, 480 are affected — their front matter shows `depends_on: "[490]"` instead of `depends_on: [490]`.
The dependency tick loop reads `depends_on` from YAML front matter and expects an integer array. Neither format matches.
## How to Reproduce
1. Create a story with `depends_on: [489]` in acceptance criteria
2. Or use update_story with `front_matter: {"depends_on": "[490]"}`
3. Check the generated front matter
4. Observe dependency tick loop does not promote the story
## Actual Result
depends_on either missing from front matter (in AC text as checkbox) or stored as quoted string instead of YAML array.
## Expected Result
depends_on stored as a proper YAML array in front matter: `depends_on: [489]`
## Acceptance Criteria
- [ ] Bug is fixed and verified
@@ -0,0 +1,21 @@
---
name: "MCP tool to run project test suite"
---
# Story 494: MCP tool to run project test suite
## User Story
As an LLM agent or web UI user, I want an MCP tool that runs the project's test suite so I can verify code changes without shelling out to Bash.
## Acceptance Criteria
- [ ] New MCP tool `run_tests` that executes `script/test` and returns pass/fail with output
- [ ] Available as a bot command (`test`) in all chat transports
- [ ] Available as a slash command (`/test`) in the web UI
- [ ] Returns structured result: pass/fail, test count, and truncated output for failures
- [ ] Runs in the project root (or optionally in a specified worktree path)
## Out of Scope
- TBD
@@ -0,0 +1,34 @@
---
name: "Status traffic light dots use unsupported HTML colouring - switch to emoji"
---
# Bug 495: Status traffic light dots use unsupported HTML colouring - switch to emoji
## Description
The status command uses Unicode dots (●, ◑, ✗, ○) with `<font data-mx-color>` HTML tags for colouring. Element X (and most modern Matrix clients) doesn't support inline text colouring via any HTML method — not `data-mx-color`, not `style="color:"`, nothing.
Switch to coloured emoji which render natively in all clients:
- 🟢 running normally (was ● green)
- 🟠 throttled/rate limited (was ◑ orange)
- 🔴 blocked (was ✗ red)
- ⚪ idle / no agent (was ○ grey)
Remove the `build_pipeline_status_html` colour-wrapping logic since it's dead code with emoji.
## How to Reproduce
1. Run `@timmy status` in Element X
2. Observe dots are not coloured
## Actual Result
Plain uncoloured Unicode dots.
## Expected Result
Coloured indicators visible in all Matrix clients.
## Acceptance Criteria
- [ ] Bug is fixed and verified
@@ -0,0 +1,34 @@
---
name: "Hard rate limit without reset_at never auto-schedules retry"
---
# Bug 496: Hard rate limit without reset_at never auto-schedules retry
## Description
When the API returns a hard rate limit block (`status=rejected`) without a `reset_at` timestamp, `pty.rs` downgrades it to a `RateLimitWarning` instead of a `RateLimitHardBlock`. The auto-scheduler only listens for `RateLimitHardBlock` events, so no timer is set and the agent is never restarted. The agent sits idle until the 300s inactivity timeout kills it, and the story is stuck.
In practice, most hard blocks come without `reset_at` (as seen in the logs: "no reset_at in rate_limit_info"). This means the auto-resume feature from story 423 almost never fires.
Fix: when there's a hard block without `reset_at`, either:
1. Send `RateLimitHardBlock` with a default backoff time (e.g. `Utc::now() + 5 minutes`)
2. Or add a separate retry mechanism that doesn't depend on knowing the exact reset time
## How to Reproduce
1. Run an agent that hits the API rate limit
2. Observe logs show "no reset_at in rate_limit_info"
3. Agent gets killed by inactivity timeout
4. Story sits in current with no agent, never restarted
## Actual Result
Hard block without reset_at is downgraded to RateLimitWarning. No timer set. Agent dies and story is stuck.
## Expected Result
Hard block without reset_at triggers a retry with a default backoff (e.g. 5 minutes). Agent is automatically restarted when the backoff expires.
## Acceptance Criteria
- [ ] Bug is fixed and verified
@@ -0,0 +1,32 @@
---
name: "Dependency promotion loop missing — stories with met deps never move from backlog to current"
---
# Bug 497: Dependency promotion loop missing — stories with met deps never move from backlog to current
## Description
Story 484 implemented dependency checking in auto-assign (skip stories with unmet deps) but did NOT implement the backlog-to-current promotion loop. Stories with `depends_on` that have all deps met sit in backlog forever — nothing moves them to current.
The AC for 484 specified: "A dependency check loop (similar to the timer tick loop) periodically scans backlog for stories whose depends_on stories have all reached done or archived. When all deps are met, the loop moves the story from backlog to current."
This loop was never built. Need a periodic tick (like the timer tick) that scans backlog, checks `depends_on` against done/archived, and promotes ready stories to current.
## How to Reproduce
1. Create story A with no deps, create story B with `depends_on: [A]`
2. Both in backlog
3. Move A to current, let it complete through to done
4. Observe B stays in backlog forever
## Actual Result
Story B stays in backlog despite all deps being met.
## Expected Result
Story B is automatically promoted from backlog to current once story A reaches done.
## Acceptance Criteria
- [ ] Bug is fixed and verified
@@ -0,0 +1,20 @@
---
name: "Web UI shows project name in browser tab with huskies favicon"
---
# Story 499: Web UI shows project name in browser tab with huskies favicon
## User Story
As a user running huskies on multiple projects, I want the browser tab to show the project name instead of hardcoded "Huskies", and I want a huskies favicon, so I can distinguish tabs and have proper branding.
## Acceptance Criteria
- [ ] Browser tab title shows the project folder name when a project is open (e.g. 'reclaimer | Huskies')
- [ ] Browser tab title shows 'Huskies' when no project is open
- [ ] A huskies-themed SVG favicon is served and shown in the browser tab
- [ ] The Vite default favicon is replaced by the huskies favicon
## Out of Scope
- TBD
Generated
+1807 -114
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["server"] members = ["server", "crates/bft-json-crdt"]
resolver = "3" resolver = "3"
[workspace.dependencies] [workspace.dependencies]
@@ -29,7 +29,7 @@ tempfile = "3"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
toml = "1.1.0" toml = "1.1.0"
uuid = { version = "1.22.0", features = ["v4", "serde"] } uuid = { version = "1.22.0", features = ["v4", "serde"] }
tokio-tungstenite = "0.29.0" tokio-tungstenite = { version = "0.29.0", features = ["connect", "rustls-tls-native-roots"] }
walkdir = "2.5.0" walkdir = "2.5.0"
filetime = "0.2" filetime = "0.2"
matrix-sdk = { version = "0.16.0", default-features = false, features = [ matrix-sdk = { version = "0.16.0", default-features = false, features = [
@@ -42,3 +42,9 @@ pulldown-cmark = { version = "0.13.3", default-features = false, features = [
] } ] }
regex = "1" regex = "1"
libc = "0.2" libc = "0.2"
sqlx = { version = "=0.9.0-alpha.1", default-features = false, features = [
"runtime-tokio",
"sqlite",
"macros",
"migrate",
] }
+145 -10
View File
@@ -1,6 +1,6 @@
# Huskies # Huskies
A story-driven development server that manages work items, spawns coding agents, and runs them through a pipeline from backlog to done. Ships as a single Rust binary with an embedded React frontend. A story-driven development server that manages work items, spawns coding agents, and runs them through a pipeline from backlog to done. Ships as a single Rust binary with an embedded React frontend. Can also be run in WhatsApp, Matrix, and Slack chats.
## Getting started with Claude Code ## Getting started with Claude Code
@@ -14,16 +14,14 @@ huskies init --port 3000
This creates a `.huskies/` directory with the pipeline structure, `project.toml`, and `.mcp.json`. The `.mcp.json` file lets Claude Code discover huskies' MCP tools automatically. This creates a `.huskies/` directory with the pipeline structure, `project.toml`, and `.mcp.json`. The `.mcp.json` file lets Claude Code discover huskies' MCP tools automatically.
3. Open a Claude Code session in the same project directory. Claude will pick up the MCP tools from `.mcp.json`. Huskies also ships an embedded React frontend. Once the server is running, open `http://localhost:3000` to see the pipeline board, agent status, and chat interface.
3. Open a Claude Code session in the same project directory, or visit http://localhost:3000/.
4. Tell Claude: "help me set up this project with huskies." Claude will walk you through the setup wizard — generating project context, tech stack docs, and test/release scripts. Review each step and confirm or ask to retry. 4. Tell Claude: "help me set up this project with huskies." Claude will walk you through the setup wizard — generating project context, tech stack docs, and test/release scripts. Review each step and confirm or ask to retry.
Once setup is complete, Claude can create stories, start agents, check status, and manage the full pipeline via MCP tools — no commands to memorize. Once setup is complete, Claude can create stories, start agents, check status, and manage the full pipeline via MCP tools — no commands to memorize.
## Web UI
Huskies also ships an embedded React frontend. Once the server is running, open `http://localhost:3000` to see the pipeline board, agent status, and chat interface.
## Chat transports ## Chat transports
Huskies can be controlled via bot commands in **Matrix**, **WhatsApp**, and **Slack**. Configure a transport in `.huskies/bot.toml` — see the example files: Huskies can be controlled via bot commands in **Matrix**, **WhatsApp**, and **Slack**. Configure a transport in `.huskies/bot.toml` — see the example files:
@@ -33,12 +31,14 @@ Huskies can be controlled via bot commands in **Matrix**, **WhatsApp**, and **Sl
- `.huskies/bot.toml.whatsapp-twilio.example` - `.huskies/bot.toml.whatsapp-twilio.example`
- `.huskies/bot.toml.slack.example` - `.huskies/bot.toml.slack.example`
## Prerequisites ## Prerequisites for building
- Rust (2024 edition) - Rust (2024 edition)
- Node.js and npm - Node.js and npm
- Docker (for Linux cross-compilation and container deployment) - Docker (for Linux cross-compilation and container deployment)
- `cross` (`cargo install cross`) for Linux static builds - `cross` (`cargo install cross`) optional, for Linux static builds. Only needed if you are building for a different architecture, e.g. if you want to build a Linux binary from a Mac.
You only need these installed if you want to build Huskies yourself. Alternately, you can just download and run the `huskies` binary for your system from https://code.crashlabs.io/crashlabs/huskies/releases
## Building for production ## Building for production
@@ -57,7 +57,11 @@ cross build --release --target x86_64-unknown-linux-musl
Docker: Docker:
```bash ```bash
docker compose -f docker/docker-compose.yml build script/docker_rebuild
# or
script/docker_restart
``` ```
## Running in development ## Running in development
@@ -85,6 +89,137 @@ script/release 0.7.1
This bumps version in `Cargo.toml` and `package.json`, builds macOS arm64 and Linux amd64 binaries, tags the repo, and publishes a Gitea release with changelog and binaries attached. This bumps version in `Cargo.toml` and `package.json`, builds macOS arm64 and Linux amd64 binaries, tags the repo, and publishes a Gitea release with changelog and binaries attached.
## License ## Multi-node CRDT sync (rendezvous)
Huskies nodes can replicate pipeline state in real-time over WebSocket. Add a
`rendezvous` field to `.huskies/project.toml` to configure a peer:
```toml
rendezvous = "ws://other-host:3001/crdt-sync"
```
On startup, this node opens an outbound WebSocket connection to the configured
URL and exchanges CRDT ops bidirectionally. The connection is fully symmetric:
both sides send a bulk state dump on connect, then stream individual ops as they
are applied locally.
### Reconnect behaviour
If the peer is unreachable on startup (or the connection drops mid-session), the
client retries with exponential backoff starting at 1 s and capping at 30 s.
Failures are logged at **WARN**; after 10 consecutive failures the level escalates
to **ERROR** to surface persistent connectivity problems.
### Deployment topologies
**Peer-to-peer (two nodes pointing at each other):**
```
Node A ←→ Node B
```
Configure each node with the other's `/crdt-sync` URL. Both nodes exchange ops
directly. Supported by this story — ops propagate in both directions and both
nodes converge to the same state. Works well for two machines collaborating on
the same project.
**Hub-and-spoke (many clients → one central rendezvous):**
```
Client 1 ──┐
Client 2 ──┤── Hub node
Client 3 ──┘
```
Point multiple client nodes at a single "hub" node. The hub receives ops from
all clients and re-broadcasts them. Clients do *not* connect to each other —
convergence is mediated through the hub. The hub itself runs a normal huskies
instance with `rendezvous` unset (it only accepts inbound connections).
> **Caveat:** Hub-to-client relay depends on the hub's `/crdt-sync` inbound
> WebSocket handler re-broadcasting every received op to all other connected
> peers. That broadcast happens automatically via the shared `SYNC_TX` channel
> (each locally-applied remote op is re-emitted), so hub-and-spoke works today
> but has not been load-tested. Follow-up work may be needed for large fan-out
> (many spoke clients) to avoid broadcast-channel lag.
## Startup reconcile pass
On startup, after CRDT replay and database initialisation, huskies runs a
**reconcile pass** that compares pipeline state across three sources:
1. **In-memory CRDT** — the primary source of truth, reconstructed from
`crdt_ops` on startup.
2. **`pipeline_items` table** — a shadow/materialised view written alongside
CRDT updates, used for fast DB queries.
3. **Filesystem shadows** (`.huskies/work/N_stage/*.md`) — legacy rendering
still written by some paths and read by agent worktrees.
Any disagreement between these sources is **drift**. The reconcile pass logs a
structured line for each drifted item:
```
[reconcile] DRIFT story=X crdt_stage=Y db_stage=Z fs_stage=W
```
(`MISSING` is used where a source has no record for that story.)
### Drift types
| Type | Meaning |
|------|---------|
| `CRDT-only` | Story present in CRDT but absent from `pipeline_items` |
| `DB-only` | Story present in `pipeline_items` but absent from CRDT |
| `FS-only` | Story on the filesystem but absent from both CRDT and DB |
| `stage-mismatch` | Story present in both CRDT and DB but with different stage values |
Note: a filesystem shadow that lags behind the CRDT/DB stage (both of which
agree) is **not** treated as drift — the FS is a best-effort rendering and is
allowed to lag.
If any drift is detected, the Matrix/Slack/WhatsApp bot startup announcement
includes a count and a suggestion to check the server logs.
### Opt-out
Set `reconcile_on_startup = false` in `.huskies/project.toml` to disable the
pass during the migration window if it produces noise.
## Debugging
### Inspecting the in-memory CRDT state
When diagnosing state issues, use the `dump_crdt` MCP tool or the `/debug/crdt` HTTP endpoint to inspect the raw in-memory CRDT state directly. These surfaces show the ground truth that the running server holds — not a summarised pipeline view and not the persisted SQLite ops.
**MCP tool** (from Claude Code or any MCP client):
```
mcp__huskies__dump_crdt
# dump everything
{}
# restrict to a single item
{"story_id": "42_story_my_feature"}
```
**HTTP endpoint** (browser or curl):
```bash
# dump everything
curl http://localhost:3001/debug/crdt
# restrict to a single item
curl "http://localhost:3001/debug/crdt?story_id=42_story_my_feature"
```
Both return a JSON document with:
- **`metadata`** — `in_memory_state_loaded`, `total_items`, `total_ops_in_list`, `max_seq_in_list`, `persisted_ops_count`, `pending_persist_ops_count`
- **`items`** — one entry per CRDT list item (including tombstoned/deleted entries), each with `story_id`, `stage`, `name`, `agent`, `retry_count`, `blocked`, `depends_on`, `content_index` (hex OpId for cross-referencing with `crdt_ops`), and `is_deleted`
> **This is a debug tool.** For normal pipeline introspection use `get_pipeline_status` or `GET /api/pipeline` instead.
## Source Map
See `.huskies/specs/tech/STACK.md` for the full source map.
GPL-3.0. See [LICENSE](LICENSE). GPL-3.0. See [LICENSE](LICENSE).
+2
View File
@@ -0,0 +1,2 @@
*.js linguist-generated
*.json linguist-generated
+1
View File
@@ -0,0 +1 @@
target
+1924
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "bft-json-crdt"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["lib"]
[features]
default = ["bft", "logging-list", "logging-json"]
logging-list = ["logging-base"]
logging-json = ["logging-base"]
logging-base = []
bft = []
[dependencies]
bft-crdt-derive = { path = "bft-crdt-derive" }
colored = "2.0.0"
fastcrypto = "0.1.8"
indexmap = { version = "2.2.6", features = ["serde"] }
rand = "0.8.5"
random_color = "0.6.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0.85", features = ["preserve_order"] }
serde_with = "3.8.1"
sha2 = "0.10.6"
[dev-dependencies]
criterion = { version = "0.4", features = ["html_reports"] }
time = "0.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0.85", features = ["preserve_order"] }
[[bench]]
name = "speed"
harness = false
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Jacky Zhao
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+58
View File
@@ -0,0 +1,58 @@
# Byzantine Fault Tolerant CRDTs
This work is mainly inspired by implementing Martin Kleppmann's 2022 paper on *Making CRDTs Byzantine Fault Tolerant*[^2]
on top of a simplified [Automerge](https://automerge.org/) implementation.
The goal is to show a working prototype that demonstrated in simple code the ideas behind
1. An Automerge-like CRDT
2. How a primitive list CRDT can be composed to create complex CRDTs like JSON
2. How to add Byzantine Fault Tolerance to arbitrary CRDTs
Unlike most other CRDT implementations, I leave out many performance optimizations that would make the basic algorithm harder to understand.
Check out the [accompanying blog post for this project!](https://jzhao.xyz/posts/bft-json-crdt)
## Benchmarks
Although this implementation does not optimize for performance, it still nonetheless performs quite well.
Benchmarking happened on a 2019 MacBook Pro with a 2.6GHz i7.
Numbers are compared to Automerge which report their performance benchmarks [here](https://github.com/automerge/automerge-perf)
| # Ops | Raw String (JS) | Ours (basic) | Ours (BFT) | Automerge (JS) | Automerge (Rust) |
|--|--|--|--|--|--|
|10k | n/a | 0.081s | 1.793s | 1.6s | 0.047s |
|100k | n/a | 9.321s | 38.842s | 43.0s | 0.597s |
|All (259k)| 0.61s | 88.610s | 334.960s | Out of Memory| 1.780s |
|Memory | 0.1MB | 27.6MB | 59.5MB | 880MB | 232.5MB |
## Flamegraph
To get some flamegraphs of the time graph on MacOS, run:
```bash
sudo cargo flamegraph --dev --root --bench speed
```
## Further Work
This is mostly a learning/instructional project but there are a few places where performance improvements are obvious:
1. This is backed by `std::Vec` which isn't great for random insert. Replace with a B-tree or something that provides better insert and find performance
1. [Diamond Types](https://github.com/josephg/diamond-types) and [Automerge (Rust)](https://github.com/automerge/automerge-rs) use a B-tree
2. Yjs is backed by a doubly linked-list and caches last ~5-10 accessed locations (assumes that most edits happen sequentially; seeks are rare)
3. (funnily enough, main performance hit is dominated by find and not insert, see [this flamegraph](./flamegraphs/flamegraph_unoptimized.svg))
2. Avoid calling `find` so many times. A few Automerge optimizations that were not implemented
1. Use an index hint (especially for local inserts)
2. Skipping the second `find` operation in `integrate` if sequence number is already larger
3. Improve storage requirement. As of now, a single `Op` weighs in at *over* 168 bytes. This doesn't even fit in a single cache line!
4. Implement 'transactions' for a group of changes that should be considered atomic.
1. This would also speed up Ed25519 signature verification time by batching.
2. For example, a peer might create an atomic 'transaction' that contains a bunch of changes.
5. Currently, each character is a single op. Similar to Yjs, we can combine runs of characters into larger entities like what André, Luc, et al.[^1] suggest
6. Implement proper persistence using SQLLite or something similar
7. Compile the project to WASM and implement a transport layer so it can be used in browser. Something similar to [Yjs' WebRTC Connector](https://github.com/yjs/y-webrtc) could work.
[^1]: André, Luc, et al. "Supporting adaptable granularity of changes for massive-scale collaborative editing." 9th IEEE International Conference on Collaborative Computing: Networking, Applications and Worksharing. IEEE, 2013.
[^2]: Kleppmann, Martin. "Making CRDTs Byzantine Fault Tolerant." Proceedings of the 9th Workshop on Principles and Practice of Consistency for Distributed Data. 2022.
## Acknowledgements
Thank you to [Nalin Bhardwaj](https://nibnalin.me/) for helping me with my cryptography questions and [Martin Kleppmann](https://martin.kleppmann.com/)
for his teaching materials and lectures which taught me a significant portion of what I've learned about distributed systems and CRDTs.
+67
View File
@@ -0,0 +1,67 @@
use bft_json_crdt::{
json_crdt::JsonValue, keypair::make_author, list_crdt::ListCrdt, op::Op, op::ROOT_ID,
};
use criterion::{criterion_group, criterion_main, Criterion};
use rand::seq::SliceRandom;
fn bench_insert_100_root(c: &mut Criterion) {
c.bench_function("bench insert 100 root", |b| {
b.iter(|| {
let mut list = ListCrdt::<i64>::new(make_author(1), vec![]);
for i in 0..100 {
list.insert(ROOT_ID, i);
}
})
});
}
fn bench_insert_100_linear(c: &mut Criterion) {
c.bench_function("bench insert 100 linear", |b| {
b.iter(|| {
let mut list = ListCrdt::<i64>::new(make_author(1), vec![]);
let mut prev = ROOT_ID;
for i in 0..100 {
let op = list.insert(prev, i);
prev = op.id;
}
})
});
}
fn bench_insert_many_agents_conflicts(c: &mut Criterion) {
c.bench_function("bench insert many agents conflicts", |b| {
b.iter(|| {
const N: u8 = 10;
let mut rng = rand::thread_rng();
let mut crdts: Vec<ListCrdt<i64>> = Vec::with_capacity(N as usize);
let mut logs: Vec<Op<JsonValue>> = Vec::new();
for i in 0..N {
let list = ListCrdt::new(make_author(i), vec![]);
crdts.push(list);
for _ in 0..5 {
let op = crdts[i as usize].insert(ROOT_ID, i as i32);
logs.push(op);
}
}
logs.shuffle(&mut rng);
for op in logs {
for c in &mut crdts {
if op.author() != c.our_id {
c.apply(op.clone());
}
}
}
assert!(crdts.windows(2).all(|w| w[0].view() == w[1].view()));
})
});
}
criterion_group!(
benches,
bench_insert_100_root,
bench_insert_100_linear,
bench_insert_many_agents_conflicts
);
criterion_main!(benches);
+100
View File
@@ -0,0 +1,100 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "bft-crdt-derive"
version = "0.1.0"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "once_cell"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860"
[[package]]
name = "proc-macro-crate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eda0fc3b0fb7c975631757e14d9049da17374063edb6ebbcbc54d880d4fe94e9"
dependencies = [
"once_cell",
"thiserror",
"toml",
]
[[package]]
name = "proc-macro2"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ea3d908b0e36316caf9e9e2c4625cdde190a7e6f440d794667ed17a1855e725"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.147"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965"
[[package]]
name = "syn"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a864042229133ada95abf3b54fdc62ef5ccabe9515b64717bcb9a1919e59445d"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10deb33631e3c9018b9baf9dcbbc4f737320d2b576bac10f6aefa048fa407e3e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "982d17546b47146b28f7c22e3d08465f6b8903d0ea13c1660d9d84a6e7adcdbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "toml"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7"
dependencies = [
"serde",
]
[[package]]
name = "unicode-ident"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3"
@@ -0,0 +1,15 @@
[package]
name = "bft-crdt-derive"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
proc-macro = true
[dependencies]
indexmap = { version = "2.2.6", features = ["serde"] }
proc-macro2 = "1.0.47"
proc-macro-crate = "1.2.1"
quote = "1.0.21"
syn = { version = "1.0.103", features = ["full"] }
@@ -0,0 +1,198 @@
use proc_macro::TokenStream as OgTokenStream;
use proc_macro2::{Ident, Span, TokenStream};
use proc_macro_crate::{crate_name, FoundCrate};
use quote::{quote, quote_spanned, ToTokens};
use syn::{
parse::{self, Parser},
parse_macro_input,
spanned::Spanned,
Data, DeriveInput, Field, Fields, ItemStruct, LitStr, Type,
};
/// Helper to get tokenstream representing the parent crate
fn get_crate_name() -> TokenStream {
let cr8 = crate_name("bft-json-bft-crdt").unwrap_or(FoundCrate::Itself);
match cr8 {
FoundCrate::Itself => quote! { ::bft_json_crdt },
FoundCrate::Name(name) => {
let ident = Ident::new(&name, Span::call_site());
quote! { ::#ident }
}
}
}
/// Proc macro to insert a keypair and path field on a given struct
#[proc_macro_attribute]
pub fn add_crdt_fields(args: OgTokenStream, input: OgTokenStream) -> OgTokenStream {
let mut input = parse_macro_input!(input as ItemStruct);
let crate_name = get_crate_name();
let _ = parse_macro_input!(args as parse::Nothing);
if let syn::Fields::Named(ref mut fields) = input.fields {
fields.named.push(
Field::parse_named
.parse2(quote! { path: Vec<#crate_name::op::PathSegment> })
.unwrap(),
);
fields.named.push(
Field::parse_named
.parse2(quote! { id: #crate_name::keypair::AuthorId })
.unwrap(),
);
}
quote! {
#input
}
.into()
}
/// Proc macro to automatically derive the CRDTNode trait
#[proc_macro_derive(CrdtNode)]
pub fn derive_json_crdt(input: OgTokenStream) -> OgTokenStream {
// parse the input tokens into a syntax tree
let input = parse_macro_input!(input as DeriveInput);
let crate_name = get_crate_name();
// used in the quasi-quotation below as `#name`
let ident = input.ident;
let ident_str = LitStr::new(&ident.to_string(), ident.span());
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
match input.data {
Data::Struct(data) => match &data.fields {
Fields::Named(fields) => {
let mut field_impls = vec![];
let mut ident_literals = vec![];
let mut ident_strings = vec![];
let mut tys = vec![];
// parse all named fields
for field in &fields.named {
let ident = field.ident.as_ref().expect("Failed to get struct field identifier");
if ident != "path" && ident != "id" {
let ty = match &field.ty {
Type::Path(t) => t.to_token_stream(),
_ => return quote_spanned! { field.span() => compile_error!("Field should be a primitive or struct which implements CRDTNode") }.into(),
};
let str_literal = LitStr::new(&ident.to_string(), ident.span());
ident_strings.push(str_literal.clone());
ident_literals.push(ident.clone());
tys.push(ty.clone());
field_impls.push(quote! {
#ident: <#ty as CrdtNode>::new(
id,
#crate_name::op::join_path(path.clone(), #crate_name::op::PathSegment::Field(#str_literal.to_string()))
)
});
}
}
let expanded = quote! {
impl #impl_generics #crate_name::json_crdt::CrdtNodeFromValue for #ident #ty_generics #where_clause {
fn node_from(value: #crate_name::json_crdt::JsonValue, id: #crate_name::keypair::AuthorId, path: Vec<#crate_name::op::PathSegment>) -> Result<Self, String> {
if let #crate_name::json_crdt::JsonValue::Object(mut obj) = value {
Ok(#ident {
path: path.clone(),
id,
#(#ident_literals: if let Some(val) = obj.remove(#ident_strings) {
val.into_node(
id,
#crate_name::op::join_path(path.clone(), #crate_name::op::PathSegment::Field(#ident_strings.to_string()))
)
.unwrap()
} else {
<#tys as #crate_name::json_crdt::CrdtNode>::new(
id,
#crate_name::op::join_path(path.clone(), #crate_name::op::PathSegment::Field(#ident_strings.to_string()))
)
}),*
})
} else {
Err(format!("failed to convert {:?} -> {}<T>", value, #ident_str.to_string()))
}
}
}
// I'm pulling this out so that we can see actual CRD content in debug output.
//
// The plan is to mostly get rid of the macros anyway, so it's a reasonable first step.
// It could (alternately) be just as good to keep the macros and change this function to
// output actual field content instead of just field names.
//
// impl #impl_generics std::fmt::Debug for #ident #ty_generics #where_clause {
// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// let mut fields = Vec::new();
// #(fields.push(format!("{}", #ident_strings.to_string()));)*
// write!(f, "{{ {:?} }}", fields.join(", "))
// }
// }
impl #impl_generics #crate_name::json_crdt::CrdtNode for #ident #ty_generics #where_clause {
fn apply(&mut self, op: #crate_name::op::Op<#crate_name::json_crdt::JsonValue>) -> #crate_name::json_crdt::OpState {
let path = op.path.clone();
let author = op.id.clone();
if !#crate_name::op::ensure_subpath(&self.path, &op.path) {
#crate_name::debug::debug_path_mismatch(self.path.to_owned(), op.path);
return #crate_name::json_crdt::OpState::ErrPathMismatch;
}
if self.path.len() == op.path.len() {
return #crate_name::json_crdt::OpState::ErrApplyOnStruct;
} else {
let idx = self.path.len();
if let #crate_name::op::PathSegment::Field(path_seg) = &op.path[idx] {
match &path_seg[..] {
#(#ident_strings => {
return self.#ident_literals.apply(op.into());
}),*
_ => {},
};
};
return #crate_name::json_crdt::OpState::ErrPathMismatch
}
}
fn view(&self) -> #crate_name::json_crdt::JsonValue {
let mut view_map = indexmap::IndexMap::new();
#(view_map.insert(#ident_strings.to_string(), self.#ident_literals.view().into());)*
#crate_name::json_crdt::JsonValue::Object(view_map)
}
fn new(id: #crate_name::keypair::AuthorId, path: Vec<#crate_name::op::PathSegment>) -> Self {
Self {
path: path.clone(),
id,
#(#field_impls),*
}
}
}
impl #crate_name::debug::DebugView for #ident {
#[cfg(feature = "logging-base")]
fn debug_view(&self, indent: usize) -> String {
let inner_spacing = " ".repeat(indent + 2);
let path_str = #crate_name::op::print_path(self.path.clone());
let mut inner = vec![];
#(inner.push(format!("{}\"{}\": {}", inner_spacing, #ident_strings, self.#ident_literals.debug_view(indent + 4)));)*
let inner_str = inner.join("\n");
format!("{} @ /{}\n{}", #ident_str, path_str, inner_str)
}
#[cfg(not(feature = "logging-base"))]
fn debug_view(&self, _indent: usize) -> String {
"".to_string()
}
}
};
// Hand the output tokens back to the compiler
expanded.into()
}
_ => {
quote_spanned! { ident.span() => compile_error!("Cannot derive CRDT on tuple or unit structs"); }
.into()
}
},
_ => quote_spanned! { ident.span() => compile_error!("Cannot derive CRDT on enums or unions"); }.into(),
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 502 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 300 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 80 KiB

+317
View File
@@ -0,0 +1,317 @@
use crate::{
json_crdt::{BaseCrdt, CrdtNode, SignedOp},
keypair::SignedDigest,
list_crdt::ListCrdt,
op::{Op, OpId, PathSegment},
};
#[cfg(feature = "logging-base")]
use {
crate::{
keypair::{lsb_32, AuthorId},
op::{print_hex, print_path, ROOT_ID},
},
colored::Colorize,
random_color::{Luminosity, RandomColor},
};
#[cfg(feature = "logging-list")]
use std::collections::HashMap;
use std::fmt::Display;
#[cfg(feature = "logging-base")]
fn author_to_hex(author: AuthorId) -> String {
format!("{:#010x}", lsb_32(author))
}
#[cfg(feature = "logging-base")]
fn display_op_id<T: CrdtNode>(op: &Op<T>) -> String {
let [r, g, b] = RandomColor::new()
.luminosity(Luminosity::Light)
.seed(lsb_32(op.author))
.to_rgb_array();
format!(
"[{},{}]",
author_to_hex(op.author).bold().truecolor(r, g, b),
op.seq.to_string().yellow()
)
}
pub fn debug_type_mismatch(_msg: String) {
#[cfg(feature = "logging-base")]
{
println!(" {}\n {_msg}", "type mismatch! ignoring this node".red(),);
}
}
pub fn debug_path_mismatch(_our_path: Vec<PathSegment>, _op_path: Vec<PathSegment>) {
#[cfg(feature = "logging-base")]
{
println!(
" {}\n current path: {}\n op path: {}",
"path mismatch!".red(),
print_path(_our_path),
print_path(_op_path),
);
}
}
pub fn debug_op_on_primitive(_op_path: Vec<PathSegment>) {
#[cfg(feature = "logging-base")]
{
println!(
" {} this is an error, ignoring op.\n op path: {}",
"trying to apply() on a primitive!".red(),
print_path(_op_path),
);
}
}
#[cfg(feature = "logging-base")]
fn display_author(author: AuthorId) -> String {
let [r, g, b] = RandomColor::new()
.luminosity(Luminosity::Light)
.seed(lsb_32(author))
.to_rgb_array();
format!(" {} ", author_to_hex(author))
.black()
.on_truecolor(r, g, b)
.to_string()
}
pub trait DebugView {
fn debug_view(&self, indent: usize) -> String;
}
impl<T: CrdtNode + DebugView> BaseCrdt<T> {
pub fn debug_view(&self) {
#[cfg(feature = "logging-json")]
println!("document is now:\n{}", self.doc.debug_view(0));
}
pub fn log_try_apply(&self, _op: &SignedOp) {
#[cfg(feature = "logging-json")]
println!(
"{} trying to apply operation {} from {}",
display_author(self.id),
&print_hex(&_op.signed_digest)[..6],
display_author(_op.inner.author())
);
}
pub fn debug_digest_failure(&self, _op: SignedOp) {
#[cfg(feature = "logging-json")]
println!(
" {} cannot confirm signed_digest from {}",
"digest failure!".red(),
display_author(_op.author())
);
}
pub fn log_missing_causal_dep(&self, _missing: &SignedDigest) {
#[cfg(feature = "logging-json")]
println!(
" {} haven't received op with digest {}",
"missing causal dependency".red(),
print_hex(_missing)
);
}
pub fn log_actually_apply(&self, _op: &SignedOp) {
#[cfg(feature = "logging-json")]
{
println!(
" applying op to path: /{}",
print_path(_op.inner.path.clone())
);
println!("{}", _op.inner.debug_view(2));
}
}
}
impl<T> Op<T>
where
T: CrdtNode,
{
pub fn debug_hash_failure(&self) {
#[cfg(feature = "logging-base")]
{
println!(" {}", "hash failure!".red());
println!(" expected: {}", print_hex(&self.id));
println!(" computed: {}", print_hex(&self.hash_to_id()));
}
}
}
impl<T> DebugView for T
where
T: Display,
{
#[cfg(feature = "logging-base")]
fn debug_view(&self, _indent: usize) -> String {
self.to_string()
}
#[cfg(not(feature = "logging-base"))]
fn debug_view(&self, _indent: usize) -> String {
"".to_string()
}
}
impl<T> DebugView for Op<T>
where
T: DebugView + CrdtNode,
{
#[cfg(not(feature = "logging-base"))]
fn debug_view(&self, _indent: usize) -> String {
"".to_string()
}
#[cfg(feature = "logging-json")]
fn debug_view(&self, indent: usize) -> String {
let op_id = display_op_id(self);
let content = if self.id == ROOT_ID && self.content.is_none() {
"root".blue().bold().to_string()
} else {
self.content
.as_ref()
.map_or("[empty]".to_string(), |c| c.debug_view(indent + 2))
};
let content_str = if self.is_deleted && self.id != ROOT_ID {
content.red().strikethrough().to_string()
} else {
content
};
format!("{op_id} {content_str}")
}
}
impl<T> ListCrdt<T>
where
T: CrdtNode,
{
pub fn log_ops(&self, _highlight: Option<OpId>) {
#[cfg(feature = "logging-list")]
{
let mut lines = Vec::<String>::new();
// do in-order traversal
let res: Vec<&Op<T>> = self.ops.iter().collect();
if res.is_empty() {
println!("[empty]");
}
// figure out parent-child hierarchies from origins
let mut parent_child_map: HashMap<OpId, Vec<OpId>> = HashMap::new();
for op in &res {
let children = parent_child_map.entry(op.origin).or_default();
children.push(op.id);
}
let is_last = |op: &Op<T>| -> bool {
if op.id == ROOT_ID {
return true;
}
if let Some(children) = parent_child_map.get(&op.origin) {
return *children.last().unwrap() == op.id;
}
false
};
// make stack of origins
let mut stack: Vec<(OpId, &str)> = Vec::new();
stack.push((ROOT_ID, ""));
let mut prev = None;
for op in &res {
let origin_idx = self.find_idx(op.origin).unwrap();
let origin = &res[origin_idx];
let origin_id = origin.id;
if let Some(prev) = prev {
if origin_id == prev {
// went down one layer, add to stack
let stack_prefix_char = if is_last(origin) { " " } else { "" };
stack.push((prev, stack_prefix_char));
}
}
// pop back up until we reach the right origin
while stack.last().unwrap().0 != origin_id {
stack.pop();
}
let cur_char = if is_last(op) { "╰─" } else { "├─" };
let prefixes = stack.iter().map(|s| s.1).collect::<Vec<_>>().join("");
let highlight_text = if _highlight.is_some() && _highlight.unwrap() == op.id {
if op.is_deleted {
"<- deleted".bold().red()
} else {
"<- inserted".bold().green()
}
.to_string()
} else {
"".to_string()
};
let content = if op.id == ROOT_ID {
"root".blue().bold().to_string()
} else {
op.content
.as_ref()
.map_or("[empty]".to_string(), |c| c.hash())
};
if op.is_deleted && op.id != ROOT_ID {
lines.push(format!(
"{}{}{} {} {}",
prefixes,
cur_char,
display_op_id(op),
content.strikethrough().red(),
highlight_text
));
} else {
lines.push(format!(
"{}{}{} {} {}",
prefixes,
cur_char,
display_op_id(op),
content,
highlight_text
));
}
prev = Some(op.id);
}
// full string
let flat = self.iter().map(|t| t.hash()).collect::<Vec<_>>().join("");
lines.push(format!("Flattened result: {}", flat));
println!("{}", lines.join("\n"));
}
}
pub fn log_apply(&self, _op: &Op<T>) {
#[cfg(feature = "logging-list")]
{
if _op.is_deleted {
println!(
"{} Performing a delete of {}@{}",
display_author(self.our_id),
display_op_id(_op),
_op.sequence_num(),
);
return;
}
if let Some(content) = _op.content.as_ref() {
println!(
"{} Performing an insert of {}@{}: '{}' after {}",
display_author(self.our_id),
display_op_id(_op),
_op.sequence_num(),
content.hash(),
display_op_id(_op)
);
}
}
}
}
+947
View File
@@ -0,0 +1,947 @@
use std::{
collections::{HashMap, HashSet},
fmt::Display,
};
use crate::{
debug::{debug_op_on_primitive, DebugView},
keypair::{sha256, sign, AuthorId, SignedDigest},
list_crdt::ListCrdt,
lww_crdt::LwwRegisterCrdt,
op::{print_hex, print_path, Hashable, Op, OpId, PathSegment},
};
pub use bft_crdt_derive::*;
use fastcrypto::traits::VerifyingKey;
use fastcrypto::{
ed25519::{Ed25519KeyPair, Ed25519PublicKey, Ed25519Signature},
traits::{KeyPair, ToFromBytes},
// Verifier,
};
// TODO: serde's json object serialization and deserialization (correctly) do not define anything
// object field order in JSON objects. However, the hash check impl in bft-json-bft-crdt does take order
// into account. This is going to cause problems later for non-Rust implementations, BFT hash checking
// currently depends on JSON serialization/deserialization object order. This shouldn't be the case
// but I've hacked in an IndexMap for the moment to get the PoC working. To see the problem, replace this with
// a std HashMap, everything will screw up (annoyingly, only *most* of the time).
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, Bytes};
/// Anything that can be nested in a JSON CRDT
pub trait CrdtNode: CrdtNodeFromValue + Hashable + Clone {
/// Create a new CRDT of this type
fn new(id: AuthorId, path: Vec<PathSegment>) -> Self;
/// Apply an operation to this CRDT, forwarding if necessary
fn apply(&mut self, op: Op<JsonValue>) -> OpState;
/// Get a JSON representation of the value in this node
fn view(&self) -> JsonValue;
}
/// Enum representing possible outcomes of applying an operation to a CRDT
#[derive(Debug, PartialEq)]
pub enum OpState {
/// Operation applied successfully
Ok,
/// Tried to apply an operation to a non-CRDT primitive (i.e. f64, bool, etc.)
/// If you would like a mutable primitive, wrap it in a [`LWWRegisterCRDT`]
ErrApplyOnPrimitive,
/// Tried to apply an operation to a static struct CRDT
/// If you would like a mutable object, use a [`Value`]
ErrApplyOnStruct,
/// Tried to apply an operation that contains content of the wrong type.
/// In other words, the content cannot be coerced to the CRDT at the path specified.
ErrMismatchedType,
/// The signed digest of the message did not match the claimed author of the message.
/// This can happen if the message was tampered with during delivery
ErrDigestMismatch,
/// The hash of the message did not match the contents of the message.
/// This can happen if the author tried to perform an equivocation attack by creating an
/// operation and modifying it has already been created
ErrHashMismatch,
/// Tried to apply an operation to a non-existent path. The author may have forgotten to attach
/// a causal dependency
ErrPathMismatch,
/// Trying to modify/delete the sentinel (zero-th) node element that is used for book-keeping
ErrListApplyToEmpty,
/// We have not received all of the causal dependencies of this operation. It has been queued
/// up and will be executed when its causal dependencies have been delivered
MissingCausalDependencies,
/// This op has already been applied (identified by its `signed_digest`).
/// The CRDT state is unchanged — this is a no-op (idempotent self-loop guard).
AlreadySeen,
}
/// Maximum total number of ops that may sit in the causal-order hold queue at any
/// one time, summed across all pending dependency buckets.
///
/// **Overflow policy: drop oldest.**
/// When the limit is reached, the oldest pending op in the largest dependency bucket
/// is silently evicted before the new op is queued. Rationale: a misbehaving or
/// heavily-partitioned peer can send ops whose causal ancestors never arrive, causing
/// unbounded memory growth. Dropping the oldest entry preserves the most recent
/// information and caps memory use. The peer can reconnect and receive a fresh bulk
/// state dump to recover any dropped ops.
pub const CAUSAL_QUEUE_MAX: usize = 256;
/// The following types can be used as a 'terminal' type in CRDTs
pub trait MarkPrimitive: Into<JsonValue> + Default {}
impl MarkPrimitive for bool {}
impl MarkPrimitive for i32 {}
impl MarkPrimitive for i64 {}
impl MarkPrimitive for f64 {}
impl MarkPrimitive for char {}
impl MarkPrimitive for String {}
impl MarkPrimitive for JsonValue {}
/// Implement CrdtNode for non-CRDTs
/// This is a stub implementation so most functions don't do anything/log an error
impl<T> CrdtNode for T
where
T: CrdtNodeFromValue + MarkPrimitive + Hashable + Clone,
{
fn apply(&mut self, _op: Op<JsonValue>) -> OpState {
OpState::ErrApplyOnPrimitive
}
fn view(&self) -> JsonValue {
self.to_owned().into()
}
fn new(_id: AuthorId, _path: Vec<PathSegment>) -> Self {
debug_op_on_primitive(_path);
Default::default()
}
}
/// The base struct for a JSON CRDT. Allows for declaring causal
/// dependencies across fields. It only accepts messages of [`SignedOp`] for BFT.
pub struct BaseCrdt<T: CrdtNode> {
/// Public key of this CRDT
pub id: AuthorId,
/// Internal base CRDT
pub doc: T,
/// In a real world scenario, this would be a proper hash graph that allows for
/// efficient reconciliation of missing dependencies. We naively keep a hash set
/// of messages we've seen (represented by their [`SignedDigest`]).
received: HashSet<SignedDigest>,
message_q: HashMap<SignedDigest, Vec<SignedOp>>,
/// Total count of ops currently held in [`message_q`] waiting for their causal
/// dependencies to be delivered. Used to enforce [`CAUSAL_QUEUE_MAX`].
queue_len: usize,
}
/// An [`Op<Value>`] with a few bits of extra metadata
#[serde_as]
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub struct SignedOp {
// Note that this can be different from the author of the inner op as the inner op could have been created
// by a different person
author: AuthorId,
/// Signed hash using priv key of author. Effectively [`OpID`] Use this as the ID to figure out what has been delivered already
#[serde_as(as = "Bytes")]
pub signed_digest: SignedDigest,
pub inner: Op<JsonValue>,
/// List of causal dependencies
#[serde_as(as = "Vec<Bytes>")]
pub depends_on: Vec<SignedDigest>,
}
impl SignedOp {
pub fn id(&self) -> OpId {
self.inner.id
}
pub fn author(&self) -> AuthorId {
self.author
}
/// Creates a digest of the following fields. Any changes in the fields will change the signed digest
/// - id (hash of the following)
/// - origin
/// - author
/// - seq
/// - is_deleted
/// - path
/// - dependencies
fn digest(&self) -> [u8; 32] {
let path_string = print_path(self.inner.path.clone());
let dependency_string = self
.depends_on
.iter()
.map(print_hex)
.collect::<Vec<_>>()
.join("");
let fmt_str = format!("{:?},{path_string},{dependency_string}", self.id());
sha256(fmt_str)
}
/// Sign this digest with the given keypair. Shouldn't need to be called manually,
/// just use [`SignedOp::from_op`] instead
fn sign_digest(&mut self, keypair: &Ed25519KeyPair) {
self.signed_digest = sign(keypair, &self.digest()).sig.to_bytes()
}
/// Ensure digest was actually signed by the author it claims to be signed by
pub fn is_valid_digest(&self) -> bool {
let digest = Ed25519Signature::from_bytes(&self.signed_digest);
let pubkey = Ed25519PublicKey::from_bytes(&self.author());
match (digest, pubkey) {
(Ok(digest), Ok(pubkey)) => pubkey.verify(&self.digest(), &digest).is_ok(),
(_, _) => false,
}
}
/// Sign a normal op and add all the needed metadata
pub fn from_op<T: CrdtNode>(
value: Op<T>,
keypair: &Ed25519KeyPair,
depends_on: Vec<SignedDigest>,
) -> Self {
let author = keypair.public().0.to_bytes();
let mut new = Self {
inner: Op {
content: value.content.map(|c| c.view()),
origin: value.origin,
author: value.author,
seq: value.seq,
path: value.path,
is_deleted: value.is_deleted,
id: value.id,
},
author,
signed_digest: [0u8; 64],
depends_on,
};
new.sign_digest(keypair);
new
}
}
impl<T: CrdtNode + DebugView> BaseCrdt<T> {
/// Create a new BaseCRDT of the given type. Multiple BaseCRDTs
/// can be created from a single keypair but you are responsible for
/// routing messages to the right BaseCRDT. Usually you should just make a single
/// struct that contains all the state you need.
pub fn new(keypair: &Ed25519KeyPair) -> Self {
let id = keypair.public().0.to_bytes();
Self {
id,
doc: T::new(id, vec![]),
received: HashSet::new(),
message_q: HashMap::new(),
queue_len: 0,
}
}
/// Apply a signed operation to this BaseCRDT, verifying integrity and routing to the right
/// nested CRDT
pub fn apply(&mut self, op: SignedOp) -> OpState {
// self.log_try_apply(&op);
#[cfg(feature = "bft")]
if !op.is_valid_digest() {
self.debug_digest_failure(op);
return OpState::ErrDigestMismatch;
}
let op_id = op.signed_digest;
// Self-loop / dedup guard: if we have already processed this op (identified by
// its signed_digest), return immediately without re-applying it. This prevents
// echo loops where an op we broadcast to a peer comes back to us.
if self.received.contains(&op_id) {
return OpState::AlreadySeen;
}
if !op.depends_on.is_empty() {
for origin in &op.depends_on {
if !self.received.contains(origin) {
self.log_missing_causal_dep(origin);
// Bounded queue overflow: evict the oldest op from the largest
// pending bucket before adding the new one. See CAUSAL_QUEUE_MAX.
if self.queue_len >= CAUSAL_QUEUE_MAX {
if let Some(bucket) = self.message_q.values_mut().max_by_key(|v| v.len()) {
if !bucket.is_empty() {
bucket.remove(0);
self.queue_len = self.queue_len.saturating_sub(1);
}
}
}
self.message_q.entry(*origin).or_default().push(op);
self.queue_len += 1;
return OpState::MissingCausalDependencies;
}
}
}
// apply
// self.log_actually_apply(&op);
let status = self.doc.apply(op.inner);
// self.debug_view();
// Only record the op as seen when it applied successfully. If the op
// was rejected (e.g. ErrHashMismatch from a tampered payload), we must
// NOT add its signed_digest to `received`: a legitimate op that shares
// the same signed_digest (e.g. the un-tampered original) would otherwise
// be silently dropped as AlreadySeen.
// Only mark as received and unblock dependents when the op was actually
// applied. If we insert on error (e.g. ErrHashMismatch), a subsequent
// apply of a *legitimate* op with the same signed_digest would be
// silently dropped as AlreadySeen, preventing equivocation detection
// from working correctly.
if status == OpState::Ok {
self.received.insert(op_id);
// apply all of its causal dependents if there are any
let dependent_queue = self.message_q.remove(&op_id);
if let Some(mut q) = dependent_queue {
self.queue_len = self.queue_len.saturating_sub(q.len());
for dependent in q.drain(..) {
self.apply(dependent);
}
}
}
status
}
/// Number of ops currently held in the causal-order queue waiting for their
/// dependencies to be satisfied.
pub fn causal_queue_len(&self) -> usize {
self.queue_len
}
}
/// An enum representing a JSON value
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum JsonValue {
Null,
Bool(bool),
Number(f64),
String(String),
Array(Vec<JsonValue>),
Object(IndexMap<String, JsonValue>),
}
impl Display for JsonValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
JsonValue::Null => "null".to_string(),
JsonValue::Bool(b) => b.to_string(),
JsonValue::Number(n) => n.to_string(),
JsonValue::String(s) => format!("\"{s}\""),
JsonValue::Array(arr) => {
if arr.len() > 1 {
format!(
"[\n{}\n]",
arr.iter()
.map(|x| format!(" {x}"))
.collect::<Vec<_>>()
.join(",\n")
)
} else {
format!(
"[ {} ]",
arr.iter()
.map(|x| x.to_string())
.collect::<Vec<_>>()
.join(", ")
)
}
}
JsonValue::Object(obj) => format!(
"{{ {} }}",
obj.iter()
.map(|(k, v)| format!(" \"{k}\": {v}"))
.collect::<Vec<_>>()
.join(",\n")
),
}
)
}
}
impl Default for JsonValue {
fn default() -> Self {
Self::Null
}
}
/// Allow easy conversion to and from serde's JSON format. This allows us to use the [`json!`]
/// macro
impl From<JsonValue> for serde_json::Value {
fn from(value: JsonValue) -> Self {
match value {
JsonValue::Null => serde_json::Value::Null,
JsonValue::Bool(x) => serde_json::Value::Bool(x),
JsonValue::Number(x) => {
serde_json::Value::Number(serde_json::Number::from_f64(x).unwrap())
}
JsonValue::String(x) => serde_json::Value::String(x),
JsonValue::Array(x) => {
serde_json::Value::Array(x.iter().map(|a| a.clone().into()).collect())
}
JsonValue::Object(x) => serde_json::Value::Object(
x.iter()
.map(|(k, v)| (k.clone(), v.clone().into()))
.collect(),
),
}
}
}
impl From<serde_json::Value> for JsonValue {
fn from(value: serde_json::Value) -> Self {
match value {
serde_json::Value::Null => JsonValue::Null,
serde_json::Value::Bool(x) => JsonValue::Bool(x),
serde_json::Value::Number(x) => JsonValue::Number(x.as_f64().unwrap()),
serde_json::Value::String(x) => JsonValue::String(x),
serde_json::Value::Array(x) => {
JsonValue::Array(x.iter().map(|a| a.clone().into()).collect())
}
serde_json::Value::Object(x) => JsonValue::Object(
x.iter()
.map(|(k, v)| (k.clone(), v.clone().into()))
.collect(),
),
}
}
}
impl JsonValue {
pub fn into_json(self) -> serde_json::Value {
self.into()
}
}
/// Conversions from primitive types to [`JsonValue`]
impl From<bool> for JsonValue {
fn from(val: bool) -> Self {
JsonValue::Bool(val)
}
}
impl From<i64> for JsonValue {
fn from(val: i64) -> Self {
JsonValue::Number(val as f64)
}
}
impl From<i32> for JsonValue {
fn from(val: i32) -> Self {
JsonValue::Number(val as f64)
}
}
impl From<f64> for JsonValue {
fn from(val: f64) -> Self {
JsonValue::Number(val)
}
}
impl From<String> for JsonValue {
fn from(val: String) -> Self {
JsonValue::String(val)
}
}
impl From<char> for JsonValue {
fn from(val: char) -> Self {
JsonValue::String(val.into())
}
}
impl<T> From<Option<T>> for JsonValue
where
T: CrdtNode,
{
fn from(val: Option<T>) -> Self {
match val {
Some(x) => x.view(),
None => JsonValue::Null,
}
}
}
impl<T> From<Vec<T>> for JsonValue
where
T: CrdtNode,
{
fn from(value: Vec<T>) -> Self {
JsonValue::Array(value.iter().map(|x| x.view()).collect())
}
}
/// Fallibly create a CRDT Node from a JSON Value
pub trait CrdtNodeFromValue: Sized {
fn node_from(value: JsonValue, id: AuthorId, path: Vec<PathSegment>) -> Result<Self, String>;
}
/// Fallibly cast a JSON Value into a CRDT Node
pub trait IntoCrdtNode<T>: Sized {
fn into_node(self, id: AuthorId, path: Vec<PathSegment>) -> Result<T, String>;
}
/// [`CrdtNodeFromValue`] implies [`IntoCrdtNode<T>`]
impl<T> IntoCrdtNode<T> for JsonValue
where
T: CrdtNodeFromValue,
{
fn into_node(self, id: AuthorId, path: Vec<PathSegment>) -> Result<T, String> {
T::node_from(self, id, path)
}
}
/// Trivial conversion from [`JsonValue`] to [`JsonValue`] as [`CrdtNodeFromValue`]
impl CrdtNodeFromValue for JsonValue {
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
Ok(value)
}
}
/// Conversions from bool to CRDT
impl CrdtNodeFromValue for bool {
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
if let JsonValue::Bool(x) = value {
Ok(x)
} else {
Err(format!("failed to convert {value:?} -> bool"))
}
}
}
/// Conversions from f64 to CRDT
impl CrdtNodeFromValue for f64 {
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
if let JsonValue::Number(x) = value {
Ok(x)
} else {
Err(format!("failed to convert {value:?} -> f64"))
}
}
}
/// Conversions from i64 to CRDT
impl CrdtNodeFromValue for i64 {
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
if let JsonValue::Number(x) = value {
Ok(x as i64)
} else {
Err(format!("failed to convert {value:?} -> f64"))
}
}
}
/// Conversions from String to CRDT
impl CrdtNodeFromValue for String {
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
if let JsonValue::String(x) = value {
Ok(x)
} else {
Err(format!("failed to convert {value:?} -> String"))
}
}
}
/// Conversions from char to CRDT
impl CrdtNodeFromValue for char {
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
if let JsonValue::String(x) = value.clone() {
x.chars().next().ok_or(format!(
"failed to convert {value:?} -> char: found a zero-length string"
))
} else {
Err(format!("failed to convert {value:?} -> char"))
}
}
}
impl<T> CrdtNodeFromValue for LwwRegisterCrdt<T>
where
T: CrdtNode,
{
fn node_from(value: JsonValue, id: AuthorId, path: Vec<PathSegment>) -> Result<Self, String> {
let mut crdt = LwwRegisterCrdt::new(id, path);
crdt.set(value);
Ok(crdt)
}
}
impl<T> CrdtNodeFromValue for ListCrdt<T>
where
T: CrdtNode,
{
fn node_from(value: JsonValue, id: AuthorId, path: Vec<PathSegment>) -> Result<Self, String> {
if let JsonValue::Array(arr) = value {
let mut crdt = ListCrdt::new(id, path);
let result: Result<(), String> =
arr.into_iter().enumerate().try_for_each(|(i, val)| {
crdt.insert_idx(i, val);
Ok(())
});
result?;
Ok(crdt)
} else {
Err(format!("failed to convert {value:?} -> ListCRDT<T>"))
}
}
}
#[cfg(test)]
mod test {
use serde_json::json;
use crate::{
json_crdt::{add_crdt_fields, BaseCrdt, CrdtNode, IntoCrdtNode, JsonValue, OpState},
keypair::make_keypair,
list_crdt::ListCrdt,
lww_crdt::LwwRegisterCrdt,
op::{print_path, ROOT_ID},
};
#[test]
fn test_derive_basic() {
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug)]
struct Player {
x: LwwRegisterCrdt<f64>,
y: LwwRegisterCrdt<f64>,
}
let keypair = make_keypair();
let crdt = BaseCrdt::<Player>::new(&keypair);
assert_eq!(print_path(crdt.doc.x.path), "x");
assert_eq!(print_path(crdt.doc.y.path), "y");
}
#[test]
fn test_derive_nested() {
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug)]
struct Position {
x: LwwRegisterCrdt<f64>,
y: LwwRegisterCrdt<f64>,
}
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug)]
struct Player {
pos: Position,
balance: LwwRegisterCrdt<f64>,
messages: ListCrdt<String>,
}
let keypair = make_keypair();
let crdt = BaseCrdt::<Player>::new(&keypair);
assert_eq!(print_path(crdt.doc.pos.x.path), "pos.x");
assert_eq!(print_path(crdt.doc.pos.y.path), "pos.y");
assert_eq!(print_path(crdt.doc.balance.path), "balance");
assert_eq!(print_path(crdt.doc.messages.path), "messages");
}
#[test]
fn test_lww_ops() {
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug)]
struct Test {
a: LwwRegisterCrdt<f64>,
b: LwwRegisterCrdt<bool>,
c: LwwRegisterCrdt<String>,
}
let kp1 = make_keypair();
let kp2 = make_keypair();
let mut base1 = BaseCrdt::<Test>::new(&kp1);
let mut base2 = BaseCrdt::<Test>::new(&kp2);
let _1_a_1 = base1.doc.a.set(3.0).sign(&kp1);
let _1_b_1 = base1.doc.b.set(true).sign(&kp1);
let _2_a_1 = base2.doc.a.set(1.5).sign(&kp2);
let _2_a_2 = base2.doc.a.set(2.13).sign(&kp2);
let _2_c_1 = base2.doc.c.set("abc".to_string()).sign(&kp2);
assert_eq!(base1.doc.a.view(), json!(3.0).into());
assert_eq!(base2.doc.a.view(), json!(2.13).into());
assert_eq!(base1.doc.b.view(), json!(true).into());
assert_eq!(base2.doc.c.view(), json!("abc").into());
assert_eq!(
base1.doc.view().into_json(),
json!({
"a": 3.0,
"b": true,
"c": null,
})
);
assert_eq!(
base2.doc.view().into_json(),
json!({
"a": 2.13,
"b": null,
"c": "abc",
})
);
assert_eq!(base2.apply(_1_a_1), OpState::Ok);
assert_eq!(base2.apply(_1_b_1), OpState::Ok);
assert_eq!(base1.apply(_2_a_1), OpState::Ok);
assert_eq!(base1.apply(_2_a_2), OpState::Ok);
assert_eq!(base1.apply(_2_c_1), OpState::Ok);
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
assert_eq!(
base1.doc.view().into_json(),
json!({
"a": 2.13,
"b": true,
"c": "abc"
})
)
}
#[test]
fn test_vec_and_map_ops() {
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug)]
struct Test {
a: ListCrdt<String>,
}
let kp1 = make_keypair();
let kp2 = make_keypair();
let mut base1 = BaseCrdt::<Test>::new(&kp1);
let mut base2 = BaseCrdt::<Test>::new(&kp2);
let _1a = base1.doc.a.insert(ROOT_ID, "a".to_string()).sign(&kp1);
let _1b = base1.doc.a.insert(_1a.id(), "b".to_string()).sign(&kp1);
let _2c = base2.doc.a.insert(ROOT_ID, "c".to_string()).sign(&kp2);
let _2d = base2.doc.a.insert(_1b.id(), "d".to_string()).sign(&kp2);
assert_eq!(
base1.doc.view().into_json(),
json!({
"a": ["a", "b"],
})
);
// as _1b hasn't been delivered to base2 yet
assert_eq!(
base2.doc.view().into_json(),
json!({
"a": ["c"],
})
);
assert_eq!(base2.apply(_1b), OpState::MissingCausalDependencies);
assert_eq!(base2.apply(_1a), OpState::Ok);
assert_eq!(base1.apply(_2d), OpState::Ok);
assert_eq!(base1.apply(_2c), OpState::Ok);
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
}
#[test]
fn test_causal_field_dependency() {
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug)]
struct Item {
name: LwwRegisterCrdt<String>,
soulbound: LwwRegisterCrdt<bool>,
}
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug)]
struct Player {
inventory: ListCrdt<Item>,
balance: LwwRegisterCrdt<f64>,
}
let kp1 = make_keypair();
let kp2 = make_keypair();
let mut base1 = BaseCrdt::<Player>::new(&kp1);
let mut base2 = BaseCrdt::<Player>::new(&kp2);
// require balance update to happen before inventory update
let _add_money = base1.doc.balance.set(5000.0).sign(&kp1);
let _spend_money = base1
.doc
.balance
.set(3000.0)
.sign_with_dependencies(&kp1, vec![&_add_money]);
let sword: JsonValue = json!({
"name": "Sword",
"soulbound": true,
})
.into();
let _new_inventory_item = base1
.doc
.inventory
.insert_idx(0, sword)
.sign_with_dependencies(&kp1, vec![&_spend_money]);
assert_eq!(
base1.doc.view().into_json(),
json!({
"balance": 3000.0,
"inventory": [
{
"name": "Sword",
"soulbound": true
}
]
})
);
// do it completely out of order
assert_eq!(
base2.apply(_new_inventory_item),
OpState::MissingCausalDependencies
);
assert_eq!(
base2.apply(_spend_money),
OpState::MissingCausalDependencies
);
assert_eq!(base2.apply(_add_money), OpState::Ok);
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
}
#[test]
fn test_2d_grid() {
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug)]
struct Game {
grid: ListCrdt<ListCrdt<LwwRegisterCrdt<bool>>>,
}
let kp1 = make_keypair();
let kp2 = make_keypair();
let mut base1 = BaseCrdt::<Game>::new(&kp1);
let mut base2 = BaseCrdt::<Game>::new(&kp2);
// init a 2d grid
let row0: JsonValue = json!([true, false]).into();
let row1: JsonValue = json!([false, true]).into();
let construct1 = base1.doc.grid.insert_idx(0, row0).sign(&kp1);
let construct2 = base1.doc.grid.insert_idx(1, row1).sign(&kp1);
assert_eq!(base2.apply(construct1), OpState::Ok);
assert_eq!(base2.apply(construct2.clone()), OpState::Ok);
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
assert_eq!(
base1.doc.view().into_json(),
json!({
"grid": [[true, false], [false, true]]
})
);
let set1 = base1.doc.grid[0][0].set(false).sign(&kp1);
let set2 = base2.doc.grid[1][1].set(false).sign(&kp2);
assert_eq!(base1.apply(set2), OpState::Ok);
assert_eq!(base2.apply(set1), OpState::Ok);
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
assert_eq!(
base1.doc.view().into_json(),
json!({
"grid": [[false, false], [false, false]]
})
);
let topright = base1.doc.grid[0].id_at(1).unwrap();
base1.doc.grid[0].delete(topright);
assert_eq!(
base1.doc.view().into_json(),
json!({
"grid": [[false], [false, false]]
})
);
base1.doc.grid.delete(construct2.id());
assert_eq!(
base1.doc.view().into_json(),
json!({
"grid": [[false]]
})
);
}
#[test]
fn test_arb_json() {
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug)]
struct Test {
reg: LwwRegisterCrdt<JsonValue>,
}
let kp1 = make_keypair();
let mut base1 = BaseCrdt::<Test>::new(&kp1);
let base_val: JsonValue = json!({
"a": true,
"b": "asdf",
"c": {
"d": [],
"e": [ false ]
}
})
.into();
base1.doc.reg.set(base_val).sign(&kp1);
assert_eq!(
base1.doc.view().into_json(),
json!({
"reg": {
"a": true,
"b": "asdf",
"c": {
"d": [],
"e": [ false ]
}
}
})
);
}
#[test]
fn test_wrong_json_types() {
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug)]
struct Nested {
list: ListCrdt<f64>,
}
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug)]
struct Test {
reg: LwwRegisterCrdt<bool>,
strct: ListCrdt<Nested>,
}
let key = make_keypair();
let mut crdt = BaseCrdt::<Test>::new(&key);
// wrong type should not go through
crdt.doc.reg.set(32);
assert_eq!(crdt.doc.reg.view(), json!(null).into());
crdt.doc.reg.set(true);
assert_eq!(crdt.doc.reg.view(), json!(true).into());
// set nested
let mut list_view: JsonValue = crdt.doc.strct.view().into();
assert_eq!(list_view, json!([]).into());
// only keeps actual numbers
let list: JsonValue = json!({"list": [0, 123, -0.45, "char", []]}).into();
crdt.doc.strct.insert_idx(0, list);
list_view = crdt.doc.strct.view().into();
assert_eq!(list_view, json!([{ "list": [0, 123, -0.45]}]).into());
}
}
+57
View File
@@ -0,0 +1,57 @@
use fastcrypto::traits::VerifyingKey;
pub use fastcrypto::{
ed25519::{
Ed25519KeyPair, Ed25519PublicKey, Ed25519Signature, ED25519_PUBLIC_KEY_LENGTH,
ED25519_SIGNATURE_LENGTH,
},
traits::{KeyPair, Signer},
// Verifier,
};
use sha2::{Digest, Sha256};
/// Represents the ID of a unique node. An Ed25519 public key
pub type AuthorId = [u8; ED25519_PUBLIC_KEY_LENGTH];
/// A signed message
pub type SignedDigest = [u8; ED25519_SIGNATURE_LENGTH];
/// Create a fake public key from a u8
pub fn make_author(n: u8) -> AuthorId {
let mut id = [0u8; ED25519_PUBLIC_KEY_LENGTH];
id[0] = n;
id
}
/// Get the least significant 32 bits of a public key
pub fn lsb_32(pubkey: AuthorId) -> u32 {
((pubkey[0] as u32) << 24)
+ ((pubkey[1] as u32) << 16)
+ ((pubkey[2] as u32) << 8)
+ (pubkey[3] as u32)
}
/// SHA256 hash of a string
pub fn sha256(input: String) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
let result = hasher.finalize();
let mut bytes = [0u8; 32];
bytes.copy_from_slice(&result[..]);
bytes
}
/// Generate a random Ed25519 keypair from OS rng
pub fn make_keypair() -> Ed25519KeyPair {
let mut csprng = rand::thread_rng();
Ed25519KeyPair::generate(&mut csprng)
}
/// Sign a byte array
pub fn sign(keypair: &Ed25519KeyPair, message: &[u8]) -> Ed25519Signature {
keypair.sign(message)
}
/// Verify a byte array was signed by the given pubkey
pub fn verify(pubkey: Ed25519PublicKey, message: &[u8], signature: Ed25519Signature) -> bool {
pubkey.verify(message, &signature).is_ok()
}
+8
View File
@@ -0,0 +1,8 @@
pub mod debug;
pub mod json_crdt;
pub mod keypair;
pub mod list_crdt;
pub mod lww_crdt;
pub mod op;
extern crate self as bft_json_crdt;
+445
View File
@@ -0,0 +1,445 @@
use crate::{
debug::debug_path_mismatch,
json_crdt::{CrdtNode, JsonValue, OpState},
keypair::AuthorId,
op::*,
};
use serde::{Deserialize, Serialize};
use std::{
cmp::{max, Ordering},
collections::HashMap,
fmt::Debug,
ops::{Index, IndexMut},
};
/// An RGA-like list CRDT that can store a CRDT-like datatype
#[derive(Clone, Serialize, Deserialize)]
pub struct ListCrdt<T>
where
T: CrdtNode,
{
/// Public key for this node
pub our_id: AuthorId,
/// Path to this CRDT
pub path: Vec<PathSegment>,
/// List of all the operations we know of
pub ops: Vec<Op<T>>,
/// Queue of messages where K is the ID of the message yet to arrive
/// and V is the list of operations depending on it
message_q: HashMap<OpId, Vec<Op<T>>>,
/// The sequence number of this node
our_seq: SequenceNumber,
}
impl<T> ListCrdt<T>
where
T: CrdtNode,
{
/// Create a new List CRDT with the given [`AuthorID`] (it should be unique)
pub fn new(id: AuthorId, path: Vec<PathSegment>) -> ListCrdt<T> {
let ops = vec![Op::make_root()];
ListCrdt {
our_id: id,
path,
ops,
message_q: HashMap::new(),
our_seq: 0,
}
}
/// Locally insert some content causally after the given operation
pub fn insert<U: Into<JsonValue>>(&mut self, after: OpId, content: U) -> Op<JsonValue> {
let mut op = Op::new(
after,
self.our_id,
self.our_seq + 1,
false,
Some(content.into()),
self.path.to_owned(),
);
// we need to know the op ID before setting the path as [`PathSegment::Index`] requires an
// [`OpID`]
let new_path = join_path(self.path.to_owned(), PathSegment::Index(op.id));
op.path = new_path;
self.apply(op.clone());
op
}
/// Shorthand function to insert at index locally. Indexing ignores deleted items
pub fn insert_idx<U: Into<JsonValue> + Clone>(
&mut self,
idx: usize,
content: U,
) -> Op<JsonValue> {
let mut i = 0;
for op in &self.ops {
if !op.is_deleted {
if idx == i {
return self.insert(op.id, content);
}
i += 1;
}
}
panic!("index {idx} out of range (length of {i})")
}
/// Shorthand to figure out the OpID of something with a given index.
/// Useful for declaring a causal dependency if you didn't create the original
pub fn id_at(&self, idx: usize) -> Option<OpId> {
let mut i = 0;
for op in &self.ops {
if !op.is_deleted {
if idx == i {
return Some(op.id);
}
i += 1;
}
}
None
}
/// Mark a node as deleted. If the node doesn't exist, it will be stuck
/// waiting for that node to be created.
pub fn delete(&mut self, id: OpId) -> Op<JsonValue> {
let op = Op::new(
id,
self.our_id,
self.our_seq + 1,
true,
None,
join_path(self.path.to_owned(), PathSegment::Index(id)),
);
self.apply(op.clone());
op
}
/// Find the idx of an operation with the given [`OpID`]
pub fn find_idx(&self, id: OpId) -> Option<usize> {
self.ops.iter().position(|op| op.id == id)
}
/// Apply an operation (both local and remote) to this local list CRDT.
/// Forwards it to a nested CRDT if necessary.
pub fn apply(&mut self, op: Op<JsonValue>) -> OpState {
if !op.is_valid_hash() {
return OpState::ErrHashMismatch;
}
if !ensure_subpath(&self.path, &op.path) {
return OpState::ErrPathMismatch;
}
// haven't reached end yet, navigate to inner CRDT
if op.path.len() - 1 > self.path.len() {
if let Some(PathSegment::Index(op_id)) = op.path.get(self.path.len()) {
let op_id = op_id.to_owned();
if let Some(idx) = self.find_idx(op_id) {
if self.ops[idx].content.is_none() {
return OpState::ErrListApplyToEmpty;
} else {
return self.ops[idx].content.as_mut().unwrap().apply(op);
}
} else {
debug_path_mismatch(
join_path(self.path.to_owned(), PathSegment::Index(op_id)),
op.path,
);
return OpState::ErrPathMismatch;
};
} else {
debug_path_mismatch(self.path.to_owned(), op.path);
return OpState::ErrPathMismatch;
}
}
// otherwise, this is just a direct replacement
self.integrate(op.into())
}
/// Main CRDT logic of integrating an op properly into our local log
/// without causing conflicts. This is basically a really fancy
/// insertion sort.
///
/// Effectively, we
/// 1) find the parent item
/// 2) find the right spot to insert before the next node
fn integrate(&mut self, new_op: Op<T>) -> OpState {
let op_id = new_op.id;
let seq = new_op.sequence_num();
let origin_id = self.find_idx(new_op.origin);
if origin_id.is_none() {
self.message_q
.entry(new_op.origin)
.or_default()
.push(new_op);
return OpState::MissingCausalDependencies;
}
let new_op_parent_idx = origin_id.unwrap();
// if its a delete operation, we don't need to do much
self.log_apply(&new_op);
if new_op.is_deleted {
let op = &mut self.ops[new_op_parent_idx];
op.is_deleted = true;
return OpState::Ok;
}
// otherwise, we are in an insert case
// start looking from right after parent
// stop when we reach end of document
let mut i = new_op_parent_idx + 1;
while i < self.ops.len() {
let op = &self.ops[i];
let op_parent_idx = self.find_idx(op.origin).unwrap();
// idempotency
if op.id == new_op.id {
return OpState::Ok;
}
// first, lets compare causal origins
match new_op_parent_idx.cmp(&op_parent_idx) {
Ordering::Greater => break,
Ordering::Equal => {
// our parents our equal, we are siblings
// siblings are sorted first by sequence number then by author id
match new_op.sequence_num().cmp(&op.sequence_num()) {
Ordering::Greater => break,
Ordering::Equal => {
// conflict, resolve arbitrarily but deterministically
// tie-break on author id as that is unique
if new_op.author() > op.author() {
break;
}
}
Ordering::Less => (),
}
}
Ordering::Less => (),
}
i += 1;
}
// insert at i
self.ops.insert(i, new_op);
self.our_seq = max(self.our_seq, seq);
self.log_ops(Some(op_id));
// apply all of its causal dependents if there are any
let dependent_queue = self.message_q.remove(&op_id);
if let Some(mut q) = dependent_queue {
for dependent in q.drain(..) {
self.integrate(dependent);
}
}
OpState::Ok
}
/// Make an iterator out of list CRDT contents, ignoring deleted items and empty content
pub fn iter(&self) -> impl Iterator<Item = &T> {
self.ops
.iter()
.filter(|op| !op.is_deleted && op.content.is_some())
.map(|op| op.content.as_ref().unwrap())
}
/// Convenience function to get a vector of visible list elements
pub fn view(&self) -> Vec<T> {
self.iter().map(|i| i.to_owned()).collect()
}
}
impl<T> Debug for ListCrdt<T>
where
T: CrdtNode,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"[{}]",
self.ops
.iter()
.map(|op| format!("{:?}", op.id))
.collect::<Vec<_>>()
.join(", ")
)
}
}
/// Allows us to index into a List CRDT like we would with an array
impl<T> Index<usize> for ListCrdt<T>
where
T: CrdtNode,
{
type Output = T;
fn index(&self, idx: usize) -> &Self::Output {
let mut i = 0;
for op in &self.ops {
if !op.is_deleted && op.content.is_some() {
if idx == i {
return op.content.as_ref().unwrap();
}
i += 1;
}
}
panic!("index {idx} out of range (length of {i})")
}
}
/// Allows us to mutably index into a List CRDT like we would with an array
impl<T> IndexMut<usize> for ListCrdt<T>
where
T: CrdtNode,
{
fn index_mut(&mut self, idx: usize) -> &mut Self::Output {
let mut i = 0;
for op in &mut self.ops {
if !op.is_deleted && op.content.is_some() {
if idx == i {
return op.content.as_mut().unwrap();
}
i += 1;
}
}
panic!("index {idx} out of range (length of {i})")
}
}
impl<T> CrdtNode for ListCrdt<T>
where
T: CrdtNode,
{
fn apply(&mut self, op: Op<JsonValue>) -> OpState {
self.apply(op.into())
}
fn view(&self) -> JsonValue {
self.view().into()
}
fn new(id: AuthorId, path: Vec<PathSegment>) -> Self {
Self::new(id, path)
}
}
#[cfg(feature = "logging-base")]
use crate::debug::DebugView;
#[cfg(feature = "logging-base")]
impl<T> DebugView for ListCrdt<T>
where
T: CrdtNode + DebugView,
{
fn debug_view(&self, indent: usize) -> String {
let spacing = " ".repeat(indent);
let path_str = print_path(self.path.clone());
let inner = self
.ops
.iter()
.map(|op| {
format!(
"{spacing}{}: {}",
&print_hex(&op.id)[..6],
op.debug_view(indent)
)
})
.collect::<Vec<_>>()
.join("\n");
format!("List CRDT @ /{path_str}\n{inner}")
}
}
#[cfg(test)]
mod test {
use crate::{json_crdt::OpState, keypair::make_author, list_crdt::ListCrdt, op::ROOT_ID};
#[test]
fn test_list_simple() {
let mut list = ListCrdt::<i64>::new(make_author(1), vec![]);
let _one = list.insert(ROOT_ID, 1);
let _two = list.insert(_one.id, 2);
let _three = list.insert(_two.id, 3);
let _four = list.insert(_one.id, 4);
assert_eq!(list.view(), vec![1, 4, 2, 3]);
}
#[test]
fn test_list_idempotence() {
let mut list = ListCrdt::<i64>::new(make_author(1), vec![]);
let op = list.insert(ROOT_ID, 1);
for _ in 1..10 {
assert_eq!(list.apply(op.clone()), OpState::Ok);
}
assert_eq!(list.view(), vec![1]);
}
#[test]
fn test_list_delete() {
let mut list = ListCrdt::<char>::new(make_author(1), vec![]);
let _one = list.insert(ROOT_ID, 'a');
let _two = list.insert(_one.id, 'b');
let _three = list.insert(ROOT_ID, 'c');
list.delete(_one.id);
list.delete(_two.id);
assert_eq!(list.view(), vec!['c']);
}
#[test]
fn test_list_interweave_chars() {
let mut list = ListCrdt::<char>::new(make_author(1), vec![]);
let _one = list.insert(ROOT_ID, 'a');
let _two = list.insert(_one.id, 'b');
let _three = list.insert(ROOT_ID, 'c');
assert_eq!(list.view(), vec!['c', 'a', 'b']);
}
#[test]
fn test_list_conflicting_agents() {
let mut list1 = ListCrdt::<char>::new(make_author(1), vec![]);
let mut list2 = ListCrdt::new(make_author(2), vec![]);
let _1_a = list1.insert(ROOT_ID, 'a');
assert_eq!(list2.apply(_1_a.clone()), OpState::Ok);
let _2_b = list2.insert(_1_a.id, 'b');
assert_eq!(list1.apply(_2_b.clone()), OpState::Ok);
let _2_d = list2.insert(ROOT_ID, 'd');
let _2_y = list2.insert(_2_b.id, 'y');
let _1_x = list1.insert(_2_b.id, 'x');
// create artificial delay, then apply out of order
assert_eq!(list2.apply(_1_x), OpState::Ok);
assert_eq!(list1.apply(_2_y), OpState::Ok);
assert_eq!(list1.apply(_2_d), OpState::Ok);
assert_eq!(list1.view(), vec!['d', 'a', 'b', 'y', 'x']);
assert_eq!(list1.view(), list2.view());
}
#[test]
fn test_list_delete_multiple_agent() {
let mut list1 = ListCrdt::<char>::new(make_author(1), vec![]);
let mut list2 = ListCrdt::new(make_author(2), vec![]);
let _1_a = list1.insert(ROOT_ID, 'a');
assert_eq!(list2.apply(_1_a.clone()), OpState::Ok);
let _2_b = list2.insert(_1_a.id, 'b');
let del_1_a = list1.delete(_1_a.id);
assert_eq!(list1.apply(_2_b), OpState::Ok);
assert_eq!(list2.apply(del_1_a), OpState::Ok);
assert_eq!(list1.view(), vec!['b']);
assert_eq!(list1.view(), list2.view());
}
#[test]
fn test_list_nested() {
let mut list1 = ListCrdt::<char>::new(make_author(1), vec![]);
let _c = list1.insert(ROOT_ID, 'c');
let _a = list1.insert(ROOT_ID, 'a');
let _d = list1.insert(_c.id, 'd');
let _b = list1.insert(_a.id, 'b');
assert_eq!(list1.view(), vec!['a', 'b', 'c', 'd']);
}
}
+192
View File
@@ -0,0 +1,192 @@
use crate::debug::DebugView;
use crate::json_crdt::{CrdtNode, JsonValue, OpState};
use crate::op::{join_path, print_path, Op, PathSegment, SequenceNumber};
use std::cmp::{max, Ordering};
use std::fmt::Debug;
use crate::keypair::AuthorId;
/// A simple delete-wins, last-writer-wins (LWW) register CRDT.
/// Basically only for adding support for primitives within a more complex CRDT
#[derive(Clone)]
pub struct LwwRegisterCrdt<T>
where
T: CrdtNode,
{
/// Public key for this node
pub our_id: AuthorId,
/// Path to this CRDT
pub path: Vec<PathSegment>,
/// Internal value of this CRDT. We wrap it in an Op to retain the author/sequence metadata
value: Op<T>,
/// The sequence number of this node
our_seq: SequenceNumber,
}
impl<T> LwwRegisterCrdt<T>
where
T: CrdtNode,
{
/// Create a new register CRDT with the given [`AuthorID`] (it should be unique)
pub fn new(id: AuthorId, path: Vec<PathSegment>) -> LwwRegisterCrdt<T> {
LwwRegisterCrdt {
our_id: id,
path,
value: Op::make_root(),
our_seq: 0,
}
}
/// Sets the current value of the register
pub fn set<U: Into<JsonValue>>(&mut self, content: U) -> Op<JsonValue> {
let mut op = Op::new(
self.value.id,
self.our_id,
self.our_seq + 1,
false,
Some(content.into()),
self.path.to_owned(),
);
// we need to know the op ID before setting the path as [`PathSegment::Index`] requires an
// [`OpID`]
let new_path = join_path(self.path.to_owned(), PathSegment::Index(op.id));
op.path = new_path;
self.apply(op.clone());
op
}
/// Apply an operation (both local and remote) to this local register CRDT.
pub fn apply(&mut self, op: Op<JsonValue>) -> OpState {
if !op.is_valid_hash() {
return OpState::ErrHashMismatch;
}
let op: Op<T> = op.into();
let seq = op.sequence_num();
// take most recent update by sequence number
match seq.cmp(&self.our_seq) {
Ordering::Greater => {
self.value = Op {
id: self.value.id,
..op
};
}
Ordering::Equal => {
// if we are equal, tie break on author
if op.author() < self.value.author() {
// we want to keep id constant so replace everything but id
self.value = Op {
id: self.value.id,
..op
};
}
}
Ordering::Less => {} // LWW, ignore if its outdate
};
// update bookkeeping
self.our_seq = max(self.our_seq, seq);
OpState::Ok
}
fn view(&self) -> Option<T> {
self.value.content.to_owned()
}
}
impl<T> CrdtNode for LwwRegisterCrdt<T>
where
T: CrdtNode,
{
fn apply(&mut self, op: Op<JsonValue>) -> OpState {
self.apply(op.into())
}
fn view(&self) -> JsonValue {
self.view().into()
}
fn new(id: AuthorId, path: Vec<PathSegment>) -> Self {
Self::new(id, path)
}
}
impl<T> DebugView for LwwRegisterCrdt<T>
where
T: CrdtNode + DebugView,
{
fn debug_view(&self, indent: usize) -> String {
let spacing = " ".repeat(indent);
let path_str = print_path(self.path.clone());
let inner = self.value.debug_view(indent + 2);
format!("LWW Register CRDT @ /{path_str}\n{spacing}{inner}")
}
}
impl<T> Debug for LwwRegisterCrdt<T>
where
T: CrdtNode,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self.value.id)
}
}
#[cfg(test)]
mod test {
use super::LwwRegisterCrdt;
use crate::{json_crdt::OpState, keypair::make_author};
#[test]
fn test_lww_simple() {
let mut register = LwwRegisterCrdt::new(make_author(1), vec![]);
assert_eq!(register.view(), None);
register.set(1);
assert_eq!(register.view(), Some(1));
register.set(99);
assert_eq!(register.view(), Some(99));
}
#[test]
fn test_lww_multiple_writer() {
let mut register1 = LwwRegisterCrdt::new(make_author(1), vec![]);
let mut register2 = LwwRegisterCrdt::new(make_author(2), vec![]);
let _a = register1.set('a');
let _b = register1.set('b');
let _c = register2.set('c');
assert_eq!(register2.view(), Some('c'));
assert_eq!(register1.apply(_c), OpState::Ok);
assert_eq!(register2.apply(_b), OpState::Ok);
assert_eq!(register2.apply(_a), OpState::Ok);
assert_eq!(register1.view(), Some('b'));
assert_eq!(register2.view(), Some('b'));
}
#[test]
fn test_lww_idempotence() {
let mut register = LwwRegisterCrdt::new(make_author(1), vec![]);
let op = register.set(1);
for _ in 1..10 {
assert_eq!(register.apply(op.clone()), OpState::Ok);
}
assert_eq!(register.view(), Some(1));
}
#[test]
fn test_lww_consistent_tiebreak() {
let mut register1 = LwwRegisterCrdt::new(make_author(1), vec![]);
let mut register2 = LwwRegisterCrdt::new(make_author(2), vec![]);
let _a = register1.set('a');
let _b = register2.set('b');
assert_eq!(register1.apply(_b), OpState::Ok);
assert_eq!(register2.apply(_a), OpState::Ok);
let _c = register1.set('c');
let _d = register2.set('d');
assert_eq!(register2.apply(_c), OpState::Ok);
assert_eq!(register1.apply(_d), OpState::Ok);
assert_eq!(register1.view(), register2.view());
assert_eq!(register1.view(), Some('c'));
}
}
+237
View File
@@ -0,0 +1,237 @@
use crate::debug::{debug_path_mismatch, debug_type_mismatch};
use crate::json_crdt::{CrdtNode, CrdtNodeFromValue, IntoCrdtNode, JsonValue, SignedOp};
use crate::keypair::{sha256, AuthorId};
use fastcrypto::ed25519::Ed25519KeyPair;
use serde::{Deserialize, Serialize};
use std::fmt::Debug;
/// A lamport clock timestamp. Used to track document versions
pub type SequenceNumber = u64;
/// A unique ID for a single [`Op<T>`]
pub type OpId = [u8; 32];
/// The root/sentinel op
pub const ROOT_ID: OpId = [0u8; 32];
/// Part of a path to get to a specific CRDT in a nested CRDT
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum PathSegment {
Field(String),
Index(OpId),
}
/// Format a byte array as a hex string
pub fn print_hex<const N: usize>(bytes: &[u8; N]) -> String {
bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<Vec<_>>()
.join("")
}
/// Pretty print a path
pub fn print_path(path: Vec<PathSegment>) -> String {
path.iter()
.map(|p| match p {
PathSegment::Field(s) => s.to_string(),
PathSegment::Index(i) => print_hex(i)[..6].to_string(),
})
.collect::<Vec<_>>()
.join(".")
}
/// Ensure our_path is a subpath of op_path. Note that two identical paths are considered subpaths
/// of each other.
pub fn ensure_subpath(our_path: &Vec<PathSegment>, op_path: &Vec<PathSegment>) -> bool {
// if our_path is longer, it cannot be a subpath
if our_path.len() > op_path.len() {
debug_path_mismatch(our_path.to_owned(), op_path.to_owned());
return false;
}
// iterate to end of our_path, ensuring each element is the same
for i in 0..our_path.len() {
let ours = our_path.get(i);
let theirs = op_path.get(i);
if ours != theirs {
debug_path_mismatch(our_path.to_owned(), op_path.to_owned());
return false;
}
}
true
}
/// Helper to easily append a [`PathSegment`] to a path
pub fn join_path(path: Vec<PathSegment>, segment: PathSegment) -> Vec<PathSegment> {
let mut p = path;
p.push(segment);
p
}
/// Parse out the field from a [`PathSegment`]
pub fn parse_field(path: Vec<PathSegment>) -> Option<String> {
path.last().and_then(|segment| {
if let PathSegment::Field(key) = segment {
Some(key.to_string())
} else {
None
}
})
}
/// Represents a single node in a CRDT
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub struct Op<T>
where
T: CrdtNode,
{
pub origin: OpId,
pub author: AuthorId, // pub key of author
pub seq: SequenceNumber,
pub content: Option<T>,
pub path: Vec<PathSegment>, // path to get to target CRDT
pub is_deleted: bool,
pub id: OpId, // hash of the operation
}
/// Something can be turned into a string. This allows us to use [`content`] as in
/// input into the SHA256 hash
pub trait Hashable {
fn hash(&self) -> String;
}
/// Anything that implements Debug is trivially hashable
impl<T> Hashable for T
where
T: Debug,
{
fn hash(&self) -> String {
format!("{self:?}")
}
}
/// Conversion from Op<Value> -> Op<T> given that T is a CRDT that can be created from a JSON value
impl Op<JsonValue> {
pub fn into<T: CrdtNodeFromValue + CrdtNode>(self) -> Op<T> {
let content = if let Some(inner_content) = self.content {
match inner_content.into_node(self.id, self.path.clone()) {
Ok(node) => Some(node),
Err(msg) => {
debug_type_mismatch(msg);
None
}
}
} else {
None
};
Op {
content,
origin: self.origin,
author: self.author,
seq: self.seq,
path: self.path,
is_deleted: self.is_deleted,
id: self.id,
}
}
}
impl<T> Op<T>
where
T: CrdtNode,
{
pub fn sign(self, keypair: &Ed25519KeyPair) -> SignedOp {
SignedOp::from_op(self, keypair, vec![])
}
pub fn sign_with_dependencies(
self,
keypair: &Ed25519KeyPair,
dependencies: Vec<&SignedOp>,
) -> SignedOp {
SignedOp::from_op(
self,
keypair,
dependencies
.iter()
.map(|dep| dep.signed_digest)
.collect::<Vec<_>>(),
)
}
pub fn author(&self) -> AuthorId {
self.author
}
pub fn sequence_num(&self) -> SequenceNumber {
self.seq
}
pub fn new(
origin: OpId,
author: AuthorId,
seq: SequenceNumber,
is_deleted: bool,
content: Option<T>,
path: Vec<PathSegment>,
) -> Op<T> {
let mut op = Self {
origin,
id: ROOT_ID,
author,
seq,
is_deleted,
content,
path,
};
op.id = op.hash_to_id();
op
}
/// Generate OpID by hashing our contents. Hash includes
/// - content
/// - origin
/// - author
/// - seq
/// - is_deleted
pub fn hash_to_id(&self) -> OpId {
let content_str = match self.content.as_ref() {
Some(content) => content.hash(),
None => "".to_string(),
};
let fmt_str = format!(
"{:?},{:?},{:?},{:?},{content_str}",
self.origin, self.author, self.seq, self.is_deleted,
);
sha256(fmt_str)
}
/// Rehashes the contents to make sure it matches the ID
pub fn is_valid_hash(&self) -> bool {
// make sure content is only none for deletion events
if self.content.is_none() && !self.is_deleted {
return false;
}
// try to avoid expensive sig check if early fail
let res = self.hash_to_id() == self.id;
if !res {
self.debug_hash_failure();
}
res
}
/// Special constructor for defining the sentinel root node
pub fn make_root() -> Op<T> {
Self {
origin: ROOT_ID,
id: ROOT_ID,
author: [0u8; 32],
seq: 0,
is_deleted: false,
content: None,
path: vec![],
}
}
}
+137
View File
@@ -0,0 +1,137 @@
use bft_json_crdt::{
json_crdt::{add_crdt_fields, BaseCrdt, CrdtNode, IntoCrdtNode, OpState},
keypair::make_keypair,
list_crdt::ListCrdt,
lww_crdt::LwwRegisterCrdt,
op::{Op, PathSegment, ROOT_ID},
};
use serde_json::json;
// What is potentially Byzantine behaviour?
// 1. send valid updates
// 2. send a mix of valid and invalid updates
// a) messages with duplicate ID (attempt to overwrite old entries)
// b) send incorrect sequence number to multiple nodes (which could lead to divergent state) -- this is called equivocation
// c) forge updates from another author (could happen when forwarding valid messages from peers)
// 3. send malformed updates (e.g. missing fields)
// this we don't test as we assume transport layer only allows valid messages
// 4. overwhelm message queue by sending many updates far into the future
// also untested! currently we keep an unbounded message queue
// 5. block actual messages from honest actors (eclipse attack)
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug)]
struct ListExample {
list: ListCrdt<char>,
}
// case 2a + 2b
#[test]
fn test_equivocation() {
let key = make_keypair();
let test_key = make_keypair();
let mut crdt = BaseCrdt::<ListExample>::new(&key);
let mut test_crdt = BaseCrdt::<ListExample>::new(&test_key);
let _a = crdt.doc.list.insert(ROOT_ID, 'a').sign(&key);
let _b = crdt.doc.list.insert(_a.id(), 'b').sign(&key);
// make a fake operation with same id as _b but different content
let mut fake_op = _b.clone();
fake_op.inner.content = Some('c'.into());
// also try modifying the sequence number
let mut fake_op_seq = _b.clone();
fake_op_seq.inner.seq = 99;
fake_op_seq.inner.is_deleted = true;
assert_eq!(crdt.apply(fake_op.clone()), OpState::ErrHashMismatch);
assert_eq!(crdt.apply(fake_op_seq.clone()), OpState::ErrHashMismatch);
assert_eq!(test_crdt.apply(fake_op_seq), OpState::ErrHashMismatch);
assert_eq!(test_crdt.apply(fake_op), OpState::ErrHashMismatch);
assert_eq!(test_crdt.apply(_a), OpState::Ok);
assert_eq!(test_crdt.apply(_b), OpState::Ok);
// make sure it doesn't accept either of the fake operations
assert_eq!(crdt.doc.list.view(), vec!['a', 'b']);
assert_eq!(crdt.doc.list.view(), test_crdt.doc.list.view());
}
// case 2c
#[test]
fn test_forge_update() {
let key = make_keypair();
let test_key = make_keypair();
let mut crdt = BaseCrdt::<ListExample>::new(&key);
let mut test_crdt = BaseCrdt::<ListExample>::new(&test_key);
let _a = crdt.doc.list.insert(ROOT_ID, 'a').sign(&key);
let fake_key = make_keypair(); // generate a new keypair as we don't have private key of list.our_id
let mut op = Op {
origin: _a.inner.id,
author: crdt.doc.id, // pretend to be the owner of list
content: Some('b'),
path: vec![PathSegment::Field("list".to_string())],
seq: 1,
is_deleted: false,
id: ROOT_ID, // placeholder, to be generated
};
// this is a completely valid hash and digest, just signed by the wrong person
// as keypair.public != list.public
op.id = op.hash_to_id();
let signed = op.sign(&fake_key);
assert_eq!(crdt.apply(signed.clone()), OpState::ErrHashMismatch);
assert_eq!(test_crdt.apply(signed), OpState::ErrHashMismatch);
assert_eq!(test_crdt.apply(_a), OpState::Ok);
// make sure it doesn't accept fake operation
assert_eq!(crdt.doc.list.view(), vec!['a']);
}
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug)]
struct Nested {
a: Nested2,
}
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug)]
struct Nested2 {
b: LwwRegisterCrdt<bool>,
}
#[test]
fn test_path_update() {
let key = make_keypair();
let test_key = make_keypair();
let mut crdt = BaseCrdt::<Nested>::new(&key);
let mut test_crdt = BaseCrdt::<Nested>::new(&test_key);
let mut _true = crdt.doc.a.b.set(true);
_true.path = vec![PathSegment::Field("x".to_string())];
let mut _false = crdt.doc.a.b.set(false);
_false.path = vec![
PathSegment::Field("a".to_string()),
PathSegment::Index(_false.id),
];
let signed_true = _true.sign(&key);
let signed_false = _false.sign(&key);
let mut signed_false_fake_path = signed_false.clone();
signed_false_fake_path.inner.path = vec![
PathSegment::Field("a".to_string()),
PathSegment::Field("b".to_string()),
];
assert_eq!(test_crdt.apply(signed_true), OpState::ErrPathMismatch);
assert_eq!(test_crdt.apply(signed_false), OpState::ErrPathMismatch);
assert_eq!(
test_crdt.apply(signed_false_fake_path),
OpState::ErrDigestMismatch
);
// make sure it doesn't accept fake operation
assert_eq!(crdt.doc.a.b.view(), json!(false).into());
assert_eq!(test_crdt.doc.a.b.view(), json!(null).into());
}
+92
View File
@@ -0,0 +1,92 @@
use bft_json_crdt::{
json_crdt::{CrdtNode, JsonValue},
keypair::make_author,
list_crdt::ListCrdt,
op::{Op, OpId, ROOT_ID},
};
use rand::{rngs::ThreadRng, seq::SliceRandom, Rng};
fn random_op<T: CrdtNode>(arr: &[Op<T>], rng: &mut ThreadRng) -> OpId {
arr.choose(rng).map(|op| op.id).unwrap_or(ROOT_ID)
}
const TEST_N: usize = 100;
#[test]
fn test_list_fuzz_commutative() {
let mut rng = rand::thread_rng();
let mut op_log = Vec::<Op<JsonValue>>::new();
let mut op_log1 = Vec::<Op<JsonValue>>::new();
let mut op_log2 = Vec::<Op<JsonValue>>::new();
let mut l1 = ListCrdt::<char>::new(make_author(1), vec![]);
let mut l2 = ListCrdt::<char>::new(make_author(2), vec![]);
let mut chk = ListCrdt::<char>::new(make_author(3), vec![]);
for _ in 0..TEST_N {
let letter1: char = rng.gen_range(b'a'..=b'z') as char;
let letter2: char = rng.gen_range(b'a'..=b'z') as char;
let op1 = if rng.gen_bool(4.0 / 5.0) {
l1.insert(random_op(&op_log1, &mut rng), letter1)
} else {
l1.delete(random_op(&op_log1, &mut rng))
};
let op2 = if rng.gen_bool(4.0 / 5.0) {
l2.insert(random_op(&op_log2, &mut rng), letter2)
} else {
l2.delete(random_op(&op_log2, &mut rng))
};
op_log1.push(op1.clone());
op_log2.push(op2.clone());
op_log.push(op1.clone());
op_log.push(op2.clone());
}
// shuffle ops
op_log1.shuffle(&mut rng);
op_log2.shuffle(&mut rng);
// apply to each other
for op in op_log1 {
l2.apply(op.clone());
chk.apply(op.into());
}
for op in op_log2 {
l1.apply(op.clone());
chk.apply(op);
}
// ensure all equal
let l1_doc = l1.view();
let l2_doc = l2.view();
let chk_doc = chk.view();
assert_eq!(l1_doc, l2_doc);
assert_eq!(l1_doc, chk_doc);
assert_eq!(l2_doc, chk_doc);
// now, allow cross mixing between both
let mut op_log1 = Vec::<Op<JsonValue>>::new();
let mut op_log2 = Vec::<Op<JsonValue>>::new();
for _ in 0..TEST_N {
let letter1: char = rng.gen_range(b'a'..=b'z') as char;
let letter2: char = rng.gen_range(b'a'..=b'z') as char;
let op1 = l1.insert(random_op(&op_log, &mut rng), letter1);
let op2 = l2.insert(random_op(&op_log, &mut rng), letter2);
op_log1.push(op1);
op_log2.push(op2);
}
for op in op_log1 {
l2.apply(op.clone());
chk.apply(op);
}
for op in op_log2 {
l1.apply(op.clone());
chk.apply(op);
}
let l1_doc = l1.view();
let l2_doc = l2.view();
let chk_doc = chk.view();
assert_eq!(l1_doc, l2_doc);
assert_eq!(l1_doc, chk_doc);
assert_eq!(l2_doc, chk_doc);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,79 @@
use bft_json_crdt::keypair::make_author;
use bft_json_crdt::list_crdt::ListCrdt;
use bft_json_crdt::op::{OpId, ROOT_ID};
use std::{fs::File, io::Read};
use time::PreciseTime;
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
struct Edit {
pos: usize,
delete: bool,
#[serde(default)]
content: Option<char>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Trace {
final_text: String,
edits: Vec<Edit>,
}
fn get_trace() -> Trace {
let fp = "./tests/edits.json";
match File::open(fp) {
Err(e) => panic!("Open edits.json failed: {:?}", e.kind()),
Ok(mut file) => {
let mut content: String = String::new();
file.read_to_string(&mut content)
.expect("Problem reading file");
serde_json::from_str(&content).expect("JSON was not well-formatted")
}
}
}
/// Really large test to run Martin Kleppmann's
/// editing trace over his paper
/// Data source: https://github.com/automerge/automerge-perf
// Commented out: takes 10+ minutes and 12GB+ RAM. Run manually with:
// cargo test --package bft-json-crdt --test kleppmann_trace -- --ignored
#[test]
#[ignore]
fn test_editing_trace() {
let t = get_trace();
let mut list = ListCrdt::<char>::new(make_author(1), vec![]);
let mut ops: Vec<OpId> = Vec::new();
ops.push(ROOT_ID);
let start = PreciseTime::now();
let edits = t.edits;
for (i, op) in edits.into_iter().enumerate() {
let origin = ops[op.pos];
if op.delete {
let delete_op = list.delete(origin);
ops.push(delete_op.id);
} else {
let new_op = list.insert(origin, op.content.unwrap());
ops.push(new_op.id);
}
match i {
10_000 | 100_000 => {
let end = PreciseTime::now();
let runtime_sec = start.to(end);
println!("took {runtime_sec:?} to run {i} ops");
}
_ => {}
};
}
let end = PreciseTime::now();
let runtime_sec = start.to(end);
println!("took {runtime_sec:?} to finish");
let result = list.iter().collect::<String>();
let expected = t.final_text;
assert_eq!(result.len(), expected.len());
assert_eq!(result, expected);
}
+4 -3
View File
@@ -9,8 +9,8 @@
FROM rust:1.90-bookworm AS base FROM rust:1.90-bookworm AS base
# Clippy is needed at runtime for acceptance gates (cargo clippy) # Clippy and rustfmt are needed at runtime for acceptance gates
RUN rustup component add clippy RUN rustup component add clippy rustfmt
# ── System deps ────────────────────────────────────────────────────── # ── System deps ──────────────────────────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -109,7 +109,8 @@ RUN groupadd -r huskies \
&& chown -R huskies:huskies /usr/local/cargo /usr/local/rustup \ && chown -R huskies:huskies /usr/local/cargo /usr/local/rustup \
&& chown -R huskies:huskies /app \ && chown -R huskies:huskies /app \
&& mkdir -p /workspace/target /app/target \ && mkdir -p /workspace/target /app/target \
&& chown huskies:huskies /workspace/target /app/target && chown huskies:huskies /workspace/target /app/target \
&& git config --global init.defaultBranch master
# ── Entrypoint ─────────────────────────────────────────────────────── # ── Entrypoint ───────────────────────────────────────────────────────
# Validates required env vars (GIT_USER_NAME, GIT_USER_EMAIL) and # Validates required env vars (GIT_USER_NAME, GIT_USER_EMAIL) and
+19
View File
@@ -69,6 +69,16 @@ services:
- workspace-target:/workspace/target - workspace-target:/workspace/target
- huskies-target:/app/target - huskies-target:/app/target
# Isolate frontend node_modules from the host.
# npm install pulls platform-specific native binaries (esbuild,
# rollup, etc.) — macOS binaries won't run on Linux and vice versa.
# Without this volume, building on the Mac host writes macOS
# node_modules into the bind mount, then the Linux container tries
# to execute them and fails. The Docker volume gives the container
# its own Linux-native node_modules that doesn't collide with the
# host's.
- frontend-modules:/workspace/frontend/node_modules
# ── Security hardening ────────────────────────────────────────── # ── Security hardening ──────────────────────────────────────────
# Read-only root filesystem. Only explicitly mounted volumes and # Read-only root filesystem. Only explicitly mounted volumes and
# tmpfs paths are writable. # tmpfs paths are writable.
@@ -108,6 +118,14 @@ services:
retries: 3 retries: 3
start_period: 10s start_period: 10s
# Log rotation test output goes to stdout via Stdio::inherit,
# so container logs grow fast without rotation.
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
# Use tini as PID 1 to reap zombie child processes. # Use tini as PID 1 to reap zombie child processes.
# Without this, grandchild processes (esbuild, cargo, etc.) spawned by # Without this, grandchild processes (esbuild, cargo, etc.) spawned by
# npm/cargo during worktree setup and gate checks become zombies. # npm/cargo during worktree setup and gate checks become zombies.
@@ -122,3 +140,4 @@ volumes:
claude-state: claude-state:
workspace-target: workspace-target:
huskies-target: huskies-target:
frontend-modules:
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/huskies.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Huskies</title> <title>Huskies</title>
</head> </head>
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "living-spec-standalone", "name": "huskies",
"version": "0.9.0", "version": "0.10.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "living-spec-standalone", "name": "huskies",
"version": "0.9.0", "version": "0.10.2",
"dependencies": { "dependencies": {
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"react": "^19.1.0", "react": "^19.1.0",
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "living-spec-standalone", "name": "huskies",
"private": true, "private": true,
"version": "0.9.0", "version": "0.10.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+35
View File
@@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- Left ear -->
<polygon points="5,14 8,4 13,13" fill="#4b5563"/>
<polygon points="6.5,12.5 8.5,6 12,12" fill="#9ca3af"/>
<!-- Right ear -->
<polygon points="27,14 24,4 19,13" fill="#4b5563"/>
<polygon points="25.5,12.5 23.5,6 20,12" fill="#9ca3af"/>
<!-- Head -->
<circle cx="16" cy="18" r="12" fill="#6b7280"/>
<!-- White face mask -->
<ellipse cx="16" cy="21" rx="8" ry="7" fill="#f9fafb"/>
<!-- Left eye white -->
<circle cx="12" cy="16" r="3" fill="white"/>
<!-- Left eye iris - blue (husky trait) -->
<circle cx="12.3" cy="16" r="2" fill="#3b82f6"/>
<!-- Left eye pupil -->
<circle cx="12.3" cy="16" r="1" fill="#111827"/>
<!-- Left eye highlight -->
<circle cx="11.7" cy="15.3" r="0.5" fill="white"/>
<!-- Right eye white -->
<circle cx="20" cy="16" r="3" fill="white"/>
<!-- Right eye iris - blue -->
<circle cx="20.3" cy="16" r="2" fill="#3b82f6"/>
<!-- Right eye pupil -->
<circle cx="20.3" cy="16" r="1" fill="#111827"/>
<!-- Right eye highlight -->
<circle cx="19.7" cy="15.3" r="0.5" fill="white"/>
<!-- Nose -->
<ellipse cx="16" cy="22" rx="2.5" ry="1.8" fill="#1f2937"/>
<!-- Nose highlight -->
<ellipse cx="15.3" cy="21.3" rx="0.7" ry="0.5" fill="#6b7280"/>
<!-- Mouth line -->
<path d="M16,23.5 Q14,25 13,24.5" stroke="#9ca3af" stroke-width="0.6" fill="none" stroke-linecap="round"/>
<path d="M16,23.5 Q18,25 19,24.5" stroke="#9ca3af" stroke-width="0.6" fill="none" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+53
View File
@@ -345,6 +345,59 @@ describe("App", () => {
}); });
}); });
it("sets document title to Huskies when no project is open", async () => {
mockedApi.getCurrentProject.mockResolvedValue(null);
await renderApp();
await waitFor(() => {
expect(document.title).toBe("Huskies");
});
});
it("sets document title to project name when a project is open", async () => {
mockedApi.getCurrentProject.mockResolvedValue("/home/user/reclaimer");
await renderApp();
await waitFor(() => {
expect(document.title).toBe("reclaimer | Huskies");
});
});
it("resets document title to Huskies after closing project", async () => {
mockedApi.openProject.mockResolvedValue("/home/user/myproject");
mockedApi.closeProject.mockResolvedValue(true);
await renderApp();
await waitFor(() => {
expect(
screen.getByPlaceholderText(/\/path\/to\/project/i),
).toBeInTheDocument();
});
const input = screen.getByPlaceholderText(
/\/path\/to\/project/i,
) as HTMLInputElement;
await userEvent.clear(input);
await userEvent.type(input, "/home/user/myproject");
const openButton = screen.getByRole("button", { name: /open project/i });
await userEvent.click(openButton);
await waitFor(() => {
expect(document.title).toBe("myproject | Huskies");
});
const closeButton = await waitFor(() => screen.getByText("✕"));
await userEvent.click(closeButton);
await waitFor(() => {
expect(document.title).toBe("Huskies");
});
});
it("handles Enter key to trigger project open", async () => { it("handles Enter key to trigger project open", async () => {
mockedApi.openProject.mockResolvedValue("/home/user/myproject"); mockedApi.openProject.mockResolvedValue("/home/user/myproject");
+29 -1
View File
@@ -2,11 +2,14 @@ import * as React from "react";
import type { OAuthStatus } from "./api/client"; import type { OAuthStatus } from "./api/client";
import { api } from "./api/client"; import { api } from "./api/client";
import { Chat } from "./components/Chat"; import { Chat } from "./components/Chat";
import { GatewayPanel } from "./components/GatewayPanel";
import { SelectionScreen } from "./components/selection/SelectionScreen"; import { SelectionScreen } from "./components/selection/SelectionScreen";
import { usePathCompletion } from "./components/selection/usePathCompletion"; import { usePathCompletion } from "./components/selection/usePathCompletion";
import { gatewayApi } from "./api/gateway";
import "./App.css"; import "./App.css";
function App() { function App() {
const [isGateway, setIsGateway] = React.useState<boolean | null>(null);
const [projectPath, setProjectPath] = React.useState<string | null>(null); const [projectPath, setProjectPath] = React.useState<string | null>(null);
const [_view, setView] = React.useState<"chat" | "token-usage">("chat"); const [_view, setView] = React.useState<"chat" | "token-usage">("chat");
const [isCheckingProject, setIsCheckingProject] = React.useState(true); const [isCheckingProject, setIsCheckingProject] = React.useState(true);
@@ -19,6 +22,14 @@ function App() {
null, null,
); );
// Detect gateway mode on startup — if /gateway/mode returns 200, we're a gateway.
React.useEffect(() => {
gatewayApi
.getServerMode()
.then((result) => setIsGateway(result.mode === "gateway"))
.catch(() => setIsGateway(false));
}, []);
React.useEffect(() => { React.useEffect(() => {
let active = true; let active = true;
function fetchOAuthStatus() { function fetchOAuthStatus() {
@@ -51,6 +62,17 @@ function App() {
}); });
}, []); }, []);
React.useEffect(() => {
if (projectPath) {
const projectName =
projectPath.replace(/\\/g, "/").split("/").filter(Boolean).pop() ??
projectPath;
document.title = `${projectName} | Huskies`;
} else {
document.title = "Huskies";
}
}, [projectPath]);
React.useEffect(() => { React.useEffect(() => {
api api
.getKnownProjects() .getKnownProjects()
@@ -177,10 +199,16 @@ function App() {
} }
} }
if (isCheckingProject) { // Still probing server mode — wait before rendering.
if (isGateway === null || isCheckingProject) {
return null; return null;
} }
// Gateway mode: render the agent management UI instead of the normal chat.
if (isGateway) {
return <GatewayPanel />;
}
return ( return (
<main <main
className="container" className="container"
+43
View File
@@ -0,0 +1,43 @@
export interface BotConfig {
transport: string | null;
enabled: boolean | null;
homeserver: string | null;
username: string | null;
password: string | null;
room_ids: string[] | null;
slack_bot_token: string | null;
slack_signing_secret: string | null;
slack_channel_ids: string[] | null;
}
const DEFAULT_API_BASE = "/api";
async function requestJson<T>(
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
): Promise<T> {
const res = await fetch(`${baseUrl}${path}`, {
headers: { "Content-Type": "application/json", ...(options.headers ?? {}) },
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
return res.json() as Promise<T>;
}
export const botConfigApi = {
getConfig(baseUrl?: string): Promise<BotConfig> {
return requestJson<BotConfig>("/bot/config", {}, baseUrl);
},
saveConfig(config: BotConfig, baseUrl?: string): Promise<BotConfig> {
return requestJson<BotConfig>(
"/bot/config",
{ method: "PUT", body: JSON.stringify(config) },
baseUrl,
);
},
};
+1
View File
@@ -48,6 +48,7 @@ export interface PipelineStageItem {
agent: AgentAssignment | null; agent: AgentAssignment | null;
review_hold: boolean | null; review_hold: boolean | null;
qa: string | null; qa: string | null;
depends_on: number[] | null;
} }
export interface PipelineState { export interface PipelineState {
+149
View File
@@ -0,0 +1,149 @@
/// Gateway API client — used when running in gateway mode.
///
/// The gateway mode is detected by checking `GET /gateway/mode`. If it returns
/// `{ "mode": "gateway" }` the frontend switches to the gateway UI.
export interface JoinedAgent {
id: string;
label: string;
address: string;
registered_at: number;
/// Unix timestamp of the last heartbeat from this agent.
last_seen: number;
/// Project this agent is assigned to, if any.
assigned_project?: string;
}
export interface GatewayProject {
name: string;
url: string;
}
export interface GatewayInfo {
active: string;
projects: GatewayProject[];
}
export interface PipelineItem {
story_id: string;
name: string;
stage: string;
agent?: { agent_name: string; model: string; status: string } | null;
blocked?: boolean;
retry_count?: number;
merge_failure?: string;
}
export interface ProjectPipelineStatus {
active: PipelineItem[];
backlog: { story_id: string; name: string }[];
backlog_count: number;
error?: string;
}
export interface AllProjectsPipeline {
active: string;
projects: Record<string, ProjectPipelineStatus>;
}
export interface GenerateTokenResponse {
token: string;
}
export interface ServerMode {
mode: "gateway" | "standard";
}
async function gatewayRequest<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const res = await fetch(path, {
headers: { "Content-Type": "application/json", ...(options.headers ?? {}) },
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
// DELETE /gateway/agents/:id returns 204 No Content.
if (res.status === 204) {
return undefined as unknown as T;
}
return res.json() as Promise<T>;
}
export const gatewayApi = {
/// Returns `{ mode: "gateway" }` if this server is a gateway, otherwise rejects.
getServerMode(): Promise<ServerMode> {
return gatewayRequest<ServerMode>("/gateway/mode");
},
/// Generate a one-time join token for a new build agent.
generateToken(): Promise<GenerateTokenResponse> {
return gatewayRequest<GenerateTokenResponse>("/gateway/tokens", {
method: "POST",
});
},
/// List all build agents that have registered with this gateway.
listAgents(): Promise<JoinedAgent[]> {
return gatewayRequest<JoinedAgent[]>("/gateway/agents");
},
/// Remove a registered build agent by its ID.
removeAgent(id: string): Promise<void> {
return gatewayRequest<void>(`/gateway/agents/${id}`, {
method: "DELETE",
});
},
/// Assign an agent to a project, or unassign it by passing null.
assignAgent(id: string, project: string | null): Promise<JoinedAgent> {
return gatewayRequest<JoinedAgent>(`/gateway/agents/${id}/assign`, {
method: "POST",
body: JSON.stringify({ project }),
});
},
/// Get the list of registered projects from the gateway.
getGatewayInfo(): Promise<GatewayInfo> {
return gatewayRequest<GatewayInfo>("/api/gateway");
},
/// Add a new project to the gateway config.
addProject(name: string, url: string): Promise<GatewayProject> {
return gatewayRequest<GatewayProject>("/api/gateway/projects", {
method: "POST",
body: JSON.stringify({ name, url }),
});
},
/// Remove a project from the gateway config.
removeProject(name: string): Promise<void> {
return gatewayRequest<void>(
`/api/gateway/projects/${encodeURIComponent(name)}`,
{ method: "DELETE" },
);
},
/// Send a heartbeat for an agent to update its last-seen timestamp.
heartbeat(id: string): Promise<void> {
return gatewayRequest<void>(`/gateway/agents/${id}/heartbeat`, {
method: "POST",
});
},
/// Fetch pipeline status from all registered projects.
getAllProjectsPipeline(): Promise<AllProjectsPipeline> {
return gatewayRequest<AllProjectsPipeline>("/api/gateway/pipeline");
},
/// Switch the active project.
switchProject(project: string): Promise<{ ok: boolean; error?: string }> {
return gatewayRequest<{ ok: boolean; error?: string }>(
"/api/gateway/switch",
{ method: "POST", body: JSON.stringify({ project }) },
);
},
};
+112
View File
@@ -0,0 +1,112 @@
interface ApiKeyDialogProps {
apiKeyInput: string;
onApiKeyChange: (value: string) => void;
onSave: () => void;
onCancel: () => void;
}
export function ApiKeyDialog({
apiKeyInput,
onApiKeyChange,
onSave,
onCancel,
}: ApiKeyDialogProps) {
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
<div
style={{
backgroundColor: "#2f2f2f",
padding: "32px",
borderRadius: "12px",
maxWidth: "500px",
width: "90%",
border: "1px solid #444",
}}
>
<h2 style={{ marginTop: 0, color: "#ececec" }}>
Enter Anthropic API Key
</h2>
<p
style={{
color: "#aaa",
fontSize: "0.9em",
marginBottom: "20px",
}}
>
To use Claude models, please enter your Anthropic API key. Your key
will be stored server-side and reused across sessions.
</p>
<input
type="password"
value={apiKeyInput}
onChange={(e) => onApiKeyChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onSave()}
placeholder="sk-ant-..."
style={{
width: "100%",
padding: "12px",
borderRadius: "8px",
border: "1px solid #555",
backgroundColor: "#1a1a1a",
color: "#ececec",
fontSize: "1em",
marginBottom: "20px",
outline: "none",
}}
/>
<div
style={{
display: "flex",
gap: "12px",
justifyContent: "flex-end",
}}
>
<button
type="button"
onClick={onCancel}
style={{
padding: "10px 20px",
borderRadius: "8px",
border: "1px solid #555",
backgroundColor: "transparent",
color: "#aaa",
cursor: "pointer",
fontSize: "0.9em",
}}
>
Cancel
</button>
<button
type="button"
onClick={onSave}
disabled={!apiKeyInput.trim()}
style={{
padding: "10px 20px",
borderRadius: "8px",
border: "none",
backgroundColor: apiKeyInput.trim() ? "#ececec" : "#555",
color: apiKeyInput.trim() ? "#000" : "#888",
cursor: apiKeyInput.trim() ? "pointer" : "not-allowed",
fontSize: "0.9em",
}}
>
Save Key
</button>
</div>
</div>
</div>
);
}
+344
View File
@@ -0,0 +1,344 @@
import * as React from "react";
import type { BotConfig } from "../api/bot_config";
import { botConfigApi } from "../api/bot_config";
const { useState, useEffect } = React;
interface BotConfigPageProps {
onBack: () => void;
}
const fieldStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: "4px",
};
const labelStyle: React.CSSProperties = {
fontSize: "0.8em",
color: "#aaa",
fontWeight: 500,
};
const inputStyle: React.CSSProperties = {
padding: "8px 10px",
borderRadius: "6px",
border: "1px solid #333",
background: "#1e1e1e",
color: "#ececec",
fontSize: "0.9em",
fontFamily: "monospace",
outline: "none",
};
const sectionStyle: React.CSSProperties = {
background: "#1e1e1e",
border: "1px solid #333",
borderRadius: "8px",
padding: "20px",
display: "flex",
flexDirection: "column",
gap: "14px",
};
const sectionTitleStyle: React.CSSProperties = {
fontSize: "0.85em",
fontWeight: 600,
color: "#aaa",
textTransform: "uppercase",
letterSpacing: "0.06em",
marginBottom: "2px",
};
function Field({
label,
value,
onChange,
placeholder,
type = "text",
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
}) {
return (
<div style={fieldStyle}>
<label style={labelStyle}>{label}</label>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={inputStyle}
autoComplete="off"
/>
</div>
);
}
function ListField({
label,
value,
onChange,
placeholder,
}: {
label: string;
value: string[];
onChange: (v: string[]) => void;
placeholder?: string;
}) {
return (
<div style={fieldStyle}>
<label style={labelStyle}>{label} (one per line)</label>
<textarea
value={value.join("\n")}
onChange={(e) =>
onChange(e.target.value.split("\n").filter((s) => s.trim()))
}
placeholder={placeholder}
rows={3}
style={{ ...inputStyle, resize: "vertical" }}
/>
</div>
);
}
/// Bot configuration page — form for Matrix and Slack credentials.
export function BotConfigPage({ onBack }: BotConfigPageProps) {
const [transport, setTransport] = useState<"matrix" | "slack">("matrix");
const [enabled, setEnabled] = useState(false);
const [homeserver, setHomeserver] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [roomIds, setRoomIds] = useState<string[]>([]);
const [slackBotToken, setSlackBotToken] = useState("");
const [slackSigningSecret, setSlackSigningSecret] = useState("");
const [slackChannelIds, setSlackChannelIds] = useState<string[]>([]);
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">(
"idle",
);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
useEffect(() => {
botConfigApi
.getConfig()
.then((cfg) => {
if (cfg.transport === "slack") setTransport("slack");
setEnabled(cfg.enabled ?? false);
setHomeserver(cfg.homeserver ?? "");
setUsername(cfg.username ?? "");
setPassword(cfg.password ?? "");
setRoomIds(cfg.room_ids ?? []);
setSlackBotToken(cfg.slack_bot_token ?? "");
setSlackSigningSecret(cfg.slack_signing_secret ?? "");
setSlackChannelIds(cfg.slack_channel_ids ?? []);
})
.catch(() => {});
}, []);
function buildConfig(): BotConfig {
return {
transport,
enabled,
homeserver: homeserver || null,
username: username || null,
password: password || null,
room_ids: roomIds.length > 0 ? roomIds : null,
slack_bot_token: slackBotToken || null,
slack_signing_secret: slackSigningSecret || null,
slack_channel_ids: slackChannelIds.length > 0 ? slackChannelIds : null,
};
}
async function handleSave() {
setStatus("saving");
setErrorMsg(null);
try {
await botConfigApi.saveConfig(buildConfig());
setStatus("saved");
setTimeout(() => setStatus("idle"), 2000);
} catch (e) {
setStatus("error");
setErrorMsg(e instanceof Error ? e.message : "Save failed");
}
}
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
backgroundColor: "#171717",
color: "#ececec",
overflow: "auto",
}}
>
<div
style={{
padding: "12px 24px",
borderBottom: "1px solid #333",
display: "flex",
alignItems: "center",
gap: "16px",
background: "#171717",
flexShrink: 0,
}}
>
<button
type="button"
onClick={onBack}
style={{
background: "transparent",
border: "none",
cursor: "pointer",
color: "#888",
fontSize: "0.9em",
padding: "4px 8px",
borderRadius: "4px",
}}
>
Back
</button>
<span style={{ fontWeight: 700, fontSize: "1em" }}>
Bot Configuration
</span>
</div>
<div
style={{
flex: 1,
padding: "24px",
display: "flex",
flexDirection: "column",
gap: "20px",
maxWidth: "600px",
}}
>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>General</div>
<div style={fieldStyle}>
<label style={labelStyle}>Transport</label>
<select
value={transport}
onChange={(e) =>
setTransport(e.target.value as "matrix" | "slack")
}
style={{ ...inputStyle, cursor: "pointer" }}
>
<option value="matrix">Matrix</option>
<option value="slack">Slack</option>
</select>
</div>
<label
style={{
display: "flex",
alignItems: "center",
gap: "8px",
cursor: "pointer",
fontSize: "0.9em",
color: "#ccc",
}}
>
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
Enabled
</label>
</div>
{transport === "matrix" && (
<div style={sectionStyle}>
<div style={sectionTitleStyle}>Matrix Credentials</div>
<Field
label="Homeserver"
value={homeserver}
onChange={setHomeserver}
placeholder="https://matrix.example.com"
/>
<Field
label="Username"
value={username}
onChange={setUsername}
placeholder="@botname:example.com"
/>
<Field
label="Password"
value={password}
onChange={setPassword}
placeholder="bot password"
type="password"
/>
<ListField
label="Room IDs"
value={roomIds}
onChange={setRoomIds}
placeholder="!roomid:example.com"
/>
</div>
)}
{transport === "slack" && (
<div style={sectionStyle}>
<div style={sectionTitleStyle}>Slack Credentials</div>
<Field
label="Bot Token"
value={slackBotToken}
onChange={setSlackBotToken}
placeholder="xoxb-..."
/>
<Field
label="Signing Secret"
value={slackSigningSecret}
onChange={setSlackSigningSecret}
placeholder="signing secret"
type="password"
/>
<ListField
label="Channel IDs"
value={slackChannelIds}
onChange={setSlackChannelIds}
placeholder="C01ABCDEF"
/>
</div>
)}
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<button
type="button"
onClick={handleSave}
disabled={status === "saving"}
style={{
padding: "8px 24px",
borderRadius: "6px",
border: "none",
background: status === "saved" ? "#1a5c2a" : "#2563eb",
color: "#fff",
cursor: status === "saving" ? "not-allowed" : "pointer",
fontSize: "0.9em",
fontWeight: 600,
opacity: status === "saving" ? 0.7 : 1,
}}
>
{status === "saving"
? "Saving..."
: status === "saved"
? "Saved!"
: "Save"}
</button>
{status === "error" && errorMsg && (
<span style={{ color: "#f08080", fontSize: "0.85em" }}>
{errorMsg}
</span>
)}
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff

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