From 1858a6c68f209d6d39ba49e934ea32bb0a2e1c71 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 22 Jul 2026 11:35:44 -0700 Subject: [PATCH] feat(nodetask): surface terminal InclusionUnverifiable gov outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle the new wire.InclusionUnverifiable inclusion state in handleFailure: a gov tx that was broadcast and accepted at CheckTx but whose on-chain outcome cannot be confirmed because the target node's tx index is off. Terminal (retrying the same node is futile) but distinct from TxFailed — the tx may have committed, so the reason (InclusionUnverifiable) and the sidecar message steer the operator to verify out-of-band rather than blindly re-run (important for GovSoftwareUpgrade, where a rollback does not unschedule an on-chain upgrade). status.outputs still carries the txHash for that check; height/proposalId are genuinely unknown and omitted. Depends on the wire enum addition in seictl (sei-protocol/seictl#237): this will not compile until seictl is released with InclusionUnverifiable and this module's seictl dependency is bumped. Validated locally against that branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/controller/nodetask/controller.go | 15 ++++++-- .../nodetask/controller_gov_test.go | 34 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/internal/controller/nodetask/controller.go b/internal/controller/nodetask/controller.go index ce828fc1..8ee9ec2d 100644 --- a/internal/controller/nodetask/controller.go +++ b/internal/controller/nodetask/controller.go @@ -317,8 +317,10 @@ func (r *SeiNodeTaskReconciler) markComplete(cr *seiv1alpha1.SeiNodeTask, exec t } // handleFailure resolves an engine-Failed task. For gov kinds it decodes the -// result to distinguish a committed-but-failed tx and an inclusion-undetermined -// (pending) result — which is re-checked, not terminal — from a hard failure. +// result to distinguish a committed-but-failed tx, an inclusion-undetermined +// (pending) result — re-checked, not terminal — and an unverifiable outcome +// (terminal; the tx may have committed, so verify out-of-band) from a hard +// failure. func (r *SeiNodeTaskReconciler) handleFailure(cr *seiv1alpha1.SeiNodeTask, exec task.TaskExecution, now time.Time) { msg := "" if e := exec.Err(); e != nil { @@ -345,6 +347,15 @@ func (r *SeiNodeTaskReconciler) handleFailure(cr *seiv1alpha1.SeiNodeTask, exec cr.Status.Task.Err = msg r.markFailed(cr, now, "TxFailed", msg) return + case wire.InclusionUnverifiable: + // Terminal, but distinct from TxFailed: the tx may have committed, + // so the reason (and the sidecar message) steer the operator to + // verify out-of-band rather than blindly re-run. + populateGovOutputs(cr, gr) + cr.Status.Task.Status = seiv1alpha1.TaskFailed + cr.Status.Task.Err = msg + r.markFailed(cr, now, "InclusionUnverifiable", msg) + return } } diff --git a/internal/controller/nodetask/controller_gov_test.go b/internal/controller/nodetask/controller_gov_test.go index 4480b718..31f26aa3 100644 --- a/internal/controller/nodetask/controller_gov_test.go +++ b/internal/controller/nodetask/controller_gov_test.go @@ -120,6 +120,40 @@ func TestReconcile_GovUpgrade_CommittedFailed(t *testing.T) { g.Expect(got.Status.Outputs.GovSoftwareUpgrade.TxHash).To(Equal("ABC")) } +// A gov Failed carrying inclusionStatus=unverifiable (the target node's tx +// index is off, so inclusion is unobservable) is terminal and distinct from +// TxFailed: outputs are still populated and the reason names the unverifiable +// state so the operator knows to verify via an indexed RPC. +func TestReconcile_GovUpgrade_Unverifiable(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + fakeSC := newFakeSidecarClient() + r, c := newReconcilerWithSidecar(t, time.Now(), fakeSC, newGovUpgradeTask(), newRunningNode()) + + _, err := r.Reconcile(ctx, req()) + g.Expect(err).NotTo(HaveOccurred()) + taskID, _ := uuid.Parse(getTask(t, ctx, c).Status.Task.ID) + _, err = r.Reconcile(ctx, req()) + g.Expect(err).NotTo(HaveOccurred()) + + fakeSC.setResultPayload(taskID, sidecar.Failed, "inclusion unverifiable", + json.RawMessage(`{"txHash":"ABC","inclusionStatus":"unverifiable"}`)) + + _, err = r.Reconcile(ctx, req()) + g.Expect(err).NotTo(HaveOccurred()) + got := getTask(t, ctx, c) + g.Expect(got.Status.Phase).To(Equal(seiv1alpha1.SeiNodeTaskPhaseFailed)) + g.Expect(failedReasonOf(got)).To(Equal("InclusionUnverifiable")) + // Not success: no Ready latch (the tx outcome is unknown, not confirmed). + g.Expect(readyReasonOf(got)).To(Equal("")) + // txHash is surfaced for the operator's out-of-band check, but height and + // proposalId are genuinely unknown → omitted (zero), never asserted as 0. + g.Expect(got.Status.Outputs).NotTo(BeNil()) + g.Expect(got.Status.Outputs.GovSoftwareUpgrade.TxHash).To(Equal("ABC")) + g.Expect(got.Status.Outputs.GovSoftwareUpgrade.Height).To(BeZero()) + g.Expect(got.Status.Outputs.GovSoftwareUpgrade.ProposalID).To(BeZero()) +} + func TestReconcile_GovUpgrade_Pending_ReSubmits(t *testing.T) { g := NewWithT(t) ctx := context.Background()