Context
Found while building the deterministic reproduction #18621 item 2 asked for (a returning item's pane not shown after a refresh that was in flight when its vessel died). The reproduction never reached the reconcile: one injected projection failure was enough to make two projections run over one host, and to make the repair re-project a document a newer commit had already replaced. Both effects sit in the repair path #18143 / PR #18147 introduced, and both contradict that path's own docblock. Whether this is Witness A's mechanism is #18621's question (its run-6 console is being asked for); this leaf is the engine defect on its own evidence.
The Problem
Neo.dashboard.dock.Workspace#onDockProjectionFailed (src/dashboard/dock/Workspace.mjs:2832) schedules the one clean re-projection as me.refreshPromise = me.timeout(0).then(() => me.refreshDockWorkspace(tabInsertDescriptor, document, {…, isDockProjectionRetry: true})). Every commit, by contrast, schedules off the SETTLED tail: projectDockZoneDocument (:2484-2495) takes me.refreshPromise?.catch(() => {}) and chains timeout(0) plus its refresh onto it. A commit that lands while a refresh is in flight is therefore already chained on that refresh's promise. When that refresh fails, the repair replaces refreshPromise instead of joining the chain, and two things follow:
- Two projections over one host. The repair's
timeout(0) and the chained commit's timeout(0) are independent timers; the chained refresh starts as soon as the repair suspends. In the App worker every host.promiseUpdate() inside Reconciler.reconcileProjection is a VDom-worker round-trip, so the repair suspends on its first phase.
- A stale snapshot re-projected. The repair re-projects the FAILED cycle's
document, not the committed one; once the newer commit has projected, the repair (or its interleaving) presents a document dockModel no longer holds.
Measured (untracked unit rig on dev@8cab26fcf8: a real DockWorkspace fixture under enableDockTearOutLifecycle, cache-backed resolvePane, reconcileProjection wrapped to throw one typed isDockProjectionFailure and, in the round-trip variants, to await one setTimeout(0) before delegating — the App worker's shape):
| variant |
refreshes (side-tabs items · retry · in flight at start) |
max in flight |
| control |
[preview]·0 → [preview,terminal]·0 |
1 |
| fail-once |
[preview] fails → [preview] retry·0 → [preview,terminal]·0 |
1 |
| control + round-trip |
[preview]·0 → [preview,terminal]·0 |
1 |
| fail-once + round-trip |
[preview] fails → [preview] retry·0 → [preview,terminal]·1 |
2 |
The same shape on the return route (capturePane → detach document → releasePane → reintegrateItem during the failing refresh) reads 2 as well. The unit tier alone cannot show it: VdomLifecycle#updateVdom resolves as a synchronous no-op in unit-test mode (src/mixin/VdomLifecycle.mjs:1052), so no projection there ever leaves the microtask queue; the stand-in models the worker round-trip, and the control with the same stand-in and no failure stays at 1 — the stand-in manufactures nothing (contrast #18573, retracted because the tail-discard there was the fixture's; here it is the engine's).
The Architectural Reality
projectDockZoneDocument (Workspace.mjs:2484-2495) is the one funnel every committed projection passes, plain path and Group path alike (onDockZoneDocumentChange :2404, projectDockCommit :2441); its tail discipline is the serialization contract the refreshPromise docblock (:428-452) describes.
refreshDockWorkspace (:2748-2758) catches the reconciler's typed failure and hands the cycle to onDockProjectionFailed, which returns null so the cycle ends. The repair's docblock (:2818-2824) justifies replacing the tail with "the current value IS this cycle, and awaiting it from inside itself would never settle" — a premise about awaiting, while a chained .then awaits nothing; and the current value is the LATER commit's chain whenever one landed mid-flight.
Reconciler.reconcileProjection awaits host.promiseUpdate() across its phases (Reconciler.mjs:415, :535, :560-575): in the App worker each is a macrotask boundary.
#18598 (closed) fixed the sibling defect on the close path — a follow-up chained onto the wrong refresh — and recorded the deadlock trap for a chain assigned BEFORE the projection is scheduled (projectDockZoneDocument would take a tail that waits on the refresh). The repair below chains after promises that never wait on it.
- Consumers: no host overrides
onDockProjectionFailed; the dockProjectionFailed listeners (DockBackToBackCommits, DockRecoveryPaneIdentity, DockProjectionDestroySentinel specs) and the dock-lazy-rail component fixture reading isDockProjectionRetry are unchanged by the fix.
The Fix
src/dashboard/dock/Workspace.mjs, onDockProjectionFailed:
- The repair chains onto the settled tail exactly as a commit does —
me.refreshPromise?.catch(() => {}) || Promise.resolve(), then timeout(0), then the re-projection — so it runs after any commit that landed mid-flight, and later commits queue behind it.
- The repair stands down when a newer document was scheduled since the failed cycle:
projectDockZoneDocument records the document it schedules (one field beside refreshPromise, scheduledDocument), and the repair re-projects only while that field still holds its own snapshot. The newer commit's projection, serialized after the failure, is the repair then; a stale snapshot never re-projects over it. With no newer commit the behaviour is today's (one repair, isDockProjectionRetry, no third attempt).
- Docblocks: the repair's "replaces rather than chains" paragraph is rewritten to the chain plus stand-down;
refreshPromise's gains the repair as one more chained writer; scheduledDocument says why identity, not dockModel (the plain path assigns dockModel after the funnel returns, the Group path leaves it to the consumer's setDocument).
Arm: test/playwright/unit/dashboard/DockWorkspace.spec.mjs beside the #18143 arms — a real projecting fixture with an in-flight counter and a cache-backed resolver; reconcileProjection wrapped to fail once and to suspend on one macrotask; two commits in one tick. Red on dev on the in-flight count (2), green on the repair; the failed snapshot appears exactly once (the failed attempt), never as a retry; the shell ends on the second document with the same live pane. A second arm keeps the single-commit path: one failure, one chained repair, the shell on that document. Mutation controls: the replace restored → arm 1 red on the count; the stand-down removed → arm 1 red on the stale re-projection.
Decision Record impact
aligned-with ADR 0029 — the projection contract is its §2 subject; nothing in the seam's shape changes.
Acceptance Criteria
Out of Scope
- The reconciler's recovery verdicts and settle paths (
#18143's contract) — unchanged.
- The Group commit's detached projection microtask (
Commit.complete) and the close follow-up chain (#18598).
- Whether the cockpit's stranded
stream (#18621 item 2) is this defect — pending its console; #18621 keeps that item.
dockModel's update discipline on the Group path (consumer-wired setDocument) — read, not changed.
Avoided Traps
| Rejected |
Why |
| Chain the repair onto the tail and keep re-projecting the failed snapshot |
After the newer commit's projection the shell would show the OLD document while dockModel holds the new one — deterministic, worse than today's race. |
| Keep replacing the tail because "awaiting it from inside itself would never settle" |
A chained .then awaits nothing; the current value is often the later commit's chain, not this cycle. #18598's deadlock was a chain assigned before the projection was scheduled — a cycle this shape does not create. |
| Serialize by making the newer commit wait for the repair |
Inverts the order: the stale snapshot would project after the newer document. |
| "The unit tier shows no overlap, so there is none" |
Unit mode resolves updateVdom synchronously as a no-op (VdomLifecycle.mjs:1052); the App worker suspends on every VDom round-trip. The control with the same stand-in and no failure stays serialized. |
Detect supersession through dockModel |
The plain path assigns it after the funnel returns; the Group path leaves it to the consumer's setDocument (apps/workstation/view/Workspace.mjs:758); a fixture-only host may never write it. |
Related
#18143 / PR #18147 (the repair path) · #18621 (item 2, candidate cause) · #18598 (sibling: a follow-up chained onto the wrong refresh) · #18573 (retracted: fixture-manufactured concurrency — the contrast) · parent epic #18303.
Sweeps: live latest-open (20 at 2026-09-12T22:47Z) — no equivalent; A2A claims (all read-states, last 30) — no overlapping claim; Memory Core (two queries, symptom- and mechanism-worded) — no prior decision on this path; own open assignments (8 bodies read) — #18621 adjacent, distinct; Knowledge Base type: ticket — nothing on this path. Structure map: N/A (engine src/).
Origin Session ID: 6f95d318-61df-4f43-b77c-d20d20224299
Retrieval Hint: query_raw_memories on "a failed dock projection's repair replaced the refresh tail and ran beside the next commit's projection" · anchors src/dashboard/dock/Workspace.mjs#onDockProjectionFailed, #projectDockZoneDocument.
Context
Found while building the deterministic reproduction #18621 item 2 asked for (a returning item's pane not shown after a refresh that was in flight when its vessel died). The reproduction never reached the reconcile: one injected projection failure was enough to make two projections run over one host, and to make the repair re-project a document a newer commit had already replaced. Both effects sit in the repair path
#18143/ PR#18147introduced, and both contradict that path's own docblock. Whether this is Witness A's mechanism is #18621's question (its run-6 console is being asked for); this leaf is the engine defect on its own evidence.The Problem
Neo.dashboard.dock.Workspace#onDockProjectionFailed(src/dashboard/dock/Workspace.mjs:2832) schedules the one clean re-projection asme.refreshPromise = me.timeout(0).then(() => me.refreshDockWorkspace(tabInsertDescriptor, document, {…, isDockProjectionRetry: true})). Every commit, by contrast, schedules off the SETTLED tail:projectDockZoneDocument(:2484-2495) takesme.refreshPromise?.catch(() => {})and chainstimeout(0)plus its refresh onto it. A commit that lands while a refresh is in flight is therefore already chained on that refresh's promise. When that refresh fails, the repair replacesrefreshPromiseinstead of joining the chain, and two things follow:timeout(0)and the chained commit'stimeout(0)are independent timers; the chained refresh starts as soon as the repair suspends. In the App worker everyhost.promiseUpdate()insideReconciler.reconcileProjectionis a VDom-worker round-trip, so the repair suspends on its first phase.document, not the committed one; once the newer commit has projected, the repair (or its interleaving) presents a documentdockModelno longer holds.Measured (untracked unit rig on dev@8cab26fcf8: a real
DockWorkspacefixture underenableDockTearOutLifecycle, cache-backedresolvePane,reconcileProjectionwrapped to throw one typedisDockProjectionFailureand, in the round-trip variants, to await onesetTimeout(0)before delegating — the App worker's shape):[preview]·0 →[preview,terminal]·0[preview]fails →[preview]retry·0 →[preview,terminal]·0[preview]·0 →[preview,terminal]·0[preview]fails →[preview]retry·0 →[preview,terminal]·1The same shape on the return route (
capturePane→ detach document →releasePane→reintegrateItemduring the failing refresh) reads 2 as well. The unit tier alone cannot show it:VdomLifecycle#updateVdomresolves as a synchronous no-op in unit-test mode (src/mixin/VdomLifecycle.mjs:1052), so no projection there ever leaves the microtask queue; the stand-in models the worker round-trip, and the control with the same stand-in and no failure stays at 1 — the stand-in manufactures nothing (contrast#18573, retracted because the tail-discard there was the fixture's; here it is the engine's).The Architectural Reality
projectDockZoneDocument(Workspace.mjs:2484-2495) is the one funnel every committed projection passes, plain path and Group path alike (onDockZoneDocumentChange:2404,projectDockCommit:2441); its tail discipline is the serialization contract therefreshPromisedocblock (:428-452) describes.refreshDockWorkspace(:2748-2758) catches the reconciler's typed failure and hands the cycle toonDockProjectionFailed, which returnsnullso the cycle ends. The repair's docblock (:2818-2824) justifies replacing the tail with "the current value IS this cycle, and awaiting it from inside itself would never settle" — a premise about awaiting, while a chained.thenawaits nothing; and the current value is the LATER commit's chain whenever one landed mid-flight.Reconciler.reconcileProjectionawaitshost.promiseUpdate()across its phases (Reconciler.mjs:415,:535,:560-575): in the App worker each is a macrotask boundary.#18598(closed) fixed the sibling defect on the close path — a follow-up chained onto the wrong refresh — and recorded the deadlock trap for a chain assigned BEFORE the projection is scheduled (projectDockZoneDocumentwould take a tail that waits on the refresh). The repair below chains after promises that never wait on it.onDockProjectionFailed; thedockProjectionFailedlisteners (DockBackToBackCommits,DockRecoveryPaneIdentity,DockProjectionDestroySentinelspecs) and thedock-lazy-railcomponent fixture readingisDockProjectionRetryare unchanged by the fix.The Fix
src/dashboard/dock/Workspace.mjs,onDockProjectionFailed:me.refreshPromise?.catch(() => {}) || Promise.resolve(), thentimeout(0), then the re-projection — so it runs after any commit that landed mid-flight, and later commits queue behind it.projectDockZoneDocumentrecords the document it schedules (one field besiderefreshPromise,scheduledDocument), and the repair re-projects only while that field still holds its own snapshot. The newer commit's projection, serialized after the failure, is the repair then; a stale snapshot never re-projects over it. With no newer commit the behaviour is today's (one repair,isDockProjectionRetry, no third attempt).refreshPromise's gains the repair as one more chained writer;scheduledDocumentsays why identity, notdockModel(the plain path assignsdockModelafter the funnel returns, the Group path leaves it to the consumer'ssetDocument).Arm:
test/playwright/unit/dashboard/DockWorkspace.spec.mjsbeside the#18143arms — a real projecting fixture with an in-flight counter and a cache-backed resolver;reconcileProjectionwrapped to fail once and to suspend on one macrotask; two commits in one tick. Red on dev on the in-flight count (2), green on the repair; the failed snapshot appears exactly once (the failed attempt), never as a retry; the shell ends on the second document with the same live pane. A second arm keeps the single-commit path: one failure, one chained repair, the shell on that document. Mutation controls: the replace restored → arm 1 red on the count; the stand-down removed → arm 1 red on the stale re-projection.Decision Record impact
aligned-with ADR 0029— the projection contract is its §2 subject; nothing in the seam's shape changes.Acceptance Criteria
refreshDockWorkspacein flight with one macrotask inside the projection; red on dev (2), green on the repair.isDockProjectionRetry,dockProjectionFailedfired once, a failure during the repair schedules no third attempt (the#18143arms stay green; a single-commit arm shows the chained repair landing its document).refreshPromise's docblocks state the chain and the stand-down; the "awaiting it from inside itself would never settle" premise is retired.#18621's Witness A arm on the cockpit, if its run-6 console shows aDock projection failedline — that link is #18621's to close, not this leaf's.Out of Scope
#18143's contract) — unchanged.Commit.complete) and the close follow-up chain (#18598).stream(#18621 item 2) is this defect — pending its console; #18621 keeps that item.dockModel's update discipline on the Group path (consumer-wiredsetDocument) — read, not changed.Avoided Traps
dockModelholds the new one — deterministic, worse than today's race..thenawaits nothing; the current value is often the later commit's chain, not this cycle.#18598's deadlock was a chain assigned before the projection was scheduled — a cycle this shape does not create.updateVdomsynchronously as a no-op (VdomLifecycle.mjs:1052); the App worker suspends on every VDom round-trip. The control with the same stand-in and no failure stays serialized.dockModelsetDocument(apps/workstation/view/Workspace.mjs:758); a fixture-only host may never write it.Related
#18143/ PR#18147(the repair path) · #18621 (item 2, candidate cause) ·#18598(sibling: a follow-up chained onto the wrong refresh) ·#18573(retracted: fixture-manufactured concurrency — the contrast) · parent epic #18303.Sweeps: live latest-open (20 at 2026-09-12T22:47Z) — no equivalent; A2A claims (all read-states, last 30) — no overlapping claim; Memory Core (two queries, symptom- and mechanism-worded) — no prior decision on this path; own open assignments (8 bodies read) — #18621 adjacent, distinct; Knowledge Base
type: ticket— nothing on this path. Structure map: N/A (enginesrc/).Origin Session ID: 6f95d318-61df-4f43-b77c-d20d20224299
Retrieval Hint:
query_raw_memorieson "a failed dock projection's repair replaced the refresh tail and ran beside the next commit's projection" · anchorssrc/dashboard/dock/Workspace.mjs#onDockProjectionFailed,#projectDockZoneDocument.