Problem / Goal
Split out of the BLOCKER on #16353 (#16353 (comment)), which correctly identified this and is a test-only PR.
LayoutFollowerBridge.onDocUpdate merges the Yjs bytes and advances lastSeq before the ECS adapter is given the frame. EcsFollowerAdapter.applyFrame → GraphMutations.batch() returns false when getScope() is null, so nothing is projected. The bridge has no way to un-consume the bytes: the replica already mutated and the state vector already advanced (same behaviour proven in #16372).
AgentPanelRoot.vue's getScope() reads boundTabFor(workflowId)?.activeState?.id, while the subscription gate isBoundWorkflowActive only compares boundTabFor(bound)?.path === active.path. The two can disagree — a bound tab whose path matches the active workflow but whose changeTracker has not hydrated yet (or whose serialized workflow carries no id) is subscribed and scope-less at the same time.
Why there is no recovery. Reproduced locally against the real bridge, adapter and createGraphMutations:
subscribe #1 state vector: AA== (empty)
frame seq=1 delivered, scope null -> applyFrame() === false, graph empty
follower state vector now: Acy648sPDA== (already contains seq=1)
scope becomes available
bridge.resubscribe()
subscribe #2 state vector: Acy648sPDA== (identical to the post-merge vector)
graph nodes after resubscribe: []
The resubscribe carries a vector that already covers the withheld update, so the host computes an empty delta and sends no catch-up. Nothing local re-drives projection either. EcsFollowerAdapter does keep session.reconcileNextFrame = true on a rejected batch, so the debt is retained — but only a further semantic frame discharges it:
frame seq=2 delivered, scope available
graph nodes: ["1", "2"] (the withheld node finally lands)
So the graph stays empty for as long as the session is idle. On a session that has finished its turn, that is indefinite.
Proposed Solution
A trigger is what is missing. The bytes are already local, so recovery needs no host round trip — only a re-run of the adapter's authoritative reconcile once scope exists. Two candidate shapes:
- Explicit reprojection. Add
EcsFollowerAdapter.retryProjection(workflowId) that replays the reconcile with the rejected frame's context, and call it when scope becomes available.
- Gate the subscription on projectability. Fold
activeState?.id != null into isBoundWorkflowActive so the follower never subscribes into a state it cannot project. The bridge would then hold sentWorkflowId === null and drop inbound frames before merging, so the later subscribe's state vector still misses them and the host's catch-up is correct.
Trap for whoever picks this up: the obvious trigger is not reactive. ComfyWorkflow.changeTracker is markRaw'd ("Non-reactive raw object" — src/platform/workflow/management/stores/comfyWorkflow.ts:43), so a watch/watchEffect on activeState?.id will never fire and would ship as a silently dead recovery path.
Acceptance Criteria
- A frame consumed while
getScope() is null is projected once scope becomes available, with no further host frame and no resubscribe required.
- A test covers that path and fails when the trigger is removed (the trigger must be proven live, not merely present — see the
markRaw trap above).
- No partial projection while scope is unavailable: the existing assertions in
followerSeam.integration.test.ts that nothing is written and no layout op is emitted continue to hold.
- The existing retained-debt baseline in
followerSeam.integration.test.ts (the withheld node is reconciled in by the next frame) still passes.
Problem / Goal
Split out of the BLOCKER on #16353 (#16353 (comment)), which correctly identified this and is a test-only PR.
LayoutFollowerBridge.onDocUpdatemerges the Yjs bytes and advanceslastSeqbefore the ECS adapter is given the frame.EcsFollowerAdapter.applyFrame→GraphMutations.batch()returnsfalsewhengetScope()is null, so nothing is projected. The bridge has no way to un-consume the bytes: the replica already mutated and the state vector already advanced (same behaviour proven in #16372).AgentPanelRoot.vue'sgetScope()readsboundTabFor(workflowId)?.activeState?.id, while the subscription gateisBoundWorkflowActiveonly comparesboundTabFor(bound)?.path === active.path. The two can disagree — a bound tab whose path matches the active workflow but whosechangeTrackerhas not hydrated yet (or whose serialized workflow carries noid) is subscribed and scope-less at the same time.Why there is no recovery. Reproduced locally against the real bridge, adapter and
createGraphMutations:The resubscribe carries a vector that already covers the withheld update, so the host computes an empty delta and sends no catch-up. Nothing local re-drives projection either.
EcsFollowerAdapterdoes keepsession.reconcileNextFrame = trueon a rejected batch, so the debt is retained — but only a further semantic frame discharges it:So the graph stays empty for as long as the session is idle. On a session that has finished its turn, that is indefinite.
Proposed Solution
A trigger is what is missing. The bytes are already local, so recovery needs no host round trip — only a re-run of the adapter's authoritative reconcile once scope exists. Two candidate shapes:
EcsFollowerAdapter.retryProjection(workflowId)that replays the reconcile with the rejected frame's context, and call it when scope becomes available.activeState?.id != nullintoisBoundWorkflowActiveso the follower never subscribes into a state it cannot project. The bridge would then holdsentWorkflowId === nulland drop inbound frames before merging, so the later subscribe's state vector still misses them and the host's catch-up is correct.Trap for whoever picks this up: the obvious trigger is not reactive.
ComfyWorkflow.changeTrackerismarkRaw'd ("Non-reactive raw object" —src/platform/workflow/management/stores/comfyWorkflow.ts:43), so awatch/watchEffectonactiveState?.idwill never fire and would ship as a silently dead recovery path.Acceptance Criteria
getScope()is null is projected once scope becomes available, with no further host frame and no resubscribe required.markRawtrap above).followerSeam.integration.test.tsthat nothing is written and no layout op is emitted continue to hold.followerSeam.integration.test.ts(the withheld node is reconciled in by the next frame) still passes.