Leaf of #16566, split so the epic's remaining criteria are not closed over by one PR (the #16776 pattern). Delivered by PR #16891.
Context
isRepoDue computes backoffCapped (ai/daemons/orchestrator/scheduling/tenantRepoSync.mjs:183) and returns it. The reported per-repo state drops it, so the only cadence number an operator can read is effectiveCadenceMs.
The Problem
effectiveCadenceMs: 7200000 has two meanings and no way to tell them apart:
- a repo configured with a 2h cadence — nothing wrong;
- a repo whose failure streak has driven the uncapped cadence far past the cap, so the cap is all that is left of it — badly wrong, and at
consecutiveFailures: 12 the multiplier is 4096.
Those need opposite responses. #16566's parent context has the live shape: repos sitting at consecutiveFailures: 12 with the cadence pinned at the 2h cap, corpus frozen, while the reported state read like a deliberate configuration.
The Architectural Reality
Verified against origin/dev at 7ef07a7ee3:
isRepoDue returns {due, dueReason, recoveryBypass, effectiveCadenceMs, jitterMs, backoffMultiplier, backoffCapped, lastRunAttemptAt}.
- There are TWO independent producers of a per-repo cadence row, and the operator-facing one is the bridge.
DeploymentStateBridgeService.summarizeTenantRepoState (:2042) builds its rows from the persisted revisions plus isRepoDue, so it never touches the record runTask assembles. It publishes effectiveCadenceMs, jitterMs and backoffMultiplier — the row an operator and inspect_deployment actually read.
- Seven
repoStates.push sites inside TenantRepoSyncService, of which only the not-due/backoff-suppressed one publishes a cadence; the other six (revalidation-deferred, recovery-receipt-deferred, deferred, active, aborted-lease-lost, failedRepoState) carry a status and no numbers.
- The "cadence-assembling path" the criterion warned about turned out to be the log line, which already prints
backoffX=${dueState.backoffMultiplier} while both structured records carried nothing. So a human tailing logs could tell; a consumer reading either row could not.
backoffMultiplier beside the cadence is what makes this look already-solved and does not solve it: a multiplier of 4096 with a cadence at the cap and a multiplier of 1 with a cadence below it are the two readings, and distinguishing them requires the reader to recompute the uncapped product. An adjacent field that almost answers the question is worse than none, because it stops the search.
Corrected 2026-08-10 after @neo-gpt's RC on PR #16891. This section originally said "all six push sites traced" and treated the internal task record as the surface. Both were wrong: the census was seven, and the bridge is a second producer I never looked for because I enumerated producers inside one file instead of asking who projects the state outward.
The Fix
Publish backoffCapped on both cadence-publishing rows — the bridge's operator-facing repos[] row first, the internal task record second.
Disabled repos report null, not false. The bridge synthesizes a dueState for a disabled repo with every cadence field nulled; a repo with no cadence has no answer to "is the cap binding?", and false would read as an observation that it is not.
The magnitude the cap hides is deliberately NOT republished. consecutiveFailures is already on the record and the multiplier is 2^failures, so a consumer can derive it and falsify the arithmetic rather than inherit a number it cannot check — the same reasoning processHeapObservation uses for carrying raw spaces beside their sums. An earlier revision published uncappedCadenceMs and extended isRepoDue to return it; both were dropped as accretion once the derivation was obvious.
Acceptance Criteria
Out of Scope
- Publishing or recomputing the uncapped cadence, per above.
- The remaining #16566 criteria: scheduling fairness, the
failed-never-uninitialized reporting half, and the plane-named L3 proof.
Related
Leaf of #16566 · sibling of #16577 (same reporting surface, different defect).
Live latest-open sweep: latest 8 open issues created-descending at 2026-08-10T13:39:08Z; A2A claim sweep over the latest messages, all read-states, same minute. No equivalent and no competing claim.
Origin Session ID: 4131135d-1b20-487f-9d23-d7213914246b
Leaf of #16566, split so the epic's remaining criteria are not closed over by one PR (the #16776 pattern). Delivered by PR #16891.
Context
isRepoDuecomputesbackoffCapped(ai/daemons/orchestrator/scheduling/tenantRepoSync.mjs:183) and returns it. The reported per-repo state drops it, so the only cadence number an operator can read iseffectiveCadenceMs.The Problem
effectiveCadenceMs: 7200000has two meanings and no way to tell them apart:consecutiveFailures: 12the multiplier is4096.Those need opposite responses. #16566's parent context has the live shape: repos sitting at
consecutiveFailures: 12with the cadence pinned at the 2h cap, corpus frozen, while the reported state read like a deliberate configuration.The Architectural Reality
Verified against
origin/devat7ef07a7ee3:isRepoDuereturns{due, dueReason, recoveryBypass, effectiveCadenceMs, jitterMs, backoffMultiplier, backoffCapped, lastRunAttemptAt}.DeploymentStateBridgeService.summarizeTenantRepoState(:2042) builds its rows from the persisted revisions plusisRepoDue, so it never touches the recordrunTaskassembles. It publisheseffectiveCadenceMs,jitterMsandbackoffMultiplier— the row an operator andinspect_deploymentactually read.repoStates.pushsites insideTenantRepoSyncService, of which only the not-due/backoff-suppressed one publishes a cadence; the other six (revalidation-deferred,recovery-receipt-deferred,deferred,active,aborted-lease-lost,failedRepoState) carry a status and no numbers.backoffX=${dueState.backoffMultiplier}while both structured records carried nothing. So a human tailing logs could tell; a consumer reading either row could not.backoffMultiplierbeside the cadence is what makes this look already-solved and does not solve it: a multiplier of4096with a cadence at the cap and a multiplier of1with a cadence below it are the two readings, and distinguishing them requires the reader to recompute the uncapped product. An adjacent field that almost answers the question is worse than none, because it stops the search.The Fix
Publish
backoffCappedon both cadence-publishing rows — the bridge's operator-facingrepos[]row first, the internal task record second.Disabled repos report
null, notfalse. The bridge synthesizes adueStatefor a disabled repo with every cadence field nulled; a repo with no cadence has no answer to "is the cap binding?", andfalsewould read as an observation that it is not.The magnitude the cap hides is deliberately NOT republished.
consecutiveFailuresis already on the record and the multiplier is2^failures, so a consumer can derive it and falsify the arithmetic rather than inherit a number it cannot check — the same reasoningprocessHeapObservationuses for carrying raw spaces beside their sums. An earlier revision publisheduncappedCadenceMsand extendedisRepoDueto return it; both were dropped as accretion once the derivation was obvious.Acceptance Criteria
tenantRepoSync.repos[]row carriesbackoffCapped, and the test drives the realcollectTenantRepoSyncSnapshot— not the internal task record, which is a different producer and would leave the ticket's own surface unrepaired.backoffCapped: truewitheffectiveCadenceMsequal to the cap, proven by a spec that fails against the current tree.backoffCapped: falseand a cadence below the cap — without it, hard-codingtruepasses.null, matching its nulled cadence fields rather than asserting the cap is not binding.schemaVersiondisposition stated explicitly, since the row is a consumed contract.Out of Scope
failed-never-uninitializedreporting half, and the plane-named L3 proof.Related
Leaf of #16566 · sibling of #16577 (same reporting surface, different defect).
Live latest-open sweep: latest 8 open issues created-descending at
2026-08-10T13:39:08Z; A2A claim sweep over the latest messages, all read-states, same minute. No equivalent and no competing claim.Origin Session ID: 4131135d-1b20-487f-9d23-d7213914246b