Skip to content

Surface review failures without host access - #52

Merged
Deicyde merged 6 commits into
mainfrom
claude/review-outage-observability
Oct 14, 2026
Merged

Deicyde merged 6 commits into
mainfrom
claude/review-outage-observability

Conversation

@Deicyde

@Deicyde Deicyde commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Runner change: takes effect when the nightly host's checkout is updated. Nothing here ships through the Brain release or promotion path; site/moderate.py and site/batch_annotate.py run from the host's checkout. The release-neutral-paths policy (site/ops/brain_release_authority.py) still classifies both as release-affecting, since it lists neutral paths and treats everything else as affecting. So after this merges, a Brain release frozen at an earlier commit cannot be promoted from main without a rebuild at the new commit. site/test_moderate.py is neutral.

Problem

The nightly AI review has failed on every article since the 2026-09-18 night. Live /stats read 117 review runs / 1,761 articles / 843 errors on 2026-10-12 22:17 UTC and 118 / 1,776 / 858 on 2026-10-13, with tokens unchanged at 26,345,645. The newest ai-moderate:review revision is from 2026-09-17 09:05 UTC (https://wikilean.jackmccarthy.org/Generalized_hypergeometric_function/history). Two runner gaps let this persist:

  1. The cause is invisible without the host. A failed article's error text is written only to that host's site/cache/.moderate_run.log. POST /api/runs already accepts a free-text notes field (wiki/src/index.ts, up to MAX_TEXT_LEN = 2000), but the runner sends only modes=review (site/moderate.py, end of main()).
  2. The queue never moves past a failing article. process_review returns before the bot POST on any agent error (site/moderate.py:934-935 on main). Only bot saves stamp moderation_state.last_reviewed_at. /api/work orders by flags, drift, human edits, then oldest last_reviewed_at. So an article that fails for its own reasons is handed out again every night, ahead of everything behind it.

Change

  • Error sample in notes.
    • run_jobs now also returns per-job {slug, error, elapsed_s}, keyed by the work-queue slug.
    • main() folds the distinct errors into notes, most common first: modes=review; 15/15 errors: 15x ~2s RuntimeError: agent_error: … (e.g. A, B) | …, with up to 5 distinct errors.
    • ~Ns is the median per-job time for that error: the article's own time in annotate_one, excluding the wait for a concurrency slot. Errors raised before annotate_one (get_failed_*) have no time. It is a hint about where a job failed, not a classification: seconds suggest a login or launch failure, minutes suggest the agents ran. An error inside the agent pipeline reports 0 tokens either way, so the token count can't tell them apart.
    • sanitize_error removes the bearer token, including when it is wrapped across lines or split by escape codes. It also removes sk-ant-/gh*_ tokens, long alphanumeric runs containing a digit, emails, /Users/<name> (replaced by ~), and control and ANSI bytes. package@version strings survive.
    • The whole notes string is cut to 2000 UTF-16 units, because the Worker 413s anything longer and drops the row.
  • Per-job timer. annotate_one (site/batch_annotate.py) now starts its clock after acquiring the semaphore. It used to start before, so elapsed_s included the wait behind other queued jobs. This also changes elapsed_s in both runners' run logs.
  • Abort on login and expired-token errors. The account-wide abort in run_jobs now also matches failed to authenticate and not logged in (case-insensitive), next to credit balance and authentication.
    • Before, Not logged in · Please run /login and Failed to authenticate. API Error: 401 OAuth access token has expired. Re-authenticate to continue. matched neither, so a night worked through the whole batch with every article failing.
    • Now the first one aborts the run (exit 3). Only the jobs already holding a slot finish. At the default concurrency of 2, a logged-out night's notes read like modes=review aborted; 2/2 errors: 2x ~2s RuntimeError: agent_error: Not logged in · Please run /login (e.g. A, B).
    • site/ops/retry-lib.sh retries an exit 3 only when the log tail carries the Max limit signature, which these messages don't.
  • Queue rotation for article-specific failures.
    • Review mode keeps a host-local, gitignored site/cache/.review_failures.json.
    • An error that is about the article stamps its slug: fetch_failed, extract_failed, agent1_no_json, agent2_no_json, render_failed, final_read_failed, human_lost_422, get_failed_404. A success clears the stamp.
    • Account- or host-wide errors are never recorded: login, limits, CLI, network, Worker writes. Neither is anything from an aborted run (exit 3). Rotating on those would push the highest-priority articles back once the cause is fixed.
    • The next /api/work call over-fetches by the number of slugs stamped in the last 3 nights (capped at 100) and moves them behind the others, oldest failure first. The run never shrinks: when nothing else is left, they fill the remaining slots.
    • The record is reloaded and merged just before saving. Dry runs never write it, and an error in it only warns.
  • No Worker change is required. Until wiki: /stats AI-review freshness, recent run counts, public GET /api/runs (counts only) #50 adds a public GET /api/runs and a recent-runs table on /stats, reading notes takes wrangler d1 execute --remote (query below).

How tested

  • site/test_moderate.py: 142 passed, 0 failed, 0 skipped (Python 3.12, with claude-agent-sdk installed so the batch_annotate tests run). 18 tests are new. They cover:
    • sanitization, the sample, and the 2000-unit cap with astral characters;
    • per-job outcomes keyed by the queue slug;
    • a job queued behind a held slot reporting only its own elapsed_s;
    • the login and expired-token errors each aborting run_jobs (exit 3) after at most the jobs holding a slot;
    • the night-based retry window, and the record's file handling.
  • Several tests drive run_mode("review") across nights with fakes: rotation past fetch_failed, no rotation after a night of Worker-write failures, nothing recorded from an aborted run, and nothing saved on a dry run.
  • Mutation checks in a scratch copy, each caught by at least one test: dropping the dry-run/abort guard, recording every error, keying by annotate_one's slug, dropping either new abort keyword, and starting the timer before the semaphore again.
  • site/test_parity.py: 8 passed. site/eval_moderation.py --offline --require-all: 7 passed.
  • Release check: blocking_changes on this branch's diff lists site/batch_annotate.py and site/moderate.py.

Risks

  • A systemic failure that looks article-specific. If every article fails fetch_failed because Wikipedia is unreachable, or extract_failed because python3 is broken, about 15 articles a night are stamped. After the fix, the top articles wait up to 3 nights. A login, limit or CLI failure records nothing.
  • Substring abort. As with authentication before, any error text containing failed to authenticate or not logged in aborts the night after the jobs already holding a slot.
  • Host-local state. Two hosts would not share the record. The runner's ordering only applies to articles inside the 100-row /api/work window, so it can't rotate the wp-update queue; that needs a Worker-side attempt stamp.
  • Sanitization is pattern-based. It errs toward redacting: a 40-hex git SHA is redacted, for example.
  • Effect needs a host update. Nothing changes on the nightly host until it runs a checkout with this commit.

What Jack must do

  • Update the nightly host's checkout to a main that includes this change. Each night's pipeline_runs.notes then carries the error sample.
  • If that checkout is older than the current nightly wrapper, the update also brings site/ops/nightly-runtime.sh. Its preflight stops the moderation nightly unless WIKILEAN_MATHLIB names an absolute mathlib4 checkout. Set it in the gitignored site/ops/nightly.local.env (template: site/ops/nightly.local.env.example) before the first night on the new checkout.
  • Until wiki: /stats AI-review freshness, recent run counts, public GET /api/runs (counts only) #50 adds GET /api/runs, read the notes with wrangler d1 execute <db> --remote --command "SELECT run_id, started_at, errors, articles_processed, notes FROM pipeline_runs WHERE kind='review' ORDER BY started_at DESC LIMIT 5".

🤖 Generated with Claude Code

…eviews

Failed reviews carry their error text only in the nightly host's
site/cache logs, and /stats shows only totals, so a night where every
article fails is invisible without host access. run_jobs now returns
per-job outcomes; main() folds the distinct errors (counts, median
time, example slugs, secrets/paths/emails stripped) into the existing
pipeline_runs notes field, capped to the Worker's 2000-unit limit.

A failed review never stamps last_reviewed_at, so /api/work hands the
same articles out every night. Review mode now keeps a host-local
site/cache/.review_failures.json and moves articles that failed in the
last 3 days behind the rest of the queue (over-fetching by that count
so the run keeps its size); success clears an entry, dry runs never
write it.
…mple

Review findings on the first cut:
- Account-wide failures (login, limits, CLI, network, Worker writes)
  stamped the record too, so a retry after a Max reset, or the night
  after a login fix, deferred the highest-priority articles. Only
  fetch/extract/agent-output/render/final-read/422/404 errors stamp now,
  and an aborted run (exit 3) records nothing.
- The retry delay is measured in nights (3 days minus 12 h), since the
  stamp is taken at run end and checked at the next run's start.
- Failures are keyed by the work-queue slug, not annotate_one's
  make_slug(title); '?' is skipped.
- The record is reloaded and merged just before saving, with a per-pid
  tmp file; a malformed n is coerced; any record error only warns.
- No extra stdout line: retry-lib greps the log tail on exit 3.
- The email rule needs an alphabetic TLD so package@version survives;
  the bearer token is matched across line breaks and escape codes; one
  distinct error gets up to 600 chars of the notes budget.
- Tests drive several nights through run_mode, assert the dry run never
  saves, and cover the aborted and account-wide cases.
@Deicyde

Deicyde commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner Author

Review status: merged in f6f5b100; follow-ups remain

Update 2026-10-14 (re-checked at 80a1162e):

  • Verified on the merged head (80a1162e, identical on main for site/): annotate_one now starts t0 after acquiring the semaphore (site/batch_annotate.py:452), so ~Ns excludes queue wait. A test covers this (site/test_moderate.py:2324).
  • Verified, beyond the review: 5b72a579 aborts on Not logged in and expired-OAuth errors (site/moderate.py:1408-1412). Aborted runs still register notes with the sample, and retry-lib.sh does not retry them. Assessment: sound.
  • Departure: the host-local rotation was kept and merged (site/moderate.py:84-97, 1475-1511). Assessment: risky. Deferred slugs still reach only host stdout, there is no all-failed-night guard, and the code does not say to delete it once a server-side queue fix lands.
  • Body: partially rewritten. The checkout-update activation and the WIKILEAN_MATHLIB step are there. Still missing: the full Operations runbook and review-outage diagnosis #56 update path and the statement that this cannot diagnose the current outage.
  • Open: settle the failure policy with the replacement for wiki: serve wp-update the whole drifted set (cap 1000) instead of attempt stamps #63, test the notes wiring in main(), and fix the stale exit-3 docstring.
Original review (2026-10-14)

Independent adversarial review of aad00cb3: a defect reviewer with skeptics, a design reviewer with a devil's advocate, and an arbiter.

Verdict: merge as the error-sample half only (per-job outcomes, sanitize_error, error_sample, build_run_notes with the 2000-unit cap); drop the host-local rotation.

Required before merge

  • Remove the host-local rotation and its tests from site/moderate.py and site/test_moderate.py. Nothing shows review articles failing night after night, the current outage looks systemic, and deferred slugs print only to host stdout, never to notes. The code stays recoverable from b89b0fbd.
  • Rewrite the body. The change takes effect only when the nightly host's checkout is updated, not through a Brain release. Replace "Nothing to configure" with Operations runbook and review-outage diagnosis #56's update path in docs/OPERATIONS.md, because a plain pull of an old checkout makes the new wrapper exit 1 on an unset WIKILEAN_MATHLIB and silently stops the nightly. Also say it cannot diagnose the current outage, and give the read path (wiki: /stats AI-review freshness, recent run counts, public GET /api/runs (counts only) #50's GET /api/runs, or wrangler with Cloudflare credentials).
  • Move t0 in site/batch_annotate.py:449 inside async with sem: (or drop the time from the sample). The ~Ns median currently counts semaphore wait and reads about 4x high for agent-stage failures.

Decisions for the maintainer

  • Whether run notes are public is decided on wiki: /stats AI-review freshness, recent run counts, public GET /api/runs (counts only) #50, whose review recommends counts only. This PR stores the error samples in D1 either way; with counts only, read them with wrangler d1 execute from the operator Mac.
  • Keep the rotation as a stopgap only if this host update will be the only one for months (default: drop it, and add rotation later only if specific articles keep failing once the outage is fixed).

Merge order: after #56; in the same release-affecting batch as #50 and the other Worker PRs, before the next Brain release build.

Fixes are in progress; this comment will be updated.

Posted by the WikiLean PR swarm (lead); updated 2026-10-14.

@Deicyde

Deicyde commented Oct 14, 2026

Copy link
Copy Markdown
Owner Author

Status review (2026-10-14): 🔧 Fix before merge

This is a runner-only, fail-soft change. Recording errors only warns, dry and aborted runs never update .review_failures.json, and the notes cap is exact in UTF-16 units. Replayed over real nights, the sample is useful, for example 15/15 errors: 15x … agent_error: Not logged in · Please run /login.

Fix before merge

  • The ~Ns median measures queue position, not time to failure. site/batch_annotate.py:449-450 sets t0 before async with sem, so elapsed_s includes the wait for a concurrency slot. On a login-failure night each job fails in about 9 s, but the recorded values climb 9 → 70 s and the median reads ~38s. On a mixed night, CLI-startup timeouts queued behind full agent runs would read as thousands of seconds. The error_sample docstring (moderate.py:584) and the PR body say this median separates login failures from failures after the agents ran; it doesn't. Start the clock after acquiring the semaphore (Operations runbook and review-outage diagnosis #56's median-based verdicts read the same elapsed_s, so re-check their thresholds afterwards), or drop the median.
  • Strongly suggested, same file: the pre-existing abort classifier at moderate.py:1404 (line 1208 on main) checks "authentication" in low. That matches neither Failed to authenticate nor Not logged in, so an expired or missing login burns all 15 jobs each night instead of aborting on the first failure, which is what happened on most nights since 2026-09-18.

Nits

Needs a decision alongside #63. This PR's host-local deferral (.review_failures.json, article-specific errors only, 3 nights) and #63's server-side attempt stamps take opposite positions on whether account-wide failures count as attempts. Pick one owner and policy before the runner change that sends attempted is written.

To take effect: the nightly host has to run this code. site/moderate.py is release-affecting, so land this before the next Brain release is built.


🤖 Automated status pass by Claude Code over all 18 open PRs (#46–#63), posted for the repo owner. An independent verifier re-checked each finding. All 18 heads were also merged and tested together in a scratch tree; the only test failure that comes from combining them is #47 × #55 (three other pairs had mechanical text conflicts). Reviewed head: aad00cb3.

Deicyde and others added 2 commits October 14, 2026 08:30
annotate_one started its clock before `async with sem:`, so a job's
elapsed_s included its wait behind the other queued articles, and the
error sample's ~Ns median mixed queue position into a per-job time.
- Start t0 after the acquire; nothing in annotate_one returns or fails
  before it, and an acquire that raises (AbortSkip) yields no rec.
- error_sample's docstring describes ~Ns as per-job time excluding
  queue wait, and as a hint rather than a clean login/post-agent split.
- main()'s comment points at wrangler d1 execute --remote for reading
  the notes.
- Test: a job queued behind a held slot reports only its own time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Not logged in · Please run /login" and "Failed to authenticate. API
Error: 401 OAuth access token has expired" fail every article the same
way, but neither contains "authentication" or "credit balance", so the
run kept handing out articles instead of aborting on the first one.
- The account-wide abort also matches "failed to authenticate" and
  "not logged in" (lowercase).
- Test: each error aborts run_jobs (exit 3) after only the jobs that
  already held a slot.
- The per-job outcome test and the no-rotation night test use errors
  that do not abort, so they still exercise a completed run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deicyde added a commit that referenced this pull request Oct 14, 2026
- An expired or revoked token reaches the runner as "Failed to
  authenticate. API Error: 401 ...", which lacks the abort keyword
  "authentication", so it gives 15 of 15 on a current checkout too. The
  "What the code says" note and ranked items 1 and 2 no longer claim it
  aborts; PR #52 extends the abort keywords.
- elapsed_s starts before annotate_one waits for a concurrency slot, so
  queued articles count the wait. nightly_health reports the fastest
  failing article instead of the median and no longer infers quota spend
  from slow failures; the outcome table and "Confirms it" lines drop
  their timing claims.
- Classify "Control request timeout: initialize" (claude CLI startup
  timeout) and "API Error: Stream idle timeout" (API stream timeout), in
  nightly_health and the outcome table, with tests.
- Step 1 writes the launchctl print dump to a private mktemp file. The
  setup token goes only in nightly.local.env, never a plist, and steps 5
  and 7 show how to run their checks with it.
- The fresh-checkout commands pass --job moderate to nightly-launchd.py.
- backup-d1.yml runs its own wrangler export, not backup-d1.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deicyde added a commit that referenced this pull request Oct 14, 2026
…kouts without #52

#52 adds the CLI's login failure strings to the abort keywords and starts
elapsed_s once a concurrency slot is acquired, so "15 of 15 on any
checkout" and "the clock includes the queue wait" now hold only for
checkouts without it.

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

Deicyde commented Oct 14, 2026

Copy link
Copy Markdown
Owner Author

Fixes pushed (2026-10-14)

This addresses the "Status review (2026-10-14)" comment and the PR swarm's review (the ~Ns timer, the error_sample docstring, the abort classifier, the notes read path and the PR body), except the swarm's request to remove the rotation.

What changed

  • 0c515369: annotate_one starts t0 after acquiring the semaphore, so elapsed_s and ~Ns exclude queue wait (required by both reviews). A new test covers a job queued behind a held slot.
  • 0c515369: the error_sample docstring calls ~Ns a per-job hint, not a login/post-agent split. The main() comment names wrangler d1 execute --remote as the read path.
  • 5b72a579: the run_jobs abort also matches failed to authenticate and not logged in (the status review's "strongly suggested" item). A new test checks each exits 3; two tests now use non-aborting errors.
  • PR body: takes effect through a host checkout update, "Nothing to configure" is now the WIKILEAN_MATHLIB step, and the read path is wrangler until wiki: /stats AI-review freshness, recent run counts, public GET /api/runs (counts only) #50.

Verification

  • test_moderate.py: 142 passed, 0 skipped (140 before). test_parity.py: 8 passed. eval_moderation.py --offline --require-all: 7 passed.
  • Mutation checks: dropping either keyword, or moving t0 back, fails a new test. 30 repeated runs of the new and adjusted tests passed.
  • CI on 5b72a579: all checks green (worker, python, browser, required).

Not changed / still open

Merge notes


🤖 Posted by Claude Code for the repo owner. Each fix was checked by an independent verifier before it was pushed. Head: 5b72a579.

@Deicyde
Deicyde merged commit f6f5b10 into main Oct 14, 2026
4 checks passed
@Deicyde
Deicyde deleted the claude/review-outage-observability branch October 14, 2026 21:15
Deicyde added a commit that referenced this pull request Oct 15, 2026
…tempt stamps

Drop the per-queue attempt stamps (migration 0014, the two
moderation_state columns, POST /api/runs `attempted` and the
/api/work ordering on them). Review rotation already lives in the
runner since #52, which defers only article-specific failures; a
second, looser server-side rotation would fight it. The columns were
also declared on the drizzle table, so any later Worker deploy without
0014 applied first would fail every moderation_state write and read
while the canary still passed. Nothing sends `attempted` yet, so
removing it changes no behavior.

The wp-update problem the stamps were for is simpler to fix by size.
A failed stage-0 re-pin writes nothing, so a 100-row page let the same
failures hold its head every night. The corpus is under 1000 articles,
so /api/work now serves wp-update up to 1000 rows (review stays at
100) and every drifted article gets a zero-token attempt each night.
The runner clamps per mode to match, the nightly asks for 1000, and
run_wp_update now paces read-only articles as update_from_upstream's
own loop does, since the sweep is several times longer.
nightly_health warns once a single wp-update run reaches 800 articles,
the point to revisit rotation. The articles.slug tiebreak stays, so
ties on last_reviewed_at (every never-reviewed article) come back in a
stable order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deicyde added a commit that referenced this pull request Oct 17, 2026
- An expired or revoked token reaches the runner as "Failed to
  authenticate. API Error: 401 ...", which lacks the abort keyword
  "authentication", so it gives 15 of 15 on a current checkout too. The
  "What the code says" note and ranked items 1 and 2 no longer claim it
  aborts; PR #52 extends the abort keywords.
- elapsed_s starts before annotate_one waits for a concurrency slot, so
  queued articles count the wait. nightly_health reports the fastest
  failing article instead of the median and no longer infers quota spend
  from slow failures; the outcome table and "Confirms it" lines drop
  their timing claims.
- Classify "Control request timeout: initialize" (claude CLI startup
  timeout) and "API Error: Stream idle timeout" (API stream timeout), in
  nightly_health and the outcome table, with tests.
- Step 1 writes the launchctl print dump to a private mktemp file. The
  setup token goes only in nightly.local.env, never a plist, and steps 5
  and 7 show how to run their checks with it.
- The fresh-checkout commands pass --job moderate to nightly-launchd.py.
- backup-d1.yml runs its own wrangler export, not backup-d1.sh.
Deicyde added a commit that referenced this pull request Oct 17, 2026
…kouts without #52

#52 adds the CLI's login failure strings to the abort keywords and starts
elapsed_s once a concurrency slot is acquired, so "15 of 15 on any
checkout" and "the clock includes the queue wait" now hold only for
checkouts without it.
Deicyde added a commit that referenced this pull request Oct 17, 2026
Surface review failures without host access
Deicyde added a commit that referenced this pull request Oct 17, 2026
…tempt stamps

Drop the per-queue attempt stamps (migration 0014, the two
moderation_state columns, POST /api/runs `attempted` and the
/api/work ordering on them). Review rotation already lives in the
runner since #52, which defers only article-specific failures; a
second, looser server-side rotation would fight it. The columns were
also declared on the drizzle table, so any later Worker deploy without
0014 applied first would fail every moderation_state write and read
while the canary still passed. Nothing sends `attempted` yet, so
removing it changes no behavior.

The wp-update problem the stamps were for is simpler to fix by size.
A failed stage-0 re-pin writes nothing, so a 100-row page let the same
failures hold its head every night. The corpus is under 1000 articles,
so /api/work now serves wp-update up to 1000 rows (review stays at
100) and every drifted article gets a zero-token attempt each night.
The runner clamps per mode to match, the nightly asks for 1000, and
run_wp_update now paces read-only articles as update_from_upstream's
own loop does, since the sweep is several times longer.
nightly_health warns once a single wp-update run reaches 800 articles,
the point to revisit rotation. The articles.slug tiebreak stays, so
ties on last_reviewed_at (every never-reviewed article) come back in a
stable order.
Deicyde added a commit that referenced this pull request Oct 17, 2026
…be like the nightly

Step 1 masks TOKEN/KEY/SECRET/PASSWORD values from launchctl's environment block. Step 5's token check runs in a subshell, and the launchd probe runs like the nightly's plist (Background, a /bin/bash parent kept alive) and reads runs and the last exit code before bootout. Step 7 says a run registers either way and names the real confirmation. The outcome table splits extract_failed/fetch_failed into host-wide (every article) and article-specific rows, and gains a Full Disk Access row. site/moderate.py line citations, shifted by #52 and #63, become function names. The fresh-checkout note lists what a clone must recreate. site/ops/README.md links the runbook.
Deicyde added a commit that referenced this pull request Oct 18, 2026
…are rows; fresh-checkout facts

The probe's -c script ends in 'exit $?' instead of an echo, so bash still runs the CLI as a child (as for the nightly) and launchd's last exit code is the CLI's, which makes the 126 sentence true. Step 7's token form runs in step 5's subshell. The checklist says to type bash first: interactive zsh does not treat # as a comment. Outcome rows: extract_failed and fetch_failed are host-wide when every article that reached that stage shows them; the article-specific row covers cached HTML and PR #52 scoping; the FDA row lists 'exit code 126' and the older wrapper's limit. The lower-bound note is described for a full old page. Fresh checkout: the wrapper's preflight is what needs wiki/.dev.vars, the job's two KV pushes run npx wrangler, and pending checkpoints must be flushed or copied.
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