feat(control-plane): add autonomous Provider Join outbox reconciliation - #45
Merged
Merged
Conversation
Closes #10. provider_chain_registrations already persisted the outbox intent transactionally alongside the provider record in CompleteJoin (migration 000002), but nothing drove READY/RETRY rows forward except the Agent retrying CompleteJoin itself -- exactly the documented MVP gap in README.md ('Provider Join recovery currently occurs when the Agent retries CompleteJoin; an autonomous outbox reconciler remains future hardening'). Adds internal/providerjoin.Reconciler: polls provider_chain_registrations for READY/RETRY rows due for another attempt, reuses the same idempotent Registrar.EnsureActive + ActivateProvider path CompleteJoin already uses, and on failure schedules a bounded exponential-backoff retry (state RETRY, next_attempt_at, last_error) or, after MaxAttempts, an explicit terminal FAILED state -- never a silent infinite loop. Wired into cmd/controlplane/main.go as 'go reconciler.Run(ctx)', the same pattern already used for orchestrator.Worker. Acceptance-criteria coverage: - transactional outbox intent: already existed, unchanged. - bounded retry + backoff + idempotency across restarts: new Reconciler; attempt_count/state/next_attempt_at live only in Postgres, never in process memory, so a restart resumes correctly (TestReconcilerSurvivesRestartByReadingAttemptCountFromTheStore). - ACTIVE only after finalized chain state: unchanged guarantee, ActivateProvider is only ever called with the Registrar's real finalized block hash/number. - terminal failures explicit: FAILED state after MaxAttempts or for unrecoverable malformed data (wrong-length stored key), never retried again, verified by test. - duplicate delivery: idempotent by construction (DueChainRegistrations only selects READY/RETRY; a FINALIZED row is never reselected) and by the existing Registrar mutex serializing concurrent EnsureActive calls; covered by TestReconcileOnceIsIdempotentAgainstDuplicateDelivery. - Redis loss: does not apply by design -- the reconciler depends only on Postgres (authoritative per AGENTS.md), never Redis. - chain unavailability: covered by the backoff/max-attempts tests above. Known scope limit, called out rather than overstated: distinguishing a genuine on-chain identity *conflict* from ordinary transient unavailability isn't possible today because blockchainbridge.Registrar returns only untyped errors, and the current single-sudo-bridge trust model (one Control Plane account performs every registration) makes a multi-party conflict essentially unreachable in practice. All EnsureActive failures are treated as retryable-then-terminal. Verified: go build ./..., go vet ./..., gofmt -l ., go test ./... (full suite, including 10 new reconciler tests). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #10 — the last open issue in milestone v0.1 — Provider Join and First Workload.
provider_chain_registrationsalready persisted the outbox intent transactionally alongside the provider record inCompleteJoin(migration000002_provider_chain_registration.sql), but nothing droveREADY/RETRYrows forward except the Agent retryingCompleteJoinitself — exactly the gapREADME.mdalready documented: "Provider Join recovery currently occurs when the Agent retriesCompleteJoin; an autonomous outbox reconciler remains future hardening."Change
internal/providerjoin.Reconciler: pollsprovider_chain_registrationsforREADY/RETRYrows due for another attempt, reuses the same idempotentRegistrar.EnsureActive+ActivateProviderpathCompleteJoinalready uses, and on failure schedules a bounded exponential-backoff retry (RETRY,next_attempt_at,last_error) or, afterMaxAttempts, an explicit terminalFAILED— never a silent infinite loop. Wired intocmd/controlplane/main.goasgo reconciler.Run(ctx), the same pattern already used fororchestrator.Worker.No schema changes needed — the outbox table already had everything (
state,attempt_count,next_attempt_at,last_error).Acceptance-criteria coverage
TestReconcilerSurvivesRestartByReadingAttemptCountFromTheStore).ActivateProvideris only ever called with the Registrar's real finalized block hash/number.FAILEDafterMaxAttempts, or immediately for unrecoverable malformed data (wrong-length stored key) — never retried again.DueChainRegistrationsonly selectsREADY/RETRY; aFINALIZEDrow is never reselected) and by the existingRegistrarmutex serializing concurrentEnsureActivecalls (TestReconcileOnceIsIdempotentAgainstDuplicateDelivery).AGENTS.md.Known, called-out scope limit: distinguishing a genuine on-chain identity conflict from ordinary transient unavailability isn't possible today because
blockchainbridge.Registrarreturns only untyped errors, and the current single-sudo-bridge trust model (one Control Plane account performs every registration) makes a multi-party conflict essentially unreachable in practice. AllEnsureActivefailures are treated as retryable-then-terminal, which is the practically meaningful behavior given that constraint.Verification
Full suite green, including 10 new reconciler tests.
🤖 Generated with Claude Code