fix(volition): intention and plan transitions are compare-and-set

Apply and Supersede updated an intention by id alone, so a reconsideration
from the event bus and one from the executor could both apply: an
intention abandoned on goal_abandoned could be resumed by resumeParent a
moment later, leaving it active with closed_at set. Each transition now
writes only when the status is still the one the caller read, before its
event and decision, and a lost race returns ErrClosed having written
nothing. Plan SetStatus likewise changes only an open plan or a draft, so
a stale copy can never relabel a completed, failed, abandoned or
superseded plan. Regression tests cover both.
This commit is contained in:
Andy Dixon
2026-10-05 19:43:22 +01:00
parent 5cf3813a2e
commit 581e9009cd
4 changed files with 98 additions and 5 deletions
+25
View File
@@ -591,3 +591,28 @@ func TestConcurrentReconsiderationReplansOnce(t *testing.T) {
t.Fatalf("want one replan, got %d plans", len(ps))
}
}
// Regression: a caller holding a stale copy of a plan (a reconsideration
// that lost a race with completion) must not relabel a closed plan.
func TestClosedPlanIsNeverRelabelled(t *testing.T) {
f := newFixture(t)
f.host.fixOnRestart = true
f.allow("service.restart")
it := f.intention(false)
p := f.start(it)
for i := 0; i < 5 && f.plan(p.ID).Status != PlanCompleted; i++ {
f.tick()
}
if f.plan(p.ID).Status != PlanCompleted {
t.Fatalf("plan did not complete: %s", f.plan(p.ID).Status)
}
if err := f.plans.SetStatus(f.ctx, nil, p.ID, PlanBlocked, "stale reconsideration"); err != nil {
t.Fatal(err)
}
if err := f.exec.Reconsider(f.ctx, p.ID, volition.Trigger{Kind: volition.TriggerResourceExhausted, Detail: "late"}); err != nil {
t.Fatal(err)
}
if got := f.plan(p.ID); got.Status != PlanCompleted || got.StatusReason == "stale reconsideration" {
t.Fatalf("closed plan relabelled: %+v", got)
}
}