diff --git a/internal/store/store.go b/internal/store/store.go index a32ae1d..6c86451 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -612,9 +612,14 @@ func (s *Store) apply(e domain.Event) error { if t.State != domain.StateBlocked { t.DecisionRequest = nil t.Blocker, t.BlockReason = "", "" - // The human's answer outranks the plan and stands as an ordinary - // decision, so the contradiction it settled is no longer live. - t.PlanMismatch = nil + // The contradiction is cleared only by the correction that answers the + // stop, never by any other event that happens to find the task + // unblocked. This block runs for every event, and clearing it here + // unconditionally erased the contradiction at the very rotation the + // reopen causes, which is exactly when the planner needs it. + if e.Type == "TaskCorrected" { + t.PlanMismatch = nil + } } if phase, ok := p["lifecycle_phase"].(string); ok && phase != "" { t.LifecyclePhase = phase diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 21aad52..4cee338 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -723,3 +723,72 @@ func TestBlockRetainsLeaseEpochForALaterReopen(t *testing.T) { t.Fatalf("last lease epoch %q, want %q", after.LastLeaseEpoch, epoch) } } + +// F66's projection has to survive the rotation the reopen causes. The session +// that reported the contradiction hands off, a successor leases, and only then +// is the planning context rendered. Live on run 21, the field was gone by +// then: the reopen recorded it and the rotation lost it. +func TestTheContradictionSurvivesTheRotationItCauses(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + b := []byte(`{"source":"s","external_id":"x","project":"p"}`) + if err := s.Append(domain.Event{ID: "create", Type: "TaskCreated", TaskID: "t", Version: 1, Payload: b, Surface: string(authz.System)}); err != nil { + t.Fatal(err) + } + if _, err := s.Lease("t", "h", time.Minute); err != nil { + t.Fatal(err) + } + // Frame to implement, the shortest legal route to the phase a mismatch + // can be reported from. + task, _ := s.Task("t") + toImplement, _ := json.Marshal(map[string]any{"phase": "implement", "from": "frame"}) + if err := s.Append(domain.Event{ID: "impl", Type: domain.EventWorkPhaseChanged, TaskID: "t", Version: task.Version + 1, Payload: toImplement, Surface: string(authz.System)}); err != nil { + t.Fatal(err) + } + task, _ = s.Task("t") + m, _ := json.Marshal(map[string]any{ + "plan_ref": "plan-a", "phase_id": "phase-2", + "at_sha": "0123456789012345678901234567890123456789", + "observed": "the body is assembled inline", + "contradicts": "the plan says one helper returns it", + "harness_id": task.Lease.HarnessID, + "lease_epoch": task.Lease.Epoch, + "requested_action": "replan", + }) + if err := s.Append(domain.Event{ID: "mismatch", Type: domain.EventPlanMismatchRecorded, TaskID: "t", Version: task.Version + 1, Payload: m, Surface: string(authz.System)}); err != nil { + t.Fatal(err) + } + if got, _ := s.Task("t"); got.PlanMismatch == nil { + t.Fatal("the contradiction was not projected at all") + } + + // The reopen, then the rotation it causes. + task, _ = s.Task("t") + ph, _ := json.Marshal(map[string]any{"phase": "plan", "from": "implement", "reopen": domain.EventPlanMismatchRecorded, "reopen_phase_id": "phase-2"}) + if err := s.Append(domain.Event{ID: "reopen", Type: domain.EventWorkPhaseChanged, TaskID: "t", Version: task.Version + 1, Payload: ph, Surface: string(authz.System)}); err != nil { + t.Fatal(err) + } + ref, err := s.PutArtifact([]byte("handoff")) + if err != nil { + t.Fatal(err) + } + task, _ = s.Task("t") + rel, _ := json.Marshal(map[string]any{"handoff_ref": ref, "anchor_sha": "0123456789012345678901234567890123456789", "harness_id": task.Lease.HarnessID, "lease_epoch": task.Lease.Epoch, "expected_version": task.Version}) + if err := s.Append(domain.Event{ID: "release", Type: "TaskReleased", TaskID: "t", Version: task.Version + 1, Payload: rel, Surface: string(authz.System)}); err != nil { + t.Fatal(err) + } + if _, err := s.Lease("t", "h", time.Minute); err != nil { + t.Fatal(err) + } + + // This is the moment the planning context is rendered. + got, _ := s.Task("t") + if got.PlanMismatch == nil { + t.Fatal("the planner is convened to settle a contradiction it is no longer told about") + } + if got.PlanMismatch.PhaseID != "phase-2" { + t.Fatalf("the contradiction changed across the rotation: %+v", got.PlanMismatch) + } +}