Context
test/playwright/e2e/colors/tearOutMatrix.spec.mjs reds intermittently on every branch, including dev, and it has been doing so for at least a day. The failure is not in the spec's own assertions — those pass and print their receipts. It is the workerErrors auto-fixture (test/playwright/fixtures.mjs:138) catching a real App Worker exception:
App Worker: TypeError: Cannot destructure property 'store' of 'grid' as it is null.
at ViewportController.updateGrid (apps/colors/view/ViewportController.mjs:218:14)
at ViewportController.updateWidgets (apps/colors/view/ViewportController.mjs:257:20)Enumerated occurrences (gh run list --workflow=test-e2e.yml), all with the identical stack and the identical 1 failed / 58 passed shape:
| run |
branch |
when |
| 35002189086 |
dev |
2026-09-15 17:35Z |
| 34991148249 |
agent/18741-teardown-liveness |
2026-09-15 15:50Z |
| 34992052709 |
agent/18741-teardown-liveness |
2026-09-15 15:59Z |
| 34993223708 |
agent/18741-teardown-liveness |
2026-09-15 16:09Z |
| 35069143332 |
dependabot/npm_and_yarn/all-deps-d54dbf6e78 |
2026-09-16 07:33Z |
A same-SHA rerun of the last one went green, which establishes intermittency and rules out the dependency bump it was blocking (PR #18756, since merged). Roughly one e2e-engine run in three over that window.
Intermittent is not spurious. The fixture is behaving correctly; the application is intermittently wrong. The standing cost is that unrelated PRs go red at ~30%, which spends a rerun per author and trains the team to re-run reds without reading them.
Two ✘ clusters in the same log are not part of this: WorkerErrorGate.spec.mjs rows 18 / 20 / 22 are that spec's own negative controls ("a worker error the test does not name fails it"). They are supposed to red, and the summary line reads 1 failed.
The Problem
updateWidgets awaits an RPC round-trip and then resumes:
async updateWidgets() {
...
response = await Colors.backend.ColorService.read({...});
if (!me.isDestroyed) {
me.updateGrid(data.tableData);
me.updateCharts(data.summaryData)
}
}
updateGrid(records) {
let grid = this.getReference('grid'),
{store} = grid; The liveness predicate is one layer off from the thing being dereferenced. me.isDestroyed answers "does the controller still exist?"; the very next line asks the tree for a component the controller does not own. Between the await and the resume, that component can go away while the controller does not — so the guard passes and the dereference fails.
This is the same class as #18731 / #18741 / #18752: deferred work checking liveness where it was scheduled rather than where it resumes. Those were engine-side; this is the app-side instance, and it is narrower — the guard exists, it just names the wrong object.
The Architectural Reality
The reference cache is not the culprit, and that is load-bearing for the diagnosis. Every mutation site of controller.Component#references, enumerated across the repo:
| operation |
site |
reachable from |
| write |
src/controller/Component.mjs:125 |
getReference() itself — the only writer |
| delete |
src/controller/Component.mjs:177 |
removeReference() (:170), called from src/component/Base.mjs:1366 inside destroy(), plus its own parent-walk at :182 |
getReference treats the cache as a hint rather than an authority: a miss falls through to me.component.down({reference: name}) and repopulates, which makes destroy-then-recreate self-healing on the next call.
So a stale cache would return the wrong component — which is exactly the failure mode the getReference TODO at src/controller/Component.mjs:113 describes (container.remove() then container.add() with the same key). This ticket is the opposite shape. null proves two things at once:
- the cache had already been correctly purged — and
destroy() is the only path that purges it; and
down({reference: 'grid'}) found nothing in the controller's subtree at that instant.
apps/colors/view/Viewport.mjs:59 declares {module: GridContainer, reference: 'grid'}, so me.reference is truthy and destroy() does reach the purge. A null here is therefore mechanical evidence that the grid instance was destroyed — not merely re-parented, not merely unmounted.
That is the part worth isolating, because it sits against object permanence: Neo's multi-window model keeps the component tree in the shared App Worker and treats windows as render targets, so a widget moving between windows should be reused, not destroyed and rebuilt. Something on the tear-out / popup path (src/dashboard/Container.mjs#openWidgetInPopup, :231 and :395) is a candidate, but that is a hypothesis and is not claimed — the failing arm (tearOutMatrix.spec.mjs:104, row 2) documents its own gesture as "small in-container moves only, never crossing the boundary", which does not obviously produce a destroy. The mechanism needs an instrumented run, not another read.
Adjacent and deliberately not folded in: destroy() sets me.controller = null at src/component/Base.mjs:1364 before calling me.getController()?.removeReference(me) at :1366, and getController() memoizes into me[closestController]. For a component that owns a controller the purge therefore walks a different chain than it would one line earlier. Harmless for the colors grid, which owns none — recorded so the next reader does not have to re-derive it.
The Fix
Two layers, and the first does not wait on the second.
apps/colors/view/ViewportController.mjs — the resume guard must cover what it dereferences. updateGrid should not destructure a reference it has not checked, and updateWidgets's post-await branch should test the surfaces it is about to touch rather than only the controller. updateCharts is on the same resume path and needs the same audit.
- The destroy itself — isolate what destroys the grid during that arm, then decide whether the destroy is correct (and the app must tolerate it) or an object-permanence violation (and the destroy is the bug). Only the first branch is an app fix; the second is engine-side and would get its own leaf.
Deliberately not prescribed here: changing getReference, removeReference, or the cache. The enumeration above shows the cache behaving correctly in this scenario, and the :113 TODO addresses a different failure mode.
Acceptance Criteria
Out of Scope
getReference / removeReference / the reference cache — see The Architectural Reality; the cache is not implicated in this failure mode.
- The
src/component/Base.mjs:1364/:1366 ordering note — recorded above, not fixed here, no consumer affected.
WorkerErrorGate.spec.mjs's three intentional red arms.
- The dependency bump in PR #18756 — falsified as a cause, merged.
- Whether any other app has the same resume-guard shape. Worth a sweep, not this leaf.
Avoided Traps
- Re-running until green. The same-SHA rerun was used to classify the failure, not to bypass it. Treating this as a flake to be re-run is how a real ~30% defect survives indefinitely while costing every author a cycle.
- Blaming the fixture.
workerErrors is doing exactly what it exists to do. Suppressing or narrowing it would convert a visible defect into an invisible one.
- Fixing the cache. The obvious read of a
null from a cached getter is "cache invalidation bug", and the enumeration refutes it. Patching getReference would leave the destroy in place and hide the evidence that one happened.
- Prescribing the mechanism from source alone. The tear-out path is the plausible candidate and the failing arm's own comment argues against it. AC-1 requires a measurement rather than accepting either reading.
Related
- #18731 · #18741 · #18752 — the engine-side instances of the same resume-liveness class.
- #17871 — Workstation full tour can lose the pooled grid after midpoint. Different app, different surface, unverified relation; linked because "a grid disappears mid-interaction" is the same symptom family and one investigation may inform the other.
- #18756 — the dependabot PR this failure blocked; falsified as a cause and merged.
Live latest-open sweep: checked the latest 20 open issues at 2026-09-16T08:28:45Z; no equivalent found. A2A in-flight claim sweep over the last 30 messages (all read-states): no competing [lane-claim] / [lane-intent] on this surface. Memory Core rationale sweep on the symptom nouns: no prior decision. Own-assignment sweep: no same-surface hit. Structure-map gate: N/A — no .mjs created or relocated, and nothing under ai/, Agent OS, MCP, Memory Core, orchestration or .agents/skills is touched.
unowned-rationale: filed from a review-adjacent investigation, not a claimed lane — the operator has a different topic in flight and I am not starting this. AC-1 is the entry point and is pickable by anyone; it needs an instrumented run, not context from this session.
Decision Record impact: none.
Origin Session ID: 5bf0b816-b919-40fc-9c18-fee751fd1635
Retrieval Hint: "colors ViewportController updateGrid null grid reference destroyed resume liveness" · query_raw_memories on "resume guard names the controller not the dereferenced surface"
Context
test/playwright/e2e/colors/tearOutMatrix.spec.mjsreds intermittently on every branch, includingdev, and it has been doing so for at least a day. The failure is not in the spec's own assertions — those pass and print their receipts. It is theworkerErrorsauto-fixture (test/playwright/fixtures.mjs:138) catching a real App Worker exception:App Worker: TypeError: Cannot destructure property 'store' of 'grid' as it is null. at ViewportController.updateGrid (apps/colors/view/ViewportController.mjs:218:14) at ViewportController.updateWidgets (apps/colors/view/ViewportController.mjs:257:20)Enumerated occurrences (
gh run list --workflow=test-e2e.yml), all with the identical stack and the identical1 failed / 58 passedshape:devagent/18741-teardown-livenessagent/18741-teardown-livenessagent/18741-teardown-livenessdependabot/npm_and_yarn/all-deps-d54dbf6e78A same-SHA rerun of the last one went green, which establishes intermittency and rules out the dependency bump it was blocking (PR #18756, since merged). Roughly one
e2e-enginerun in three over that window.Intermittent is not spurious. The fixture is behaving correctly; the application is intermittently wrong. The standing cost is that unrelated PRs go red at ~30%, which spends a rerun per author and trains the team to re-run reds without reading them.
Two
✘clusters in the same log are not part of this:WorkerErrorGate.spec.mjsrows 18 / 20 / 22 are that spec's own negative controls ("a worker error the test does not name fails it"). They are supposed to red, and the summary line reads1 failed.The Problem
updateWidgetsawaits an RPC round-trip and then resumes:async updateWidgets() { ... response = await Colors.backend.ColorService.read({...}); if (!me.isDestroyed) { // guards the CONTROLLER me.updateGrid(data.tableData); // dereferences the GRID me.updateCharts(data.summaryData) } } updateGrid(records) { let grid = this.getReference('grid'), {store} = grid; // :218 — throws when grid is nullThe liveness predicate is one layer off from the thing being dereferenced.
me.isDestroyedanswers "does the controller still exist?"; the very next line asks the tree for a component the controller does not own. Between theawaitand the resume, that component can go away while the controller does not — so the guard passes and the dereference fails.This is the same class as #18731 / #18741 / #18752: deferred work checking liveness where it was scheduled rather than where it resumes. Those were engine-side; this is the app-side instance, and it is narrower — the guard exists, it just names the wrong object.
The Architectural Reality
The reference cache is not the culprit, and that is load-bearing for the diagnosis. Every mutation site of
controller.Component#references, enumerated across the repo:src/controller/Component.mjs:125getReference()itself — the only writersrc/controller/Component.mjs:177removeReference()(:170), called fromsrc/component/Base.mjs:1366insidedestroy(), plus its own parent-walk at:182getReferencetreats the cache as a hint rather than an authority: a miss falls through tome.component.down({reference: name})and repopulates, which makes destroy-then-recreate self-healing on the next call.So a stale cache would return the wrong component — which is exactly the failure mode the
getReferenceTODO atsrc/controller/Component.mjs:113describes (container.remove()thencontainer.add()with the same key). This ticket is the opposite shape.nullproves two things at once:destroy()is the only path that purges it; anddown({reference: 'grid'})found nothing in the controller's subtree at that instant.apps/colors/view/Viewport.mjs:59declares{module: GridContainer, reference: 'grid'}, some.referenceis truthy anddestroy()does reach the purge. Anullhere is therefore mechanical evidence that the grid instance was destroyed — not merely re-parented, not merely unmounted.That is the part worth isolating, because it sits against object permanence: Neo's multi-window model keeps the component tree in the shared App Worker and treats windows as render targets, so a widget moving between windows should be reused, not destroyed and rebuilt. Something on the tear-out / popup path (
src/dashboard/Container.mjs#openWidgetInPopup,:231and:395) is a candidate, but that is a hypothesis and is not claimed — the failing arm (tearOutMatrix.spec.mjs:104, row 2) documents its own gesture as "small in-container moves only, never crossing the boundary", which does not obviously produce a destroy. The mechanism needs an instrumented run, not another read.Adjacent and deliberately not folded in:
destroy()setsme.controller = nullatsrc/component/Base.mjs:1364before callingme.getController()?.removeReference(me)at:1366, andgetController()memoizes intome[closestController]. For a component that owns a controller the purge therefore walks a different chain than it would one line earlier. Harmless for the colors grid, which owns none — recorded so the next reader does not have to re-derive it.The Fix
Two layers, and the first does not wait on the second.
apps/colors/view/ViewportController.mjs— the resume guard must cover what it dereferences.updateGridshould not destructure a reference it has not checked, andupdateWidgets's post-await branch should test the surfaces it is about to touch rather than only the controller.updateChartsis on the same resume path and needs the same audit.Deliberately not prescribed here: changing
getReference,removeReference, or the cache. The enumeration above shows the cache behaving correctly in this scenario, and the:113TODO addresses a different failure mode.Acceptance Criteria
updateWidgets' resume path guards every surface it dereferences,updateChartsincluded. NogetReference(...)result is destructured or dereferenced without a null check on the same resume.unit-test/whitebox-e2eskill, whichever the seam allows.tearOutMatrix.spec.mjsruns clean across 10 consecutivee2e-engineinvocations, or the residual rate is measured and recorded here. A single green run does not discharge this: the pre-fix rate is ~1 in 3, so one pass is ~67% likely to prove nothing.Out of Scope
getReference/removeReference/ the reference cache — see The Architectural Reality; the cache is not implicated in this failure mode.src/component/Base.mjs:1364/:1366ordering note — recorded above, not fixed here, no consumer affected.WorkerErrorGate.spec.mjs's three intentional red arms.Avoided Traps
workerErrorsis doing exactly what it exists to do. Suppressing or narrowing it would convert a visible defect into an invisible one.nullfrom a cached getter is "cache invalidation bug", and the enumeration refutes it. PatchinggetReferencewould leave the destroy in place and hide the evidence that one happened.Related
Live latest-open sweep: checked the latest 20 open issues at 2026-09-16T08:28:45Z; no equivalent found. A2A in-flight claim sweep over the last 30 messages (all read-states): no competing
[lane-claim]/[lane-intent]on this surface. Memory Core rationale sweep on the symptom nouns: no prior decision. Own-assignment sweep: no same-surface hit. Structure-map gate: N/A — no.mjscreated or relocated, and nothing underai/, Agent OS, MCP, Memory Core, orchestration or.agents/skillsis touched.unowned-rationale: filed from a review-adjacent investigation, not a claimed lane — the operator has a different topic in flight and I am not starting this. AC-1 is the entry point and is pickable by anyone; it needs an instrumented run, not context from this session.
Decision Record impact: none.
Origin Session ID: 5bf0b816-b919-40fc-9c18-fee751fd1635
Retrieval Hint: "colors ViewportController updateGrid null grid reference destroyed resume liveness" ·
query_raw_memorieson "resume guard names the controller not the dereferenced surface"