Repository navigation
Three Repository resources never retry after an async update failure, so a fixed root cause cannot reach them #137
Description
🤖 Generated by the Agentic Engineer
Evidence
#112 restored the repository write path (#136, merged 2026-08-06T01:37:50Z, artifact 1.22.1). Every resource that attempted an update afterwards succeeded:
| Repository | LastAsyncOperation after the fix |
|---|---|
aws |
Success 2026-08-06T01:39:11Z |
doggy-countdown |
Success 01:39:09Z |
kyverno-policies |
Success 01:39:11Z |
provider-upjet-unifi |
Success 01:39:10Z |
Three did not, and still report ReconcileError with the old 422 message:
| Repository | last update attempt | age at 2026-08-06 |
|---|---|---|
agent-skills |
2026-07-20T00:32:52Z | 17 days |
platform-tenant-template |
2026-08-02T18:54:05Z | 3 days |
agent-plugins |
2026-08-03T19:45:36Z | 2 days |
Problem
The Synced message on these three is the cached result of that old attempt, not a fresh rejection — the underlying cause it names was fixed and demonstrably no longer rejects anyone else. They are not being rejected; they are not trying.
That matters beyond these three repositories: it means a resource can enter a state where fixing the root cause is not enough to recover it, and the stale condition message actively misleads whoever looks — it points at a cause that is already resolved. Any future repository-settings failure can strand a resource the same way.
Synced.lastTransitionTime is rewritten roughly every 60s whether or not anything happened, so it cannot be used to tell a fresh failure from a stale one. Use LastAsyncOperation.lastTransitionTime, which only moves when an operation actually completes.
Observation worth checking first
The three carry only the crossplane.io/external-name annotation. Healed resources such as kyverno-policies also carry crossplane.io/external-create-pending, crossplane.io/external-create-succeeded and crossplane.io/external-create-failed. Whether that is the cause or a side effect of the same history is unverified — it is the first thing to test, not a conclusion.
Suggested approach
- Establish why no reconcile issues an update: a stuck async-operation record, an exponential backoff that has grown unbounded, or the resource being considered up to date while the condition says otherwise.
- Prefer a fix that recovers automatically. A manual nudge (annotation bump, provider restart) may confirm the diagnosis, but a resource that cannot self-recover from a transient provider error is the actual defect.
- Establish whether a bounded retry or a staleness check on
LastAsyncOperationbelongs in the repository-update contract, so a wedged resource is visible rather than silently frozen behind a stale message.
Acceptance criteria
- All 20
Repositoryresources reportReconcileSuccess. - The mechanism that stopped the three from retrying is identified and recorded here.
- A wedged resource is either recovered automatically or surfaced as a distinct signal, rather than presenting a stale message naming an already-fixed cause.
Blocks #112, which stays open until 20/20 is observed.
Evidence gathered live against admin@prod; no cluster mutation was performed, deliberately, so the stuck state remains available for diagnosis.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Effort
Projects
- StatusShow more project fields✅ Done
Activity
coderabbitai commentedon Aug 6, 2026
🔗 Related PRs
#88 - feat: archived-repo pattern + reusable-workflows archival groundwork [merged]
#90 - feat: archive the reusable-workflows repository (phase 2) [merged]
#125 - fix(repositories): pin hasDownloads so reconciliation stops 422-ing [merged]
#127 - fix(repositories): keep org-enforced signoff out of update payloads [merged]
#128 - fix(repositories): stop declaring org-enforced signoff so updates can apply [merged]
📝 Issue Planner
Check the box below or use the
@coderabbitai plancommand to generate an implementation plan and prompts that you can use with your favorite coding assistant.🧪 Issue enrichment is currently in open beta.
You can configure auto-planning by selecting labels in the issue_enrichment configuration.
To disable automatic issue enrichment, add the following to your
.coderabbit.yaml:💬 Have feedback or questions? Drop into our discord!
devantler commentedon Aug 6, 2026
Independent verification from a separate run, plus two hypotheses I tested and refuted — recording both so the next run doesn't re-derive them.
Confirms the diagnosis. For
agent-plugins, declaredforProviderand live GitHub state are identical on every comparable field (allowAutoMerge,allowMergeCommit,allowRebaseMerge,allowSquashMerge,allowUpdateBranch,deleteBranchOnMerge,hasIssues,hasProjects,hasWiki,visibility,webCommitSignoffRequired). There is nothing left to PATCH, so the surfaced 422 cannot be describing a write that is still being attempted — it is a stored result from before #136.Timing. #136 merged
2026-08-06T01:37:50Z. The threeLastAsyncOperationtransitions are frozen at2026-07-20T00:32:52Z,2026-08-02T18:54:05Z,2026-08-03T19:45:36Z— all pre-fix — whileSynced=ReconcileErroris rewritten every ~60s (observed 03:22:08Z against a 03:22:17Z clock). The pairing of a live-refreshedSyncedwith a frozenLastAsyncOperationis the signature of this stuck state, and it is whySynced.lastTransitionTimemust not be used to judge staleness here.Refuted —
archivedpresence. All three failing resources omitforProvider.archived, which looks like the same absent-optional-bool shape as the signoff bug. It is not the discriminator:aws,dotnet-template,go-template,homebrew-tap,kyverno-policies,platform-template,provider-upjet-unifi,unifiandworld-at-ruinalso omit it and all reconcileSuccess. A full field-presence comparison found no key present in every working resource and missing from every failing one.Refuted — provider logs as evidence.
provider-upjet-githubshows zero occurrences ofcannot be disabledin its entire retained log and only 2 lines in 20 minutes, so it is not logging reconcile activity at this verbosity. A zero here is not evidence of "no retry" in either direction — I nearly drew that inference before running the control.Net: nothing in this run contradicts the issue as written; the remaining action is still to force one fresh reconcile per resource. I hold read-only cluster access, so I could not apply or verify that step.
devantler commentedon Aug 6, 2026
Two measurements, one of which refutes this issue's own lead
Re-measured the fleet live. Still 17/20 synced; the same three are stuck.
1. The annotation lead in this issue is REFUTED
This issue recorded that the three stuck repos carry only
crossplane.io/external-namewhile thehealed ones also carry
crossplane.io/external-create-{pending,succeeded,failed}, and proposed thatas the discriminator.
That is not the discriminator.
awsis healed and carries onlycrossplane.io/external-name—the identical annotation set to all three stuck repos:
external-nameonlyAsyncUpdateFailure@ 2026-08-03external-nameonlyAsyncUpdateFailure@ 2026-07-20external-nameonlyAsyncUpdateFailure@ 2026-08-02external-nameonlySuccess@ 2026-08-06 01:39Success@ 2026-08-06 01:39Please don't spend a run on that path.
2. There is no remaining desired-state work — the reported error is stale
Field-by-field comparison of
spec.forProvideragainststatus.atProviderreturns zerodiffering fields for all 20 repositories, stuck and healed alike. Every repo has
spec=true, atProvider=trueforwebCommitSignoffRequired. So.github#112's fix is complete andcorrect; nothing about the three repos' desired state is still wrong.
The resource spec shape is also effectively identical between stuck and healed (same
managementPolicies: [Observe, Create, Update], sameproviderConfigRef, same finalizer, sameforProviderkey set bar an unrelatedtopics).Working hypothesis — a self-sustaining deadlock, not a retry failure. The stored async result is
only replaced by running a new async operation; upjet only launches one when there is a diff; there
is no diff. So the failure from days ago can never be cleared by reconciliation, which matches
Syncedbeing rewritten every ~60s with a message quoting a PATCH that is no longer being attempted.LastAsyncOperation.lastTransitionTimebeing frozen is not by itselfproof that nothing is retrying — Kubernetes conditions only re-stamp that field when the reason
changes, so continuous identical failures look frozen too. The zero-diff measurement is what
distinguishes the two, not the timestamp.
Not yet established
I could not corroborate from
provider-upjet-githublogs: the pod emitted 0 lines in a 20-minutewindow, and my healed-repo control also returned 0, so the empty result says nothing either way.
Whoever picks this up should raise the provider log level rather than read that silence as evidence.
I deliberately did not force a reconcile — cluster mutation is outside my grant, and it would destroy
the very evidence that distinguishes these three.
devantler commentedon Sep 19, 2026
Closing: all three stranded repositories have recovered
Live read from prod, 2026-09-19 (read-only):
SyncedLastAsyncOperationagent-pluginsagent-skillsplatform-tenant-templateThe rest of the fleet is also
Synced=True(20/20). So no resource is stranded and none reports a stale error throughSynced, which was this issue's problem.One residue, recorded rather than chased:
platform-tenant-templatestill stores the old 422 inLastAsyncOperation. That matches the "the stored async result is only replaced by a new async operation, and none runs without a diff" mechanism described above. SinceSyncedandReadyare both clean, it misleads only someone reading that one condition, so it isn't worth a forced update. Any change to that repository's declared settings will overwrite it.If a Repository shows
Synced=Falsewith a cause that has already been fixed, reopen this with that resource's conditions.