In gateway mode the bot's Claude Code CLI was spawned with cwd set to
a nonexistent project subdirectory (gateway_config_dir/project_name).
This meant it couldn't find .mcp.json and had no MCP tools available.
Now the bot uses the gateway config directory as cwd in gateway mode,
where the auto-generated .mcp.json points to the gateway's MCP proxy.
Also fixes cargo fmt formatting.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The gateway proxy was sending every message's first word to the project
server's /api/bot/command endpoint, then displaying the "Unknown command"
response before falling through to the LLM. Now the proxy only fires
when the first word matches a known bot command.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
In gateway mode the bot has no local CRDT or project filesystem, so all
bot commands (status, backlog, start, assign, etc.) returned empty or
broken results. Now the gateway bot proxies non-local commands via HTTP
to the active project's /api/bot/command endpoint, which already exists
on every project server.
Only a small set of gateway-local commands (help, ambient, reset, switch)
are still handled directly by the gateway. Everything else is forwarded
automatically, so new commands added in the future will work through the
proxy without additional gateway changes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Strips the YAML front matter block and shows useful fields
(depends_on, agent, blocked, retries) as a summary line at the top.
Eliminates the duplicate title problem.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Element X doesn't style <h2> tags distinctly. Convert ## headings to
**bold** text with a blank line above for consistent rendering across
all Matrix clients.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Rename all references from storkit to huskies across the codebase:
- .storkit/ directory → .huskies/
- Binary name, Cargo package name, Docker image references
- Server code, frontend code, config files, scripts
- Fix script/test to build frontend before cargo clippy/test
so merge worktrees have frontend/dist available for RustEmbed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The timer tick loop now calls move_story_to_current() before
start_agent(), so stories scheduled from the backlog are moved into the
pipeline automatically when the timer fires. The timer bot command also
accepts backlog stories (previously required current).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
strip_mention_separator now skips all non-ASCII-alphanumeric chars
(emoji, colons, spaces) and returns a slice starting at the first
command character. Fixes mention pills with emoji display names
(e.g. "timmy ⚡️ status") not matching bot commands.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The /help test expected the help overlay to appear, but /help now goes
through botCommand like other slash commands. Updated the test to match.
Also added reader thread join and child.wait() calls to
claude_code.rs to prevent PTY master fd leaks from web UI chat sessions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The reader thread spawned in run_agent_pty_blocking was never joined,
leaving a cloned PTY master fd open after the agent exited. When the
pipeline restarted the agent on the same worktree, the stale fd from
the previous session interfered with the new PTY allocation, causing
Claude Code's bundled ripgrep to crash with:
fatal runtime error: assertion failed: output.write(&bytes).is_ok()
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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).
Docs live in `website/docs/*.html` (static HTML), **not** Markdown files. When a story asks you to document something, edit the relevant `.html` file in `website/docs/`.
## Configuration files
- Agent config: `.huskies/agents.toml` (preferred) or `[[agent]]` blocks in `.huskies/project.toml`
- Project settings: `.huskies/project.toml`
- Bot credentials: `.huskies/bot.toml` (gitignored — never commit)
## Frontend build
The frontend is embedded into the Rust binary via `rust-embed`. Run `npm run build` in `frontend/` before testing frontend changes, or the embedded assets will be stale.
## Quality gates (all enforced by `script/test`)
1.`npm run build` (frontend)
2.`cargo fmt --all --check`
3.`cargo clippy -- -D warnings`
4.`cargo test`
5.`npm test` (frontend Vitest)
Clippy is zero-tolerance: no warnings allowed. Fix every warning before committing.
## Runtime validation
The `validate_agents` function in `server/src/config.rs` rejects unknown runtimes. Supported values: `"claude-code"` and `"gemini"`. Adding a new runtime requires updating that function.
**Target Audience:** LLM agents working as engineers.
**Goal:** Maintain project coherence and ensure high-quality code through persistent work items and automated pipelines.
---
## 0. First Steps (For New Agent Sessions)
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. Pipeline Overview
Work items (stories, bugs, spikes, refactors) move through stages managed by a CRDT state machine:
**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.
---
## 2. Your Workflow as a Coder Agent
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.
**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.
---
## 3. Work Item Types
- **Story:** New functionality → implement and test
- **Bug:** Broken functionality → fix with minimal surgical change
- **Spike:** Research/uncertainty → investigate, document findings, no production code
- **Refactor:** Code improvement → restructure without changing behaviour
---
## 4. Bug Workflow
When working on bugs:
1. Read the story description first. If it specifies exact files and locations, go directly there.
2. If not specified, investigate with targeted grep.
3. Fix with a surgical, minimal change.
4. Commit early. Don't spend turns on unnecessary verification.
---
## 5. Code Quality
Before exiting, ensure your code compiles and tests pass. Use `run_tests` MCP tool to verify. Fix all errors and warnings — zero tolerance.
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
```
Full server: web UI, MCP endpoint, chat bot, agent pool, pipeline. One project per instance.
### Headless Build Agent
```
huskies --rendezvous ws://host:port/crdt-sync
```
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)
```
huskies --gateway [--port 3000] /path/to/config
```
Lightweight proxy that sits in front of multiple project containers. Reads a `projects.toml` that maps project names to container URLs:
```toml
[projects.huskies]
url="http://huskies:3001"
[projects.robot-studio]
url="http://robot-studio:3002"
```
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 |
| `init_project` | Scaffold a new `.huskies/` project at a given path — prefer this over asking the user to run `huskies init` on the CLI |
**Initialising a new project via MCP (preferred):** Instead of asking the user to run `huskies init <path>` in a terminal, call `init_project` with the `path` argument. Optionally pass `name` and `url` to register the project in `projects.toml` immediately. After that, start a huskies server at the path and use `switch_project` to make it active before calling `wizard_status`.
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", "notimplemented"
- 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/ACfailure" or "Buildfailed")
- 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", "notimplemented"
- 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/ACfailure" or "Buildfailed")
- 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."
As a storkit user with multiple Claude Max subscriptions, I want the system to automatically rotate to a different account when one gets rate limited, so that agents and chat don't stall out waiting for limits to reset.
As a huskies user with multiple Claude Max subscriptions, I want the system to automatically rotate to a different account when one gets rate limited, so that agents and chat don't stall out waiting for limits to reset.
## Acceptance Criteria
- [ ] OAuth login flow stores credentials per-account (keyed by email), not overwriting previous accounts
- [ ] GET /oauth/status returns all stored accounts and their status (active, rate-limited, expired)
- [ ] When the active account hits a rate limit, storkit automatically swaps to the next available account's refresh token, refreshes, and retries
- [ ] When the active account hits a rate limit, huskies automatically swaps to the next available account's refresh token, refreshes, and retries
- [ ] The bot sends a notification in Matrix/WhatsApp when it swaps accounts
- [ ] If all accounts are rate limited, the bot surfaces a clear message with the time until the earliest reset
- [ ] A new /oauth/authorize login adds to the account pool rather than replacing the current credentials
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
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.
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.
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
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
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
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
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)
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.
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
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
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
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):**
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):
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 {
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
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)
@@ -10,7 +10,7 @@ The `prompt_permission` MCP tool returns plain text ("Permission granted for '..
## How to Reproduce
1. Start the storkit server and open the web UI
1. Start the huskies server and open the web UI
2. Chat with the claude-code-pty model
3. Ask it to do something that requires a tool NOT in `.claude/settings.json` allow list (e.g. `wc -l /etc/hosts`, or WebFetch to a non-allowed domain)
@@ -6,7 +6,7 @@ name: "Retry limit for mergemaster and pipeline restarts"
## User Story
As a developer using storkit, I want pipeline auto-restarts to have a configurable retry limit so that failing agents don't loop infinitely consuming CPU and API credits.
As a developer using huskies, I want pipeline auto-restarts to have a configurable retry limit so that failing agents don't loop infinitely consuming CPU and API credits.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.