Skip to content

Agent CRDT follower: a frame consumed while graph scope is unavailable is never projected until an unrelated later update #17392

Description

@christian-byrne

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.applyFrameGraphMutations.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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions