Skip to content

fix: make a waiting review reachable, and stop explain hiding broken targets - #54

Merged
maxigimenez merged 2 commits into
mainfrom
fix/explain-broken-targets
Sep 8, 2026
Merged

maxigimenez merged 2 commits into
mainfrom
fix/explain-broken-targets

Conversation

@maxigimenez

@maxigimenez maxigimenez commented Sep 8, 2026 •

Copy link
Copy Markdown
Owner

Two independent reasons the reviewer route could not run, both found on the live runner. Either alone is enough to keep it silent forever.

1. A review that is already outstanding is unreachable

This is the one that matters, and it is not a configuration mistake.

  • reviewersAdded fires only on the single cycle that observes the request appear. A review asked for before the route existed, or while the runner was down, is lost permanently.
  • Re-requesting does not recover it. A reviewer who has not yet reviewed never leaves requested_reviewers, so removing and re-adding them inside one ~25s poll window is invisible. Winning that race is not a reasonable thing to ask of an operator.

The catalog recorded this as an accepted limitation. It shouldn't have been one.

Matching on the state — "a review is outstanding" — is true for as long as it is true, which is exactly what's needed. What made state clauses unusable was the dedupe key: a PR's revision moves on every push, so under per-change each commit started another review round.

while-matched separates still waiting from asked again: claim the item on the first cycle it matches, hold the claim while it keeps matching, release it when it stops.

  • dedupeKey drops the revision, as once does — pushes and replies cannot re-fire it.
  • releaseUnmatchedClaims re-arms the route when the condition lapses. For a review that's the agent answering it: GitHub clears the request and the trigger stops being raised at all.
  • A claim whose run has not settled is never released. Mid-run the item carries sentinel0:in-progress and so stops matching by construction; releasing on that basis would re-arm the route against work it is still doing.
  • collectEvents returns undefined on failure, not []. A tracker outage read as "nothing matches any more" would release every claim and replay the lot. The project is skipped instead and the cycle summary says unavailable.
  • The shipped reviewer template moves to while-matched + match.reviewers.

Enabling such a route acts on everything already matching. That is the point — it is how a waiting PR finally gets picked up — and it is documented rather than hidden.

2. explain hid a broken target behind an unrelated clause

Matching and targeting fail independently, and explain reported only the first failing match clause:

skips    Reviewer agent: match.reviewersAdded wants any of [EomiAIBot]; ...

True, but that route also addressed a GitHub identity no Hermes profile claims — all four profiles on the runner had no githubLogin, so resolveAgent could never find one and the route would have died as unknown-agent the day it matched. Until then it read as an ordinary "did not match", and two rounds of debugging went into the clause that merely came first.

  • resolveAgent and explainTarget move into the rule engine and are shared with the dispatcher, so the diagnostic gives the answer dispatch would give.
  • explainTarget separates no agent has that identity from the agent is disabled, and names the profiles carrying no githubLogin plus the file to set it in.
  • The target is evaluated for every route on every item regardless of the match, printed in red as broken.
      skips    Reviewer agent: match.reviewersAdded wants any of [EomiAIBot]; ...
      broken   Reviewer agent: no enabled agent has githubLogin "EomiAIBot"
               (eomi, james, kitt have no githubLogin set — see
               hermes.profiles[].githubLogin in ~/.sentinel0/config.json)

Covered

while-matched: fires with no transition to observe · holds one claim across pushes · re-arms after the request is answered and fires again when asked again · keeps the claim while the item still matches · never releases a running or approval-blocked run · re-arms after a failure so a retry is possible · touches no other route's claims.

Targeting: resolution by profile and by login, disabled vs missing, a target naming neither, and the exact production shape — match fails on reviewersAdded and the unresolvable target is reported alongside it.

593 tests pass across all five packages; lint and build clean.

Upgrading

Both fixes are needed, and so is one piece of configuration this cannot do for you: set hermes.profiles[].githubLogin in ~/.sentinel0/config.json so a profile declares which GitHub account it acts as. Nothing verifies it, preflight only warns, and without it the route resolves no agent.

Then switch the existing route to while-matched + match.reviewers and every PR already waiting on the agent is picked up on the next cycle — including ones that were previously unreachable.

Releases 0.0.7.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QNEqbFkMFoPiiVnJGxpjj2

maxigimenez and others added 2 commits September 8, 2026 12:15
…h did

Matching and targeting fail independently, and `explain` only ever reported the
first failing *match* clause. On a live runner that hid a second, unrelated
breakage behind the first:

    skips   Reviewer agent: match.reviewersAdded wants any of [EomiAIBot]; ...

The route was also addressing a GitHub identity that no Hermes profile claimed.
`hermes.profiles[].githubLogin` is the operator's declaration and nothing
verifies it; all four profiles on that machine had none, so `resolveAgent`
could never find one and the route would have died as `unknown-agent` on the
day it finally matched. Until then it read as an ordinary "did not match", and
two rounds of debugging went into the clause that was merely first.

- `resolveAgent` and `explainTarget` move into the rule engine and are shared
  with the dispatcher rather than duplicated, so a diagnostic asking "could
  this ever run" gets the answer the dispatcher would give.
- `explainTarget` separates "no agent has that identity" from "the agent is
  disabled" -- identical symptom, different fix -- and, for the case that
  actually occurred, names the profiles carrying no `githubLogin` and the file
  to set it in. Preflight has warned about them all along, which is exactly how
  easy that warning is to miss.
- `GET /routes/explain` evaluates the target for every route on every item
  regardless of the match, and the CLI prints it in red as `broken` with a
  pointer to `sentinel0 agents`.

Covered end to end at the API, in the shape that occurred: the match fails on
`reviewersAdded`, and the unresolvable target is reported alongside it rather
than instead of it.

Releases 0.0.7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QNEqbFkMFoPiiVnJGxpjj2
A review request that is already outstanding could never be acted on. Not by
waiting, and not by any operator action:

- `reviewersAdded` fires only on the cycle that observes the request appear, so
  a review asked for before the route existed, or while the runner was down, is
  lost permanently.
- Re-requesting does not recover it. A reviewer who has not yet reviewed never
  leaves `requested_reviewers`, so removing and re-adding them inside one ~25s
  poll window is invisible to the runner. Winning that race is not a thing to
  ask of an operator.

The catalog already recorded this as an accepted limitation, and it should not
have been one. Matching on the *state* -- "a review is outstanding" -- is always
true while it is true, which is exactly what is needed. What made state clauses
unusable was the dedupe key: a pull request's revision moves on every push, so
under `per-change` each commit started another review round.

`while-matched` separates "still waiting" from "asked again": claim the item on
the first cycle it matches, hold the claim while it keeps matching, release it
when it stops.

- `dedupeKey` drops the revision, as `once` does, so pushes and replies cannot
  re-fire the route.
- `releaseUnmatchedClaims` re-arms it once the condition lapses -- which for a
  review is the agent answering it, since GitHub clears the request and the
  trigger stops being raised at all.
- A claim whose run has not settled is never released. Mid-run the item carries
  `sentinel0:in-progress` and so stops matching by construction, and releasing
  on that basis would re-arm the route against work it is still doing.
- `collectEvents` now returns `undefined` on failure rather than `[]`. A
  tracker outage read as "nothing matches any more" would release every claim
  and replay the lot next cycle; the project is skipped instead, and the cycle
  summary says `unavailable`.
- The shipped reviewer template moves to `while-matched` + `match.reviewers`.

Enabling such a route acts on everything already matching. That is the point --
it is how a waiting pull request finally gets picked up -- and it is documented
rather than hidden.

Covered: fires with no transition to observe, holds one claim across pushes,
re-arms after the request is answered and fires again when asked again, keeps
the claim while the item still matches, never releases a running or
approval-blocked run, re-arms after a failure so a retry is possible, and
touches no other route's claims.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QNEqbFkMFoPiiVnJGxpjj2
@maxigimenez maxigimenez changed the title fix: report a route whose agent cannot be resolved, whatever its match did fix: make a waiting review reachable, and stop explain hiding broken targets Sep 8, 2026
@maxigimenez
maxigimenez merged commit b525751 into main Sep 8, 2026
2 checks passed
@maxigimenez
maxigimenez deleted the fix/explain-broken-targets branch September 8, 2026 11:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant