Skip to content

fix(operator): force source-watcher drift on stuck ArtifactGenerator status - #3182

Merged
myasnikovdaniil merged 18 commits into
mainfrom
fix/packagesource-synthesise-ready-when-source-watcher-status-stuck
Jul 6, 2026
Merged

myasnikovdaniil merged 18 commits into
mainfrom
fix/packagesource-synthesise-ready-when-source-watcher-status-stuck

Conversation

@IvanHunters

@IvanHunters IvanHunters commented Jul 2, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

cozystack-platform HelmRelease times out at 15m on fresh install when a
PackageSource's derived ArtifactGenerator stalls in Ready: Unknown even
though its ExternalArtifacts are produced and consumed successfully.
Root cause: fluxcd source-watcher patches .status and
.status.conditions as separate apiserver requests via patch.Helper,
so an etcd timeout under first-install load can leave Inventory +
ObservedSourcesDigest persisted while the Ready-condition write is
lost. Upstream tracker:
fluxcd/pkg#934 — patch.Helper creates race conditions in downstream
controllers
.

The bug is present in the currently-deployed v2.1.0 (pinned at
internal/fluxinstall/manifests/fluxcd.yaml:8230), and a source-watcher
version bump alone does not fix it because the race lives in the shared
fluxcd/pkg runtime.

What this PR does

PackageSourceReconciler now detects the stuck ArtifactGenerator
signature (Inventory populated, ObservedSourcesDigest set, Ready=Unknown
on the current generation, LastTransitionTime older than a 30s grace
period) and forces source-watcher onto its drifted-branch reconcile by
writing both signals it keys on:

  1. Ready=False on the AG's status subresource. source-watcher's
    detectDrift (v2.1.0 artifactgenerator_drift.go:52) treats
    IsFalse(Ready) as NotReady drift, promoting the reconcile past
    the no-drift early-return at artifactgenerator_controller.go:164.
  2. reconcile.fluxcd.io/requestedAt bump on the AG's metadata.
    source-watcher's ArtifactGenerator watch is registered with
    predicate.Or(GenerationChangedPredicate, ReconcileRequestedPredicate)
    — the annotation is what enqueues the reconcile in the first place.
    Without it a status-only change never fires the watch and the AG
    would sit until the periodic self-requeue (hardcoded to 1h in
    GetRequeueAfter), well outside the 15m HR install timeout.

Both signals are load-bearing — either alone is a no-op. The status
write triggers the drifted branch inside source-watcher's reconcile;
the annotation write triggers the reconcile that inspects that drift.

On successful rebuild source-watcher writes Ready=True at
controller.go:254; on a genuine rebuild failure it writes
Ready=False with a real reason. Either way an honest condition from
upstream replaces our synthetic False, and the
Owns(&ArtifactGenerator{}) watch fires this reconciler to copy the
resolved condition onto the PackageSource.

Bounded-retry harness sizes the total budget at ~11.5m
(30s + 60s + 2m + 4m + 4m) safely inside the 15m HR install timeout;
after maxRecoveryAttempts fruitless force-drift attempts the driver
surfaces PackageSource.Ready=False, reason=SourceWatcherStalled so an
operator can intervene, rather than lying about the state.

Retry state (attempt count + last-force timestamp) lives on the AG as
cozystack.io/source-watcher-recovery-* annotations so it survives
operator restarts. Counter reset is via the canonical
clearRecoveryTracking on the not-stuck path (Ready=True from
source-watcher, or Ready=False with an upstream reason), NOT keyed on
LastTransitionTime — source-watcher's Progressing write during
rebuild always advances that timestamp, and a heuristic reset there
would make SourceWatcherStalled unreachable under the very race this
driver targets.

Owns()-re-entrancy handling

Owns(&ArtifactGenerator{}) re-fires this reconciler on the
force-drift's own writes (both the status patch and the metadata
patch). A unified gate in updateStatus routes three overlapping
signals into the same state machine:

  • Our own marker reflecting back — Ready.Reason carries
    SourceWatcherRecoveryForced (written only by
    forceArtifactGeneratorDrift).
  • source-watcher's Progressing during rebuild — Ready=Unknown on
    the current generation, tracking annotations already present.
  • Classic first-time stuck detection — the patch.Helper creates race conditions in downstream controllers fluxcd/pkg#934 signature
    past the grace period.

All three route to maybeRecoverArtifactGenerator, whose
decideRecovery state machine is the single arbiter of wait vs
re-force vs give-up. The alternative — a separate "wait for
source-watcher" helper — was tried and rejected: if source-watcher
never responds (missing predicate, network partition, dead pod), a
wait-only path tight-loops without ever advancing to
SourceWatcherStalled. The unified gate lets the state machine's own
backoff schedule bound both the wait and the eventual give-up.

Contract

PackageSource.status.conditions[Ready] contract is unchanged:

  • True still means "source-watcher processed the current revision"
    (copied through from AG on the fast path).
  • False from a genuine upstream failure still passes through unchanged.
  • False, reason=SourceWatcherStalled is new — surfaces after
    maxRecoveryAttempts fruitless force-drift attempts, an honest
    operator-actionable signal rather than a false-positive True.
  • During force-drift and backoff windows, Ready=Unknown, reason=AwaitingSourceWatcherRecovery describes what the operator is
    waiting on.
  • The synthetic SourceWatcherRecoveryForced marker is an
    operator-internal signal on the AG only; it never leaks to the
    PackageSource.

Retirement

Once fluxcd/pkg#934 lands and source-watcher consumes a patch.Helper
that either serialises or transactionally combines the .status /
.status.conditions writes, this whole recovery driver can be deleted
and updateStatus can copy the AG's Ready condition through
unconditionally. A TODO(remove once fluxcd/pkg#934 lands and is rolled out) marker on the driver points at the upstream issue.

HA note

The recovery driver stores its state as annotations on the
ArtifactGenerator, so it races on write when two cozystack-operator
replicas both see the same stuck target. Deploy the operator with
--leader-elect=true (or replicas: 1, which is the shipped default in
packages/core/installer/templates/cozystack-operator.yaml).

Testing

Unit tests cover: stuck-detection predicate with grace-period cases,
bounded-retry state machine (Force / Wait / GiveUp branches), backoff
schedule, tracking-annotation parser hardening, mutation contract on
the two-subresource force-drift shape (success, status-patch failure
rollback, metadata-patch failure with Ready=False persisted and
annotations rolled back, rollback-restores-prior-bump), no-op
clear-tracking, Reconcile early-return on DeletionTimestamp, follow-up
scheduling on Unknown-within-grace to avoid the 10h informer resync
dormancy, Owns()-re-entrancy for both our own marker and Progressing
during recovery, canonical reset on Ready=True, and the
source-watcher-unresponsive path (stale own-marker triggers retry
through Force, exhaustion surfaces as SourceWatcherStalled). All pass
with -race; go vet and golangci-lint clean.

E2E on dev3 — pending on the current tip. An earlier e2e ran on
97b054bbb, which observed a stuck AG recovering after our force-drift
but also had a dormancy bug (fixed since) and the requestedAt bump
missing (fixed since). Both of the current shape's load-bearing
mechanisms — requestedAt trigger + status-condition drift — need
verification against a live source-watcher on a fresh e2e run before
merge.

@github-actions github-actions Bot added size/L This PR changes 100-499 lines, ignoring generated files area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review kind/bug Categorizes issue or PR as related to a bug labels Jul 2, 2026
@coderabbitai

coderabbitai Bot commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

PackageSourceReconciler now exits early for deleting PackageSource objects, threads one timestamp through status updates, detects stuck ArtifactGenerator Ready conditions, and applies bounded recovery with annotation tracking, rollback-safe patching, and explicit Unknown/False readiness reasons.

Changes

Bounded ArtifactGenerator recovery

Layer / File(s) Summary
Entry point and status timing
internal/operator/packagesource_reconciler.go
Reconcile returns immediately on DeletionTimestamp and passes a single now into updateStatus, whose signature now returns (ctrl.Result, error); new constants, annotation keys, and an overridable nowFunc are added.
Status updates and stuck detection
internal/operator/packagesource_reconciler.go
updateStatus sets specific Unknown reasons for missing SourceRef, missing ArtifactGenerator, and missing ArtifactGenerator Ready; it detects the stuck signature, schedules follow-up requeues, and routes stuck conditions into recovery handling.
Recovery scheduling and annotation bookkeeping
internal/operator/packagesource_reconciler.go
Recovery reads retry annotations, computes bounded backoff decisions, forces ArtifactGenerator drift with rollback-safe status and metadata patching, clears recovery tracking when healthy, and defines the clone and parsing helpers.
Recovery predicates and helpers tests
internal/operator/packagesource_reconciler_test.go
Tests cover stuck detection, recovery decisions, backoff scheduling, annotation parsing, cloning, and follow-up delay computation.
Recovery flow and reconcile tests
internal/operator/packagesource_reconciler_test.go
Tests cover forced drift success, rollback on patch failures, clearing recovery tracking, deletion-timestamp early return, and the force, wait, give-up, and reset branches of maybeRecoverArtifactGenerator.

Estimated code review effort: 4 (Complex) | ~60 minutes

Suggested reviewers: kvaps, lllamnyp, androndo

Sequence Diagram(s)

sequenceDiagram
  participant Reconcile
  participant updateStatus
  participant artifactGeneratorStuck
  participant maybeRecoverArtifactGenerator
  participant ArtifactGenerator

  Reconcile->>updateStatus: updateStatus(ctx, packageSource, now)
  updateStatus->>artifactGeneratorStuck: check Ready condition
  artifactGeneratorStuck-->>updateStatus: stuck true/false
  alt stuck
    updateStatus->>maybeRecoverArtifactGenerator: recoverArtifactGenerator(ctx, packageSource, ag, now)
    maybeRecoverArtifactGenerator->>ArtifactGenerator: patch status and annotations
    maybeRecoverArtifactGenerator-->>updateStatus: ctrl.Result
  else not stuck
    updateStatus->>ArtifactGenerator: clear recovery tracking annotations
  end
  updateStatus-->>Reconcile: ctrl.Result, error
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: forcing source-watcher drift when an ArtifactGenerator status becomes stuck.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/packagesource-synthesise-ready-when-source-watcher-status-stuck

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kvaps Andrei Kvapil (kvaps) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I verified the diagnosis against the source-watcher v2.1.0 code and it holds up completely. The no-drift branch in reconcile() returns before touching conditions, and the deferred summarizeStatus never promotes a stuck Unknown to True — it only adjusts the Reconciling condition. The half-persisted state in your evidence (Inventory/ObservedSourcesDigest written, Ready=True lost) is explained by fluxcd's patch.Helper patching status and conditions as separate requests, so an etcd timeout can land between them. Good find, and the predicate is the right shape: both observable-artifacts invariants plus the ObservedGeneration match, with True/False passed through untouched so real failures are never masked.

Two asks before this leaves draft:

1. Document the semantic change. The Generation check catches spec edits, but a content change (new OCI artifact revision) doesn't bump Generation — so during a normal regeneration the AG sits in Ready=Unknown with the previous Inventory/digest still persisted, and the predicate fires. Net effect: PackageSource Ready no longer dips to Unknown while a new revision is being processed; it now means "valid consumable artifacts exist", not "the current revision has been processed". I think that's actually the better contract for install/upgrade ordering (no InProgress flapping, and a failed regeneration still surfaces as False), but it's a real contract change and the comment/PR body should state it explicitly rather than implying synthesis only happens in the stuck-status window.

2. Finish the test plan before un-drafting. The dev3 live verification and the E2E run on top of #3178 are the two items that actually prove the workaround unblocks cozystack-platform; the unit tests only lock in the predicate. (Also, the body says 7 test branches — the table has 8 cases.)

Nit: artifactGeneratorObservablyReady re-runs FindStatusCondition that the caller just executed; passing the condition in would keep one lookup and make the call sites read better.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOT LGTM — concur with the existing review: this should not leave draft until the contract change is documented and the live + E2E items in the test plan are actually run. The approach itself is sound. Adding evidence for the diagnosis and two points not raised yet.

Business context: on fresh install cozystack-platform times out at 15m when a single PackageSource's ArtifactGenerator stalls in Ready: Unknown after source-watcher's status-patch fails under first-install etcd load — even though its ExternalArtifacts are produced and consumed fine.

Confirming the diagnosis (extra evidence)

The predicate firing on a content-only regeneration (new OCI revision, .metadata.generation unchanged) is provable, not incidental: fluxcd/pkg/runtime/conditions.Set assigns condition.ObservedGeneration = to.GetGeneration() on every condition write, including the Progressing/Unknown mark at the start of a rebuild. And ArtifactGeneratorStatus exposes no object-level ObservedGeneration (it inlines only ReconcileRequestStatus), so the condition-level value is the only generation signal available — there is no clean field to distinguish "current revision fully processed" from "rebuild in progress, previous Inventory/digest still persisted".

So the contract genuinely becomes "valid consumable artifacts exist" and cannot be tightened back to "current revision processed" without a new upstream signal. That makes documenting the contract (rather than gating around it) the right call — please make the comment and PR body state it outright instead of implying synthesis only happens in the stuck-status window.

Two points not raised yet

  1. Link an upstream issue and add a removal marker. This papers over a source-watcher bug that is still present in current main, so it cannot be retired by a version bump alone. File/link a fluxcd/source-watcher issue and add a // TODO(remove once fixed upstream: <link>) on the workaround so it does not silently become permanent.

  2. Version attribution. The PR body pins the bug to source-watcher v2.1.0, but the deployment tracks the flux-aio latest distribution (~v2.2.x) and the failure reproduced there in the referenced run. Worth correcting, since it also confirms "just bump source-watcher" is not an available fix today.

Nit: the workaround comment says the next reconcile "early-exits before touching status again" — the !hasDrifted branch still runs the top-level deferred summarizeStatus; the accurate reason Ready stays Unknown is that the no-drift path never re-marks it True.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the blocker is resolved.

The synthesis logic is unchanged, but the safety reasoning is now correct: the comment no longer claims the Generation check guarantees the Inventory matches the current spec. It documents the real contract change — Ready=True now means "valid consumable artifacts exist" rather than "current revision processed" — names the precise mechanism (conditions.Set stamps ObservedGeneration = ag.Generation on every mutation, including the Progressing mark, and ArtifactGeneratorStatus exposes no object-level ObservedGeneration), and pins the content-only-regeneration firing with a dedicated test. Ready=False still passes through, so a genuine regeneration failure is never masked; a spec edit still bumps Generation and is caught by the match.

The workaround is now anchored to a real open upstream issue — fluxcd/pkg#934 describes exactly this: patch.Helper writing .status.conditions and .status.artifact in separate patches, which is what leaves Inventory/digest persisted while Ready stays Unknown — with a TODO(remove) so it retires when the upstream fix lands. The Ready condition is passed into the predicate now, dropping the duplicate lookup.

Note: the E2E check is currently red, so this still needs a green run before merge — separate from the code review.

@myasnikovdaniil myasnikovdaniil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This correctly diagnoses the upstream race (fluxcd/pkg#934), and the writeup is thorough. My concern is with the chosen fix: synthesising a terminal Ready=True that the operator never observed reinterprets the ArtifactGenerator's status semantics permanently, and I think it introduces two correctness problems that outweigh the transient install stall it removes. There's also a lower-cost fix that doesn't change what Ready means. Details inline.

The two "blocking the draft's removal" boxes in the test plan (live cozy-dev3 + full E2E) are also still unchecked — this hasn't yet been shown to actually unblock the install it targets.

Comment on lines +439 to +441
// * a genuine regeneration FAILURE still surfaces — the upstream
// writes Ready=False on the same reconcile, and this predicate
// returns false for Ready=False so the caller passes it through.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This safety guarantee assumes the Ready=False write always lands. But the whole premise of the workaround is that condition writes can be lost to the patch.Helper/etcd race. Consider a content-only regeneration that fails and whose Ready=False write is the one lost: Inventory/ObservedSourcesDigest still hold the previous success, Ready is stuck Unknown, and ObservedGeneration == ag.Generation, so artifactGeneratorObservablyReady returns true and we synthesise Ready=True — masking a real failure.

Is there something that guarantees the failure path persists Ready=False before/atomically with the artifact fields? If not, "must NEVER mask a genuine failure" doesn't hold in exactly the race this code exists for.

Comment on lines +431 to +433
// persisted" — meaning the predicate can (and does) fire during a
// legitimate content-only regeneration where the OCI revision changed
// but `.metadata.generation` did not.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Firing on a content-only regeneration means PackageSource Ready=True is reported while the previous revision's artifacts are still what's on disk. For a GitOps upgrade that's the one moment we don't want to signal Ready: downstream Packages/HRs can proceed against stale artifacts and treat the upgrade as converged.

The ObservedGeneration guard only catches spec-edit staleness (.metadata.generation bump), not content staleness — as the comment itself acknowledges. Trading "no InProgress flapping" for "Ready over stale content on every upgrade" reads as a correctness regression rather than a strictly-better contract. What's the concrete downstream harm from the brief Unknown dip that justifies this?

// one. The synthesis reason `ArtifactsGeneratedAwaitingUpstreamStatus`
// is greppable so operators can audit which PackageSources are
// currently on the workaround path.
if artifactGeneratorObservablyReady(ag, readyCondition) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An alternative that doesn't reinterpret the CRD semantics: when artifacts are present but Ready is stuck Unknown, force a fresh reconcile of the ArtifactGenerator (bump reconcile.fluxcd.io/requestedAt) and requeue, so source-watcher re-writes the real condition. That unblocks the 15m install timeout without changing what Ready means and without the masked-failure / stale-artifact windows above.

Given the TODO(remove) will realistically outlive the upstream fix, a fix that doesn't durably change semantics seems preferable.

//
// This regression-tests both the stuck-status case (the bug we are
// papering over) and every case that must NOT trigger it.
func TestArtifactGeneratorObservablyReady(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All 9 cases exercise the pure predicate artifactGeneratorObservablyReady. The actual synthesis in updateStatus has no coverage: the Status().Update, the pass-through branches, the LastTransitionTime-preservation claim (reconciler.go:459-463), and the Owns()-driven overwrite once upstream resolves. Those are the load-bearing, higher-risk parts.

Could you add a table test (or envtest) on updateStatus asserting the synthetic condition is written and that a subsequent real condition overwrites it?

// runtime, so a source-watcher version bump alone does not remove
// the need for this synthesis.
//
// Kvaps' review on #3182 confirmed the diagnosis by tracing the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These block comments cite the PR conversation directly ("Kvaps' review on #3182 confirmed…", and "kvaps' nit" in the predicate doc further down). Once merged this reads as unexplained context and will rot. Suggest trimming to the technical rationale plus the upstream issue link and dropping the reviewer/PR references.

@github-actions github-actions Bot added size/XL This PR changes 500-999 lines, ignoring generated files size/XXL This PR changes 1000+ lines, ignoring generated files and removed size/L This PR changes 100-499 lines, ignoring generated files size/XL This PR changes 500-999 lines, ignoring generated files labels Jul 3, 2026
@IvanHunters
IvanHunters marked this pull request as ready for review July 3, 2026 07:17
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a race condition in the upstream fluxcd/source-watcher that causes PackageSource resources to remain stuck in an Unknown ready state during high-load scenarios. By detecting these stalls and proactively triggering reconciliations via annotations, the operator ensures that PackageSource resources converge to a Ready state, significantly improving the reliability of platform installations.

Highlights

  • Workaround for upstream race condition: Introduced a detection mechanism in PackageSourceReconciler to identify ArtifactGenerator resources stuck in an Unknown ready state due to a known race condition in fluxcd/source-watcher.
  • Bounded-retry mechanism: Implemented a retry driver that uses annotations on the ArtifactGenerator to nudge source-watcher into completing status updates, using exponential backoff to avoid overloading the system.
  • Comprehensive test coverage: Added a new test suite in internal/operator/packagesource_reconciler_test.go covering 9 branches of the reconciliation logic, including failure scenarios and rollback contracts.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@dosubot dosubot Bot added the area/platform Issues or PRs related to platform infrastructure (bundle, flux, talos, installer) label Jul 3, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a bounded-retry workaround in the PackageSourceReconciler to address a known split-write race condition in source-watcher (fluxcd/pkg#934) where the Ready condition can be lost. The retry mechanism tracks attempts and timestamps via annotations on the ArtifactGenerator and uses exponential backoff to nudge source-watcher before eventually marking the PackageSource as stalled. Comprehensive unit tests are also added to validate this state machine. The review feedback suggests improving error handling by propagating status update errors in Reconcile to trigger immediate retries on transient failures, and optimizing the retry actions (requeueActionGiveUp and requeueActionWait) to avoid redundant API server writes when the status is already up to date.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines 122 to 126
result, err := r.updateStatus(ctx, packageSource, nowFunc())
if err != nil {
logger.Error(err, "failed to update status")
// Don't return error, status update is not critical
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Now that updateStatus is responsible for driving the workaround and retry loop (including bumping annotations and setting the AwaitingSourceWatcherRequeue condition), ignoring status update errors is risky.

If r.Status().Update fails due to a transient error (such as a Kubernetes API conflict, which is very common), ignoring the error means the controller won't retry immediately. Instead, it will wait for the RequeueAfter duration (which can be up to 4 minutes), leaving the PackageSource status stale or out of sync with the bumped ArtifactGenerator annotations.

Consider returning the error from Reconcile so that controller-runtime can perform immediate exponential backoff retries on transient failures.

Suggested change
result, err := r.updateStatus(ctx, packageSource, nowFunc())
if err != nil {
logger.Error(err, "failed to update status")
// Don't return error, status update is not critical
}
result, err := r.updateStatus(ctx, packageSource, nowFunc())
if err != nil {
logger.Error(err, "failed to update status")
return ctrl.Result{}, err
}

Comment on lines +519 to +535
case requeueActionGiveUp:
message := fmt.Sprintf(
"ArtifactGenerator %s/%s has been stuck with a lost Ready condition through %d requeue attempts; "+
"source-watcher is not recovering. See https://github.com/fluxcd/pkg/issues/934. "+
"An operator must restart source-watcher or manually inspect the ArtifactGenerator.",
ag.Namespace, ag.Name, maxRequeueAttempts,
)
meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionFalse,
Reason: reasonSourceWatcherBad,
Message: message,
ObservedGeneration: packageSource.Generation,
})
logger.Info("source-watcher stalled after bounded requeues; surfacing PackageSource Ready=False",
"packageSource", packageSource.Name, "artifactGenerator", ag.Name, "attempts", attempts)
return ctrl.Result{}, r.Status().Update(ctx, packageSource)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In requeueActionGiveUp, r.Status().Update is called unconditionally on every reconciliation. Once the controller has given up, any subsequent reconciliation (triggered by other watches or resyncs) will repeatedly call r.Status().Update even though the status hasn't changed.

We can optimize this by checking if the existing Ready condition is already set to Ready: False with reasonSourceWatcherBad and the same message/generation before calling r.Status().Update.

	case requeueActionGiveUp:
		message := fmt.Sprintf(
			"ArtifactGenerator %s/%s has been stuck with a lost Ready condition through %d requeue attempts; "+
				"source-watcher is not recovering. See https://github.com/fluxcd/pkg/issues/934. "+
				"An operator must restart source-watcher or manually inspect the ArtifactGenerator.",
			ag.Namespace, ag.Name, maxRequeueAttempts,
		)
		existing := meta.FindStatusCondition(packageSource.Status.Conditions, "Ready")
		if existing != nil &&
			existing.Status == metav1.ConditionFalse &&
			existing.Reason == reasonSourceWatcherBad &&
			existing.Message == message &&
			existing.ObservedGeneration == packageSource.Generation {
			return ctrl.Result{}, nil
		}

		meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
			Type:               "Ready",
			Status:             metav1.ConditionFalse,
			Reason:             reasonSourceWatcherBad,
			Message:            message,
			ObservedGeneration: packageSource.Generation,
		})
		logger.Info("source-watcher stalled after bounded requeues; surfacing PackageSource Ready=False",
			"packageSource", packageSource.Name, "artifactGenerator", ag.Name, "attempts", attempts)
		return ctrl.Result{}, r.Status().Update(ctx, packageSource)

Comment on lines +537 to +552
case requeueActionWait:
meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionUnknown,
Reason: reasonAwaitingRequeue,
Message: fmt.Sprintf(
"ArtifactGenerator Ready condition lost to fluxcd/pkg#934 patch.Helper race; "+
"requeue %d/%d nudged source-watcher, waiting for a real Ready write.",
attempts, maxRequeueAttempts,
),
ObservedGeneration: packageSource.Generation,
})
if err := r.Status().Update(ctx, packageSource); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: decision.wait}, nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In requeueActionWait, r.Status().Update is called unconditionally on every reconciliation. If the Ready condition is already set to the desired state during the wait window, this results in redundant API server writes on every subsequent reconciliation (which can be triggered by other watches or periodic syncs).

We can optimize this by checking if the existing Ready condition already matches the desired state before calling r.Status().Update.

	case requeueActionWait:
		message := fmt.Sprintf(
			"ArtifactGenerator Ready condition lost to fluxcd/pkg#934 patch.Helper race; "+
				"requeue %d/%d nudged source-watcher, waiting for a real Ready write.",
			attempts, maxRequeueAttempts,
		)
		existing := meta.FindStatusCondition(packageSource.Status.Conditions, "Ready")
		if existing != nil &&
			existing.Status == metav1.ConditionUnknown &&
			existing.Reason == reasonAwaitingRequeue &&
			existing.Message == message &&
			existing.ObservedGeneration == packageSource.Generation {
			return ctrl.Result{RequeueAfter: decision.wait}, nil
		}

		meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
			Type:               "Ready",
			Status:             metav1.ConditionUnknown,
			Reason:             reasonAwaitingRequeue,
			Message:            message,
			ObservedGeneration: packageSource.Generation,
		})
		if err := r.Status().Update(ctx, packageSource); err != nil {
			return ctrl.Result{}, err
		}
		return ctrl.Result{RequeueAfter: decision.wait}, nil

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/operator/packagesource_reconciler_test.go (1)

208-271: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Loose wait-time assertions could mask backoff regressions.

wantWaitMin only checks got.wait < tt.wantWaitMin fails, i.e. a lower bound, not the exact expected wait. Since decideRequeue's wait is deterministic (needed - elapsed), asserting exact values would catch subtle backoff-schedule regressions that a loose bound wouldn't.

Tighten to exact wait assertions
 		{
 			name:          "backoff not yet elapsed after attempt 1 — wait",
 			attempts:      1,
 			lastRequeueAt: referenceTime.Add(-10 * time.Second),
 			now:           referenceTime,
 			wantAction:    requeueActionWait,
-			wantWaitMin:   19 * time.Second, // ~initialBackoff (30s) minus elapsed 10s
+			wantWait:      20 * time.Second, // initialBackoff (30s) minus elapsed 10s
 		},

(and switch the assertion to != exact comparison)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/operator/packagesource_reconciler_test.go` around lines 208 - 271,
The test in TestDecideRequeue uses a loose lower-bound check on got.wait, which
can miss regressions in decideRequeue’s deterministic backoff calculation.
Update the wait assertion to compare exact duration equality for the cases that
set wantWaitMin, using the existing decideRequeue, requeueActionWait, and
maxRequeueAttempts symbols to keep the expected schedule tightly validated.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/operator/packagesource_reconciler.go`:
- Around line 122-128: The wait-path status update in updateStatus is swallowing
errors, which can prevent the follow-up requeue from being scheduled. In the
requeueActionWait path inside the PackageSource reconciler, stop ignoring the
error returned from r.updateStatus and propagate it instead of always returning
nil. Keep the existing logging, but make sure the caller sees the failure so the
reconcile can retry and preserve the AwaitingSourceWatcherRequeue flow.

---

Nitpick comments:
In `@internal/operator/packagesource_reconciler_test.go`:
- Around line 208-271: The test in TestDecideRequeue uses a loose lower-bound
check on got.wait, which can miss regressions in decideRequeue’s deterministic
backoff calculation. Update the wait assertion to compare exact duration
equality for the cases that set wantWaitMin, using the existing decideRequeue,
requeueActionWait, and maxRequeueAttempts symbols to keep the expected schedule
tightly validated.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d24ee9a3-e610-43be-87cc-3a28c4af82ba

📥 Commits

Reviewing files that changed from the base of the PR and between caa6607 and a99249c.

📒 Files selected for processing (2)
  • internal/operator/packagesource_reconciler.go
  • internal/operator/packagesource_reconciler_test.go

@myasnikovdaniil myasnikovdaniil left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOT LGTM — the two prior blockers are resolved, but the force-drift rework introduces a new correctness issue on the same path: the operator now co-authors the ArtifactGenerator's Ready condition, and its own Owns(AG) watch copies the synthetic Ready=False onto the PackageSource — which also makes the bounded give-up path unreachable in the running controller.

Business context: on fresh install cozystack-platform times out at 15m when a single PackageSource's ArtifactGenerator stalls in Ready: Unknown after source-watcher loses the Ready-condition write under first-install etcd load (fluxcd/pkg#934), even though its ExternalArtifacts are produced and consumed fine.

Resolved since the previous review

  • Requeue can't force drift (prior blocker): resolved. Patching AG.status.conditions[Ready]=False does reach source-watcher's drift path (detectDrift treats IsFalse(Ready) as drift → rebuild → real Ready), unlike the reconcile.fluxcd.io/requestedAt bump.
  • Untested status transitions (prior blocker): resolved — force/wait/give-up branches and the updateStatus follow-up scheduling now have unit tests; go test ./internal/operator/... passes.
  • Stale PR title/description: resolved — both now describe force-drift.
  • The swallowed-error note on Reconcile (line 126) is also addressed — the code returns the error now.

Blocker

The operator co-authors the AG Ready condition; the self-write is copied through, defeating the give-up path

forceArtifactGeneratorDrift writes AG.status.conditions[Ready]=False (reason SourceWatcherRecoveryForced) via r.Status().Patch (internal/operator/packagesource_reconciler.go:749-756). SetupWithManager registers Owns(&ArtifactGenerator{}) with no predicate (:858), so that self-write re-enqueues the PackageSource.

On the self-triggered reconcile:

  • artifactGeneratorStuck returns false because Ready is now False, not Unknown (:658-660);
  • so updateStatus takes the not-stuck path → clearRecoveryTracking deletes the attempt counter (:449) → then copies the synthetic False onto the PackageSource (:471-478), with no check that the condition is the operator's own SourceWatcherRecoveryForced marker.

This is newly introduced in this revision. The previous requeue approach wrote metadata only (reconcile.fluxcd.io/requestedAt + tracking annotations via r.Patch) and never touched ag.Status; the self-refire then re-saw Ready=Unknown, re-detected stuck, waited, and preserved the counter. Writing Ready=False to the AG status is what turns that same self-refire harmful.

Impact:

  1. Every recovery episode transiently publishes PackageSource Ready=False with a misleading "source-watcher will rebuild" message. Dependency readiness is waited-on rather than aborted, so this is not install-breaking on its own — but it is wrong status on a platform-critical resource.
  2. The bounded give-up (SourceWatcherStalled / "restart source-watcher") is effectively unreachable: the self-refire clears the counter (and the fresh LastTransitionTime also resets it via :557-559), so attempts never reaches maxRecoveryAttempts. A genuinely wedged source-watcher yields the copied synthetic False instead of the intended actionable condition, and the install still rides the 15m timeout.
  3. This integrated behavior is untested — the new tests call maybeRecoverArtifactGenerator/decideRecovery directly, bypassing the Reconcile → Owns-watch → updateStatus cycle where the self-refire happens. The unit tests pass while the real state machine short-circuits.

Fix directions (either/both):

  • In updateStatus, treat an AG Ready=False whose reason is the operator's own SourceWatcherRecoveryForced as "still recovering" (hold Ready=Unknown/wait) rather than copying it through; and/or
  • Add a predicate to Owns(&ArtifactGenerator{}) that ignores the operator's own status writes so force-drift doesn't re-enqueue and clear tracking.
  • Add an envtest exercising a stuck AG through the full recover → copy cycle so the give-up path is covered end-to-end.

Non-blocking follow-ups

  1. Install still unproven on this revision — E2E is in progress, and the description lists the live force-drift test on a genuinely stuck AG as required before merge. The mechanism is sound for the common fluxcd/pkg#934 case (source-watcher alive → recovers on drift); it just hasn't been demonstrated.
  2. Redundant Status().Update on the wait/give-up branches — with the self-refire in play these add avoidable write churn; guard with a no-op-if-unchanged check.
  3. The give-up test doesn't assert zero AG writes; an envtest (above) would cover this more meaningfully.

// This mechanism relies on source-watcher registering the standard flux
// ReconcileRequestedPredicate on ArtifactGenerator (fluxcd/pkg/runtime/predicates),
// which fires a fresh reconcile whenever `reconcile.fluxcd.io/requestedAt`
// changes. All flux-native controllers wire this predicate through

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B1 (blocker). This paragraph assumes a reconcile.fluxcd.io/requestedAt bump makes source-watcher re-write the real Ready condition. It won't in this state. source-watcher's ArtifactGenerator reconcile is drift-gated: with Inventory + ObservedSourcesDigest persisted, Ready stuck Unknown, Ready.observedGeneration == generation and sources unchanged, detectDrift returns NoDriftDetected (artifactgenerator_drift.go:52-111 @ v2.1.0), so reconcile() early-returns at controller.go:160-164 without MarkTrue(Ready), and the deferred summarizeStatus (status.go:43-67) only records lastHandledReconcileAt/adjusts Reconciling — it never promotes Unknown→True. The predicate is wired (manager.go:63-66), so the bump does fire a reconcile — but it lands on the same no-drift path. Note source-watcher already self-requeues this AG every GetRequeueAfter() (controller.go:164) and the PR evidence shows it stuck 30+ min across those; a requested reconcile is identical. Net: the driver exhausts retries and reports Ready=False, so cozystack-platform still never converges.

)
meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionFalse,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B2. This give-up branch (Ready=False / SourceWatcherStalled) is the outcome that fails the install, and it has no test. The added tests exercise decideRequeue and the annotation mechanics but not the three status-condition transitions of maybeRequeueArtifactGenerator (bump/wait AwaitingSourceWatcherRequeue, and this give-up write) or the returned RequeueAfter. In a package that already has a test convention, please pin these.

// source-watcher out of the stuck window. First attempt bumps immediately; the
// N-th subsequent attempt waits backoffFor(N) since the last bump before firing
// again; after maxRequeueAttempts fruitless attempts we surface Ready=False.
func TestDecideRequeue(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage suggestion (B2): decideRequeue is well covered, but the status side-effects of maybeRequeueArtifactGenerator are not — a fake-client test driving updateStatus through the stuck path and asserting (a) bump/wait set Ready=Unknown/AwaitingSourceWatcherRequeue with the right RequeueAfter, and (b) exhaustion sets Ready=False/SourceWatcherStalled, would lock in the load-bearing transitions.

@IvanHunters IvanHunters changed the title fix(operator): synthesise PackageSource Ready when source-watcher status-patch stalls fix(operator): force source-watcher drift on stuck ArtifactGenerator status Jul 3, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/operator/packagesource_reconciler.go (1)

445-464: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Treat SourceWatcherRecoveryForced as still in recovery

Owns(&ArtifactGenerator{}) means the forced Ready=False patch requeues this reconciler, and this branch will clear the retry annotations and copy that synthetic failure straight through. That lets the recovery budget reset before it is exhausted. Special-case the synthetic reason (or another in-recovery marker) so the AG keeps its tracking annotations and PackageSource stays Ready=Unknown until source-watcher reports a real condition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/operator/packagesource_reconciler.go` around lines 445 - 464, The
recovery handling in packageSourceReconciler should keep treating the synthetic
SourceWatcherRecoveryForced condition as still in recovery instead of clearing
annotations and propagating it as a real failure. Update the logic around
clearRecoveryTracking and the readyCondition handling so this
synthetic/in-recovery reason is special-cased, preserving the ArtifactGenerator
tracking annotations and leaving PackageSource Ready as Unknown until a real
ArtifactGenerator condition arrives.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/operator/packagesource_reconciler.go`:
- Around line 445-464: The recovery handling in packageSourceReconciler should
keep treating the synthetic SourceWatcherRecoveryForced condition as still in
recovery instead of clearing annotations and propagating it as a real failure.
Update the logic around clearRecoveryTracking and the readyCondition handling so
this synthetic/in-recovery reason is special-cased, preserving the
ArtifactGenerator tracking annotations and leaving PackageSource Ready as
Unknown until a real ArtifactGenerator condition arrives.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 98b164b8-d22a-467d-a06b-8bef72ceb8b0

📥 Commits

Reviewing files that changed from the base of the PR and between a99249c and 7b49d91.

📒 Files selected for processing (2)
  • internal/operator/packagesource_reconciler.go
  • internal/operator/packagesource_reconciler_test.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
internal/operator/packagesource_reconciler_test.go (1)

816-849: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

GiveUpBranch doesn't verify the AG is left untouched.

The line-range summary states this test asserts the AG "is left untouched," but unlike TestMaybeRecoverArtifactGenerator_WaitBranch (which uses an interceptor to count AG writes), this test only checks RequeueAfter and the PackageSource condition. Consider reusing the same interceptor pattern to assert zero AG writes on the give-up path, closing the gap between the documented intent and what's actually verified.

♻️ Suggested addition
 func TestMaybeRecoverArtifactGenerator_GiveUpBranch(t *testing.T) {
 	ps := &cozyv1alpha1.PackageSource{
 		ObjectMeta: metav1.ObjectMeta{Name: "example", Namespace: "cozy-system", Generation: 1},
 	}
 	priorForceAt := referenceTime.Add(-time.Hour).UTC().Format(time.RFC3339Nano)
 	ag := stuckAG(t, map[string]string{
 		annotationRecoveryAttempts: strconv.Itoa(maxRecoveryAttempts),
 		annotationLastRecoveryAt:   priorForceAt,
 	}, referenceTime.Add(-time.Hour))
 	c := fake.NewClientBuilder().WithScheme(testScheme(t)).WithStatusSubresource(ps, ag).WithObjects(ps, ag).Build()
-	r := &PackageSourceReconciler{Client: c, Scheme: testScheme(t)}
+	agWriteCount := 0
+	watched := interceptor.NewClient(c, interceptor.Funcs{
+		Patch: func(ctx context.Context, cli client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
+			if _, ok := obj.(*sourcewatcherv1beta1.ArtifactGenerator); ok {
+				agWriteCount++
+			}
+			return cli.Patch(ctx, obj, patch, opts...)
+		},
+		SubResourcePatch: func(ctx context.Context, cli client.Client, sub string, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error {
+			if _, ok := obj.(*sourcewatcherv1beta1.ArtifactGenerator); ok {
+				agWriteCount++
+			}
+			return cli.SubResource(sub).Patch(ctx, obj, patch, opts...)
+		},
+	})
+	r := &PackageSourceReconciler{Client: watched, Scheme: testScheme(t)}
 
 	ready := meta.FindStatusCondition(ag.Status.Conditions, "Ready")
 	res, err := r.maybeRecoverArtifactGenerator(context.Background(), ps, ag, ready, referenceTime)
 	if err != nil {
 		t.Fatalf("maybeRecoverArtifactGenerator: %v", err)
 	}
 	if res.RequeueAfter != 0 {
 		t.Errorf("RequeueAfter = %v, want 0 (give-up path stops rescheduling)", res.RequeueAfter)
 	}
+	if agWriteCount != 0 {
+		t.Errorf("give-up branch wrote %d times to AG, want 0", agWriteCount)
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/operator/packagesource_reconciler_test.go` around lines 816 - 849,
The give-up-path test for maybeRecoverArtifactGenerator does not verify that the
ArtifactGenerator remains untouched, despite the test name and summary saying it
should. Update TestMaybeRecoverArtifactGenerator_GiveUpBranch to reuse the same
interceptor approach used in TestMaybeRecoverArtifactGenerator_WaitBranch, and
assert that no write/update calls are made against the AG object while still
checking RequeueAfter and the PackageSource Ready condition.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/operator/packagesource_reconciler_test.go`:
- Around line 816-849: The give-up-path test for maybeRecoverArtifactGenerator
does not verify that the ArtifactGenerator remains untouched, despite the test
name and summary saying it should. Update
TestMaybeRecoverArtifactGenerator_GiveUpBranch to reuse the same interceptor
approach used in TestMaybeRecoverArtifactGenerator_WaitBranch, and assert that
no write/update calls are made against the AG object while still checking
RequeueAfter and the PackageSource Ready condition.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 662b8939-1af2-4283-b98e-c2d9434d4bcc

📥 Commits

Reviewing files that changed from the base of the PR and between 7b49d91 and ff4f02c.

📒 Files selected for processing (2)
  • internal/operator/packagesource_reconciler.go
  • internal/operator/packagesource_reconciler_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/operator/packagesource_reconciler.go

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOT LGTM — the force-drift status patch never wakes source-watcher (its ArtifactGenerator watch filters status-only changes, and the AG's own requeue interval is hourly), and this controller's own Owns watch re-enters and discards the recovery state, so the bounded-recovery driver does not function on a live cluster.

Business context: on a fresh install, cozystack-platform times out at 15m when a PackageSource's ArtifactGenerator stalls in Ready=Unknown after source-watcher loses its terminal Ready-condition write to the patch.Helper split-write race (fluxcd/pkg#934), even though the ExternalArtifacts are produced and consumed fine. This PR drives source-watcher back onto its drifted-branch reconcile by forcing Ready=False on the AG.

Blockers

B1: A status-only patch does not trigger a source-watcher reconcile

File: internal/operator/packagesource_reconciler.go:756

forceArtifactGeneratorDrift writes Ready=False on the AG status subresource and relies on source-watcher picking it up as drift. But source-watcher v2.1.0 (the deployed pin — internal/fluxinstall/manifests/fluxcd.yaml:8230) registers the ArtifactGenerator watch with predicate.Or(GenerationChangedPredicate{}, ReconcileRequestedPredicate{}). A status-only patch changes neither .metadata.generation nor reconcile.fluxcd.io/requestedAt, so it does not enqueue a reconcile. The only fallback is the AG's periodic self-requeue, which GetRequeueAfter() hardcodes to time.Hour. So a forced Ready=False can sit for up to ~1h before source-watcher reconciles it — well outside the 15m HelmRelease install timeout this change exists to beat. detectDrift does correctly treat IsFalse(Ready) as drift, so the condition is right; the trigger is missing.

Fix: in the metadata patch, also set reconcile.fluxcd.io/requestedAt to a fresh timestamp — that annotation is what ReconcileRequestedPredicate watches. Setting Ready=False and bumping requestedAt together makes source-watcher reconcile immediately and see the drift. The comment frames the requestedAt bump and the status patch as alternatives; they are complementary — both are required.

B2: The force-drift's own AG writes re-enter this reconciler and abandon recovery

File: internal/operator/packagesource_reconciler.go:449

SetupWithManager uses .Owns(&ArtifactGenerator{}) with no predicate, so both patches in forceArtifactGeneratorDrift (status, then metadata) enqueue a PackageSource reconcile. That reconcile reads the AG as Ready=False, artifactGeneratorStuck returns false (Ready != Unknown), so this branch runs clearRecoveryTracking — deleting the attempts/lastRecoveryAt annotations just written — and then copies the synthetic Ready=False (reason SourceWatcherRecoveryForced) onto the PackageSource. Net effect: the attempt counter never accumulates, so recoveryActionGiveUp / SourceWatcherStalled is unreachable, and the operator's internal drift-forcing marker leaks onto the user-facing PackageSource status and flaps it Unknown→False.

Fix: exclude the operator's own forced Ready=False (for example by its SourceWatcherRecoveryForced reason) from the not-stuck / clear path, and never copy that synthetic reason through to the PackageSource.

B3: The attempt-counter reset makes the give-up budget unreachable

File: internal/operator/packagesource_reconciler.go:557

When source-watcher reacts to a forced drift it first writes Ready=Unknown (Progressing) before rebuilding. That transition's LastTransitionTime is necessarily newer than lastRecoveryAt, so this branch resets attempts to 0 every cycle. Even setting B2 aside, maxRecoveryAttempts can never be reached under the repeated-loss race the driver targets, so the "~11.5m budget, safely inside the 15m install timeout" guarantee in the constants comment does not hold.

Non-blocking follow-ups

  1. The unit tests are green but cannot catch B1–B3: they drive updateStatus / maybeRecoverArtifactGenerator / forceArtifactGeneratorDrift against a fake client and simulate neither source-watcher's watch predicate nor this controller's own Owns-watch re-entrancy. An envtest that runs the manager and asserts end-to-end recovery would exercise the load-bearing interactions — the same ones the still-unchecked live verification would surface.
  2. The mechanism is pinned to source-watcher v2.1.0 in the comments, which matches the deployed image — keep it in sync when the flux distribution bumps source-watcher, since the watch predicate and GetRequeueAfter are the load-bearing upstream assumptions.

Verified, not a blocker

  • detectDrift does return drift on IsFalse(Ready) — that check is the first branch in source-watcher's drift detection, so the condition half of the approach is correct; only the trigger (B1) is missing.
  • The earlier masked-failure / stale-content concerns (raised against the previous synthesize-Ready=True approach) no longer apply: the operator never writes Ready=True itself, so it cannot mask a genuine failure or report over stale content. The pivot resolved those.

Message: "cozystack-operator forced drift after fluxcd/pkg#934 stall; source-watcher will rebuild",
ObservedGeneration: ag.Generation,
})
if err := r.Status().Patch(ctx, ag, client.MergeFrom(statusBase)); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B1: this status-only patch does not enqueue a source-watcher reconcile. The ArtifactGenerator watch filters on Or(GenerationChangedPredicate, ReconcileRequestedPredicate), and this changes neither .metadata.generation nor reconcile.fluxcd.io/requestedAt. The AG's own requeue is hardcoded to time.Hour, so a forced Ready=False can sit ~1h — past the 15m install timeout. Also set reconcile.fluxcd.io/requestedAt in the metadata patch so the reconcile is actually enqueued and detectDrift sees the Ready=False. See B1 in the summary.

// AG is not stuck — clear any recovery-tracking annotations the previous
// stuck path left behind, then either surface the missing-Ready case or
// copy the real condition through.
if err := r.clearRecoveryTracking(ctx, ag); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B2: .Owns(&ArtifactGenerator{}) has no predicate, so the force-drift's own status/metadata patches re-enter this reconciler. It reads Ready=False (classified not-stuck), clears the tracking annotations just written, and copies the synthetic SourceWatcherRecoveryForced reason onto the PackageSource. The attempt counter never accumulates and the give-up path is unreachable. See B2.

// (b) our operator missed the recovery event (crash, restart) and the AG
// re-stalled independently. Either way, stale attempts should not force
// an immediate give-up.
if ready != nil && !ready.LastTransitionTime.IsZero() && ready.LastTransitionTime.After(lastRecoveryAt) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B3: source-watcher writes Ready=Unknown (Progressing) before rebuilding, so its LastTransitionTime is always newer than lastRecoveryAt and this resets attempts to 0 every cycle — maxRecoveryAttempts is never reached under the repeated-loss race the driver targets. See B3.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
internal/operator/packagesource_reconciler_test.go (1)

1093-1158: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider also asserting annotationLastRecoveryAt is held unchanged.

The test verifies annotationRecoveryAttempts stays at "2" through the Progressing transition, but doesn't check that annotationLastRecoveryAt remains at forceAt rather than being bumped. A regression that accidentally refreshes the timestamp on Progressing (without touching attempts) would slip through.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/operator/packagesource_reconciler_test.go` around lines 1093 - 1158,
Add an assertion in TestUpdateStatus_ProgressingDuringRecoveryRoutesToAwait to
verify annotationLastRecoveryAt is preserved across the
Ready=Unknown/Progressing path, not just annotationRecoveryAttempts. After
calling PackageSourceReconciler.updateStatus and reloading the
ArtifactGenerator, check that persistedAG.Annotations[annotationLastRecoveryAt]
still matches forceAt.UTC().Format(time.RFC3339Nano) so updateStatus does not
refresh the recovery timestamp while holding tracking.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/operator/packagesource_reconciler_test.go`:
- Around line 1093-1158: Add an assertion in
TestUpdateStatus_ProgressingDuringRecoveryRoutesToAwait to verify
annotationLastRecoveryAt is preserved across the Ready=Unknown/Progressing path,
not just annotationRecoveryAttempts. After calling
PackageSourceReconciler.updateStatus and reloading the ArtifactGenerator, check
that persistedAG.Annotations[annotationLastRecoveryAt] still matches
forceAt.UTC().Format(time.RFC3339Nano) so updateStatus does not refresh the
recovery timestamp while holding tracking.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 28fddcc8-81eb-4876-85c0-53e9f558a7b2

📥 Commits

Reviewing files that changed from the base of the PR and between 97b054b and 467b788.

📒 Files selected for processing (2)
  • internal/operator/packagesource_reconciler.go
  • internal/operator/packagesource_reconciler_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/operator/packagesource_reconciler.go

@IvanHunters
IvanHunters force-pushed the fix/packagesource-synthesise-ready-when-source-watcher-status-stuck branch from 341c2a2 to 36ef73c Compare July 4, 2026 09:20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the three prior blockers (missing requestedAt trigger, Owns()-refire clearing recovery state, and the LastTransitionTime counter reset) are all resolved on this tip and covered by unit tests; the one new finding below is non-blocking. This is a code-level approval — a green E2E on this tip is still required before merge (see the gate note).

Verified resolved

  • requestedAt trigger — forceArtifactGeneratorDrift now bumps reconcile.fluxcd.io/requestedAt (internal/operator/packagesource_reconciler.go:891) alongside the Ready=False status patch, so source-watcher's ReconcileRequestedPredicate actually enqueues the reconcile. Both signals are present; the status write alone was a no-op.
  • Owns()-re-entrancy — the own-marker gate in updateStatus (:490-503) routes the reflected SourceWatcherRecoveryForced write (and Progressing-during-recovery) back through the recovery state machine instead of the clear/copy-through path, so the synthetic False no longer leaks onto the PackageSource and the attempt counter is preserved. Pinned by TestUpdateStatus_OwnMarkerHoldsTrackingViaStateMachine.
  • Counter reset — the LastTransitionTime-keyed reset is gone; the only reset is clearRecoveryTracking on the not-stuck path, so the give-up budget is reachable. Pinned by TestMaybeRecoverArtifactGenerator_DoesNotResetCounterOnFreshTransition.

Cross-checked the upstream anchors: the deployed pin is source-watcher:v2.1.0 (internal/fluxinstall/manifests/fluxcd.yaml:8230), and ArtifactGeneratorStatus inlines only ReconcileRequestStatus (no object-level ObservedGeneration), so keying stuck-detection on the condition-level generation is the only signal available and is correct.

Non-blocking follow-ups

  1. Give-up preempts the final backoff window. decideRecovery (:769-770) checks attempts >= maxRecoveryAttempts before the elapsed/backoff computation. After the 5th force sets attempts=5 and returns RequeueAfter=backoffFor(5)=4m, the status+metadata patches immediately re-fire Owns() → updateStatus → isOwnMarker → decideRecovery(5, lastRecoveryAt≈now) → GiveUp within milliseconds, preempting that 4-minute window. Effective time-to-give-up is 30s+60s+2m+4m = ~7.5m, not the ~11.5m documented at :50-53, and the final nudge gets no response window. Not install-breaking — it is still well inside the 15m HR timeout, and it self-heals if source-watcher recovers on the last nudge (Owns → Ready=True copy-through) — but the constants comment is inaccurate and the final attempt is effectively wasted. The give-up unit tests set lastRecoveryAt an hour in the past, so this exact path is untested. Fix: hold Wait until now - lastRecoveryAt >= backoffFor(maxRecoveryAttempts) before returning GiveUp, and add a test asserting Wait immediately after the final force — or correct the comment to ~7.5m and accept the collapsed window.

  2. No envtest over the full Reconcile → Owns-watch → updateStatus cycle. The unit tests drive updateStatus/maybeRecoverArtifactGenerator directly and hand-simulate the re-fire, which is exactly why the timing gap in (1) slipped through — the give-up test bypasses the real re-fire. An envtest running the manager and asserting end-to-end recovery would cover the load-bearing re-entrancy and predicate interactions that unit tests structurally cannot.

  3. Version skew (note). The operator compiles against api/v2 v2.0.3 while the comments reference v2.1.0 controller internals (drift.go:52, controller.go:164/254). This is inherent to a workaround over the deployed binary, but keep the referenced line anchors in sync when the flux distribution bumps source-watcher, since the watch predicate and GetRequeueAfter are the load-bearing upstream assumptions.

Merge gate (not a code blocker)

E2E has not been shown green on this tip against a live source-watcher; the earlier run was on a revision predating the requestedAt-trigger and dormancy fixes. The two load-bearing mechanisms — the requestedAt trigger and the status-condition drift — need a green E2E before merge. This is separate from the code review.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the follow-up commit resolves both items from my previous review; re-affirming on this tip.

  • Give-up now honors the final backoff window. decideRecovery holds Wait in the exhaustion branch until now - lastRecoveryAt >= backoffFor(maxRecoveryAttempts), so the last force-drift gets its full ~4-minute response window before the driver surfaces SourceWatcherStalled — restoring the documented ~11.5m budget instead of the ~7.5m collapse. Covered by both a TestDecideRecovery table case and TestUpdateStatus_OwnMarkerAttemptsExhaustedButFinalBackoffPending, which reproduces the exact Owns()-refire shape (attempts=max, lastRecoveryAt=now-ε) and asserts Wait rather than GiveUp. Verified locally: go test ./internal/operator/... -race passes.
  • Version-skew note added to the constants block, flagging that the v2.1.0 line anchors track the deployed binary (not the api/v2 v2.0.3 module) and must be re-verified on a source-watcher bump.

Still open, both non-blocking and unchanged:

  • No envtest over the full Reconcile → Owns-watch → updateStatus cycle. The new updateStatus-level test is closer, but it still hand-arranges the AG state rather than exercising the real manager watch + predicate.
  • E2E must be green on this tip against a live source-watcher before merge (the author's own gate). This is separate from the code review.

@IvanHunters
IvanHunters force-pushed the fix/packagesource-synthesise-ready-when-source-watcher-status-stuck branch from 578b9aa to 66d39cf Compare July 4, 2026 19:50
IvanHunters and others added 18 commits July 5, 2026 08:30
…tus-patch stalls

Symptom
  cozystack-platform HelmRelease times out at 15m on fresh install
  with a single PackageSource stuck in InProgress. Snapshot from
  #3178 CI run 28587656430:

    PackageSource cozystack.vm-disk-application
      Ready: Unknown, reason: Progressing
      lastTransitionTime: 2026-07-02T13:39:26Z
      (snapshot generated 30+ minutes later — no state change)

    ArtifactGenerator cozystack.vm-disk-application
      status.inventory:              2 items with sha256 digests
      status.observedSourcesDigest:  populated
      status.conditions[Ready]:      Unknown, reason: Progressing
      status.conditions[Reconciling]: True, reason: Progressing

  ExternalArtifacts are produced. Downstream Flux consumes them and
  its HelmReleases install fine. But PackageSourceReconciler
  faithfully copies the Unknown Ready condition through, so
  cozystack-platform (which waits on PackageSource.Ready=True) never
  converges.

Root cause
  Traced in fluxcd/source-watcher:v2.1.0 log for the affected
  ArtifactGenerator:

    13:39:28.475  ExternalArtifact vm-disk       reconciled ✅
    13:39:28.641  ExternalArtifact vm-disk-rd    reconciled ✅
    13:39:45.719  ❌ failed to update status: etcdserver: request timed out
    13:39:45.720  ❌ Reconciler error (etcd timeout)
    13:39:46.485  ✅ No drift detected, 2 artifact(s) up to date
    ——— nothing else for this ArtifactGenerator ———

  Under first-install etcd pressure the patchStatus call times out.
  The reconciler returns the etcd error; controller-runtime prints
  the "returned both a result and non-nil error, RequeueAfter
  ignored" warning and requeues. The next reconcile hits the "No
  drift detected" short-circuit (artifacts are already up to date)
  and early-exits before touching status. Result: the Ready
  condition is stuck Unknown until either the process restarts or
  something forces a state change on the ArtifactGenerator. Neither
  happens in a real install run.

  This is an upstream bug in source-watcher's reconcile loop. It has
  been papered over once already at the runtime-tuning level in
  #3163 (source-watcher concurrency + CPU), but a bad etcd second
  during install still lands us here. Cozystack cannot afford to
  wait for an upstream release cycle to unblock every install.

Fix
  PackageSourceReconciler.updateStatus now checks two invariants
  before falling back to copying the (potentially stuck) upstream
  Ready condition:

    1. ArtifactGenerator.status.Inventory is non-empty
    2. ArtifactGenerator.status.ObservedSourcesDigest is set

  Both fields are only written by source-watcher after the artifacts
  have actually been produced — status.Inventory carries the
  ExternalArtifact digests downstream Flux is already consuming. If
  BOTH are present AND the upstream Ready condition is still Unknown
  (or absent), the PackageSource Ready condition is synthesised as
  True with a distinctive reason `ArtifactsGeneratedAwaitingUpstreamStatus`
  and a message that names the upstream bug and the observable state
  it relied on. If the upstream Ready ever catches up (True or False)
  the Owns() watch re-fires the reconciler and the real condition
  overwrites the synthetic one — True stays True, False surfaces the
  real failure. Failures are NEVER masked: the synthesis path only
  triggers when Ready is Unknown or missing.

Safety
  * Never synthesises True over a real False → upstream failure is
    surfaced unchanged.
  * Requires BOTH Inventory non-empty AND ObservedSourcesDigest set
    → a fresh ArtifactGenerator that has literally not started work
    cannot be prematurely marked Ready.
  * ObservedGeneration is stamped so a spec change re-invalidates
    the synthesis until the next observable-artifacts pass.
  * Reason string is greppable so operators can audit which
    PackageSources came up via the workaround.

Tests
  Added TestArtifactGeneratorObservablyReady with 7 cases covering
  every branch of the predicate:
    - fresh generator: false
    - inventory only:  false
    - digest only:     false
    - both set + Unknown Ready:   true  (the actual bug)
    - both set + no Ready cond:   true  (same window pre-stamp)
    - both set + Ready=True:      false (pass real condition)
    - both set + Ready=False:     false (never mask failure)
  All pass; existing operator unit tests are unaffected.

Signed-off-by: Ivan Okhotnikov <[email protected]>
…re synthesising

Own-audit found the first pass had a spec-update race: if a user
edited PackageSource.spec.variants on a running cluster, cozystack
would bump the derived ArtifactGenerator to Generation=2, and
between "source-watcher started reconciling gen=2" and "source-
watcher wrote new Inventory + ObservedSourcesDigest for gen=2" the
ArtifactGenerator still exposes the previous generation's
Inventory / ObservedSourcesDigest AND Ready=Unknown (Progressing).
The workaround would synthesise Ready=True on stale artifacts, and
downstream Flux would consume the previous-generation
ExternalArtifacts as if the new spec had converged.

Tighten the predicate to require the Ready condition's
ObservedGeneration to match ag.Generation. Only when source-watcher
has actually processed the current spec (which happens strictly
after it writes the current-generation Inventory + Digest) does the
Ready condition carry ObservedGeneration == ag.Generation. That is
exactly the window where Inventory / Digest are guaranteed current-
spec AND only the status patch on Ready itself failed — the actual
upstream bug this workaround exists to paper over.

Consequence: if Ready is missing entirely we return false now
(was true). We cannot verify ObservedGeneration on an absent
condition, and the missing-Ready state is indistinguishable from
mid-reconcile-of-a-newer-generation. On the next reconcile source-
watcher will write at least a Progressing condition, at which point
the current-spec Generation check will be evaluable and the
workaround will kick in if — and only if — Inventory / Digest also
match the current spec.

Tests updated:
  * changed 'no Ready condition' case from want=true to want=false
    (reflecting the tighter invariant);
  * added a case where Ready=Unknown carries an OLD Generation
    (want=false — do not synthesise on stale artifacts);
  * stamped ObservedGeneration on every existing condition-bearing
    case so they exercise the matching-Generation branch;
  * introduced a small agWithGen helper to keep the fixtures
    readable now that Generation is load-bearing.

All 8 subtests pass; existing operator suite unaffected.

Signed-off-by: Ivan Okhotnikov <[email protected]>
… review nits

kvaps and lexfrei confirmed the diagnosis by tracing source-watcher
v2.1.0 (same code paths in v2.2.x / flux-aio latest, so a simple
version bump does not remove the need for this workaround):

  * fluxcd's patch.Helper patches .status and .status.conditions as
    SEPARATE apiserver requests, so an etcd timeout during first-
    install load can land between them — leaving Inventory /
    ObservedSourcesDigest persisted while the Ready-condition write
    is lost.
  * On the next reconcile the !hasDrifted branch returns before
    touching conditions. The deferred summarizeStatus still runs and
    adjusts the Reconciling condition, but it never promotes a
    stuck Unknown Ready to True — that promotion only happens on the
    successful hasDrifted path.

Two substantive changes prompted by the reviews:

  1. Document the semantic change the workaround introduces on
     PackageSource.status.conditions[Ready]. fluxcd/pkg/runtime/
     conditions.Set writes condition.ObservedGeneration =
     ag.Generation on EVERY condition mutation including the
     Progressing/Unknown mark at the start of a rebuild. And
     ArtifactGeneratorStatus does not expose an object-level
     ObservedGeneration (only ReconcileRequestStatus is inlined).
     So Generation-matching cannot distinguish 'current revision
     fully processed' from 'rebuild in progress, previous
     Inventory/digest still persisted' — meaning the predicate can
     (and does) fire during a content-only regeneration (new OCI
     revision, same .metadata.generation).

     Net effect: PackageSource Ready no longer dips through Unknown
     while a new revision is being processed; it now means 'valid
     consumable artifacts exist' rather than 'the current revision
     has been processed'. Kvaps flagged this as the better contract
     for install/upgrade ordering (no InProgress flapping, and a
     genuine regeneration failure still surfaces as Ready=False on
     the successful hasDrifted path). Document it inline instead of
     implying the workaround only fires in the stuck-status window.

  2. Reworked the predicate signature to take the pre-resolved
     Ready condition as a parameter. The caller was already doing
     the FindStatusCondition lookup for the pass-through path, so
     the helper no longer duplicates it (kvaps' nit).

Also refreshed the pre-existing comments:
  * The 'early-exits before touching status again' step is now
    correctly worded as 'returns before touching conditions again;
    the deferred summarizeStatus never promotes a stuck Unknown
    Ready to True'.
  * Bug attribution now names the code paths ('!hasDrifted branch',
    'summarizeStatus deferred'), notes that v2.1.0 and v2.2.x
    ('flux-aio latest') both ship the affected code, and carries a
    TODO(remove once upstream is fixed) marker so the workaround
    cannot silently outlive its cause.

Tests: existing 8 subtests updated to construct the ready condition
manually so the call matches the new signature, plus one new
subtest locking in the intentional 'content-only regeneration
predicate fires' behaviour so a future refactor that tightens the
predicate back to 'current revision processed' — which would
re-introduce the flapping — fails loudly. 9/9 pass.

Signed-off-by: Ivan Okhotnikov <[email protected]>
…issue)

Reviewer nit was to file an upstream issue so the workaround does
not silently outlive its cause. Checked first — fluxcd/pkg already
has one open: #934 \"patch.Helper creates race conditions in
downstream controllers\" (May 2025). Same root cause we traced:
patch.Helper writes .status and .status.conditions as separate
apiserver requests, so an etcd blip between the two leaves the
object half-persisted. The upstream issue is broader than just
source-watcher — every fluxcd downstream controller consuming
fluxcd/pkg's runtime patch helper is affected.

Update the TODO(remove) marker on our workaround to point at
that issue and note that both source-watcher v2.1.0 and v2.2.x
(flux-aio latest) pull the same fluxcd/pkg runtime, so bumping
source-watcher alone does not remove the need for this synthesis.
Compile + unit tests unchanged.

Signed-off-by: Ivan Okhotnikov <[email protected]>
…comment

review-branch skill flagged that the comment claimed
meta.SetStatusCondition rewrites LastTransitionTime on Status OR
Reason change. Actually only Status change triggers a
LastTransitionTime rewrite — Reason and Message are updated in
place, LastTransitionTime is preserved. Outcome (repeated
identical calls are no-op-y on persisted state) is unchanged;
just tightening the technical claim to match apimachinery.

Signed-off-by: Ivan Okhotnikov <[email protected]>
…of synthesising PackageSource Ready

Replaces the Ready=True synthesis for PackageSources whose ArtifactGenerator
hit the fluxcd/pkg#934 patch.Helper split-write race with a bounded retry
driver: on detected stall, bump reconcile.fluxcd.io/requestedAt with
exponential backoff (30s to 4m cap) up to 5 attempts, then surface as
PackageSource Ready=False with reason SourceWatcherStalled.

The previous approach could mask a genuine regeneration failure if the same
patch.Helper race that the workaround exists for also swallowed a real
Ready=False write, and it permanently reinterpreted PackageSource.Ready
semantics from "current revision processed" to "valid consumable artifacts
exist". Requeue keeps the CRD contract intact and, on stall exhaustion,
produces an honest failure signal an operator can act on instead of a
false-positive True.

Retry state (attempts + last-bump timestamp) lives on the AG as
cozystack.io/source-watcher-* annotations so it survives operator restarts.

Signed-off-by: Ivan Okhotnikov <[email protected]>
…ches, tighten status

Address self-review nits on the bounded-requeue driver:

* Sample `now := time.Now()` once at Reconcile entry and thread it into
  `updateStatus`, `artifactGeneratorStuck` and `maybeRequeueArtifactGenerator`
  so every time-sensitive check inside a reconcile agrees on the same
  wall-clock reading.
* Set `ObservedGeneration` in the three early Ready-condition branches
  (`SourceRefNotSet`, `ArtifactGeneratorNotFound`, `ArtifactGeneratorNotReady`)
  for parity with the retry-driver branches.
* Patch the ArtifactGenerator via a deep copy in
  `bumpArtifactGeneratorRequeue` and `clearRequeueTracking` so a failed Patch
  no longer leaves the caller's in-memory `ag` desynchronised from the
  apiserver.
* Document the retry driver's dependency on source-watcher's
  ReconcileRequestedPredicate on ArtifactGenerator, so a future upstream
  change dropping the predicate is easier to trace.

Signed-off-by: Ivan Okhotnikov <[email protected]>
…ct, document HA + timing rationale

Address second-pass self-review findings on the bounded-requeue driver:

* Skip reconcile early when the PackageSource has a deletionTimestamp so
  the retry driver stops writing "AwaitingSourceWatcherRequeue" onto an
  object about to be garbage collected. ownerReference cascade handles
  the ArtifactGenerator cleanup.
* Fix the mutation contract on bumpArtifactGeneratorRequeue and
  clearRequeueTracking: the previous deep-copy shape left the caller's
  `ag` desynchronised from the apiserver on success. Now mutations land
  on `ag` itself with a rollback to the pre-call annotation snapshot on
  Patch failure — so success reflects persisted state and failure does
  not leak unpersisted state to subsequent code paths.
* Convert the "unreachable" default in maybeRequeueArtifactGenerator to
  an explicit panic so a future requeueAction added without a matching
  case fails loudly instead of freezing the Ready condition silently.
* Document the timing constants' rationale (grace period matches the
  observed &lt;10s source-watcher settle time on dev3; total ~11.5m budget
  keeps SourceWatcherStalled inside the 15m HR install timeout) and note
  the operator must run with leader election when replicas > 1 so the
  annotation-based retry state does not race.

Signed-off-by: Ivan Okhotnikov <[email protected]>
…lper for the requeue driver

Address the third-pass self-review's blocker: the mutation contract on
bumpArtifactGeneratorRequeue / clearRequeueTracking, the DeletionTimestamp
early-return in Reconcile, and the nil-preserving cloneAnnotations helper
had no regression tests, so a future refactor could silently break the
contracts documented in comments.

New fake-client + interceptor-based tests:

* TestCloneAnnotations — pins the "nil in / nil out" sentinel behaviour
  and mutation-independence of the shallow copy.
* TestBumpArtifactGeneratorRequeue_Success — asserts the persisted state
  matches the caller's in-memory `ag.Annotations` on success, including
  that reconcile.fluxcd.io/requestedAt is actually written (previously
  never asserted anywhere).
* TestBumpArtifactGeneratorRequeue_PatchFailure_Rollback and its
  PreservesUnrelated variant — drive the Patch-failure branch via an
  interceptor client and assert both that our tracking annotations are
  rolled back and that pre-existing unrelated annotations survive.
* TestClearRequeueTracking_Noop — asserts we don't issue a no-op Patch on
  every healthy reconcile when there are no tracking annotations to clear.
* TestClearRequeueTracking_PatchFailure_Rollback — same rollback contract
  as bump, on the clear path.
* TestReconcile_DeletionTimestamp_EarlyReturn — asserts a PackageSource
  with a deletionTimestamp does not receive any writes and does not
  schedule a RequeueAfter.

Doc tightening in the same commit:

* Reword clearRequeueTracking's comment on the flux annotation semantics —
  standard flux controllers do not clear reconcile.fluxcd.io/requestedAt,
  they only advance .status.lastHandledReconcileAt via the
  ReconcileRequestedPredicate.
* Note controller-runtime v0.15+'s default RecoverPanic behaviour where
  the retry-driver's unhandled-action panic is raised, so a future
  SetupWithManager flip that disables it is easier to spot.
* Explain why cloneAnnotations' shallow copy is sound (annotation values
  are immutable strings).

Signed-off-by: Ivan Okhotnikov <[email protected]>
Address the fourth-pass self-review's non-blocking NITs:

* Add TestBumpArtifactGeneratorRequeue_PatchFailure_RollbackRestoresPriorBump
  to cover the subtle case the two existing rollback tests miss: when the
  tracking annotations were already set from a prior bump, a failed Patch
  must restore the PRIOR values, not delete the keys. A refactor that
  naively swapped rollback for `delete(ag.Annotations, key)` would pass
  the nil-rollback tests but silently regress this contract.
* Rename the cloneAnnotations subtest from "deep copy" to "independent
  copy" — the code documents shallow copy semantics and the misnomer was
  the only thing pointing readers at the wrong mental model.
* Hoist `now.UTC().Format(time.RFC3339Nano)` to a local so the two
  timestamp annotations use one formatted value instead of two identical
  Format calls.

Signed-off-by: Ivan Okhotnikov <[email protected]>
…ue bump

Rework the fluxcd/pkg#934 recovery driver after myasnikovdaniil's B1
finding: bumping reconcile.fluxcd.io/requestedAt cannot recover a stuck
ArtifactGenerator because source-watcher v2.1.0's reconcile is
drift-gated. detectDrift (artifactgenerator_drift.go:52) returns
NoDriftDetected for exactly the stuck signature we target — Inventory
populated, ObservedSourcesDigest set, Ready=Unknown on the current
generation — so the ReconcileRequestedPredicate fires a reconcile that
lands on the no-drift early-return at controller.go:164 and MarkTrue at
:254 is never reached. Every attempt in the previous shape was a no-op.

New mechanism: force drift by patching Ready=False on the AG's status
subresource. detectDrift picks this up as NotReady drift, source-watcher
takes the drifted branch, and either MarkTrue (on healthy rebuild) or
MarkFalse with a real reason (on genuine failure) replaces our synthetic
False. Owns() watch fires this reconciler to copy the resolved condition
through. Same bounded-retry harness (30s / 60s / 2m / 4m / 4m budget
inside the 15m HR install timeout, SourceWatcherStalled surfaced on
exhaustion), same annotation-based state on the AG, same rollback
contract on Patch failure — only the action itself changed.

Additional fixes bundled in:

* Reset attempt counter when the AG's Ready.LastTransitionTime is newer
  than our lastRecoveryAt, so a fresh stall episode after a previous
  give-up gets a fresh budget rather than an immediate re-give-up
  (myasnikovdaniil's follow-up #3).
* Propagate updateStatus errors from Reconcile instead of swallowing them,
  so controller-runtime's exponential backoff retries transient failures
  on force-drift / status-update writes rather than dropping them silently
  (coderabbit's inline finding).
* forceArtifactGeneratorDrift documents the two-subresource shape (status
  patch first, metadata patch second) and the partial-failure semantics.
* New tests cover the status-patch success / status-patch failure /
  metadata-patch failure paths, all with fake client + interceptor. The
  successful case asserts Ready=False actually lands in the apiserver via
  WithStatusSubresource.
* All annotation / action / decision / helper names renamed from
  *Requeue* to *Recovery* to match the new semantics.

Signed-off-by: Ivan Okhotnikov <[email protected]>
…ait assertions, polish comments

Address the fourth-pass self-review's non-blocking findings:

* Add TestMaybeRecoverArtifactGenerator_{ForceBranch, WaitBranch,
  GiveUpBranch, ResetsCounterOnFreshTransition}. The three switch
  outcomes of the driver (force / wait / give-up) and the reset-counter
  guard on a fresh Ready.LastTransitionTime transition were previously
  only covered indirectly through their pure-function components. Now
  each outcome is asserted end-to-end against a fake client with a
  status subresource, verifying the Ready condition, tracking
  annotations, RequeueAfter, and — for the wait branch — that no AG
  writes are issued at all.
* Tighten TestDecideRecovery's wait-branch assertions from a loose lower
  bound to exact equality. decideRecovery is deterministic
  (needed - elapsed) so an exact assertion catches subtle backoff
  regressions a lower bound would miss (coderabbit's inline nit).
* Rename the local var `wantWaitMin` to `wantWait` for the tighter
  assertion shape.
* Fix two stale identifier references in comments
  (maybeRequeueArtifactGenerator, AwaitingSourceWatcherRequeue) left
  over from the rename pass.
* Add a docstring paragraph on forceArtifactGeneratorDrift explaining
  why the JSON-merge-patch full-array-replace on
  `.status.conditions[]` is net-neutral in this workaround's context.

Signed-off-by: Ivan Okhotnikov <[email protected]>
…hin grace

E2E on dev3 caught a dormancy bug that renders the whole recovery driver
inactive in practice: when updateStatus copies Ready=Unknown across but
the AG's LastTransitionTime is still within stuckGracePeriod,
artifactGeneratorStuck returns false and we fall through to the copy
path with no RequeueAfter set. If the reconcile happens to fire in the
[stuck_start, stuck_start+30s) window — a routine case since operator
restarts touch every PackageSource — the controller then sleeps
waiting for an AG-status change that will never come (that IS the
fluxcd/pkg#934 lost-write race), up to the 10h informer resync.

Observed on dev3: `cozystack.networking` stuck at Ready=Unknown for 9+
minutes with zero recovery attempts. Two reconciles fired at
stuck_start+23s and +24s — both correctly detected "not yet stuck" —
and then nothing until observation ended. Upstream OCIRepository
revision was unchanged throughout, ruling out a coincidental source
update as the cause.

Fix: whenever we copy Ready=Unknown to the PackageSource (or set a
synthetic Unknown because no Ready condition exists yet), compute the
remaining grace period against the AG's LastTransitionTime (or
CreationTimestamp for the missing-Ready branch) and schedule a
RequeueAfter at that expiry. A 1s floor prevents a zero-delay tight
loop when the anchor is right at or past grace expiry.

Extracted the delay computation to `agFollowUpDelay` for direct unit
testing. Adds three tests: the follow-up scheduling on Ready=Unknown
in-grace, the same on the missing-Ready branch, and a table test on
agFollowUpDelay covering the floor edge cases.

Signed-off-by: Ivan Okhotnikov <[email protected]>
…remove reset-counter loop

Address lexfrei's three blockers on the force-drift approach — as
committed, the recovery driver did not function on a live cluster.

**B1**: `forceArtifactGeneratorDrift` wrote `Ready=False` on the AG's
status subresource but did not touch its metadata annotations.
source-watcher v2.1.0 registers its ArtifactGenerator watch with
`predicate.Or(GenerationChangedPredicate, ReconcileRequestedPredicate)`
— a status-only patch changes neither the generation nor
`reconcile.fluxcd.io/requestedAt`, so it does NOT enqueue a
source-watcher reconcile. The AG's periodic self-requeue is hardcoded
to `time.Hour`, well outside the 15m HR install timeout. Fix: also
stamp `reconcile.fluxcd.io/requestedAt` in the metadata patch so the
ReconcileRequestedPredicate fires and source-watcher's next reconcile
picks up the `IsFalse(Ready)` drift signal.

**B2**: `Owns(&ArtifactGenerator{})` re-fires this reconciler on the
force-drift's own writes. Both the status patch (Ready=False, reason
`SourceWatcherRecoveryForced`) and the metadata patch enqueue a
PackageSource reconcile. Blindly running `clearRecoveryTracking` on
those re-entrant fires nuked the attempts counter — the give-up
budget was unreachable, and the synthetic marker leaked to
`PackageSource.status.conditions[Ready]`. Fix: in `updateStatus`,
detect our own reason and route into a new `awaitSourceWatcherResponse`
helper that holds tracking and reports Unknown /
AwaitingSourceWatcherRecovery on the PS. The same helper covers the
Progressing/Unknown write source-watcher issues during rebuild once
tracking is present (attempts > 0) — otherwise the transient
Progressing would trigger the same premature tracking clear.

**B3**: The attempt-counter reset keyed on
`Ready.LastTransitionTime > lastRecoveryAt`. That inequality is true
on every source-watcher response during recovery (the Progressing
write is always newer than our force), so under the very race this
driver targets the counter would reset every cycle and
`recoveryActionGiveUp` was unreachable. Fix: remove the reset. The
canonical counter reset is the natural `clearRecoveryTracking` on the
not-stuck path (Ready=True from source-watcher, or a real upstream
Ready=False), and the HA/leader-election requirement already
contains the "missed recovery event on operator restart" edge case
the reset was trying to paper over.

Extract `reasonRecoveryForced` to a named constant so both the
writer (forceArtifactGeneratorDrift) and the reader (updateStatus'
own-marker filter) use the same symbol.

New tests pin all three fixes:
- ForceArtifactGeneratorDrift_Success now asserts the
  reconcile.fluxcd.io/requestedAt annotation is set (B1).
- UpdateStatus_OwnMarkerRoutesToAwait asserts we do NOT clear
  tracking and do NOT copy the synthetic Ready=False through when we
  see our own marker (B2).
- UpdateStatus_ProgressingDuringRecoveryRoutesToAwait covers the
  second Owns() re-entrancy path — source-watcher's Progressing
  write during rebuild (B2).
- UpdateStatus_ReadyTrueClearsTracking pins that the natural not-
  stuck path IS the canonical counter reset after B3.
- The old ResetsCounterOnFreshTransition test is replaced with
  DoesNotResetCounterOnFreshTransition, asserting the opposite
  behaviour that closes B3.

Signed-off-by: Ivan Okhotnikov <[email protected]>
…rce-watcher-unresponsive dormancy loop

Self-review on 467b788 caught a MAJOR bug introduced by the separate
awaitSourceWatcherResponse helper: if source-watcher never responds to
a forced drift (network partition, missing predicate, dead pod), our
own reasonRecoveryForced marker keeps reflecting back via Owns() forever,
and the wait-forever helper schedules a 1-second requeue tight loop
without ever advancing the retry state machine. SourceWatcherStalled
give-up was unreachable.

Fix: remove awaitSourceWatcherResponse and unify all three recovery
entry conditions — our own marker, source-watcher's Progressing during
rebuild, and the classic first-time stuck detection — through a single
gate that routes to maybeRecoverArtifactGenerator. The state machine's
decideRecovery is the single arbiter of wait/force/give-up, and its
Wait branch is bounded by the same backoff schedule as Force. When
backoff elapses on a still-reflecting own-marker the machine escalates
to another force (bumps attempts, re-stamps requestedAt); when attempts
exhaust it surfaces SourceWatcherStalled honestly.

The own-marker filter also no longer requires attempts>0, so an
operator crash between step 1 (status patch) and step 2 (metadata
patch) of forceArtifactGeneratorDrift is still routed through the
state machine on the next reconcile rather than leaking the synthetic
marker onto the PackageSource.

Also address the non-blocking notes from the same self-review:

* Docstring on maybeRecoverArtifactGenerator was still describing the
  removed reset-counter branch and the "requestedAt alone is a no-op"
  framing. Rewrite to explain the current shape: status write triggers
  the drift signal, requestedAt bump triggers the reconcile event —
  both are required, neither alone works.
* TestForceArtifactGeneratorDrift_MetadataPatchFailure_RollbackAnnotations
  now asserts reconcile.fluxcd.io/requestedAt is also rolled back on
  the metadata-patch failure branch (symmetric with the tracking
  annotations).
* TestUpdateStatus_OwnMarkerRoutesToAwait asserts an exact 25s
  RequeueAfter (backoffFor(1)=30s minus 5s elapsed) so a silent
  regression in the backoff arithmetic is caught.

New regression tests for the source-watcher-unresponsive path:

* TestUpdateStatus_OwnMarkerStale_TriggersRetry: with attempts=1 and
  lastRecoveryAt older than backoffFor(1), the reconcile must
  escalate through the Force branch and bump attempts to 2 rather
  than idle in a tight requeue loop.
* TestUpdateStatus_OwnMarkerAttemptsExhausted_GivesUp: with
  attempts=maxRecoveryAttempts and the marker still reflecting back,
  the reconcile must set PackageSource Ready=False /
  SourceWatcherStalled instead of extending the loop indefinitely.

Signed-off-by: Ivan Okhotnikov <[email protected]>
…m comments and test names

Self-review after the previous commit removed the awaitSourceWatcherResponse
helper (routing unified through maybeRecoverArtifactGenerator's state machine
via a single own-marker / progressing-in-recovery / stuck gate). Four
comments and two test names still referenced the now-deleted symbol —
misleading anyone tracing the architecture.

* maybeRecoverArtifactGenerator docstring: rewrote the trade-off paragraph
  to describe the unified gate and Wait branch instead of the removed
  helper.
* TestMaybeRecoverArtifactGenerator_DoesNotResetCounterOnFreshTransition
  comment: same rewrite.
* TestUpdateStatus_OwnMarkerRoutesToAwait renamed to
  TestUpdateStatus_OwnMarkerHoldsTrackingViaStateMachine + comment
  rewritten to describe the state-machine Wait branch.
* TestUpdateStatus_ProgressingDuringRecoveryRoutesToAwait renamed to
  TestUpdateStatus_ProgressingDuringRecoveryHoldsTracking + comment
  rewritten in parallel.

Pure doc / naming; no code or test-body changes. Tests still pass with
-race, vet clean, golangci-lint 0 issues.

Signed-off-by: Ivan Okhotnikov <[email protected]>
…OG check, warn on partial force-drift

Self-review nits raised in a skeptical pass over the current tip:

* The status-patch docstring in forceArtifactGeneratorDrift described
  the JSON-merge-patch full-array-replace on `status.conditions[]` as
  "net-neutral". That was too optimistic: if source-watcher writes a
  concurrent Reconciling condition between our Get and our Patch, our
  patch does transiently wipe it. source-watcher restores it on its
  next reconcile (which our requestedAt bump is about to trigger), but
  downstream tooling watching for Reconciling can observe the momentary
  absence. Rewrite the comment to name that trade-off explicitly and
  point at server-side apply as the fuller fix if the operator ever
  migrates to SSA globally.

* The unified own-marker/progressing/stuck gate in updateStatus is
  asymmetric on ObservedGeneration — isProgressingInRecovery checks it,
  isOwnMarker does not. That asymmetry is deliberate (a stale own-marker
  after a spec edit must still route to the state machine to avoid
  leaking the synthetic reason to the PackageSource — exactly the B2
  regression), not an oversight. Add a comment explaining the
  reasoning so a future edit doesn't "symmetrise" the check and re-open
  B2.

* forceArtifactGeneratorDrift's step-2 (metadata patch) failure was
  silent when it happened after a successful step-1 status patch. In
  that state source-watcher has been nudged, but the tracking
  annotations never persisted, so the next reconcile reads attempts=0
  and re-forces from scratch instead of accumulating toward
  SourceWatcherStalled — a persistent metadata-write-only apiserver
  malfunction would loop forever. Log the failure loudly with the
  intended attempt count so operators can spot this specific mode
  from cozystack-operator logs.

Signed-off-by: Ivan Okhotnikov <[email protected]>
…m version skew

Address lexfrei's non-blocking follow-ups on the LGTM approval:

Follow-up #1 — give-up preempted the final backoff. `decideRecovery`
returned `recoveryActionGiveUp` as soon as `attempts >= maxRecoveryAttempts`,
without checking whether the final-force backoff window had elapsed. The
Owns()-watch re-fire from the N-th force-drift's own writes triggered a
reconcile milliseconds after the force, so the effective time-to-give-up
was ~7.5m (30s+60s+2m+4m) — not the ~11.5m documented on the constants
block. The final 4-minute response window the last nudge was supposed to
grant source-watcher was never actually granted.

Fix: extend the exhaustion branch to hold `Wait` until
`now - lastRecoveryAt >= backoffFor(maxRecoveryAttempts)` before switching
to GiveUp. Now the documented ~11.5m budget matches the observed
behaviour, and the last force gets a real chance to succeed (Owns
watch → source-watcher Ready=True copy-through) before we surface
SourceWatcherStalled.

Tests:
* `TestDecideRecovery` gains a case pinning `Wait` when
  attempts=maxRecoveryAttempts but the final backoff has not yet
  elapsed (the exact re-fire shape the previous behaviour tripped on).
* `TestUpdateStatus_OwnMarkerAttemptsExhaustedButFinalBackoffPending`
  drives the full updateStatus path against the same shape, asserting
  the PackageSource stays in Unknown/AwaitingSourceWatcherRecovery and
  the RequeueAfter equals the remaining backoff, NOT zero (GiveUp).

Follow-up #3 — upstream version-skew note. The Go module dependency
tracks `fluxcd/source-watcher/api/v2 v2.0.3` (CRD types package only),
but the deployed binary this workaround targets is pinned to v2.1.0
at `internal/fluxinstall/manifests/fluxcd.yaml`. Comments referencing
`drift.go:52` / `controller.go:164/254` describe the deployed binary,
not the compiled API package. Documented on the constants block so a
future flux bump re-verifies the anchors AND the watch predicate /
drift-detection heuristics, which are the load-bearing upstream
assumptions.

Follow-up #2 (envtest over full Reconcile→Owns→updateStatus cycle) is
architectural and out of scope for this PR — noted for a follow-up.

Signed-off-by: Ivan Okhotnikov <[email protected]>
@IvanHunters
IvanHunters force-pushed the fix/packagesource-synthesise-ready-when-source-watcher-status-stuck branch from 66d39cf to 0d98e27 Compare July 5, 2026 05:30

@myasnikovdaniil myasnikovdaniil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Re-reviewed at 0d98e279 — my earlier blocker is resolved: the unified own-marker gate in updateStatus routes the reflected SourceWatcherRecoveryForced write into the recovery state machine instead of copying it onto the PackageSource, so the synthetic Ready=False no longer leaks and the SourceWatcherStalled give-up is reachable again. B1 (force-drift writes both the status condition and requestedAt) and B2 (give-up path now tested) are addressed, and the integrated updateStatus recovery cycle is covered by fake-client tests. Unit + E2E green on this head.

Non-blocking nits, fine to defer: the unconditional Status().Update in the wait/give-up branches (Gemini's note), and the lexfrei's review on PR #3182 / B2 shorthand in the comments will read as orphaned context once merged — trim to the technical rationale.

@myasnikovdaniil
myasnikovdaniil merged commit 6b6fad0 into main Jul 6, 2026
42 checks passed
@myasnikovdaniil
myasnikovdaniil deleted the fix/packagesource-synthesise-ready-when-source-watcher-status-stuck branch July 6, 2026 04:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/platform Issues or PRs related to platform infrastructure (bundle, flux, talos, installer) area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review kind/bug Categorizes issue or PR as related to a bug size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants