Go and read the reply to a question Orchestra asked

F64. A plan mismatch that asks for a human decision blocks the task, and
nothing came back. Two independent gaps, either one enough to strand it:
the reconciler ran only before a lease and at a turn boundary, so a blocked
task's reply was never even read, and ResumeAnsweredBlockers listed two
block reasons, not this one. PlanMismatchAnswered had no caller anywhere.

Blocked tasks awaiting a reply are now reconciled on their own loop, the
same reasoning the submitted-work loop above it already uses: a task that
cannot be leased cannot be reconciled behind a pre-lease hook. One
predicate, BlockReasonAwaitsReply, now names the set for both loops so they
cannot drift apart again.

The existing test asserted the predicate and never the resume, which is how
this survived. It asserts the resume now, and fails without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
This commit is contained in:
2026-08-29 19:57:11 +04:00
parent b5c37f693b
commit 6cb2f932d8
4 changed files with 57 additions and 14 deletions
+3 -5
View File
@@ -107,11 +107,9 @@ func ResumeAnsweredBlockers(s *store.Store) ([]domain.Event, error) {
if t.State != domain.StateBlocked {
continue
}
switch t.BlockReason {
case domain.BlockReasonHumanDecision, domain.BlockReasonTrajectoryGate:
default:
// operator_required is deliberately not resumed by a reply. An
// operator decides when a task that spent its budget continues.
// operator_required is deliberately not resumed by a reply. An
// operator decides when a task that spent its budget continues.
if !domain.BlockReasonAwaitsReply(t.BlockReason) {
continue
}
if !blockerAnswered(s, t.ID, t.BlockReason) {
+9
View File
@@ -215,7 +215,16 @@ func TestHumanAnswerResumesTheSamePlanWithoutResealing(t *testing.T) {
if !PlanMismatchAnswered(s, id) {
t.Fatal("the human answered and the task is still waiting")
}
// F64: asserting the predicate is not asserting the resume. This test
// passed for as long as the predicate had no caller, while a task blocked
// on a mismatch stayed blocked forever however the human replied.
if events, err := ResumeAnsweredBlockers(s); err != nil || len(events) != 1 {
t.Fatalf("an answered mismatch did not return to the queue: events=%v err=%v", events, err)
}
after, _ := s.Task(id)
if after.State != domain.StateQueued {
t.Fatalf("state = %s after the human answered, want queued", after.State)
}
if after.PlanRef != planRef {
t.Fatal("answering the question replaced the plan")
}