Fixes counterexamples 1-4 from specs/orchestrator.qnt: a repeated
'approve', 'approve rev-1' meant for the architecture, approving on both
channels, and 'approve' plus an afterthought all landed on the next
plan, one the operator had not seen.
- Revisions are numbered across the work item (A4 already required
monotonic tags; the code numbered per gate), so rev-N names one plan.
- Each reply is stamped on arrival with the revision that was up: in
Matrix the one whose approval request had been sent (a plain value
the transport thread reads, restored on boot), in Gitea the latest
plan comment older than the reply (board.posts).
- Gate.evaluate applies a reply that names no revision only if its stamp
is the current revision. Otherwise an approval is not applied and
feedback rejects nothing (it is still in the transcript), and the
agent says which revision is up. 'approve rev-N' binds by its number.
- Spec A4 states both rules. The model applies them; scenarios 1-4 now
expect the properties to hold, and both held over 200,000 random
traces and Apalache's exhaustive check to depth 8.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
A phone keyboard's "Approve" did not match the approval pattern, so it
counted as rejection feedback and threw the plan away; "Stop" did not
reach the cancel flag; other capitalised commands got a hint instead of
acting.
gates.command_form() is how a message reads as a command: case,
surrounding whitespace, inner runs of whitespace and a trailing '.' or
'!' do not matter. The approval gate and every command match in the
orchestrator (stop, status, abandon, use, start milestone, next
milestone, project complete, the restart choices) use it. Only matching
changes: the project name in 'use <name>' keeps its case, and feedback
and answers are recorded and passed on as written. A stale revision is
still refused, and approval still needs the whole message to be the
command.
Closes#64.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
`stop` could not have worked by adding the command alone. Planning
turns, milestones and review responses ran synchronously on the thread
that delivered the message, so the Matrix sync loop read nothing until
the work finished. The state store was also opened on the main thread
and used from the transport and poller threads, which SQLite refuses
with its default same-thread check; the in-memory test transport never
showed it.
A single worker thread now runs every handler and owns the store:
- The worker opens the store, so the same-thread check stays on and
fails loudly if anything touches the store from elsewhere
- Matrix messages are handed to the worker; pollers hand over and wait,
so their cursor never passes an unhandled comment
- A message that arrives during long work gets an immediate "busy; say
'stop' to halt" reply and is recorded, not applied later in a state
the operator was not answering
- Tests keep running inline through InlineWorker, which defers a job
submitted from inside another as the real worker would queue it
`stop` sets a shared Cancel flag straight from the transport's thread.
The executor and delegation loops check it before each model call and
between tool calls; the planner checks it before each turn. Calls left
unrun get a result saying so, so the transcript still renders. A
stopped delegation is discarded like a failed one. The orchestrator
moves to AWAIT_INPUT and asks why the work was stopped. The reply is
recorded for the model, and a stopped planning turn resumes with it as
the answer. `stop` with nothing running says so and changes nothing; at
a gate it is no longer taken as rejection feedback.
Closes#40
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>