poly-weaver 0.14.4 → 0.14.6

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.
Files changed (36) hide show
  1. package/dist/crosscheck/broadcast-composer.d.ts +12 -7
  2. package/dist/crosscheck/broadcast-composer.d.ts.map +1 -1
  3. package/dist/crosscheck/broadcast-composer.js +36 -30
  4. package/dist/crosscheck/broadcast-composer.js.map +1 -1
  5. package/dist/crosscheck/coordinator.d.ts +10 -0
  6. package/dist/crosscheck/coordinator.d.ts.map +1 -1
  7. package/dist/crosscheck/coordinator.js +247 -56
  8. package/dist/crosscheck/coordinator.js.map +1 -1
  9. package/dist/crosscheck/state.d.ts +12 -1
  10. package/dist/crosscheck/state.d.ts.map +1 -1
  11. package/dist/crosscheck/state.js +77 -17
  12. package/dist/crosscheck/state.js.map +1 -1
  13. package/dist/offscreen/controller.d.ts.map +1 -1
  14. package/dist/offscreen/controller.js +11 -1
  15. package/dist/offscreen/controller.js.map +1 -1
  16. package/dist/offscreen/session-turn-monitor.d.ts +18 -2
  17. package/dist/offscreen/session-turn-monitor.d.ts.map +1 -1
  18. package/dist/offscreen/session-turn-monitor.js +156 -18
  19. package/dist/offscreen/session-turn-monitor.js.map +1 -1
  20. package/dist/providers/copilot/completion.d.ts +3 -0
  21. package/dist/providers/copilot/completion.d.ts.map +1 -1
  22. package/dist/providers/copilot/completion.js +96 -7
  23. package/dist/providers/copilot/completion.js.map +1 -1
  24. package/dist/providers/copilot/session.d.ts +10 -8
  25. package/dist/providers/copilot/session.d.ts.map +1 -1
  26. package/dist/providers/copilot/session.js +49 -11
  27. package/dist/providers/copilot/session.js.map +1 -1
  28. package/dist/providers/copilot/strategy.d.ts.map +1 -1
  29. package/dist/providers/copilot/strategy.js +5 -1
  30. package/dist/providers/copilot/strategy.js.map +1 -1
  31. package/dist/providers/fork.d.ts +25 -0
  32. package/dist/providers/fork.d.ts.map +1 -1
  33. package/dist/providers/fork.js.map +1 -1
  34. package/dist/providers/types.d.ts +4 -2
  35. package/dist/providers/types.d.ts.map +1 -1
  36. package/package.json +1 -1
@@ -6,6 +6,7 @@ import { buildExecutorInitialPrompt, buildExecutorCrosscheckPrompt, buildExecuto
6
6
  import { sendInquiryQuestion } from "../offscreen/inquiry-sender.js";
7
7
  import { SessionTurnMonitor } from "../offscreen/session-turn-monitor.js";
8
8
  import { resolveProvider } from "../providers/registry.js";
9
+ import { readSessionFileWithStatus } from "../session/reader.js";
9
10
  import { encodeKeysToBytes } from "../terminal/key-to-bytes.js";
10
11
  import { WHEEL_LINES_PER_EVENT, } from "../terminal/input-router.js";
11
12
  import { isUnmodifiedRightButtonGestureFrame, isUnmodifiedRightButtonPress, isUnmodifiedRightButtonRelease, parseSgrMouseFrame, } from "../terminal/sgr-mouse.js";
@@ -73,6 +74,8 @@ export class CrosscheckCoordinator {
73
74
  dispatching = false;
74
75
  broadcastSendActive = false;
75
76
  dispatchAbort = new AbortController();
77
+ operationFence = 0;
78
+ activeOperationAborts = new Set();
76
79
  dispatchOperations = new Set();
77
80
  activityOperations = new Set();
78
81
  resendAfterLaunch = new Set();
@@ -105,6 +108,7 @@ export class CrosscheckCoordinator {
105
108
  this.detachUi = this.options.host.attachParentUi(this);
106
109
  const layout = this.options.host.getLayout();
107
110
  const dimensions = this.paneDimensions(layout.columns, layout.childRows);
111
+ await this.reconcilePersistedHistory();
108
112
  const persisted = this.store.snapshot();
109
113
  const launchAbort = new AbortController();
110
114
  this.launchAbort = launchAbort;
@@ -120,7 +124,8 @@ export class CrosscheckCoordinator {
120
124
  try {
121
125
  const config = this.options.configurations[agent];
122
126
  const resultFile = persisted.generation.resultFiles[agent];
123
- const resultReady = await fileHasContent(resultFile);
127
+ const resultState = persisted.generation.rewind?.[agent];
128
+ const resultReady = resultState ? false : await fileHasContent(resultFile);
124
129
  const prior = persisted.sessions[agent];
125
130
  const resumeIdentity = prior?.driver === config.driver
126
131
  ? {
@@ -145,13 +150,15 @@ export class CrosscheckCoordinator {
145
150
  : resumeIdentity && priorSubmitCount !== undefined
146
151
  ? await resolveProvider(config.driver).live.classifyGenerationDelivery(prior.sessionFilePath, priorSubmitCount) === "present"
147
152
  : undefined;
148
- const prompt = generationDelivered === false
149
- ? resumePrompt
150
- : activityState === "idle"
151
- ? ""
152
- : activityState === "working"
153
- ? RESUME_CONTINUE_PROMPT
154
- : resumePrompt;
153
+ const prompt = persisted.generation.rewind
154
+ ? ""
155
+ : generationDelivered === false
156
+ ? resumePrompt
157
+ : activityState === "idle"
158
+ ? ""
159
+ : activityState === "working"
160
+ ? RESUME_CONTINUE_PROMPT
161
+ : resumePrompt;
155
162
  const session = await (this.options.launchSession ?? LiveAgentSession.launch)({
156
163
  driver: config.driver,
157
164
  workdir: this.options.workdir,
@@ -180,6 +187,13 @@ export class CrosscheckCoordinator {
180
187
  });
181
188
  this.launchingSessions.delete(agent);
182
189
  const monitor = new SessionTurnMonitor(config.driver, session.identity.sessionFilePath, session.baseline, {
190
+ initialHistorySnapshot: persisted.sessions[agent]?.historySnapshot,
191
+ onHistorySnapshot: (snapshot) => {
192
+ void this.store.updateSessionHistory(agent, snapshot);
193
+ },
194
+ onHistoryRegression: (_regression, snapshot) => {
195
+ this.runDispatchOperation(agent, this.invalidateGenerationForRewind(agent, snapshot), "invalidate rewound generation");
196
+ },
183
197
  onChange: (idle) => {
184
198
  this.refreshStatusHint();
185
199
  this.options.host.scheduleParentUiRender();
@@ -204,9 +218,11 @@ export class CrosscheckCoordinator {
204
218
  session,
205
219
  monitor,
206
220
  resultReady,
221
+ resultState,
207
222
  freshForPendingDispatch,
208
223
  launchTurnExpected: session.resumed !== true || prompt.length > 0,
209
224
  });
225
+ monitor.startHistoryPolling();
210
226
  await this.store.update((state) => {
211
227
  const dispatch = state.generation.dispatch;
212
228
  if (freshForPendingDispatch
@@ -225,6 +241,9 @@ export class CrosscheckCoordinator {
225
241
  ...(freshForPendingDispatch && {
226
242
  pendingDispatchBaselineReset: true,
227
243
  }),
244
+ ...(persisted.sessions[agent]?.historySnapshot && {
245
+ historySnapshot: persisted.sessions[agent].historySnapshot,
246
+ }),
228
247
  };
229
248
  });
230
249
  if (freshForPendingDispatch
@@ -261,7 +280,7 @@ export class CrosscheckCoordinator {
261
280
  }
262
281
  await this.reconcilePersistedDispatch();
263
282
  await this.maybeSettleReconcileGate();
264
- this.broadcast.reconcileMode(this.currentReadiness());
283
+ this.broadcast.reconcileMode(this.currentReadiness(), this.currentGenerationPresentation());
265
284
  this.refreshMouseModes();
266
285
  this.resultTimer = setInterval(() => {
267
286
  void this.refreshResults();
@@ -298,7 +317,7 @@ export class CrosscheckCoordinator {
298
317
  focusSwitchHint: this.focusSwitchHint(),
299
318
  version: `poly-weaver v${APP_VERSION}`,
300
319
  transient: this.currentTransientFlash(),
301
- });
320
+ }, this.currentGenerationPresentation());
302
321
  const lines = [...top.lines.slice(0, agentAreaRows), ...broadcastPane.lines];
303
322
  while (lines.length < totalRows)
304
323
  lines.push("");
@@ -548,7 +567,7 @@ export class CrosscheckCoordinator {
548
567
  if (this.focus === "broadcast") {
549
568
  flush();
550
569
  const submissionState = this.currentSubmissionState();
551
- const outcome = this.broadcast.handleKey(event, this.currentReadiness(), submissionState);
570
+ const outcome = this.broadcast.handleKey(event, this.currentReadiness(), submissionState, 0, this.currentGenerationPresentation());
552
571
  if (outcome === "submit") {
553
572
  this.runDispatchOperation(undefined, this.submitBroadcast(), "broadcast send");
554
573
  }
@@ -608,12 +627,14 @@ export class CrosscheckCoordinator {
608
627
  const focused = this.focus === agent;
609
628
  const config = runtime?.config ?? this.options.configurations[agent];
610
629
  const monitor = runtime?.monitor;
630
+ const resultSlot = runtime?.resultState
631
+ ?? (runtime?.resultReady ? "done" : undefined);
611
632
  const label = [
612
633
  config.driver,
613
634
  config.model,
614
635
  config.effort,
615
636
  monitor ? (monitor.isIdle ? "idle" : "working") : "starting",
616
- runtime?.resultReady ? "done" : undefined,
637
+ resultSlot,
617
638
  ].filter(Boolean).join(" | ");
618
639
  const color = focused
619
640
  ? `${MUTED_YELLOW_BG}${TRUE_BLACK_FG}${bold}`
@@ -770,6 +791,11 @@ export class CrosscheckCoordinator {
770
791
  agent2Ready: !!this.agents.get("agent-2")?.resultReady,
771
792
  };
772
793
  }
794
+ currentGenerationPresentation() {
795
+ return this.store?.generationInvalidated()
796
+ ? { kind: "rewound" }
797
+ : { kind: "active" };
798
+ }
773
799
  currentResultFile(agent) {
774
800
  return this.store.snapshot().generation.resultFiles[agent];
775
801
  }
@@ -779,8 +805,63 @@ export class CrosscheckCoordinator {
779
805
  const generation = this.store.snapshot().generation;
780
806
  return generation.id === 0 ? this.options.task : generation.prompt;
781
807
  }
808
+ async reconcilePersistedHistory() {
809
+ const snapshot = this.store.snapshot();
810
+ await Promise.all(["agent-1", "agent-2"].map(async (agent) => {
811
+ const session = snapshot.sessions[agent];
812
+ const config = this.options.configurations[agent];
813
+ if (!session || session.driver !== config.driver || !session.historySnapshot) {
814
+ return;
815
+ }
816
+ const provider = resolveProvider(config.driver);
817
+ const buildSnapshot = provider.fork.sessionHistorySnapshot;
818
+ const compare = provider.fork.compareSessionHistory;
819
+ if (!buildSnapshot || !compare)
820
+ return;
821
+ try {
822
+ const { lines, malformed } = await readSessionFileWithStatus(session.sessionFilePath);
823
+ if (malformed)
824
+ return;
825
+ const current = buildSnapshot(lines);
826
+ if (!current)
827
+ return;
828
+ if (compare(session.historySnapshot, current)) {
829
+ await this.invalidateGenerationForRewind(agent, current);
830
+ }
831
+ else {
832
+ await this.store.updateSessionHistory(agent, current);
833
+ }
834
+ }
835
+ catch {
836
+ // Unreadable history is indeterminate; it is never proof of rewind.
837
+ }
838
+ }));
839
+ }
840
+ async invalidateGenerationForRewind(agent, snapshot) {
841
+ this.abortActiveDispatchOperations(new Error("Crosscheck generation invalidated by session rewind"));
842
+ await this.store.invalidateGenerationForRewind(agent, snapshot);
843
+ const rewind = this.store.generationRewindState();
844
+ for (const side of ["agent-1", "agent-2"]) {
845
+ const runtime = this.agents.get(side);
846
+ if (!runtime)
847
+ continue;
848
+ runtime.resultReady = false;
849
+ runtime.resultState = rewind?.[side];
850
+ runtime.freshForPendingDispatch = false;
851
+ }
852
+ this.crosscheckProgress = undefined;
853
+ this.crosscheckTurnIdentities.clear();
854
+ this.resendAfterLaunch.clear();
855
+ this.reconciliationBlocked.clear();
856
+ this.freshDispatchPreparations.clear();
857
+ this.broadcast.reconcileMode(this.currentReadiness(), this.currentGenerationPresentation());
858
+ this.refreshStatusHint();
859
+ this.options.host.scheduleParentUiRender();
860
+ }
782
861
  currentCrosscheckStatus() {
783
862
  const progress = this.crosscheckProgress;
863
+ if (this.store?.generationInvalidated())
864
+ return { kind: "unavailable" };
784
865
  if (progress
785
866
  && this.store
786
867
  && this.store.generationId() === progress.generationId) {
@@ -831,6 +912,8 @@ export class CrosscheckCoordinator {
831
912
  isCrosscheckArmed() {
832
913
  if (!this.store || !this.sessionsReady)
833
914
  return false;
915
+ if (this.store.generationInvalidated())
916
+ return false;
834
917
  if (this.dispatching || this.store.generationConsumed() || this.store.hasDispatch()) {
835
918
  return false;
836
919
  }
@@ -839,6 +922,8 @@ export class CrosscheckCoordinator {
839
922
  isReconcileArmed() {
840
923
  if (!this.store || !this.sessionsReady)
841
924
  return false;
925
+ if (this.store.generationInvalidated())
926
+ return false;
842
927
  if (this.dispatching
843
928
  || !this.store.generationConsumed()
844
929
  || this.store.hasDispatch()
@@ -976,9 +1061,11 @@ export class CrosscheckCoordinator {
976
1061
  const attemptId = this.beginCrosscheckProgress(generationId, token, phase);
977
1062
  const agents = ["agent-1", "agent-2"];
978
1063
  try {
979
- await this.withDispatchLifecycle(agents, async () => {
1064
+ await this.withDispatchLifecycle(agents, async (operation) => {
980
1065
  await this.store.update((state) => {
981
- if (state.generation.id !== generationId)
1066
+ if (state.generation.id !== generationId
1067
+ || state.generation.rewind
1068
+ || !this.isOperationCurrent(operation))
982
1069
  return;
983
1070
  state.generation.reconcileAllowed = false;
984
1071
  state.generation.reconcileSettled = false;
@@ -998,12 +1085,16 @@ export class CrosscheckCoordinator {
998
1085
  },
999
1086
  };
1000
1087
  });
1001
- const results = await Promise.allSettled(agents.map((agent) => this.sendDispatchAgent(agent, generationId, attemptId)));
1088
+ if (!this.isCurrentGenerationOperation(generationId, operation))
1089
+ return;
1090
+ const results = await Promise.allSettled(agents.map((agent) => this.sendDispatchAgent(agent, generationId, attemptId, operation)));
1002
1091
  for (const [index, result] of results.entries()) {
1003
1092
  if (result.status === "rejected") {
1004
1093
  this.warnDispatchFailure(agents[index], "send", result.reason);
1005
1094
  }
1006
1095
  }
1096
+ if (!this.isCurrentGenerationOperation(generationId, operation))
1097
+ return;
1007
1098
  await this.finalizeDispatchIfComplete(generationId);
1008
1099
  });
1009
1100
  }
@@ -1011,12 +1102,12 @@ export class CrosscheckCoordinator {
1011
1102
  this.settleUnsubmittedCrosscheckSlots(attemptId);
1012
1103
  }
1013
1104
  }
1014
- async sendDispatchAgent(agent, generationId, progressAttemptId) {
1105
+ async sendDispatchAgent(agent, generationId, progressAttemptId, operation = this.currentDispatchOperationContext()) {
1015
1106
  if (this.reconciliationBlocked.has(agent))
1016
1107
  return;
1017
- const state = this.store.snapshot();
1018
- if (state.generation.id !== generationId)
1108
+ if (!this.isCurrentGenerationOperation(generationId, operation))
1019
1109
  return;
1110
+ const state = this.store.snapshot();
1020
1111
  const dispatch = state.generation.dispatch;
1021
1112
  if (!dispatch || dispatch.agents[agent].status !== "pending")
1022
1113
  return;
@@ -1028,9 +1119,11 @@ export class CrosscheckCoordinator {
1028
1119
  const releaseInputLock = this.lockAutomatedInput(agent);
1029
1120
  try {
1030
1121
  await (this.options.sendInquiry ?? sendInquiryQuestion)(runtime.session, this.buildDispatchPrompt(agent, generationId, dispatch.phase, dispatch.round), {
1031
- signal: this.dispatchAbort.signal,
1122
+ signal: operation.signal,
1032
1123
  composer: provider.composer,
1033
1124
  onSubmitted: () => {
1125
+ if (!this.isCurrentGenerationOperation(generationId, operation))
1126
+ return;
1034
1127
  submitted = true;
1035
1128
  if (progressAttemptId) {
1036
1129
  this.markCrosscheckSlotSubmitted(agent, generationId, dispatch.token, progressAttemptId);
@@ -1042,13 +1135,17 @@ export class CrosscheckCoordinator {
1042
1135
  finally {
1043
1136
  releaseInputLock();
1044
1137
  }
1045
- if (!submitted)
1138
+ if (!submitted || !this.isCurrentGenerationOperation(generationId, operation))
1046
1139
  return;
1047
1140
  await this.transitionDispatchStatus(agent, generationId, dispatch.token, "pending", "submitted");
1048
1141
  const classification = await provider.live.classifyDispatchDelivery(runtime.session.identity.sessionFilePath, dispatch.agents[agent].baseline);
1142
+ if (!this.isCurrentGenerationOperation(generationId, operation))
1143
+ return;
1049
1144
  if (classification === "present") {
1050
1145
  await this.markDispatchDelivered(agent, generationId, dispatch.token, "submitted");
1051
1146
  }
1147
+ if (!this.isCurrentGenerationOperation(generationId, operation))
1148
+ return;
1052
1149
  await this.finalizeDispatchIfComplete(generationId);
1053
1150
  }
1054
1151
  buildDispatchPrompt(agent, generationId, phase, round) {
@@ -1098,9 +1195,9 @@ export class CrosscheckCoordinator {
1098
1195
  const lockedAgents = ["agent-1", "agent-2"].filter((agent) => stateAtStart.generation.dispatch?.agents[agent].status === "pending"
1099
1196
  && !this.reconciliationBlocked.has(agent));
1100
1197
  try {
1101
- await this.withDispatchLifecycle(lockedAgents, async () => {
1198
+ await this.withDispatchLifecycle(lockedAgents, async (operation) => {
1102
1199
  const state = this.store.snapshot();
1103
- if (state.generation.id !== generationId)
1200
+ if (!this.isCurrentGenerationOperation(generationId, operation))
1104
1201
  return;
1105
1202
  const dispatch = state.generation.dispatch;
1106
1203
  if (!dispatch || (state.generation.consumed && dispatch.phase === "crosscheck")) {
@@ -1114,15 +1211,17 @@ export class CrosscheckCoordinator {
1114
1211
  continue;
1115
1212
  const current = await this.reconcileDispatchSide(agent, generationId, dispatch, expectedStatus, async () => {
1116
1213
  try {
1117
- await this.sendDispatchAgent(agent, generationId, attemptId);
1214
+ await this.sendDispatchAgent(agent, generationId, attemptId, operation);
1118
1215
  }
1119
1216
  catch (err) {
1120
1217
  this.warnDispatchFailure(agent, "retry", err);
1121
1218
  }
1122
- });
1219
+ }, operation);
1123
1220
  if (!current)
1124
1221
  return;
1125
1222
  }
1223
+ if (!this.isCurrentGenerationOperation(generationId, operation))
1224
+ return;
1126
1225
  await this.finalizeDispatchIfComplete(generationId);
1127
1226
  });
1128
1227
  }
@@ -1130,7 +1229,9 @@ export class CrosscheckCoordinator {
1130
1229
  this.settleUnsubmittedCrosscheckSlots(attemptId);
1131
1230
  }
1132
1231
  }
1133
- async reconcileDispatchSide(agent, generationId, dispatch, expectedStatus, onAbsent) {
1232
+ async reconcileDispatchSide(agent, generationId, dispatch, expectedStatus, onAbsent, operation = this.currentDispatchOperationContext()) {
1233
+ if (!this.isCurrentGenerationOperation(generationId, operation))
1234
+ return false;
1134
1235
  const target = onAbsent
1135
1236
  ? (() => {
1136
1237
  const runtime = this.agents.get(agent);
@@ -1155,6 +1256,8 @@ export class CrosscheckCoordinator {
1155
1256
  }
1156
1257
  const classification = await resolveProvider(target.driver)
1157
1258
  .live.classifyDispatchDelivery(target.sessionFilePath, dispatch.agents[agent].baseline);
1259
+ if (!this.isCurrentGenerationOperation(generationId, operation))
1260
+ return false;
1158
1261
  if (!this.isCurrentDispatch(generationId, dispatch.token))
1159
1262
  return false;
1160
1263
  if (!this.isCurrentDispatchSide(agent, generationId, dispatch.token, expectedStatus))
@@ -1185,14 +1288,16 @@ export class CrosscheckCoordinator {
1185
1288
  return true;
1186
1289
  }
1187
1290
  async withDispatchLifecycle(lockedAgents, dispatch) {
1291
+ const operation = this.beginDispatchOperation();
1188
1292
  this.dispatching = true;
1189
1293
  this.refreshStatusHint();
1190
1294
  this.options.host.scheduleParentUiRender();
1191
1295
  const releaseInputLocks = lockedAgents.map((agent) => this.lockAutomatedInput(agent));
1192
1296
  try {
1193
- await dispatch();
1297
+ await dispatch(operation);
1194
1298
  }
1195
1299
  finally {
1300
+ operation.release();
1196
1301
  for (const release of releaseInputLocks)
1197
1302
  release();
1198
1303
  this.dispatching = false;
@@ -1235,28 +1340,40 @@ export class CrosscheckCoordinator {
1235
1340
  }
1236
1341
  async settleReconcileGate(generationId) {
1237
1342
  const generation = this.store.snapshot().generation;
1343
+ const round = generation.reconcileRound;
1238
1344
  if (generation.id !== generationId
1239
1345
  || !generation.reconcilePendingSettlement
1240
- || generation.dispatch)
1346
+ || generation.dispatch
1347
+ || !this.allAgentsIdle())
1241
1348
  return;
1242
- const round = generation.reconcileRound;
1243
1349
  const paths = ["agent-1", "agent-2"].map((agent) => round === 0
1244
1350
  ? crosscheckJsonFilePath(this.options.persistentDir, agent, generationId)
1245
1351
  : reconcileJsonFilePath(this.options.persistentDir, agent, generationId, round));
1246
1352
  const [agent1, agent2] = await Promise.all(paths.map((path) => readReviewResult(path)));
1247
- if (this.store.snapshot().generation.id !== generationId)
1353
+ const afterRead = this.store.snapshot().generation;
1354
+ if (afterRead.id !== generationId
1355
+ || afterRead.reconcileRound !== round
1356
+ || !afterRead.reconcilePendingSettlement
1357
+ || afterRead.dispatch
1358
+ || !this.allAgentsIdle())
1248
1359
  return;
1249
1360
  if (!agent1 || !agent2) {
1361
+ let markedMissing = false;
1250
1362
  await this.store.update((state) => {
1251
1363
  if (state.generation.id !== generationId
1364
+ || state.generation.reconcileRound !== round
1252
1365
  || !state.generation.reconcilePendingSettlement
1253
- || state.generation.dispatch)
1366
+ || state.generation.dispatch
1367
+ || !this.allAgentsIdle())
1254
1368
  return;
1255
1369
  state.generation.reconcileJsonMissing = true;
1256
1370
  state.generation.reconcileAllowed = false;
1257
1371
  state.generation.reconcileSettled = false;
1258
1372
  state.generation.reconcilePendingSettlement = false;
1373
+ markedMissing = true;
1259
1374
  });
1375
+ if (!markedMissing)
1376
+ return;
1260
1377
  this.options.output.warn("Crosscheck review JSON is missing or malformed; Ctrl+X Reconcile is unavailable.");
1261
1378
  this.refreshStatusHint();
1262
1379
  this.options.host.scheduleParentUiRender();
@@ -1265,16 +1382,22 @@ export class CrosscheckCoordinator {
1265
1382
  const reconcileAllowed = round === 0
1266
1383
  ? true
1267
1384
  : agent1.rejected.length > 0 || agent2.rejected.length > 0;
1385
+ let settled = false;
1268
1386
  await this.store.update((state) => {
1269
1387
  if (state.generation.id !== generationId
1388
+ || state.generation.reconcileRound !== round
1270
1389
  || !state.generation.reconcilePendingSettlement
1271
- || state.generation.dispatch)
1390
+ || state.generation.dispatch
1391
+ || !this.allAgentsIdle())
1272
1392
  return;
1273
1393
  state.generation.reconcileJsonMissing = false;
1274
1394
  state.generation.reconcileAllowed = reconcileAllowed;
1275
1395
  state.generation.reconcileSettled = true;
1276
1396
  state.generation.reconcilePendingSettlement = false;
1397
+ settled = true;
1277
1398
  });
1399
+ if (!settled)
1400
+ return;
1278
1401
  this.refreshStatusHint();
1279
1402
  this.options.host.scheduleParentUiRender();
1280
1403
  }
@@ -1287,6 +1410,7 @@ export class CrosscheckCoordinator {
1287
1410
  transitionDispatchStatus(agent, generationId, dispatchToken, expectedStatus, nextStatus) {
1288
1411
  return this.store.update((state) => {
1289
1412
  if (state.generation.id === generationId
1413
+ && !state.generation.rewind
1290
1414
  && state.generation.dispatch?.token === dispatchToken
1291
1415
  && state.generation.dispatch.agents[agent].status === expectedStatus) {
1292
1416
  state.generation.dispatch.agents[agent].status = nextStatus;
@@ -1321,30 +1445,35 @@ export class CrosscheckCoordinator {
1321
1445
  this.refreshStatusHint();
1322
1446
  this.options.host.scheduleParentUiRender();
1323
1447
  try {
1324
- if (hasImagePlaceholders(text)) {
1325
- try {
1326
- finalizedText = await finalizeImageAttachments(text, this.broadcast.attachments, this.options.persistentDir);
1327
- }
1328
- catch (err) {
1329
- this.flashTransient(err instanceof Error ? err.message : String(err), 3000);
1330
- throw err;
1331
- }
1332
- }
1333
- else {
1334
- finalizedText = text;
1335
- }
1336
- await this.withDispatchLifecycle(agents, async () => {
1448
+ await this.withDispatchLifecycle(agents, async (operation) => {
1337
1449
  for (const agent of agents) {
1338
1450
  if (!this.agents.has(agent)) {
1339
1451
  throw new Error(`${agentLabel(agent)} is not ready for Broadcast`);
1340
1452
  }
1341
1453
  }
1454
+ if (hasImagePlaceholders(text)) {
1455
+ try {
1456
+ finalizedText = await finalizeImageAttachments(text, this.broadcast.attachments, this.options.persistentDir);
1457
+ }
1458
+ catch (err) {
1459
+ this.flashTransient(err instanceof Error ? err.message : String(err), 3000);
1460
+ throw err;
1461
+ }
1462
+ }
1463
+ else {
1464
+ finalizedText = text;
1465
+ }
1466
+ if (!this.isOperationCurrent(operation))
1467
+ return;
1342
1468
  if (mode === "steer" || mode === "follow-up") {
1343
1469
  const deliverySubmitCounts = await this.captureGenerationDeliveryCounts();
1470
+ if (!this.isOperationCurrent(operation))
1471
+ return;
1344
1472
  await this.store.startGeneration(mode, finalizedText, deliverySubmitCounts);
1345
1473
  this.crosscheckProgress = undefined;
1346
1474
  for (const runtime of this.agents.values()) {
1347
1475
  runtime.resultReady = false;
1476
+ runtime.resultState = undefined;
1348
1477
  runtime.freshForPendingDispatch = false;
1349
1478
  }
1350
1479
  this.resendAfterLaunch.clear();
@@ -1356,12 +1485,14 @@ export class CrosscheckCoordinator {
1356
1485
  const generationKind = mode === "steer" || mode === "follow-up"
1357
1486
  ? mode
1358
1487
  : undefined;
1359
- const sends = agents.map((agent) => this.sendBroadcastAgent(agent, finalizedText, generationId, generationKind));
1488
+ const sends = agents.map((agent) => this.sendBroadcastAgent(agent, finalizedText, generationId, generationKind, operation));
1360
1489
  initiated = true;
1361
1490
  this.broadcast.completeSubmission(submission);
1362
1491
  this.resizeSessions();
1363
1492
  this.options.host.scheduleParentUiRender();
1364
1493
  const results = await Promise.allSettled(sends);
1494
+ if (!this.isOperationCurrent(operation))
1495
+ return;
1365
1496
  for (const [index, result] of results.entries()) {
1366
1497
  if (result.status === "rejected") {
1367
1498
  this.warnDispatchFailure(agents[index], "broadcast send", result.reason);
@@ -1385,19 +1516,18 @@ export class CrosscheckCoordinator {
1385
1516
  return [agent, runtime];
1386
1517
  });
1387
1518
  await Promise.all(runtimes.map(([, runtime]) => runtime.monitor.waitForPendingTurnAttempts()));
1388
- const entries = await Promise.all(runtimes.map(async ([agent, runtime]) => {
1389
- const lines = await resolveProvider(runtime.config.driver).live.readLines(runtime.session.identity.sessionFilePath);
1390
- return [agent, runtime.monitor.expectedSubmitCount(lines)];
1391
- }));
1519
+ const entries = await Promise.all(runtimes.map(async ([agent, runtime]) => [agent, await runtime.monitor.expectedSubmitCountFromSession()]));
1392
1520
  return Object.fromEntries(entries);
1393
1521
  }
1394
- async sendBroadcastAgent(agent, text, generationId, generationKind) {
1522
+ async sendBroadcastAgent(agent, text, generationId, generationKind, operation = this.currentDispatchOperationContext()) {
1395
1523
  const runtime = this.agents.get(agent);
1396
1524
  if (!runtime)
1397
1525
  throw new Error(`${agentLabel(agent)} is not ready for Broadcast`);
1398
- const generation = this.store.snapshot().generation;
1399
- if (generation.id !== generationId)
1526
+ const conversational = generationKind === undefined;
1527
+ if (!this.isCurrentGenerationOperation(generationId, operation, { allowRewound: conversational })) {
1400
1528
  return;
1529
+ }
1530
+ const generation = this.store.snapshot().generation;
1401
1531
  const prompt = generationKind === "steer"
1402
1532
  ? buildExecutorSteerPrompt(text, generation.resultFiles[agent])
1403
1533
  : generationKind === "follow-up"
@@ -1407,12 +1537,19 @@ export class CrosscheckCoordinator {
1407
1537
  const releaseInputLock = this.lockAutomatedInput(agent);
1408
1538
  try {
1409
1539
  if (generationKind && provider.cancelSequence) {
1410
- await sendCancelSequence(runtime.session.pty, provider.cancelSequence, this.dispatchAbort.signal, runtime.monitor.isIdle);
1540
+ await sendCancelSequence(runtime.session.pty, provider.cancelSequence, operation.signal, runtime.monitor.isIdle);
1541
+ }
1542
+ if (!this.isCurrentGenerationOperation(generationId, operation, { allowRewound: conversational })) {
1543
+ return;
1411
1544
  }
1412
1545
  await (this.options.sendInquiry ?? sendInquiryQuestion)(runtime.session, prompt, {
1413
- signal: this.dispatchAbort.signal,
1546
+ signal: operation.signal,
1414
1547
  composer: provider.composer,
1415
- onSubmitted: () => runtime.monitor.beginTurn(),
1548
+ onSubmitted: () => {
1549
+ if (!this.isCurrentGenerationOperation(generationId, operation, { allowRewound: conversational }))
1550
+ return;
1551
+ runtime.monitor.beginTurn();
1552
+ },
1416
1553
  ...(generationKind ? { escapeBeforePaste: true } : {}),
1417
1554
  });
1418
1555
  }
@@ -1433,6 +1570,8 @@ export class CrosscheckCoordinator {
1433
1570
  async performResultRefresh() {
1434
1571
  let changed = false;
1435
1572
  const generation = this.store.snapshot().generation;
1573
+ if (generation.rewind)
1574
+ return;
1436
1575
  const readiness = await Promise.all(["agent-1", "agent-2"].map(async (agent) => ({
1437
1576
  agent,
1438
1577
  ready: await fileHasContent(generation.resultFiles[agent]),
@@ -1448,7 +1587,7 @@ export class CrosscheckCoordinator {
1448
1587
  }
1449
1588
  }
1450
1589
  if (changed) {
1451
- this.broadcast.reconcileMode(this.currentReadiness());
1590
+ this.broadcast.reconcileMode(this.currentReadiness(), this.currentGenerationPresentation());
1452
1591
  this.refreshStatusHint();
1453
1592
  this.options.host.scheduleParentUiRender();
1454
1593
  this.maybeRetryPersistedDispatch();
@@ -1630,6 +1769,7 @@ export class CrosscheckCoordinator {
1630
1769
  this.finishing = true;
1631
1770
  this.launchAbort?.abort(new Error("Crosscheck launch cancelled"));
1632
1771
  this.dispatchAbort.abort(new Error("Crosscheck coordinator stopped"));
1772
+ this.abortActiveDispatchOperations(new Error("Crosscheck coordinator stopped"));
1633
1773
  this.finishResolve?.({ kind: "crosscheck", interrupted: true });
1634
1774
  }
1635
1775
  fail(error) {
@@ -1638,6 +1778,7 @@ export class CrosscheckCoordinator {
1638
1778
  this.finishing = true;
1639
1779
  this.launchAbort?.abort(error);
1640
1780
  this.dispatchAbort.abort(error);
1781
+ this.abortActiveDispatchOperations(error);
1641
1782
  if (this.finishReject)
1642
1783
  this.finishReject(error);
1643
1784
  else
@@ -1648,6 +1789,7 @@ export class CrosscheckCoordinator {
1648
1789
  if (!this.dispatchAbort.signal.aborted) {
1649
1790
  this.dispatchAbort.abort(new Error("Crosscheck coordinator disposed"));
1650
1791
  }
1792
+ this.abortActiveDispatchOperations(new Error("Crosscheck coordinator disposed"));
1651
1793
  if (this.resultTimer)
1652
1794
  clearInterval(this.resultTimer);
1653
1795
  this.resultTimer = undefined;
@@ -1685,6 +1827,55 @@ export class CrosscheckCoordinator {
1685
1827
  });
1686
1828
  this.dispatchOperations.add(tracked);
1687
1829
  }
1830
+ beginDispatchOperation() {
1831
+ const controller = new AbortController();
1832
+ const abortFromRoot = () => {
1833
+ controller.abort(this.dispatchAbort.signal.reason
1834
+ ?? new Error("Crosscheck dispatch cancelled"));
1835
+ };
1836
+ if (this.dispatchAbort.signal.aborted) {
1837
+ abortFromRoot();
1838
+ }
1839
+ else {
1840
+ this.dispatchAbort.signal.addEventListener("abort", abortFromRoot, { once: true });
1841
+ }
1842
+ this.activeOperationAborts.add(controller);
1843
+ let released = false;
1844
+ return {
1845
+ fence: this.operationFence,
1846
+ signal: controller.signal,
1847
+ release: () => {
1848
+ if (released)
1849
+ return;
1850
+ released = true;
1851
+ this.dispatchAbort.signal.removeEventListener("abort", abortFromRoot);
1852
+ this.activeOperationAborts.delete(controller);
1853
+ },
1854
+ };
1855
+ }
1856
+ currentDispatchOperationContext() {
1857
+ return {
1858
+ fence: this.operationFence,
1859
+ signal: this.dispatchAbort.signal,
1860
+ };
1861
+ }
1862
+ isOperationCurrent(operation) {
1863
+ return operation.fence === this.operationFence
1864
+ && !operation.signal.aborted
1865
+ && !this.dispatchAbort.signal.aborted;
1866
+ }
1867
+ isCurrentGenerationOperation(generationId, operation, options = {}) {
1868
+ if (!this.isOperationCurrent(operation))
1869
+ return false;
1870
+ return this.store.isGenerationCurrent(generationId, options);
1871
+ }
1872
+ abortActiveDispatchOperations(reason) {
1873
+ this.operationFence++;
1874
+ for (const controller of this.activeOperationAborts) {
1875
+ controller.abort(reason);
1876
+ }
1877
+ this.activeOperationAborts.clear();
1878
+ }
1688
1879
  startActivityHeartbeat() {
1689
1880
  if (!this.options.touchActivity)
1690
1881
  return;