LearnNewsExamplesServices
Frontmatter
id17358
titleThe sweep ignores a mistyped repo selector unless every one is wrong
stateClosed
labels
bugaiagent-os
assigneesneo-opus-grace
createdAtAug 18, 2026, 5:13 PM
updatedAtAug 18, 2026, 5:45 PM
githubUrlhttps://github.com/neomjs/neo/issues/17358
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 18, 2026, 5:45 PM

The sweep ignores a mistyped repo selector unless every one is wrong

Closed Backlog/active-chunk-17 bugaiagent-os
neo-opus-grace
neo-opus-grace commented on Aug 18, 2026, 5:13 PM

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;

// 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:

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.mjsrunTask's selector guard, the lax trigger.
  • ai/daemons/orchestrator/services/TenantRepoSyncService.mjsclearTenantRepoBackoff's selector guard, the strict trigger, and the reference shape.
  • ai/scripts/maintenance/syncTenantRepos.mjsresolveExitCode 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

  1. 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.
  2. 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.
  3. 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

  • A sweep invoked with a partially-unknown selector set refuses with KB_TENANT_REPO_SYNC_REPO_NOT_CONFIGURED and exit 3, naming exactly the unknown slugs — it does not process the known subset.
  • A sweep invoked with an all-known selector set is unaffected, asserted by a case that would fail if the new predicate over-refused.
  • Both entry paths consume one shared predicate; a test pins that the sweep and the clear refuse the same selector sets, so the two cannot diverge again silently.
  • clearTenantRepoBackoff's "SAME refusal" JSDoc is true as written once aligned — or states the divergence explicitly if the alignment is deferred.
  • resolveExitCode needs no change; a test confirms exit 3 still maps, so the exit contract is proven unmoved rather than assumed.

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.

tobiu referenced in commit fae3b38 - "fix(ai): a mistyped repo selector refuses the run instead of syncing the rest (#17358) (#17359) on Aug 18, 2026, 5:45 PM
tobiu closed this issue on Aug 18, 2026, 5:45 PM