Two Desktop e2e tests each failed once on main today, and once each across the first two attempts of the same PR run. Both are element(s) not found after a send, both are green on rerun, and both have the same cause.
Shared root cause
Every send in the app goes through an unbounded asynchronous admission chain before anything renders:
app-shell-chat-actions.ts:424 if (!initialSessionId && !initialNewTaskTarget) return false; // silent false
app-shell-chat-actions.ts:425 if (!(await checkTaskSubmissionReadiness())) return false; // async IPC probe, silent false
app-shell-chat-actions.ts:499 await activateSessionForFirstSend(session.id); // first-send observation barrier
activateSessionForFirstSend (app-shell.tsx:1745-1766) waits for completeObservationSeed, bounded by FIRST_SEND_OBSERVATION_TIMEOUT_MS = 30_000 (app-shell.tsx:244). Upstream of all of it, sendCurrent (packages/ui/src/composer.tsx:1185-1196) returns early on props.disabled || props.sendBlocked || sendPendingRef.current — silently, with no queue, no retry and no toast, so an Enter that lands in a blocked window is simply lost.
Meanwhile e2e/playwright.config.ts:52-54 sets expect: { timeout: 10_000 } with retries: 0.
A 10s assertion is watching a production path the app itself allows 30s for, and neither test fences the gap: both wait on an intermediate signal (a cleared draft, an updated label) that does not mean admission is ready.
The margin was there until concurrency changed. #4523 (merge commit 9859c68e, 2026-09-02 07:08 UTC) moved Desktop e2e from 1 worker to 4 workers plus four Xvfb displays on one runner, cutting each worker's CPU share to a quarter. It touched neither spec.
Over the last 150 main CI runs (2026-08-30 → 09-02), counting only runs that actually executed the Desktop e2e step:
| Window |
Runs with e2e |
e2e failures |
| before #4523 merged |
48 |
0 |
| after |
10 |
2 |
Small sample — #4523 had been on main seven hours — so the direction is credible and the 20% is not.
1. streaming-remount.spec.ts:161
Symptom
apps/desktop/e2e/streaming-remount.spec.ts:133 — keeps a completed reply after an interrupted turn and conversation remount — fails at line 161:
Error: expect(locator).toContainText(expected) failed
Locator: locator('.maka-bubble-streaming')
Expected substring: "Fake backend waiting"
Timeout: 10000ms
Error: element(s) not found
100 passed / 1 failed / 4 skipped.
Occurrences
Attempt 2 of that run failed on transcript-scroll.spec.ts:316 instead; attempt 3 was green. Same code, three different outcomes.
Root cause
Not a product race — a missing fence.
155: await sidebar.getByRole('button', { name: '新任务', exact: true }).click();
156: await expect(composer).toHaveText('');
159: await composer.fill(FAKE_HOLD_OPEN_PROMPT);
160: await composer.press('Enter');
161: await expect(page.locator('.maka-bubble-streaming')).toContainText('Fake backend waiting');
toHaveText('') proves the draft key swapped. It does not prove the shell will accept a submission. Clicking 新任务 clears activeId, which re-projects useShellConnections({ kind: 'new-task' }) (app-shell.tsx:575-607) and restarts the useTaskSubmissionReadiness probe (app-shell.tsx:1513-1519). An Enter landing in that window is either dropped by sendCurrent or blocks on the 30s first-send barrier.
The same test already knows this. Fifteen lines later, at :177, it fences the identical fill+Enter with
await expect(page.getByRole('button', { name: '发送' })).toBeEnabled({ timeout: 20_000 });
and every other live-Turn assertion in the test carries an explicit timeout: 20_000. Line 161 is the only bare one.
This is why #4018 (e0be705c0, which closed #3177) did not settle it: that PR replaced the old race with the bounded activateSessionForFirstSend barrier and relaxed the expected substring, but never widened the window observing it. The barrier's 30s timeout and its recovery path — discard the unsent Session, keep the draft — can never run before a 10s assertion fires.
Fix
- Fence line 160 the way line 177 already is:
await expect(page.getByRole('button', { name: '发送' })).toBeEnabled({ timeout: 20_000 });. sendDisabled (composer.tsx:1379-1385) folds in both sendBlocked and noModelConnection, so this single wait proves the readiness probe and the connections projection have settled.
- Give line 161 an explicit
{ timeout: 20_000 }, matching the rest of the test and leaving an order of magnitude under the product's 30s barrier.
- Apply the same to line 60 in the first test of the file, which has the identical shape.
Reproduces only under load: 20/20 green locally on macOS at 1 worker (--repeat-each 5), this test taking 3.4–3.8s end to end. CI needed more than 10s for a single assertion inside it.
2. transcript-scroll.spec.ts:316
Symptom
apps/desktop/e2e/transcript-scroll.spec.ts:251 — switching Sessions restores a Turn anchor while a tail Session follows background growth — fails at line 316:
Error: expect(locator).toBeVisible() failed
Locator: locator('.maka-user-message').filter({ hasText: '第 1 行' })
Timeout: 10000ms
Error: element(s) not found
Occurrences
This test itself only landed today, in #4414 (1581dc1c4), so the clean pre-#4523 window partly reflects its absence.
Root cause
A missing fence, plus a fixture readiness gap.
sendPrompt (transcript-scroll.spec.ts:196-200) is a bare fill + Enter with no gate:
async function sendPrompt(page: Page, text: string): Promise<void> {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(text);
await composer.press('Enter');
}
Two things that restart asynchronous send admission happen immediately before the call at line 315:
294: await rowButton(tailSessionId).click(); // switches Session
300: await modelSwitcher.click();
301: await page.getByRole('menuitemradio', { name: 'glm-4.5' }).click();
302: await expect(modelSwitcher).toContainText('glm-4.5'); // waits on the label only
315: await sendPrompt(page, LONG_PROMPT);
316: await expect(page.locator('.maka-user-message', { hasText: '第 1 行' })).toBeVisible();
Line 302 waits on the switcher's local label, not on the Session committing the model and connection identity. The test's own comment concedes the step has a side effect: "The fixture Session predates connection identities. Choosing any current model upgrades it onto the E2E Runtime Host before this test starts a Turn." That upgrade is asynchronous and nothing waits for it.
Compounding it, promptRailWindow is the only fixture in the suite that does not wait for the composer. fixtures.ts:676-682 uses seed: false and readinessSelector: '[data-turn-id]', while every other fixture uses readinessSelector: COMPOSER_INPUT — which fixtures.ts:468 documents as the cold-start convergence point (connection seed, onboarding cleared, renderer hydrated). #4523 then added warm worker-scoped reuse with a page.reload(), and the post-reload gate (fixtures.ts:522) is again only [data-turn-id]: the transcript paints well before the connections projection is ready.
So the Enter at line 315 can be dropped silently by sendCurrent (composer.tsx:1188), or stall in checkTaskSubmissionReadiness() (app-shell.tsx:1880-1885, an unbounded IPC probe). Either way nothing renders.
.maka-user-message is rendered optimistically (packages/ui/src/chat-turn.tsx:279, TransientUserMessage), so if the send had landed at all the element would appear almost immediately. Its total absence means the send never happened — not that rendering was slow. Which of the two mechanisms fired cannot be distinguished without a trace, but both take the same fence.
Fix
- Gate
sendPrompt before the keypress: await expect(page.getByRole('button', { name: '发送' })).toBeEnabled({ timeout: 20_000 });. Give line 316 an explicit { timeout: 20_000 }.
resetPromptRailWindow (fixtures.ts:508-524) should also wait for COMPOSER_INPUT after page.reload(). The fixture comment still says "This scenario is read-only at the Host boundary", but transcript-scroll.spec.ts:251 now changes the model and runs a full Turn through it.
Does not reproduce locally: 5/5 green on macOS at 1 worker, and 4/4 green in a 42-test combined run sharing the warm worker with prompt-rail.spec.ts and fixture-thread-search.spec.ts. The test takes 10.5–11.0s end to end on an idle machine — the whole test already sits at the single-assertion budget.
Also in this pass
-
Fixture readiness gate. Both promptRailWindow (fixtures.ts:676-682, and the post-reload gate at fixtures.ts:522) and promptRailMotionWindow (fixtures.ts:717-725) gate on [data-turn-id] rather than COMPOSER_INPUT. This is reproducible locally on the motion fixture: prompt-rail.spec.ts:213 failed 1 of 2 local repeats with Expected: "smooth" / Received: undefined at line 228 — the transcript paints before data-maka-scroll-motion reaches documentElement. Same class of gap; worth fixing together.
-
CI should upload apps/desktop/e2e/test-results/ on failure. e2e/playwright.config.ts sets trace: 'retain-on-failure', plus video and screenshot, but .github/workflows/ci.yml has no upload-artifact step, so the directory is discarded with the runner. gh api .../artifacts returns empty for all three runs above. This class of flake is currently undiagnosable after the fact — which is why the two mechanisms in section 2 cannot be separated.
-
sendCurrent drops a submission silently (packages/ui/src/composer.tsx:1185-1196) — no queue, no retry, no feedback. User-visible in miniature: type and press Enter right after switching a conversation or a model, and the message can vanish with no indication. Pre-existing and independent of this flake; P3, worth its own issue.
Severity: P2 — does not block release, but each false red costs a 6-minute e2e rerun and masks real regressions.
Two Desktop e2e tests each failed once on main today, and once each across the first two attempts of the same PR run. Both are
element(s) not foundafter a send, both are green on rerun, and both have the same cause.Shared root cause
Every send in the app goes through an unbounded asynchronous admission chain before anything renders:
activateSessionForFirstSend(app-shell.tsx:1745-1766) waits forcompleteObservationSeed, bounded byFIRST_SEND_OBSERVATION_TIMEOUT_MS = 30_000(app-shell.tsx:244). Upstream of all of it,sendCurrent(packages/ui/src/composer.tsx:1185-1196) returns early onprops.disabled || props.sendBlocked || sendPendingRef.current— silently, with no queue, no retry and no toast, so an Enter that lands in a blocked window is simply lost.Meanwhile
e2e/playwright.config.ts:52-54setsexpect: { timeout: 10_000 }withretries: 0.A 10s assertion is watching a production path the app itself allows 30s for, and neither test fences the gap: both wait on an intermediate signal (a cleared draft, an updated label) that does not mean admission is ready.
The margin was there until concurrency changed. #4523 (merge commit
9859c68e, 2026-09-02 07:08 UTC) moved Desktop e2e from 1 worker to 4 workers plus four Xvfb displays on one runner, cutting each worker's CPU share to a quarter. It touched neither spec.Over the last 150 main CI runs (2026-08-30 → 09-02), counting only runs that actually executed the Desktop e2e step:
Small sample — #4523 had been on main seven hours — so the direction is credible and the 20% is not.
1.
streaming-remount.spec.ts:161Symptom
apps/desktop/e2e/streaming-remount.spec.ts:133— keeps a completed reply after an interrupted turn and conversation remount — fails at line 161:100 passed / 1 failed / 4 skipped.
Occurrences
1581dc1c41(main)Attempt 2 of that run failed on
transcript-scroll.spec.ts:316instead; attempt 3 was green. Same code, three different outcomes.Root cause
Not a product race — a missing fence.
toHaveText('')proves the draft key swapped. It does not prove the shell will accept a submission. Clicking 新任务 clearsactiveId, which re-projectsuseShellConnections({ kind: 'new-task' })(app-shell.tsx:575-607) and restarts theuseTaskSubmissionReadinessprobe (app-shell.tsx:1513-1519). An Enter landing in that window is either dropped bysendCurrentor blocks on the 30s first-send barrier.The same test already knows this. Fifteen lines later, at
:177, it fences the identical fill+Enter withand every other live-Turn assertion in the test carries an explicit
timeout: 20_000. Line 161 is the only bare one.This is why #4018 (
e0be705c0, which closed #3177) did not settle it: that PR replaced the old race with the boundedactivateSessionForFirstSendbarrier and relaxed the expected substring, but never widened the window observing it. The barrier's 30s timeout and its recovery path — discard the unsent Session, keep the draft — can never run before a 10s assertion fires.Fix
await expect(page.getByRole('button', { name: '发送' })).toBeEnabled({ timeout: 20_000 });.sendDisabled(composer.tsx:1379-1385) folds in bothsendBlockedandnoModelConnection, so this single wait proves the readiness probe and the connections projection have settled.{ timeout: 20_000 }, matching the rest of the test and leaving an order of magnitude under the product's 30s barrier.Reproduces only under load: 20/20 green locally on macOS at 1 worker (
--repeat-each 5), this test taking 3.4–3.8s end to end. CI needed more than 10s for a single assertion inside it.2.
transcript-scroll.spec.ts:316Symptom
apps/desktop/e2e/transcript-scroll.spec.ts:251— switching Sessions restores a Turn anchor while a tail Session follows background growth — fails at line 316:Occurrences
ed4468e597(main)This test itself only landed today, in #4414 (
1581dc1c4), so the clean pre-#4523 window partly reflects its absence.Root cause
A missing fence, plus a fixture readiness gap.
sendPrompt(transcript-scroll.spec.ts:196-200) is a bare fill + Enter with no gate:Two things that restart asynchronous send admission happen immediately before the call at line 315:
Line 302 waits on the switcher's local label, not on the Session committing the model and connection identity. The test's own comment concedes the step has a side effect: "The fixture Session predates connection identities. Choosing any current model upgrades it onto the E2E Runtime Host before this test starts a Turn." That upgrade is asynchronous and nothing waits for it.
Compounding it,
promptRailWindowis the only fixture in the suite that does not wait for the composer.fixtures.ts:676-682usesseed: falseandreadinessSelector: '[data-turn-id]', while every other fixture usesreadinessSelector: COMPOSER_INPUT— whichfixtures.ts:468documents as the cold-start convergence point (connection seed, onboarding cleared, renderer hydrated). #4523 then added warm worker-scoped reuse with apage.reload(), and the post-reload gate (fixtures.ts:522) is again only[data-turn-id]: the transcript paints well before the connections projection is ready.So the Enter at line 315 can be dropped silently by
sendCurrent(composer.tsx:1188), or stall incheckTaskSubmissionReadiness()(app-shell.tsx:1880-1885, an unbounded IPC probe). Either way nothing renders..maka-user-messageis rendered optimistically (packages/ui/src/chat-turn.tsx:279,TransientUserMessage), so if the send had landed at all the element would appear almost immediately. Its total absence means the send never happened — not that rendering was slow. Which of the two mechanisms fired cannot be distinguished without a trace, but both take the same fence.Fix
sendPromptbefore the keypress:await expect(page.getByRole('button', { name: '发送' })).toBeEnabled({ timeout: 20_000 });. Give line 316 an explicit{ timeout: 20_000 }.resetPromptRailWindow(fixtures.ts:508-524) should also wait forCOMPOSER_INPUTafterpage.reload(). The fixture comment still says "This scenario is read-only at the Host boundary", buttranscript-scroll.spec.ts:251now changes the model and runs a full Turn through it.Does not reproduce locally: 5/5 green on macOS at 1 worker, and 4/4 green in a 42-test combined run sharing the warm worker with
prompt-rail.spec.tsandfixture-thread-search.spec.ts. The test takes 10.5–11.0s end to end on an idle machine — the whole test already sits at the single-assertion budget.Also in this pass
Fixture readiness gate. Both
promptRailWindow(fixtures.ts:676-682, and the post-reload gate atfixtures.ts:522) andpromptRailMotionWindow(fixtures.ts:717-725) gate on[data-turn-id]rather thanCOMPOSER_INPUT. This is reproducible locally on the motion fixture:prompt-rail.spec.ts:213failed 1 of 2 local repeats withExpected: "smooth" / Received: undefinedat line 228 — the transcript paints beforedata-maka-scroll-motionreachesdocumentElement. Same class of gap; worth fixing together.CI should upload
apps/desktop/e2e/test-results/on failure.e2e/playwright.config.tssetstrace: 'retain-on-failure', plus video and screenshot, but.github/workflows/ci.ymlhas noupload-artifactstep, so the directory is discarded with the runner.gh api .../artifactsreturns empty for all three runs above. This class of flake is currently undiagnosable after the fact — which is why the two mechanisms in section 2 cannot be separated.sendCurrentdrops a submission silently (packages/ui/src/composer.tsx:1185-1196) — no queue, no retry, no feedback. User-visible in miniature: type and press Enter right after switching a conversation or a model, and the message can vanish with no indication. Pre-existing and independent of this flake; P3, worth its own issue.Severity: P2 — does not block release, but each false red costs a 6-minute e2e rerun and masks real regressions.