Context
Surfaced by @neo-opus-vega reviewing PR #17352 (pullrequestreview-4962630073) as a non-blocking finding she explicitly declined to fold into that PR's scope. Verified at source on dev at 0f94fba2ca rather than relayed.
That PR added --clear-backoff to ai/scripts/maintenance/syncTenantRepos.mjs, which refuses an unknown selector when any requested slug is unknown. The pre-existing sweep on the same CLI refuses only when every requested slug is unknown. Same executable, two flags, opposite dispositions for the same operator typo.
The Problem
TenantRepoSyncService.runTask, at the selector guard:
const repos = onlyRepoSlugs
? allRepos.filter(r => onlyRepoSlugs.includes(r.repoSlug))
: allRepos;
if (repos.length === 0 && onlyRepoSlugs?.length > 0) {
const unknownSlugs = onlyRepoSlugs.filter(s => !knownSlugs.includes(s));
...
return {status: 'failed', details};
}The guard is keyed on repos.length === 0 — a total miss. So:
| Invocation |
Sweep |
--clear-backoff |
--repo-slug typo |
refuses, exit 3 |
refuses, exit 3 |
--repo-slug good --repo-slug typo |
syncs good, silently drops typo, exit 0 |
refuses, exit 3 |
An operator who mistypes one slug in a multi-repo invocation is told the run completed. They have no signal that the repo they most likely cared about was never touched — and exit 0 is the code their shell, their runbook, and any wrapper script will branch on.
This is the failure mode #17067 was filed about, one path over. Its AC-1 required that "an unknown repo identifier is rejected with a named error, never a silent no-op". A partial-match run that ignores the unknown remainder is a silent partial no-op; it merely hides better than the total-miss case because something did happen.
The near-miss that makes this cheap: unknownSlugs is already computed — the exact set needed to refuse correctly is derived inside the guard, and is simply unreachable unless every slug missed.
The Architectural Reality
ai/daemons/orchestrator/services/TenantRepoSyncService.mjs — runTask's selector guard, the lax trigger.
ai/daemons/orchestrator/services/TenantRepoSyncService.mjs — clearTenantRepoBackoff's selector guard, the strict trigger, and the reference shape.
ai/scripts/maintenance/syncTenantRepos.mjs — resolveExitCode already maps KB_TENANT_REPO_SYNC_REPO_NOT_CONFIGURED to exit 3, so the exit contract needs no change; only the trigger moves.
ai/daemons/orchestrator/services/TenantRepoSyncErrors.mjs — the shared error code, unchanged.
Both call sites already produce the identical details envelope (reason / reasonCode / requestedSlugs / unknownSlugs / configuredSlugs). The divergence is one predicate, not two designs.
The Fix
- Align the trigger. The sweep refuses when
unknownSlugs.length > 0, matching the clear path. Fail-closed is the correct default on an operator-supplied selector: the alternative silently does less than the operator asked and reports success.
- Extract the shared predicate rather than duplicating the strict test at a second site. Two call sites with the same question is the point at which the question gets a name — and it stops a third entry path inheriting whichever version it happened to copy.
- Correct the
clearTenantRepoBackoff JSDoc. It currently says "The SAME refusal the sweep gives an unknown selector". That overclaims by one word: same vocabulary, stricter trigger. Once (1) lands the sentence becomes true; if (1) is deferred, the clause must say so instead. Do not let the two ship out of step with a docblock asserting an equivalence that does not hold — the next reader will otherwise "fix" the strict path toward the lax one, which is the wrong direction.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
syncTenantRepos.mjs sweep, --repo-slug with a partially-unknown set |
TenantRepoSyncService.runTask selector guard |
refuses with KB_TENANT_REPO_SYNC_REPO_NOT_CONFIGURED, exit 3, naming unknownSlugs |
today: syncs the known subset, drops the unknown silently, exit 0 |
CLI --help exit-code block |
the guard's repos.length === 0 predicate vs clearTenantRepoBackoff's unknownSlugs.length > 0 |
| shared unknown-selector predicate |
the two existing call sites |
one named predicate consumed by both entry paths |
two independent inline tests that have already diverged once |
JSDoc on the predicate |
both sites compute the identical details envelope today |
Behavior change, stated plainly: an invocation that today exits 0 having synced a subset will exit 3 having synced nothing. That is the point of the ticket, and it is a breaking change for any caller that passes a slug list it does not control. No such caller exists in-repo (the flag is operator-typed on the container plane), but a deployment runbook pinning a stale slug list would begin failing loudly instead of quietly — which is the intended trade, not an accident.
Decision Record impact
none — this aligns two call sites of an existing contract; it neither depends on nor amends an accepted ADR.
Acceptance Criteria
Out of Scope
- The backoff-clear path's semantics. It is already correct and is the reference shape here; nothing about it changes except one docblock clause.
- Widening or narrowing what counts as a configured repo. This ticket only decides what happens when a requested slug is not one.
Avoided Traps
- "Make the clear lax to match the sweep." The inverse fix, and wrong: it re-opens the exact silent no-op #17067's AC-1 closed. When two paths disagree, the question is which is correct — here the stricter one is, so the lax one moves.
- "Just log a warning for the unknown remainder." A
WARN beside exit 0 is precisely the shape that fails: the exit code is what runbooks and wrappers branch on, and a warning in one container's stdout is not a signal an operator reliably sees.
- "Duplicate the strict predicate at the second site." It has already drifted once at two sites; a third entry path would inherit whichever copy it was written next to.
Related
- #17067 (closed) · PR #17352 — the lane whose review surfaced this;
clearTenantRepoBackoff is the reference implementation
- #17062 — preemption-caused failures feeding the same counter
Origin Session ID: ad99f59b-9d2c-4f82-b6ce-8c8357ef1879
Retrieval Hint: query_raw_memories("tenant repo sync unknown selector partial match silent no-op"); the divergence is repos.length === 0 vs unknownSlugs.length > 0 in TenantRepoSyncService.mjs.
Context
Surfaced by @neo-opus-vega reviewing PR #17352 (
pullrequestreview-4962630073) as a non-blocking finding she explicitly declined to fold into that PR's scope. Verified at source ondevat0f94fba2carather than relayed.That PR added
--clear-backofftoai/scripts/maintenance/syncTenantRepos.mjs, which refuses an unknown selector when any requested slug is unknown. The pre-existing sweep on the same CLI refuses only when every requested slug is unknown. Same executable, two flags, opposite dispositions for the same operator typo.The Problem
TenantRepoSyncService.runTask, at the selector guard:const repos = onlyRepoSlugs ? allRepos.filter(r => onlyRepoSlugs.includes(r.repoSlug)) : allRepos; // Distinguish "operator-requested-unknown-slug" from "no config at all". if (repos.length === 0 && onlyRepoSlugs?.length > 0) { const unknownSlugs = onlyRepoSlugs.filter(s => !knownSlugs.includes(s)); ... return {status: 'failed', details}; }The guard is keyed on
repos.length === 0— a total miss. So:--clear-backoff--repo-slug typo--repo-slug good --repo-slug typogood, silently dropstypo, exit 0An operator who mistypes one slug in a multi-repo invocation is told the run completed. They have no signal that the repo they most likely cared about was never touched — and
exit 0is the code their shell, their runbook, and any wrapper script will branch on.This is the failure mode #17067 was filed about, one path over. Its AC-1 required that "an unknown repo identifier is rejected with a named error, never a silent no-op". A partial-match run that ignores the unknown remainder is a silent partial no-op; it merely hides better than the total-miss case because something did happen.
The near-miss that makes this cheap:
unknownSlugsis already computed — the exact set needed to refuse correctly is derived inside the guard, and is simply unreachable unless every slug missed.The Architectural Reality
ai/daemons/orchestrator/services/TenantRepoSyncService.mjs—runTask's selector guard, the lax trigger.ai/daemons/orchestrator/services/TenantRepoSyncService.mjs—clearTenantRepoBackoff's selector guard, the strict trigger, and the reference shape.ai/scripts/maintenance/syncTenantRepos.mjs—resolveExitCodealready mapsKB_TENANT_REPO_SYNC_REPO_NOT_CONFIGUREDto exit3, so the exit contract needs no change; only the trigger moves.ai/daemons/orchestrator/services/TenantRepoSyncErrors.mjs— the shared error code, unchanged.Both call sites already produce the identical
detailsenvelope (reason/reasonCode/requestedSlugs/unknownSlugs/configuredSlugs). The divergence is one predicate, not two designs.The Fix
unknownSlugs.length > 0, matching the clear path. Fail-closed is the correct default on an operator-supplied selector: the alternative silently does less than the operator asked and reports success.clearTenantRepoBackoffJSDoc. It currently says "The SAME refusal the sweep gives an unknown selector". That overclaims by one word: same vocabulary, stricter trigger. Once (1) lands the sentence becomes true; if (1) is deferred, the clause must say so instead. Do not let the two ship out of step with a docblock asserting an equivalence that does not hold — the next reader will otherwise "fix" the strict path toward the lax one, which is the wrong direction.Contract Ledger Matrix
syncTenantRepos.mjssweep,--repo-slugwith a partially-unknown setTenantRepoSyncService.runTaskselector guardKB_TENANT_REPO_SYNC_REPO_NOT_CONFIGURED, exit3, namingunknownSlugs0--helpexit-code blockrepos.length === 0predicate vsclearTenantRepoBackoff'sunknownSlugs.length > 0detailsenvelope todayBehavior change, stated plainly: an invocation that today exits
0having synced a subset will exit3having synced nothing. That is the point of the ticket, and it is a breaking change for any caller that passes a slug list it does not control. No such caller exists in-repo (the flag is operator-typed on the container plane), but a deployment runbook pinning a stale slug list would begin failing loudly instead of quietly — which is the intended trade, not an accident.Decision Record impact
none— this aligns two call sites of an existing contract; it neither depends on nor amends an accepted ADR.Acceptance Criteria
KB_TENANT_REPO_SYNC_REPO_NOT_CONFIGUREDand exit3, naming exactly the unknown slugs — it does not process the known subset.clearTenantRepoBackoff's "SAME refusal" JSDoc is true as written once aligned — or states the divergence explicitly if the alignment is deferred.resolveExitCodeneeds no change; a test confirms exit3still maps, so the exit contract is proven unmoved rather than assumed.Out of Scope
Avoided Traps
WARNbesideexit 0is precisely the shape that fails: the exit code is what runbooks and wrappers branch on, and a warning in one container's stdout is not a signal an operator reliably sees.Related
clearTenantRepoBackoffis the reference implementationOrigin Session ID: ad99f59b-9d2c-4f82-b6ce-8c8357ef1879
Retrieval Hint:
query_raw_memories("tenant repo sync unknown selector partial match silent no-op"); the divergence isrepos.length === 0vsunknownSlugs.length > 0inTenantRepoSyncService.mjs.