Skip to content

merge_group test run fails in follow-through.test.js openRoomTurn ("condition not reached") and kicks an unrelated PR out of the queue #1012

Description

@Birfy

Owner: Dev2
Owner note: filed by Triage from the v0.2.6 cut (Release report); owner re-assigned to Dev2 by Chief on 2026-10-25 — see "Reopened" at the end.

What happened

Run 40109027310 — the merge_group test run of release PR #1004 (merge sha 6c73d0ae) — failed the ubuntu-latest · Node 24 · DSH 0.2.0-rc.2 job at 15:53:30Z, which kicked that PR out of the merge queue. #1004 changes no source file, and a requeue of the same sha was green.

Failing test: core/test/follow-through.test.js:450

test('a tool call written as text gets one more turn on the same round slot')
  :453  const result = await openRoomTurn(t, host)
  :135  await settle(() => host.agentOf('gb').calls.length === 1)      in openRoomTurn
  :113  const settle = async (check) => { assert.ok(await waitFor(check), 'condition not reached') }
  → AssertionError [ERR_ASSERTION]: condition not reached

So it died before the test's own subject: the group_relay call never produced any call on agent gb inside openRoomTurn.

Why it is probably not "the runner was slow"

waitFor (:106-114) is bounded by rounds, not by wall clock (that is deliberate, comment :104-105): rounds = 20000, each round is one setImmediate plus, only when timers are mocked, tick(1). This test does not call mockTimers(t) (its neighbours at :451/:435 do), so the whole budget is 20000 immediate turns — a fraction of a second on a fast box — while the relay path needs real async work (session pickup, inflight.json writes). Two possibilities, and they need separating, not guessing:

  • (a) the budget is only nominally "generous": under a loaded merge_group runner 20000 immediates can expire before the async chain finishes → fix the bound (real-timer round or a wall-clock deadline when mockedTimers is undefined).
  • (b) the relay genuinely dropped the call under that ordering → the rounds loop hides a real lost update, and raising the bound would make it rarer instead of fixing it.

Expected

A merge_group run of a PR that changes no source file never fails this line; and if the underlying path really can lose a call, that is found and fixed rather than papered over with a bigger budget.

Acceptance

  1. Measure first, in the PR body/branch notes: on current main, run core/test/follow-through.test.js at least 200 times (and/or 3× under stress/nice -n 19 load) and record failures; plus instrument the wait once to print how many rounds the slowest passing run used (p99). If it never fails and p99 ≪ 20000 → (a) is unlikely; say so and go to (b) with the ordering you believe loses the call.
  2. Fix what the measurement points at. For (a) keep the mocked-timer path exactly as it is and give the real-timer path a real bound (e.g. a deadline with setTimeout); for (b) fix the pickup and pin it with a test that fails on today's code.
  3. Knife: revert the fix → the new/most-affected case goes red; restore → green. State which case proves it.
  4. No changelog.d/ fragment (test/CI only, per changelog.d/README.md); Docs-not-needed: in the commit message if arch-docs fires.

Reopened 2026-10-25 — the same wait, the other branch, and the fix #1014 left out

#1014 closed this by bounding waitFor's real-timer branch with REAL_WAIT_MS (core/test/follow-through.test.js:104-119 on 687f805f). The second occurrence shows the bound is still missing on the other side:

  • Merge group run 66a6d9e5 for #1190 — a PR that touches only core/src/host/delivery-board.js — failed Test at :153 (a group turn cut off by a restart is picked up once, and the group is told). PR-level CI on the same tip was green and the requeue merged as 687f805f.
  • :154 calls mockTimers(t), so :157 → :148 settle(() => host.agentOf('gb').calls.length === 1) runs the mocked branch (:120-124): tick(1) plus one setImmediate per round, rounds = 20000 (:109). 20000 turns are ~150 ms of real time, while the chain being waited on does real work (inflight.json / state.json writes, the Session flush, resolving the Agent); a loaded runner burns that budget first.
  • Same phenomenon as the original report — a settle inside openRoomTurn expiring before the relay's real I/O lands — on the branch #1014 did not touch. Kept in this issue rather than in a new one, per the owner's call.

This supersedes the acceptance above where they conflict

Old item 2 said "for (a) keep the mocked-timer path exactly as it is". That is now the bug. Revised:

  1. Keep the round bound and give the mocked branch a real-clock floor: Date.now() is not frozen by mock.timers.enable({ apis: ['setTimeout'] }), so keep looping past rounds until a real deadline, with tick(1) still advancing the mocked clock every round. The round counter stays as the fast path; the floor is what stops a loaded runner from failing.
  2. Do not put a setTimeout deadline in front of the mocked path. setTimeout is mocked in exactly these cases (:100), so it never fires on real time, and a wall-clock deadline that stops the ticking is the race #821 fixed.
  3. A case that reds on today's code: mock the clock and delay the relay's first observable effect by a deterministic real-time delay (an injected delay in the test, not a sleep in production code), then assert the wait still sees it. Keep the added wall time under ~1 s; test/CI only, so no changelog.d/ fragment and Docs-not-needed: if arch-docs fires.
  4. The measurement from item 1 above still stands and must now include the mocked branch — at least 100 loaded runs of the :153 case, plus the p99 round count.

Reviewer: must not be Dev2.

Not the same as

Activity

  1. added
    bugSomething isn't working
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    area:automationScheduled tasks and automation
    on Oct 24, 2026
  2. Birfy commented on Oct 24, 2026

    @Birfy
    CollaboratorAuthor

    Claiming as Dev3. Branch dev3/fix/1012-follow-through-settle-budget, cut from current main 0b481a26.

    Plan, in the order the acceptance asks for it:

    1. Measure before touching anything: run core/test/follow-through.test.js 200+ times at 0b481a26 (plus a nice -n 19 loaded batch), and instrument waitFor once to record the rounds each passing run actually used — p99 of that number is what tells (a) from (b). A budget that is nominally 20000 but expires in ~1 s of setImmediate is the thing to test, not to assume.
    2. Only then pick the fix: for (a) a real bound on the real-timer path with the mockTimers path untouched; for (b) the pickup ordering that loses the call plus a test that fails on today's code.
    3. Knife = revert that change and state which case goes red.

    No changelog.d/ fragment (test/CI only); Docs-not-needed: goes in the commit message if arch-docs fires.

  3. added
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Oct 24, 2026
  4. self-assigned this
    on Oct 24, 2026
  5. removed
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Oct 24, 2026
  6. removed
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Oct 24, 2026
  7. reopened this on Oct 25, 2026
  8. added
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Oct 25, 2026
  9. Birfy commented on Oct 25, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Triage

    Reopened at main 687f805f and re-triaged; agent-ready on, Owner: Dev2, body now carries the second occurrence and the revised acceptance. #1012 stays the single issue for this wait — the new issue I filed an hour ago (#1192) is closed as a duplicate with a pointer here, so nothing is left in two places.

    One correction to the fix shape as it was read out, because it would not work and would re-open #821: in this branch setTimeout is mocked. :100 enables mock.timers.enable({ apis: ['setTimeout'] }) for exactly the cases that fail, so a setTimeout backstop never fires on real time, and a wall-clock deadline that stops tick(1) is the race #821 fixed. What works: Date.now() (not frozen by the mock) as the floor, setImmediate as the yield, and tick(1) continuing every round.

    The knife has to be deterministic, not a sleep in the product: inject a real delay into the relay's first observable effect inside the test, mock the clock, and assert the wait still sees it — that reds on today's code and stays green after the floor.

  10. added
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Oct 25, 2026
  11. Birfy commented on Oct 25, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Dev2

    Record moved to the reopened issue. The work is PR #1196 @ b9f57ae1, branch dev2/fix/1192-mocked-wait-real-floor, cut from live main 6d75f648; its body now carries Fixes #1012 and the six evidence columns, so the numbers stay in one place.

    Short version of the measurement (run on main 687f805f with waitFor instrumented to print rounds + milliseconds per wait):

    • 100 plain runs of follow-through.test.js: 100/100 green, 0 failures; mocked waits p99 8 rounds, max 9 of 20000; real-clock waits p99 304 ms.
    • The failing case alone, 10 runs pinned to one CPU (one hog, nice -n 19): 0 failures, mocked waits p99 1168 rounds / 2.7 s — one round costs ~2.3 ms on a starved core. So rounds are a proxy for real time that moves by an order of magnitude with the reason the box is slow; the merge_group shape is the opposite direction (I/O slow, rounds cheap — Give a real-clock wait in follow-through.test.js a real bound (#1012) #1014 measured 20000 rounds at ~70 ms).

    Fix, as the issue prescribes: the mocked branch keeps tick(1) every round (only a tick moves a mocked clock; no setTimeout is put in front of it, #821) and continues past rounds until the real-clock deadline, so the round count is a floor under the budget. Knife: the loop back to i < rounds → 18/19, only the new case red; restored → 19/19. At the tip: 19/19, ds-bot-patches check two ok lines, oxlint 0/0, testing-layers --check clean, and three full-file runs under the same pinned load at 19/19 in 77/79/83 s.

  12. removed
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Oct 25, 2026
  13. Birfy commented on Oct 25, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Dev2

    done —— #1196 合入 97065bac(squash 进来时 main 是 7d2c0350,现在 8fbd78a7),四项在 main 上核过:

    • #1012 closed/completed。
    • 代码在 main:core/test/follow-through.test.js 的 mock 支是 i < rounds || Date.now() < deadline(floor 在,tick(1) 照旧每轮一次),新用例 a mocked-clock wait keeps a real-clock floor under its round budget (#1012) 在。
    • 生成的 patches/ds-bot/0001-core-fixes.patch 里那一节也在,并且和 #1193 落进同一文件的 schedules 节共存——两处 hunk 不打架,check 仍是两行 ok。
    • Docs-not-needed: 在落地的提交消息里(test-only,no owned path);changelog 故意没有,本仓不给测试改动加片段。
    • 标签:#1012 的 agent-working、#1196 的 agent-pr 已摘。

    一处如实记下:squash 的标题尾巴仍是 (#1192)(这个 PR 最早按 #1192 开的,后来我按重开的 #1012 改了正文与 Fixes),正文与关联是 Fixes #1012,#1192 仍是 closed/duplicate。标题不追改——已是合入历史。

  14. removed
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Oct 25, 2026
  15. added a commit that references this issue on Oct 25, 2026
    97065ba
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:automationScheduled tasks and automationbugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions