comfyui-mcp 0.52.156 → 0.52.157

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -184,8 +184,8 @@ function journalConversationFor(ctx) {
184
184
  return conversationOfScopeAddress(ctx.tabId);
185
185
  }
186
186
  import { BRIDGE_DEFAULT_TIMEOUT_MS, BRIDGE_READ_DEFAULT_TIMEOUT_MS, dispatchOutcomeOf, GRAPH_CMD_EFFECT, isCapabilityRefusal, isPanelCmdUnsupportedError, isMidCommandDisconnectTagged, isReplyTimeoutTagged, isRoutingAmbiguity, requiresWorkflowStampEnforcement, } from "../services/ui-bridge.js";
187
- import { PANEL_TOOL_BY_GRAPH_CMD, QUEUE_BUSY_READ_TOOLS, RETRY_TOKEN_CMD_BY_TOOL, panelToolForGraphCmd, } from "../services/panel-graph-cmd-tools.js";
188
- export { PANEL_TOOL_BY_GRAPH_CMD, QUEUE_BUSY_READ_TOOLS, RETRY_TOKEN_CMD_BY_TOOL, panelToolForGraphCmd, };
187
+ import { GRAPH_READ_CMD_BY_TOOL, PANEL_TOOL_BY_GRAPH_CMD, QUEUE_BUSY_READ_TOOLS, RETRY_TOKEN_CMD_BY_TOOL, panelToolForGraphCmd, } from "../services/panel-graph-cmd-tools.js";
188
+ export { GRAPH_READ_CMD_BY_TOOL, PANEL_TOOL_BY_GRAPH_CMD, QUEUE_BUSY_READ_TOOLS, RETRY_TOKEN_CMD_BY_TOOL, panelToolForGraphCmd, };
189
189
  import { withWorkflowTarget, } from "../services/workflow-target-store.js";
190
190
  import { addUserMcpServer, backendInheritsUserMcpServers, readUserMcpServers, removeUserMcpServer, setUserMcpServerSecret, } from "../services/user-mcp-config.js";
191
191
  import { setComfyuiSecret, setAgentSecret, isAllowedAgentSecretKey, receiptDisclosures, shadowedNote, storeDamageNote, } from "../services/panel-secrets.js";
@@ -853,6 +853,10 @@ export const __panelToolsTestHooks = {
853
853
  setRunLateAckGraceMs(ms) {
854
854
  runLateAckGraceMsOverride = ms;
855
855
  },
856
+ /** Shrink the #2527 graph-read wait for an outstanding timed-out mutation. */
857
+ setOutstandingMutationReadSettleMs(ms) {
858
+ outstandingMutationReadSettleMsOverride = ms;
859
+ },
856
860
  /** Mark a synthetic graph_run result as the bridge's authoritative reply timeout. */
857
861
  markRunReplyTimeout(res) {
858
862
  Object.defineProperty(res, REPLY_TIMEOUT_RESULT, {
@@ -949,6 +953,65 @@ const OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS = 90_000;
949
953
  * rather than "wait longer for a chance at completeness". 8 s is the same elective
950
954
  * probe budget the graph_query / graph_serialize probes in this file already use. */
951
955
  const GET_ERRORS_COMPLETION_BUDGET_MS = 8_000;
956
+ // #2527 — after a mutating graph command times out, the frontend may still be
957
+ // applying it. An immediate graph read can then echo the pre-write value, and a
958
+ // later write reports the timed-out value as `previous`. Graph reads wait this
959
+ // long for that settlement, then disclose any still-outstanding receipt.
960
+ const OUTSTANDING_MUTATION_READ_SETTLE_MS = 8_000;
961
+ let outstandingMutationReadSettleMsOverride = null;
962
+ function outstandingMutationReadSettleMs() {
963
+ return outstandingMutationReadSettleMsOverride ?? OUTSTANDING_MUTATION_READ_SETTLE_MS;
964
+ }
965
+ const GRAPH_READ_SETTLE_CMDS = new Set([
966
+ ...Object.values(GRAPH_READ_CMD_BY_TOOL),
967
+ "graph_get_subgraph",
968
+ "graph_get_state",
969
+ "graph_serialize",
970
+ ]);
971
+ function isGraphReadSettleCmd(cmd) {
972
+ return typeof cmd.cmd === "string" && GRAPH_READ_SETTLE_CMDS.has(cmd.cmd);
973
+ }
974
+ function sameMutationScalar(a, b) {
975
+ if (a === b)
976
+ return true;
977
+ if (a == null || b == null)
978
+ return a == null && b == null;
979
+ return String(a) === String(b);
980
+ }
981
+ /**
982
+ * Semantic identity of a delivered mutation vs a retry, ignoring bridge-owned
983
+ * stamps (`rid`, `retry_of`, `timeout_ms`, `workflow_uuid`) and write-fence
984
+ * witnesses that a later probe may reconstruct (`expected_*`).
985
+ */
986
+ function mutationIdentityMatches(retryCmd, delivered) {
987
+ if (retryCmd.cmd !== delivered.cmd)
988
+ return false;
989
+ if (retryCmd.cmd === "graph_set_widget") {
990
+ return (sameMutationScalar(retryCmd.node_id, delivered.node_id) &&
991
+ sameMutationScalar(retryCmd.widget, delivered.widget) &&
992
+ sameMutationScalar(retryCmd.value, delivered.value));
993
+ }
994
+ if ("node_id" in delivered)
995
+ return sameMutationScalar(retryCmd.node_id, delivered.node_id);
996
+ return true;
997
+ }
998
+ async function awaitOutstandingMutationSettle(bridge, tabId) {
999
+ const wait = bridge.waitForOutstandingMutations;
1000
+ if (typeof wait !== "function")
1001
+ return;
1002
+ await wait.call(bridge, tabId, outstandingMutationReadSettleMs());
1003
+ }
1004
+ function discloseOutstandingMutations(bridge, tabId, res) {
1005
+ const list = bridge.listOutstandingMutations?.(tabId);
1006
+ if (!list?.length)
1007
+ return res;
1008
+ const named = list
1009
+ .map((m) => `"${m.cmd}" retry_of:"${m.rid}"`)
1010
+ .join(", ");
1011
+ return appendReplyNote(res, `OUTCOME UNKNOWN: ${list.length} delivered mutation(s) still unsettled on this tab (${named}). ` +
1012
+ `This graph read may show values from before those writes applied. Do not treat this snapshot ` +
1013
+ `as proof the write failed; wait, or retry the named mutation with its retry_of token.`);
1014
+ }
952
1015
  // #1639 — while a ComfyUI prompt is running the frontend main thread often
953
1016
  // cannot service graph_* at all. Waiting out the 20/90 s ack bound only
954
1017
  // surfaces "tab may be backgrounded or frozen" with an unknown mutation
@@ -9800,6 +9863,20 @@ function pinHonorsLiveCanvas(pin, liveActive) {
9800
9863
  return false;
9801
9864
  return readOpenActiveAgainstTarget(liveActive, pin.path) === "same";
9802
9865
  }
9866
+ /**
9867
+ * Automatic fence repair needs stronger identity than legacy pin resolution.
9868
+ * `activeMatchesTarget` intentionally accepts filename/basename aliases for
9869
+ * compatibility, but a basename cannot prove which saved workflow is live
9870
+ * when two directories contain the same filename. Use only a canonical path
9871
+ * or a canonical key here; otherwise leave the dispatch fence refusing.
9872
+ */
9873
+ function exactPinnedSavedPathMatchesLive(pinPath, liveActive) {
9874
+ const expected = canonicalSavedWorkflowPath(pinPath);
9875
+ if (!expected || !expected.includes("/") || !liveActive)
9876
+ return false;
9877
+ const observed = liveActive;
9878
+ return [observed.path, observed.key].some((value) => canonicalSavedWorkflowPath(value) === expected);
9879
+ }
9803
9880
  /**
9804
9881
  * The ONE remedy that is actually reachable from every wedged state below, and
9805
9882
  * the one the reporters found to be the only thing that worked.
@@ -12363,6 +12440,12 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets, onRunTicketOpen
12363
12440
  const blocked = graphCmdBlockedByRunningPrompt(cmd);
12364
12441
  if (blocked)
12365
12442
  return fail(blocked);
12443
+ // #2527 — a timed-out mutation may still be applying. Graph reads wait for
12444
+ // that settlement (or disclose the outstanding receipt) so they cannot
12445
+ // certify a stale widget value as current.
12446
+ if (isGraphReadSettleCmd(cmd)) {
12447
+ await awaitOutstandingMutationSettle(bridge, ctx.tabId);
12448
+ }
12366
12449
  // The bridge owns the graph lane and re-resolves the live connection after
12367
12450
  // waiting. Pass the fence through so it runs at the actual socket dispatch,
12368
12451
  // not before that retarget window.
@@ -12373,7 +12456,9 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets, onRunTicketOpen
12373
12456
  // switch inherited its age and was announced as stuck at once (codex r4).
12374
12457
  if (successProvesSwitchCleared(cmd.cmd))
12375
12458
  clearSwitchHold(ctx.tabId);
12376
- return firstTry;
12459
+ return isGraphReadSettleCmd(cmd)
12460
+ ? discloseOutstandingMutations(bridge, ctx.tabId, firstTry)
12461
+ : firstTry;
12377
12462
  }
12378
12463
  catch (err) {
12379
12464
  onFailure?.(err); // #1560 — the error this attempt failed on (see the wrapper).
@@ -12450,6 +12535,9 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets, onRunTicketOpen
12450
12535
  await awaitReachable();
12451
12536
  ensureReachable(); // rebinds a current-mode session onto the reconnected tab
12452
12537
  holdTab = ctx.tabId;
12538
+ if (isGraphReadSettleCmd(cmd)) {
12539
+ await awaitOutstandingMutationSettle(bridge, ctx.tabId);
12540
+ }
12453
12541
  const retried = ok(await sendRouted(cmd, timeoutMs, observeRid, beforeDispatch, options));
12454
12542
  // Cleared HERE and nowhere else, and only when the failure this retried
12455
12543
  // was a SWITCH refusal. Clearing on every routed success let an unguarded
@@ -12463,7 +12551,9 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets, onRunTicketOpen
12463
12551
  if (isWorkflowSwitchGuardRefusal(err) && successProvesSwitchCleared(cmd.cmd)) {
12464
12552
  clearSwitchHold(holdTab);
12465
12553
  }
12466
- return retried;
12554
+ return isGraphReadSettleCmd(cmd)
12555
+ ? discloseOutstandingMutations(bridge, ctx.tabId, retried)
12556
+ : retried;
12467
12557
  }
12468
12558
  catch (err2) {
12469
12559
  // #1560 — the RETRY's failure is the one every exit below describes, so it
@@ -13017,7 +13107,9 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets, onRunTicketOpen
13017
13107
  const retried = ok(await sendRouted(cmd, timeoutMs, observeRid));
13018
13108
  if (successProvesSwitchCleared(cmd.cmd))
13019
13109
  clearSwitchHold(ctx.tabId);
13020
- return retried;
13110
+ return isGraphReadSettleCmd(cmd)
13111
+ ? discloseOutstandingMutations(bridge, ctx.tabId, retried)
13112
+ : retried;
13021
13113
  }
13022
13114
  }
13023
13115
  catch {
@@ -13046,7 +13138,9 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets, onRunTicketOpen
13046
13138
  const retried = ok(await sendRouted(cmd, timeoutMs, observeRid));
13047
13139
  if (successProvesSwitchCleared(cmd.cmd))
13048
13140
  clearSwitchHold(ctx.tabId);
13049
- return retried;
13141
+ return isGraphReadSettleCmd(cmd)
13142
+ ? discloseOutstandingMutations(bridge, ctx.tabId, retried)
13143
+ : retried;
13050
13144
  }
13051
13145
  catch (retryErr) {
13052
13146
  onFailure?.(retryErr);
@@ -13061,7 +13155,9 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets, onRunTicketOpen
13061
13155
  const retried = ok(await sendRouted(cmd, timeoutMs, observeRid));
13062
13156
  if (successProvesSwitchCleared(cmd.cmd))
13063
13157
  clearSwitchHold(ctx.tabId);
13064
- return retried;
13158
+ return isGraphReadSettleCmd(cmd)
13159
+ ? discloseOutstandingMutations(bridge, ctx.tabId, retried)
13160
+ : retried;
13065
13161
  }
13066
13162
  catch (retryErr) {
13067
13163
  onFailure?.(retryErr);
@@ -13494,6 +13590,95 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets, onRunTicketOpen
13494
13590
  * what I have open" is the common ask, and requiring a save-to-disk round trip
13495
13591
  * first derailed real sessions (deleted placeholder files, 404 tabs).
13496
13592
  */
13593
+ /**
13594
+ * #2487 — whether the dispatch-time stamp-target gate would refuse a fenced
13595
+ * graph command from this session.
13596
+ *
13597
+ * TRI-STATE on purpose. The gate compares the session stamp against the routed
13598
+ * tab's LAST ADVERTISED identity, which is not the pin and is not the live
13599
+ * canvas. A missing or throwing probe is an unmade observation, not "no
13600
+ * disagreement".
13601
+ */
13602
+ function stampTargetDisagrees(ctx) {
13603
+ try {
13604
+ const cap = ctx.tabGraphMutationCapability?.();
13605
+ if (!cap)
13606
+ return undefined;
13607
+ if (cap.known !== true)
13608
+ return undefined;
13609
+ return cap.canMutate === false && cap.because === "target_disagreement";
13610
+ }
13611
+ catch {
13612
+ return undefined;
13613
+ }
13614
+ }
13615
+ /**
13616
+ * #2487 — a pin is a PATH, not the last-active tab's advertised instance.
13617
+ *
13618
+ * After a verified pin, subgraph enter/exit can succeed (they go through
13619
+ * `ctx.call`) while the next live-canvas capture — `panel_strip_workflow({})`
13620
+ * via `bridge.send` — is refused because the routed tab still advertises a
13621
+ * different workflow instance. The refusal names `mode:"current"`, which
13622
+ * RELEASES the pin.
13623
+ *
13624
+ * Re-resolve the pin against the live canvas. If it still NAMES that canvas,
13625
+ * reconcile a stale advertisement (#2209) or refresh a reminted instance
13626
+ * stamp (#1913: honoring the path is not abandoning it). A pin to a
13627
+ * DIFFERENT canvas is left alone — that refusal is the fence working.
13628
+ *
13629
+ * Returns whether a repair actually happened, so a caller can retry a
13630
+ * capture that was refused for this reason and not for any other.
13631
+ */
13632
+ async function repairPinnedStampForLiveCanvas(ctx) {
13633
+ let pin;
13634
+ try {
13635
+ pin = ctx.workflowTarget?.get(ctx.tabId);
13636
+ }
13637
+ catch {
13638
+ return false;
13639
+ }
13640
+ if (pin?.mode !== "pinned" || typeof pin.path !== "string" || pin.path.length === 0) {
13641
+ return false;
13642
+ }
13643
+ if (typeof ctx.call !== "function")
13644
+ return false;
13645
+ let probe;
13646
+ try {
13647
+ probe = await rebindWorkflowFence(ctx, { adopt: false });
13648
+ }
13649
+ catch {
13650
+ return false;
13651
+ }
13652
+ // pinHonorsLiveCanvas is the UUID-adoption gate: it needs routing_key ===
13653
+ // wf:<path>. Current panels advertise wf:<route>:<path> (#640), which that
13654
+ // gate reads as indeterminate. Path/filename/key equality still proves the
13655
+ // pin names this canvas. A path/key match is required when the panel's
13656
+ // routing shape is indeterminate; a bare filename is never enough.
13657
+ const live = liveActiveFromProbe(probe);
13658
+ const namesLive = pinHonorsLiveCanvas(pin, live) ||
13659
+ exactPinnedSavedPathMatchesLive(pin.path, live);
13660
+ if (!namesLive)
13661
+ return false;
13662
+ if (probe.status === "already_current") {
13663
+ return reconcileStampTarget(ctx, probe.uuid).ok;
13664
+ }
13665
+ if (probe.status === "diverged") {
13666
+ const identity = probe.active ?? { workflow_uuid: probe.uuid };
13667
+ if (!refreshWorkflowUuid(ctx, identity))
13668
+ return false;
13669
+ const rec = reconcileStampTarget(ctx, probe.uuid);
13670
+ return rec.ok || rec.why === "no_disagreement";
13671
+ }
13672
+ return false;
13673
+ }
13674
+ /** Live-canvas capture: the same pin injection `ctx.call` applies, over the
13675
+ * direct `bridge.send` this path has to use (it needs the raw graph, not a
13676
+ * ToolResult wrapper). */
13677
+ async function sendLiveCanvasGraphCmd(ctx, cmd, timeoutMs) {
13678
+ const target = ctx.workflowTarget?.get(ctx.tabId);
13679
+ const routed = target ? withWorkflowTarget({ cmd }, target) : { cmd };
13680
+ return ctx.bridge.send(routed, { tabId: ctx.tabId, timeoutMs });
13681
+ }
13497
13682
  /**
13498
13683
  * Rebuild a UI-format workflow ({ nodes, links }) from the panel's back-compat
13499
13684
  * `graph_get_state` reply (the #384 fallback). Each summarized node carries its
@@ -13672,18 +13857,27 @@ notes) {
13672
13857
  let reply;
13673
13858
  try {
13674
13859
  ctx.ensureReachable?.();
13675
- // Route to the SAME authoritative target as ctx.call: when the session is
13676
- // pinned, inject the pinned workflow_path so the live-canvas capture serializes
13677
- // the PINNED workflow, not whatever tab is visible (codex this direct send
13678
- // otherwise bypasses withWorkflowTarget and reads the wrong graph).
13679
- const target = ctx.workflowTarget?.get(ctx.tabId);
13680
- const cmd = target
13681
- ? withWorkflowTarget({ cmd: "graph_serialize" }, target)
13682
- : { cmd: "graph_serialize" };
13683
- reply = await ctx.bridge.send(cmd, {
13684
- tabId: ctx.tabId,
13685
- timeoutMs: 30000,
13686
- });
13860
+ // #2487 honor a verified pin BEFORE the capture is compared against the
13861
+ // routed tab's last-advertised identity. That advertisement is unrelated
13862
+ // active-tab state: subgraph enter/exit can leave it stale while the pin
13863
+ // still names the live canvas. Re-resolve here rather than telling the
13864
+ // caller to `mode:"current"`, which would release the pin. Cheap when the
13865
+ // gate is not refusing; a missing probe is not treated as disagreement.
13866
+ if (stampTargetDisagrees(ctx) === true) {
13867
+ await repairPinnedStampForLiveCanvas(ctx);
13868
+ }
13869
+ try {
13870
+ reply = await sendLiveCanvasGraphCmd(ctx, "graph_serialize", 30000);
13871
+ }
13872
+ catch (err) {
13873
+ // The capability probe is optional (lightweight ctxs omit it) and can
13874
+ // miss a disagreement the send() gate still sees. One repair+retry, and
13875
+ // only for this refusal — any other capture error keeps its own path.
13876
+ if (!isWorkflowInstanceMismatch(err) || !(await repairPinnedStampForLiveCanvas(ctx))) {
13877
+ throw err;
13878
+ }
13879
+ reply = await sendLiveCanvasGraphCmd(ctx, "graph_serialize", 30000);
13880
+ }
13687
13881
  }
13688
13882
  catch (err) {
13689
13883
  // #384: a panel too old to register graph_serialize (added at 0.8.2) still
@@ -13700,14 +13894,7 @@ notes) {
13700
13894
  const msg = err instanceof Error ? err.message : String(err);
13701
13895
  if (allowStateFallback && isPanelCmdUnsupportedError(err, "graph_serialize")) {
13702
13896
  try {
13703
- const target = ctx.workflowTarget?.get(ctx.tabId);
13704
- const stateCmd = target
13705
- ? withWorkflowTarget({ cmd: "graph_get_state" }, target)
13706
- : { cmd: "graph_get_state" };
13707
- const stateReply = await ctx.bridge.send(stateCmd, {
13708
- tabId: ctx.tabId,
13709
- timeoutMs: 30000,
13710
- });
13897
+ const stateReply = await sendLiveCanvasGraphCmd(ctx, "graph_get_state", 30000);
13711
13898
  const rebuilt = reconstructUiFromState(stateReply);
13712
13899
  if (rebuilt)
13713
13900
  return rebuilt;
@@ -13741,14 +13928,7 @@ notes) {
13741
13928
  if (allowStateFallback && notes) {
13742
13929
  let stateReply;
13743
13930
  try {
13744
- const target = ctx.workflowTarget?.get(ctx.tabId);
13745
- const stateCmd = target
13746
- ? withWorkflowTarget({ cmd: "graph_get_state" }, target)
13747
- : { cmd: "graph_get_state" };
13748
- stateReply = await ctx.bridge.send(stateCmd, {
13749
- tabId: ctx.tabId,
13750
- timeoutMs: 30000,
13751
- });
13931
+ stateReply = await sendLiveCanvasGraphCmd(ctx, "graph_get_state", 30000);
13752
13932
  }
13753
13933
  catch (err) {
13754
13934
  // Never fatal: strip already HAS a usable graph. Losing the cross-check
@@ -15093,32 +15273,50 @@ function withRetryToken(d) {
15093
15273
  // they just named it. Drained here (once) and reported alongside the
15094
15274
  // retry's own outcome.
15095
15275
  //
15096
- // Deliberately NOT a short-circuit. Skipping the dispatch would be wrong
15097
- // whenever the token is stale or pasted onto different args -- the bridge
15098
- // stores the rid, not a fingerprint of what it carried, so "the write for
15099
- // this rid landed" does not prove "the write you are asking for now is
15100
- // that same write". Suppressing a real mutation on that inference is the
15101
- // #683 mistake with the arrow reversed, and #687 reverted it for cause.
15102
- // Double-apply is already the #521 panel ledger's job; this only makes the
15103
- // outcome VISIBLE, which is the half of #694 nothing else does.
15104
- const wrapped = Object.create(ctx);
15105
- wrapped.call = (cmd, timeoutMs, onDispatchedRid, beforeDispatch) => ctx.call(
15106
- // Ask the RETRY MAP's question, not the workflow fence's (codex gate).
15107
- // These were the same answer only while isMutatingGraphCommand
15108
- // over-classified — the #778 defect. Once the fence got its own effect
15109
- // ledger they diverged, and gating on the fence would have SILENTLY
15110
- // DROPPED a caller-supplied retry_of for the four UI-state commands the
15111
- // map admits on purpose: the schema accepts the token, the description
15112
- // promises dedupe, and nothing would reach the wire. A caller who
15113
- // believes their retry is deduped and is wrong is exactly the failure
15114
- // the token exists to prevent.
15276
+ // #683/#687 — do NOT short-circuit on rid alone. The token names an
15277
+ // attempt, not a fingerprint of its args, so "this rid landed" does not
15278
+ // prove "the write you are asking for now is that same write".
15115
15279
  //
15116
- // A read probe inside a mutating handler (panel_flatten_workflow's
15117
- // graph_serialize) is still excluded RETRY_TOKEN_CMDS contains no
15118
- // reads, which is asserted in panel-retry-identity.test.ts.
15119
- RETRY_TOKEN_CMDS.has(typeof cmd.cmd === "string" ? cmd.cmd : "")
15120
- ? { ...cmd, retry_of: retryOf }
15121
- : cmd, timeoutMs, onDispatchedRid, beforeDispatch);
15280
+ // #2527 when the receipt for that rid is the SAME command (semantic
15281
+ // identity) and the frontend has already settled it, re-dispatching is
15282
+ // what gets rejected as "retry_of refers to a different command or
15283
+ // workflow". Answer from the receipt instead. A sibling mutation inside
15284
+ // the same handler (enter_subgraph during a set_widget retry) must not
15285
+ // carry this token at all.
15286
+ const wrapped = Object.create(ctx);
15287
+ wrapped.call = (cmd, timeoutMs, onDispatchedRid, beforeDispatch) => {
15288
+ const cmdName = typeof cmd.cmd === "string" ? cmd.cmd : "";
15289
+ const original = ctx.bridge?.peekDeliveredMutation?.(retryOf);
15290
+ if (original) {
15291
+ if (original.cmd !== cmdName) {
15292
+ return ctx.call(cmd, timeoutMs, onDispatchedRid, beforeDispatch);
15293
+ }
15294
+ const settled = ctx.bridge.peekLateMutation?.(retryOf);
15295
+ if (settled && mutationIdentityMatches(cmd, original.frame)) {
15296
+ return Promise.resolve(ok(settled.result !== undefined ? settled.result : { ok: true, retry_of: retryOf }));
15297
+ }
15298
+ if (mutationIdentityMatches(cmd, original.frame)) {
15299
+ const replay = { ...original.frame, retry_of: retryOf };
15300
+ delete replay.rid;
15301
+ return ctx.call(replay, timeoutMs, onDispatchedRid, beforeDispatch);
15302
+ }
15303
+ }
15304
+ return ctx.call(
15305
+ // Ask the RETRY MAP's question, not the workflow fence's (codex gate).
15306
+ // These were the same answer only while isMutatingGraphCommand
15307
+ // over-classified — the #778 defect. Once the fence got its own effect
15308
+ // ledger they diverged, and gating on the fence would have SILENTLY
15309
+ // DROPPED a caller-supplied retry_of for the four UI-state commands the
15310
+ // map admits on purpose: the schema accepts the token, the description
15311
+ // promises dedupe, and nothing would reach the wire. A caller who
15312
+ // believes their retry is deduped and is wrong is exactly the failure
15313
+ // the token exists to prevent.
15314
+ //
15315
+ // A read probe inside a mutating handler (panel_flatten_workflow's
15316
+ // graph_serialize) is still excluded — RETRY_TOKEN_CMDS contains no
15317
+ // reads, which is asserted in panel-retry-identity.test.ts.
15318
+ RETRY_TOKEN_CMDS.has(cmdName) ? { ...cmd, retry_of: retryOf } : cmd, timeoutMs, onDispatchedRid, beforeDispatch);
15319
+ };
15122
15320
  const out = await d.handler(args, wrapped);
15123
15321
  // Drained AFTER the handler, deliberately, for two measured reasons.
15124
15322
  //