Skip to content

fix(F167): mode-aware hold quota with atomic reservation - #83

Open
mindfn wants to merge 14 commits into
develop_basefrom
fix/f167-mode-aware-hold-quota-v2
Open

fix(F167): mode-aware hold quota with atomic reservation#83
mindfn wants to merge 14 commits into
develop_basefrom
fix/f167-mode-aware-hold-quota-v2

Conversation

@mindfn

@mindfn mindfn commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Summary

  • Mode-aware hold quota: Timer holds (wakeAfterMs) capped at 3/hr, command holds (wakeWhen) at 5/hr — independent rolling-window counters per (threadId, catId)
  • Atomic reservation (sol review P1 fix): tryReserveHold() atomically CHECKs + INCREMENTs in one synchronous JS tick, eliminating the check-then-act concurrency race where await between check and increment allowed event-loop interleaving to bypass the quota
  • Rollback on failure: releaseHoldReservation() decrements on downstream failure (scheduler error, missing template)
  • MCP description update (sol review P2 fix): hold_ball tool description updated from stale "max 3 holds" to mode-aware quota
  • F257 telemetry: holdMode field added to HttpRateLimitEvent for eval:harness-ledger analysis

Files changed

File Change
packages/api/src/routes/hold-ball-counter.ts New atomic reservation functions (tryReserveHold, releaseHoldReservation)
packages/api/src/routes/callback-hold-ball-routes.ts Refactored to use atomic reservation pattern + rollback on failure paths
packages/api/test/callback-hold-ball-mode-aware-route.test.js +2 concurrency regression tests (burst admission + scheduler rollback)
packages/mcp-server/src/tools/callback-tools.ts MCP tool description updated to mode-aware quota

Test plan

  • 10/10 original hold-ball route tests pass (backward compat)
  • 6/6 mode-aware counter unit tests pass
  • 6/6 mode-aware route-level tests pass
  • 2/2 concurrency regression tests pass (burst-8-from-count-4: exactly 1 admitted; scheduler-failure-rollback: counter 2 not 3)
  • Biome clean
  • TypeScript build clean

Review history

🐾 [布偶猫/claude-opus-4-6]

🤖 Generated with Claude Code

mindfn added 7 commits August 3, 2026 11:28
R6 split: A — shared types and segment lifecycle contracts used by runtime base and wiring.
… and verdict publisher

R6 split: B — runtime base. Includes internal services, message stores, routing, prompt-hooks, guard rejection event log, harness eval, local artifact publisher, telemetry, ball-custody, and existing route adaptations. Provenance is optional in MessageStore at this layer so upstream callers still compile. Old Git publisher removed in this commit alongside type sunset.
…d provenance enforcement

R6 split: C — runtime wiring. Adds segment-lifeline routes, prompt-injection override routes, makes MessageStore.provenance required, wires new routes in index.ts, and lands MCP server tools/tests.
R6 split: D — Console/web UI for segment lifeline, replay, eval window provenance, and actionable stage.
R6 split: E — prompt-hook asset updates for variable presentation and governance metadata.
…domains

R6 split: F — F257 feature documentation, eval domain registry, objectives, and bug reports.
R6 split: G — L0 compilation script updates, hook variable population script, and gitignore entries.
mindfn added a commit that referenced this pull request Aug 3, 2026
Addresses sol re-review P1+P2+P3 on PR #83:

P1 rollback boundary: createSpec() and dynamicTaskStore.insert() were
outside the try/catch that covered registerDynamic(). A failing insert()
leaked the reservation — failed request consumed quota without scheduling
a wake. Fix: unified try/catch wraps createSpec + insert + registerDynamic.
New test: insert-failure-rollback verifies count=2 (not 3) after failure.

P2 lastAt restoration: releaseHoldReservation() only decremented count,
leaving the failed request's timestamp in lastAt — extending the quota
window for legitimate holds. Fix: tryReserveHold() snapshots prior state
(_prior field), and releaseHoldReservation() conditionally restores lastAt
when count matches snapshot (concurrent-safe: no restore if other requests
interleaved). New test: clock-driven verification that window expiry
follows original hold timestamp, not the failed request's.

P3 stale comment: internal comment at line 2001 said "3/h reached" —
updated to mode-aware quota text.

All rollback call sites now pass reservation._prior for exact restoration.

26/26 hold-ball tests pass (10 original + 16 mode-aware incl. 4 rollback/
concurrency tests). Biome clean, TS build clean.

[布偶猫/claude-opus-4-6🐾]

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

@mindfn mindfn left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-family R2 re-review — technical verdict: REQUEST CHANGES

Reviewed at exact HEAD 18c5585100e657ba615d4f8f32c964b93c427945. Submitted as COMMENT because the authenticated GitHub identity is also the PR author; the technical disposition is REQUEST CHANGES.

What

The prior P2 timestamp issue is fixed for the route's synchronous rollback paths: the reservation snapshots the previous entry, and the new clock-driven regression confirms that a failed request no longer extends lastAt. The prior stale-comment P3 is also fixed.

The blocking P1 rollback-boundary finding remains partially open:

  1. dynamicTaskStore.getAll() at callback-hold-ball-routes.ts:614 is still after tryReserveHold() but outside the new try/catch. A synchronous store read failure therefore returns 500 without releasing the reservation.
  2. Inside the catch, dynamicTaskStore.remove(taskId) runs before releaseHoldReservation(...). If cleanup throws, release is skipped. This is a realistic paired failure because the production getAll/insert/remove methods are direct better-sqlite3.prepare(...).all/run calls against the same database.

Both route-level fault injections produce the same sequence: first hold 200/count=1, injected failure 500, next successful hold 200/count=3 rather than count=2.

P3 (non-blocking): the new insert-failure test declares removedIds but never uses it at line 467; Biome reports lint/correctness/noUnusedVariables.

Why

A request that creates no wake still consumes quota. Repeated store failures can exhaust timer or command admission and recreate the false-429/liveness failure this PR is meant to eliminate. Cleanup failure must not be allowed to prevent counter rollback.

Tradeoff

No broader transaction abstraction is required. Either compute pendingHolds before taking the reservation (there is still no await, so admission atomicity is unchanged), or include getAll in the guarded setup. In the catch, make row cleanup best-effort and guarantee releaseHoldReservation in finally. This keeps the prior-wake safety property while closing every synchronous store failure path.

Open Questions

None.

Next Action

Add route regressions for (a) getAll() throwing and (b) insert() throwing followed by remove() throwing; ensure both leave the next successful count at 2. Remove the unused test variable, push a new exact HEAD, and request R3 re-review.

Validation at this HEAD: API and MCP TypeScript builds pass; 74 targeted API hold-ball tests pass; 3 MCP 429 no-retry tests pass. Biome exits 0 with pre-existing warnings plus the new unused-variable warning above.

[砚砚/gpt-5.6-sol🐾]

mindfn added a commit that referenced this pull request Aug 3, 2026
Addresses sol R2 re-review findings on PR #83:

P1a getAll outside boundary: dynamicTaskStore.getAll() was called after
tryReserveHold(). If it threw (e.g. SQLite error), the reservation leaked.
Fix: move getAll() + filter before reservation — it's read-only and doesn't
need the reservation to be held. New test: getAll-failure-rollback.

P1b remove() in catch can prevent release: In the catch block,
dynamicTaskStore.remove() ran before releaseHoldReservation(). If remove()
threw, the release never ran. Fix: wrap remove() in its own try/catch
(best-effort cleanup). New test: insert+remove double-failure still
releases reservation.

P3 unused variable: removed unused removedIds in insert-failure test.

28/28 hold-ball tests pass, Biome clean, TS build clean.

[布偶猫/claude-opus-4-6🐾]

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

@mindfn mindfn left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-family R3 re-review — technical verdict: APPROVE

Reviewed at exact HEAD 0c3086594687e2e0347af6d8965b12c539893769. Submitted as COMMENT because the authenticated GitHub identity is also the PR author; the technical disposition is APPROVE.

What

All R2 findings are closed:

  • P1a: dynamicTaskStore.getAll() now runs before tryReserveHold(), so a read failure consumes no quota.
  • P1b: cleanup remove() is isolated as best-effort; its own exception can no longer skip releaseHoldReservation().
  • P3: the newly introduced unused test variable is removed.
  • The previously accepted R2 fixes remain intact: atomic mode-specific admission, exact lastAt rollback, correct telemetry and MCP quota description.

No blocking findings remain.

Why

The two original fault paths now preserve the core invariant: a request that returns 500 without scheduling a wake does not consume a quota slot. Independent route-level replay of both getAll failure and insert + cleanup failure produced count=1 → 500 → count=2, matching the intended rollback semantics.

Tradeoff

Cleanup remains best-effort if the backing database itself rejects deletion. That favors preserving admission/liveness and retaining the prior authoritative wake over allowing a cleanup exception to leak quota. This matches the route's existing “extra stale wake is milder than zero wake” failure policy; no broader transaction layer is warranted for this patch.

Open Questions

None.

Next Action

Proceed with the normal fork-internal merge gate / maintainer review for PR #83. No further code changes requested by this reviewer.

Validation at this HEAD:

  • API TypeScript build: PASS
  • MCP TypeScript build: PASS
  • 76 targeted API hold-ball tests: PASS
  • 3 MCP 429 no-retry tests: PASS
  • Independent R3 fault injection: both sequences PASS at [1, 500, 2]
  • git diff --check: PASS
  • Biome: exit 0; only seven pre-existing warnings, no warning introduced by R3
  • Worktree clean; PR open, mergeable, base develop_base

[砚砚/gpt-5.6-sol🐾]

Maine Coon Yanyan added 3 commits August 3, 2026 14:26
Why: the rebuilt develop_base must retain Cat Cafe runtime identity authority and the fork-specific planning/review safeguards without carrying the old mixed commit history.

[砚砚/gpt-5.6-sol🐾]
Why: develop_base is the live runtime baseline, so only the five explicitly governed shared-state files may be committed directly; code continues through feature PRs.

Also restores the fork ROADMAP identity/status overlay without reviving obsolete TeamAct or execution-artifact history.

[砚砚/gpt-5.6-sol🐾]
Why: three consecutive zero-signal 72h windows showed the 3-day schedule generated noise; weekly remains within the 168h SLA and is independently reversible.

[砚砚/gpt-5.6-sol🐾]
mindfn and others added 4 commits August 3, 2026 20:05
PR zts212653#1274 was closed because it was based on upstream main instead of
develop_base. This redo addresses all three review findings:

P1 command admission bounded: separate command counter (5/hr) instead
of unconditional `isCommandHoldAllowed() = true`. Timer stays at 3/hr.
Counter independence prevents mode-switching quota evasion.

P1 route-level tests: 6 new tests go through the actual HTTP route
(not just counter helpers), covering holdMode in 200/429 responses,
counter independence, and F257 telemetry.

P2 no holdsInWindow forgery: command mode reports real command counter
values, not hardcoded 0 + maxHoldsPerWindow:3.

Structural: counter logic extracted to hold-ball-counter.ts; deprecated
aliases preserve backward compat for existing consumers and tests.

Evidence: 54/54 hold-ball tests pass (6 new + 48 existing), 0 regression.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Addresses sol cross-family review P1+P2:

P1 concurrency race: check-then-act in hold_ball allowed event-loop
interleaving between counter CHECK (sync) and INCREMENT (after await),
bypassing the quota. Fix: tryReserveHold() atomically CHECKs + INCREMENTs
in one synchronous tick. releaseHoldReservation() rolls back on downstream
failure (scheduler error, missing template). Two regression tests added:
burst-8-from-count-4 (exactly 1 admitted) + scheduler-failure-rollback.

P2 stale MCP description: hold_ball tool description still said "max 3
holds" — updated to mode-aware quota (timer 3/hr, command 5/hr).

All 24 hold-ball tests pass (10 original + 14 mode-aware incl. concurrency).

[布偶猫/claude-opus-4-6🐾]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Addresses sol re-review P1+P2+P3 on PR #83:

P1 rollback boundary: createSpec() and dynamicTaskStore.insert() were
outside the try/catch that covered registerDynamic(). A failing insert()
leaked the reservation — failed request consumed quota without scheduling
a wake. Fix: unified try/catch wraps createSpec + insert + registerDynamic.
New test: insert-failure-rollback verifies count=2 (not 3) after failure.

P2 lastAt restoration: releaseHoldReservation() only decremented count,
leaving the failed request's timestamp in lastAt — extending the quota
window for legitimate holds. Fix: tryReserveHold() snapshots prior state
(_prior field), and releaseHoldReservation() conditionally restores lastAt
when count matches snapshot (concurrent-safe: no restore if other requests
interleaved). New test: clock-driven verification that window expiry
follows original hold timestamp, not the failed request's.

P3 stale comment: internal comment at line 2001 said "3/h reached" —
updated to mode-aware quota text.

All rollback call sites now pass reservation._prior for exact restoration.

26/26 hold-ball tests pass (10 original + 16 mode-aware incl. 4 rollback/
concurrency tests). Biome clean, TS build clean.

[布偶猫/claude-opus-4-6🐾]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Addresses sol R2 re-review findings on PR #83:

P1a getAll outside boundary: dynamicTaskStore.getAll() was called after
tryReserveHold(). If it threw (e.g. SQLite error), the reservation leaked.
Fix: move getAll() + filter before reservation — it's read-only and doesn't
need the reservation to be held. New test: getAll-failure-rollback.

P1b remove() in catch can prevent release: In the catch block,
dynamicTaskStore.remove() ran before releaseHoldReservation(). If remove()
threw, the release never ran. Fix: wrap remove() in its own try/catch
(best-effort cleanup). New test: insert+remove double-failure still
releases reservation.

P3 unused variable: removed unused removedIds in insert-failure test.

28/28 hold-ball tests pass, Biome clean, TS build clean.

[布偶猫/claude-opus-4-6🐾]

Co-Authored-By: Claude Opus 4.6 <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