Skip to content

fix(orchestration): dedupe a bridge read until it settles, not for 250 ms - #1101

Merged
Juliusolsson05 merged 2 commits into
mainfrom
fix/bridge-read-dedup
Sep 20, 2026
Merged

Juliusolsson05 merged 2 commits into
mainfrom
fix/bridge-read-dedup

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

Closes #925 (planning finding F12 from #918).

The defect

CachedValue combined the promise with an expiresAt set when the request was created:

this.listAgentsCache.set(key, { expiresAt: now + STATUS_CACHE_TTL_MS, promise })

So a read that took longer than 250 ms was "expired" before it had answered, the opportunistic prune deleted it, and the next identical poll enqueued a duplicate.

That is not an edge case. The bridge serialises every orchestration request behind one in-flight slot with no timer on the queue, and orchestration_wait_agents polls these keys every 250 ms–1 s by design — this cache's own comment describes twenty children polling the same key in the same quarter-second. A read waiting its turn collected one duplicate per poll, each queued behind the last: the cache built to prevent a thundering herd produced one.

It is also invisible from outside: MAX_ACTIVE_RENDERER_REQUESTS = 1 means the duplicates queue rather than fly, so nothing is observable until the first read settles and the queue drains into them.

The fix

Two questions were conflated in one field, and they are now two states of it:

  • Pending (expiresAt === null) — a dedup handle. It has no freshness to lose, so it is never pruned and is always joinable. It leaves the cache on settlement, as a value, or on failure (the existing .catch removes it so the next caller retries rather than joining a rejection).
  • Settled — a value, whose window starts when it arrived.

Republishing on settlement is conditional, which is the half that is easy to get wrong: if a mutation invalidated the key while the read was in flight, the entry is gone and must not come back. That answer describes the world before the change, and a caller polling after the mutation has to reach the renderer.

Tests

Three new cases drive the real bridge over its real queue: a read still in flight across four polling intervals (one renderer request, not four), freshness starting at settlement rather than request time, and a mutation invalidating a read that is still in flight (its settlement must not refill the cache).

They count requests per parent session rather than by the length of the module-global array — the bridge dispatches from a queue, so a request another test enqueued can land after that test reset it. That leak made one of these mutations look dead until the counting was fixed, which is recorded at the helper.

Mutations, all killed:

mutation result
prune pending reads again (the bug) 2 failed
never join a pending entry 2 failed
freshness starts at request time again 2 failed
republish after an invalidation 1 failed
a settled read never becomes a value 2 failed

npx tsc -b clean; src/main + src/mcp 219 files / 1847 tests pass. The one failure, workflows/control.system.test.ts, reproduces identically on unrelated branches off main and is green in CI.

Not covered here: #926 (retaining mutation authority after a response timeout) is the sibling finding and a different mechanism — this PR does not touch the timeout path.

🤖 Generated with Claude Code

Juliusolsson05 and others added 2 commits September 20, 2026 12:35
…0 ms

Closes #925.

`CachedValue` combined the promise with an `expiresAt` set when the request was
CREATED. A read that took longer than 250 ms was therefore "expired" before it
had answered, the opportunistic prune deleted it, and the next identical poll
enqueued a DUPLICATE.

That is not an edge case. The bridge serialises every orchestration request
behind ONE in-flight slot with no timer on the queue, and `wait_agents` polls
these keys every 250 ms to 1 s by design — the cache's own comment describes
twenty children polling the same key in the same quarter-second. So a read
waiting its turn collected one duplicate per poll, each queued behind the last:
the cache built to prevent a thundering herd produced one.

Two questions were conflated in one field, and they are now separate states of
it. A PENDING entry is a dedup handle with no freshness to lose, so it is never
pruned and is always joinable; a SETTLED one is a value, and its window starts
when it arrived.

Republishing on settlement is conditional, which is the half that is easy to
get wrong: if a mutation invalidated the key while the read was in flight, the
entry is gone and must not come back — that answer describes the world before
the change, and a caller polling after the mutation has to reach the renderer.

Tests drive the real bridge over its real queue. They count requests PER PARENT
rather than by the length of the module-global array, because the bridge
dispatches from a queue and a request another test enqueued can land after that
test reset it — a cross-test leak made one of these mutations look dead before
the counting was fixed.

Mutations, all killed: prune pending reads again 2 failed; never join a pending
entry 2; freshness starts at request time again 2; republish after an
invalidation 1; a settled read never becomes a value 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round-one review findings on #1101. The implementation survived every attack;
8 of 19 mutations did not die, and three of them let you silently revert half
the fix.

- The identity-guarded `.catch` delete is now the ONLY thing that removes a
  pending entry, because prune skips `expiresAt === null` by design. Four
  mutations of it survived, and one is not transient: with the delete removed a
  single renderer error poisons that key until the app restarts, where before
  #925 prune healed it in 250 ms. Four tests now pin it — a failed read is
  retried rather than joined, and a late rejection from a superseded generation
  leaves the newer entry alone — on both caches, because the two catch blocks
  are separate code.
- Every readRunOutputs mutation survived: its pending expiry, its join
  condition and its prune guard could each be reverted with the suite green.
  It is also the read that matters most — transcript slices, and one at the end
  of every wait_agents.
- The cap-param key carries an emphatic comment about one caller silently
  receiving another's differently-truncated payload, and had no test.
- #925 asked for the pending-key count to be measured rather than a speedup
  claimed: 500 polls across 20 keys leave 20 entries, not 500.

Also, three things the review was right to call out in prose:

- The `requestsFor` rationale asserted a cross-test leak as fact. The reviewer
  instrumented the mock and could not reproduce it — no test in this file ends
  with a queued request. Rewritten as what it is: a guard against a reachable
  shape.
- `startFreshnessOnSettle` did not mention that a joined caller inherits the
  original dispatch's 30s deadline. It is better than the 60s it replaces, but
  wait_agents shows that error text to the model.
- `closeRun` takes its tombstone snapshot through the cached read, so the join
  window it can fall into grew from 250ms to the whole flight of an earlier
  read. Narrow, and cheaper than the alternative — now written down.

Nine mutations re-measured individually, all killed: list catch guard dropped,
list catch removed, rro join refuses pending, rro pending expires at creation,
prune kills pending (rro / list), key ignores char caps, rro catch guard
dropped, rro catch removed.

`npx tsc -b` clean on Node 24. src/main + src/mcp, unit + system: 1854 passed,
1 failed — src/main/workflows/control.system.test.ts, untouched by this branch
and failing identically on main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Review round one — addressed in cae83772. You were right that the gap was entirely in the suite, and F1's permanence argument is the finding that mattered: I had read the .catch delete as belt-and-braces next to prune, and it is now the only thing that can remove a pending entry.

F1 — error path load-bearing, permanent, untested. Four tests, two per cache, because the two .catch blocks are separate code and M5b/M5d survive independently of M5/M5c:

  • a failed read is dropped and the next poll re-reads (kills "never deletes")
  • a rejection arriving after a mutation invalidated the key and a second read replaced it leaves the newer entry intact (kills "delete whatever is stored")

F2 — readRunOutputs half untested. Mirrored the in-flight dedup test. M1b, M2b and M3c all die now.

F3 — cap-param keying. Five reads differing only in caps produce four distinct pending entries; a repeat of the first set joins. M6/M6b die.

F7 — #925's measurement. Landed as your probe E: 500 polls across 20 keys → exactly 20 pending entries, one request in flight. That is the number that answers "a pending entry is never pruned", so it belongs in the file rather than in a comment.

F4 — the requestsFor rationale. Wrong as written, and I have rewritten it rather than softened it. Your instrumentation is more evidence than I had: no test in this file ends with a queued request, so the leak cannot happen today. The comment now says it guards a reachable shape, names the 30 s-timeout route that would produce it, and says explicitly that the cross-test leak I cited was in a different file. I left the four positional tests alone — each drives exactly one bridge to completion, so converting them is churn without a behaviour change; the note says that too. The new measurement test is the one that does end with queued requests, so it is pinned last in the file with a comment saying why.

F5 — inherited deadline. Documented on startFreshnessOnSettle, including the t=29 s → fails at t=30 s example and why it beats the t≈60 s it replaces. wait_agents surfacing that text to the model is exactly why it needed writing down.

F6 — closeRun's tombstone snapshot. Documented at the call site: the join window grew from 250 ms to the whole flight of an earlier read, it needs an exact key collision on maxMessagesPerAgent: 100 with no char caps, and the alternative — an uncached read of every child at close — costs more than the seconds it recovers. Noted next to it that closeAgent uses the uncached readAgent deliberately.

The (#925) suffix. Renamed to (#925 guard) with a comment saying it passes on main by design, guards the conditional republish this PR introduces, and must not be read as evidence the bug reproduced. Thank you for checking fail-first on all three rather than just the two that fail.

M10. Agreed and not touched — pruneStatusCaches(now) runs immediately before the lookup with the same now binding, so no surviving entry can have expiresAt === now.

Nine mutations re-measured individually against a verified-clean tree (all killed):

Mutation Was Now
list .catch drops the identity guard SURVIVED 1/15 fail
list .catch never deletes SURVIVED 1/15 fail
rro join refuses a pending entry SURVIVED 1/15 fail
rro pending expires at creation+TTL SURVIVED 1/15 fail
prune deletes pending (rro) SURVIVED 2/15 fail
prune deletes pending (list) KILLED 4/15 fail
rro key ignores the char caps SURVIVED 1/15 fail
rro .catch drops the identity guard SURVIVED 1/15 fail
rro .catch never deletes SURVIVED 1/15 fail

npx tsc -b clean on Node 24. src/main + src/mcp, unit + system: 1854 passed, 1 failed — src/main/workflows/control.system.test.ts, which this branch does not touch (git diff origin/main --name-only has no workflows file) and which fails identically without the PR. Same file, same 5 s timeout you saw.

🤖 Generated with Claude Code

@Juliusolsson05
Juliusolsson05 merged commit 4638aaa into main Sep 20, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the fix/bridge-read-dedup branch September 20, 2026 22:58
Juliusolsson05 added a commit that referenced this pull request Sep 20, 2026
…erge

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.

bug(orchestration): deduplicate pending reads beyond freshness TTL

1 participant