Skip to content

fix(revanote): reliable batch dispatch, merged-commit gate, generation-bound replies - #504

Open
finedesignz wants to merge 23 commits into
mainfrom
fix/revanote-reliability
Open

finedesignz wants to merge 23 commits into
mainfrom
fix/revanote-reliability

Conversation

@finedesignz

Copy link
Copy Markdown
Owner

Why

43 Revanote client comments were marked resolved citing a never-pushed commit. Owner rules: "resolved" only when the fix commit is MERGED to the default branch, deployed and verified live; all comments of one Revanote run (shared payload_raw.batch_id) go to the site session as ONE prompt and ONE PR.

What changed

  • supervisor/process-manager: close the SessionBridge (logged failure) on restart give-up / circuit-open so the hub marks the session offline. Requires a new signed supervisor MSI for the process-manager closeBridge change.
  • Commit gate (commit-verify): resolved=true is downgraded unless the SHA is on the repo default branch (GitHub compare, fail closed on every API error); rejects commit_predates_dispatch.
  • Batch dispatch: DB-driven sweep coalesces pending annotations sharing batch_id per (user, session, mapping) after a debounce into one fenced prompt with an array <> reply; per-member finalize via the same commit gate; malformed batch reply fails all members; batch run ceiling; grace replay re-gathers members from DB.
  • Atomic claim at send time (pending -> dispatched CAS) for single and batch paths; channel/offline check before the claim; offline rows stay pending.
  • Retry route: 409 while live or while a durable run is not terminal/past ceiling; reset is a CAS; closes the superseded in_flight run; unknown ?status= is 400.
  • CAS class fix: every annotations.status write is a CAS or justified; stale-pending redispatch with bounded backoff (5 attempts then failed + callback).
  • Dispatch-generation binding: prompt states dispatch_id, reply must echo it, pipeline shouldFinalize declines mismatches, annotations.current_run_id + finalize CAS so a late pre-restart reply cannot finalize a newer dispatch. Schema: one idempotent ADD COLUMN IF NOT EXISTS.
  • Stall alert: owner alert (revanote_stall_alerts) when annotations park/fail >1h or a run is stuck.
  • Merged origin/main (reviewer attachments); docs/revanote.md updated.

QC history

7 rounds of cross-model adversarial QC (Claude lenses + Codex). Rounds 1-6 findings were all fixed (claim races, retry-vs-sweep, token collisions, mixed-trust grouping, stranded queued rows, stale-snapshot status writes, reply-to-generation correlation). Round 7 (Adversary/Integrator + Codex): no confirmed blocker. Codex's one BLOCKER (postgres.js ${ids}::uuid[] binding) was refuted: identical pattern is in production at hub/src/db/dal.ts:768. Open non-blocking MINORs: (a) concurrent double-retry CAS checks status only, not current_run_id; (b) grace replay bypasses batch debounce (could split a late member into another turn); (c) commit_predates_dispatch may use a prior generation's dispatched_at (lenient, narrow race); (d) a foreign reply arriving after the dispatch's own finalize timeout fails that already-timed-out run as dispatch_id_mismatch.

Tests

Each hub/test/revanote-, dispatch-, token-cap-coverage file passes individually; all 292 hub test files pass individually from hub/; bun run check-baseline OK (pass=2404 fail=0; one earlier run showed a single transient fail that did not reproduce). human-interactive-flag-guard passes now. Not run locally: the claim SQL against real Postgres (no docker/postgres locally); Woodpecker postgres:16 e2e is the check.

🤖 Generated with Claude Code

finedesignz and others added 23 commits September 27, 2026 20:43
A stream-json session whose `claude` process exits repeatedly used to leak
its SessionBridge (and the `/ws/agent` WS connection it owns) once local
recovery gave up — both on `max_restarts_exceeded` (scheduleRestart) and on
`circuit_open` (tripBreaker). The bridge was dropped from the in-process
`runs` map but never stopped, so its hub-side WS channel stayed registered
forever.

That meant hub's `sessions.status` never flipped to 'offline' (only a real
WS close does that, in hub/src/ws/agent.ts's close handler), and
`ensureSessionOnline`'s fast path (`getChannel(sessionId) != null`) kept
returning true — so an inbound dispatch (revanote, error-capture, scheduler)
never respawned the dead session. It just silently vanished into a void the
hub believed was alive.

Fix: call `run.bridge.stop()` on both give-up paths before dropping the run.
Closing the bridge closes its WS, which hub's existing close handler turns
into `setSessionStatus(sessionId, 'offline')` — the next dispatch then sees
the session offline and `ensureSessionOnline` actually respawns it.

No new wire protocol needed; this reuses the existing WS-close-implies-
offline contract, so old and new supervisors interoperate — a supervisor
without this fix just keeps the pre-existing dangling-bridge behavior.

Tests: process-manager.test.ts asserts stopCalls==1 on max_restarts_exceeded;
process-manager-circuit-breaker.test.ts asserts stopCalls==1 on circuit_open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Incident: a background subagent marked 43 Lakeside annotations resolved,
citing commit 86ad71296 on every single one. That commit was real but
dangling (never pushed, never merged, never deployed) and unrelated to any
of the 43 fixes -- nothing shipped, but the client's comments were closed.

The hub now refuses to forward or persist a resolved:true callback unless
the agent's cited commit_sha is verified to exist on the GitHub remote:

- hub/src/revanote/commit-verify.ts: verifyCommitOnRemote() calls
  GET /repos/{owner}/{repo}/commits/{sha} via the existing GitHub App
  installation token (hub/src/auth/github-app.ts -- no new credential).
  Fails closed: missing commit_sha, missing installation_id/repo_slug
  context, an unparseable repo slug, a 404, or any API error all mean
  "not verified".
- hub/src/revanote/run-lifecycle.ts: finalizeAnnotationReply now runs this
  gate before persisting annotation_run/annotation status or enqueuing the
  outbound callback. An unverifiable resolved:true claim is downgraded to
  resolved:false with a specific reason (commit_sha_missing /
  repo_context_missing / repo_slug_unparseable / commit_not_on_remote /
  commit_verify_failed), surfaced as both skip_reason and the callback's
  error field. resolved:false replies never touch this gate.
- result-schema.ts / callback.ts: added commit_sha (gated) and deploy_url
  (advisory) fields end to end.
- prompt.ts: the envelope contract now asks for commit_sha/deploy_url and
  states resolved:true is only honored once the commit is actually
  pushed/deployed/verified. Per owner directive, also documents the batch
  protocol -- one branch/PR per whole review, merge + redeploy + per-page
  verification BEFORE resolving any comment, citing the merged SHA. This
  prompt text is advisory only; the code gate above is the actual control
  and treats a merged SHA the same as any other -- it exists on the remote,
  so it passes.

Investigated PR #489 (revanote burst queueing): a review's N comments are
NOT dispatched as one batched prompt -- each annotation is its own dispatch
token, one in-flight per session with the rest FIFO-queued. #489 raised the
waiter cap so a large burst queues instead of dropping as session_busy; it
did not change the one-token-at-a-time shape. The batch protocol therefore
relies on the agent's own session memory across sequential per-comment
turns. Dispatch is intentionally NOT restructured by this fix.

Tests: hub/test/revanote-commit-verify.test.ts (the gate, GitHub API
mocked), hub/test/revanote-run-lifecycle-commit-gate.test.ts (finalize
integration: unpushed SHA -> downgraded, missing SHA -> downgraded,
verified SHA -> forwarded, resolved:false bypasses the gate). Updated
revanote-dispatch.test.ts and revanote-prompt.test.ts fixtures/baselines
for the new required field and prompt text.

docs/revanote.md updated with the commit-verify gate, the batch protocol,
and the per-comment-dispatch finding.

check-baseline: 0 fail (was already 0 fail with pre-existing skip-count
drift on origin/main, confirmed via git stash before this change -- not
introduced here). typecheck: no new errors (one pre-existing
ParseFallback|ParseOk union error in run-lifecycle.ts predates this diff).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
All ~23 revanote client sites map onto ONE remo-code session (in-flight
cap 1); when that session wedges every client's annotations pile up
failed/failed_offline/parked-pending with zero owner-visible signal (the
callback to revanote reports the failure, but nothing tells the owner).

Add a boot-started sweep (hub/src/revanote/stall-alert.ts, modeled on
scheduler/run-reaper.ts) that fires on two independent stall signatures:
- an annotation sitting parked-offline / rejected / target-offline-expired
  past REMO_REVANOTE_STALL_PARKED_MAX_MS (default 1h)
- an annotation_runs row stuck status='in_flight' past
  REMO_REVANOTE_STALL_RUN_MAX_MS (default 30min) -- worse than the above,
  since the dispatcher's own 20min finalizeTimeoutMs should have forced a
  finalize before this point

Fan-out reuses the existing orchestrator notify channel (fanOutNotify --
telegram + in-app + emails4agents email); no new transport. De-dup via a
new revanote_stall_alerts table (one row per user, last_alert_at) caps
alerts to one per user per REMO_REVANOTE_STALL_COOLDOWN_MS (default 1h)
so a backlog of dozens of stalled annotations across 23 sites sends one
alert, not one per annotation. REMO_REVANOTE_STALL_DISABLED escape hatch.

Also re-measures tools/regression-baseline.json: origin/main already
drifted from the stale 2026-07-28 snapshot (pass=2059/skip=255) to
pass=2157/skip=259 before this PR touched anything (confirmed via
git stash -u); this PR's own 9 new DB-free tests land the actual at
pass=2166/skip=259/fail=0, and skip_max is raised 256->260 to absorb
the pre-existing drift plus zero new skips from this change.

Tests: hub/test/revanote-stall-alert.test.ts -- fires at threshold for
both signatures independently, does not fire below threshold or on
zero-count rows, cooldown blocks a repeat alert then fires again once
elapsed, one user's notify failure never aborts the pass for others,
load failure is fail-open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sequential per-annotation dispatch cannot produce "all comments of one
review fixed in ONE PR": the shared pipeline only releases the next
queued token once the current one finalizes, so N comments became N
separate turns/branches/PRs relying on unsatisfiable cross-turn agent
memory.

Coalescing is keyed ONLY on payload_raw.batch_id (per prod sampling,
present on 3/88 annotations but correctly grouping a burst where set).

- dispatcher.ts: a batch_id-carrying annotation short-circuits after
  mapping/session resolution and stays pending instead of dispatching.
  forceSingle (used by the manual retry endpoint) bypasses this.
- batch-dispatch.ts (new): boot-started, fully DB-state-driven poll
  (REMO_REVANOTE_BATCH_POLL_MS, default 5s) that groups pending
  batch_id annotations by (user, batch_id, resolved target session)
  and dispatches a group once no new arrival has landed for
  REMO_REVANOTE_BATCH_DEBOUNCE_MS (default 30s) since the group's
  latest arrival. One prompt, one dispatch token, one pipeline turn
  per group. No per-batch timer state or content buffering -- restart
  loses nothing, the next tick re-derives the due set from the DB.
- prompt.ts: renderBatchAnnotationPrompt fences every member
  separately and asks for one branch/PR covering the whole batch, one
  <<JSON>>{"annotations":[...]}<<END>> envelope keyed by external
  annotation id. The single-annotation contract's unsatisfiable
  "batch across turns using memory" text is replaced with a
  satisfiable one-comment-one-PR contract.
- result-schema.ts: RevanoteBatchResult/parseRevanoteBatchOutput +
  envelopeForBatchItem rebuilds a single-item envelope per member so
  finalize reuses run-lifecycle.finalizeAnnotationReply UNCHANGED --
  same commit-verify gate, DB writes, and callback shape per member.
  A member missing from the reply array finalizes resolved:false/
  missing_from_reply; an unparseable batch reply falls every member
  back through the same single-item parse fallback.
- REMO_REVANOTE_BATCH_RUN_MAX_MS (default 2h) sets both the batch
  dispatch's finalizeTimeoutMs and hookMaxMs, since a batch turn does
  N comments' worth of branch/PR/CI/merge/redeploy work and needs a
  longer ceiling than a single annotation's 20min default.

Tests: hub/test/revanote-batch-dispatch.test.ts (grouping, debounce
reset on new arrival, one-prompt-per-batch, array-reply finalize with
per-item commit-verify, missing member, unparseable reply, no-batch_id
single path, forceSingle retry, batch finalize ceiling not tripped by
the single path's 20min timeout). Updated the golden prompt-text
baseline in revanote-prompt.test.ts for the corrected single-comment
contract. docs/revanote.md + CLAUDE.md updated in the same commit.

check-baseline: fail=0 (pre-existing skip drift 259>256 unrelated,
left untouched per integrator note).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eiling

Stall-alert flagged any annotation_runs row in_flight past 30min
(REMO_REVANOTE_STALL_RUN_MAX_MS), but a batch-dispatched turn legitimately
runs up to REMO_REVANOTE_BATCH_RUN_MAX_MS (default 2h) doing N comments'
worth of branch/PR/CI/merge/redeploy work in one turn. A healthy batch run
would false-alarm as a stuck session.

Add runStallThresholdMs(isBatch) reading the live batchRunMaxMs() knob from
batch-dispatch.ts (no duplicated default) for a batch run; single-annotation
runs keep the existing 30min threshold. The SQL sweep now joins annotations
to detect payload_raw.batch_id per row and applies the matching cutoff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
F1 [BLOCKER]: verifyCommitOnRemote only checked GET /commits/{sha}, which
returns 200 for a commit reachable from ANY branch — so a pushed-but-unmerged
PR branch tip passed the resolved=true gate. Now fetches the repo's
default_branch then compares {default_branch}...{sha}; verified only when
compare status is 'identical' or 'behind'. 'ahead'/'diverged'/404/API error/
missing field all fail closed with a distinct reason. Updated prompt.ts
wording and docs/revanote.md to match.

F5 [MINOR]: process-manager.ts's three `void run.bridge.stop().catch(() => {})`
teardown sites swallowed a failed close silently, hiding the exact
"hub never learns the session died" failure mode this teardown exists to
prevent. Extracted a closeBridge() helper that logs the failure via the
file's existing onLog('warn', ...) pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mappings

Five confirmed QC findings, all in the revanote batch-dispatch class:

- F2: dispatch() sent the batch prompt before the per-member
  updateAnnotationStatus('dispatched') loop, so a crash mid-loop left
  members pending under the same batch_id and the sweep re-dispatched
  them after restart.
- F4: retry's reset-to-pending + forceSingle dispatch could race a sweep
  tick grabbing the same batch_id-carrying row under a different token;
  session-queue dedup is token-identity only and can't catch it.
- F3: a batch reply that parses as a single (non-array) object was
  re-fed to the single-item parser, resolving every member from one
  verdict instead of failing the whole batch.
- C3: inFlightBatches/the dispatch token was keyed by the raw batch_id,
  which collides once one batch_id splits into several per-session (or,
  after the C5 fix, per-mapping) groups, or repeats across users.
- C5: renderBatchAnnotationPrompt derived deploy trust/strategy from a
  single member's mapping, but prod runs ~23 client sites -- each its
  own mapping/trust -- sharing one session, so an untrusted site's
  annotation could inherit a trusted site's direct/auto-merge plan.

Fixes: a single conditional `UPDATE annotations SET status='dispatching'
WHERE status='pending' RETURNING id` (claimAnnotationsForDispatch) runs
before ANY prompt is built or sent, on every dispatch path (single,
forceSingle retry, batch); a crash after a successful claim parks the
row at 'dispatching' rather than auto-reverting to pending, and
stall-alert.ts now alarms on a stuck 'dispatching' row. Batch sweep
sub-groups by (session, mapping) so every dispatched batch is
single-mapping, and the dispatch token is scoped
batch:<userId>:<sessionId>:<mappingId>:<batch_id>. A batch parse
failure now finalizes every member failed/resolved:false directly,
never via the single-item parser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merges qcfix/commit-on-default (path-aware GitHub mock, default-branch
commit verification) and qcfix/batch-claim (atomic pre-send claim,
isolated batch dispatch tokens/mappings) into fix/revanote-reliability.
Both merges auto-resolved with no conflicts; all revanote-* and
process-manager* tests pass individually, check-baseline 0 fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…R2-1/R2-2)

Round-2 QC found the round-1 pre-send claim (pending->dispatching, before
dispatch()) opened a new strand class: an annotation/batch merely QUEUED
behind another in-flight send on the same session was already claimed even
though nothing was ever sent for it. A hub restart drops the in-memory
queue/waiter state that would have promoted and sent it, permanently
stranding the row off the 'pending' sweep/retry path (R2-1).

The claim now lives inside the pipeline's send() adapter, immediately before
the WS push, for both a first dispatch and a promoted queue re-dispatch
(dispatch() calls the same send() either way) - a merely-queued row never
reaches it and stays 'pending'. The CAS transitions straight pending ->
dispatched; the intermediate 'dispatching' claim state is removed entirely
(AnnotationStatus union, schema.sql CHECK, stall-alert.ts).

R2-2: POST /api/revanote/annotations/:id/retry no longer unconditionally
resets a 'dispatched' row to 'pending' - it first checks pipeline.isTokenLive
and batch-dispatch.isAnnotationLiveInBatch and refuses with 409 while the
row's first turn is genuinely live, only allowing the reset once it's
stranded (post-restart).

R2-4: an unrecognized ?status= on GET /api/revanote/annotations now 400s
instead of silently returning the unfiltered list.

R2-3 investigated and refuted: the process-manager.ts bridge-teardown-on-
ordinary-restart call site predates the revanote-reliability branch entirely
(introduced by #96, long before f5dd003); f5dd003 only extracted the same
call into a closeBridge() helper. No behavior change to revert.

New tests: revanote-dispatch.test.ts (R2-1 single-path restart recovery),
revanote-batch-dispatch.test.ts (R2-1 batch-path restart recovery),
revanote-retry-guard.test.ts (R2-2 live-ownership 409, R2-4 400).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keeps main's pushed-commit verifier (#491) and layers the merged-to-default-branch requirement on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e claim, CAS retry reset)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nding

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…redundant status CHECK re-narrow; docs

- Sweep re-dispatches pending non-batch rows no in-memory queue owns (REMO_REVANOTE_PENDING_REDISPATCH_MS, default 2min); fixes the docs claim of a stale-lease sweep.
- Remove the DROP/ADD annotations_status_check pair (inline CHECK already has the same 5 values; main has no such pair).
- Remove obsolete lifecycle commit-gate test of the superseded verifyCommitOnRemote API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tch, single doc for commit gate

- batch/single send: check channel before claim; post-claim failure fails claimed members with run + callback
- retry liveness via isTokenLiveAnywhere (session_id is NULL in the claim window)
- single-path markSkipped CAS on pending
- stale-pending sweep: exponential backoff, fail after 5 throws with callback
- docs: one accurate commit-verify section; prompt says MERGED to default branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rget is a CAS on pending

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…class fix)

- parked_offline, grace-expiry, post-send and send-failure writes are now
  compare-and-set; callbacks/broadcasts only when the row transitioned
- grace registration deduped by dispatch token; batch sweep skips groups
  parked in grace while offline; session_offline rows re-eligible when no
  grace entry exists (restart recovery) in both sweeps
- batch markFailed acts only on ids this send claimed
- webhook pre-resolve writes only mapping_id
- retry of a dispatched row checks durable annotation_runs state
- merged-to-default is the whole commit-verify bar (drop branch containment)
- send-time session_offline parks like parked_offline; stale-pending cap
  counts returned failed outcomes
- stall alert covers dispatched rows with no in_flight run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…neration

A reply was correlated by session only, so a late reply from an earlier
dispatch (hub restart, then another annotation on the same session) finalized
the armed hook, and the old run's merged SHA passed the new run's commit check.

- dispatch_id (the annotation_runs id; one uuid per batch turn) is stated in the
  single and batch prompts, REQUIRED in the reply envelope, and checked by the
  revanote shouldFinalize hooks: a missing/foreign id leaves the hook armed and
  the reply is dropped. Terminal-timeout finalizes also refuse a foreign id.
- annotations.current_run_id (additive) is set at the send-time claim (batch:
  bindAnnotationRun); the finalize annotation write is a CAS on
  status='dispatched' AND current_run_id, written before the run row. A lost
  CAS writes nothing else. Retry closes the superseded in_flight run.
- Retry durable-liveness ceiling uses the real hook max (REMO_DISPATCH_HOOK_MAX_MS
  for single runs) instead of the 20min narration timeout.
- Batch grace replay re-gathers the CURRENT pending members instead of the list
  captured at park time.
- commit-verify: new reason commit_predates_dispatch (committer date must be
  >= dispatched_at - 5min).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@agentautofix-fixer

Copy link
Copy Markdown
Contributor

AgentAutofix AI Review

claude

PASSED

  • warning hub/src/revanote/prompt.ts: Unfenced interpolation of external annotation id into suggested branch name -- AgentAutofix suppressed this message because it contained repository content or credential-shaped data. Where a code change was made, it is visible in the pull request itself.
  • info hub/src/revanote/batch-dispatch.ts: Batch dispatch broadcast uses the batch token as run_id -- AgentAutofix suppressed this message because it contained repository content or credential-shaped data. Where a code change was made, it is visible in the pull request itself.

codex

BLOCKED

  • blocking hub/src/revanote/prompt.ts: Batch completion contract contradicts every deploy strategy -- renderBatchAnnotationPrompt emits strategy-specific instructions such as “Leave the PR open for human review” for normal PR mappings, “push directly” for direct mappings, or “Do NOT git push” for none mappings, but then unconditionally requires the agent to open and merge a PR, redeploy, and only then report resolved. These mutually exclusive instructions make successful batch completion impossible or unreliable, especially for the default untrusted PR mapping and the none strategy.

agy

advisory -- not part of the gate decision

INFRA FAILURE: reviewer CLI reported quota exhaustion

Result: Blocking finding from: codex

Reviewer models (requested pin, not independently confirmed):

  • claude: claude-haiku-4-5-20251001 (requested pin, not independently confirmed)
  • codex: gpt-5.5 (requested pin, not independently confirmed)

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