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
+6 -2
View File
@@ -385,7 +385,10 @@ func (s *Store) SaveIf(ctx context.Context, st Step, expected string) (bool, err
return tag.RowsAffected() == 1, nil
}
// SetStatus writes a plan's status and reason.
// SetStatus writes a plan's status and reason. Only an open plan (or a
// draft) changes: a plan that was completed, failed, abandoned or
// superseded meanwhile keeps that state, so a caller holding a stale copy
// can never reopen or relabel a closed plan.
func (s *Store) SetStatus(ctx context.Context, q storage.Querier, id ids.ID, status, reason string) error {
if q == nil {
q = s.db
@@ -395,7 +398,8 @@ func (s *Store) SetStatus(ctx context.Context, q storage.Querier, id ids.ID, sta
if !PlanOpen(status) && status != PlanDraft {
closed = &now
}
_, err := q.Exec(ctx, `UPDATE plans SET status=$2, status_reason=$3, closed_at=coalesce($4, closed_at), updated_at=$5 WHERE id=$1`, id, status, trunc(reason, 500), closed, now)
_, err := q.Exec(ctx, `UPDATE plans SET status=$2, status_reason=$3, closed_at=coalesce($4, closed_at), updated_at=$5 WHERE id=$1 AND status = ANY($6)`,
id, status, trunc(reason, 500), closed, now, []string{PlanDraft, PlanValidated, PlanWaiting, PlanExecuting, PlanBlocked, PlanAwaitingApproval, PlanReplanning})
return err
}