From 75396963ef2d2c76940a40d16417b439398b0a40 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 29 Aug 2026 21:05:01 +0400 Subject: [PATCH] Keep the contradiction alive across the rotation the reopen causes The clearing rule sat below the reducer switch, so it ran for every event rather than for the correction it was written for. TaskReleased found the task unblocked and erased the contradiction, which is the rotation the reopen itself causes: the planning session launched one lease later and was told nothing again. Scoped to TaskCorrected, and the store test walks the real sequence (mismatch, reopen, release, lease) rather than reading the projection at the moment it is written. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1 --- internal/store/store.go | 11 ++++-- internal/store/store_test.go | 69 ++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) 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) + } +}