fix: serialize Hermes inbox doorbells with typed commands - #121
Merged
Conversation
An ordinary steer to a Hermes worker takes the durable inbox plane, whose doorbell ring reached the Hermes terminal without the `.hermes-delivery.lock` that every typed slash command holds. Both planes write to the same terminal, so a doorbell rung while a typed command was in flight could interleave into a single corrupted line. fm_task_inbox_ring now takes that same lock for harness=hermes, around the one ring boundary both production callers already go through (fm-send's first ring and fm-watch's re-ring), so there is no second lock owner. The wait is bounded and the record stays durable, so a contended ring is an ordinary skip (5) the retry ladder re-rings rather than a block that could park the watcher behind a long Hermes turn. Non-Hermes harnesses keep their existing path untouched. Lock ordering stays one-directional and cannot cycle: the inbox sequence and metadata locks are both released before the ring, and fm_lock_try_acquire reclaims the lock from a holder that exited. Files: - bin/fm-task-inbox-lib.sh: fm_task_inbox_hermes_delivery_lock_path as the one lock-path owner; fm_task_inbox_ring_deliver split out as the unserialized body; fm_task_inbox_ring wraps it in the shared critical section. - bin/fm-send.sh: use the shared lock-path owner; report the new skip reason. - tests/fm-task-inbox.test.sh: a held lock refuses without touching the terminal, another harness is unaffected, the lock is reclaimed after its holder exits, and concurrent rings deliver only whole lines. - tests/fm-hermes-harness.test.sh: a parked typed command blocks the real ring boundary (proving both planes share one lock), and ordinary steers still travel the durable inbox plane. Verified: tests/fm-task-inbox.test.sh, tests/fm-hermes-harness.test.sh, tests/fm-send-inbox.test.sh, tests/fm-omp-task-inbox-doorbell.test.sh, tests/fm-backend.test.sh, bin/fm-lint.sh, bin/fm-doc-audience-check.sh all pass. Both new regressions were confirmed to fail with the serialization removed and pass with it restored. Claude-Session: https://claude.ai/code/session_01JPTpBPk37bW1ENoifiiAa4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Fix the fork-specific Hermes steering race deferred from PR #68: an ordinary durable-inbox doorbell currently reaches Hermes terminal input before the shared
.hermes-delivery.lock, so it can interleave with a concurrent typed slash command.Serialize Hermes doorbells at the single shared ring boundary with the same delivery lock used by typed commands, preserving the durable inbox as the ordinary-text data plane and leaving all non-Hermes harness behavior unchanged. Avoid a second lock owner, a polling workaround, timing sleeps, or a compatibility path.
Accepted requirements:
.hermes-delivery.lockcritical section before input reaches the terminal; lock acquisition and release are correct on success, refusal, timeout, and signal paths.bin/fm-lint.sh, and any affected harness-compatibility checks pass.Deliberate design decisions a reviewer reading only the diff would not know:
The lock is taken inside
fm_task_inbox_ringrather than at each call site on purpose. That function is the ONE boundary where every doorbell-caused Hermes terminal write happens: both production callers (bin/fm-send.sh's first ring after enqueue, and bin/fm-watch.sh's re-ring) go through it. Putting the critical section there serializes both callers without creating a second lock owner, which the task explicitly forbids.The wait is BOUNDED and a contended ring is a skip, not a block. It reuses the library's existing
fm_task_inbox_lock_acquire(bounded by FM_TASK_INBOX_LOCK_WAIT_SECS, default 5) rather than the unboundedfm_lock_acquire_waitthe typed plane uses. This is deliberate: bin/fm-watch.sh is a long-lived singleton watcher, and an unbounded wait there would park the whole watcher behind a long Hermes turn. A refusal returns the new code 5, the durable record is untouched, and the existing re-ring ladder retries - which is exactly the pre-existing semantics for the other non-delivery outcomes (1 composer-pending, 2 send failed). This is not a polling workaround; it is the ring's already-established best-effort contract.Return code 5 is new rather than reusing 1. Code 1 means "the composer provenly holds pending text" and bin/fm-send.sh prints that exact reason to the operator. Reusing it would have made fm-send report a false cause, so a distinct code with its own message keeps the reported reason honest.
fm_task_inbox_hermes_delivery_lock_pathwas added so the lock path has exactly one owner. bin/fm-send.sh previously composed the path inline; it now calls the helper. The ring derives the state dir and task id from the record path using the same string derivation the OMP branch of that function already uses for its ready marker, rather than adding new parameters to a public function with two production callers and existing tests.The subshell exists specifically so the lock is released on signal paths (EXIT HUP INT TERM) without requiring either caller to cooperate via its own trap. The trap is installed only AFTER a proven acquisition; installing it before would make a refused acquisition release a lock owned by someone else.
Deadlock analysis (AC4): ordering is one-directional and cannot cycle. The inbox sequence lock and the metadata lock are both released before the ring is called, so neither is ever held across it. The watcher singleton lock is only ever acquired before this lock, never after.
fm_lock_try_acquirealready reclaims a lock from a holder that exited, so a crashed holder cannot wedge the boundary permanently.Verification performed: tests/fm-task-inbox.test.sh (14 cases), tests/fm-hermes-harness.test.sh (22 cases), tests/fm-send-inbox.test.sh, tests/fm-omp-task-inbox-doorbell.test.sh, tests/fm-backend.test.sh, bin/fm-lint.sh (exit 0) and bin/fm-doc-audience-check.sh all pass. Both new regressions were deliberately checked for vacuity: with the serialization removed they fail ("expected exit 5, got 0") and with it restored they pass. An earlier end-to-end formulation that only observed the fake backend was discarded precisely because it passed with the fix removed.
No maintained prose surface changed, and no per-harness verification fact changed: the fix adds no vendor-emitted signal, only a filesystem lock, so no live-harness proof was required.
Firstmate-Validation-Generation: 4fad98529b0c29941f211a7b7151c2a4
What Changed
fm-send.shto use the shared lock path for typed Hermes delivery while keeping ordinary inbox records durable and retries best-effort.Risk Assessment
✅ Low: The change centralizes Hermes doorbell locking on the existing shared lock, preserves non-Hermes behavior, and correctly releases then exits on signal paths without introducing a separate owner or compatibility path.
Testing
Ran the focused inbox, Hermes harness, fm-send inbox, OMP doorbell, and backend behavior suites against target commit a3c05ca. The lock refusal, holder recovery, concurrent serialization, and signal-release-without-delivery cases all passed, with a transcript captured as evidence. Linters were not run because this assigned test phase explicitly forbids lint/static-analysis commands.
Evidence: Hermes inbox behavioral test transcript
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 1 issue found → auto-fixed ✅
bin/fm-task-inbox-lib.sh:297- AC1 requires lock release to be correct on signal paths, but theHUP/INT/TERMtrap only releases the lock and does not exit. A signal after acquisition and before/whilefm_task_inbox_ring_delivercan therefore release the lock and then continue into the terminal write without holding it, reintroducing the Hermes interleaving race. The signal handler must terminate or otherwise prevent delivery after releasing the lock.🔧 Fix: Abort signaled Hermes delivery after releasing lock
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
bash tests/fm-task-inbox.test.shbash tests/fm-hermes-harness.test.shbash tests/fm-send-inbox.test.shbash tests/fm-omp-task-inbox-doorbell.test.shbash tests/fm-backend.test.shVerified worktree remained clean after testing.✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.