Comfy-Org / Comfy-Org/ComfyUI_frontend
Agent CRDT follower: a frame consumed while graph scope is unavailable is never projected until an unrelated later update
- Dominant language
- TypeScript
- Stars
- 2k
- Forks
- 699
- Avg merge
- 1d 7h
- Merged PRs (30d)
- 490
Description
## Problem / Goal
Split out of the BLOCKER on #16353 (https://github.com/Comfy-Org/ComfyUI_frontend/pull/16353#discussion_r3928738710), 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:
1. **Explicit reprojection.** Add `EcsFollowerAdapter.retryProjection(workflowId)` that replays the reconcile with the rejected frame's context, and call it when scope becomes available.
2. **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.
Contributor guide
Research direction
Start with AgentPanelRoot.vue, LayoutFollowerBridge.onDocUpdate, and EcsFollowerAdapter.applyFrame to trace how a frame is consumed when getScope() is null; also inspect the non-reactive changeTracker in src/platform/workflow/management/stores/comfyWorkflow.ts. Run followerSeam.integration.test.ts and add coverage proving the withheld frame is projected once scope becomes available without another host frame or resubscribe, while existing no-partial-projection and retained-debt assertions still pass.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- frontend, testing-qa
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100