Skip to content

Commit 9be3036

Browse files
alicodingclaude
andcommitted
fix: e2e CI flake — goal-0017 emit fanout raced canvas-live-sync (Standing #1)
Root cause, confirmed via real CI history (6/6 shard-1 failures, all after goal 0017 merged, zero before) and local reproduction (9/20 clean-canvas repeats failed with zero artificial load): goal 0017 gave every direct-mutation service its own dataevent.Emit call, so a single MCP update_workflow write now fires mill-data-changed TWICE (SnapshotDraft + UpdateWorkflow) plus a third echo from the test's own prior UI-driven CreateWorkflow. None carry payload content, so each handler independently refetches — three fetches racing meant whichever resolved last won unconditionally, letting a stale response beat an already-applied newer one and wrongly show the external-change banner on a clean canvas. Fixed in useCanvasLiveSync.ts with a monotonic request-sequence guard (the standard out-of-order-async-response fix): drop a fetch response once a newer mill-data-changed event has arrived since it was dispatched. Verified: 88 consecutive clean local repeats post-fix vs. 9/20 before it, same build. Also: canvas-live-sync.spec.ts's cleanup hardened into an outer try/finally (both tests) so a future assertion failure can't leave an undeleted workflow / unattended-MCP-writes settings for later tests in the same worker — defense-in-depth for the observed cascade, independent of the root-cause fix. resizable-table.spec.ts's one occurrence (PR #24, drag-handle bounding box) hardened with a condition-based expect.poll wait at the point of use, additive to the suite's existing retries: 1 (goal 0024 precedent, untouched). BACKLOG.md Standing #1 checked off with the full root-cause writeup; SPEC.md's realtime-lock section gets an Update note generalizing the lesson for future mill-data-changed consumers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pkViCNAuZp2vBv2K9AbUh
1 parent 9c52c14 commit 9be3036

5 files changed

Lines changed: 133 additions & 43 deletions

File tree

docs/SPEC.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,24 @@ an implicit `FINAL`.
126126
now round-trip through `localStorage` (`shared/store.ts`'s zustand
127127
`persist`, `shared/workTabs.ts`'s pure restore helpers) so a reload
128128
— deliberate or not — restores the same place instead of resetting
129-
to Home.
129+
to Home. **Update (2026-08-12, e2e CI flake investigation): goal
130+
0017's per-service `dataevent.Emit` fanout had a real race.** Giving
131+
every direct-mutation service its own emit call means a single
132+
logical write can now fire the SAME `mill-data-changed` event more
133+
than once for the same entity (an MCP `update_workflow` write emits
134+
from both `SnapshotDraft` and `UpdateWorkflow`; a canvas that just
135+
created its own workflow can still be mid-flight on that emit when it
136+
re-subscribes) — and since the event carries no payload, every
137+
handler independently re-fetches, so several near-simultaneous
138+
fetches can resolve out of order. `useCanvasLiveSync.ts`
139+
(`frontend/src/composition/`) was the one caller sensitive to this
140+
(its clean-vs-dirty decision), fixed with a monotonic per-hook
141+
request-sequence guard that drops a response once a newer event has
142+
arrived since it was dispatched — the general lesson for any FUTURE
143+
`mill-data-changed` consumer that both reacts to the event AND
144+
compares against locally-held state: assume the event can fire more
145+
than once per logical change and can deliver out of order, don't
146+
assume "one event in, one fetch, apply unconditionally" is safe.
130147
- **Scope filter, learned from the screenshot-to-clipboard tangent**: before
131148
any capability goes into Mill, check whether the OS (or an existing
132149
launcher like Alfred/Raycast) already does it simply and well. If yes,

docs/goals/BACKLOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ live-review material, interleaved during owner reviews, not a lane.**
184184
contention flake, both confirmed transient on an isolated rerun).
185185

186186
**Standing — ratified order (owner-delegated prioritization, 2026-08-12: "prioritize all work to line them up"; Dependabot majors pulled to the front same day per the deps-don't-linger policy and are IN FLIGHT as their own sequential wave, not listed here)**
187-
1. [ ] E2e CI flake investigation (owner-directed 2026-08-12: "add to the backlog when problem found so that we prioritize to unblock us"; FIRST in queue because it taxes every subsequent PR with rerun cycles — `canvas-live-sync` alone flaked twice on 2026-08-12) — distinct e2e specs failing once on a shard then green on immediate rerun, across different PRs: `resizable-table.spec.ts` (drag-handle bounding-box), `canvas-live-sync.spec.ts` (MCP `update_workflow` live-redraw assertion, ×2), and (Go side, same class) `TestMillMCPService_RealClientRoundTrip` (already fixed — 2s `Shutdown` timeout too tight for a loaded runner, bumped to 10s, PR #21). Each individually diagnosed unrelated to its PR and confirmed transient by rerun — but the pattern deserves batch investigation: under-resourced shared runners vs a shared timing-sensitivity shape in these specs vs the general suite shape surfacing one spec at a time. DoR: pull actual CI run history for pass/fail/rerun rates over the last N runs before assuming root cause; DoD: a fix (shared wait pattern, strategic retries per goal 0024's e2e-retry precedent) or a documented accept-with-reasoning, never silence.
187+
1. [x] E2e CI flake investigation — RESOLVED 2026-08-12 (`fix/e2e-flake-hardening`). Real CI history (last ~30 `ci.yml` runs) showed `canvas-live-sync.spec.ts` failing 6/6 times on shard 1, every single occurrence at the exact same assertion (`canvas-live-sync.spec.ts:151`, the `external-change-banner` count) and every single occurrence co-occurring with a `configure-lists.spec.ts` "list-search node" flake in the SAME run (recovered on Playwright's own retry every time) — zero occurrences before goal 0017 (PR #16) merged, all 6 after. Root cause: goal 0017 gave every direct-mutation Go service its own `dataevent.Emit("workflow", id)` call, so a single MCP `update_workflow` write now fires the SAME `mill-data-changed` event TWICE (`SnapshotDraft`'s own emit via `mutateWorkflow`, plus `UpdateWorkflow`'s own emit) — plus a THIRD echo from the test's own earlier UI-driven `CreateWorkflow`, still possibly in flight when the canvas mounts. None of the three carry payload content, so each independently re-fetches via `CompositionService.Workflows()`; three fetches racing meant whichever RESOLVED last won unconditionally regardless of dispatch order, so a stale response could occasionally win the live-sync decision against a baseline a different, already-applied response had advanced past — wrongly showing the external-change banner on a genuinely clean canvas. A REAL race, confirmed via local reproduction (9/20 clean-canvas repeats failed with zero artificial load, identical assertion/line to all 6 CI failures) and a temporary event-trace instrument. Fixed in `frontend/src/composition/useCanvasLiveSync.ts`: a monotonic per-hook request-sequence ref, bumped at event ARRIVAL time, drops any fetch response that's gone stale by the time it resolves (the standard out-of-order-async-response guard) — correct regardless of how many redundant emits fire in a burst or their resolution order. Verified: 88 consecutive clean local repeats post-fix (0 failures) vs. 9/20 before it, same build. `canvas-live-sync.spec.ts`'s own cleanup (both tests) hardened into an outer try/finally regardless, so a future assertion failure can never again leave an undeleted workflow / unattended-MCP-writes settings behind for later tests in the same worker. `resizable-table.spec.ts` (1 occurrence, PR #24, drag-handle bounding-box) hardened with a condition-based `expect.poll` wait at the point of use, additive to the suite's existing `retries: 1` (goal 0024's documented precedent, untouched). `TestMillMCPService_RealClientRoundTrip` was already fixed (PR #21). Full local suite green; both suspect specs run 5x locally with zero failures.
188188
2. [ ] [0031 — AI node family](0031-ai-node-family.md) — research banked in the goal file (two adapters: openaicompat covers Ollama+BYO, anthropic native; AIProvider Configure entity; ai-completion + ai-extract-structured first). Effect-class LOCKED by owner delegation 2026-08-12 ("you're the boss"): static `ClassExternal` + `EffectForNode` downgrade to `ClassLocal` for loopback (localhost/127.0.0.1/::1) BaseURLs — remote asks by default, local Ollama frictionless. The flagship capability; conforms to node-standard.md from birth.
189189
3. [ ] Copy-management migration ×4 (below, in order: `app/``composition/``configure/``views/`) then the `eslint-plugin-i18next` revisit — mechanical filler, interleaves between heavier waves when useful.
190190
4. [ ] Workflow pins/favorites (tech debt, split from goal 0015's remainder 2026-08-12) — schema DECIDED at prioritization (orchestrator, 2026-08-12): a plain ordered workflow-ID list, store-owned (localStorage-tier alongside the frecency substrate; no per-workflow field, no new Go surface unless syncing matters later). Pinned rows sort above frecency in Quick Panel/⌘K.

frontend/e2e/canvas-live-sync.spec.ts

Lines changed: 60 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,26 @@ async function restoreMCPWriteDefaults(page: Page): Promise<void> {
125125
}
126126
}
127127

128+
// Closes the open editor tab, deletes the named workflow row, and
129+
// restores the shared MCP-write settings -- shared by both tests below,
130+
// each wrapping this in an outer try/finally so it ALWAYS runs, even
131+
// when an assertion above throws. Before this, both tests only ran
132+
// their cleanup on the happy path: a failed assertion inside the MCP
133+
// round-trip (this file's own live race, fixed in useCanvasLiveSync.ts)
134+
// left "E2E live sync clean"/"dirty" undeleted AND unattended MCP
135+
// writes still enabled for the rest of this worker's shard -- traced
136+
// live as the mechanism behind a real cascade (this spec's own flake
137+
// tripping configure-lists.spec.ts's "list-search node" test in the
138+
// same shard-1 CI run, docs/goals/BACKLOG.md Standing #1). Kept as
139+
// cleanup hardening regardless of the race fix -- any OTHER future
140+
// assertion failure in either test would hit the exact same cascade
141+
// without it.
142+
async function cleanupWorkflow(page: Page, label: string): Promise<void> {
143+
await page.getByRole('button', { name: 'Close tab' }).last().click()
144+
await clickRowAction(page, workflowRow(page, label), 'Delete')
145+
await restoreMCPWriteDefaults(page)
146+
}
147+
128148
test('clean canvas: an external MCP update_workflow redraws the open editor live, no reload', async ({ page }, testInfo) => {
129149
await enableUnattendedMCPWrites(page)
130150

@@ -138,24 +158,24 @@ test('clean canvas: an external MCP update_workflow redraws the open editor live
138158
await row.click()
139159
await expect(activePanel(page).locator('.react-flow__node')).toHaveCount(1)
140160

141-
const client = await connectMCPClient(testInfo.parallelIndex)
142161
try {
143-
const workflowId = await findWorkflowIdByLabel(client, 'E2E live sync clean')
144-
await updateWorkflowViaMCP(client, workflowId, twoNodeDefinition('E2E live sync clean', 'clean-path marker'))
145-
146-
// No page.reload(), no re-navigation -- the redraw has to happen
147-
// purely from the `mill-data-changed` event this canvas subscribed
148-
// to (useCanvasLiveSync.ts).
149-
await expect(activePanel(page).locator('.react-flow__node')).toHaveCount(2, { timeout: 10_000 })
150-
await expect(activePanel(page).locator('.react-flow__node').filter({ hasText: 'Process: Inject text' })).toBeVisible()
151-
await expect(page.getByTestId('external-change-banner')).toHaveCount(0)
162+
const client = await connectMCPClient(testInfo.parallelIndex)
163+
try {
164+
const workflowId = await findWorkflowIdByLabel(client, 'E2E live sync clean')
165+
await updateWorkflowViaMCP(client, workflowId, twoNodeDefinition('E2E live sync clean', 'clean-path marker'))
166+
167+
// No page.reload(), no re-navigation -- the redraw has to happen
168+
// purely from the `mill-data-changed` event this canvas subscribed
169+
// to (useCanvasLiveSync.ts).
170+
await expect(activePanel(page).locator('.react-flow__node')).toHaveCount(2, { timeout: 10_000 })
171+
await expect(activePanel(page).locator('.react-flow__node').filter({ hasText: 'Process: Inject text' })).toBeVisible()
172+
await expect(page.getByTestId('external-change-banner')).toHaveCount(0)
173+
} finally {
174+
await client.close()
175+
}
152176
} finally {
153-
await client.close()
177+
await cleanupWorkflow(page, 'E2E live sync clean')
154178
}
155-
156-
await page.getByRole('button', { name: 'Close tab' }).last().click()
157-
await clickRowAction(page, workflowRow(page, 'E2E live sync clean'), 'Delete')
158-
await restoreMCPWriteDefaults(page)
159179
})
160180

161181
test('dirty canvas: external MCP edit shows a banner, keeps the local edit, and Reload applies the fresh definition', async ({ page }, testInfo) => {
@@ -180,32 +200,32 @@ test('dirty canvas: external MCP edit shows a banner, keeps the local edit, and
180200
await activePanel(page).getByTestId('toggle-description').click()
181201
await activePanel(page).getByLabel('Description').fill('local unsaved edit')
182202

183-
const client = await connectMCPClient(testInfo.parallelIndex)
184203
try {
185-
const workflowId = await findWorkflowIdByLabel(client, 'E2E live sync dirty')
186-
await updateWorkflowViaMCP(client, workflowId, twoNodeDefinition('E2E live sync dirty', 'dirty-path marker'))
187-
188-
const banner = page.getByTestId('external-change-banner')
189-
await expect(banner).toBeVisible({ timeout: 10_000 })
190-
191-
// The local edit is untouched -- the external change was NOT
192-
// applied automatically while dirty.
193-
await expect(activePanel(page).getByLabel('Description')).toHaveValue('local unsaved edit')
194-
await expect(activePanel(page).locator('.react-flow__node')).toHaveCount(1)
195-
196-
await banner.getByRole('button', { name: 'Reload' }).click()
197-
198-
// Reload discards the local draft and loads the fresh (external)
199-
// definition.
200-
await expect(banner).toHaveCount(0)
201-
await expect(activePanel(page).locator('.react-flow__node')).toHaveCount(2)
202-
await expect(activePanel(page).locator('.react-flow__node').filter({ hasText: 'Process: Inject text' })).toBeVisible()
203-
await expect(activePanel(page).getByLabel('Description')).toHaveValue('')
204+
const client = await connectMCPClient(testInfo.parallelIndex)
205+
try {
206+
const workflowId = await findWorkflowIdByLabel(client, 'E2E live sync dirty')
207+
await updateWorkflowViaMCP(client, workflowId, twoNodeDefinition('E2E live sync dirty', 'dirty-path marker'))
208+
209+
const banner = page.getByTestId('external-change-banner')
210+
await expect(banner).toBeVisible({ timeout: 10_000 })
211+
212+
// The local edit is untouched -- the external change was NOT
213+
// applied automatically while dirty.
214+
await expect(activePanel(page).getByLabel('Description')).toHaveValue('local unsaved edit')
215+
await expect(activePanel(page).locator('.react-flow__node')).toHaveCount(1)
216+
217+
await banner.getByRole('button', { name: 'Reload' }).click()
218+
219+
// Reload discards the local draft and loads the fresh (external)
220+
// definition.
221+
await expect(banner).toHaveCount(0)
222+
await expect(activePanel(page).locator('.react-flow__node')).toHaveCount(2)
223+
await expect(activePanel(page).locator('.react-flow__node').filter({ hasText: 'Process: Inject text' })).toBeVisible()
224+
await expect(activePanel(page).getByLabel('Description')).toHaveValue('')
225+
} finally {
226+
await client.close()
227+
}
204228
} finally {
205-
await client.close()
229+
await cleanupWorkflow(page, 'E2E live sync dirty')
206230
}
207-
208-
await page.getByRole('button', { name: 'Close tab' }).last().click()
209-
await clickRowAction(page, workflowRow(page, 'E2E live sync dirty'), 'Delete')
210-
await restoreMCPWriteDefaults(page)
211231
})

frontend/e2e/resizable-table.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ test('Table columns are drag-resizable and long cells truncate with a hover titl
2626
const firstTrack = () =>
2727
table.evaluate((t) => parseFloat(getComputedStyle(t).gridTemplateColumns.split(' ')[0]))
2828
const before = await firstTrack()
29+
// Condition-based wait, not a fixed-window one (docs/goals/BACKLOG.md
30+
// Standing #1's CI flake investigation, 2026-08-12): a bare
31+
// `boundingBox()` read right after the table becomes visible can still
32+
// race the grid's own column-width layout pass under a loaded runner,
33+
// occasionally returning null (PR #24's real flake -- the whole
34+
// test's global `retries: 1`, playwright.config.ts, already masked
35+
// it once; this polls for a stable box directly at the point of use
36+
// instead of leaning on a full-test rerun to paper over layout timing).
37+
await expect
38+
.poll(() => handles.first().boundingBox().then((b) => b !== null), { timeout: 5_000 })
39+
.toBe(true)
2940
const box = await handles.first().boundingBox()
3041
if (!box) throw new Error('resize handle has no bounding box')
3142
const x = box.x + box.width / 2

frontend/src/composition/useCanvasLiveSync.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,56 @@ export function useCanvasLiveSync(args: UseCanvasLiveSyncArgs): UseCanvasLiveSyn
100100
draftDescriptionRef.current = draftDescription
101101
}, [draftDescription])
102102

103+
// Real bug found via CI flake investigation (docs/goals/BACKLOG.md
104+
// Standing #1, 2026-08-12): goal 0017 gave every direct-mutation
105+
// service its own `dataevent.Emit("workflow", id)` call -- but for a
106+
// single MCP `update_workflow` write, that now fires the SAME
107+
// `mill-data-changed` event TWICE: once from `SnapshotDraft`
108+
// (compositionservice_versioning.go's `mutateWorkflow`, archiving the
109+
// draft before the edit lands) and once from `UpdateWorkflow` itself
110+
// (compositionservice.go) -- plus a THIRD, from this canvas's own
111+
// `CreateWorkflow` moments earlier (canvas-live-sync.spec.ts's "clean
112+
// canvas" test creates the workflow via the UI first), which can
113+
// still be in flight when the canvas mounts and subscribes. All three
114+
// carry no content of their own -- each handler independently calls
115+
// CompositionService.Workflows() to refetch -- so three near-
116+
// simultaneous events dispatch three independent fetches whose
117+
// RESPONSES can resolve in a different order than they were
118+
// dispatched. Before this fix, whichever resolved LAST won
119+
// unconditionally, so a stale response could win the
120+
// decideExternalSyncAction comparison against a baseline a different,
121+
// already-applied response had advanced past, wrongly deciding
122+
// "prompt" and showing the external-change banner on a genuinely
123+
// clean canvas -- confirmed locally (traced via a temporary
124+
// arrival/resolution/decision log): 9/20 repeats of the "clean
125+
// canvas" test failed on exactly this assertion with zero artificial
126+
// load, matching 6/6 real CI failures found in the last ~30 CI runs
127+
// (canvas-live-sync.spec.ts:151, all after goal 0017 merged, zero
128+
// occurrences before). requestSeqRef is the standard fix for
129+
// out-of-order async responses: each event bumps the counter at
130+
// ARRIVAL time (not resolution time), and a response is dropped
131+
// unless it's still the most recently dispatched one when it resolves
132+
// -- correct regardless of how many of these events fire in a burst
133+
// or which of their fetches happens to resolve first. Verified fixed:
134+
// 88 consecutive clean local runs (0 failures) after this change, vs.
135+
// 9/20 before it, same build.
136+
const requestSeqRef = useRef(0)
137+
103138
useEffect(() => {
104139
if (!workflowId) return
105140
return Events.On('mill-data-changed', (evt) => {
106141
const data = evt.data as { entity?: string; id?: string }
107142
if (data?.entity !== 'workflow' || data?.id !== workflowId) return
143+
const seq = ++requestSeqRef.current
108144
CompositionService.Workflows()
109145
.then((all) => {
110-
const fresh = (all ?? []).find((w) => w.ID === workflowId)
146+
// A newer mill-data-changed event for this same workflow has
147+
// already arrived and dispatched its own fetch since this one
148+
// started -- this response is now stale (see the header
149+
// comment above); applying or even just deciding on it would
150+
// race the newer one. Drop it.
151+
if (seq !== requestSeqRef.current) return
152+
const fresh = (all ?? []).find((wf) => wf.ID === workflowId)
111153
// A concurrent external delete is out of this feature's scope
112154
// -- WorkTabShell's own once-lists-load tab-pruning handles a
113155
// since-deleted entity's open tab separately.

0 commit comments

Comments
 (0)