fix(kanban): fence task completion to claim ownership and make retries idempotent - #73188
fix(kanban): fence task completion to claim ownership and make retries idempotent#73188ryangu00 wants to merge 7 commits into
Conversation
1d61477 to
efc3911
Compare
|
Rebased onto
The ownership check runs inside the |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for adding focused claim-lock and retry coverage.
Problems
- The new
expected_claim_lockupdate athermes_cli/kanban_db.py:4820drops the existingstatus IN ('running', 'ready', 'blocked')guard. The added test deliberately changes a claimed task totriageand accepts completion (tests/hermes_cli/test_kanban_claim_lock.py:27-42), but current main uses triage as a human-routing state (hermes_cli/kanban_db.py:5581-5618). Preserve the status predicate in the new branch. - The stale-worker premise is already covered for dispatcher workers by the current run-id fence: the dispatcher exports
HERMES_KANBAN_RUN_ID(hermes_cli/kanban_db.py:8864-8867), both worker completion paths pass it (tools/kanban_tools.py:670-675,hermes_cli/kanban.py:2216-2222), andcomplete_taskrequires it to match (hermes_cli/kanban_db.py:4777-4794). The claim lock is therefore defense-in-depth plus the new retry behavior, rather than the first ownership fence.
Suggested changes
- Restore the accepted-status predicate and add a matching-lock/non-completable-state regression.
Automated hermes-sweeper review.
| block_kind = NULL, | ||
| block_recurrences = 0 | ||
| WHERE id = ? | ||
| AND claim_lock = ? |
There was a problem hiding this comment.
Please retain AND status IN ('running', 'ready', 'blocked') here (and the run-id condition when supplied). This branch otherwise completes any row that still has this lock; the new test currently demonstrates an unintended triage -> done transition, while the legacy branches correctly reject triage.
efc3911 to
28f541b
Compare
|
Thanks — and the correction on framing is taken. With the dispatcher's Problem 1 — status predicate. Restored One more I added. Converting that test left the Rebased onto current main ( pytest tests/hermes_cli/test_kanban_claim_lock.py tests/hermes_cli/test_kanban_db.py -q # 38 passed
# both rejection tests fail without the predicate:
git checkout HEAD~3 -- hermes_cli/kanban_db.py
pytest tests/hermes_cli/test_kanban_claim_lock.py -q # 2 failed, 6 passed
git checkout HEAD -- hermes_cli/kanban_db.py |
Require worker completions to prove claim ownership when dispatcher claim data is available. Record a one-way lock digest on the completed event so retries are idempotent without repeating completion effects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous commit restored the status predicate and converted the one positive test into a rejection case, which left the expected_claim_lock branch with only negative coverage. The other complete_task tests all call the unfenced branch, so a predicate that rejected everything — or that dropped a legitimate status from the IN list — would have passed the whole suite while breaking every worker completing its own claimed task. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…atus
Adversarial review pointed out that a single positive case on `running` would
stay green if the predicate silently dropped `ready` or `blocked` from the IN
list. Parametrizing over all three pins the full set: degrading the predicate
to IN ('running') now fails exactly the ready/blocked cases.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…state Every component this branch was fencing on reaches a child process through the environment. A nested Hermes CLI inherits HERMES_KANBAN_TASK and HERMES_KANBAN_CLAIM_LOCK from its parent, presents a claim lock that matches, and completes the parent's card from a different pid — the production shape SharadKumar reported on NousResearch#71175, where run id alone was shown to be insufficient. Inherited identity can say where a caller came from; it cannot say who it is. worker_pid is the one component a child cannot inherit as its own, but only if it is read from the running process: taking it from an environment variable would rebuild the same hole one layer down. The CLI passes os.getpid(), and the comparison happens both in the precheck and inside the statement that flips the row, so a concurrent claim cannot slip between them. The check stays permissive where the pid is unknown — a NULL worker_pid on the row still completes, so tasks claimed by paths that do not stamp a pid keep working. Callers that pass no expected_worker_pid are unaffected. Two tests, because the refusing half passes for a gate that refuses everything: an inherited claim lock from another pid is rejected, and the real worker still completes its own card. Confirmed both discriminate by removing the pid predicate and watching only the first turn red. Related: NousResearch#71175 (production reproduction), NousResearch#81508 (process-identity sibling).
…fiable one Two gaps in the previous commit, both found by review. The pid only reached the database from the CLI. The bypass on NousResearch#71175 travels the agent tool handoff, so the fix had landed beside the door it was meant to close: kanban_tools still sent the inherited claim lock alone, and a nested CLI completed its parent's card from there with worker_pid ending up NULL. That call site now passes os.getpid() too. The NULL branch was the second way back. Allowing a row with no recorded pid turned the fence into lock-only precisely when identity could not be checked. A caller that supplies its own pid is asserting an identity, so an unverifiable row is now refused instead of waved through. Callers that assert nothing are unaffected — the predicate still short-circuits on a NULL expectation, which is what keeps manual and orchestrator completion working. The new test is at the tool layer on purpose. The database-level tests prove that a mismatched pid is rejected; they say nothing about whether the tool sends one, which was exactly the gap. Confirmed it discriminates by removing the tool's os.getpid() and watching only that case turn red. Suite parity with origin/main: same 227 pre-existing environment failures on both, 5375 passing here against 5374 there — the difference is this test.
d7c231f to
07841d8
Compare
|
Rebased onto current Rebase. Two conflicts, both in The fence needed more than the claim lock. @SharadKumar's reproduction on #71175 shows why: a nested Hermes CLI inherits
Two things worth flagging in how this landed:
Tests. The new one is at the tool layer on purpose: the database-level tests prove a mismatched pid is rejected, but say nothing about whether the tool sends one, which was the actual gap. Both new cases were confirmed to discriminate by removing the predicate they cover and watching only that case turn red. Still happy to fold this into a different shape if you'd rather the identity check live in one place for |
fix(kanban): fence task completion to claim ownership and make retries idempotent
|
Review caught an over-correction of mine. An earlier round made an unrecorded worker_pid a refusal, reasoning that a caller asserting an identity we cannot verify should fail closed. That is the right instinct in the wrong place: reporting a pid from spawn_fn is a crash-detection nicety rather than a contract, the dispatcher only stamps one when spawn returns it, and a deployment whose spawn returns none leaves the column NULL forever. Refusing those rejects the legitimate worker finishing its own task — reproduced before changing it back. Restored to leaving a NULL row alone, with a test pinning it so the next tightening instinct has to argue with a red suite first. Where no pid was ever recorded the fence is no weaker than before this branch; where one was, it is strictly stronger, which is the whole of what it claims. Two reviews were each half right: one flagged that a NULL row lets the fence fall back to lock-only, the other that refusing NULL breaks pid-less spawns. Both are true, and the second is the worse failure to ship.
|
Thanks — point 3 found a regression I had just introduced. Taking all three in order. 1. Concurrent-completion race — already covered, but by placement rather than by design. The fenced branch does reach a rowcount check: 2. Digest comparison without a run id. Agreed, and it is reachable rather than purely theoretical — 3. worker_pid lifecycle — you caught a real regression. Checking your two conditions:
But following that thread found the case you were pointing at. That is worse than the hole it was closing, so it is reverted: a NULL row is left alone, with a test pinning it so the next tightening instinct has to argue with a red suite. Where no pid was ever recorded the fence is no weaker than before this branch; where one was, it is strictly stronger, which is all it should claim.
|
What does this PR do?
complete_task()accepts a completion from any caller, without checking that the caller still owns the claim. Two consequences on a board where a task can be reclaimed:complete_task(). The legacy path (no run id) writes A's result over B's in-flight run.claim_lockandcurrent_run_id, so a network retry of a call that actually succeeded returnsFalse— the caller can't tell "someone else finished this" from "your own retry".This threads the dispatcher's existing
claim_lockthrough asexpected_claim_lockand fences the write on it, inside the sameBEGIN IMMEDIATEtransaction that already guards the update. A completion whose lock doesn't match is rejected; a retry carrying the same lock succeeds idempotently without re-running the completion side effects (a one-way digest of the lock is recorded on the completed event so the retry can be recognised after the lock itself is cleared).Callers that don't have claim data — the existing legacy path — behave exactly as before, so nothing that works today breaks.
Related Issue
No existing issue. Searched open and closed PRs for
kanban complete_task idempotent/claim_lock— nothing prior.Type of Change
Changes Made
hermes_cli/kanban_db.py:complete_task()gainsexpected_claim_lock; ownership proof + idempotent-retry handling inside the existing write transaction. All current behaviour (completion artifacts, attachments, hallucinated-card handling, block-state clearing, lifecycle) preserved.hermes_cli/kanban.py,tools/kanban_tools.py: pass the worker's claim lock through at the two call sites that have it.tests/hermes_cli/test_kanban_claim_lock.py(new): 4 regressions.How to Test
pytest tests/hermes_cli/test_kanban_claim_lock.py tests/hermes_cli/test_kanban_db.py -q— 234 passed.git checkout origin/main -- hermes_cli/kanban_db.pyand re-run the new file — 3 of 4 fail (matching-lock completion, stale-lock rejection, idempotent retry). The 4th is the legacy no-lock path, which passes either way by design. Restore and all 4 pass.Checklist
Code
fix(kanban):)Documentation & Housekeeping
cli-config.yaml.example— N/A (no config keys)CONTRIBUTING.md/AGENTS.md— N/A