Skip to content

feat(run): heal mid-batch drift once and let the remaining rows reuse the fix - #26

Merged
josharsh merged 1 commit into
mainfrom
mid-run-heal
Jul 30, 2026
Merged

josharsh merged 1 commit into
mainfrom
mid-run-heal

Conversation

@josharsh

Copy link
Copy Markdown
Owner

What

run --heal now repairs a mid-batch drift once and lets the rest of the batch reuse the fix, instead of healing only the warm-up row.

  • Fan-out rows still replay strictly on the fast path — nothing changes when the site is stable.
  • When a fan-out row drifts under --heal, one worker takes a heal lock, repairs that row with the model, and grafts the repair onto the shared template via the same graftHeals path the warm-up already used (so {{var}} values survive the write-back).
  • Workers that drifted against the stale template retry for free against the repair rather than each spending a model run — whether the repair is still in flight or already landed.
  • Bounded by a new --max-heals <n> (default 3). Past the budget, drifted rows are reported exactly as before and the reason is warned once, so a genuinely broken site can't become one model run per row.
  • RunSummary gains healed, surfaced in the summary line and the --json summary event.

No behavior change without --heal, and none for replay, the SDK's .over() (which passes heal: false), or run without the flag.

Why

run.ts fanned out every row with heal: false. If a site changed at row 900 of a 5,000-row batch — an A/B test flips a button label, a cookie banner appears, pagination moves — every row from 900 on returned drift, and each one still paid for a Chrome launch. You'd notice later, run replay --heal by hand, then run --resume.

That also made the README's claim on the run section ("a 5,000-row job costs one model run plus, at most, a handful of repairs") aspirational rather than literal. It's now literal.

Self-healing at scale is the load-bearing claim of record → replay → run, and the repair machinery already existed one function away.

Verification

End to end, real Chrome + real model repairs. A local fixture site renames its submit button after N page loads ("Search now" → "Find results"); the recorded trace clicks the old label, so early rows replay clean and later rows hit the renamed button — the exact mid-batch drift an A/B test causes.

case rows result
--max-heals 0 (today's behavior) 6 @ concurrency 1 3 ok, 3 drift, 0 healed
--max-heals 3 6 @ concurrency 1 6 ok, 0 drift, 1 healed
--max-heals 3 12 @ concurrency 4 12 ok, 0 drift, 1 healed

Every row returned its own correct output, and the healed trace kept its {{query}} template (http://127.0.0.1:8731/?q={{query}}) while the act target moved "Search now" → "Find results".

The first 12-row parallel run surfaced a real gap: 2 heals fired where 1 should have. A row that started before a repair and drifted after it landed found the lock already released, so it never noticed the newer template and burned its own heal. Fixed by checking the template generation before deciding to heal, not only after waiting on the lock — re-run gives 1 heal.

Tests: 248 (+5). Five new cases in run.test.ts covering: one repair for a mid-batch change with later rows passing on their first strict attempt; a single heal lock under 4 concurrent drifting workers; the free-retry regression above; --max-heals exhaustion (budget respected, every row still reported, warning emitted); and no mid-run healing at all without --heal.

Both concurrency tests were confirmed non-vacuous — they fail when the free-retry check is disabled.

Checklist

  • pnpm test (248 passing) and pnpm -r typecheck pass
  • Docs/README updated if behavior changed — --max-heals is documented in --help; the README's run section needs a sentence on mid-run healing (happy to add it here or in a follow-up)

🤖 Generated with Claude Code

… the fix

`run --heal` only ever repaired the warm-up row. If a site changed at row 900 of
a 5,000-row batch — an A/B test flips a button label, a banner appears — every
row from there on returned drift, and each still paid for a Chrome launch. The
operator noticed later, ran `replay --heal` by hand, then `run --resume`.

Fan-out rows still replay strictly on the fast path. When one drifts under
--heal, a single worker takes a heal lock, repairs that row with the model, and
grafts the repair onto the shared template through the same graftHeals path the
warm-up already used (so {{var}} values survive). Other workers that drifted
against the stale template retry for free against the repair instead of each
spending a model run — whether the repair is still in flight or already landed.

Bounded by --max-heals (default 3): past the budget, drifted rows are reported
exactly as before and the reason is warned once, so a genuinely broken site
cannot turn into one model run per row. RunSummary gains `healed`, surfaced in
the summary line and the --json summary event.

Verified end-to-end against a real Chrome and real model repairs, on a local
fixture that renames its submit button mid-batch:

  before (--max-heals 0):  6 rows -> 3 ok, 3 drift, 0 healed
  after  (--max-heals 3):  6 rows -> 6 ok, 0 drift, 1 healed
  12 rows @ concurrency 4: 12 ok, 0 drift, 1 healed

248 tests (+5). Both new concurrency tests were confirmed to fail when the
free-retry check is disabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@josharsh
josharsh merged commit c782377 into main Jul 30, 2026
1 check passed
@josharsh
josharsh deleted the mid-run-heal branch July 30, 2026 14:07
josharsh added a commit that referenced this pull request Jul 30, 2026
Documents the mid-run heal feature merged in #26 and bumps pixelpi to 0.4.0
(minor: new user-visible behavior and a new flag, pre-1.0). The core/cdp/ai
packages are unchanged at 0.2.0/0.2.2/0.1.0.

Adds CHANGELOG.md (Keep a Changelog + semver), backfilled from git history and
npm release dates for 0.1.0 through 0.4.0. CONTRIBUTING and the PR template now
ask for an Unreleased entry on user-visible changes.

README: the run section documents mid-run healing and --max-heals, and the
"self-heals" bullet now says what it actually does (fix once at row 900, reuse
for the rest) rather than implying it only worked on the warm-up row.

packages/agent/README.md is a copy of the root README that npm renders, and it
had to be kept in sync by hand. A prepack hook now copies it, so a published
tarball can no longer ship a stale README — this release would have.

Verified: build, typecheck, 248 tests, and the packed 0.4.0 tarball installed
from disk and run end to end against a real Chrome — 12 rows at concurrency 4
over a site that renames its button mid-batch gave 12 ok, 0 drift, 1 heal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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