Verbatim symptom
NOT MOVED YET
STILL HELD, NOT MOVEDSymptom
I just wanted to clear some blockers by voice. That's it. Say the category, say "yes", watch them move to resolved. Instead, every batch needed three or four "yes" answers before anything actually happened, and the assistant kept cheerfully asking the same question again.
The tell was in the replies. After a "yes", the tool came back with the exact string NOT MOVED YET. Not an error, not a crash, just a polite non-event. The spoken side sounded like progress ("okay, I'll move those") while the data sat exactly where it was. Three to four confirmations per batch, every batch, and the open blockers list never looked like it was shrinking the way it should.
There were 16 open blockers in play. Most of them never showed up in the spoken list at all, which I'll get to, because that turned out to be a separate defect wearing the same coat.
What we thought it was
My first guess was speech recognition. You know the usual suspect: the "yes" is being mangled, clipped, or dropped before it reaches the model, so the confirmation never lands. It was believable because voice pipelines really do eat short words, and "yes" is about as short as words get.
Second guess: the model was just being cautious. Models hedge, they re-ask, and a "are you sure?" loop is a very recognisable personality quirk. Easy to shrug at.
Both theories had the same problem. Neither explained why the failure was so regular. Dropped audio is random; this was three to four, every time. Also the transcripts showed the "yes" arriving fine. The words were there. They just weren't doing anything.
Anyway, once I stopped blaming the microphone and read what the tool was actually returning, the shape of the thing changed.
Root cause
move_thoughts is a two-phase gate. The first call doesn't move anything. It stashes a preview and replies NOT MOVED YET. The commit only happens if the model routes the user's "yes" to a different tool, confirm_handoff. So the whole design hangs on one routing decision going the right way.
When that decision went the wrong way, the "yes" got sent back to move_thoughts instead. And here's the bit that bit us: the branch that arms the preview, around src/conversation.js:1807 and src/conversation.js:1810 in the parent of the fix, had no same-action anti-loop guard. The heavy tools already had one. This branch didn't. So a mis-routed "yes" just re-armed the preview, silently, and asked again. Same stash, same NOT MOVED YET, same question. Nothing flagged the repeat because nothing was checking for it.
That explains the regularity. It wasn't dropped audio. It was a gate that could be re-triggered by the very answer meant to open it.
Two other defects made it worse, and they stacked.
The conversation history held only spoken text. The tool calls and their results weren't in it. So from the model's side, there was no in-context proof it had already armed a move; all it could see was a user saying "yes" to a spoken question. Looking back at the history around src/conversation.js:407, that's where the spoken-only record gets built. A model with no memory of arming the preview will, quite reasonably, arm it again.
The third piece was the list itself. The open-items query truncated to the newest 10 (the default list limit) and only then filtered by category. With 16 open blockers, filtering a pre-cut slice of 10 meant most of the blockers never surfaced. You'd ask for blockers and get whichever of the newest 10 happened to be blockers. The ones you actually wanted to resolve were often outside the window. Related code sits near src/conversation.js:1870 and src/conversation.js:1894.
So three small faults, each survivable alone: a missing guard, a blind history, a limit applied in the wrong order. Together they made a loop that looked like a flaky microphone.
The fix
The fix landed on 2026-07-07, the same day we detected it, in commit 0e41a92 of claude-voice-convo. Four changes.
First, a "STILL HELD" guard. When the same move is requested again while a preview is already armed, the tool now says STILL HELD, NOT MOVED instead of quietly re-arming. That's the same-action anti-loop guard the heavy tools already had, finally applied to the branch that needed it. The repeat is now visible rather than invisible.
Second, a commit-backstop for claimed-but-uncommitted moves. If the model says a move happened but nothing was committed, the backstop catches the gap and commits what was held. It's a safety net under the routing decision, so one bad route no longer strands the whole batch.
Third, category filtering moved SQL-side. The query filters by category first and limits afterwards, so the 16 open blockers can actually surface instead of hiding behind a newest-10 slice. The relevant change is around src/conversation.js:1891 in the fixed version.
Fourth, cross-turn item memory. The items surfaced in one turn are remembered into the next, so a "yes" refers to something the model can actually see it already showed. We added 12 tests for the move loop guard to pin the behaviour down.
Why it happened
The systemic cause is boring, which is why it survived review. The guard existed. It was just applied to the heavy tools and not to the branch that arms a preview, so the codebase looked protected when it was only partly protected. Reviewers saw a guard pattern and reasonably assumed it was everywhere.
The failure also never threw. A loop that returns a polite string passes every check that looks for errors. NOT MOVED YET is a valid output, so nothing failed; it just failed to finish.
How to avoid it
If you run a two-phase confirm flow, treat the arming step as the dangerous one. Anything that can be re-triggered by its own confirmation needs a same-action guard, and it should be tested for explicitly: send the confirm to the wrong tool and assert the loop breaks.
Keep tool calls and results in the history the model sees, not just spoken text. A model can't know it already did something it has no record of doing.
And filter before you limit. Always. Truncating first and filtering second quietly deletes data, and it will do it politely.
Right, that's it. Three "yes" answers, and one very patient microphone I'd unfairly blamed.
Every commit, file reference and date above is checked
against this incident's Noctem evidence pack before publication. Evidence:
jarvis-blocker-move-confirm-loop.