Supersedes plan-flakiness.md (whose Step 1, event-driven expect, landed
in commit 3122d91) and the earlier draft of this file. This version is
grounded in a full reproduction + bisect done against HEAD (c0541ed).
hy3's plan-frame-split.md correctly identified that frame splitting is
the remaining source of flakiness and that the child already emits the
signals needed to split deterministically. Two claims were wrong:
-
"Frame count flickers" / "over-splits or under-splits." Measured: frame count is stable across pass/fail runs.
harness_commandsproduces exactly 35 frames whether it passes or fails;simpleproduces 6 every time. The flakiness is boundary placement drift, not count drift. The same logical renders get grouped into frames at different seams depending on poll timing, so a given render's bytes land in frame N on one run and frame N+1 on the next.meaningfulFrameTextcompresses adjacent identical frames, but when content moves between frames the compressed sequences differ and the golden comparison fails. -
"This is purely a splitting defect." It is not. Two of the consistent failures (
simple,multiline) are stale golden fixtures caused by a real rendering change, not splitting noise. See "Phase 0" below.
hy3's core mechanism — split on the child's render-burst signal, not on a
settle timer — is correct and is the heart of this plan. The improvement is
to key the split on the sync protocol the child already emits
(SyncBegin/SyncEnd = \x1b[?2026h/\x1b[?2026l), which is a stronger
signal than the "zero-wait poll" heuristic hy3 proposed (a single child
flushFile burst can still arrive as several read() returns under load, so
"poll returned no POLLIN" does not reliably mean the writer stopped).
Deterministic (fail every run) — stale fixtures:
simple one-turn prompt and replymultiline prompt and queued multiline autosend
Flaky (fail intermittently) — frame-boundary drift:
harness commands are transcript itemsbash tool success and nonzero exitconsecutive turns never accumulate extra blank separator linesinitial prompt with -i stays in the interactive REPLidle ctrl-c clears typed line without eating the row above
The deterministic pair must be fixed first or they mask progress on the flaky set.
Bisect: simple/multiline passed at f9c75d0 (where the fixtures were
regenerated) and first failed at 2bb7199 ("Merge branch 'polish'"). That
merge introduced a rendering change — the blank separator row between the
"type a prompt" hint and the prompt echo disappeared (prompt echo now paints
one row higher), and when the turn starts the separator inserts and content
scrolls, dropping "type a prompt" from later frames. The fixtures were never
regenerated after the merge.
Concretely, at f9c75d0 frame 0001 had changed=@[1,2,3,5,6,8,10] with a
blank row 9 between "type a prompt" (row 8) and the echo (row 10). At HEAD
frame 0001 has changed=@[1,2,3,5,6,8,9] — echo on row 9, no blank.
This is a real rendering behavior change, not a bug to revert. Action:
regenerate testdata/fixtures/tty/simple.txt and multiline.txt by
running the tests with the current binary and promoting the
*_actual.txt outputs, after confirming (by eye) the new layout is
correct. Do NOT regenerate the flaky tests' fixtures yet — those must
stabilize via Phase 1 first, or we'd be baking in a particular frame
partition that drifts again.
Note: b7902e3 ("strip leading/trailing blank lines from assistant
content") and 6c73037 ("new line fix") are later rendering changes on the
same theme; eyeball the regenerated fixtures against the design doc's visual
contract (.agents/design.md) before committing.
Every render burst on the main path is wrapped in exactly one
SyncBegin..SyncEnd pair followed by flushFile (engine.nim
renderFooter/renderToolViewport/appendTranscript; terminal.nim
syncWrite/beginEditorRedraw..finishEditorRedraw). The harness already
parses these via noteSyncState and tracks syncDepth, and flushFrame is
already gated on syncDepth == 0. The fix is to make flushFrame fire
exactly when syncDepth transitions back to 0 (a SyncEnd was parsed),
rather than only when a poll happens to return no bytes.
This makes each frame = one child render burst, deterministically. The boundary signal comes from the child's own output stream, so it is load-independent. This is the content-based split hy3 sought, implemented on the protocol that already exists.
Changes (all in tests/tty_expect.nim):
noteSyncState: when it decrementssyncDepthto 0, callflushFrame()(commit the just-completed burst as a frame). KeepmarkFrameDirtyon byte arrival so the frame captures the burst's net effect.flushFrame: keep thesyncDepth > 0guard (don't flush mid-burst) and thependingFrameguard (don't double-commit). The new call site fromnoteSyncStateis the primary commit path.pollOnce: the idle-pathflushFrame(when poll returns no bytes) becomes a fallback for non-sync writes (writeRaw,enterPromptInput, ui.nim wizard writes) that have no SyncEnd. Keep it, but it is no longer the pacing mechanism for the main path.
emitTestFrameEvent (called from turns.nim at streaming/tool boundaries
and config.nim at the experimental-gate refusal) writes one byte to the
frame-event pipe and blocks on an ack. pollOnce already drains the PTY,
flushes a frame, and acks on event arrival. Keep this. With Phase 1's
SyncEnd split, the frame event becomes a redundant-but-harmless hard
boundary: the burst that preceded it already committed via its SyncEnd, and
the event ensures a frame even for boundaries with no visible sync write.
Gap to audit: emitTestFrameEvent has only 5 call sites (all in turns.nim
streaming/tool paths + config gate). The prompt-echo commit
(commitTranscriptBytes), the final assistant content commit, and the idle
prompt repaint do NOT emit frame events. With SyncEnd splitting these are
covered (they're sync-wrapped), so no new emits are strictly required. But
if a specific flaky test still drifts after Phase 1, the cause is a missing
sync wrap on that path — add a syncWrite/SyncBegin..SyncEnd there, not a
new frame event.
drain(settleMs) is called all over test bodies as a "let it settle" pause.
Repurpose it to mean "capture all frames until the child is quiet" with no
wall-clock floor:
- Loop
pollOnce(0)while bytes/events keep arriving. Stop when a poll returns nothing. - Keep a hard cap (2s) purely as a dead-child safety net, not pacing.
- Drop the
sleep 1from the settle loop.
This removes the timing that causes boundary drift. The frame stream becomes a function of the child's output, identical run to run.
plan-flakiness.md Step 1 converted most expect* to waitForOutput, but
two still use sleep 5 busy-loops:
expectNo(tty_expect.nim:774,777)expectTokenBar(tty_expect.nim:961,970)expectAlive(tty_expect.nim:833) usesdrain(5)
Convert these to the waitForOutput-then-check pattern so they don't
starve under load. expectNo is trickier (it asserts absence over a window)
— keep its deadline but drive the polls via waitForOutput not sleep.
- All
expect*assertion signatures and call sites in test bodies. expectMeaningfulFrameArtifact,meaningfulFrameText, all normalizers (normalizeElapsed,normalizeSpinnerPhases,normalizeVersionBanner,normalizeTtyRunRoots,normalizeWrappedPathTail,stripFrameBlanks,normalizeFrameSeparators).TtyFrame.ms(diagnostics only; no assertion reads it — verified).- The
advanceTicker/ ticker-pipe deterministic spinner drive. src/production code (no new emits unless Phase 1 surfaces a gap).
- After Phase 0:
simpleandmultilinepass 5/5. - After Phase 1: run each previously-flaky subtest 20x in isolation +
20x under full
testament allparallel load: 0 failures. - Diff
meaningful_frames.txtforharness_commandsacross 5 runs: byte-identical (proving the split is now deterministic). - Full
testament --megatest:off all5x: 0 failures, no exit-code flips.
Current state: testament all runs 6 categories (stream, core, api, config,
shell, tty) as parallel execProcesses children; within a category, tests
run sequentially. Locally ~5min wall; CI Linux amd64 ~4m13s. The tty
category is the long pole (~44s for test_tty_functional.nim alone, plus
12 other tty files sequentially).
The "under 1 minute" goal is not achievable for the whole suite on a 2-core GitHub runner via testament alone — compilation of 6 category processes dominates. But once flakiness is killed:
- Tests stop timing out/retrying, removing the largest source of variance.
- The tty category can be split into 2-3 sub-categories (e.g.
tty_visualvstty_regression) so testament parallelizes them. This is the highest- leverage timing win:test_tty_functional.nimis 44s; splitting it roughly halves the tty long pole. Do this ONLY after Phase 1 lands and is stable — splitting flaky tests just multiplies the flake surface. - Consider
--batchsharding if further reduction is needed later.
There is also a separate CI masking bug: the "Run tests" step throws an
OSError/nimscript error when testament exits non-zero, but the GitHub job
still reports ✓ (verified on run 28999965132, which had FAILURE! total: 12 passed: 10 failed: 2 yet shows green). This means flaky failures are
silently swallowed in the dev hot path. Investigate separately — likely
nimble's nimscript exec not propagating the sh exit code. Not blocking
this plan but worth a follow-up so green CI actually means green tests.
- Phase 0: regenerate
simple.txt+multiline.txt, verify 5/5 pass. - Phase 1a: implement SyncEnd-driven
flushFrameintty_expect.nim. - Phase 1b: rewrite
drainto be event-driven (no settle timer). - Phase 1c: convert
expectNo/expectTokenBar/expectAlivetowaitForOutput. - Verify per the checklist above.
- (Optional, post-stability) split the
ttycategory for parallelism.