Skip to content

IR-012 — cross-process WIP-cap lease, shipped opt-in behind `PETROVA_WIP_CAP_CROSS_PROCESS_LOCK`

Date: 2026-09-13 Status: closed Supersedes: none Superseded-by: none — current

irina/tickets/IR-012-cross-runner-wip-cap-lock.xml (filed while scoping IR-007’s real E2E test — docs/decisions/2026-09-13-ir-007-e2e-fixture-repo.md) names the honest remaining gap in the fleet-wide WIP cap: withWipCapGuard (host/src/tools/wip-cap.ts) only serializes check-then-act within one Node process. Two separate irina-cycle.yml runs — two separate GitHub Actions runners — have no shared memory to serialize against, so the same TOCTOU race IR-007 fixed in-process can still happen across processes.

Ship the fix (host/src/tools/wip-cap-lock.ts) as a leased file, .petrova/wip-cap.lock, committed to this repo and acquired via the GitHub Contents API’s optimistic-concurrency sha match — a second acquire against a stale sha fails with 409/422, read as “lock held.” A lease carries its own expiresAt (LEASE_TTL_MS = 5 minutes); a runner killed mid-hold does not wedge the cap, because the next acquirer treats an expired lease as free rather than waiting on the crashed holder to release it.

Chosen over the ticket’s other two candidates:

  • A GitHub Deployments-API mutex — no material advantage over the Contents API here, and it’s a less-obvious primitive to future readers of this code.
  • Accept-the-race-and-reconcile — cheaper to build, but it lets a real 4th PR exist (even briefly) before closing it, which is a worse failure mode than refusing it up front for a system whose whole point is the refusal.

withWipCapGuard calls it only when PETROVA_WIP_CAP_CROSS_PROCESS_LOCK=1 is set — the lock ships disabled by default. Flipping it unconditionally on would make every act() call depend on GitHub App write access to this repo’s .petrova/wip-cap.lock, for every existing caller (CLI, Fleet MCP server, any future consumer), unconditionally, the same day this code lands. That is a bigger blast radius than IR-012 asked for. The intended rollout: enable the flag on irina-cycle.yml specifically first (the only real multi-runner caller today), observe a burn-in period, then flip the default in a follow-up PR once it has actually run concurrently against live GitHub state without wedging.

  • Does not change the ratified cap value (3) or the single-process serialization in wip-cap.ts — additive only.
  • Does not (yet) wire PETROVA_WIP_CAP_CROSS_PROCESS_LOCK=1 into irina-cycle.yml’s env — that’s the burn-in step above, deliberately left as a separate, reviewable change once this lands.
  • Does not include IR-012’s C1 as a real two-runner workflow_dispatch E2E test (the IR-007 pattern, extended). host/tests/wip-cap-lock.test.ts proves C1/C2/C3 against a fake Contents-API double with the same optimistic-concurrency contract real GitHub enforces — real 405 proof against two live runners racing the real fixture repo is scoped out to the burn-in step, not fabricated here.
  • IR-004 (write-path wiring) is unblocked from IR-012’s specific dependency, but its own human-ruling-required gate is unchanged — this doc does not authorize starting IR-004.
  • A future flip-the-default PR should cite this doc and the burn-in evidence, not re-litigate the design.
  • irina/tickets/IR-012-cross-runner-wip-cap-lock.xml
  • host/src/tools/wip-cap-lock.ts, host/src/tools/wip-cap.ts
  • host/tests/wip-cap-lock.test.ts
  • docs/decisions/2026-09-13-ir-007-e2e-fixture-repo.md (the pattern this follows: real gap, honestly scoped, follow-up ticket for what’s deferred)
  • Subagent: claude-sonnet-5 (session_01488gmoyur1UAMfpNMgUAhC)
  • Human: alex@devarno.com

Countersigned by human:alex@devarno.com on 2026-09-13 — commit 46bb0eba (this file’s creation commit) is authored by the human directly, per docs/decisions/2026-09-05-proxy-countersign-retired.md’s post-retirement definition. Line added retroactively by the agent as scribe.